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 -->
This commit is contained in:
Victor Lyuboslavsky 2025-07-11 09:44:07 -05:00 committed by GitHub
parent a51420f201
commit 0180cc8086
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1188 additions and 62 deletions

View file

@ -295,6 +295,7 @@ debug-go-tests:
DEFAULT_PKGS_TO_TEST := ./cmd/... ./ee/... ./orbit/pkg/... ./orbit/cmd/orbit ./pkg/... ./server/... ./tools/...
# fast tests are quick and do not require out-of-process dependencies (such as MySQL, etc.)
FAST_PKGS_TO_TEST := \
./ee/server/service/hostidentity/types \
./ee/tools/mdm \
./orbit/pkg/cryptoinfo \
./orbit/pkg/dataflatten \

View file

@ -25,6 +25,7 @@ import (
"github.com/fleetdm/fleet/v4/ee/server/scim"
eeservice "github.com/fleetdm/fleet/v4/ee/server/service"
"github.com/fleetdm/fleet/v4/ee/server/service/digicert"
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/pkg/scripts"
"github.com/fleetdm/fleet/v4/server"
@ -1193,6 +1194,18 @@ the way that the Fleet server works.
if err = scim.RegisterSCIM(rootMux, ds, svc, logger); err != nil {
initFatal(err, "setup SCIM")
}
// Host identify SCEP feature only works if a private key has been set up
if len(config.Server.PrivateKey) > 0 {
hostIdentitySCEPDepot, err := mds.NewHostIdentitySCEPDepot(kitlog.With(logger, "component", "host-id-scep-depot"))
if err != nil {
initFatal(err, "setup host identity SCEP depot")
}
if err = hostidentity.RegisterSCEP(rootMux, hostIdentitySCEPDepot, ds, logger); err != nil {
initFatal(err, "setup host identity SCEP")
}
} else {
level.Warn(logger).Log("msg", "Host identity SCEP is not available because no server private key has been set up.")
}
}
if config.Prometheus.BasicAuth.Username != "" && config.Prometheus.BasicAuth.Password != "" {

View file

@ -0,0 +1,322 @@
package hostidentity
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
scepclient "github.com/fleetdm/fleet/v4/server/mdm/scep/client"
"github.com/fleetdm/fleet/v4/server/mdm/scep/x509util"
"github.com/smallstep/scep"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testEnrollmentSecret = "test_secret"
func TestHostIdentitySCEP(t *testing.T) {
s := SetUpSuite(t, "integrationtest.HostIdentitySCEP")
cases := []struct {
name string
fn func(t *testing.T, s *Suite)
}{
{"GetCert", testGetCert},
{"GetCertFailures", testGetCertFailures},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
defer mysql.TruncateTables(t, s.BaseSuite.DS, []string{
"host_identity_scep_serials", "host_identity_scep_certificates",
}...)
c.fn(t, s)
})
}
}
func testGetCert(t *testing.T, s *Suite) {
t.Run("ECC P256", func(t *testing.T) {
testGetCertWithCurve(t, s, elliptic.P256())
})
t.Run("ECC P384", func(t *testing.T) {
testGetCertWithCurve(t, s, elliptic.P384())
})
}
func testGetCertWithCurve(t *testing.T, s *Suite, curve elliptic.Curve) {
ctx := t.Context()
// Create an enrollment secret
err := s.DS.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{
{
Secret: testEnrollmentSecret,
},
})
require.NoError(t, err)
// Create ECC private key with specified curve
eccPrivateKey, err := ecdsa.GenerateKey(curve, rand.Reader)
require.NoError(t, err)
// Create SCEP client
scepURL := fmt.Sprintf("%s/api/fleet/orbit/host_identity/scep", s.Server.URL)
scepClient, err := scepclient.New(scepURL, s.Logger, nil)
require.NoError(t, err)
// Get CA certificate
resp, _, err := scepClient.GetCACert(ctx, "")
require.NoError(t, err)
caCerts, err := x509.ParseCertificates(resp)
require.NoError(t, err)
require.NotEmpty(t, caCerts)
// Create CSR using ECC key
csrTemplate := x509util.CertificateRequest{
CertificateRequest: x509.CertificateRequest{
Subject: pkix.Name{
CommonName: "test-host-identity",
},
SignatureAlgorithm: x509.ECDSAWithSHA256,
},
ChallengePassword: testEnrollmentSecret,
}
csrDerBytes, err := x509util.CreateCertificateRequest(rand.Reader, &csrTemplate, eccPrivateKey)
require.NoError(t, err)
csr, err := x509.ParseCertificateRequest(csrDerBytes)
require.NoError(t, err)
tempRSAKey, deviceCert := createTempRSAKeyAndCert(t, "test-host-identity")
// Create SCEP PKI message
pkiMsgReq := &scep.PKIMessage{
MessageType: scep.PKCSReq,
Recipients: caCerts,
SignerKey: tempRSAKey, // Use RSA key for SCEP protocol
SignerCert: deviceCert,
}
msg, err := scep.NewCSRRequest(csr, pkiMsgReq, scep.WithLogger(s.Logger))
require.NoError(t, err)
// Send PKI operation request
respBytes, err := scepClient.PKIOperation(ctx, msg.Raw)
require.NoError(t, err)
// Parse response
pkiMsgResp, err := scep.ParsePKIMessage(respBytes, scep.WithLogger(s.Logger), scep.WithCACerts(msg.Recipients))
require.NoError(t, err)
// Verify successful response
require.Equal(t, scep.SUCCESS, pkiMsgResp.PKIStatus, "SCEP request should succeed")
// Decrypt PKI envelope using RSA key
err = pkiMsgResp.DecryptPKIEnvelope(deviceCert, tempRSAKey)
require.NoError(t, err)
// Verify we got a certificate
require.NotNil(t, pkiMsgResp.CertRepMessage)
require.NotNil(t, pkiMsgResp.CertRepMessage.Certificate)
// Verify the certificate was signed by the CA
cert := pkiMsgResp.CertRepMessage.Certificate
require.NotNil(t, cert)
// Verify certificate properties
assert.Equal(t, "test-host-identity", cert.Subject.CommonName)
assert.Equal(t, x509.ECDSA, cert.PublicKeyAlgorithm)
certPubKey, ok := cert.PublicKey.(*ecdsa.PublicKey)
require.True(t, ok, "Certificate should contain ECC public key")
assert.True(t, eccPrivateKey.PublicKey.Equal(certPubKey), "Certificate public key should match our ECC private key")
assert.Equal(t, curve, certPubKey.Curve, "Certificate should use the expected elliptic curve")
// Retrieve the certificate from datastore and verify it matches SCEP response
storedCert, err := s.DS.GetHostIdentityCertBySerialNumber(ctx, cert.SerialNumber.Uint64())
require.NoError(t, err)
require.NotNil(t, storedCert)
// Verify stored certificate properties match the SCEP response
assert.Equal(t, cert.SerialNumber.Uint64(), storedCert.SerialNumber)
assert.Equal(t, cert.Subject.CommonName, storedCert.CommonName)
assert.Equal(t, cert.NotAfter, storedCert.NotValidAfter)
// Verify the stored public key matches the certificate public key
storedPubKey, err := storedCert.UnmarshalPublicKey()
require.NoError(t, err)
require.NotNil(t, storedPubKey)
assert.True(t, certPubKey.Equal(storedPubKey), "Stored public key should match certificate public key")
assert.Equal(t, curve, storedPubKey.Curve, "Stored public key should use the expected elliptic curve")
}
func createTempRSAKeyAndCert(t *testing.T, commonName string) (*rsa.PrivateKey, *x509.Certificate) {
// Create temporary RSA key for SCEP envelope (required by SCEP protocol)
tempRSAKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
// Create self-signed certificate for SCEP protocol using RSA key
deviceCertTemplate := x509.Certificate{
Subject: pkix.Name{
CommonName: commonName,
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
BasicConstraintsValid: true,
}
deviceCertDerBytes, err := x509.CreateCertificate(
rand.Reader,
&deviceCertTemplate,
&deviceCertTemplate,
&tempRSAKey.PublicKey,
tempRSAKey,
)
require.NoError(t, err)
deviceCert, err := x509.ParseCertificate(deviceCertDerBytes)
require.NoError(t, err)
return tempRSAKey, deviceCert
}
func testGetCertFailures(t *testing.T, s *Suite) {
cases := []struct {
name string
config SCEPFailureConfig
}{
{
name: "empty challenge password",
config: SCEPFailureConfig{
ChallengePassword: "",
CommonName: "test-host-identity",
UseECC: true,
},
},
{
name: "wrong challenge password",
config: SCEPFailureConfig{
ChallengePassword: "wrong-secret",
CommonName: "test-host-identity",
UseECC: true,
},
},
{
name: "CN longer than 255 characters",
config: SCEPFailureConfig{
ChallengePassword: testEnrollmentSecret,
CommonName: strings.Repeat("a", 256),
UseECC: true,
},
},
{
name: "non-ECC algorithm used",
config: SCEPFailureConfig{
ChallengePassword: testEnrollmentSecret,
CommonName: "test-host-identity",
UseECC: false,
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
testSCEPFailure(t, s, c.config)
})
}
}
type SCEPFailureConfig struct {
ChallengePassword string
CommonName string
UseECC bool
}
func testSCEPFailure(t *testing.T, s *Suite, config SCEPFailureConfig) {
ctx := t.Context()
// Create an enrollment secret
err := s.DS.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{
{
Secret: testEnrollmentSecret,
},
})
require.NoError(t, err)
// Create SCEP client
scepURL := fmt.Sprintf("%s/api/fleet/orbit/host_identity/scep", s.Server.URL)
scepClient, err := scepclient.New(scepURL, s.Logger, nil)
require.NoError(t, err)
// Get CA certificate
resp, _, err := scepClient.GetCACert(ctx, "")
require.NoError(t, err)
caCerts, err := x509.ParseCertificates(resp)
require.NoError(t, err)
require.NotEmpty(t, caCerts)
var privateKey interface{}
var sigAlg x509.SignatureAlgorithm
if config.UseECC {
// Create ECC private key
eccKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
privateKey = eccKey
sigAlg = x509.ECDSAWithSHA256
} else {
// Create RSA private key to test non-ECC algorithm rejection (should fail)
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
privateKey = rsaKey
sigAlg = x509.SHA256WithRSA
}
// Create CSR
csrTemplate := x509util.CertificateRequest{
CertificateRequest: x509.CertificateRequest{
Subject: pkix.Name{
CommonName: config.CommonName,
},
SignatureAlgorithm: sigAlg,
},
ChallengePassword: config.ChallengePassword,
}
csrDerBytes, err := x509util.CreateCertificateRequest(rand.Reader, &csrTemplate, privateKey)
require.NoError(t, err)
csr, err := x509.ParseCertificateRequest(csrDerBytes)
require.NoError(t, err)
tempRSAKey, deviceCert := createTempRSAKeyAndCert(t, config.CommonName)
// Create SCEP PKI message
pkiMsgReq := &scep.PKIMessage{
MessageType: scep.PKCSReq,
Recipients: caCerts,
SignerKey: tempRSAKey,
SignerCert: deviceCert,
}
msg, err := scep.NewCSRRequest(csr, pkiMsgReq, scep.WithLogger(s.Logger))
require.NoError(t, err)
// Send PKI operation request
respBytes, err := scepClient.PKIOperation(ctx, msg.Raw)
require.NoError(t, err)
// Parse response
pkiMsgResp, err := scep.ParsePKIMessage(respBytes, scep.WithLogger(s.Logger), scep.WithCACerts(msg.Recipients))
require.NoError(t, err)
// Verify failure response
assert.Equal(t, scep.FAILURE, pkiMsgResp.PKIStatus, "SCEP request should fail")
}

