fleet/server/datastore/mysql/scep.go
Victor Lyuboslavsky 0180cc8086
Add SCEP endpoint for host identity. (#30589)
Fixes #30458 

Contributor docs PR: https://github.com/fleetdm/fleet/pull/30651

# Checklist for submitter

- We will add changes file later.
- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [x] If database migrations are included, checked table schema to
confirm autoupdate
- For database migrations:
- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).
- [x] Added/updated automated tests
- Did not do manual QA since the SCEP client I have doesn't support ECC.
Will rely on next subtasks for manual QA.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced Host Identity SCEP (Simple Certificate Enrollment Protocol)
support, enabling secure host identity certificate enrollment and
management.
* Added new API endpoints for Host Identity SCEP, including certificate
issuance and retrieval.
* Implemented MySQL-backed storage and management for host identity SCEP
certificates and serials.
* Added new database tables for storing host identity SCEP certificates
and serial numbers.
* Provided utilities for encoding certificates and keys, and handling
ECDSA public keys.

* **Bug Fixes**
  * None.

* **Tests**
* Added comprehensive integration and unit tests for Host Identity SCEP
functionality, including certificate issuance, validation, and error
scenarios.

* **Chores**
* Updated test utilities to support unique test names and new SCEP
storage options.
* Extended mock datastore and interfaces for new host identity
certificate methods.

* **Documentation**
* Added comments and documentation for new SCEP-related interfaces,
methods, and database schema changes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 11:44:07 -03:00

106 lines
2.8 KiB
Go

package mysql
import (
"context"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"database/sql"
_ "embed"
"errors"
"fmt"
"math/big"
"github.com/fleetdm/fleet/v4/pkg/certificate"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/assets"
"github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
)
// SCEPDepot is a MySQL-backed SCEP certificate depot.
type SCEPDepot struct {
db *sql.DB
ds fleet.Datastore
}
var _ depot.Depot = (*SCEPDepot)(nil)
// newSCEPDepot creates and returns a *SCEPDepot.
func newSCEPDepot(db *sql.DB, ds fleet.Datastore) (*SCEPDepot, error) {
if err := db.Ping(); err != nil {
return nil, err
}
return &SCEPDepot{
db: db,
ds: ds,
}, nil
}
// CA returns the CA's certificate and private key.
func (d *SCEPDepot) CA(_ []byte) ([]*x509.Certificate, *rsa.PrivateKey, error) {
// TODO(roberto): nano interfaces doesn't receive a context for this method.
cert, err := assets.CAKeyPair(context.Background(), d.ds)
if err != nil {
return nil, nil, fmt.Errorf("getting assets: %w", err)
}
pk, ok := cert.PrivateKey.(*rsa.PrivateKey)
if !ok {
return nil, nil, errors.New("private key not in RSA format")
}
return []*x509.Certificate{cert.Leaf}, pk, nil
}
// Serial allocates and returns a new (increasing) serial number.
func (d *SCEPDepot) Serial() (*big.Int, error) {
result, err := d.db.Exec(`INSERT INTO scep_serials () VALUES ();`)
if err != nil {
return nil, err
}
lid, err := result.LastInsertId()
if err != nil {
return nil, err
}
return big.NewInt(lid), nil
}
// HasCN returns whether the given certificate exists in the depot.
//
// TODO(lucas): Implement and use allowTime and revokeOldCertificate.
// - allowTime are the maximum days before expiration to allow clients to do certificate renewal.
// - revokeOldCertificate specifies whether to revoke the old certificate once renewed.
func (d *SCEPDepot) HasCN(cn string, allowTime int, cert *x509.Certificate, revokeOldCertificate bool) (bool, error) {
var ct int
row := d.db.QueryRow(`SELECT COUNT(*) FROM scep_certificates WHERE name = ?`, cn)
if err := row.Scan(&ct); err != nil {
return false, err
}
return ct >= 1, nil
}
// Put stores a certificate under the given name.
//
// If the provided certificate has empty crt.Subject.CommonName,
// then the hex sha256 of the crt.Raw is used as name.
func (d *SCEPDepot) Put(name string, crt *x509.Certificate) error {
if crt.Subject.CommonName == "" {
name = fmt.Sprintf("%x", sha256.Sum256(crt.Raw))
}
if !crt.SerialNumber.IsInt64() {
return errors.New("cannot represent serial number as int64")
}
certPEM := certificate.EncodeCertPEM(crt)
_, err := d.db.Exec(`
INSERT INTO scep_certificates
(serial, name, not_valid_before, not_valid_after, certificate_pem)
VALUES
(?, ?, ?, ?, ?)`,
crt.SerialNumber.Int64(),
name,
crt.NotBefore,
crt.NotAfter,
certPEM,
)
return err
}