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:
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user