View file

@ -0,0 +1,51 @@
package hostidentity
import (
"os"
"testing"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/fleetdm/fleet/v4/server/service/integrationtest"
"github.com/go-kit/kit/log"
kitlog "github.com/go-kit/log"
"github.com/stretchr/testify/require"
)
type Suite struct {
integrationtest.BaseSuite
}
func SetUpSuite(t *testing.T, uniqueTestName string) *Suite {
// Note: t.Parallel() is called when MySQL datastore options are processed
license := &fleet.LicenseInfo{
Tier: fleet.TierPremium,
}
ds, fleetCfg, fleetSvc, ctx := integrationtest.SetUpMySQLAndService(t, uniqueTestName, &service.TestServerOpts{
License: license,
})
logger := log.NewLogfmtLogger(os.Stdout)
hostIdentitySCEPDepot, err := ds.NewHostIdentitySCEPDepot(kitlog.With(logger, "component", "host-id-scep-depot"))
require.NoError(t, err)
users, server := service.RunServerForTestsWithServiceWithDS(t, ctx, ds, fleetSvc, &service.TestServerOpts{
License: license,
FleetConfig: &fleetCfg,
Logger: logger,
HostIdentitySCEPStorage: hostIdentitySCEPDepot,
})
s := &Suite{
BaseSuite: integrationtest.BaseSuite{
Logger: logger,
DS: ds,
FleetCfg: fleetCfg,
Users: users,
Server: server,
},
}
integrationtest.SetUpServerURL(t, ds, server)
s.BaseSuite.Token = s.BaseSuite.GetTestAdminToken(t)
return s
}

