diff --git a/doc/plugin_server_nodeattestor_azure_imds.md b/doc/plugin_server_nodeattestor_azure_imds.md index 5e442d42a5..3e0bdc3a87 100644 --- a/doc/plugin_server_nodeattestor_azure_imds.md +++ b/doc/plugin_server_nodeattestor_azure_imds.md @@ -20,6 +20,7 @@ attestation or to resolve selectors. | `tenants` | Required | A map of tenants, keyed by tenant domain, that are authorized for attestation. Tokens for unspecified tenants are rejected. | | | `agent_path_template` | Optional | A URL path portion format of Agent's SPIFFE ID. Describe in text/template format. | `"/{{ .PluginName }}/{{ .TenantID }}/{{ .SubscriptionID }}/{{ .VMID }}"` | | `allowed_metadata_domains` | Optional | A list of allowed Azure metadata domains for certificate validation. Each domain accepts the base domain (e.g., `metadata.azure.com`) and automatically validates certificates with that domain or any subdomain (e.g., `eastus.metadata.azure.com`, `sub.eastus.metadata.azure.com`). | `["metadata.azure.com"]` | +| `trust_bundle_path` | Optional | Path to a PEM file with one or more additional root CA certificates to trust when validating the signing certificate chain. These are added to (not a replacement for) the roots embedded in SPIRE, so operators can trust a new Azure root CA without a new SPIRE release. | | Each tenant in the main configuration supports the following @@ -217,6 +218,25 @@ NodeAttestor "azure_imds" { } ``` +#### Configuration with an Additional Trust Bundle + +This configuration trusts additional root CAs from a PEM file on disk, in +addition to the roots embedded in SPIRE. This lets operators react to an Azure +root CA change without waiting for a new SPIRE release: + +```hcl +NodeAttestor "azure_imds" { + plugin_data { + tenants = { + "example.onmicrosoft.com" = { + restrict_to_subscriptions = ["d5b40d61-272e-48da-beb9-05f295c42bd6"] + } + } + trust_bundle_path = "/opt/spire/conf/azure-roots.pem" + } +} +``` + ## Selectors The plugin produces the following selectors. diff --git a/pkg/server/plugin/nodeattestor/azureimds/doc_validation.go b/pkg/server/plugin/nodeattestor/azureimds/doc_validation.go index ce5aa3f5e1..5a0cf35176 100644 --- a/pkg/server/plugin/nodeattestor/azureimds/doc_validation.go +++ b/pkg/server/plugin/nodeattestor/azureimds/doc_validation.go @@ -27,7 +27,7 @@ const ( ) // ValidateAttestedDocument validates the Azure IMDS attested document signature -func validateAttestedDocument(ctx context.Context, doc *azure.AttestedDocument, allowedMetadataDomains []string) (*azure.AttestedDocumentContent, error) { +func validateAttestedDocument(ctx context.Context, doc *azure.AttestedDocument, allowedMetadataDomains []string, additionalRoots []*x509.Certificate) (*azure.AttestedDocumentContent, error) { if doc.Signature == "" { return nil, errors.New("missing signature in attested document") } @@ -80,8 +80,8 @@ func validateAttestedDocument(ctx context.Context, doc *azure.AttestedDocument, } // Step 8: Validate certificate chain - if err := validateCertificateChain(signingCert, intermediateCert); err != nil { - return nil, fmt.Errorf("certificate chain validation failed: %w", err) + if err := validateCertificateChain(signingCert, intermediateCert, additionalRoots); err != nil { + return nil, err } // Final step: Unmarshal the attested document payload @@ -168,8 +168,9 @@ func validateAzureCertificate(cert *x509.Certificate, baseDomains []string) erro cert.DNSNames, baseDomains) } -// validateCertificateChain validates the certificate chain against the DigiCert Global Root CA -func validateCertificateChain(signingCert, intermediateCert *x509.Certificate) error { +// validateCertificateChain validates the certificate chain against the embedded +// roots plus any operator-configured additional roots. +func validateCertificateChain(signingCert, intermediateCert *x509.Certificate, additionalRoots []*x509.Certificate) error { intermediates := x509.NewCertPool() if intermediateCert != nil { intermediates.AddCert(intermediateCert) @@ -187,6 +188,9 @@ func validateCertificateChain(signingCert, intermediateCert *x509.Certificate) e return fmt.Errorf("failed to parse root certificate at index %d", i) } } + for _, c := range additionalRoots { + rootCerts.AddCert(c) + } opts := x509.VerifyOptions{ Intermediates: intermediates, Roots: rootCerts, diff --git a/pkg/server/plugin/nodeattestor/azureimds/doc_validation_test.go b/pkg/server/plugin/nodeattestor/azureimds/doc_validation_test.go index 5a11e06405..2cec465581 100644 --- a/pkg/server/plugin/nodeattestor/azureimds/doc_validation_test.go +++ b/pkg/server/plugin/nodeattestor/azureimds/doc_validation_test.go @@ -324,7 +324,7 @@ func TestValidateCertificateChain(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { signingCert, intermediateCert := tt.setupCerts() - err := validateCertificateChain(signingCert, intermediateCert) + err := validateCertificateChain(signingCert, intermediateCert, nil) if tt.expectErr { require.Error(t, err) @@ -482,7 +482,7 @@ func TestValidateAttestedDocument(t *testing.T) { ctx := context.Background() allowedMetadataDomains := []string{DefaultMetadataDomain} - content, err := validateAttestedDocument(ctx, doc, allowedMetadataDomains) + content, err := validateAttestedDocument(ctx, doc, allowedMetadataDomains, nil) if tt.expectErr { require.Error(t, err) @@ -638,7 +638,7 @@ func TestValidateAttestedDocumentRejectsContentSignedByNonAzureCertificate(t *te content, err := validateAttestedDocument(context.Background(), &azure.AttestedDocument{ Encoding: "pkcs7-signature", Signature: base64.StdEncoding.EncodeToString(signature), - }, []string{DefaultMetadataDomain}) + }, []string{DefaultMetadataDomain}, nil) require.Error(t, err) require.Contains(t, err.Error(), "signing certificate validation failed") require.Nil(t, content) @@ -649,3 +649,37 @@ type roundTripFunc func(*http.Request) (*http.Response, error) func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) } + +func TestValidateCertificateChainWithAdditionalRoots(t *testing.T) { + // Root that is deliberately NOT in the embedded roots list. + rootKey := testkey.NewEC256(t) + rootCert := spiretest.SelfSignCertificateWithKey(t, &x509.Certificate{ + SerialNumber: big.NewInt(100), + Subject: pkix.Name{CommonName: "Custom Test Root CA"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour * 24 * 365), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + }, rootKey) + + signingKey := testkey.NewEC256(t) + signingCert := spiretest.CreateCertificate(t, &x509.Certificate{ + SerialNumber: big.NewInt(101), + Subject: pkix.Name{CommonName: "metadata.azure.com"}, + Issuer: pkix.Name{CommonName: "Custom Test Root CA"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour * 24 * 365), + }, rootCert, signingKey.Public(), rootKey) + + t.Run("rejected without the additional root", func(t *testing.T) { + err := validateCertificateChain(signingCert, nil, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "certificate chain validation failed") + }) + + t.Run("accepted when supplied as an additional root", func(t *testing.T) { + err := validateCertificateChain(signingCert, nil, []*x509.Certificate{rootCert}) + require.NoError(t, err) + }) +} diff --git a/pkg/server/plugin/nodeattestor/azureimds/imds.go b/pkg/server/plugin/nodeattestor/azureimds/imds.go index b4def2615a..3a3551e774 100644 --- a/pkg/server/plugin/nodeattestor/azureimds/imds.go +++ b/pkg/server/plugin/nodeattestor/azureimds/imds.go @@ -2,6 +2,7 @@ package azureimds import ( "context" + "crypto/x509" "encoding/json" "os" "sort" @@ -19,6 +20,7 @@ import ( "github.com/spiffe/spire/pkg/common/catalog" "github.com/spiffe/spire/pkg/common/plugin/azure" "github.com/spiffe/spire/pkg/common/pluginconf" + "github.com/spiffe/spire/pkg/common/util" nodeattestorbase "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/base" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -61,6 +63,7 @@ type IMDSAttestorConfig struct { Tenants map[string]*TenantConfig `hcl:"tenants" json:"tenants"` AgentPathTemplate string `hcl:"agent_path_template" json:"agent_path_template"` AllowedMetadataDomains []string `hcl:"allowed_metadata_domains" json:"allowed_metadata_domains"` + TrustBundlePath string `hcl:"trust_bundle_path" json:"trust_bundle_path"` } type tenantConfig struct { @@ -74,6 +77,7 @@ type imdsAttestorConfig struct { tenants map[string]*tenantConfig idPathTemplate *agentpathtemplate.Template allowedMetadataDomains []string + additionalRoots []*x509.Certificate } func (t *tenantConfig) subscriptionAllowed(subscriptionID string) bool { @@ -202,11 +206,21 @@ func (p *IMDSAttestorPlugin) buildConfig(coreConfig catalog.CoreConfig, hclText allowedMetadataDomains = []string{DefaultMetadataDomain} } + var additionalRoots []*x509.Certificate + if newConfig.TrustBundlePath != "" { + certs, err := util.LoadCertificates(newConfig.TrustBundlePath) + if err != nil { + status.ReportErrorf("unable to load trust bundle %q: %v", newConfig.TrustBundlePath, err) + } + additionalRoots = certs + } + return &imdsAttestorConfig{ td: coreConfig.TrustDomain, tenants: tenants, idPathTemplate: tmpl, allowedMetadataDomains: allowedMetadataDomains, + additionalRoots: additionalRoots, } } @@ -224,7 +238,7 @@ type IMDSAttestorPlugin struct { tenantIdMap map[string]string newClient func(azcore.TokenCredential) (apiClient, error) fetchCredential func(string) (azcore.TokenCredential, error) - validateAttestedDoc func(context.Context, *azure.AttestedDocument, []string) (*azure.AttestedDocumentContent, error) + validateAttestedDoc func(context.Context, *azure.AttestedDocument, []string, []*x509.Certificate) (*azure.AttestedDocumentContent, error) lookupTenantID func(string) (string, error) } } @@ -296,7 +310,7 @@ func (p *IMDSAttestorPlugin) Attest(stream nodeattestorv1.NodeAttestor_AttestSer } // parse the document - docData, err := p.hooks.validateAttestedDoc(stream.Context(), &attestationData.Document, config.allowedMetadataDomains) + docData, err := p.hooks.validateAttestedDoc(stream.Context(), &attestationData.Document, config.allowedMetadataDomains, config.additionalRoots) if err != nil { return status.Errorf(codes.InvalidArgument, "failed to validate attested document: %v", err) } diff --git a/pkg/server/plugin/nodeattestor/azureimds/imds_test.go b/pkg/server/plugin/nodeattestor/azureimds/imds_test.go index 97f94c1a17..19cb34eb18 100644 --- a/pkg/server/plugin/nodeattestor/azureimds/imds_test.go +++ b/pkg/server/plugin/nodeattestor/azureimds/imds_test.go @@ -2,12 +2,18 @@ package azureimds import ( "context" + "crypto/x509" + "crypto/x509/pkix" "encoding/json" "errors" "fmt" + "math/big" + "os" + "path/filepath" "slices" "sort" "testing" + "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" @@ -15,12 +21,14 @@ import ( agentstorev1 "github.com/spiffe/spire-plugin-sdk/proto/spire/hostservice/server/agentstore/v1" configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" "github.com/spiffe/spire/pkg/common/catalog" + "github.com/spiffe/spire/pkg/common/pemutil" "github.com/spiffe/spire/pkg/common/plugin/azure" "github.com/spiffe/spire/pkg/server/plugin/nodeattestor" "github.com/spiffe/spire/proto/spire/common" "github.com/spiffe/spire/test/fakes/fakeagentstore" "github.com/spiffe/spire/test/plugintest" "github.com/spiffe/spire/test/spiretest" + "github.com/spiffe/spire/test/testkey" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" ) @@ -606,6 +614,57 @@ func (s *IMDSAttestorSuite) TestConfigure() { require.NoError(t, err) }) + s.T().Run("trust_bundle_path loads additional roots", func(t *testing.T) { + key := testkey.NewEC256(t) + cert := spiretest.SelfSignCertificateWithKey(t, &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Custom Test Root CA"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour * 24 * 365), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + }, key) + bundlePath := filepath.Join(t.TempDir(), "roots.pem") + require.NoError(t, os.WriteFile(bundlePath, pemutil.EncodeCertificate(cert), 0600)) + + attestor := s.newTestAttestor() + v1 := new(nodeattestor.V1) + var err error + plugintest.Load(t, builtin(attestor), v1, + plugintest.CaptureConfigureError(&err), + plugintest.HostServices(agentstorev1.AgentStoreServiceServer(s.agentStore)), + plugintest.CoreConfig(coreConfig), + plugintest.Configure(fmt.Sprintf(` + tenants = { + "example.com" = {} + } + trust_bundle_path = %q + `, bundlePath)), + ) + require.NoError(t, err) + require.Len(t, attestor.config.additionalRoots, 1) + require.Equal(t, cert.Raw, attestor.config.additionalRoots[0].Raw) + }) + + s.T().Run("trust_bundle_path missing file", func(t *testing.T) { + attestor := s.newTestAttestor() + v1 := new(nodeattestor.V1) + var err error + plugintest.Load(t, builtin(attestor), v1, + plugintest.CaptureConfigureError(&err), + plugintest.HostServices(agentstorev1.AgentStoreServiceServer(s.agentStore)), + plugintest.CoreConfig(coreConfig), + plugintest.Configure(` + tenants = { + "example.com" = {} + } + trust_bundle_path = "/does/not/exist.pem" + `), + ) + spiretest.RequireGRPCStatusContains(t, err, codes.InvalidArgument, "unable to load trust bundle") + }) + s.T().Run("success with secret auth", func(t *testing.T) { attestor := s.newTestAttestor() @@ -794,7 +853,7 @@ func (s *IMDSAttestorSuite) loadPluginWithChallengeHandler( } return domain, nil } - attestor.hooks.validateAttestedDoc = func(ctx context.Context, doc *azure.AttestedDocument, allowedMetadataDomains []string) (*azure.AttestedDocumentContent, error) { + attestor.hooks.validateAttestedDoc = func(ctx context.Context, doc *azure.AttestedDocument, allowedMetadataDomains []string, additionalRoots []*x509.Certificate) (*azure.AttestedDocumentContent, error) { s.lastValidatedDoc = doc var ( content *azure.AttestedDocumentContent