fleet/ee/server/integrationtest/hostidentity/hostidentity_test.go
2025-07-31 19:49:02 +02:00

1604 lines
54 KiB
Go

//go:build !windows
// Windows is disabled because the TPM simulator requires CGO, which causes lint failures on Windows.
package hostidentity
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
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"
"github.com/fleetdm/fleet/v4/server/mdm/scep/x509util"
"github.com/fleetdm/fleet/v4/server/service/contract"
"github.com/google/go-tpm/tpm2/transport/simulator"
"github.com/remitly-oss/httpsig-go"
"github.com/rs/zerolog"
"github.com/smallstep/scep"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testEnrollmentSecret = "test_secret"
func TestHostIdentity(t *testing.T) {
s := SetUpSuiteWithConfig(t, "integrationtest.HostIdentity", false, func(cfg *config.FleetConfig) {
cfg.Osquery.EnrollCooldown = 0 // Disable rate limiting for tests
})
cases := []struct {
name string
fn func(t *testing.T, s *Suite)
}{
{"GetCertAndSignReq", testGetCertAndSignReq},
{"GetCertFailures", testGetCertFailures},
{"WrongCertAuthentication", testWrongCertAuthentication},
{"RealSecureHWAndSCEP", testRealSecureHWAndSCEP},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
defer mysql.TruncateTables(t, s.BaseSuite.DS, []string{
"host_identity_scep_serials", "host_identity_scep_certificates",
}...)
c.fn(t, s)
})
}
}
func testGetCertAndSignReq(t *testing.T, s *Suite) {
t.Run("ECC P256, orbit", func(t *testing.T) {
t.Parallel()
cert, eccPrivateKey := testGetCertWithCurve(t, s, elliptic.P256())
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())
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())
nodeKey := testOsqueryEnrollment(t, s, cert, eccPrivateKey)
testCertificateRenewal(t, s, cert, eccPrivateKey, nodeKey, true) // true = osquery
testDeleteHostAndReenrollOsquery(t, s, cert, eccPrivateKey, nodeKey)
})
}
func generateRandomString(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
result := make([]byte, length)
for i := range result {
result[i] = charset[mathrand.IntN(len(charset))] // nolint:gosec // waive G404 since this is test code
}
return string(result)
}
func testGetCertWithCurve(t *testing.T, s *Suite, curve elliptic.Curve) (cert *x509.Certificate, eccPrivateKey *ecdsa.PrivateKey) {
ctx := t.Context()
// Create an enrollment secret
err := s.DS.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{
{
Secret: testEnrollmentSecret,
},
})
require.NoError(t, err)
// Create ECC private key with specified curve
eccPrivateKey, err = ecdsa.GenerateKey(curve, rand.Reader)
require.NoError(t, err)
// Create SCEP client
scepURL := fmt.Sprintf("%s/api/fleet/orbit/host_identity/scep", s.Server.URL)
scepClient, err := scepclient.New(scepURL, s.Logger)
require.NoError(t, err)
// Get CA certificate
resp, _, err := scepClient.GetCACert(ctx, "")
require.NoError(t, err)
caCerts, err := x509.ParseCertificates(resp)
require.NoError(t, err)
require.NotEmpty(t, caCerts)
// Create CSR using ECC key
hostIdentifier := generateRandomString(16)
csrTemplate := x509util.CertificateRequest{
CertificateRequest: x509.CertificateRequest{
Subject: pkix.Name{
CommonName: hostIdentifier,
},
SignatureAlgorithm: x509.ECDSAWithSHA256,
},
ChallengePassword: testEnrollmentSecret,
}
csrDerBytes, err := x509util.CreateCertificateRequest(rand.Reader, &csrTemplate, eccPrivateKey)
require.NoError(t, err)
csr, err := x509.ParseCertificateRequest(csrDerBytes)
require.NoError(t, err)
tempRSAKey, deviceCert := createTempRSAKeyAndCert(t, hostIdentifier)
// Create SCEP PKI message
pkiMsgReq := &scep.PKIMessage{
MessageType: scep.PKCSReq,
Recipients: caCerts,
SignerKey: tempRSAKey, // Use RSA key for SCEP protocol
SignerCert: deviceCert,
}
msg, err := scep.NewCSRRequest(csr, pkiMsgReq, scep.WithLogger(s.Logger))
require.NoError(t, err)
// Send PKI operation request
respBytes, err := scepClient.PKIOperation(ctx, msg.Raw)
require.NoError(t, err)
// Parse response
pkiMsgResp, err := scep.ParsePKIMessage(respBytes, scep.WithLogger(s.Logger), scep.WithCACerts(msg.Recipients))
require.NoError(t, err)
// Verify successful response
require.Equal(t, scep.SUCCESS, pkiMsgResp.PKIStatus, "SCEP request should succeed")
// Decrypt PKI envelope using RSA key
err = pkiMsgResp.DecryptPKIEnvelope(deviceCert, tempRSAKey)
require.NoError(t, err)
// Verify we got a certificate
require.NotNil(t, pkiMsgResp.CertRepMessage)
require.NotNil(t, pkiMsgResp.CertRepMessage.Certificate)
// Verify the certificate was signed by the CA
cert = pkiMsgResp.CertRepMessage.Certificate
require.NotNil(t, cert)
// Verify certificate properties
assert.Equal(t, hostIdentifier, cert.Subject.CommonName)
assert.Equal(t, x509.ECDSA, cert.PublicKeyAlgorithm)
certPubKey, ok := cert.PublicKey.(*ecdsa.PublicKey)
require.True(t, ok, "Certificate should contain ECC public key")
assert.True(t, eccPrivateKey.PublicKey.Equal(certPubKey), "Certificate public key should match our ECC private key")
assert.Equal(t, curve, certPubKey.Curve, "Certificate should use the expected elliptic curve")
// Retrieve the certificate from datastore and verify it matches SCEP response
storedCert, err := s.DS.GetHostIdentityCertBySerialNumber(ctx, cert.SerialNumber.Uint64())
require.NoError(t, err)
require.NotNil(t, storedCert)
// Verify stored certificate properties match the SCEP response
assert.Equal(t, cert.SerialNumber.Uint64(), storedCert.SerialNumber)
assert.Equal(t, cert.Subject.CommonName, storedCert.CommonName)
assert.Equal(t, cert.NotAfter, storedCert.NotValidAfter)
// Verify the stored public key matches the certificate public key
storedPubKey, err := storedCert.UnmarshalPublicKey()
require.NoError(t, err)
require.NotNil(t, storedPubKey)
assert.True(t, certPubKey.Equal(storedPubKey), "Stored public key should match certificate public key")
assert.Equal(t, curve, storedPubKey.Curve, "Stored public key should use the expected elliptic curve")
return cert, eccPrivateKey
}
// createHTTPSigner creates an HTTP signature signer for the given ECC private key and certificate
func createHTTPSigner(t *testing.T, eccPrivateKey *ecdsa.PrivateKey, cert *x509.Certificate) *httpsig.Signer {
// Determine the algorithm based on the curve
var algo httpsig.Algorithm
switch eccPrivateKey.Curve {
case elliptic.P256():
algo = httpsig.Algo_ECDSA_P256_SHA256
case elliptic.P384():
algo = httpsig.Algo_ECDSA_P384_SHA384
default:
t.Fatalf("Unsupported curve: %v", eccPrivateKey.Curve)
}
// Create signer
signer, err := fleethttpsig.Signer(
fmt.Sprintf("%d", cert.SerialNumber.Uint64()),
eccPrivateKey,
algo,
)
require.NoError(t, err)
return signer
}
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{
EnrollSecret: testEnrollmentSecret,
HardwareUUID: "test-uuid-" + cert.Subject.CommonName,
HardwareSerial: "test-serial-" + cert.Subject.CommonName,
Hostname: "test-hostname-" + cert.Subject.CommonName,
OsqueryIdentifier: cert.Subject.CommonName,
}
// This request is sent without an HTTP signature, so it should fail.
var enrollResp enrollOrbitResponse
s.DoJSON(t, "POST", "/api/fleet/orbit/enroll", enrollRequest, http.StatusUnauthorized, &enrollResp)
// Now send the same request with an HTTP signature
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")
// Create signer using the shared helper
signer := createHTTPSigner(t, eccPrivateKey, cert)
// Sign the request
err = signer.Sign(req)
require.NoError(t, err)
// Clone the request before sending it to preserve the body
clonedRequest := req.Clone(ctx)
clonedRequest.Body = io.NopCloser(bytes.NewReader(reqBody))
// Send the signed request
client := fleethttp.NewClient()
httpResp, err := client.Do(req)
require.NoError(t, err)
defer httpResp.Body.Close()
// The request with a valid HTTP signature should succeed
require.Equal(t, http.StatusOK, httpResp.StatusCode, "Request with HTTP signature should succeed")
// Parse the response
var signedEnrollResp enrollOrbitResponse
err = json.NewDecoder(httpResp.Body).Decode(&signedEnrollResp)
require.NoError(t, err)
require.NotEmpty(t, signedEnrollResp.OrbitNodeKey, "Should receive orbit node key")
require.NoError(t, signedEnrollResp.Err)
// Send the same request again. We don't have replay protection, so it should succeed.
httpResp, err = client.Do(clonedRequest)
require.NoError(t, err)
defer httpResp.Body.Close()
require.Equal(t, http.StatusOK, httpResp.StatusCode, "Same request with HTTP signature should succeed")
// Parse the response
signedEnrollResp = enrollOrbitResponse{}
err = json.NewDecoder(httpResp.Body).Decode(&signedEnrollResp)
require.NoError(t, err)
require.NotEmpty(t, signedEnrollResp.OrbitNodeKey, "Should receive orbit node key")
require.NoError(t, signedEnrollResp.Err)
// Test /api/fleet/orbit/config endpoint with different signature scenarios
t.Run("config endpoint signature tests", func(t *testing.T) {
testCases := []struct {
name string
setupRequest func() (*http.Request, error)
expectedStatus int
}{
{
name: "without signature",
setupRequest: func() (*http.Request, error) {
configReq := orbitConfigRequest{OrbitNodeKey: signedEnrollResp.OrbitNodeKey}
reqBody, err := json.Marshal(configReq)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", s.Server.URL+"/api/fleet/orbit/config", bytes.NewReader(reqBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return req, nil
},
expectedStatus: http.StatusUnauthorized,
},
{
name: "with valid signature",
setupRequest: func() (*http.Request, error) {
configReq := orbitConfigRequest{OrbitNodeKey: signedEnrollResp.OrbitNodeKey}
reqBody, err := json.Marshal(configReq)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", s.Server.URL+"/api/fleet/orbit/config", bytes.NewReader(reqBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
err = signer.Sign(req)
if err != nil {
return nil, err
}
return req, nil
},
expectedStatus: http.StatusOK,
},
{
name: "with corrupted signature",
setupRequest: func() (*http.Request, error) {
configReq := orbitConfigRequest{OrbitNodeKey: signedEnrollResp.OrbitNodeKey}
reqBody, err := json.Marshal(configReq)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", s.Server.URL+"/api/fleet/orbit/config", bytes.NewReader(reqBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
// Sign with the correct signer first
err = signer.Sign(req)
if err != nil {
return nil, err
}
// Then corrupt the signature by modifying the signature header
sigHeader := req.Header.Get("Signature")
if sigHeader != "" {
// Corrupt the signature by changing the last character
corrupted := sigHeader[:len(sigHeader)-1] + "X"
req.Header.Set("Signature", corrupted)
}
return req, nil
},
expectedStatus: http.StatusUnauthorized,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req, err := tc.setupRequest()
require.NoError(t, err)
httpResp, err := client.Do(req)
require.NoError(t, err)
defer httpResp.Body.Close()
require.Equal(t, tc.expectedStatus, httpResp.StatusCode)
})
}
})
return signedEnrollResp.OrbitNodeKey
}
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,
HostIdentifier: cert.Subject.CommonName,
HostDetails: map[string]map[string]string{
"osquery_info": {
"version": "5.0.0",
},
},
}
// This request is sent without an HTTP signature, so it should fail.
var enrollResp contract.EnrollOsqueryAgentResponse
s.DoJSON(t, "POST", "/api/v1/osquery/enroll", enrollRequest, http.StatusUnauthorized, &enrollResp)
// Now send the same request with HTTP message signature
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")
// Create signer using the shared helper
signer := createHTTPSigner(t, eccPrivateKey, cert)
// Sign the request
err = signer.Sign(req)
require.NoError(t, err)
// Send the signed request
client := fleethttp.NewClient()
httpResp, err := client.Do(req)
require.NoError(t, err)
defer httpResp.Body.Close()
// The request with a valid HTTP signature should succeed
require.Equal(t, http.StatusOK, httpResp.StatusCode, "Osquery enrollment with HTTP signature should succeed")
// Parse the response
enrollResp = contract.EnrollOsqueryAgentResponse{}
err = json.NewDecoder(httpResp.Body).Decode(&enrollResp)
require.NoError(t, err)
require.NotEmpty(t, enrollResp.NodeKey, "Should receive node key")
require.NoError(t, enrollResp.Err)
// Test /api/osquery/config endpoint with different signature scenarios
t.Run("osquery config endpoint signature tests", func(t *testing.T) {
testCases := []struct {
name string
setupRequest func() (*http.Request, error)
expectedStatus int
}{
{
name: "without signature",
setupRequest: func() (*http.Request, error) {
configReq := osqueryConfigRequest{NodeKey: enrollResp.NodeKey}
reqBody, err := json.Marshal(configReq)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", s.Server.URL+"/api/osquery/config", bytes.NewReader(reqBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return req, nil
},
expectedStatus: http.StatusUnauthorized,
},
{
name: "with valid signature",
setupRequest: func() (*http.Request, error) {
configReq := osqueryConfigRequest{NodeKey: enrollResp.NodeKey}
reqBody, err := json.Marshal(configReq)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", s.Server.URL+"/api/osquery/config", bytes.NewReader(reqBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
err = signer.Sign(req)
if err != nil {
return nil, err
}
return req, nil
},
expectedStatus: http.StatusOK,
},
{
name: "with corrupted signature",
setupRequest: func() (*http.Request, error) {
configReq := osqueryConfigRequest{NodeKey: enrollResp.NodeKey}
reqBody, err := json.Marshal(configReq)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", s.Server.URL+"/api/osquery/config", bytes.NewReader(reqBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
// Sign with the correct signer first
err = signer.Sign(req)
if err != nil {
return nil, err
}
// Then corrupt the signature by modifying the signature header
sigHeader := req.Header.Get("Signature")
if sigHeader != "" {
// Corrupt the signature by changing the last character
corrupted := sigHeader[:len(sigHeader)-1] + "X"
req.Header.Set("Signature", corrupted)
}
return req, nil
},
expectedStatus: http.StatusUnauthorized,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req, err := tc.setupRequest()
require.NoError(t, err)
httpResp, err := client.Do(req)
require.NoError(t, err)
defer httpResp.Body.Close()
require.Equal(t, tc.expectedStatus, httpResp.StatusCode)
})
}
})
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)
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()
// 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) {
// Create temporary RSA key for SCEP envelope (required by SCEP protocol)
tempRSAKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
// Create self-signed certificate for SCEP protocol using RSA key
deviceCertTemplate := x509.Certificate{
Subject: pkix.Name{
CommonName: commonName,
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
BasicConstraintsValid: true,
}
deviceCertDerBytes, err := x509.CreateCertificate(
rand.Reader,
&deviceCertTemplate,
&deviceCertTemplate,
&tempRSAKey.PublicKey,
tempRSAKey,
)
require.NoError(t, err)
deviceCert, err := x509.ParseCertificate(deviceCertDerBytes)
require.NoError(t, err)
return tempRSAKey, deviceCert
}
func testGetCertFailures(t *testing.T, s *Suite) {
cases := []struct {
name string
config SCEPFailureConfig
}{
{
name: "empty challenge password",
config: SCEPFailureConfig{
ChallengePassword: "",
CommonName: "test-host-identity",
UseECC: true,
},
},
{
name: "wrong challenge password",
config: SCEPFailureConfig{
ChallengePassword: "wrong-secret",
CommonName: "test-host-identity",
UseECC: true,
},
},
{
name: "CN longer than 255 characters",
config: SCEPFailureConfig{
ChallengePassword: testEnrollmentSecret,
CommonName: strings.Repeat("a", 256),
UseECC: true,
},
},
{
name: "non-ECC algorithm used",
config: SCEPFailureConfig{
ChallengePassword: testEnrollmentSecret,
CommonName: "test-host-identity",
UseECC: false,
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
testSCEPFailure(t, s, c.config)
})
}
}
type SCEPFailureConfig struct {
ChallengePassword string
CommonName string
UseECC bool
}
func testSCEPFailure(t *testing.T, s *Suite, config SCEPFailureConfig) {
ctx := t.Context()
// Create an enrollment secret
err := s.DS.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{
{
Secret: testEnrollmentSecret,
},
})
require.NoError(t, err)
// Create SCEP client
scepURL := fmt.Sprintf("%s/api/fleet/orbit/host_identity/scep", s.Server.URL)
scepClient, err := scepclient.New(scepURL, s.Logger)
require.NoError(t, err)
// Get CA certificate
resp, _, err := scepClient.GetCACert(ctx, "")
require.NoError(t, err)
caCerts, err := x509.ParseCertificates(resp)
require.NoError(t, err)
require.NotEmpty(t, caCerts)
var privateKey interface{}
var sigAlg x509.SignatureAlgorithm
if config.UseECC {
// Create ECC private key
eccKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
privateKey = eccKey
sigAlg = x509.ECDSAWithSHA256
} else {
// Create RSA private key to test non-ECC algorithm rejection (should fail)
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
privateKey = rsaKey
sigAlg = x509.SHA256WithRSA
}
// Create CSR
csrTemplate := x509util.CertificateRequest{
CertificateRequest: x509.CertificateRequest{
Subject: pkix.Name{
CommonName: config.CommonName,
},
SignatureAlgorithm: sigAlg,
},
ChallengePassword: config.ChallengePassword,
}
csrDerBytes, err := x509util.CreateCertificateRequest(rand.Reader, &csrTemplate, privateKey)
require.NoError(t, err)
csr, err := x509.ParseCertificateRequest(csrDerBytes)
require.NoError(t, err)
tempRSAKey, deviceCert := createTempRSAKeyAndCert(t, config.CommonName)
// Create SCEP PKI message
pkiMsgReq := &scep.PKIMessage{
MessageType: scep.PKCSReq,
Recipients: caCerts,
SignerKey: tempRSAKey,
SignerCert: deviceCert,
}
msg, err := scep.NewCSRRequest(csr, pkiMsgReq, scep.WithLogger(s.Logger))
require.NoError(t, err)
// Send PKI operation request
respBytes, err := scepClient.PKIOperation(ctx, msg.Raw)
require.NoError(t, err)
// Parse response
pkiMsgResp, err := scep.ParsePKIMessage(respBytes, scep.WithLogger(s.Logger), scep.WithCACerts(msg.Recipients))
require.NoError(t, err)
// Verify failure response
assert.Equal(t, scep.FAILURE, pkiMsgResp.PKIStatus, "SCEP request should fail")
}
func testWrongCertAuthentication(t *testing.T, s *Suite) {
// Test that hosts cannot use another host's certificate for authentication
// Create two P384 certificates for different hosts
certHost1, eccPrivateKeyHost1 := testGetCertWithCurve(t, s, elliptic.P384())
certHost2, eccPrivateKeyHost2 := testGetCertWithCurve(t, s, elliptic.P384())
// Create signers for both hosts
signerHost1 := createHTTPSigner(t, eccPrivateKeyHost1, certHost1)
signerHost2 := createHTTPSigner(t, eccPrivateKeyHost2, certHost2)
// Generate a local ECC P384 private key (not from Fleet SCEP)
localPrivateKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
require.NoError(t, err)
// Create a signer using the local private key with a fake certificate serial
localSigner, err := fleethttpsig.Signer(
"999999", // Fake certificate serial number
localPrivateKey,
httpsig.Algo_ECDSA_P384_SHA384,
)
require.NoError(t, err)
enrollRequest := contract.EnrollOrbitRequest{
EnrollSecret: testEnrollmentSecret,
HardwareUUID: "test-uuid-" + certHost1.Subject.CommonName,
HardwareSerial: "test-serial-" + certHost1.Subject.CommonName,
Hostname: "test-hostname-" + certHost1.Subject.CommonName,
OsqueryIdentifier: certHost1.Subject.CommonName,
}
// Test enrollment with wrong certificate
enrollHostWithOtherHostCertShouldFail := func(t *testing.T) {
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")
// Sign with host2's signer (wrong cert)
err = signerHost2.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 certificate doesn't match the host identifier
require.Equal(t, http.StatusUnauthorized, httpResp.StatusCode, "Enrollment with wrong certificate should fail")
}
t.Run("enroll host1 with host2 cert should fail", enrollHostWithOtherHostCertShouldFail)
// Test enrollment with local private key
enrollHostWithLocalPrivateKeyShouldFail := func(t *testing.T) {
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")
// Sign with local private key (not managed by Fleet)
err = localSigner.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 certificate is not managed by Fleet
require.Equal(t, http.StatusUnauthorized, httpResp.StatusCode, "Enrollment with local private key should fail")
}
t.Run("enroll host1 with local private key should fail", enrollHostWithLocalPrivateKeyShouldFail)
// Successfully enroll host1 with correct certificate
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")
// Sign with host1's signer (correct cert)
err = signerHost1.Sign(req)
require.NoError(t, err)
client := fleethttp.NewClient()
httpResp, err := client.Do(req)
require.NoError(t, err)
defer httpResp.Body.Close()
require.Equal(t, http.StatusOK, httpResp.StatusCode, "Enrollment with correct certificate should succeed")
var enrollResp enrollOrbitResponse
err = json.NewDecoder(httpResp.Body).Decode(&enrollResp)
require.NoError(t, err)
require.NotEmpty(t, enrollResp.OrbitNodeKey)
nodeKeyHost1 := enrollResp.OrbitNodeKey
type orbitConfigRequest struct {
OrbitNodeKey string `json:"orbit_node_key"`
}
t.Run("re-enroll host1 with host2 cert should fail", enrollHostWithOtherHostCertShouldFail)
t.Run("re-enroll host1 with local private key should fail", enrollHostWithLocalPrivateKeyShouldFail)
// Try to use host1's endpoint with host2's certificate
t.Run("host1 config with host2 cert should fail", func(t *testing.T) {
configRequest := orbitConfigRequest{
OrbitNodeKey: nodeKeyHost1,
}
reqBody, err := json.Marshal(configRequest)
require.NoError(t, err)
req, err := http.NewRequest("POST", s.Server.URL+"/api/fleet/orbit/config", bytes.NewReader(reqBody))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
// Sign with host2's signer (wrong cert)
err = signerHost2.Sign(req)
require.NoError(t, err)
client := fleethttp.NewClient()
httpResp, err := client.Do(req)
require.NoError(t, err)
defer httpResp.Body.Close()
require.Equal(t, http.StatusUnauthorized, httpResp.StatusCode, "Config request with wrong certificate should fail")
})
// Successfully enroll host2 with correct certificate
enrollRequest2 := contract.EnrollOrbitRequest{
EnrollSecret: testEnrollmentSecret,
HardwareUUID: "test-uuid-" + certHost2.Subject.CommonName,
HardwareSerial: "test-serial-" + certHost2.Subject.CommonName,
Hostname: "test-hostname-" + certHost2.Subject.CommonName,
OsqueryIdentifier: certHost2.Subject.CommonName,
}
reqBody, err = json.Marshal(enrollRequest2)
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")
// Sign with host2's signer (correct cert)
err = signerHost2.Sign(req)
require.NoError(t, err)
httpResp, err = client.Do(req)
require.NoError(t, err)
defer httpResp.Body.Close()
require.Equal(t, http.StatusOK, httpResp.StatusCode, "Enrollment with correct certificate should succeed")
enrollResp = enrollOrbitResponse{}
err = json.NewDecoder(httpResp.Body).Decode(&enrollResp)
require.NoError(t, err)
require.NotEmpty(t, enrollResp.OrbitNodeKey)
nodeKeyHost2 := enrollResp.OrbitNodeKey
t.Run("re-enroll host1 with host2-enrolled cert should still fail", enrollHostWithOtherHostCertShouldFail)
// Try to use host2's endpoint with host1's certificate
t.Run("host2 config with host1 cert should fail", func(t *testing.T) {
configRequest := orbitConfigRequest{
OrbitNodeKey: nodeKeyHost2,
}
reqBody, err := json.Marshal(configRequest)
require.NoError(t, err)
req, err := http.NewRequest("POST", s.Server.URL+"/api/fleet/orbit/config", bytes.NewReader(reqBody))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
// Sign with host1's signer (wrong cert)
err = signerHost1.Sign(req)
require.NoError(t, err)
client := fleethttp.NewClient()
httpResp, err := client.Do(req)
require.NoError(t, err)
defer httpResp.Body.Close()
require.Equal(t, http.StatusUnauthorized, httpResp.StatusCode, "Config request with wrong certificate should fail")
})
// Test config request with local private key
t.Run("config request with local private key should fail", func(t *testing.T) {
configRequest := orbitConfigRequest{
OrbitNodeKey: nodeKeyHost1,
}
reqBody, err := json.Marshal(configRequest)
require.NoError(t, err)
req, err := http.NewRequest("POST", s.Server.URL+"/api/fleet/orbit/config", bytes.NewReader(reqBody))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
// Sign with local private key (not managed by Fleet)
err = localSigner.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 certificate is not managed by Fleet
require.Equal(t, http.StatusUnauthorized, httpResp.StatusCode, "Config request with local private key should fail")
})
// Test enrollment failures after host is enrolled - use different host identifiers to avoid re-enrollment
t.Run("enroll new host with host1 cert should fail after enrollment", func(t *testing.T) {
newHostEnrollRequest := contract.EnrollOrbitRequest{
EnrollSecret: testEnrollmentSecret,
HardwareUUID: "test-uuid-new-host-wrong-cert",
HardwareSerial: "test-serial-new-host-wrong-cert",
Hostname: "test-hostname-new-host-wrong-cert",
OsqueryIdentifier: "new-host-wrong-cert",
}
reqBody, err := json.Marshal(newHostEnrollRequest)
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")
// Sign with host1's signer (wrong cert for this new host)
err = signerHost1.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 certificate doesn't match the host identifier
require.Equal(t, http.StatusUnauthorized, httpResp.StatusCode, "Enrollment with wrong certificate should fail even after other hosts are enrolled")
})
}
// testRealSecureHWAndSCEP uses the SecureHW and SCEP packages that are used by Orbit. Only the TPM device is fake/simulated.
func testRealSecureHWAndSCEP(t *testing.T, s *Suite) {
t.Parallel()
ctx := t.Context()
// Create TPM simulator
sim, err := simulator.OpenSimulator()
require.NoError(t, err)
// Create a temporary directory for metadata
tempDir := t.TempDir()
// Create a zerolog logger for the test
zerologLogger := zerolog.New(os.Stdout).With().Timestamp().Logger()
// Create SecureHW instance with TPM simulator
tpmHW, err := securehw.NewTestSecureHW(sim, tempDir, zerologLogger)
require.NoError(t, err)
// Create a new key in the TPM
tpmKey, err := tpmHW.CreateKey()
require.NoError(t, err)
// Set up cleanup - the TPM hardware will be closed once at the end
t.Cleanup(func() {
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
pubKey, err := tpmKey.Public()
require.NoError(t, err)
eccPubKey, ok := pubKey.(*ecdsa.PublicKey)
require.True(t, ok, "Expected ECC public key")
// Create enrollment secret
err = s.DS.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{
{
Secret: testEnrollmentSecret,
},
})
require.NoError(t, err)
// Generate a unique common name
commonName := generateRandomString(16)
// Create SCEP client with the TPM key
scepClient, err := orbitscep.NewClient(
orbitscep.WithSigningKey(tpmKey),
orbitscep.WithURL(fmt.Sprintf("%s/api/fleet/orbit/host_identity/scep", s.Server.URL)),
orbitscep.WithCommonName(commonName),
orbitscep.WithChallenge(testEnrollmentSecret),
orbitscep.WithLogger(zerologLogger),
)
require.NoError(t, err)
// Fetch certificate using SCEP
cert, err := scepClient.FetchCert(ctx)
require.NoError(t, err)
require.NotNil(t, cert)
// Verify certificate properties
assert.Equal(t, commonName, cert.Subject.CommonName)
assert.Equal(t, x509.ECDSA, cert.PublicKeyAlgorithm)
// Verify the certificate's public key matches our TPM key
certPubKey, ok := cert.PublicKey.(*ecdsa.PublicKey)
require.True(t, ok, "Certificate should contain ECC public key")
assert.True(t, eccPubKey.Equal(certPubKey), "Certificate public key should match TPM key")
// Test enrollment with HTTP signature using TPM key
enrollRequest := contract.EnrollOrbitRequest{
EnrollSecret: testEnrollmentSecret,
HardwareUUID: "test-uuid-" + commonName,
HardwareSerial: "test-serial-" + commonName,
Hostname: "test-hostname-" + commonName,
OsqueryIdentifier: 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")
// Get HTTP signer from TPM key
httpSigner, err := tpmKey.HTTPSigner()
require.NoError(t, err)
// Determine algorithm based on the curve
var algo httpsig.Algorithm
switch httpSigner.ECCAlgorithm() {
case securehw.ECCAlgorithmP256:
algo = httpsig.Algo_ECDSA_P256_SHA256
case securehw.ECCAlgorithmP384:
algo = httpsig.Algo_ECDSA_P384_SHA384
default:
t.Fatalf("Unsupported ECC algorithm from TPM")
}
// Create HTTP signature signer
signer, err := fleethttpsig.Signer(
fmt.Sprintf("%d", cert.SerialNumber.Uint64()),
httpSigner,
algo,
)
require.NoError(t, err)
// Sign the request
err = signer.Sign(req)
require.NoError(t, err)
// Send the signed request
client := fleethttp.NewClient()
httpResp, err := client.Do(req)
require.NoError(t, err)
defer httpResp.Body.Close()
// The request with a valid HTTP signature should succeed
require.Equal(t, http.StatusOK, httpResp.StatusCode, "Request with TPM-based HTTP signature should succeed")
// Parse the response
var enrollResp enrollOrbitResponse
err = json.NewDecoder(httpResp.Body).Decode(&enrollResp)
require.NoError(t, err)
require.NotEmpty(t, enrollResp.OrbitNodeKey, "Should receive orbit node key")
require.NoError(t, enrollResp.Err)
// Test that we can load the key from storage
require.NoError(t, tpmKey.Close()) // Close the original key
loadedKey, err := tpmHW.LoadKey()
require.NoError(t, err)
// Verify loaded key has same public key
loadedPubKey, err := loadedKey.Public()
require.NoError(t, err)
loadedECCPubKey, ok := loadedPubKey.(*ecdsa.PublicKey)
require.True(t, ok, "Loaded key should be ECC")
assert.True(t, eccPubKey.Equal(loadedECCPubKey), "Loaded key should match original")
// Test config endpoint with loaded key
configRequest := orbitConfigRequest{
OrbitNodeKey: enrollResp.OrbitNodeKey,
}
configReqBody, err := json.Marshal(configRequest)
require.NoError(t, err)
configReq, err := http.NewRequest("POST", s.Server.URL+"/api/fleet/orbit/config", bytes.NewReader(configReqBody))
require.NoError(t, err)
configReq.Header.Set("Content-Type", "application/json")
// Sign with loaded key
loadedHTTPSigner, err := loadedKey.HTTPSigner()
require.NoError(t, err)
loadedSigner, err := fleethttpsig.Signer(
fmt.Sprintf("%d", cert.SerialNumber.Uint64()),
loadedHTTPSigner,
algo,
)
require.NoError(t, err)
err = loadedSigner.Sign(configReq)
require.NoError(t, err)
httpResp, err = client.Do(configReq)
require.NoError(t, err)
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()
})
})
}