View file

@ -0,0 +1,50 @@
package hostidentity
import (
"context"
"fmt"
"github.com/fleetdm/fleet/v4/pkg/certificate"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
)
func initAssets(ds fleet.Datastore) error {
// Check if we have existing certs and keys
expectedAssets := []fleet.MDMAssetName{
fleet.MDMAssetHostIdentityCACert,
fleet.MDMAssetHostIdentityCAKey,
}
savedAssets, err := ds.GetAllMDMConfigAssetsByName(context.Background(), expectedAssets, nil)
if err != nil {
// allow not found errors as it means we're generating the assets for the first time.
if !fleet.IsNotFound(err) {
return fmt.Errorf("loading existing host identity assets from the database: %w", err)
}
}
if len(savedAssets) != len(expectedAssets) {
// Then we should create them
scepCert, scepKey, err := depot.NewSCEPCACertKey()
if err != nil {
return fmt.Errorf("generating host identity SCEP cert and key: %w", err)
}
// Store our config assets encrypted
var assets []fleet.MDMConfigAsset
for k, v := range map[fleet.MDMAssetName][]byte{
fleet.MDMAssetHostIdentityCACert: certificate.EncodeCertPEM(scepCert),
fleet.MDMAssetHostIdentityCAKey: certificate.EncodePrivateKeyPEM(scepKey),
} {
assets = append(assets, fleet.MDMConfigAsset{
Name: k,
Value: v,
})
}
if err := ds.InsertMDMConfigAssets(context.Background(), assets, nil); err != nil {
return fmt.Errorf("inserting host identity SCEP assets: %w", err)
}
}
return nil
}

View file

