diff --git a/pkg/teeattestation/go.mod b/pkg/teeattestation/go.mod new file mode 100644 index 000000000..c4af6c486 --- /dev/null +++ b/pkg/teeattestation/go.mod @@ -0,0 +1,16 @@ +module github.com/smartcontractkit/chainlink-common/pkg/teeattestation + +go 1.25.3 + +require ( + github.com/fxamacker/cbor/v2 v2.9.0 + github.com/hf/nitrite v0.0.0-20241225144000-c2d5d3c4f303 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/pkg/teeattestation/go.sum b/pkg/teeattestation/go.sum new file mode 100644 index 000000000..b09a296e8 --- /dev/null +++ b/pkg/teeattestation/go.sum @@ -0,0 +1,17 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fxamacker/cbor/v2 v2.2.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= +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/hf/nitrite v0.0.0-20241225144000-c2d5d3c4f303 h1:XBSq4rXFUgD8ic6Mr7dBwJN/47yg87XpZQhiknfr4Cg= +github.com/hf/nitrite v0.0.0-20241225144000-c2d5d3c4f303/go.mod h1:ycRhVmo6wegyEl6WN+zXOHUTJvB0J2tiuH88q/McTK8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/teeattestation/hash.go b/pkg/teeattestation/hash.go new file mode 100644 index 000000000..e6bec6578 --- /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 000000000..11804f6d2 --- /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 000000000..0551bce5c --- /dev/null +++ b/pkg/teeattestation/nitro/fake/fake.go @@ -0,0 +1,222 @@ +// Package fake provides a FakeAttestor that produces structurally valid +// COSE Sign1 attestation documents. These documents pass nitrite.Verify's +// full validation chain (CBOR parsing, cert chain, ECDSA signature, UserData, +// PCRs) without requiring real Nitro hardware. +package fake + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha512" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "math/big" + "time" + + "github.com/fxamacker/cbor/v2" +) + +// FakeAttestor produces structurally valid COSE Sign1 attestation documents +// that pass nitrite.Verify with a custom CA root. +type FakeAttestor struct { + rootKey *ecdsa.PrivateKey + rootCert *x509.Certificate + rootCertDER []byte + leafKey *ecdsa.PrivateKey + leafCert *x509.Certificate + leafCertDER []byte + pcrs map[uint][]byte +} + +// NewFakeAttestor generates a self-signed P-384 root CA, a leaf cert signed +// by that root, and deterministic 48-byte fake PCR values. +func NewFakeAttestor() (*FakeAttestor, 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 &FakeAttestor{ + 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 *FakeAttestor) CreateAttestation(userData []byte) ([]byte, error) { + doc := attestationDocument{ + ModuleID: "fake-enclave-module", + Timestamp: uint64(time.Now().UnixMilli()), + 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 *FakeAttestor) CARoots() *x509.CertPool { + pool := x509.NewCertPool() + pool.AddCert(f.rootCert) + return pool +} + +// CARootsPEM returns the root CA certificate in PEM format. +func (f *FakeAttestor) 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 *FakeAttestor) TrustedPCRsJSON() []byte { + m := map[string]string{ + "pcr0": hex.EncodeToString(f.pcrs[0]), + "pcr1": hex.EncodeToString(f.pcrs[1]), + "pcr2": hex.EncodeToString(f.pcrs[2]), + } + // json.Marshal on map[string]string cannot fail. + b, _ := json.Marshal(m) + return b +} + +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"` + Protected []byte + Unprotected cbor.RawMessage + Payload []byte + Signature []byte +} + +type coseSignature struct { + _ struct{} `cbor:",toarray"` + 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 000000000..4a1e1e563 --- /dev/null +++ b/pkg/teeattestation/nitro/fake/fake_test.go @@ -0,0 +1,53 @@ +package fake + +import ( + "testing" + "time" + + "github.com/hf/nitrite" + "github.com/stretchr/testify/require" +) + +func TestFakeAttestor_RoundTrip(t *testing.T) { + fa, err := NewFakeAttestor() + require.NoError(t, err) + + userData := []byte("test-user-data-12345") + attestation, err := fa.CreateAttestation(userData) + require.NoError(t, err) + require.NotEmpty(t, attestation) + + result, err := nitrite.Verify(attestation, nitrite.VerifyOptions{ + CurrentTime: time.Now(), + Roots: fa.CARoots(), + }) + require.NoError(t, err) + require.True(t, result.SignatureOK, "ECDSA signature should be valid") + require.Equal(t, userData, result.Document.UserData) + require.Equal(t, "SHA384", result.Document.Digest) + require.Equal(t, "fake-enclave-module", result.Document.ModuleID) + require.Len(t, result.Document.PCRs, 3) + require.Len(t, result.Document.PCRs[0], 48) + require.Len(t, result.Document.PCRs[1], 48) + require.Len(t, result.Document.PCRs[2], 48) +} + +func TestFakeAttestor_TrustedPCRsJSON(t *testing.T) { + fa, err := NewFakeAttestor() + 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 TestFakeAttestor_CARootsPEM(t *testing.T) { + fa, err := NewFakeAttestor() + 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 000000000..7a7c998fd --- /dev/null +++ b/pkg/teeattestation/nitro/validate.go @@ -0,0 +1,95 @@ +// Package nitro provides AWS Nitro Enclave attestation validation. +package nitro + +import ( + "bytes" + "crypto/x509" + "encoding/hex" + "encoding/json" + "fmt" + "time" + + "github.com/hf/nitrite" +) + +// 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. +func ValidateAttestation(attestation, expectedUserData, trustedMeasurements []byte, caRootsPEM string) error { + if attestation == nil { + return fmt.Errorf("attestation is nil") + } + + roots := DefaultCARoots + if caRootsPEM != "" { + roots = caRootsPEM + } + pool := x509.NewCertPool() + ok := pool.AppendCertsFromPEM([]byte(roots)) + if !ok { + return fmt.Errorf("failed to parse CA roots") + } + result, err := nitrite.Verify(attestation, nitrite.VerifyOptions{ + CurrentTime: time.Now(), + Roots: pool, + }) + if err != nil { + return fmt.Errorf("failed to verify nitro attestation: %w", err) + } + if !result.SignatureOK { + return fmt.Errorf("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 !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 000000000..a104489c1 --- /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_FakeAttestor(t *testing.T) { + fa, err := fake.NewFakeAttestor() + require.NoError(t, err) + + userData := teeattestation.DomainHash("test-tag", []byte(`{"key":"value"}`)) + doc, err := fa.CreateAttestation(userData) + require.NoError(t, err) + + err = ValidateAttestation(doc, userData, fa.TrustedPCRsJSON(), fa.CARootsPEM()) + require.NoError(t, err) +} + +func TestValidateAttestation_WrongUserData(t *testing.T) { + fa, err := fake.NewFakeAttestor() + 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 = ValidateAttestation(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 := fake.NewFakeAttestor() + 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 = ValidateAttestation(doc, userData, wrongPCRs, fa.CARootsPEM()) + require.Error(t, err) + require.Contains(t, err.Error(), "PCR0 mismatch") +}