From 8ed513c6202d646a058cbfffee546b12cb7f9a85 Mon Sep 17 00:00:00 2001 From: Carlo DiCelico Date: Fri, 7 Nov 2025 06:58:26 -0500 Subject: [PATCH 1/3] update tables used for cert-based auth --- server/datastore/mysql/host_identity_scep.go | 55 ++++++++++++++++++++ server/fleet/datastore.go | 3 ++ server/service/devices.go | 8 +-- 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/server/datastore/mysql/host_identity_scep.go b/server/datastore/mysql/host_identity_scep.go index e0c1038427b..3c4c2a88f3b 100644 --- a/server/datastore/mysql/host_identity_scep.go +++ b/server/datastore/mysql/host_identity_scep.go @@ -2,7 +2,11 @@ package mysql import ( "context" + "crypto/sha256" + "crypto/x509" "database/sql" + "encoding/hex" + "encoding/pem" "errors" "fmt" @@ -60,3 +64,54 @@ func (ds *Datastore) GetHostIdentityCertByName(ctx context.Context, name string) } return &hostIdentityCert, nil } + +// GetMDMSCEPCertBySerial looks up an MDM SCEP certificate by serial number +// and returns the device UUID it's associated with. This is used for iOS/iPadOS +// certificate-based authentication on the My Device page. +// +// This query uses the nano_cert_auth_associations table which maps device IDs to +// certificate hashes. The serial number lookup in scep_certificates provides +// the raw certificate data, but we need the nanomdm association to get the device UUID. +func (ds *Datastore) GetMDMSCEPCertBySerial(ctx context.Context, serialNumber uint64) (deviceUUID string, err error) { + // First get the certificate by serial + var certPEM string + err = sqlx.GetContext(ctx, ds.reader(ctx), &certPEM, ` + SELECT certificate_pem + FROM scep_certificates + WHERE serial = ? + AND not_valid_after > NOW() + AND revoked = 0`, serialNumber) + switch { + case errors.Is(err, sql.ErrNoRows): + return "", notFound("MDM SCEP certificate") + case err != nil: + return "", err + } + + // Calculate the SHA256 hash of the certificate the same way nanomdm does + // (see server/mdm/nanomdm/service/certauth/certauth.go HashCert function) + // The hash is calculated from cert.Raw (DER-encoded bytes), not the PEM string + block, _ := pem.Decode([]byte(certPEM)) + if block == nil { + return "", fmt.Errorf("failed to decode PEM certificate") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "", fmt.Errorf("failed to parse certificate: %w", err) + } + hashed := sha256.Sum256(cert.Raw) + hash := hex.EncodeToString(hashed[:]) + + // Look up the device UUID by certificate hash + err = sqlx.GetContext(ctx, ds.reader(ctx), &deviceUUID, ` + SELECT id + FROM nano_cert_auth_associations + WHERE sha256 = ?`, hash) + switch { + case errors.Is(err, sql.ErrNoRows): + return "", notFound("MDM certificate association") + case err != nil: + return "", err + } + return deviceUUID, nil +} diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index f72c5612438..bf8f5476a07 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -2429,6 +2429,9 @@ type Datastore interface { GetHostIdentityCertByName(ctx context.Context, name string) (*types.HostIdentityCertificate, error) // UpdateHostIdentityCertHostIDBySerial updates the host ID associated with a certificate using its serial number. UpdateHostIdentityCertHostIDBySerial(ctx context.Context, serialNumber uint64, hostID uint) error + // GetMDMSCEPCertBySerial looks up an MDM SCEP certificate by serial number and returns the device UUID. + // This is used for iOS/iPadOS certificate-based authentication. + GetMDMSCEPCertBySerial(ctx context.Context, serialNumber uint64) (deviceUUID string, err error) // ///////////////////////////////////////////////////////////////////////////// // Certificate Authorities diff --git a/server/service/devices.go b/server/service/devices.go index ad556f74f9a..4fa21fac30e 100644 --- a/server/service/devices.go +++ b/server/service/devices.go @@ -273,8 +273,8 @@ func (svc *Service) AuthenticateDeviceByCertificate(ctx context.Context, certSer return nil, false, ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("authentication error: missing host UUID")) } - // Look up the certificate by serial number - cert, err := svc.ds.GetHostIdentityCertBySerialNumber(ctx, certSerial) + // Look up the MDM SCEP certificate by serial number to get the device UUID + certDeviceUUID, err := svc.ds.GetMDMSCEPCertBySerial(ctx, certSerial) switch { case err == nil: // OK @@ -284,8 +284,8 @@ func (svc *Service) AuthenticateDeviceByCertificate(ctx context.Context, certSer return nil, false, ctxerr.Wrap(ctx, err, "lookup certificate by serial") } - // Verify certificate matches the host UUID (CN should match UUID) - if cert.CommonName != hostUUID { + // Verify certificate's device UUID matches the requested host UUID + if certDeviceUUID != hostUUID { return nil, false, ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("authentication error: certificate does not match host")) } From 75f0af7803935c475e50f161b0816c6f107f20de Mon Sep 17 00:00:00 2001 From: Carlo DiCelico Date: Fri, 7 Nov 2025 09:46:11 -0500 Subject: [PATCH 2/3] update tests for changes --- server/mock/datastore_mock.go | 12 ++ server/service/devices_test.go | 68 ++++------- server/service/integration_enterprise_test.go | 113 +++++++++++++++--- .../service/integration_vpp_install_test.go | 29 +++-- 4 files changed, 147 insertions(+), 75 deletions(-) diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index e84c32dbc6d..9292891b54a 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1569,6 +1569,8 @@ type GetHostIdentityCertByNameFunc func(ctx context.Context, name string) (*type type UpdateHostIdentityCertHostIDBySerialFunc func(ctx context.Context, serialNumber uint64, hostID uint) error +type GetMDMSCEPCertBySerialFunc func(ctx context.Context, serialNumber uint64) (string, error) + type NewCertificateAuthorityFunc func(ctx context.Context, ca *fleet.CertificateAuthority) (*fleet.CertificateAuthority, error) type GetCertificateAuthorityByIDFunc func(ctx context.Context, id uint, includeSecrets bool) (*fleet.CertificateAuthority, error) @@ -3909,6 +3911,9 @@ type DataStore struct { UpdateHostIdentityCertHostIDBySerialFunc UpdateHostIdentityCertHostIDBySerialFunc UpdateHostIdentityCertHostIDBySerialFuncInvoked bool + GetMDMSCEPCertBySerialFunc GetMDMSCEPCertBySerialFunc + GetMDMSCEPCertBySerialFuncInvoked bool + NewCertificateAuthorityFunc NewCertificateAuthorityFunc NewCertificateAuthorityFuncInvoked bool @@ -9353,6 +9358,13 @@ func (s *DataStore) UpdateHostIdentityCertHostIDBySerial(ctx context.Context, se return s.UpdateHostIdentityCertHostIDBySerialFunc(ctx, serialNumber, hostID) } +func (s *DataStore) GetMDMSCEPCertBySerial(ctx context.Context, serialNumber uint64) (string, error) { + s.mu.Lock() + s.GetMDMSCEPCertBySerialFuncInvoked = true + s.mu.Unlock() + return s.GetMDMSCEPCertBySerialFunc(ctx, serialNumber) +} + func (s *DataStore) NewCertificateAuthority(ctx context.Context, ca *fleet.CertificateAuthority) (*fleet.CertificateAuthority, error) { s.mu.Lock() s.NewCertificateAuthorityFuncInvoked = true diff --git a/server/service/devices_test.go b/server/service/devices_test.go index ac25d9087a0..bc9ddeac0a8 100644 --- a/server/service/devices_test.go +++ b/server/service/devices_test.go @@ -8,7 +8,6 @@ import ( "testing" "time" - "github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/types" "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mock" @@ -625,13 +624,9 @@ func TestAuthenticateDeviceByCertificate(t *testing.T) { return &fleet.AppConfig{}, nil } - ds.GetHostIdentityCertBySerialNumberFunc = func(ctx context.Context, serialNumber uint64) (*types.HostIdentityCertificate, error) { + ds.GetMDMSCEPCertBySerialFunc = func(ctx context.Context, serialNumber uint64) (string, error) { require.Equal(t, certSerial, serialNumber) - return &types.HostIdentityCertificate{ - SerialNumber: certSerial, - CommonName: hostUUID, - HostID: ptr.Uint(1), - }, nil + return hostUUID, nil } ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { @@ -662,12 +657,9 @@ func TestAuthenticateDeviceByCertificate(t *testing.T) { return &fleet.AppConfig{}, nil } - ds.GetHostIdentityCertBySerialNumberFunc = func(ctx context.Context, serialNumber uint64) (*types.HostIdentityCertificate, error) { - return &types.HostIdentityCertificate{ - SerialNumber: certSerial, - CommonName: hostUUID, - HostID: ptr.Uint(2), - }, nil + ds.GetMDMSCEPCertBySerialFunc = func(ctx context.Context, serialNumber uint64) (string, error) { + require.Equal(t, certSerial, serialNumber) + return hostUUID, nil } ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { @@ -715,8 +707,8 @@ func TestAuthenticateDeviceByCertificate(t *testing.T) { svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{SkipCreateTestUsers: true}) certSerial := uint64(99999) - ds.GetHostIdentityCertBySerialNumberFunc = func(ctx context.Context, serialNumber uint64) (*types.HostIdentityCertificate, error) { - return nil, &mock.Error{Message: "certificate not found"} + ds.GetMDMSCEPCertBySerialFunc = func(ctx context.Context, serialNumber uint64) (string, error) { + return "", &mock.Error{Message: "certificate not found"} } host, debug, err := svc.AuthenticateDeviceByCertificate(ctx, certSerial, "test-uuid") @@ -727,19 +719,15 @@ func TestAuthenticateDeviceByCertificate(t *testing.T) { require.ErrorAs(t, err, &authErr) }) - t.Run("error - certificate CN does not match UUID", func(t *testing.T) { + t.Run("error - device UUID mismatch", func(t *testing.T) { ds := new(mock.Store) svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{SkipCreateTestUsers: true}) certSerial := uint64(12345) hostUUID := "test-uuid" - ds.GetHostIdentityCertBySerialNumberFunc = func(ctx context.Context, serialNumber uint64) (*types.HostIdentityCertificate, error) { - return &types.HostIdentityCertificate{ - SerialNumber: certSerial, - CommonName: "different-uuid", - HostID: ptr.Uint(1), - }, nil + ds.GetMDMSCEPCertBySerialFunc = func(ctx context.Context, serialNumber uint64) (string, error) { + return "different-uuid", nil } host, debug, err := svc.AuthenticateDeviceByCertificate(ctx, certSerial, hostUUID) @@ -757,12 +745,8 @@ func TestAuthenticateDeviceByCertificate(t *testing.T) { certSerial := uint64(12345) hostUUID := "nonexistent-uuid" - ds.GetHostIdentityCertBySerialNumberFunc = func(ctx context.Context, serialNumber uint64) (*types.HostIdentityCertificate, error) { - return &types.HostIdentityCertificate{ - SerialNumber: certSerial, - CommonName: hostUUID, - HostID: ptr.Uint(1), - }, nil + ds.GetMDMSCEPCertBySerialFunc = func(ctx context.Context, serialNumber uint64) (string, error) { + return hostUUID, nil } ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { @@ -784,12 +768,8 @@ func TestAuthenticateDeviceByCertificate(t *testing.T) { certSerial := uint64(12345) hostUUID := "test-uuid-macos" - ds.GetHostIdentityCertBySerialNumberFunc = func(ctx context.Context, serialNumber uint64) (*types.HostIdentityCertificate, error) { - return &types.HostIdentityCertificate{ - SerialNumber: certSerial, - CommonName: hostUUID, - HostID: ptr.Uint(1), - }, nil + ds.GetMDMSCEPCertBySerialFunc = func(ctx context.Context, serialNumber uint64) (string, error) { + return hostUUID, nil } ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { @@ -815,12 +795,8 @@ func TestAuthenticateDeviceByCertificate(t *testing.T) { certSerial := uint64(12345) hostUUID := "test-uuid-windows" - ds.GetHostIdentityCertBySerialNumberFunc = func(ctx context.Context, serialNumber uint64) (*types.HostIdentityCertificate, error) { - return &types.HostIdentityCertificate{ - SerialNumber: certSerial, - CommonName: hostUUID, - HostID: ptr.Uint(1), - }, nil + ds.GetMDMSCEPCertBySerialFunc = func(ctx context.Context, serialNumber uint64) (string, error) { + return hostUUID, nil } ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { @@ -844,8 +820,8 @@ func TestAuthenticateDeviceByCertificate(t *testing.T) { svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{SkipCreateTestUsers: true}) certSerial := uint64(12345) - ds.GetHostIdentityCertBySerialNumberFunc = func(ctx context.Context, serialNumber uint64) (*types.HostIdentityCertificate, error) { - return nil, errors.New("database connection error") + ds.GetMDMSCEPCertBySerialFunc = func(ctx context.Context, serialNumber uint64) (string, error) { + return "", errors.New("database connection error") } host, debug, err := svc.AuthenticateDeviceByCertificate(ctx, certSerial, "test-uuid") @@ -862,12 +838,8 @@ func TestAuthenticateDeviceByCertificate(t *testing.T) { certSerial := uint64(12345) hostUUID := "test-uuid" - ds.GetHostIdentityCertBySerialNumberFunc = func(ctx context.Context, serialNumber uint64) (*types.HostIdentityCertificate, error) { - return &types.HostIdentityCertificate{ - SerialNumber: certSerial, - CommonName: hostUUID, - HostID: ptr.Uint(1), - }, nil + ds.GetMDMSCEPCertBySerialFunc = func(ctx context.Context, serialNumber uint64) (string, error) { + return hostUUID, nil } ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 66a01a2cd94..dc427dc3a81 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -4,13 +4,19 @@ import ( "bytes" "cmp" "context" + "crypto/rand" + "crypto/rsa" "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" "database/sql" "encoding/hex" "encoding/json" + "encoding/pem" "errors" "fmt" "io" + "math/big" "mime/multipart" "net/http" "net/http/httptest" @@ -21755,6 +21761,43 @@ FqU+KJOed6qlzj7qy+u5l6CQeajLGdjUxFlFyw== ) } +// generateTestCertForDeviceAuth generates a test certificate for device authentication. +// Returns: certPEM, certHash (SHA256 of DER bytes), parsed certificate +func generateTestCertForDeviceAuth(t *testing.T, certSerial uint64, deviceUUID string) (string, string, *x509.Certificate) { + // Generate a private key + priv, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + // Create certificate template + serialNumber := new(big.Int).SetUint64(certSerial) + certTemplate := &x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + CommonName: deviceUUID, + }, + NotBefore: time.Now().Add(-24 * time.Hour), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + + // Create self-signed certificate + certDER, err := x509.CreateCertificate(rand.Reader, certTemplate, certTemplate, &priv.PublicKey, priv) + require.NoError(t, err) + + // Parse the certificate to get cert.Raw for hashing + cert, err := x509.ParseCertificate(certDER) + require.NoError(t, err) + + // Calculate SHA256 hash of certificate DER bytes (same as nanomdm) + hashed := sha256.Sum256(cert.Raw) + certHash := hex.EncodeToString(hashed[:]) + + // Encode to PEM + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + return string(certPEM), certHash, cert +} + func (s *integrationEnterpriseTestSuite) TestDeviceCertificateAuthentication() { t := s.T() ctx := context.Background() @@ -21794,27 +21837,45 @@ func (s *integrationEnterpriseTestSuite) TestDeviceCertificateAuthentication() { return err }) - // Insert a host identity certificate for the iOS host + // Generate test certificate for the iOS host certSerial := uint64(123456789) + certPEM, certHash, cert := generateTestCertForDeviceAuth(t, certSerial, iosHost.UUID) + + // Insert certificate into nanomdm tables mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error { - _, err := db.ExecContext(ctx, `INSERT INTO host_identity_scep_serials (serial) VALUES (?)`, certSerial) + // Insert serial (scep_serials uses auto-increment but we can insert explicit value) + _, err := db.ExecContext(ctx, `INSERT INTO scep_serials (serial) VALUES (?)`, certSerial) if err != nil { return err } + + // Insert certificate into scep_certificates _, err = db.ExecContext(ctx, ` - INSERT INTO host_identity_scep_certificates - (serial, host_id, name, not_valid_before, not_valid_after, certificate_pem, public_key_raw, revoked) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO scep_certificates + (serial, name, not_valid_before, not_valid_after, certificate_pem, revoked) + VALUES (?, ?, ?, ?, ?, ?) `, certSerial, - iosHost.ID, iosHost.UUID, - time.Now().Add(-24*time.Hour), - time.Now().Add(365*24*time.Hour), - "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", - []byte{0x04}, + cert.NotBefore, + cert.NotAfter, + certPEM, false, ) + if err != nil { + return err + } + + // Insert certificate association into nano_cert_auth_associations + _, err = db.ExecContext(ctx, ` + INSERT INTO nano_cert_auth_associations + (id, sha256, cert_not_valid_after) + VALUES (?, ?, ?) + `, + iosHost.UUID, + certHash, + cert.NotAfter, + ) return err }) @@ -21887,25 +21948,39 @@ func (s *integrationEnterpriseTestSuite) TestDeviceCertificateAuthentication() { require.NoError(t, err) ipadCertSerial := uint64(987654321) + ipadCertPEM, ipadCertHash, ipadCert := generateTestCertForDeviceAuth(t, ipadCertSerial, ipadHost.UUID) + mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error { - _, err := db.ExecContext(ctx, `INSERT INTO host_identity_scep_serials (serial) VALUES (?)`, ipadCertSerial) + _, err := db.ExecContext(ctx, `INSERT INTO scep_serials (serial) VALUES (?)`, ipadCertSerial) if err != nil { return err } + _, err = db.ExecContext(ctx, ` - INSERT INTO host_identity_scep_certificates - (serial, host_id, name, not_valid_before, not_valid_after, certificate_pem, public_key_raw, revoked) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO scep_certificates + (serial, name, not_valid_before, not_valid_after, certificate_pem, revoked) + VALUES (?, ?, ?, ?, ?, ?) `, ipadCertSerial, - ipadHost.ID, ipadHost.UUID, - time.Now().Add(-24*time.Hour), - time.Now().Add(365*24*time.Hour), - "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", - []byte{0x04}, + ipadCert.NotBefore, + ipadCert.NotAfter, + ipadCertPEM, false, ) + if err != nil { + return err + } + + _, err = db.ExecContext(ctx, ` + INSERT INTO nano_cert_auth_associations + (id, sha256, cert_not_valid_after) + VALUES (?, ?, ?) + `, + ipadHost.UUID, + ipadCertHash, + ipadCert.NotAfter, + ) return err }) diff --git a/server/service/integration_vpp_install_test.go b/server/service/integration_vpp_install_test.go index d7d39c775f0..746cd4a3450 100644 --- a/server/service/integration_vpp_install_test.go +++ b/server/service/integration_vpp_install_test.go @@ -1712,26 +1712,39 @@ func (s *integrationMDMTestSuite) addHostIdentityCertificate(hostID uint, hostUU s.setSkipWorkerJobs(t) ctx := context.Background() - // Insert a host identity certificate for the iOS host + // Generate a real certificate for the device with proper SHA256 hash + certPEM, certHash, _ := generateTestCertForDeviceAuth(t, certSerial, hostUUID) + + // Insert certificate data using the new nanomdm tables mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error { - _, err := db.ExecContext(ctx, `INSERT INTO host_identity_scep_serials (serial) VALUES (?)`, certSerial) + // Insert serial number + _, err := db.ExecContext(ctx, `INSERT INTO scep_serials (serial) VALUES (?)`, certSerial) if err != nil { return err } + + // Insert certificate _, err = db.ExecContext(ctx, ` - INSERT INTO host_identity_scep_certificates - (serial, host_id, name, not_valid_before, not_valid_after, certificate_pem, public_key_raw, revoked) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO scep_certificates + (serial, name, not_valid_before, not_valid_after, certificate_pem, revoked) + VALUES (?, ?, ?, ?, ?, ?) `, certSerial, - hostID, hostUUID, time.Now().Add(-24*time.Hour), time.Now().Add(365*24*time.Hour), - "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", - []byte{0x04}, + certPEM, false, ) + if err != nil { + return err + } + + // Insert certificate association for device authentication + _, err = db.ExecContext(ctx, ` + INSERT INTO nano_cert_auth_associations (id, sha256) + VALUES (?, ?) + `, hostUUID, certHash) return err }) } From e9e881d6ed40fd4e9aa22ff3bbf85356fc23ac9a Mon Sep 17 00:00:00 2001 From: Carlo DiCelico Date: Fri, 7 Nov 2025 10:02:59 -0500 Subject: [PATCH 3/3] placate linter --- server/datastore/mysql/host_identity_scep.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/datastore/mysql/host_identity_scep.go b/server/datastore/mysql/host_identity_scep.go index 3c4c2a88f3b..1bf5f003946 100644 --- a/server/datastore/mysql/host_identity_scep.go +++ b/server/datastore/mysql/host_identity_scep.go @@ -93,7 +93,7 @@ func (ds *Datastore) GetMDMSCEPCertBySerial(ctx context.Context, serialNumber ui // The hash is calculated from cert.Raw (DER-encoded bytes), not the PEM string block, _ := pem.Decode([]byte(certPEM)) if block == nil { - return "", fmt.Errorf("failed to decode PEM certificate") + return "", errors.New("failed to decode PEM certificate") } cert, err := x509.ParseCertificate(block.Bytes) if err != nil {