@ -0,0 +1,136 @@
package depot
import (
"context"
"crypto/ecdsa"
"crypto/rsa"
"crypto/x509"
"errors"
"fmt"
"math/big"
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/types"
"github.com/fleetdm/fleet/v4/pkg/certificate"
"github.com/fleetdm/fleet/v4/server/datastore/mysql/common_mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/assets"
"github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
"github.com/go-kit/log"
"github.com/jmoiron/sqlx"
)
const maxCommonNameLength = 255
// HostIdentitySCEPDepot is a MySQL-backed SCEP certificate depot.
type HostIdentitySCEPDepot struct {
db *sqlx.DB
ds fleet.Datastore
logger log.Logger
}
var _ depot.Depot = (*HostIdentitySCEPDepot)(nil)
// NewHostIdentitySCEPDepot creates and returns a *HostIdentitySCEPDepot.
func NewHostIdentitySCEPDepot(db *sqlx.DB, ds fleet.Datastore, logger log.Logger) (*HostIdentitySCEPDepot, error) {
if err := db.Ping(); err != nil {
return nil, err
}
return &HostIdentitySCEPDepot{
db: db,
ds: ds,
logger: logger,
}, nil
}
// CA returns the CA's certificate and private key.
func (d *HostIdentitySCEPDepot) CA(_ []byte) ([]*x509.Certificate, *rsa.PrivateKey, error) {
cert, err := assets.KeyPair(context.Background(), d.ds, fleet.MDMAssetHostIdentityCACert, fleet.MDMAssetHostIdentityCAKey)
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 *HostIdentitySCEPDepot) Serial() (*big.Int, error) {
// Insert an empty row to generate a new auto-incremented serial number
result, err := d.db.Exec(`INSERT INTO host_identity_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.
func (d *HostIdentitySCEPDepot) HasCN(cn string, allowTime int, cert *x509.Certificate, revokeOldCertificate bool) (bool, error) {
// Not used right now. May be used for renewal in the future.
return false, 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 *HostIdentitySCEPDepot) Put(name string, crt *x509.Certificate) error {
if crt.Subject.CommonName == "" || len(crt.Subject.CommonName) > maxCommonNameLength {
return errors.New("common name empty or too long")
}
if !crt.SerialNumber.IsInt64() {
return errors.New("cannot represent serial number as int64")
}
// Extract the ECC uncompressed point (04-prefixed X || Y); 0x04 means this is the raw representation
// Lengths:
// - P-256: 65 bytes
// - P-384: 97 bytes
key, ok := crt.PublicKey.(*ecdsa.PublicKey)
if !ok {
return errors.New("public key not in ECDSA format")
}
pubKeyRaw, err := types.CreateECDSAPublicKeyRaw(key)
if err != nil {
return fmt.Errorf("creating public key raw: %w", err)
}
certPEM := certificate.EncodeCertPEM(crt)
return common_mysql.WithRetryTxx(context.Background(), d.db, func(tx sqlx.ExtContext) error {
// Revoke existing certs for this host id.
// Note: Because the challenge is shared, it is possible for a bad actor to revoke a cert for an existing host
// if they have the challenge and the host identifier (CN).
result, err := tx.ExecContext(context.Background(), `
UPDATE host_identity_scep_certificates
SET revoked = 1
WHERE name = ?`, name)
if err != nil {
return err
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected > 0 {
d.logger.Log("msg", "revoked existing host identity certificate", "name", name)
}
_, err = tx.ExecContext(context.Background(), `
INSERT INTO host_identity_scep_certificates
(serial, name, not_valid_before, not_valid_after, certificate_pem, public_key_raw)
VALUES
(?, ?, ?, ?, ?, ?)`,
crt.SerialNumber.Int64(),
name,
crt.NotBefore,
crt.NotAfter,
certPEM,
pubKeyRaw,
)
return err
}, d.logger)
}

View file

@ -0,0 +1,186 @@
package hostidentity
import (
"context"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net/http"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/assets"
scepdepot "github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server"
"github.com/go-kit/kit/log"
kitlog "github.com/go-kit/log"
"github.com/smallstep/scep"
)
const (
scepPath = "/api/fleet/orbit/host_identity/scep"
scepValidityDays = 365
)
// RegisterSCEP registers the HTTP handler for SCEP service needed for fleetd enrollment.
func RegisterSCEP(
mux *http.ServeMux,
scepStorage scepdepot.Depot,
ds fleet.Datastore,
logger kitlog.Logger,
) error {
err := initAssets(ds)
if err != nil {
return fmt.Errorf("initializing host identity assets: %w", err)
}
var signer scepserver.CSRSignerContext = scepserver.SignCSRAdapter(scepdepot.NewSigner(
scepStorage,
scepdepot.WithValidityDays(scepValidityDays),
scepdepot.WithAllowRenewalDays(scepValidityDays/2),
))
signer = challengeMiddleware(ds, signer)
scepService := NewSCEPService(
ds,
signer,
kitlog.With(logger, "component", "host-id-scep"),
)
scepLogger := kitlog.With(logger, "component", "http-host-id-scep")
e := scepserver.MakeServerEndpoints(scepService)
e.GetEndpoint = scepserver.EndpointLoggingMiddleware(scepLogger)(e.GetEndpoint)
e.PostEndpoint = scepserver.EndpointLoggingMiddleware(scepLogger)(e.PostEndpoint)
// Note: Monitoring (APM/OpenTel) is missing for this SCEP server.
// In addition, the scepserver error handler does not send errors to APM/Sentry/Redis.
// It should be enhanced to do so if/when we start monitoring error traces.
// This note also applies to the other SCEP servers we use.
// That is why we're not using ctxerr wrappers here.
scepHandler := scepserver.MakeHTTPHandler(e, scepService, scepLogger)
mux.Handle(scepPath, scepHandler)
return nil
}
// challengeMiddleware checks that ChallengePassword matches an enrollment secret
func challengeMiddleware(ds fleet.Datastore, next scepserver.CSRSignerContext) scepserver.CSRSignerContextFunc {
return func(ctx context.Context, m *scep.CSRReqMessage) (*x509.Certificate, error) {
if m.ChallengePassword == "" {
return nil, errors.New("missing challenge")
}
_, err := ds.VerifyEnrollSecret(ctx, m.ChallengePassword)
switch {
case fleet.IsNotFound(err):
return nil, errors.New("invalid challenge")
case err != nil:
return nil, fmt.Errorf("verifying enrollment secret: %w", err)
}
return next.SignCSRContext(ctx, m)
}
}
var _ scepserver.Service = (*service)(nil)
type service struct {
// The (chainable) CSR signing function. Intended to handle all
// SCEP request functionality such as CSR & challenge checking, CA
// issuance, RA proxying, etc.
signer scepserver.CSRSignerContext
logger log.Logger
ds fleet.MDMAssetRetriever
}
func (svc *service) GetCACaps(_ context.Context) ([]byte, error) {
// Supported SCEP CA Capabilities:
//
// Cryptographic and Algorithm Support:
// [x] POSTPKIOperation // Supports HTTP POST for PKIOperation (preferred over GET)
// [ ] SHA-1 // Supports SHA-1 for signing
// [x] SHA-256 // Supports SHA-256 for signing
// [ ] SHA-512 // Supports SHA-512 for signing
// [x] AES // Supports AES encryption for PKCS#7 enveloped data
// [ ] DES3 // Supports Triple DES encryption - older, weaker encryption
//
// Operational Capabilities:
// [ ] GetNextCACert // Supports fetching next CA certificate (rollover)
// [ ] Renewal // Supports certificate renewal (same key, new cert)
// [ ] Update // Supports certificate update (new key)
//
// These capabilities are implied by the protocol and don't need to be explicitly declared:
// [x] SCEPStandard // Conforms to a known SCEP standard version
// [x] PKCS7 // Responses are in PKCS#7 format
// [x] X509 // Supports X.509 certificates
//
defaultCaps := []byte("SHA-256\nAES\nPOSTPKIOperation")
return defaultCaps, nil
}
func (svc *service) GetCACert(ctx context.Context, _ string) ([]byte, int, error) {
cert, err := caKeyPair(ctx, svc.ds)
if err != nil {
return nil, 0, fmt.Errorf("retrieving host identity SCEP CA certificate (GetCACert): %w", err)
}
return cert.Leaf.Raw, 1, nil
}
func caKeyPair(ctx context.Context, ds fleet.MDMAssetRetriever) (*tls.Certificate, error) {
return assets.KeyPair(ctx, ds, fleet.MDMAssetHostIdentityCACert, fleet.MDMAssetHostIdentityCAKey)
}
func (svc *service) PKIOperation(ctx context.Context, data []byte) ([]byte, error) {
if len(data) == 0 {
return nil, &fleet.BadRequestError{Message: "missing data for PKIOperation"}
}
msg, err := scep.ParsePKIMessage(data, scep.WithLogger(svc.logger))
if err != nil {
return nil, err
}
cert, err := caKeyPair(ctx, svc.ds)
if err != nil {
return nil, fmt.Errorf("retrieving host identity SCEP CA certificate: %w", err)
}
pk, ok := cert.PrivateKey.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("private key not in RSA format")
}
if err := msg.DecryptPKIEnvelope(cert.Leaf, pk); err != nil {
return nil, err
}
crt, err := svc.signer.SignCSRContext(ctx, msg.CSRReqMessage)
if err == nil && crt == nil {
err = errors.New("signer returned nil certificate without error")
}
if err != nil {
svc.logger.Log("msg", "failed to sign CSR", "err", err)
certRep, err := msg.Fail(cert.Leaf, pk, scep.BadRequest)
if certRep == nil {
return nil, err
}
return certRep.Raw, err
}
certRep, err := msg.Success(cert.Leaf, pk, crt)
if certRep == nil {
return nil, err
}
return certRep.Raw, err
}
func (svc *service) GetNextCACert(_ context.Context) ([]byte, error) {
return nil, errors.New("not implemented")
}
// NewSCEPService creates a new scep service
func NewSCEPService(ds fleet.Datastore, signer scepserver.CSRSignerContext, logger log.Logger) scepserver.Service {
return &service{
ds: ds,
signer: signer,
logger: logger,
}
}

View file

@ -0,0 +1,67 @@
package types
import (
"crypto/ecdsa"
"crypto/elliptic"
"errors"
"fmt"
"math/big"
"time"
)
type HostIdentityCertificate struct {
SerialNumber uint64 `db:"serial"`
CommonName string `db:"name"`
HostID *uint `db:"host_id"`
NotValidAfter time.Time `db:"not_valid_after"`
PublicKeyRaw []byte `db:"public_key_raw"`
}
func (h *HostIdentityCertificate) UnmarshalPublicKey() (*ecdsa.PublicKey, error) {
if len(h.PublicKeyRaw) == 0 || h.PublicKeyRaw[0] != 4 { // 0x04 means this is the raw representation
return nil, errors.New("unsupported EC point format")
}
curve, err := guessCurve(h.PublicKeyRaw)
if err != nil {
return nil, err
}
byteLen := (len(h.PublicKeyRaw) - 1) / 2
x := new(big.Int).SetBytes(h.PublicKeyRaw[1 : 1+byteLen])
y := new(big.Int).SetBytes(h.PublicKeyRaw[1+byteLen:])
return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil
}
func guessCurve(raw []byte) (elliptic.Curve, error) {
switch len(raw) {
case 65: // 0x04 + 32 + 32
return elliptic.P256(), nil
case 97: // 0x04 + 48 + 48
return elliptic.P384(), nil
default:
return nil, fmt.Errorf("unknown curve: unsupported key length %d", len(raw))
}
}
func CreateECDSAPublicKeyRaw(key *ecdsa.PublicKey) ([]byte, error) {
var keySize int
switch key.Curve {
case elliptic.P256():
keySize = 32
case elliptic.P384():
keySize = 48
default:
return nil, fmt.Errorf("unsupported curve: %s", key.Curve.Params().Name)
}
// Pad X and Y coordinates to the expected size
xBytes := make([]byte, keySize)
yBytes := make([]byte, keySize)
key.X.FillBytes(xBytes)
key.Y.FillBytes(yBytes)
pubKeyRaw := append([]byte{0x04}, append(xBytes, yBytes...)...)
return pubKeyRaw, nil
}

View file

@ -0,0 +1,122 @@
package types
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHostIdentityCertificate_PublicKey(t *testing.T) {
t.Run("P256", func(t *testing.T) {
testUnmarshalPublicKeyWithCurve(t, elliptic.P256())
})
t.Run("P384", func(t *testing.T) {
testUnmarshalPublicKeyWithCurve(t, elliptic.P384())
})
t.Run("unsupported curve", func(t *testing.T) {
// Generate a key with P521 curve (unsupported)
key, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader)
require.NoError(t, err)
// Try to create raw public key - should fail
_, err = CreateECDSAPublicKeyRaw(&key.PublicKey)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported curve")
})
t.Run("invalid format - missing 0x04 prefix", func(t *testing.T) {
// Generate a P256 key but remove the 0x04 prefix
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
// Create raw bytes without 0x04 prefix, padded to 32 bytes each
xBytes := make([]byte, 32)
yBytes := make([]byte, 32)
key.X.FillBytes(xBytes)
key.Y.FillBytes(yBytes)
pubKeyRaw := xBytes
pubKeyRaw = append(pubKeyRaw, yBytes...)
cert := &HostIdentityCertificate{
PublicKeyRaw: pubKeyRaw,
}
_, err = cert.UnmarshalPublicKey()
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported EC point format")
})
t.Run("invalid format - wrong prefix", func(t *testing.T) {
// Generate a P256 key but use wrong prefix
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
// Create raw bytes with wrong prefix (0x03 instead of 0x04)
pubKeyRaw, err := CreateECDSAPublicKeyRaw(&key.PublicKey)
require.NoError(t, err)
pubKeyRaw[0] = 0x03
cert := &HostIdentityCertificate{
PublicKeyRaw: pubKeyRaw,
}
_, err = cert.UnmarshalPublicKey()
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported EC point format")
})
t.Run("empty public key", func(t *testing.T) {
cert := &HostIdentityCertificate{
PublicKeyRaw: []byte{},
}
_, err := cert.UnmarshalPublicKey()
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported EC point format")
})
t.Run("unsupported key length", func(t *testing.T) {
// Create a key with unsupported length (not 65 or 97 bytes)
pubKeyRaw := make([]byte, 33) // 33 bytes total (0x04 + 16 + 16)
pubKeyRaw[0] = 0x04
cert := &HostIdentityCertificate{
PublicKeyRaw: pubKeyRaw,
}
_, err := cert.UnmarshalPublicKey()
assert.Error(t, err)
assert.Contains(t, err.Error(), "unknown curve")
})
}
func testUnmarshalPublicKeyWithCurve(t *testing.T, curve elliptic.Curve) {
// Generate a key with the specified curve
originalKey, err := ecdsa.GenerateKey(curve, rand.Reader)
require.NoError(t, err)
pubKeyRaw, err := CreateECDSAPublicKeyRaw(&originalKey.PublicKey)
require.NoError(t, err)
// Create HostIdentityCertificate with the raw public key
cert := &HostIdentityCertificate{
PublicKeyRaw: pubKeyRaw,
}
// Unmarshal the public key
unmarshaledKey, err := cert.UnmarshalPublicKey()
require.NoError(t, err)
require.NotNil(t, unmarshaledKey)
// Verify the unmarshaled key matches the original
assert.Equal(t, curve, unmarshaledKey.Curve)
assert.True(t, originalKey.X.Cmp(unmarshaledKey.X) == 0, "X coordinates should match")
assert.True(t, originalKey.Y.Cmp(unmarshaledKey.Y) == 0, "Y coordinates should match")
assert.True(t, originalKey.PublicKey.Equal(unmarshaledKey), "Public keys should be equal")
}

View file

@ -3,8 +3,10 @@ package certificate
import (
"context"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"net"
@ -237,3 +239,21 @@ func parseFullClientCertificate(crt, key []byte) (tls.Certificate, error) {
cert.Leaf = parsedLeaf
return cert, nil
}
// EncodeCertPEM returns PEM-endcoded certificate data.
func EncodeCertPEM(cert *x509.Certificate) []byte {
block := pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
}
return pem.EncodeToMemory(&block)
}
// EncodePrivateKeyPEM returns PEM-encoded private key data
func EncodePrivateKeyPEM(key *rsa.PrivateKey) []byte {
block := pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
}
return pem.EncodeToMemory(&block)
}

View file

@ -401,7 +401,6 @@ func (n notFoundErr) Error() string {
}
func TestCalendarEvents1KHosts(t *testing.T) {
t.Parallel()
ds := new(mock.Store)
ctx := context.Background()
var logger kitlog.Logger

View file

@ -94,6 +94,8 @@ type DatastoreTestOptions struct {
// RealReplica indicates that the replica should be a real DB replica, with a dedicated connection.
RealReplica bool
UniqueTestName string
}
func LoadSchema(t testing.TB, testName string, opts *DatastoreTestOptions, schemaPath string) {
@ -182,16 +184,22 @@ func ProcessOptions(t testing.TB, opts *DatastoreTestOptions) (string, *Datastor
}
}
const numberOfStackFramesFromTest = 3
pc, _, _, ok := runtime.Caller(numberOfStackFramesFromTest)
details := runtime.FuncForPC(pc)
if !ok || details == nil {
t.FailNow()
var cleanTestName string
if opts.UniqueTestName != "" {
cleanTestName = opts.UniqueTestName
} else {
const numberOfStackFramesFromTest = 3
pc, _, _, ok := runtime.Caller(numberOfStackFramesFromTest)
details := runtime.FuncForPC(pc)
if !ok || details == nil {
t.FailNow()
}
cleanTestName = strings.ReplaceAll(
strings.TrimPrefix(details.Name(), "github.com/fleetdm/fleet/v4/"), "/", "_",
)
}
cleanTestName := strings.ReplaceAll(
strings.TrimPrefix(details.Name(), "github.com/fleetdm/fleet/v4/"), "/", "_",
)
cleanTestName = strings.ReplaceAll(cleanTestName, ".", "_")
if len(cleanTestName) > 60 {
// the later parts are more unique than the start, with the package names,

View file

@ -0,0 +1,24 @@
package mysql
import (
"context"
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/types"
"github.com/jmoiron/sqlx"
)
// Most of the code for the host identity feature is located at ./ee/server/service/hostidentity
func (ds *Datastore) GetHostIdentityCertBySerialNumber(ctx context.Context, serialNumber uint64) (*types.HostIdentityCertificate, error) {
var hostIdentityCert types.HostIdentityCertificate
err := sqlx.GetContext(ctx, ds.reader(ctx), &hostIdentityCert, `
SELECT serial, host_id, name, not_valid_after, public_key_raw
FROM host_identity_scep_certificates
WHERE serial = ?
AND not_valid_after > NOW()
AND revoked = 0`, serialNumber)
if err != nil {
return nil, err
}
return &hostIdentityCert, nil
}

View file

@ -0,0 +1,55 @@
package tables
import (
"database/sql"
"fmt"
)
func init() {
MigrationClient.AddMigration(Up_20250707095725, Down_20250707095725)
}
func Up_20250707095725(tx *sql.Tx) error {
// Create host_identity_scep_serials table first (referenced by foreign key)
// In SCEP (Simple Certificate Enrollment Protocol) implementations, it's common practice to reserve serial number 1 for the CA (Certificate Authority) certificate itself or for other system-level certificates.
_, err := tx.Exec(`
CREATE TABLE host_identity_scep_serials (
serial bigint unsigned NOT NULL AUTO_INCREMENT,
created_at DATETIME(6) NULL DEFAULT NOW(6),
PRIMARY KEY (serial)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`)
if err != nil {
return fmt.Errorf("failed to create host_identity_scep_serials table: %w", err)
}
// Create host_identity_scep_certificates table
_, err = tx.Exec(`
CREATE TABLE host_identity_scep_certificates (
serial bigint unsigned NOT NULL,
host_id int unsigned NULL,
name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
not_valid_before datetime NOT NULL,
not_valid_after datetime NOT NULL,
certificate_pem text COLLATE utf8mb4_unicode_ci NOT NULL, -- 65K max, stored for debug/auditing but not used
public_key_raw VARBINARY(100) NOT NULL, -- for quick retrieval/verification
revoked tinyint(1) NOT NULL DEFAULT '0',
created_at DATETIME(6) NULL DEFAULT NOW(6),
updated_at DATETIME(6) NULL DEFAULT NOW(6) ON UPDATE NOW(6),
PRIMARY KEY (serial),
KEY idx_host_id_scep_name (name), -- for quick revocation
KEY idx_host_id_scep_host_id (host_id),
CONSTRAINT host_identity_scep_certificates_ibfk_1 FOREIGN KEY (serial) REFERENCES host_identity_scep_serials (serial),
CONSTRAINT host_identity_scep_certificates_chk_1 CHECK ((substr(certificate_pem,1,27) = _utf8mb4'-----BEGIN CERTIFICATE-----'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`)
if err != nil {
return fmt.Errorf("failed to create host_identity_scep_certificates table: %w", err)
}
return nil
}
func Down_20250707095725(_ *sql.Tx) error {
return nil
}

View file

@ -17,6 +17,7 @@ import (
"github.com/XSAM/otelsql"
"github.com/doug-martin/goqu/v9"
"github.com/doug-martin/goqu/v9/exp"
hostidscepdepot "github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/depot"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
@ -183,12 +184,18 @@ func (ds *Datastore) deleteCachedStmt(query string) {
}
}
// NewMDMAppleSCEPDepot returns a scep_depot.Depot that uses the Datastore
// NewSCEPDepot returns a scep_depot.Depot that uses the Datastore
// underlying MySQL writer *sql.DB.
func (ds *Datastore) NewSCEPDepot() (scep_depot.Depot, error) {
return newSCEPDepot(ds.primary.DB, ds)
}
// NewHostIdentitySCEPDepot returns a scep_depot.Depot for host identity certs that uses the Datastore
// underlying MySQL writer *sql.DB.
func (ds *Datastore) NewHostIdentitySCEPDepot(logger log.Logger) (scep_depot.Depot, error) {
return hostidscepdepot.NewHostIdentitySCEPDepot(ds.primary, ds, logger)
}
type entity struct {
name string
}

View file

@ -11,8 +11,8 @@ import (
"fmt"
"math/big"
"github.com/fleetdm/fleet/v4/pkg/certificate"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/assets"
"github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
)
@ -90,7 +90,7 @@ func (d *SCEPDepot) Put(name string, crt *x509.Certificate) error {
if !crt.SerialNumber.IsInt64() {
return errors.New("cannot represent serial number as int64")
}
certPEM := apple_mdm.EncodeCertPEM(crt)
certPEM := certificate.EncodeCertPEM(crt)
_, err := d.db.Exec(`
INSERT INTO scep_certificates
(serial, name, not_valid_before, not_valid_after, certificate_pem)

File diff suppressed because one or more lines are too long

View file

@ -9,7 +9,7 @@ import (
"math/big"
"strings"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/pkg/certificate"
microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft"
)
@ -28,7 +28,7 @@ func (ds *Datastore) WSTEPStoreCertificate(ctx context.Context, name string, crt
if !crt.SerialNumber.IsInt64() {
return errors.New("cannot represent serial number as int64")
}
certPEM := apple_mdm.EncodeCertPEM(crt)
certPEM := certificate.EncodeCertPEM(crt)
_, err := ds.writer(ctx).ExecContext(ctx, `
INSERT INTO wstep_certificates
(serial, name, not_valid_before, not_valid_after, certificate_pem)

View file

@ -10,6 +10,7 @@ import (
"math/big"
"time"
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/types"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/health"
"github.com/fleetdm/fleet/v4/server/mdm/android"
@ -2203,6 +2204,12 @@ type Datastore interface {
// SetHostConditionalAccessStatus sets the "managed" and "compliant" statuses last set on Entra.
// It does nothing if the host doesn't have a status entry created with CreateHostConditionalAccessStatus yet.
SetHostConditionalAccessStatus(ctx context.Context, hostID uint, managed, compliant bool) error
// /////////////////////////////////////////////////////////////////////////////
// Host identity certificates
// GetHostIdentityCertBySerialNumber gets the unrevoked valid cert corresponding to the provided serial number.
GetHostIdentityCertBySerialNumber(ctx context.Context, serialNumber uint64) (*types.HostIdentityCertificate, error)
}
type AndroidDatastore interface {

View file

@ -767,6 +767,10 @@ const (
MDMAssetAndroidPubSubToken MDMAssetName = "android_pubsub_token" // nolint:gosec // Ignore G101: Potential hardcoded credentials
// MDMAssetAndroidFleetServerSecret is the bearer token for Android requests sent to the fleetdm.com Android management proxy.
MDMAssetAndroidFleetServerSecret MDMAssetName = "android_fleet_server_secret" // nolint:gosec // Ignore G101: Potential hardcoded credentials
// MDMAssetHostIdentityCACert is the name of the root CA certificate used for host identity
MDMAssetHostIdentityCACert MDMAssetName = "host_identity_ca_cert"
// MDMAssetHostIdentityCAKey is the name of the root CA private key used for host identity
MDMAssetHostIdentityCAKey MDMAssetName = "host_identity_ca_key"
)
type MDMConfigAsset struct {

View file

@ -202,27 +202,7 @@ func GetSignedAPNSCSRNoEmail(client *http.Client, csr *x509.CertificateRequest)
// NewSCEPCACertKey creates a self-signed CA certificate for use with SCEP and
// returns the certificate and its private key.
func NewSCEPCACertKey() (*x509.Certificate, *rsa.PrivateKey, error) {
key, err := newPrivateKey()
if err != nil {
return nil, nil, err
}
caCert := depot.NewCACert(
depot.WithYears(10),
depot.WithCommonName("Fleet"),
)
crtBytes, err := caCert.SelfSign(rand.Reader, key.Public(), key)
if err != nil {
return nil, nil, err
}
cert, err := x509.ParseCertificate(crtBytes)
if err != nil {
return nil, nil, err
}
return cert, key, nil
return depot.NewSCEPCACertKey()
}
// NEWDEPKeyPairPEM generates a new public key certificate and private key for downloading the Apple DEP token.

View file

@ -26,15 +26,6 @@ func newPrivateKey() (*rsa.PrivateKey, error) {
return rsa.GenerateKey(rand.Reader, rsaKeySize)
}
// EncodeCertPEM returns PEM-endcoded certificate data.
func EncodeCertPEM(cert *x509.Certificate) []byte {
block := pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
}
return pem.EncodeToMemory(&block)
}
func EncodeCertRequestPEM(cert *x509.CertificateRequest) []byte {
pemBlock := &pem.Block{
Type: "CERTIFICATE REQUEST",
@ -45,15 +36,6 @@ func EncodeCertRequestPEM(cert *x509.CertificateRequest) []byte {
return pem.EncodeToMemory(pemBlock)
}
// EncodePrivateKeyPEM returns PEM-encoded private key data
func EncodePrivateKeyPEM(key *rsa.PrivateKey) []byte {
block := pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
}
return pem.EncodeToMemory(&block)
}
// GenerateRandomPin generates a `lenght`-digit PIN number that takes into
// account the current time as described in rfc4226 (for one time passwords)
//

View file

@ -0,0 +1,41 @@
package depot
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
)
// NewSCEPCACertKey creates a self-signed CA certificate for use with SCEP and
// returns the certificate and its private key.
func NewSCEPCACertKey() (*x509.Certificate, *rsa.PrivateKey, error) {
key, err := newPrivateKey()
if err != nil {
return nil, nil, err
}
caCert := NewCACert(
WithYears(10),
WithCommonName("Fleet"),
)
crtBytes, err := caCert.SelfSign(rand.Reader, key.Public(), key)
if err != nil {
return nil, nil, err
}
cert, err := x509.ParseCertificate(crtBytes)
if err != nil {
return nil, nil, err
}
return cert, key, nil
}
// Note Apple rejects CSRs if the key size is not 2048.
const rsaKeySize = 2048
// newPrivateKey creates an RSA private key
func newPrivateKey() (*rsa.PrivateKey, error) {
return rsa.GenerateKey(rand.Reader, rsaKeySize)
}

View file

@ -11,6 +11,7 @@ import (
"sync"
"time"
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/types"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/android"
@ -1412,6 +1413,8 @@ type CreateHostConditionalAccessStatusFunc func(ctx context.Context, hostID uint
type SetHostConditionalAccessStatusFunc func(ctx context.Context, hostID uint, managed bool, compliant bool) error
type GetHostIdentityCertBySerialNumberFunc func(ctx context.Context, serialNumber uint64) (*types.HostIdentityCertificate, error)
type DataStore struct {
HealthCheckFunc HealthCheckFunc
HealthCheckFuncInvoked bool
@ -3498,6 +3501,9 @@ type DataStore struct {
SetHostConditionalAccessStatusFunc SetHostConditionalAccessStatusFunc
SetHostConditionalAccessStatusFuncInvoked bool
GetHostIdentityCertBySerialNumberFunc GetHostIdentityCertBySerialNumberFunc
GetHostIdentityCertBySerialNumberFuncInvoked bool
mu sync.Mutex
}
@ -8365,3 +8371,10 @@ func (s *DataStore) SetHostConditionalAccessStatus(ctx context.Context, hostID u
s.mu.Unlock()
return s.SetHostConditionalAccessStatusFunc(ctx, hostID, managed, compliant)
}
func (s *DataStore) GetHostIdentityCertBySerialNumber(ctx context.Context, serialNumber uint64) (*types.HostIdentityCertificate, error) {
s.mu.Lock()
s.GetHostIdentityCertBySerialNumberFuncInvoked = true
s.mu.Unlock()
return s.GetHostIdentityCertBySerialNumberFunc(ctx, serialNumber)
}

View file

@ -7,6 +7,7 @@ import (
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/datastore/mysql/common_mysql/testing_utils"
"github.com/fleetdm/fleet/v4/server/datastore/redis/redistest"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/service"
@ -53,7 +54,9 @@ func SetUpMySQLAndService(t *testing.T, uniqueTestName string, opts ...*service.
config.FleetConfig,
fleet.Service, context.Context,
) {
ds := mysql.CreateMySQLDS(t)
ds := mysql.CreateMySQLDSWithOptions(t, &testing_utils.DatastoreTestOptions{
UniqueTestName: uniqueTestName,
})
test.AddAllHostsLabel(t, ds)
// Set up the required fields on AppConfig

View file

@ -21,6 +21,7 @@ import (
"github.com/VividCortex/mysqlerr"
"github.com/docker/go-units"
"github.com/fleetdm/fleet/v4/pkg/certificate"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/authz"
@ -209,9 +210,9 @@ func (svc *Service) RequestMDMAppleCSR(ctx context.Context, email, org string) (
}
// PEM-encode the cert and keys
scepCACertPEM := apple_mdm.EncodeCertPEM(scepCACert)
scepCAKeyPEM := apple_mdm.EncodePrivateKeyPEM(scepCAKey)
apnsKeyPEM := apple_mdm.EncodePrivateKeyPEM(apnsKey)
scepCACertPEM := certificate.EncodeCertPEM(scepCACert)
scepCAKeyPEM := certificate.EncodePrivateKeyPEM(scepCAKey)
apnsKeyPEM := certificate.EncodePrivateKeyPEM(apnsKey)
return &fleet.AppleCSR{
APNsKey: apnsKeyPEM,
@ -2525,9 +2526,9 @@ func (svc *Service) GetMDMAppleCSR(ctx context.Context) ([]byte, error) {
// Store our config assets encrypted
var assets []fleet.MDMConfigAsset
for k, v := range map[fleet.MDMAssetName][]byte{
fleet.MDMAssetCACert: apple_mdm.EncodeCertPEM(scepCert),
fleet.MDMAssetCAKey: apple_mdm.EncodePrivateKeyPEM(scepKey),
fleet.MDMAssetAPNSKey: apple_mdm.EncodePrivateKeyPEM(apnsRSAKey),
fleet.MDMAssetCACert: certificate.EncodeCertPEM(scepCert),
fleet.MDMAssetCAKey: certificate.EncodePrivateKeyPEM(scepKey),
fleet.MDMAssetAPNSKey: certificate.EncodePrivateKeyPEM(apnsRSAKey),
} {
assets = append(assets, fleet.MDMConfigAsset{
Name: k,

View file

@ -18,6 +18,7 @@ import (
"github.com/fleetdm/fleet/v4/ee/server/scim"
eeservice "github.com/fleetdm/fleet/v4/ee/server/service"
"github.com/fleetdm/fleet/v4/ee/server/service/digicert"
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/license"
@ -363,6 +364,7 @@ type TestServerOpts struct {
DigiCertService fleet.DigiCertService
EnableSCIM bool
ConditionalAccessMicrosoftProxy ConditionalAccessMicrosoftProxy
HostIdentitySCEPStorage scep_depot.Depot
}
func RunServerForTestsWithDS(t *testing.T, ds fleet.Datastore, opts ...*TestServerOpts) (map[string]fleet.User, *httptest.Server) {
@ -470,6 +472,10 @@ func RunServerForTestsWithServiceWithDS(t *testing.T, ctx context.Context, ds fl
rootMux.Handle("/api/latest/fleet/scim/details", apiHandler)
}
if len(opts) > 0 && opts[0].HostIdentitySCEPStorage != nil {
require.NoError(t, hostidentity.RegisterSCEP(rootMux, opts[0].HostIdentitySCEPStorage, ds, logger))
}
server := httptest.NewUnstartedServer(rootMux)
server.Config = cfg.Server.DefaultHTTPServer(ctx, rootMux)
// WriteTimeout is set for security purposes.