From 34c45b256f54298aae0fc7bd70c68cd6a99faf7a Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Wed, 30 Jul 2025 16:46:36 +0200 Subject: [PATCH] Host identity cert renewal (#31372) For #30476 Contributor doc updates: https://github.com/fleetdm/fleet/pull/31371 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. ## Testing - [x] Added/updated automated tests - [x] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually ## fleetd/orbit/Fleet Desktop - [x] Verified compatibility with the latest released version of Fleet (see [Must rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md)) - [x] If the change applies to only one platform, confirmed that `runtime.GOOS` is used as needed to isolate changes - [x] Verified that fleetd runs on macOS, Linux and Windows - [x] Verified auto-update works from the released version of component to the new version (see [tools/tuf/test](../tools/tuf/test/README.md)) ## Summary by CodeRabbit * **New Features** * Automated certificate renewal is now supported, including proof-of-possession for enhanced security. * Certificate renewal can be triggered when the existing certificate is within 180 days of expiration. * Dynamic configuration of certificate validity period via environment variable. * Improved TPM hardware integration for certificate management. * **Bug Fixes** * Enhanced error handling and logging for TPM device closure and certificate operations. * **Tests** * Extended integration tests to cover certificate renewal flows, host deletion, and TPM-based scenarios for improved reliability. --- changes/31372-host-identity-cert-renewal | 1 + ee/orbit/pkg/hostidentity/host_identity.go | 218 ++++++- ee/orbit/pkg/scep/scep.go | 11 + ee/orbit/pkg/securehw/securehw_linux.go | 7 +- ee/orbit/pkg/securehw/securehw_tpm.go | 44 +- .../hostidentity/hostidentity_test.go | 544 ++++++++++++++++-- ee/server/service/hostidentity/scep.go | 125 +++- .../types/host_identity_certificates.go | 12 + .../changes/31372-host-identity-cert-renewal | 1 + orbit/cmd/orbit/orbit.go | 8 +- orbit/pkg/constant/constant.go | 4 + 11 files changed, 884 insertions(+), 91 deletions(-) create mode 100644 changes/31372-host-identity-cert-renewal create mode 100644 orbit/changes/31372-host-identity-cert-renewal diff --git a/changes/31372-host-identity-cert-renewal b/changes/31372-host-identity-cert-renewal new file mode 100644 index 0000000000..d2b9835cb0 --- /dev/null +++ b/changes/31372-host-identity-cert-renewal @@ -0,0 +1 @@ +* Added host identity certificate renewal support for TPM-backed certificates (Linux-only). When a certificate is within 180 days of expiration, orbit will automatically renew it using proof-of-possession with the existing certificate's private key. diff --git a/ee/orbit/pkg/hostidentity/host_identity.go b/ee/orbit/pkg/hostidentity/host_identity.go index e3c1b4e24f..19ab427d33 100644 --- a/ee/orbit/pkg/hostidentity/host_identity.go +++ b/ee/orbit/pkg/hostidentity/host_identity.go @@ -2,19 +2,33 @@ package hostidentity import ( "context" + "crypto" + "crypto/rand" + "crypto/sha256" "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" "encoding/pem" "errors" "fmt" "os" "path/filepath" + "time" "github.com/fleetdm/fleet/v4/ee/orbit/pkg/scep" "github.com/fleetdm/fleet/v4/ee/orbit/pkg/securehw" + "github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/types" "github.com/fleetdm/fleet/v4/orbit/pkg/constant" "github.com/rs/zerolog" ) +const ( + // certificateRenewalThreshold is the time before certificate expiration + // when renewal should be initiated (180 days) + certificateRenewalThreshold = 180 * 24 * time.Hour +) + // Credentials holds a certificate and its corresponding private key handle stored in secure hardware. type Credentials struct { // Certificate holds the public certificate issued via SCEP. @@ -25,18 +39,20 @@ type Credentials struct { // CertificatePath is the file path to the public certificate issued via SCEP. CertificatePath string - secureHW securehw.SecureHW + SecureHW securehw.SecureHW } // Close releases key resources. func (c *Credentials) Close() { - c.secureHW.Close() + c.SecureHW.Close() } // Setup creates a private key using a SecureHW and generates a new client // certificate using SCEP. // If there's already a key and certificate in the metadata directory it will return them. // The returned Credentials needs to be closed after its use. +// The restartFunc will be called to trigger an Orbit restart for certificate renewal +// if the certificate is close to expiration. func Setup( ctx context.Context, metadataDir string, @@ -46,12 +62,17 @@ func Setup( rootCA string, insecure bool, logger zerolog.Logger, + restartFunc func(reason string), ) (*Credentials, error) { - teeDevice, err := securehw.New(metadataDir, logger) + secureHWDevice, err := securehw.New(metadataDir, logger) if err != nil { return nil, fmt.Errorf("failed to initialize secure hardware device: %w", err) } - secureHWKey, err := teeDevice.LoadKey() + credentials := &Credentials{ + CertificatePath: filepath.Join(metadataDir, constant.FleetHTTPSignatureCertificateFileName), + SecureHW: secureHWDevice, + } + credentials.SecureHWKey, err = secureHWDevice.LoadKey() switch { case err == nil: // OK @@ -66,7 +87,7 @@ func Setup( return nil, fmt.Errorf("failed to clear the host identity certificate: %w", err) } - secureHWKey, err = teeDevice.CreateKey() + credentials.SecureHWKey, err = secureHWDevice.CreateKey() if err != nil { return nil, fmt.Errorf("failed to create secure hardware key: %w", err) } @@ -76,13 +97,24 @@ func Setup( clientCert, err := loadSCEPClientCert(metadataDir) switch { - case err == nil: - // OK, we have a certificate already, let's use it. + case err == nil && certNeedsRenewal(clientCert, certificateRenewalThreshold): + logger.Info().Msg("Certificate expires within 180 days, initiating renewal") + + // Perform certificate renewal + credentials.Certificate = clientCert + renewedCert, err := RenewCertificate(ctx, metadataDir, credentials, scepURL, rootCA, insecure, logger) + if err != nil { + // This error can occur when Fleet server is offline. We will continue and schedule another renewal attempt in the future. + logger.Error().Err(err).Msg("Certificate renewal failed, continuing with existing certificate") + } else { + clientCert = renewedCert + logger.Info().Msg("Certificate renewal completed successfully") + } case errors.Is(err, os.ErrNotExist): // We don't have a certificate, let's issue one using SCEP. opts := []scep.Option{ scep.WithRootCA(rootCA), - scep.WithSigningKey(secureHWKey), + scep.WithSigningKey(credentials.SecureHWKey), scep.WithLogger(logger), scep.WithURL(scepURL), scep.WithChallenge(scepChallenge), @@ -102,14 +134,17 @@ func Setup( if err := saveSCEPClientCert(metadataDir, clientCert); err != nil { return nil, fmt.Errorf("failed to save certificate: %w", err) } + case err != nil: + return nil, fmt.Errorf("failed to load host identity certificate: %w", err) } + credentials.Certificate = clientCert // Sanity check in case the public key material on the secure HW // does not match the certificate public key. // This can happen if something or someone deletes the private and public blobs // and they are re-generated at startup. - secureHWPubKey, err := secureHWKey.Public() + secureHWPubKey, err := credentials.SecureHWKey.Public() if err != nil { return nil, fmt.Errorf("error getting public key from secure HW key: %w", err) } @@ -128,13 +163,33 @@ func Setup( } logger.Debug().Msg("secure HW key matches certificate public key") - return &Credentials{ - Certificate: clientCert, - SecureHWKey: secureHWKey, - CertificatePath: filepath.Join(metadataDir, constant.FleetHTTPSignatureCertificateFileName), + // Start a goroutine with a timer to trigger restart for certificate renewal + if restartFunc != nil { + go func() { + // Calculate time until certificate expires + timeUntilExpiry := time.Until(clientCert.NotAfter) - secureHW: teeDevice, - }, nil + // Set timer for 180 days before expiry (plus 1 minute buffer) + // or 1 hour, whichever is longer + renewalTime := timeUntilExpiry - certificateRenewalThreshold + 1*time.Minute + if renewalTime < 1*time.Hour { + renewalTime = 1 * time.Hour + } + + logger.Info(). + Dur("renewal_in", renewalTime). + Time("cert_expires", clientCert.NotAfter). + Msg("Scheduling host identity certificate renewal timer") + + timer := time.NewTimer(renewalTime) + <-timer.C + + logger.Info().Msg("Certificate renewal timer triggered") + restartFunc("host identity certificate renewal") + }() + } + + return credentials, nil } func loadSCEPClientCert(metadataDir string) (*x509.Certificate, error) { @@ -165,3 +220,136 @@ func saveSCEPClientCert(metadataDir string, cert *x509.Certificate) error { } return nil } + +// certNeedsRenewal checks if the certificate expires within the given duration +func certNeedsRenewal(cert *x509.Certificate, renewalThreshold time.Duration) bool { + return time.Until(cert.NotAfter) < renewalThreshold +} + +// RenewCertificate performs certificate renewal with proof-of-possession +func RenewCertificate( + ctx context.Context, + metadataDir string, + credentials *Credentials, + scepURL string, + rootCA string, + insecure bool, + logger zerolog.Logger, +) (*x509.Certificate, error) { + // First, backup the existing key file + keyPath := filepath.Join(metadataDir, constant.FleetHTTPSignatureTPMKeyFileName) + oldKeyPath := filepath.Join(metadataDir, constant.FleetHTTPSignatureTPMKeyBackupFileName) + + if _, err := os.Stat(keyPath); err != nil { + return nil, fmt.Errorf("failed to find existing TPM key: %w", err) + } + + // Clean up any existing old key file + if err := os.RemoveAll(oldKeyPath); err != nil { + return nil, fmt.Errorf("failed to clean up existing old key: %w", err) + } + + // Backup the current key + if err := os.Rename(keyPath, oldKeyPath); err != nil { + return nil, fmt.Errorf("failed to backup existing key: %w", err) + } + + // Ensure we restore the backup if something goes wrong, like we cannot connect to Fleet server to get a cert + defer func() { + if _, err := os.Stat(oldKeyPath); err == nil { + _ = os.Rename(oldKeyPath, keyPath) + } + }() + + // Create new key (this will create it at the standard path) + newKey, err := credentials.SecureHW.CreateKey() + if err != nil { + return nil, fmt.Errorf("failed to create renewal key: %w", err) + } + + // Get the old key's signer for proof-of-possession + oldSigner, err := credentials.SecureHWKey.Signer() + if err != nil { + return nil, fmt.Errorf("failed to get signer from old key: %w", err) + } + + // Create renewal data with proof-of-possession + serialHex := fmt.Sprintf("0x%x", credentials.Certificate.SerialNumber.Bytes()) + hash := sha256.Sum256([]byte(serialHex)) + signature, err := oldSigner.Sign(rand.Reader, hash[:], crypto.SHA256) + if err != nil { + return nil, fmt.Errorf("failed to sign renewal data: %w", err) + } + + renewalData := types.RenewalData{ + SerialNumber: serialHex, + Signature: base64.StdEncoding.EncodeToString(signature), + } + + renewalDataJSON, err := json.Marshal(renewalData) + if err != nil { + return nil, fmt.Errorf("failed to marshal renewal data: %w", err) + } + + // Create SCEP client with custom CSR that includes the renewal extension + renewedCert, err := fetchCertWithRenewal(ctx, newKey, scepURL, credentials.Certificate.Subject.CommonName, rootCA, insecure, renewalDataJSON, logger) + if err != nil { + return nil, fmt.Errorf("failed to fetch renewed certificate: %w", err) + } + + // Save the renewed certificate + if err := saveSCEPClientCert(metadataDir, renewedCert); err != nil { + return nil, fmt.Errorf("failed to save renewed certificate: %w", err) + } + + // Remove the old key backup now that renewal was successful + if err := os.Remove(oldKeyPath); err != nil { + return nil, fmt.Errorf("failed to remove old key backup: %w", err) + } + + // Close the old TPM key since it will no longer be used. + _ = credentials.SecureHWKey.Close() + + credentials.SecureHWKey = newKey + + return renewedCert, nil +} + +// fetchCertWithRenewal performs SCEP certificate fetch with renewal extension +func fetchCertWithRenewal( + ctx context.Context, + signingKey securehw.Key, + scepURL string, + commonName string, + rootCA string, + insecure bool, + renewalDataJSON []byte, + logger zerolog.Logger, +) (*x509.Certificate, error) { + // Create the renewal extension + renewalExtension := pkix.Extension{ + Id: types.RenewalExtensionOID, + Value: renewalDataJSON, + } + + // Create SCEP client with the renewal extension + opts := []scep.Option{ + scep.WithRootCA(rootCA), + scep.WithSigningKey(signingKey), + scep.WithLogger(logger), + scep.WithURL(scepURL), + scep.WithCommonName(commonName), + scep.WithExtraExtensions([]pkix.Extension{renewalExtension}), + } + if insecure { + opts = append(opts, scep.Insecure()) + } + + scepClient, err := scep.NewClient(opts...) + if err != nil { + return nil, fmt.Errorf("failed to create SCEP client: %w", err) + } + + // Fetch the certificate with the renewal extension in the CSR + return scepClient.FetchCert(ctx) +} diff --git a/ee/orbit/pkg/scep/scep.go b/ee/orbit/pkg/scep/scep.go index 2c15bb0724..d33580d9fc 100644 --- a/ee/orbit/pkg/scep/scep.go +++ b/ee/orbit/pkg/scep/scep.go @@ -42,6 +42,9 @@ type Client struct { insecure bool rootCA string + + // extraExtensions allows adding custom extensions to the CSR + extraExtensions []pkix.Extension } // Option is a functional option for configuring a SCEP Client @@ -104,6 +107,13 @@ func Insecure() Option { } } +// WithExtraExtensions adds custom extensions to the CSR +func WithExtraExtensions(extensions []pkix.Extension) Option { + return func(c *Client) { + c.extraExtensions = extensions + } +} + // NewClient creates a new SCEP client with the provided options func NewClient(opts ...Option) (*Client, error) { // Create client with default options @@ -179,6 +189,7 @@ func (c *Client) FetchCert(ctx context.Context) (*x509.Certificate, error) { }, // Currently, signer.Public() will always be of type *ecdsa.PublicKey. SignatureAlgorithm: x509.ECDSAWithSHA256, + ExtraExtensions: c.extraExtensions, }, ChallengePassword: c.scepChallenge, } diff --git a/ee/orbit/pkg/securehw/securehw_linux.go b/ee/orbit/pkg/securehw/securehw_linux.go index fe6b038ebc..5d8b5ff4c5 100644 --- a/ee/orbit/pkg/securehw/securehw_linux.go +++ b/ee/orbit/pkg/securehw/securehw_linux.go @@ -7,6 +7,7 @@ import ( "fmt" "path/filepath" + "github.com/fleetdm/fleet/v4/orbit/pkg/constant" "github.com/google/go-tpm/tpm2/transport/linuxtpm" "github.com/rs/zerolog" ) @@ -20,7 +21,7 @@ func newSecureHW(metadataDir string, logger zerolog.Logger) (SecureHW, error) { return nil, errors.New("required metadata directory not set") } - logger.Info().Msg("initializing TPM 2.0 connection") + logger.Info().Msg("opening TPM 2.0 resource manager") // Open the TPM 2.0 resource manager, which // - Provides managed access to TPM resources, allowing multiple applications to share the TPM safely. @@ -32,11 +33,11 @@ func newSecureHW(metadataDir string, logger zerolog.Logger) (SecureHW, error) { } } - logger.Info().Str("device_path", tpm20DevicePath).Msg("successfully opened TPM 2.0 device") + logger.Info().Str("device_path", tpm20DevicePath).Msg("successfully opened TPM 2.0 resource manager") return &tpm2SecureHW{ device: device, logger: logger.With().Str("component", "securehw-tpm").Logger(), - keyFilePath: filepath.Join(metadataDir, "host_identity_tpm.pem"), + keyFilePath: filepath.Join(metadataDir, constant.FleetHTTPSignatureTPMKeyFileName), }, nil } diff --git a/ee/orbit/pkg/securehw/securehw_tpm.go b/ee/orbit/pkg/securehw/securehw_tpm.go index 8d00f1ba48..8a2b4087d6 100644 --- a/ee/orbit/pkg/securehw/securehw_tpm.go +++ b/ee/orbit/pkg/securehw/securehw_tpm.go @@ -11,7 +11,9 @@ import ( "math/big" "os" "path/filepath" + "strings" + "github.com/fleetdm/fleet/v4/orbit/pkg/constant" keyfile "github.com/foxboron/go-tpm-keyfiles" "github.com/google/go-tpm/tpm2" "github.com/google/go-tpm/tpm2/transport" @@ -46,7 +48,7 @@ func NewTestSecureHW(device transport.TPMCloser, metadataDir string, logger zero return &tpm2SecureHW{ device: device, logger: logger.With().Str("component", "securehw-test").Logger(), - keyFilePath: filepath.Join(metadataDir, "host_identity_tpm_test.pem"), + keyFilePath: filepath.Join(metadataDir, constant.FleetHTTPSignatureTPMKeyFileName), }, nil } @@ -93,6 +95,8 @@ func (t *tpm2SecureHW) CreateKey() (Key, error) { InPublic: eccTemplate, }.Execute(t.device) if err != nil { + // Flush the parent key before returning error + t.flushHandle(parentKeyHandle.Handle, "parent") return nil, fmt.Errorf("create child key: %w", err) } @@ -103,18 +107,20 @@ func (t *tpm2SecureHW) CreateKey() (Key, error) { InPublic: createKey.OutPublic, }.Execute(t.device) if err != nil { + // Flush the parent key before returning error + t.flushHandle(parentKeyHandle.Handle, "parent") return nil, fmt.Errorf("load key: %w", err) } + // Flush the parent key as it's no longer needed + t.flushHandle(parentKeyHandle.Handle, "parent") + t.logger.Debug(). Str("handle", fmt.Sprintf("0x%x", loadedKey.ObjectHandle)). Msg("key loaded successfully") cleanUpOnError := func() { - flush := tpm2.FlushContext{ - FlushHandle: loadedKey.ObjectHandle, - } - _, _ = flush.Execute(t.device) + t.flushHandle(loadedKey.ObjectHandle, "child") } t.logger.Info(). @@ -229,10 +235,7 @@ func (t *tpm2SecureHW) selectBestECCCurve() (tpm2.TPMECCCurve, string) { } // Clean up the test key - flush := tpm2.FlushContext{ - FlushHandle: testKey.ObjectHandle, - } - _, _ = flush.Execute(t.device) + t.flushHandle(testKey.ObjectHandle, "test") t.logger.Debug().Msg("TPM supports P-384") return tpm2.TPMECCNistP384, "P-384" @@ -290,12 +293,13 @@ func (t *tpm2SecureHW) LoadKey() (Key, error) { InPublic: *public, }.Execute(t.device) if err != nil { + // Flush the parent key before returning error + t.flushHandle(parentKeyHandle.Handle, "parent") return nil, fmt.Errorf("load parent key: %w", err) } - t.logger.Debug(). - Str("handle", fmt.Sprintf("0x%x", loadedKey.ObjectHandle)). - Msg("key loaded successfully") + // Flush the parent key as it's no longer needed + t.flushHandle(parentKeyHandle.Handle, "parent") t.logger.Info(). Str("handle", fmt.Sprintf("0x%x", loadedKey.ObjectHandle)). @@ -312,12 +316,28 @@ func (t *tpm2SecureHW) LoadKey() (Key, error) { }, nil } +// flushHandle flushes a TPM handle, logging any errors but not returning them +func (t *tpm2SecureHW) flushHandle(handle tpm2.TPMHandle, handleType string) { + flush := tpm2.FlushContext{ + FlushHandle: handle, + } + if _, err := flush.Execute(t.device); err != nil { + t.logger.Warn().Err(err).Str("handle_type", handleType).Msg("failed to flush TPM handle") + } +} + // Close partially implements SecureHW. func (t *tpm2SecureHW) Close() error { t.logger.Info().Msg("closing TPM device") if t.device != nil { err := t.device.Close() if err != nil { + // Check if it's an already closed error + if strings.Contains(err.Error(), "already closed") || strings.Contains(err.Error(), "use of closed") { + t.logger.Debug().Msg("TPM device was already closed") + t.device = nil + return nil + } t.logger.Error().Err(err).Msg("error closing TPM device") return err } diff --git a/ee/server/integrationtest/hostidentity/hostidentity_test.go b/ee/server/integrationtest/hostidentity/hostidentity_test.go index 900ef7e0d2..c7d20cd756 100644 --- a/ee/server/integrationtest/hostidentity/hostidentity_test.go +++ b/ee/server/integrationtest/hostidentity/hostidentity_test.go @@ -10,21 +10,29 @@ import ( "crypto/elliptic" "crypto/rand" "crypto/rsa" + "crypto/sha256" "crypto/x509" "crypto/x509/pkix" + "encoding/base64" "encoding/json" + "encoding/pem" "fmt" mathrand "math/rand/v2" "net/http" "os" + "path/filepath" "strings" "testing" "time" + "github.com/fleetdm/fleet/v4/ee/orbit/pkg/hostidentity" orbitscep "github.com/fleetdm/fleet/v4/ee/orbit/pkg/scep" "github.com/fleetdm/fleet/v4/ee/orbit/pkg/securehw" + "github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/types" + "github.com/fleetdm/fleet/v4/orbit/pkg/constant" "github.com/fleetdm/fleet/v4/pkg/fleethttp" "github.com/fleetdm/fleet/v4/pkg/fleethttpsig" + "github.com/fleetdm/fleet/v4/server/config" "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" @@ -41,7 +49,9 @@ import ( const testEnrollmentSecret = "test_secret" func TestHostIdentity(t *testing.T) { - s := SetUpSuite(t, "integrationtest.HostIdentity", false) + s := SetUpSuiteWithConfig(t, "integrationtest.HostIdentity", false, func(cfg *config.FleetConfig) { + cfg.Osquery.EnrollCooldown = 0 // Disable rate limiting for tests + }) cases := []struct { name string @@ -66,19 +76,25 @@ func testGetCertAndSignReq(t *testing.T, s *Suite) { t.Run("ECC P256, orbit", func(t *testing.T) { t.Parallel() cert, eccPrivateKey := testGetCertWithCurve(t, s, elliptic.P256()) - testOrbitEnrollment(t, s, cert, eccPrivateKey) + nodeKey := testOrbitEnrollment(t, s, cert, eccPrivateKey) + testCertificateRenewal(t, s, cert, eccPrivateKey, nodeKey, false) // false = orbit + testDeleteHostAndReenroll(t, s, cert, eccPrivateKey, nodeKey) }) t.Run("ECC P384, orbit", func(t *testing.T) { t.Parallel() cert, eccPrivateKey := testGetCertWithCurve(t, s, elliptic.P384()) - testOrbitEnrollment(t, s, cert, eccPrivateKey) + nodeKey := testOrbitEnrollment(t, s, cert, eccPrivateKey) + testCertificateRenewal(t, s, cert, eccPrivateKey, nodeKey, false) // false = orbit + testDeleteHostAndReenroll(t, s, cert, eccPrivateKey, nodeKey) }) t.Run("ECC P384, osquery", func(t *testing.T) { t.Parallel() cert, eccPrivateKey := testGetCertWithCurve(t, s, elliptic.P384()) - testOsqueryEnrollment(t, s, cert, eccPrivateKey) + nodeKey := testOsqueryEnrollment(t, s, cert, eccPrivateKey) + testCertificateRenewal(t, s, cert, eccPrivateKey, nodeKey, true) // true = osquery + testDeleteHostAndReenrollOsquery(t, s, cert, eccPrivateKey, nodeKey) }) } @@ -221,7 +237,7 @@ func createHTTPSigner(t *testing.T, eccPrivateKey *ecdsa.PrivateKey, cert *x509. return signer } -func testOrbitEnrollment(t *testing.T, s *Suite, cert *x509.Certificate, eccPrivateKey *ecdsa.PrivateKey) { +func testOrbitEnrollment(t *testing.T, s *Suite, cert *x509.Certificate, eccPrivateKey *ecdsa.PrivateKey) string { ctx := t.Context() // Test orbit enrollment with the certificate enrollRequest := contract.EnrollOrbitRequest{ @@ -375,38 +391,10 @@ func testOrbitEnrollment(t *testing.T, s *Suite, cert *x509.Certificate, eccPriv } }) - // Important: since this subtest deletes the host, it should run last. - // Test deleting host and trying to enroll with same certificate - t.Run("delete host and enroll with same certificate", func(t *testing.T) { - // Get the host using the orbit node key (standard pattern used in Fleet tests) - hostToDelete, err := s.DS.LoadHostByOrbitNodeKey(ctx, signedEnrollResp.OrbitNodeKey) - require.NoError(t, err) - require.NotNil(t, hostToDelete, "Should find the enrolled host") - - // Delete the host using the API endpoint - s.Do(t, "DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d", hostToDelete.ID), nil, http.StatusOK) - - // Try to enroll the same host with the same certificate - this should fail - // because deleting the host should have invalidated its certificate - req, err := http.NewRequest("POST", s.Server.URL+"/api/fleet/orbit/enroll", bytes.NewReader(reqBody)) - require.NoError(t, err) - req.Header.Set("Content-Type", "application/json") - - err = signer.Sign(req) - require.NoError(t, err) - - httpResp, err := client.Do(req) - require.NoError(t, err) - defer httpResp.Body.Close() - - // This should fail because the host certificate should be deleted when the host is deleted. - // The host needs to request a new cert to re-enroll. - require.Equal(t, http.StatusUnauthorized, httpResp.StatusCode, "Enrollment with deleted host certificate should fail") - }) + return signedEnrollResp.OrbitNodeKey } -func testOsqueryEnrollment(t *testing.T, s *Suite, cert *x509.Certificate, eccPrivateKey *ecdsa.PrivateKey) { - ctx := t.Context() +func testOsqueryEnrollment(t *testing.T, s *Suite, cert *x509.Certificate, eccPrivateKey *ecdsa.PrivateKey) string { // Test osquery enrollment with the certificate enrollRequest := contract.EnrollOsqueryAgentRequest{ EnrollSecret: testEnrollmentSecret, @@ -546,34 +534,334 @@ func testOsqueryEnrollment(t *testing.T, s *Suite, cert *x509.Certificate, eccPr } }) - // Important: since this subtest deletes the host, it should run last. - // Test deleting host and trying to enroll with same certificate - t.Run("delete host and enroll with same certificate", func(t *testing.T) { - // Get the host using the osquery node key (standard pattern used in Fleet tests) - hostToDelete, err := s.DS.LoadHostByNodeKey(ctx, enrollResp.NodeKey) + return enrollResp.NodeKey +} + +// testCertificateRenewal tests the SCEP certificate renewal flow with proof-of-possession +func testCertificateRenewal(t *testing.T, s *Suite, existingCert *x509.Certificate, eccPrivateKey *ecdsa.PrivateKey, nodeKey string, isOsquery bool) { + ctx := t.Context() + + // Get the original certificate's host_id before renewal (it will get revoked) + originalStoredCert, err := s.DS.GetHostIdentityCertBySerialNumber(ctx, existingCert.SerialNumber.Uint64()) + require.NoError(t, err) + require.NotNil(t, originalStoredCert) + require.NotNil(t, originalStoredCert.HostID, "Original certificate should have host_id") + originalHostID := *originalStoredCert.HostID + + // Generate a new ECC key pair for the renewed certificate + newEccPrivateKey, err := ecdsa.GenerateKey(eccPrivateKey.Curve, rand.Reader) + require.NoError(t, err) + + // Create the renewal data + serialHex := fmt.Sprintf("0x%x", existingCert.SerialNumber.Bytes()) + + // Sign the message with the existing private key + hash := sha256.Sum256([]byte(serialHex)) + signature, err := ecdsa.SignASN1(rand.Reader, eccPrivateKey, hash[:]) + require.NoError(t, err) + + renewalData := types.RenewalData{ + SerialNumber: serialHex, + Signature: base64.StdEncoding.EncodeToString(signature), + } + + renewalDataJSON, err := json.Marshal(renewalData) + require.NoError(t, err) + + // Create CSR with renewal extension + csrTemplate := x509util.CertificateRequest{ + CertificateRequest: x509.CertificateRequest{ + Subject: pkix.Name{ + CommonName: existingCert.Subject.CommonName, + }, + SignatureAlgorithm: x509.ECDSAWithSHA256, + ExtraExtensions: []pkix.Extension{ + { + Id: types.RenewalExtensionOID, + Value: renewalDataJSON, + }, + }, + }, + // No challenge password for renewal + } + + csrDerBytes, err := x509util.CreateCertificateRequest(rand.Reader, &csrTemplate, newEccPrivateKey) + require.NoError(t, err) + csr, err := x509.ParseCertificateRequest(csrDerBytes) + 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) + 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 temporary RSA key for SCEP envelope + tempRSAKey, tempRSACert := createTempRSAKeyAndCert(t, existingCert.Subject.CommonName) + + // Create SCEP PKI message for renewal + pkiMsgReq := &scep.PKIMessage{ + MessageType: scep.PKCSReq, + Recipients: caCerts, + SignerKey: tempRSAKey, + SignerCert: tempRSACert, + } + + 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) + + // The renewal should succeed + require.Equal(t, scep.SUCCESS, pkiMsgResp.PKIStatus, "Renewal should succeed") + + // Decrypt PKI envelope using RSA key + err = pkiMsgResp.DecryptPKIEnvelope(tempRSACert, tempRSAKey) + require.NoError(t, err) + + // Verify we got a new certificate + require.NotNil(t, pkiMsgResp.CertRepMessage) + require.NotNil(t, pkiMsgResp.CertRepMessage.Certificate) + + renewedCert := pkiMsgResp.CertRepMessage.Certificate + require.NotNil(t, renewedCert) + + // Verify renewed certificate properties + assert.Equal(t, existingCert.Subject.CommonName, renewedCert.Subject.CommonName, "Common name should be preserved") + assert.Equal(t, x509.ECDSA, renewedCert.PublicKeyAlgorithm) + + // Verify the renewed certificate has the new public key + renewedPubKey, ok := renewedCert.PublicKey.(*ecdsa.PublicKey) + require.True(t, ok, "Renewed certificate should contain ECC public key") + assert.True(t, newEccPrivateKey.PublicKey.Equal(renewedPubKey), "Renewed certificate should have the new public key") + + // Verify the renewed certificate has a different serial number + assert.NotEqual(t, existingCert.SerialNumber, renewedCert.SerialNumber, "Renewed certificate should have a new serial number") + + // Verify the renewed certificate maintains the host_id association + renewedStoredCert, err := s.DS.GetHostIdentityCertBySerialNumber(ctx, renewedCert.SerialNumber.Uint64()) + require.NoError(t, err) + require.NotNil(t, renewedStoredCert) + require.NotNil(t, renewedStoredCert.HostID, "Renewed certificate should maintain host_id association") + require.Equal(t, originalHostID, *renewedStoredCert.HostID, "Renewed certificate should have the same host_id as the original") + + // Test that we can use the renewed certificate to access the config endpoint + t.Run("test config endpoint with renewed certificate", func(t *testing.T) { + var configReq interface{} + var configURL string + + if isOsquery { + configReq = osqueryConfigRequest{NodeKey: nodeKey} + configURL = s.Server.URL + "/api/osquery/config" + } else { + configReq = orbitConfigRequest{OrbitNodeKey: nodeKey} + configURL = s.Server.URL + "/api/fleet/orbit/config" + } + + configReqBody, err := json.Marshal(configReq) require.NoError(t, err) - require.NotNil(t, hostToDelete, "Should find the enrolled host") - // Delete the host using the API endpoint - s.Do(t, "DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d", hostToDelete.ID), nil, http.StatusOK) - - // Try to enroll the same host with the same certificate - this should fail - // because deleting the host should have invalidated its certificate - req, err := http.NewRequest("POST", s.Server.URL+"/api/osquery/enroll", bytes.NewReader(reqBody)) + req, err := http.NewRequest("POST", configURL, bytes.NewReader(configReqBody)) require.NoError(t, err) req.Header.Set("Content-Type", "application/json") + // Create signer with the renewed certificate and new private key + signer := createHTTPSigner(t, newEccPrivateKey, renewedCert) err = signer.Sign(req) require.NoError(t, err) + client := fleethttp.NewClient() httpResp, err := client.Do(req) require.NoError(t, err) defer httpResp.Body.Close() - // This should fail because the host certificate should be deleted when the host is deleted. - // The host needs to request a new cert to re-enroll. - require.Equal(t, http.StatusUnauthorized, httpResp.StatusCode, "Enrollment with deleted host certificate should fail") + // Should succeed with the renewed certificate + require.Equal(t, http.StatusOK, httpResp.StatusCode, "Config request with renewed certificate should succeed") }) + + // Test that config endpoint does not work with old certificate after renewal + t.Run("config endpoint fails with old certificate after renewal", func(t *testing.T) { + var configReq interface{} + var configURL string + + if isOsquery { + configReq = osqueryConfigRequest{NodeKey: nodeKey} + configURL = s.Server.URL + "/api/osquery/config" + } else { + configReq = orbitConfigRequest{OrbitNodeKey: nodeKey} + configURL = s.Server.URL + "/api/fleet/orbit/config" + } + + configReqBody, err := json.Marshal(configReq) + require.NoError(t, err) + + req, err := http.NewRequest("POST", configURL, bytes.NewReader(configReqBody)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + // Create signer with the OLD certificate and OLD private key + signer := createHTTPSigner(t, eccPrivateKey, existingCert) + err = signer.Sign(req) + require.NoError(t, err) + + client := fleethttp.NewClient() + httpResp, err := client.Do(req) + require.NoError(t, err) + defer httpResp.Body.Close() + + // Should fail because the old certificate has been revoked + require.Equal(t, http.StatusUnauthorized, httpResp.StatusCode, "Config request with old certificate should fail after renewal") + }) + + // Test that renewal cannot be retried with the same serial number + t.Run("renewal fails when retrying with same serial", func(t *testing.T) { + // Try to renew again using the same old certificate serial number + // This should fail because the certificate has already been revoked + + // Generate another new key pair for this attempt + anotherNewKey, err := ecdsa.GenerateKey(eccPrivateKey.Curve, rand.Reader) + require.NoError(t, err) + + // Use the same renewal data as before (same serial and signature) + retryCSRTemplate := x509util.CertificateRequest{ + CertificateRequest: x509.CertificateRequest{ + Subject: pkix.Name{ + CommonName: existingCert.Subject.CommonName, + }, + SignatureAlgorithm: x509.ECDSAWithSHA256, + ExtraExtensions: []pkix.Extension{ + { + Id: types.RenewalExtensionOID, + Value: renewalDataJSON, // Reuse the same renewal data + }, + }, + }, + } + + retryCSRDerBytes, err := x509util.CreateCertificateRequest(rand.Reader, &retryCSRTemplate, anotherNewKey) + require.NoError(t, err) + retryCSR, err := x509.ParseCertificateRequest(retryCSRDerBytes) + require.NoError(t, err) + + // Create new temp RSA key for SCEP envelope + retryTempRSAKey, retryTempRSACert := createTempRSAKeyAndCert(t, existingCert.Subject.CommonName) + + // Create SCEP PKI message for retry + retryPkiMsgReq := &scep.PKIMessage{ + MessageType: scep.PKCSReq, + Recipients: caCerts, + SignerKey: retryTempRSAKey, + SignerCert: retryTempRSACert, + } + + retryMsg, err := scep.NewCSRRequest(retryCSR, retryPkiMsgReq, scep.WithLogger(s.Logger)) + require.NoError(t, err) + + // Send PKI operation request + retryRespBytes, err := scepClient.PKIOperation(ctx, retryMsg.Raw) + require.NoError(t, err) + + // Parse response + retryPkiMsgResp, err := scep.ParsePKIMessage(retryRespBytes, scep.WithLogger(s.Logger), scep.WithCACerts(retryMsg.Recipients)) + require.NoError(t, err) + + // Should fail - the certificate has already been revoked + require.Equal(t, scep.FAILURE, retryPkiMsgResp.PKIStatus, "Renewal retry with same serial should fail") + }) +} + +func testDeleteHostAndReenroll(t *testing.T, s *Suite, cert *x509.Certificate, eccPrivateKey *ecdsa.PrivateKey, nodeKey string) { + ctx := t.Context() + + // Get the host using the orbit node key + hostToDelete, err := s.DS.LoadHostByOrbitNodeKey(ctx, nodeKey) + require.NoError(t, err) + require.NotNil(t, hostToDelete, "Should find the enrolled host") + + // Delete the host using the API endpoint + s.Do(t, "DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d", hostToDelete.ID), nil, http.StatusOK) + + // Try to enroll the same host with the same certificate - this should fail + enrollRequest := contract.EnrollOrbitRequest{ + EnrollSecret: testEnrollmentSecret, + HardwareUUID: "test-uuid-" + cert.Subject.CommonName, + HardwareSerial: "test-serial-" + cert.Subject.CommonName, + Hostname: "test-hostname-" + cert.Subject.CommonName, + OsqueryIdentifier: cert.Subject.CommonName, + } + + reqBody, err := json.Marshal(enrollRequest) + require.NoError(t, err) + + req, err := http.NewRequest("POST", s.Server.URL+"/api/fleet/orbit/enroll", bytes.NewReader(reqBody)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + signer := createHTTPSigner(t, eccPrivateKey, cert) + err = signer.Sign(req) + require.NoError(t, err) + + client := fleethttp.NewClient() + httpResp, err := client.Do(req) + require.NoError(t, err) + defer httpResp.Body.Close() + + // This should fail because the host certificate should be deleted when the host is deleted + require.Equal(t, http.StatusUnauthorized, httpResp.StatusCode, "Enrollment with deleted host certificate should fail") +} + +func testDeleteHostAndReenrollOsquery(t *testing.T, s *Suite, cert *x509.Certificate, eccPrivateKey *ecdsa.PrivateKey, nodeKey string) { + ctx := t.Context() + + // Get the host using the osquery node key + hostToDelete, err := s.DS.LoadHostByNodeKey(ctx, nodeKey) + require.NoError(t, err) + require.NotNil(t, hostToDelete, "Should find the enrolled host") + + // Delete the host using the API endpoint + s.Do(t, "DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d", hostToDelete.ID), nil, http.StatusOK) + + // Try to enroll the same host with the same certificate - this should fail + enrollRequest := contract.EnrollOsqueryAgentRequest{ + EnrollSecret: testEnrollmentSecret, + HostIdentifier: cert.Subject.CommonName, + HostDetails: map[string]map[string]string{ + "osquery_info": { + "version": "5.0.0", + }, + }, + } + + reqBody, err := json.Marshal(enrollRequest) + require.NoError(t, err) + + req, err := http.NewRequest("POST", s.Server.URL+"/api/osquery/enroll", bytes.NewReader(reqBody)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + signer := createHTTPSigner(t, eccPrivateKey, cert) + err = signer.Sign(req) + require.NoError(t, err) + + client := fleethttp.NewClient() + httpResp, err := client.Do(req) + require.NoError(t, err) + defer httpResp.Body.Close() + + // This should fail because the host certificate should be deleted when the host is deleted + require.Equal(t, http.StatusUnauthorized, httpResp.StatusCode, "Enrollment with deleted host certificate should fail") } func createTempRSAKeyAndCert(t *testing.T, commonName string) (*rsa.PrivateKey, *x509.Certificate) { @@ -1013,9 +1301,12 @@ func testRealSecureHWAndSCEP(t *testing.T, s *Suite) { tpmKey, err := tpmHW.CreateKey() require.NoError(t, err) - // Set up cleanup in reverse order - keys first, then hardware, then simulator + // Set up cleanup - the TPM hardware will be closed once at the end t.Cleanup(func() { - require.NoError(t, tpmHW.Close()) + if err := tpmHW.Close(); err != nil { + // Don't fail if already closed + t.Logf("TPM close error (may be expected): %v", err) + } }) // Verify we can get the public key @@ -1123,10 +1414,6 @@ func testRealSecureHWAndSCEP(t *testing.T, s *Suite) { loadedKey, err := tpmHW.LoadKey() require.NoError(t, err) - // Close the loaded key at the end of this section - t.Cleanup(func() { - require.NoError(t, loadedKey.Close()) - }) // Verify loaded key has same public key loadedPubKey, err := loadedKey.Public() @@ -1166,4 +1453,149 @@ func testRealSecureHWAndSCEP(t *testing.T, s *Suite) { defer httpResp.Body.Close() require.Equal(t, http.StatusOK, httpResp.StatusCode, "Config request with loaded TPM key should succeed") + + t.Run("renew certificate with real SecureHW and SCEP client", func(t *testing.T) { + // Get the original certificate's host_id before renewal (it will get revoked) + originalStoredCert, err := s.DS.GetHostIdentityCertBySerialNumber(ctx, cert.SerialNumber.Uint64()) + require.NoError(t, err) + require.NotNil(t, originalStoredCert) + require.NotNil(t, originalStoredCert.HostID, "Original certificate should have host_id") + originalHostID := *originalStoredCert.HostID + // Save the current certificate to the expected location + certPath := filepath.Join(tempDir, constant.FleetHTTPSignatureCertificateFileName) + certFile, err := os.OpenFile(certPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) + require.NoError(t, err) + err = pem.Encode(certFile, &pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Raw, + }) + require.NoError(t, err) + require.NoError(t, certFile.Close()) + + // Now we can use hostidentity.RenewCertificate directly since SecureHW is exported + // Create a Credentials struct with our test TPM + credentials := &hostidentity.Credentials{ + Certificate: cert, + SecureHWKey: loadedKey, + CertificatePath: certPath, + SecureHW: tpmHW, + } + + // Use the hostidentity.RenewCertificate method directly + renewedCert, err := hostidentity.RenewCertificate( + ctx, + tempDir, + credentials, + fmt.Sprintf("%s/api/fleet/orbit/host_identity/scep", s.Server.URL), + "", // rootCA - empty for insecure + true, // insecure + zerologLogger, + ) + require.NoError(t, err) + require.NotNil(t, renewedCert) + + // The RenewCertificate method should have updated credentials.SecureHWKey + // and saved the renewed certificate + + // Verify renewed certificate properties + assert.Equal(t, cert.Subject.CommonName, renewedCert.Subject.CommonName, "Common name should be preserved") + assert.NotEqual(t, cert.SerialNumber, renewedCert.SerialNumber, "Serial number should be different") + assert.Equal(t, x509.ECDSA, renewedCert.PublicKeyAlgorithm) + + // Verify the renewed certificate has a new public key (from the new TPM key) + renewedPubKey, ok := renewedCert.PublicKey.(*ecdsa.PublicKey) + require.True(t, ok, "Renewed certificate should contain ECC public key") + assert.False(t, certPubKey.Equal(renewedPubKey), "Renewed certificate should have a different public key") + + // Verify the new key's public key matches the renewed certificate + // The new key is now in credentials.SecureHWKey + newPubKey, err := credentials.SecureHWKey.Public() + require.NoError(t, err) + newECCPubKey, ok := newPubKey.(*ecdsa.PublicKey) + require.True(t, ok, "New key should be ECC") + assert.True(t, renewedPubKey.Equal(newECCPubKey), "Renewed certificate public key should match new TPM key") + + // Verify the renewed certificate maintains the host_id association + renewedStoredCert, err := s.DS.GetHostIdentityCertBySerialNumber(ctx, renewedCert.SerialNumber.Uint64()) + require.NoError(t, err) + require.NotNil(t, renewedStoredCert) + require.NotNil(t, renewedStoredCert.HostID, "Renewed certificate should maintain host_id association") + require.Equal(t, originalHostID, *renewedStoredCert.HostID, "Renewed certificate should have the same host_id as the original") + + // Test that we can use the renewed certificate and new key + renewedConfigRequest := orbitConfigRequest{ + OrbitNodeKey: enrollResp.OrbitNodeKey, + } + + renewedConfigReqBody, err := json.Marshal(renewedConfigRequest) + require.NoError(t, err) + + renewedConfigReq, err := http.NewRequest("POST", s.Server.URL+"/api/fleet/orbit/config", bytes.NewReader(renewedConfigReqBody)) + require.NoError(t, err) + renewedConfigReq.Header.Set("Content-Type", "application/json") + + // Sign with renewed certificate and new key + renewedHTTPSigner, err := credentials.SecureHWKey.HTTPSigner() + require.NoError(t, err) + + // Determine algorithm for renewed key + var renewedAlgo httpsig.Algorithm + switch renewedHTTPSigner.ECCAlgorithm() { + case securehw.ECCAlgorithmP256: + renewedAlgo = httpsig.Algo_ECDSA_P256_SHA256 + case securehw.ECCAlgorithmP384: + renewedAlgo = httpsig.Algo_ECDSA_P384_SHA384 + default: + t.Fatalf("Unsupported ECC algorithm from renewed TPM key") + } + + renewedSigner, err := fleethttpsig.Signer( + fmt.Sprintf("%d", renewedCert.SerialNumber.Uint64()), + renewedHTTPSigner, + renewedAlgo, + ) + require.NoError(t, err) + + err = renewedSigner.Sign(renewedConfigReq) + require.NoError(t, err) + + httpResp, err = client.Do(renewedConfigReq) + require.NoError(t, err) + defer httpResp.Body.Close() + + require.Equal(t, http.StatusOK, httpResp.StatusCode, "Config request with renewed certificate should succeed") + + // Test that old certificate no longer works + // Since the old key was closed and replaced, we need to recreate the signer with the old serial + oldConfigReq, err := http.NewRequest("POST", s.Server.URL+"/api/fleet/orbit/config", bytes.NewReader(renewedConfigReqBody)) + require.NoError(t, err) + oldConfigReq.Header.Set("Content-Type", "application/json") + + // Create a signer with the old certificate serial but it should fail since the cert was replaced + oldSerialSigner, err := fleethttpsig.Signer( + fmt.Sprintf("%d", cert.SerialNumber.Uint64()), + renewedHTTPSigner, // Using new key with old serial + renewedAlgo, + ) + require.NoError(t, err) + + err = oldSerialSigner.Sign(oldConfigReq) + require.NoError(t, err) + + httpResp, err = client.Do(oldConfigReq) + require.NoError(t, err) + defer httpResp.Body.Close() + + require.Equal(t, http.StatusUnauthorized, httpResp.StatusCode, "Config request with old certificate serial should fail after renewal") + + // Verify the old key backup was cleaned up by RenewCertificate + oldKeyPath := filepath.Join(tempDir, constant.FleetHTTPSignatureTPMKeyBackupFileName) + _, err = os.Stat(oldKeyPath) + require.True(t, os.IsNotExist(err), "Old key backup should have been removed by RenewCertificate") + + // Clean up the new key + t.Cleanup(func() { + _ = credentials.SecureHWKey.Close() + }) + }) } diff --git a/ee/server/service/hostidentity/scep.go b/ee/server/service/hostidentity/scep.go index acc546b5f8..ac9419e9c1 100644 --- a/ee/server/service/hostidentity/scep.go +++ b/ee/server/service/hostidentity/scep.go @@ -2,20 +2,31 @@ package hostidentity import ( "context" + "crypto/ecdsa" "crypto/rsa" + "crypto/sha256" "crypto/tls" "crypto/x509" + "encoding/base64" + "encoding/json" "errors" "fmt" + "math/big" "net/http" + "os" + "strconv" + "strings" "github.com/cenkalti/backoff/v4" + "github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/types" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "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/go-kit/log/level" "github.com/smallstep/scep" ) @@ -37,6 +48,18 @@ func (e *RateLimitError) Error() string { // StatusCode implements the kithttp StatusCoder interface func (e *RateLimitError) StatusCode() int { return http.StatusTooManyRequests } +// getCertValidityDays returns the certificate validity period in days. +// It checks for FLEET_DEV_HOST_IDENTITY_CERT_VALIDITY_DAYS environment variable +// and falls back to scepValidityDays if not set or invalid. +func getCertValidityDays() int { + if envValue := os.Getenv("FLEET_DEV_HOST_IDENTITY_CERT_VALIDITY_DAYS"); envValue != "" { + if days, err := strconv.Atoi(envValue); err == nil && days > 0 { + return days + } + } + return scepValidityDays +} + // RegisterSCEP registers the HTTP handler for SCEP service needed for fleetd enrollment. func RegisterSCEP( mux *http.ServeMux, @@ -50,11 +73,11 @@ func RegisterSCEP( } var signer scepserver.CSRSignerContext = scepserver.SignCSRAdapter(scepdepot.NewSigner( scepStorage, - scepdepot.WithValidityDays(scepValidityDays), - scepdepot.WithAllowRenewalDays(scepValidityDays/2), + scepdepot.WithValidityDays(getCertValidityDays()), )) signer = challengeMiddleware(ds, signer) + signer = renewalMiddleware(ds, logger, signer) scepService := NewSCEPService( ds, signer, @@ -79,6 +102,13 @@ func RegisterSCEP( // 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) { + // Check if this is a renewal request by looking for the custom Fleet extension + if hasRenewalExtension(m.CSR) { + // Skip challenge verification for renewal requests + // The renewal middleware will handle authentication + return next.SignCSRContext(ctx, m) + } + if m.ChallengePassword == "" { return nil, errors.New("missing challenge") } @@ -93,6 +123,93 @@ func challengeMiddleware(ds fleet.Datastore, next scepserver.CSRSignerContext) s } } +// hasRenewalExtension checks if the CSR contains the renewal extension +func hasRenewalExtension(csr *x509.CertificateRequest) bool { + for _, ext := range csr.Extensions { + if ext.Id.Equal(types.RenewalExtensionOID) { + return true + } + } + return false +} + +// renewalMiddleware handles certificate renewal with proof-of-possession +func renewalMiddleware(ds fleet.Datastore, logger kitlog.Logger, next scepserver.CSRSignerContext) scepserver.CSRSignerContextFunc { + return func(ctx context.Context, m *scep.CSRReqMessage) (*x509.Certificate, error) { + // Check if this is a renewal request + var renewalData types.RenewalData + found := false + for _, ext := range m.CSR.Extensions { + if ext.Id.Equal(types.RenewalExtensionOID) { + if err := json.Unmarshal(ext.Value, &renewalData); err != nil { + return nil, fmt.Errorf("invalid renewal extension: %w", err) + } + found = true + break + } + } + + if !found { + // Not a renewal request, pass through + return next.SignCSRContext(ctx, m) + } + + logger.Log("msg", "processing renewal request", "serial", renewalData.SerialNumber) + + // Parse the serial number from hex + serialBigInt := new(big.Int) + _, success := serialBigInt.SetString(strings.TrimPrefix(renewalData.SerialNumber, "0x"), 16) + if !success { + return nil, fmt.Errorf("invalid serial number format: %s", renewalData.SerialNumber) + } + + // Retrieve the old certificate data + oldCertData, err := ds.GetHostIdentityCertBySerialNumber(ctx, serialBigInt.Uint64()) + if err != nil { + return nil, fmt.Errorf("retrieving old certificate: %w", err) + } + + // Get the public key from the stored data + pubKey, err := oldCertData.UnmarshalPublicKey() + if err != nil { + return nil, fmt.Errorf("unmarshaling public key: %w", err) + } + + // Verify the signature + sigBytes, err := base64.StdEncoding.DecodeString(renewalData.Signature) + if err != nil { + return nil, fmt.Errorf("decoding signature: %w", err) + } + + // Verify the signature + hash := sha256.Sum256([]byte(renewalData.SerialNumber)) + if !ecdsa.VerifyASN1(pubKey, hash[:], sigBytes) { + return nil, errors.New("invalid renewal signature") + } + + logger.Log("msg", "renewal signature verified", "serial", renewalData.SerialNumber, "cn", oldCertData.CommonName) + + // Issue the new certificate + newCert, err := next.SignCSRContext(ctx, m) + if err != nil { + return nil, fmt.Errorf("signing renewal CSR: %w", err) + } + + // Update the new certificate's host_id to match the old certificate + if oldCertData.HostID != nil { + err = ds.UpdateHostIdentityCertHostIDBySerial(ctx, newCert.SerialNumber.Uint64(), *oldCertData.HostID) + if err != nil { + // Log the error but don't fail the renewal + ctxerr.Handle(ctx, err) + level.Error(logger).Log("msg", "failed to update host_id for renewed certificate", "err", err, "new_serial", + newCert.SerialNumber.Uint64(), "host_id", *oldCertData.HostID) + } + } + + return newCert, nil + } +} + var _ scepserver.Service = (*service)(nil) type service struct { @@ -103,7 +220,7 @@ type service struct { logger log.Logger - ds fleet.MDMAssetRetriever + ds fleet.Datastore } func (svc *service) GetCACaps(_ context.Context) ([]byte, error) { @@ -119,7 +236,7 @@ func (svc *service) GetCACaps(_ context.Context) ([]byte, error) { // // Operational Capabilities: // [ ] GetNextCACert // Supports fetching next CA certificate (rollover) - // [ ] Renewal // Supports certificate renewal (same key, new cert) + // [ ] Renewal // Supports certificate renewal (same or new key, new cert) // [ ] Update // Supports certificate update (new key) // // These capabilities are implied by the protocol and don't need to be explicitly declared: diff --git a/ee/server/service/hostidentity/types/host_identity_certificates.go b/ee/server/service/hostidentity/types/host_identity_certificates.go index 623154725d..92544176f7 100644 --- a/ee/server/service/hostidentity/types/host_identity_certificates.go +++ b/ee/server/service/hostidentity/types/host_identity_certificates.go @@ -3,12 +3,24 @@ package types import ( "crypto/ecdsa" "crypto/elliptic" + "encoding/asn1" "errors" "fmt" "math/big" "time" ) +// RenewalExtensionOID is the custom OID for the renewal extension +// 1.3.6.1.4.1.99999.1.1 +// TODO: Replace 99999 with Fleet's IANA private enterprise number once it is issued +var RenewalExtensionOID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 99999, 1, 1} + +// RenewalData represents the JSON data in the renewal extension +type RenewalData struct { + SerialNumber string `json:"sn"` // Hex-encoded serial number of the old certificate + Signature string `json:"sig"` // Base64-encoded ECDSA signature +} + type HostIdentityCertificate struct { SerialNumber uint64 `db:"serial"` CommonName string `db:"name"` diff --git a/orbit/changes/31372-host-identity-cert-renewal b/orbit/changes/31372-host-identity-cert-renewal new file mode 100644 index 0000000000..ed59e89a54 --- /dev/null +++ b/orbit/changes/31372-host-identity-cert-renewal @@ -0,0 +1 @@ +* Added automatic host identity certificate renewal for TPM-backed certificates. When a certificate is within 180 days of expiration, orbit will automatically renew it using proof-of-possession with the existing certificate's private key. diff --git a/orbit/cmd/orbit/orbit.go b/orbit/cmd/orbit/orbit.go index 6763a93b0b..9bd5219408 100644 --- a/orbit/cmd/orbit/orbit.go +++ b/orbit/cmd/orbit/orbit.go @@ -961,6 +961,7 @@ func main() { var ( signerWrapper func(*http.Client) *http.Client hostIdentityCertificatePath string + orbitClient *service.OrbitClient ) if c.Bool("fleet-managed-host-identity-certificate") { commonName := osqueryHostInfo.HardwareUUID @@ -976,6 +977,11 @@ func main() { c.String("fleet-certificate"), c.Bool("insecure"), log.Logger, + func(reason string) { + if orbitClient != nil { + orbitClient.TriggerOrbitRestart(reason) + } + }, ) if err != nil { if c.Bool("fleet-desktop") { @@ -1055,7 +1061,7 @@ func main() { ) } - orbitClient, err := service.NewOrbitClient( + orbitClient, err = service.NewOrbitClient( c.String("root-dir"), fleetURL, c.String("fleet-certificate"), diff --git a/orbit/pkg/constant/constant.go b/orbit/pkg/constant/constant.go index 7d34ed1e7e..89f05c6269 100644 --- a/orbit/pkg/constant/constant.go +++ b/orbit/pkg/constant/constant.go @@ -77,4 +77,8 @@ const ( FleetURLFileName = "fleet_url.txt" FleetHTTPSignatureCertificateFileName = "host_identity.crt" + // FleetHTTPSignatureTPMKeyFileName is the filename for the TPM key used for HTTP signature authentication + FleetHTTPSignatureTPMKeyFileName = "host_identity_tpm.pem" + // FleetHTTPSignatureTPMKeyBackupFileName is the filename for the backup of the TPM key during renewal + FleetHTTPSignatureTPMKeyBackupFileName = "host_identity_tpm.old.pem" )