Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions doc/plugin_server_nodeattestor_azure_imds.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, letting operators trust a new Azure root CA without waiting for a new SPIRE release. | |

Each tenant in the main configuration supports the following

Expand Down Expand Up @@ -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.
Expand Down
12 changes: 8 additions & 4 deletions pkg/server/plugin/nodeattestor/azureimds/doc_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -80,7 +80,7 @@ func validateAttestedDocument(ctx context.Context, doc *azure.AttestedDocument,
}

// Step 8: Validate certificate chain
if err := validateCertificateChain(signingCert, intermediateCert); err != nil {
if err := validateCertificateChain(signingCert, intermediateCert, additionalRoots); err != nil {
return nil, fmt.Errorf("certificate chain validation failed: %w", err)
}
Comment thread
ravishen marked this conversation as resolved.

Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
40 changes: 37 additions & 3 deletions pkg/server/plugin/nodeattestor/azureimds/doc_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
})
}
18 changes: 16 additions & 2 deletions pkg/server/plugin/nodeattestor/azureimds/imds.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package azureimds

import (
"context"
"crypto/x509"
"encoding/json"
"os"
"sort"
Expand All @@ -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"
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
}
}

Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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)
}
Expand Down
59 changes: 58 additions & 1 deletion pkg/server/plugin/nodeattestor/azureimds/imds_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,33 @@ 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"
"github.com/spiffe/go-spiffe/v2/spiffeid"
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"
)
Expand Down Expand Up @@ -606,6 +614,55 @@ 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)
})
Comment thread
ravishen marked this conversation as resolved.

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

Expand Down Expand Up @@ -794,7 +851,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
Expand Down
Loading