diff --git a/go.mod b/go.mod index addfd096ae..40aeac2efa 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/bytecodealliance/wasmtime-go/v28 v28.0.0 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/dominikbraun/graph v0.23.0 - github.com/fxamacker/cbor/v2 v2.7.0 + github.com/fxamacker/cbor/v2 v2.9.0 github.com/gagliardetto/utilz v0.1.3 github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 github.com/go-playground/validator/v10 v10.26.0 diff --git a/go.sum b/go.sum index b90e6b6184..92713f3607 100644 --- a/go.sum +++ b/go.sum @@ -61,8 +61,8 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= -github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gagliardetto/hashsearch v0.0.0-20191005111333-09dd671e19f9/go.mod h1:513DXpQPzeRo7d4dsCP3xO3XI8hgvruMl9njxyQeraQ= diff --git a/pkg/capabilities/v2/actions/confidentialrelay/types.go b/pkg/capabilities/v2/actions/confidentialrelay/types.go index 89044ec701..3c5652992b 100644 --- a/pkg/capabilities/v2/actions/confidentialrelay/types.go +++ b/pkg/capabilities/v2/actions/confidentialrelay/types.go @@ -41,6 +41,9 @@ type SecretsResponseResult struct { // CapabilityRequestParams is the JSON-RPC params for "confidential.capability.execute". type CapabilityRequestParams struct { WorkflowID string `json:"workflow_id"` + Owner string `json:"owner,omitempty"` + ExecutionID string `json:"execution_id,omitempty"` + ReferenceID string `json:"reference_id,omitempty"` CapabilityID string `json:"capability_id"` Payload string `json:"payload"` Attestation string `json:"attestation,omitempty"` diff --git a/pkg/teeattestation/hash.go b/pkg/teeattestation/hash.go new file mode 100644 index 0000000000..e6bec6578c --- /dev/null +++ b/pkg/teeattestation/hash.go @@ -0,0 +1,20 @@ +// Package teeattestation provides platform-agnostic primitives for TEE +// attestation validation. Platform-specific validators (e.g. AWS Nitro) +// live in subpackages. +package teeattestation + +import "crypto/sha256" + +// DomainSeparator is prepended to attestation payloads before hashing. +const DomainSeparator = "CONFIDENTIAL_COMPUTE_PAYLOAD" + +// DomainHash computes SHA-256 over DomainSeparator + "\n" + tag + "\n" + data. +// This is the standard domain-separated hash used for attestation UserData +// throughout the system. +func DomainHash(tag string, data []byte) []byte { + h := sha256.New() + h.Write([]byte(DomainSeparator)) + h.Write([]byte("\n" + tag + "\n")) + h.Write(data) + return h.Sum(nil) +} diff --git a/pkg/teeattestation/hash_test.go b/pkg/teeattestation/hash_test.go new file mode 100644 index 0000000000..11804f6d25 --- /dev/null +++ b/pkg/teeattestation/hash_test.go @@ -0,0 +1,54 @@ +package teeattestation + +import ( + "crypto/sha256" + "testing" +) + +func TestDomainHash(t *testing.T) { + tag := "TestTag" + data := []byte(`{"key":"value"}`) + + got := DomainHash(tag, data) + + h := sha256.New() + h.Write([]byte(DomainSeparator)) + h.Write([]byte("\n" + tag + "\n")) + h.Write(data) + want := h.Sum(nil) + + if len(got) != sha256.Size { + t.Fatalf("expected %d bytes, got %d", sha256.Size, len(got)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("hash mismatch at byte %d: want %x, got %x", i, want, got) + } + } +} + +func TestDomainHash_DifferentTags(t *testing.T) { + data := []byte("same-data") + h1 := DomainHash("Tag1", data) + h2 := DomainHash("Tag2", data) + + for i := range h1 { + if h1[i] != h2[i] { + return + } + } + t.Fatal("different tags should produce different hashes") +} + +func TestDomainHash_DifferentData(t *testing.T) { + tag := "SameTag" + h1 := DomainHash(tag, []byte("data-a")) + h2 := DomainHash(tag, []byte("data-b")) + + for i := range h1 { + if h1[i] != h2[i] { + return + } + } + t.Fatal("different data should produce different hashes") +} diff --git a/pkg/teeattestation/nitro/fake/fake.go b/pkg/teeattestation/nitro/fake/fake.go new file mode 100644 index 0000000000..4e781247b1 --- /dev/null +++ b/pkg/teeattestation/nitro/fake/fake.go @@ -0,0 +1,218 @@ +// Package nitrofake provides an Attestor that produces structurally valid +// COSE Sign1 attestation documents. These documents pass the local Nitro +// attestation validator's full validation chain (CBOR parsing, cert chain, +// ECDSA signature, UserData, PCRs) without requiring real Nitro hardware. +package nitrofake + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha512" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/pem" + "fmt" + "math/big" + "time" + + "github.com/fxamacker/cbor/v2" +) + +// Attestor produces structurally valid COSE Sign1 attestation documents +// that pass the local Nitro validator with a custom CA root. +type Attestor struct { + rootKey *ecdsa.PrivateKey + rootCert *x509.Certificate + rootCertDER []byte + leafKey *ecdsa.PrivateKey + leafCert *x509.Certificate + leafCertDER []byte + pcrs map[uint][]byte +} + +// NewAttestor generates a self-signed P-384 root CA, a leaf cert signed +// by that root, and deterministic 48-byte fake PCR values. +func NewAttestor() (*Attestor, error) { + rootKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate root key: %w", err) + } + rootTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Fake Nitro Root CA"}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + IsCA: true, + BasicConstraintsValid: true, + } + rootCertDER, err := x509.CreateCertificate(rand.Reader, rootTemplate, rootTemplate, &rootKey.PublicKey, rootKey) + if err != nil { + return nil, fmt.Errorf("create root cert: %w", err) + } + rootCert, err := x509.ParseCertificate(rootCertDER) + if err != nil { + return nil, fmt.Errorf("parse root cert: %w", err) + } + + leafKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate leaf key: %w", err) + } + leafTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "Fake Nitro Enclave"}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + SignatureAlgorithm: x509.ECDSAWithSHA384, + } + leafCertDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, rootCert, &leafKey.PublicKey, rootKey) + if err != nil { + return nil, fmt.Errorf("create leaf cert: %w", err) + } + leafCert, err := x509.ParseCertificate(leafCertDER) + if err != nil { + return nil, fmt.Errorf("parse leaf cert: %w", err) + } + + pcrs := map[uint][]byte{ + 0: sha384Sum([]byte("fake-pcr-0")), + 1: sha384Sum([]byte("fake-pcr-1")), + 2: sha384Sum([]byte("fake-pcr-2")), + } + + return &Attestor{ + rootKey: rootKey, + rootCert: rootCert, + rootCertDER: rootCertDER, + leafKey: leafKey, + leafCert: leafCert, + leafCertDER: leafCertDER, + pcrs: pcrs, + }, nil +} + +// CreateAttestation builds a COSE Sign1 document encoding a Nitro-like +// attestation with the given userData. +func (f *Attestor) CreateAttestation(userData []byte) ([]byte, error) { + doc := attestationDocument{ + ModuleID: "fake-enclave-module", + Timestamp: uint64(time.Now().UnixMilli()), //nolint:gosec // timestamp is always positive + Digest: "SHA384", + PCRs: f.pcrs, + Certificate: f.leafCertDER, + CABundle: [][]byte{f.rootCertDER}, + UserData: userData, + } + + payloadBytes, err := cbor.Marshal(doc) + if err != nil { + return nil, fmt.Errorf("cbor encode document: %w", err) + } + + header := coseHeader{Alg: int64(-35)} + protectedBytes, err := cbor.Marshal(header) + if err != nil { + return nil, fmt.Errorf("cbor encode protected header: %w", err) + } + + sigStruct := coseSignature{ + Context: "Signature1", + Protected: protectedBytes, + ExternalAAD: []byte{}, + Payload: payloadBytes, + } + sigStructBytes, err := cbor.Marshal(sigStruct) + if err != nil { + return nil, fmt.Errorf("cbor encode sig structure: %w", err) + } + + hash := sha512.Sum384(sigStructBytes) + r, s, err := ecdsa.Sign(rand.Reader, f.leafKey, hash[:]) + if err != nil { + return nil, fmt.Errorf("ecdsa sign: %w", err) + } + + signature := make([]byte, 96) + rBytes := r.Bytes() + sBytes := s.Bytes() + copy(signature[48-len(rBytes):48], rBytes) + copy(signature[96-len(sBytes):96], sBytes) + + outer := cosePayload{ + Protected: protectedBytes, + Payload: payloadBytes, + Signature: signature, + } + result, err := cbor.Marshal(outer) + if err != nil { + return nil, fmt.Errorf("cbor encode cose sign1: %w", err) + } + return result, nil +} + +// CARoots returns an x509.CertPool containing the fake root CA certificate. +func (f *Attestor) CARoots() *x509.CertPool { + pool := x509.NewCertPool() + pool.AddCert(f.rootCert) + return pool +} + +// CARootsPEM returns the root CA certificate in PEM format. +func (f *Attestor) CARootsPEM() string { + return string(pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: f.rootCertDER, + })) +} + +// TrustedPCRsJSON returns the PCR values as a JSON object matching the +// format expected by the attestation validator. +func (f *Attestor) TrustedPCRsJSON() []byte { + return []byte(fmt.Sprintf(`{"pcr0":"%s","pcr1":"%s","pcr2":"%s"}`, + hex.EncodeToString(f.pcrs[0]), + hex.EncodeToString(f.pcrs[1]), + hex.EncodeToString(f.pcrs[2]), + )) +} + +func sha384Sum(data []byte) []byte { + h := sha512.Sum384(data) + return h[:] +} + +type attestationDocument struct { + ModuleID string `cbor:"module_id"` + Timestamp uint64 `cbor:"timestamp"` + Digest string `cbor:"digest"` + PCRs map[uint][]byte `cbor:"pcrs"` + Certificate []byte `cbor:"certificate"` + CABundle [][]byte `cbor:"cabundle"` + PublicKey []byte `cbor:"public_key,omitempty"` + UserData []byte `cbor:"user_data,omitempty"` + Nonce []byte `cbor:"nonce,omitempty"` +} + +type coseHeader struct { + Alg int64 `cbor:"1,keyasint"` +} + +type cosePayload struct { + _ struct{} `cbor:",toarray"` //nolint:revive // idiomatic CBOR array encoding + Protected []byte + Unprotected cbor.RawMessage + Payload []byte + Signature []byte +} + +type coseSignature struct { + _ struct{} `cbor:",toarray"` //nolint:revive // idiomatic CBOR array encoding + Context string + Protected []byte + ExternalAAD []byte + Payload []byte +} diff --git a/pkg/teeattestation/nitro/fake/fake_test.go b/pkg/teeattestation/nitro/fake/fake_test.go new file mode 100644 index 0000000000..f8fb78913e --- /dev/null +++ b/pkg/teeattestation/nitro/fake/fake_test.go @@ -0,0 +1,42 @@ +package nitrofake + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/teeattestation/nitro" +) + +func TestAttestor_RoundTrip(t *testing.T) { + fa, err := NewAttestor() + require.NoError(t, err) + + userData := []byte("test-user-data-12345") + attestation, err := fa.CreateAttestation(userData) + require.NoError(t, err) + require.NotEmpty(t, attestation) + + err = nitro.ValidateAttestationWithRoots(attestation, userData, fa.TrustedPCRsJSON(), fa.CARootsPEM()) + require.NoError(t, err) +} + +func TestAttestor_TrustedPCRsJSON(t *testing.T) { + fa, err := NewAttestor() + require.NoError(t, err) + + pcrsJSON := fa.TrustedPCRsJSON() + require.NotEmpty(t, pcrsJSON) + require.Contains(t, string(pcrsJSON), `"pcr0"`) + require.Contains(t, string(pcrsJSON), `"pcr1"`) + require.Contains(t, string(pcrsJSON), `"pcr2"`) +} + +func TestAttestor_CARootsPEM(t *testing.T) { + fa, err := NewAttestor() + require.NoError(t, err) + + pemStr := fa.CARootsPEM() + require.Contains(t, pemStr, "BEGIN CERTIFICATE") + require.Contains(t, pemStr, "END CERTIFICATE") +} diff --git a/pkg/teeattestation/nitro/validate.go b/pkg/teeattestation/nitro/validate.go new file mode 100644 index 0000000000..4d56721646 --- /dev/null +++ b/pkg/teeattestation/nitro/validate.go @@ -0,0 +1,101 @@ +// Package nitro provides AWS Nitro Enclave attestation validation. +package nitro + +import ( + "bytes" + "crypto/x509" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "time" +) + +// HexBytes is a custom type that unmarshals hex strings into a byte slice +// and marshals byte slices back to hex strings. This allows parsing AWS Nitro +// measurements, which use hex byte strings in JSON. +type HexBytes []byte + +func (h *HexBytes) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return fmt.Errorf("HexBytes: cannot unmarshal JSON into string: %w", err) + } + + decoded, err := hex.DecodeString(s) + if err != nil { + return fmt.Errorf("HexBytes: failed to decode hex string '%s': %w", s, err) + } + *h = decoded + return nil +} + +func (h HexBytes) MarshalJSON() ([]byte, error) { + s := hex.EncodeToString(h) + return json.Marshal(s) +} + +// PCRs holds Platform Configuration Register values for attestation validation. +type PCRs struct { + PCR0 HexBytes `json:"pcr0"` + PCR1 HexBytes `json:"pcr1"` + PCR2 HexBytes `json:"pcr2"` +} + +// DefaultCARoots is the AWS Nitro Enclaves root certificate. +// Downloaded from: https://aws-nitro-enclaves.amazonaws.com/AWS_NitroEnclaves_Root-G1.zip +const DefaultCARoots = "-----BEGIN CERTIFICATE-----\nMIICETCCAZagAwIBAgIRAPkxdWgbkK/hHUbMtOTn+FYwCgYIKoZIzj0EAwMwSTEL\nMAkGA1UEBhMCVVMxDzANBgNVBAoMBkFtYXpvbjEMMAoGA1UECwwDQVdTMRswGQYD\nVQQDDBJhd3Mubml0cm8tZW5jbGF2ZXMwHhcNMTkxMDI4MTMyODA1WhcNNDkxMDI4\nMTQyODA1WjBJMQswCQYDVQQGEwJVUzEPMA0GA1UECgwGQW1hem9uMQwwCgYDVQQL\nDANBV1MxGzAZBgNVBAMMEmF3cy5uaXRyby1lbmNsYXZlczB2MBAGByqGSM49AgEG\nBSuBBAAiA2IABPwCVOumCMHzaHDimtqQvkY4MpJzbolL//Zy2YlES1BR5TSksfbb\n48C8WBoyt7F2Bw7eEtaaP+ohG2bnUs990d0JX28TcPQXCEPZ3BABIeTPYwEoCWZE\nh8l5YoQwTcU/9KNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUkCW1DdkF\nR+eWw5b6cp3PmanfS5YwDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMDA2kAMGYC\nMQCjfy+Rocm9Xue4YnwWmNJVA44fA0P5W2OpYow9OYCVRaEevL8uO1XYru5xtMPW\nrfMCMQCi85sWBbJwKKXdS6BptQFuZbT73o/gBh1qUxl/nNr12UO8Yfwr6wPLb+6N\nIwLz3/Y=\n-----END CERTIFICATE-----\n" + +// ValidateAttestation verifies an AWS Nitro attestation document against +// expected user data and trusted PCR measurements. Always validates against +// the AWS Nitro Enclaves root certificate. +// +// For testing with fake enclaves, use ValidateAttestationWithRoots or inject +// a custom validator function. +func ValidateAttestation(attestation, expectedUserData, trustedMeasurements []byte) error { + return ValidateAttestationWithRoots(attestation, expectedUserData, trustedMeasurements, DefaultCARoots) +} + +// ValidateAttestationWithRoots verifies an AWS Nitro attestation document +// using a custom CA root certificate. This is primarily for testing with +// fake enclaves that use self-signed CA roots. +func ValidateAttestationWithRoots(attestation, expectedUserData, trustedMeasurements []byte, caRootsPEM string) error { + if attestation == nil { + return errors.New("attestation is nil") + } + + pool := x509.NewCertPool() + ok := pool.AppendCertsFromPEM([]byte(caRootsPEM)) + if !ok { + return errors.New("failed to parse CA roots") + } + result, err := verifyAttestationDocument(attestation, pool, time.Now()) + if err != nil { + return fmt.Errorf("failed to verify nitro attestation: %w", err) + } + if !result.signatureOK { + return errors.New("signature verification failed") + } + + if !bytes.Equal(expectedUserData, result.document.UserData) { + return fmt.Errorf("expected user data %x, got %x", expectedUserData, result.document.UserData) + } + + var trustedPCRs PCRs + if err := json.Unmarshal(trustedMeasurements, &trustedPCRs); err != nil { + return fmt.Errorf("failed to unmarshal trusted PCRs: %w", err) + } + if len(result.document.PCRs) < 3 { + return fmt.Errorf("attestation document has %d PCRs, need at least 3", len(result.document.PCRs)) + } + if !bytes.Equal(result.document.PCRs[0], trustedPCRs.PCR0) { + return fmt.Errorf("PCR0 mismatch: expected %x", trustedPCRs.PCR0) + } + if !bytes.Equal(result.document.PCRs[1], trustedPCRs.PCR1) { + return fmt.Errorf("PCR1 mismatch: expected %x", trustedPCRs.PCR1) + } + if !bytes.Equal(result.document.PCRs[2], trustedPCRs.PCR2) { + return fmt.Errorf("PCR2 mismatch: expected %x", trustedPCRs.PCR2) + } + return nil +} diff --git a/pkg/teeattestation/nitro/validate_test.go b/pkg/teeattestation/nitro/validate_test.go new file mode 100644 index 0000000000..630906e6f9 --- /dev/null +++ b/pkg/teeattestation/nitro/validate_test.go @@ -0,0 +1,50 @@ +package nitro + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/teeattestation" + "github.com/smartcontractkit/chainlink-common/pkg/teeattestation/nitro/fake" +) + +func TestValidateAttestation_Attestor(t *testing.T) { + fa, err := nitrofake.NewAttestor() + require.NoError(t, err) + + userData := teeattestation.DomainHash("test-tag", []byte(`{"key":"value"}`)) + doc, err := fa.CreateAttestation(userData) + require.NoError(t, err) + + err = ValidateAttestationWithRoots(doc, userData, fa.TrustedPCRsJSON(), fa.CARootsPEM()) + require.NoError(t, err) +} + +func TestValidateAttestation_WrongUserData(t *testing.T) { + fa, err := nitrofake.NewAttestor() + require.NoError(t, err) + + userData := teeattestation.DomainHash("test-tag", []byte(`{"key":"value"}`)) + doc, err := fa.CreateAttestation(userData) + require.NoError(t, err) + + wrongData := teeattestation.DomainHash("wrong-tag", []byte(`{"key":"value"}`)) + err = ValidateAttestationWithRoots(doc, wrongData, fa.TrustedPCRsJSON(), fa.CARootsPEM()) + require.Error(t, err) + require.Contains(t, err.Error(), "expected user data") +} + +func TestValidateAttestation_WrongPCRs(t *testing.T) { + fa, err := nitrofake.NewAttestor() + require.NoError(t, err) + + userData := []byte("test-data") + doc, err := fa.CreateAttestation(userData) + require.NoError(t, err) + + wrongPCRs := []byte(`{"pcr0":"aa","pcr1":"bb","pcr2":"cc"}`) + err = ValidateAttestationWithRoots(doc, userData, wrongPCRs, fa.CARootsPEM()) + require.Error(t, err) + require.Contains(t, err.Error(), "PCR0 mismatch") +} diff --git a/pkg/teeattestation/nitro/verify.go b/pkg/teeattestation/nitro/verify.go new file mode 100644 index 0000000000..0c3676a540 --- /dev/null +++ b/pkg/teeattestation/nitro/verify.go @@ -0,0 +1,251 @@ +package nitro + +import ( + "crypto/ecdsa" + "crypto/sha256" + "crypto/sha512" + "crypto/x509" + "errors" + "fmt" + "math/big" + "time" + + "github.com/fxamacker/cbor/v2" +) + +var ( + errBadCOSESign1Structure = errors.New("data is not a COSE Sign1 array") + errEmptyProtectedSection = errors.New("COSE Sign1 protected section is nil or empty") + errEmptyPayloadSection = errors.New("COSE Sign1 payload section is nil or empty") + errEmptySignatureSection = errors.New("COSE Sign1 signature section is nil or empty") + errUnsupportedSignatureAlgorithm = errors.New("COSE Sign1 algorithm is not ECDSA P-384") + errBadAttestationDocument = errors.New("bad attestation document") + errMandatoryFieldsMissing = errors.New("attestation document is missing mandatory fields") + errBadDigest = errors.New("attestation digest is not SHA384") + errBadTimestamp = errors.New("attestation timestamp is 0") + errBadPCRs = errors.New("attestation pcrs is less than 1 or more than 32") + errBadPCRIndex = errors.New("attestation pcr index is not in [0, 32)") + errBadPCRValue = errors.New("attestation pcr value length is invalid") + errBadCABundle = errors.New("attestation cabundle is empty") + errBadCABundleItem = errors.New("attestation cabundle item is empty or too large") + errBadPublicKey = errors.New("attestation public_key length is invalid") + errBadUserData = errors.New("attestation user_data length is invalid") + errBadNonce = errors.New("attestation nonce length is invalid") + errBadCertificatePublicKeyAlgo = errors.New("attestation certificate public key algorithm is not ECDSA") + errBadCertificateSigningAlgo = errors.New("attestation certificate signature algorithm is not ECDSAWithSHA384") + errBadSignature = errors.New("attestation signature does not match certificate") +) + +type verifyResult struct { + document *attestationDocument + signatureOK bool +} + +type attestationDocument struct { + ModuleID string `cbor:"module_id"` + Timestamp uint64 `cbor:"timestamp"` + Digest string `cbor:"digest"` + PCRs map[uint][]byte `cbor:"pcrs"` + Certificate []byte `cbor:"certificate"` + CABundle [][]byte `cbor:"cabundle"` + PublicKey []byte `cbor:"public_key,omitempty"` + UserData []byte `cbor:"user_data,omitempty"` + Nonce []byte `cbor:"nonce,omitempty"` +} + +type coseProtectedHeader struct { + Alg any `cbor:"1,keyasint,omitempty"` +} + +type coseSign1 struct { + _ struct{} `cbor:",toarray"` //nolint:revive // idiomatic CBOR array encoding + Protected []byte + Unprotected cbor.RawMessage + Payload []byte + Signature []byte +} + +type coseSignatureInput struct { + _ struct{} `cbor:",toarray"` //nolint:revive // idiomatic CBOR array encoding + Context string + Protected []byte + ExternalAAD []byte + Payload []byte +} + +func verifyAttestationDocument(data []byte, roots *x509.CertPool, currentTime time.Time) (*verifyResult, error) { + var sign1 coseSign1 + if err := cbor.Unmarshal(data, &sign1); err != nil { + return nil, errBadCOSESign1Structure + } + if len(sign1.Protected) == 0 { + return nil, errEmptyProtectedSection + } + if len(sign1.Payload) == 0 { + return nil, errEmptyPayloadSection + } + if len(sign1.Signature) == 0 { + return nil, errEmptySignatureSection + } + + var protected coseProtectedHeader + if err := cbor.Unmarshal(sign1.Protected, &protected); err != nil { + return nil, errBadCOSESign1Structure + } + if err := validateProtectedAlgorithm(protected.Alg); err != nil { + return nil, err + } + + var doc attestationDocument + if err := cbor.Unmarshal(sign1.Payload, &doc); err != nil { + return nil, errBadAttestationDocument + } + if err := validateAttestationPayload(&doc); err != nil { + return nil, err + } + + leafCert, intermediates, err := parseCertificateChain(&doc) + if err != nil { + return nil, err + } + if currentTime.IsZero() { + currentTime = time.Now() + } + if _, err := leafCert.Verify(x509.VerifyOptions{ + Intermediates: intermediates, + Roots: roots, + CurrentTime: currentTime, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + }); err != nil { + return nil, err + } + + sigStructure, err := cbor.Marshal(&coseSignatureInput{ + Context: "Signature1", + Protected: sign1.Protected, + ExternalAAD: []byte{}, + Payload: sign1.Payload, + }) + if err != nil { + return nil, fmt.Errorf("build signature structure: %w", err) + } + + pubKey, ok := leafCert.PublicKey.(*ecdsa.PublicKey) + if !ok { + return nil, errBadCertificatePublicKeyAlgo + } + signatureOK := verifyECDSASignature(pubKey, sigStructure, sign1.Signature) + if !signatureOK { + return &verifyResult{document: &doc, signatureOK: false}, errBadSignature + } + + return &verifyResult{document: &doc, signatureOK: true}, nil +} + +func validateProtectedAlgorithm(alg any) error { + switch v := alg.(type) { + case int64: + if v == -35 { + return nil + } + case string: + if v == "ES384" { + return nil + } + } + return errUnsupportedSignatureAlgorithm +} + +func validateAttestationPayload(doc *attestationDocument) error { + if doc.ModuleID == "" || doc.Digest == "" || doc.Timestamp == 0 || doc.PCRs == nil || doc.Certificate == nil || doc.CABundle == nil { + return errMandatoryFieldsMissing + } + if doc.Digest != "SHA384" { + return errBadDigest + } + if doc.Timestamp < 1 { + return errBadTimestamp + } + if len(doc.PCRs) < 1 || len(doc.PCRs) > 32 { + return errBadPCRs + } + for idx, value := range doc.PCRs { + if idx > 31 { + return errBadPCRIndex + } + if value == nil || (len(value) != 32 && len(value) != 48 && len(value) != 64) { + return errBadPCRValue + } + } + if len(doc.CABundle) < 1 { + return errBadCABundle + } + for _, item := range doc.CABundle { + if item == nil || len(item) < 1 || len(item) > 1024 { + return errBadCABundleItem + } + } + if doc.PublicKey != nil && len(doc.PublicKey) > 1024 { + return errBadPublicKey + } + if doc.UserData != nil && len(doc.UserData) > 1024 { + return errBadUserData + } + if doc.Nonce != nil && len(doc.Nonce) > 1024 { + return errBadNonce + } + return nil +} + +func parseCertificateChain(doc *attestationDocument) (*x509.Certificate, *x509.CertPool, error) { + leafCert, err := x509.ParseCertificate(doc.Certificate) + if err != nil { + return nil, nil, err + } + if leafCert.PublicKeyAlgorithm != x509.ECDSA { + return nil, nil, errBadCertificatePublicKeyAlgo + } + if leafCert.SignatureAlgorithm != x509.ECDSAWithSHA384 { + return nil, nil, errBadCertificateSigningAlgo + } + + intermediates := x509.NewCertPool() + for _, der := range doc.CABundle { + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, nil, err + } + intermediates.AddCert(cert) + } + return leafCert, intermediates, nil +} + +func verifyECDSASignature(publicKey *ecdsa.PublicKey, sigStructure, signature []byte) bool { + hash, ok := hashForCurve(publicKey, sigStructure) + if !ok || len(signature) != 2*len(hash) { + return false + } + + r := new(big.Int).SetBytes(signature[:len(hash)]) + s := new(big.Int).SetBytes(signature[len(hash):]) + return ecdsa.Verify(publicKey, hash, r, s) +} + +func hashForCurve(publicKey *ecdsa.PublicKey, sigStructure []byte) ([]byte, bool) { + switch publicKey.Curve.Params().Name { + case "P-224": + sum := sha256.Sum224(sigStructure) + return sum[:], true + case "P-256": + sum := sha256.Sum256(sigStructure) + return sum[:], true + case "P-384": + sum := sha512.Sum384(sigStructure) + return sum[:], true + case "P-512": + sum := sha512.Sum512(sigStructure) + return sum[:], true + default: + return nil, false + } +}