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))


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

## 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.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Victor Lyuboslavsky
2025-07-30 16:46:36 +02:00
committed by GitHub
parent ae2d452146
commit 34c45b256f
11 changed files with 884 additions and 91 deletions
+1
View File
@@ -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.
+203 -15
View File
@@ -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)
}
+11
View File
@@ -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,
}
+4 -3
View File
@@ -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
}
+32 -12
View File
@@ -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
}
@@ -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()
})
})
}
+121 -4
View File
@@ -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:
@@ -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"`
@@ -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.
+7 -1
View File
@@ -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"),
+4
View File
@@ -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"
)