feat(attest): add KindNitro — AWS Nitro Enclaves attestation verifier

Adds AWS Nitro Enclaves as an orthogonal hardware Kind behind the existing
one-Verifier seam, mirroring the SEV-SNP/NVTrust pattern (struct + Verify +
compile-time guard; wired into Dispatch).

Nitro.Verify performs real, end-to-end verification of a Nitro attestation
document (CBOR-encoded COSE_Sign1, RFC 9052):

  1. Parse the COSE_Sign1 envelope, tagged (CBOR tag 18) or untagged.
  2. CBOR-decode the attestation-document payload with a hardened decoder
     (duplicate map keys rejected) and validate its shape per the AWS CDDL
     (module_id, digest=="SHA384", timestamp, pcrs 0..31 of 48 bytes,
     certificate, cabundle).
  3. Require the leaf certificate's key to be ECDSA P-384.
  4. Build the X.509 chain leaf -> cabundle intermediates -> the pinned AWS
     Nitro Enclaves Root G1, anchored ONLY at the embedded root (never the
     cabundle's own root), ExtKeyUsageAny (Nitro leaves carry no EKU), CRL
     disabled per AWS.
  5. Verify the COSE_Sign1 signature against the leaf P-384 key, binding the
     protected-header algorithm to ES384 (kills algorithm substitution).
  6. Map PCR0 -> Measurement, nonce -> ReportData, user_data -> HostData; all
     PCRs into Extra. Enforce caller policy: WithNitroPCRs, the generic
     WithExpectedMeasurement (PCR0) / WithExpectedReportData (nonce), and
     WithNitroMaxAge freshness.

The trust anchor is pinned, not taken from the evidence: the embedded AWS
root PEM is self-checked at parse time against the AWS-published SHA-256
fingerprint 641a0321...79bb5b. No insecure mode — a document that does not
chain to the pinned AWS root is refused. WithNitroRoots overrides the anchor
for deterministic tests only (mirrors the SEV WithKDSGetter test override).

Tests (white-box, hermetic, no network): a self-consistent vector built with
real cryptography (P-384 root/intermediate/leaf chain, real COSE_Sign1/ES384
via go-cose, real CBOR via fxamacker), the genuine pinned-root authenticity
check (fingerprint/CN/curve/self-signature/validity), and a full tamper matrix
(bad signature, tampered payload, wrong/unrelated root, expired/broken chain,
wrong/absent PCR, measurement/nonce mismatch, stale/future timestamp, wrong
digest, bad PCR length, non-P384 leaf, algorithm substitution, duplicate CBOR
key, bad CBOR). CompositeHash determinism + content-binding asserted.

Deps: github.com/fxamacker/cbor/v2 v2.9.2, github.com/veraison/go-cose v1.3.0.
This commit is contained in:
2026-06-27 13:49:34 -07:00
parent adf365df9a
commit a99d0e0c2f
5 changed files with 1170 additions and 1 deletions
+508
View File
@@ -0,0 +1,508 @@
// Copyright (c) 2026, Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
package attest
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/sha256"
"crypto/subtle"
"crypto/x509"
"encoding/binary"
"encoding/hex"
"encoding/pem"
"fmt"
"sort"
"sync"
"time"
"github.com/fxamacker/cbor/v2"
cose "github.com/veraison/go-cose"
)
// Nitro is the production AWS Nitro Enclaves verifier.
//
// An AWS Nitro Enclaves attestation document is a CBOR-encoded COSE_Sign1
// structure (RFC 9052) produced by the Nitro Hypervisor's Nitro Security
// Module (NSM). It is signed with ECDSA-P384 / SHA-384 (COSE alg ES384,
// label -35) by an ephemeral leaf certificate that the AWS Nitro Attestation
// PKI issues, chaining up to the well-known AWS Nitro Enclaves Root G1
// certificate. Nitro verifies, in order:
//
// 1. Parse the COSE_Sign1 envelope (tagged CBOR tag 18 or untagged).
// 2. Decode the CBOR attestation-document payload and validate its shape
// (module_id, digest == "SHA384", timestamp, pcrs, certificate,
// cabundle) per the AWS CDDL.
// 3. Parse the leaf certificate; require an ECDSA P-384 public key.
// 4. Build the X.509 chain leaf -> cabundle intermediates -> the pinned AWS
// Nitro Enclaves Root G1, anchored ONLY at our embedded copy of that
// root (never the cabundle's own root). CRL is disabled per AWS.
// 5. Verify the COSE_Sign1 signature against the leaf's P-384 key, binding
// the protected-header algorithm to ES384 (no algorithm substitution).
// 6. Apply caller policy: expected PCR set, expected measurement (PCR0),
// expected nonce (report data), and freshness (document timestamp).
//
// The trust anchor is pinned, not taken from the evidence: the embedded AWS
// root PEM (nitroRootCAPEM) is checked at parse time against the AWS-published
// SHA-256 fingerprint (NitroRootCAFingerprintSHA256). There is no insecure
// mode — a document that does not chain to the pinned AWS root is refused.
//
// Honest scope: this verifier proves "an AWS Nitro enclave whose image
// measures to these PCRs produced this document, bound to this nonce, and the
// signing leaf chains to the AWS Nitro Enclaves root." Whether a given PCR0
// is an *acceptable* enclave is the caller's policy (WithExpectedMeasurement /
// WithNitroPCRs). PCR0 is the SHA-384 measurement of the entire enclave image
// file; PCR1 covers the linux kernel/bootstrap and PCR2 the application.
type Nitro struct{}
const (
// nitroVendor is the canonical issuer string for verified Nitro reports.
nitroVendor = "aws.nitro.enclaves"
// nitroDigestSHA384 is the only digest the NSM emits; PCRs are 48 bytes.
nitroDigestSHA384 = "SHA384"
// nitroPCRLenSHA384 is the byte length of a SHA-384 PCR.
nitroPCRLenSHA384 = 48
// nitroMaxPCRIndex is the highest valid PCR index (AWS CDDL: 0..31).
nitroMaxPCRIndex = 31
// nitroMaxOptionalField bounds public_key / user_data / nonce (AWS CDDL:
// bytes .size (0..1024)).
nitroMaxOptionalField = 1024
// nitroMaxClockSkew is the tolerance for a document timestamp that sits
// slightly ahead of the verifier's clock when freshness is enforced.
nitroMaxClockSkew = 5 * time.Minute
)
// NitroRootCAFingerprintSHA256 is the AWS-published SHA-256 fingerprint of the
// DER encoding of the AWS Nitro Enclaves Root G1 certificate. The embedded
// root (nitroRootCAPEM) is checked against this value before it is ever used
// as a trust anchor; a mismatch panics (a corrupted pin is a build defect, not
// a runtime condition to recover from).
//
// Source: https://docs.aws.amazon.com/enclaves/latest/user/verify-root.html
//
// 64:1A:03:21:A3:E2:44:EF:E4:56:46:31:95:D6:06:31:7E:D7:CD:CC:3C:17:56:E0:98:93:F3:C6:8F:79:BB:5B
const NitroRootCAFingerprintSHA256 = "641a0321a3e244efe456463195d606317ed7cdcc3c1756e09893f3c68f79bb5b"
// nitroRootCAPEM is the AWS Nitro Enclaves Root G1 certificate, downloaded
// from https://aws-nitro-enclaves.amazonaws.com/AWS_NitroEnclaves_Root-G1.zip
// and pinned here. Self-signed P-384 (ecdsa-with-SHA384),
// CN=aws.nitro-enclaves, O=Amazon, OU=AWS, valid 2019-10-28..2049-10-28.
const nitroRootCAPEM = `-----BEGIN CERTIFICATE-----
MIICETCCAZagAwIBAgIRAPkxdWgbkK/hHUbMtOTn+FYwCgYIKoZIzj0EAwMwSTEL
MAkGA1UEBhMCVVMxDzANBgNVBAoMBkFtYXpvbjEMMAoGA1UECwwDQVdTMRswGQYD
VQQDDBJhd3Mubml0cm8tZW5jbGF2ZXMwHhcNMTkxMDI4MTMyODA1WhcNNDkxMDI4
MTQyODA1WjBJMQswCQYDVQQGEwJVUzEPMA0GA1UECgwGQW1hem9uMQwwCgYDVQQL
DANBV1MxGzAZBgNVBAMMEmF3cy5uaXRyby1lbmNsYXZlczB2MBAGByqGSM49AgEG
BSuBBAAiA2IABPwCVOumCMHzaHDimtqQvkY4MpJzbolL//Zy2YlES1BR5TSksfbb
48C8WBoyt7F2Bw7eEtaaP+ohG2bnUs990d0JX28TcPQXCEPZ3BABIeTPYwEoCWZE
h8l5YoQwTcU/9KNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUkCW1DdkF
R+eWw5b6cp3PmanfS5YwDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMDA2kAMGYC
MQCjfy+Rocm9Xue4YnwWmNJVA44fA0P5W2OpYow9OYCVRaEevL8uO1XYru5xtMPW
rfMCMQCi85sWBbJwKKXdS6BptQFuZbT73o/gBh1qUxl/nNr12UO8Yfwr6wPLb+6N
IwLz3/Y=
-----END CERTIFICATE-----
`
var (
nitroRootOnce sync.Once
nitroRootPoolCached *x509.CertPool
nitroRootCertCached *x509.Certificate
)
// nitroRootInit parses and fingerprint-checks the pinned AWS root exactly
// once. It panics on any failure: the only way it can fail is a corrupted
// embedded constant, which must never reach production.
func nitroRootInit() {
block, _ := pem.Decode([]byte(nitroRootCAPEM))
if block == nil || block.Type != "CERTIFICATE" {
panic("attest: pinned AWS Nitro root is not a CERTIFICATE PEM block")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
panic("attest: pinned AWS Nitro root failed to parse: " + err.Error())
}
fp := sha256.Sum256(cert.Raw)
if hex.EncodeToString(fp[:]) != NitroRootCAFingerprintSHA256 {
panic("attest: pinned AWS Nitro root fingerprint mismatch (" +
hex.EncodeToString(fp[:]) + ") — refusing to run")
}
pool := x509.NewCertPool()
pool.AddCert(cert)
nitroRootCertCached = cert
nitroRootPoolCached = pool
}
// nitroRootPool returns the pinned AWS Nitro Enclaves root as a verify pool.
func nitroRootPool() *x509.CertPool {
nitroRootOnce.Do(nitroRootInit)
return nitroRootPoolCached
}
// NitroRootCertificate returns the pinned, fingerprint-checked AWS Nitro
// Enclaves Root G1 certificate. Callers must treat it as read-only.
func NitroRootCertificate() *x509.Certificate {
nitroRootOnce.Do(nitroRootInit)
return nitroRootCertCached
}
// WithNitroRoots overrides the trust anchor used to verify the Nitro
// attestation document's certificate chain. Production callers MUST NOT set
// this — the pinned AWS Nitro Enclaves root is the only valid anchor. It
// exists so deterministic tests can verify a self-consistent chain rooted at a
// generated CA. A nil pool falls back to the pinned AWS root. (Mirrors the
// SEV path's WithKDSGetter test-only override.)
func WithNitroRoots(pool *x509.CertPool) Option {
return func(c *config) { c.nitroRoots = pool }
}
// WithNitroPCRs pins required PCR values by index. Every index supplied must be
// present in the document and equal byte-for-byte, or Verify returns ErrPolicy.
// Use this to pin the enclave image identity (PCR0) and boot chain (PCR1/PCR2).
func WithNitroPCRs(pcrs map[uint][]byte) Option {
return func(c *config) {
m := make(map[uint][]byte, len(pcrs))
for k, v := range pcrs {
b := make([]byte, len(v))
copy(b, v)
m[k] = b
}
c.nitroExpectedPCRs = m
}
}
// WithNitroMaxAge bounds document freshness. When d > 0, Verify refuses a
// document whose timestamp is older than d, or more than nitroMaxClockSkew in
// the future. When unset (the default) no age bound is enforced — callers that
// care about replay MUST set it (or bind a fresh nonce via
// WithExpectedReportData).
func WithNitroMaxAge(d time.Duration) Option {
return func(c *config) { c.nitroMaxAge = d }
}
// nitroDoc is the decoded AWS Nitro attestation-document payload. Field names
// and types match the AWS CDDL exactly. Optional fields decode to nil when
// absent.
type nitroDoc struct {
ModuleID string `cbor:"module_id"`
Timestamp uint64 `cbor:"timestamp"`
Digest string `cbor:"digest"`
PCRs map[uint64][]byte `cbor:"pcrs"`
Certificate []byte `cbor:"certificate"`
CABundle [][]byte `cbor:"cabundle"`
PublicKey []byte `cbor:"public_key"`
UserData []byte `cbor:"user_data"`
Nonce []byte `cbor:"nonce"`
}
// nitroCBORDecMode is the hardened CBOR decoder for the attestation payload.
// Duplicate map keys are rejected so a forger cannot smuggle a second value
// for a field (e.g. two "pcrs" or two "nonce") past the verifier.
var nitroCBORDecMode = func() cbor.DecMode {
dm, err := cbor.DecOptions{
DupMapKey: cbor.DupMapKeyEnforcedAPF,
}.DecMode()
if err != nil {
panic("attest: nitro CBOR dec mode: " + err.Error())
}
return dm
}()
// Verify implements Verifier for AWS Nitro Enclaves attestation documents.
func (Nitro) Verify(ctx context.Context, evidence []byte, opts ...Option) (*VerifiedReport, error) {
cfg := applyOptions(opts...)
// 1. COSE_Sign1 envelope (tagged or untagged).
msg, err := parseNitroCOSESign1(evidence)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidEvidence, err)
}
// 2. Decode the attestation-document payload.
doc, err := decodeNitroDoc(msg.Payload)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidEvidence, err)
}
// 3. Structural / semantic validation.
if err := doc.validate(); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidEvidence, err)
}
// 4. Parse the leaf certificate; require ECDSA P-384.
leaf, err := x509.ParseCertificate(doc.Certificate)
if err != nil {
return nil, fmt.Errorf("%w: leaf certificate: %v", ErrInvalidEvidence, err)
}
pub, ok := leaf.PublicKey.(*ecdsa.PublicKey)
if !ok || pub.Curve != elliptic.P384() {
return nil, fmt.Errorf("%w: leaf key is not ECDSA P-384", ErrInvalidEvidence)
}
// 5. Build and verify the X.509 chain to the pinned AWS root. We anchor
// ONLY at our pinned root; the cabundle's own root (cabundle[0]) is
// placed in intermediates, never trusted as an anchor. ExtKeyUsageAny
// is required because Nitro leaf certs carry no server-auth EKU.
roots, err := nitroResolveRoots(cfg)
if err != nil {
return nil, err
}
intermediates := x509.NewCertPool()
for i, der := range doc.CABundle {
c, err := x509.ParseCertificate(der)
if err != nil {
return nil, fmt.Errorf("%w: cabundle[%d]: %v", ErrInvalidEvidence, i, err)
}
intermediates.AddCert(c)
}
now := cfg.nowOrWall()
if _, err := leaf.Verify(x509.VerifyOptions{
Roots: roots,
Intermediates: intermediates,
CurrentTime: now,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
}); err != nil {
return nil, fmt.Errorf("%w: %v", ErrChainInvalid, err)
}
// 6. COSE_Sign1 signature against the leaf's P-384 key. go-cose binds the
// protected-header alg to the verifier alg, so a document claiming any
// algorithm other than ES384 is rejected here (no alg substitution).
verifier, err := cose.NewVerifier(cose.AlgorithmES384, pub)
if err != nil {
return nil, fmt.Errorf("%w: cose verifier: %v", ErrSignatureInvalid, err)
}
if err := msg.Verify(nil, verifier); err != nil {
return nil, fmt.Errorf("%w: %v", ErrSignatureInvalid, err)
}
// 7. Caller policy on the now-verified fields.
pcr0 := doc.PCRs[0]
if cfg.nitroMaxAge > 0 {
docTime := time.UnixMilli(int64(doc.Timestamp))
age := now.Sub(docTime)
if age < -nitroMaxClockSkew {
return nil, fmt.Errorf("%w: document timestamp %s is %s in the future",
ErrPolicy, docTime.UTC(), (-age).String())
}
if age > cfg.nitroMaxAge {
return nil, fmt.Errorf("%w: document age %s exceeds max %s",
ErrPolicy, age.String(), cfg.nitroMaxAge.String())
}
}
for idx, want := range cfg.nitroExpectedPCRs {
got, ok := doc.PCRs[uint64(idx)]
if !ok {
return nil, fmt.Errorf("%w: required pcr%d absent", ErrPolicy, idx)
}
if subtle.ConstantTimeCompare(want, got) != 1 {
return nil, fmt.Errorf("%w: pcr%d mismatch (got %s)",
ErrPolicy, idx, hex.EncodeToString(got))
}
}
// Generic policy vocabulary, shared with the other Kinds:
// expected measurement -> PCR0 (the enclave image identity),
// expected report data -> the document nonce (freshness challenge).
if cfg.expectedMeasurement != nil {
if subtle.ConstantTimeCompare(cfg.expectedMeasurement, pcr0) != 1 {
return nil, fmt.Errorf("%w: measurement (pcr0) mismatch (got %s)",
ErrPolicy, hex.EncodeToString(pcr0))
}
}
if cfg.expectedReportData != nil {
if subtle.ConstantTimeCompare(cfg.expectedReportData, doc.Nonce) != 1 {
return nil, fmt.Errorf("%w: nonce mismatch (got %s)",
ErrPolicy, hex.EncodeToString(doc.Nonce))
}
}
out := &VerifiedReport{
Kind: KindNitro,
Vendor: nitroVendor,
Measurement: cloneBytes(pcr0),
ReportData: cloneBytes(doc.Nonce),
HostData: cloneBytes(doc.UserData),
ChipID: nil, // Nitro documents carry no silicon ID; see Extra["nitro.module_id"].
IssuedAt: now.UTC(),
Extra: buildNitroExtra(doc, leaf),
}
out.CompositeHash = computeCompositeHash(KindNitro, canonicalNitroBytes(doc, leaf))
return out, nil
}
// nitroResolveRoots returns the trust-anchor pool: the test override when set,
// otherwise the pinned AWS Nitro Enclaves root.
func nitroResolveRoots(cfg config) (*x509.CertPool, error) {
if cfg.nitroRoots != nil {
pool, ok := cfg.nitroRoots.(*x509.CertPool)
if !ok {
return nil, fmt.Errorf("%w: nitro roots override is not *x509.CertPool", ErrInvalidEvidence)
}
if pool != nil {
return pool, nil
}
}
return nitroRootPool(), nil
}
// parseNitroCOSESign1 decodes a COSE_Sign1, accepting both the CBOR tag-18
// tagged form (0xd2 prefix, what the NSM emits) and the bare untagged 4-array.
func parseNitroCOSESign1(data []byte) (*cose.Sign1Message, error) {
if len(data) == 0 {
return nil, fmt.Errorf("empty evidence")
}
if data[0] == 0xd2 { // CBOR tag 18 == COSE_Sign1
var msg cose.Sign1Message
if err := msg.UnmarshalCBOR(data); err != nil {
return nil, err
}
return &msg, nil
}
var um cose.UntaggedSign1Message
if err := um.UnmarshalCBOR(data); err != nil {
return nil, err
}
msg := cose.Sign1Message(um)
return &msg, nil
}
// decodeNitroDoc CBOR-decodes the attestation-document payload with the
// hardened, duplicate-key-rejecting decoder.
func decodeNitroDoc(payload []byte) (*nitroDoc, error) {
if len(payload) == 0 {
return nil, fmt.Errorf("empty attestation payload")
}
var doc nitroDoc
if err := nitroCBORDecMode.Unmarshal(payload, &doc); err != nil {
return nil, err
}
return &doc, nil
}
// validate enforces the structural invariants of the AWS CDDL before any trust
// decision is made on the document's contents.
func (d *nitroDoc) validate() error {
if d.ModuleID == "" {
return fmt.Errorf("missing module_id")
}
if d.Digest != nitroDigestSHA384 {
return fmt.Errorf("unsupported digest %q (want %q)", d.Digest, nitroDigestSHA384)
}
if d.Timestamp == 0 {
return fmt.Errorf("missing timestamp")
}
if len(d.Certificate) == 0 {
return fmt.Errorf("missing certificate")
}
if len(d.CABundle) == 0 {
return fmt.Errorf("empty cabundle")
}
if len(d.PCRs) == 0 {
return fmt.Errorf("no pcrs")
}
for idx, v := range d.PCRs {
if idx > nitroMaxPCRIndex {
return fmt.Errorf("pcr index %d out of range (max %d)", idx, nitroMaxPCRIndex)
}
// digest is SHA384, so every locked PCR is exactly 48 bytes.
if len(v) != nitroPCRLenSHA384 {
return fmt.Errorf("pcr%d has length %d, want %d", idx, len(v), nitroPCRLenSHA384)
}
}
if _, ok := d.PCRs[0]; !ok {
return fmt.Errorf("missing pcr0")
}
if len(d.PublicKey) > nitroMaxOptionalField {
return fmt.Errorf("public_key exceeds %d bytes", nitroMaxOptionalField)
}
if len(d.UserData) > nitroMaxOptionalField {
return fmt.Errorf("user_data exceeds %d bytes", nitroMaxOptionalField)
}
if len(d.Nonce) > nitroMaxOptionalField {
return fmt.Errorf("nonce exceeds %d bytes", nitroMaxOptionalField)
}
return nil
}
// buildNitroExtra collects Nitro-specific fields that don't fit the common
// shape. Keys are stable wire identifiers prefixed "nitro.".
func buildNitroExtra(d *nitroDoc, leaf *x509.Certificate) map[string]string {
extra := make(map[string]string, len(d.PCRs)+6)
extra["nitro.module_id"] = d.ModuleID
extra["nitro.digest"] = d.Digest
extra["nitro.timestamp_ms"] = fmt.Sprintf("%d", d.Timestamp)
for _, idx := range sortedPCRIndices(d.PCRs) {
extra[fmt.Sprintf("nitro.pcr%d", idx)] = hex.EncodeToString(d.PCRs[idx])
}
if len(d.PublicKey) > 0 {
extra["nitro.public_key"] = hex.EncodeToString(d.PublicKey)
}
if len(d.UserData) > 0 {
extra["nitro.user_data"] = hex.EncodeToString(d.UserData)
}
leafFP := sha256.Sum256(leaf.Raw)
extra["nitro.leaf_fingerprint"] = hex.EncodeToString(leafFP[:])
return extra
}
// canonicalNitroBytes returns the bytes that participate in CompositeHash. We
// hash the verifier-extracted fields (module_id, digest, timestamp, every PCR
// in ascending index order, nonce, user_data, public_key, and the leaf cert
// fingerprint) in a fixed order. Every variable-length field is 4-byte
// big-endian length-prefixed and the PCR count is prefixed, so the encoding is
// injective: distinct verifier outputs cannot produce the same bytes (and thus
// not the same CompositeHash), even though nonce/user_data/public_key may
// contain NUL. This makes the package guarantee "same CompositeHash ⇒ same
// verifier output" airtight rather than relying on a NUL-free assumption.
func canonicalNitroBytes(d *nitroDoc, leaf *x509.Certificate) []byte {
buf := make([]byte, 0, 512)
appendLP := func(b []byte) {
var l [4]byte
binary.BigEndian.PutUint32(l[:], uint32(len(b)))
buf = append(buf, l[:]...)
buf = append(buf, b...)
}
appendU64 := func(v uint64) {
var b [8]byte
binary.BigEndian.PutUint64(b[:], v)
buf = append(buf, b[:]...)
}
appendLP([]byte(d.ModuleID))
appendLP([]byte(d.Digest))
appendU64(d.Timestamp)
idxs := sortedPCRIndices(d.PCRs)
appendU64(uint64(len(idxs)))
for _, idx := range idxs {
appendU64(idx)
appendLP(d.PCRs[idx])
}
appendLP(d.Nonce)
appendLP(d.UserData)
appendLP(d.PublicKey)
leafFP := sha256.Sum256(leaf.Raw)
buf = append(buf, leafFP[:]...) // fixed 32 bytes
return buf
}
// sortedPCRIndices returns the PCR indices in ascending order for deterministic
// iteration (Go map order is randomized).
func sortedPCRIndices(pcrs map[uint64][]byte) []uint64 {
idx := make([]uint64, 0, len(pcrs))
for k := range pcrs {
idx = append(idx, k)
}
sort.Slice(idx, func(i, j int) bool { return idx[i] < idx[j] })
return idx
}
// Compile-time guard: Nitro satisfies Verifier.
var _ Verifier = Nitro{}
+633
View File
@@ -0,0 +1,633 @@
// Copyright (c) 2026, Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
package attest
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"errors"
"math/big"
"testing"
"time"
"github.com/fxamacker/cbor/v2"
cose "github.com/veraison/go-cose"
)
// nitroVec is a self-consistent AWS Nitro attestation vector: a real
// COSE_Sign1 (ECDSA-P384 / ES384) over a real CBOR attestation document, with
// a real X.509 chain rooted at a generated CA. It uses 100% real cryptography;
// only the trust anchor is a test CA (instead of AWS's HSM-held root, whose
// private key nobody outside AWS can sign with). The pinned, fingerprint-
// verified genuine AWS root is exercised separately in
// TestNitro_PinnedRootIsAuthenticAWS.
type nitroVec struct {
evidence []byte
rootPool *x509.CertPool
leaf *x509.Certificate
leafPriv *ecdsa.PrivateKey
cabundle [][]byte
doc *nitroDoc
now time.Time
pcr0 []byte
pcr1 []byte
pcr2 []byte
nonce []byte
}
func rep48(b byte) []byte { return bytes.Repeat([]byte{b}, nitroPCRLenSHA384) }
// genNitroChain builds a P-384 root -> intermediate -> leaf chain valid in
// [notBefore, notAfter]. leafCurve selects the leaf key curve (P-384 for the
// real shape; P-256 to exercise the curve-rejection path). The leaf carries no
// EKU, exactly like a real Nitro signing certificate.
func genNitroChain(t *testing.T, leafCurve elliptic.Curve, notBefore, notAfter time.Time) (
rootPool *x509.CertPool, leaf *x509.Certificate, leafPriv *ecdsa.PrivateKey, cabundle [][]byte,
) {
t.Helper()
rootPriv, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
t.Fatalf("root key: %v", err)
}
rootTmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "test.nitro-enclaves", Organization: []string{"LuxTest"}},
NotBefore: notBefore,
NotAfter: notAfter,
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
}
rootDER, err := x509.CreateCertificate(rand.Reader, rootTmpl, rootTmpl, &rootPriv.PublicKey, rootPriv)
if err != nil {
t.Fatalf("root cert: %v", err)
}
rootCert, err := x509.ParseCertificate(rootDER)
if err != nil {
t.Fatalf("parse root: %v", err)
}
interPriv, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
t.Fatalf("inter key: %v", err)
}
interTmpl := &x509.Certificate{
SerialNumber: big.NewInt(2),
Subject: pkix.Name{CommonName: "test.nitro-enclaves intermediate"},
NotBefore: notBefore,
NotAfter: notAfter,
IsCA: true,
BasicConstraintsValid: true,
MaxPathLenZero: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
}
interDER, err := x509.CreateCertificate(rand.Reader, interTmpl, rootCert, &interPriv.PublicKey, rootPriv)
if err != nil {
t.Fatalf("inter cert: %v", err)
}
interCert, err := x509.ParseCertificate(interDER)
if err != nil {
t.Fatalf("parse inter: %v", err)
}
leafPriv, err = ecdsa.GenerateKey(leafCurve, rand.Reader)
if err != nil {
t.Fatalf("leaf key: %v", err)
}
leafTmpl := &x509.Certificate{
SerialNumber: big.NewInt(3),
Subject: pkix.Name{CommonName: "i-0abc.eu-west-1.aws.nitro-enclaves"},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature,
}
leafDER, err := x509.CreateCertificate(rand.Reader, leafTmpl, interCert, &leafPriv.PublicKey, interPriv)
if err != nil {
t.Fatalf("leaf cert: %v", err)
}
leaf, err = x509.ParseCertificate(leafDER)
if err != nil {
t.Fatalf("parse leaf: %v", err)
}
rootPool = x509.NewCertPool()
rootPool.AddCert(rootCert)
// AWS cabundle order is [ROOT, INTERM_1, ...]; the leaf is NOT included.
cabundle = [][]byte{rootDER, interDER}
return rootPool, leaf, leafPriv, cabundle
}
// encodeNitroPayload CBOR-encodes an AWS-faithful attestation document map:
// string keys, uint PCR indices, and optional fields omitted when nil (matching
// what the NSM emits, rather than encoding them as CBOR null).
func encodeNitroPayload(t *testing.T, d *nitroDoc) []byte {
t.Helper()
m := map[string]interface{}{
"module_id": d.ModuleID,
"timestamp": d.Timestamp,
"digest": d.Digest,
"pcrs": d.PCRs,
"certificate": d.Certificate,
"cabundle": d.CABundle,
}
if d.PublicKey != nil {
m["public_key"] = d.PublicKey
}
if d.UserData != nil {
m["user_data"] = d.UserData
}
if d.Nonce != nil {
m["nonce"] = d.Nonce
}
b, err := cbor.Marshal(m)
if err != nil {
t.Fatalf("cbor marshal payload: %v", err)
}
return b
}
// signNitro produces a tagged COSE_Sign1 (CBOR tag 18) over payload, signed by
// leafPriv with the given COSE algorithm.
func signNitro(t *testing.T, leafPriv *ecdsa.PrivateKey, alg cose.Algorithm, payload []byte) []byte {
t.Helper()
signer, err := cose.NewSigner(alg, leafPriv)
if err != nil {
t.Fatalf("cose signer: %v", err)
}
headers := cose.Headers{Protected: cose.ProtectedHeader{cose.HeaderLabelAlgorithm: alg}}
ev, err := cose.Sign1(rand.Reader, signer, headers, payload, nil)
if err != nil {
t.Fatalf("cose sign1: %v", err)
}
return ev
}
// signDoc encodes d and signs it with leafPriv (ES384), returning evidence.
func signDoc(t *testing.T, leafPriv *ecdsa.PrivateKey, d *nitroDoc) []byte {
t.Helper()
return signNitro(t, leafPriv, cose.AlgorithmES384, encodeNitroPayload(t, d))
}
func newNitroVec(t *testing.T) *nitroVec {
t.Helper()
now := time.Date(2026, 1, 15, 12, 0, 0, 0, time.UTC)
rootPool, leaf, leafPriv, cabundle := genNitroChain(t, elliptic.P384(), now.Add(-time.Hour), now.Add(3*time.Hour))
v := &nitroVec{
rootPool: rootPool,
leaf: leaf,
leafPriv: leafPriv,
cabundle: cabundle,
now: now,
pcr0: rep48(0xa0),
pcr1: rep48(0xb1),
pcr2: rep48(0xc2),
nonce: bytes.Repeat([]byte{0x11}, 32),
}
v.doc = &nitroDoc{
ModuleID: "i-0abc1234def567890-enc0123456789abcdef",
Timestamp: uint64(now.UnixMilli()),
Digest: nitroDigestSHA384,
PCRs: map[uint64][]byte{0: v.pcr0, 1: v.pcr1, 2: v.pcr2},
Certificate: leaf.Raw,
CABundle: cabundle,
PublicKey: bytes.Repeat([]byte{0x22}, 64),
UserData: []byte("lux-attestation"),
Nonce: v.nonce,
}
v.evidence = signDoc(t, leafPriv, v.doc)
return v
}
// ---------------------------------------------------------------------------
// Happy path
// ---------------------------------------------------------------------------
func TestNitro_HappyPath(t *testing.T) {
v := newNitroVec(t)
rep, err := Dispatch(context.Background(), KindNitro, v.evidence,
WithNitroRoots(v.rootPool), WithNow(v.now))
if err != nil {
t.Fatalf("verify: %v", err)
}
if rep.Kind != KindNitro {
t.Fatalf("kind = %q, want %q", rep.Kind, KindNitro)
}
if rep.Vendor != "aws.nitro.enclaves" {
t.Fatalf("vendor = %q", rep.Vendor)
}
if !bytes.Equal(rep.Measurement, v.pcr0) {
t.Fatalf("measurement = %x, want pcr0 %x", rep.Measurement, v.pcr0)
}
if !bytes.Equal(rep.ReportData, v.nonce) {
t.Fatalf("report_data = %x, want nonce %x", rep.ReportData, v.nonce)
}
if !bytes.Equal(rep.HostData, []byte("lux-attestation")) {
t.Fatalf("host_data = %x, want user_data", rep.HostData)
}
if rep.CompositeHash == [32]byte{} {
t.Fatal("composite hash must not be zero")
}
if rep.Extra["nitro.module_id"] != v.doc.ModuleID {
t.Fatalf("extra module_id = %q", rep.Extra["nitro.module_id"])
}
if rep.Extra["nitro.pcr0"] != hex.EncodeToString(v.pcr0) {
t.Fatalf("extra pcr0 = %q", rep.Extra["nitro.pcr0"])
}
if rep.Extra["nitro.pcr2"] != hex.EncodeToString(v.pcr2) {
t.Fatalf("extra pcr2 = %q", rep.Extra["nitro.pcr2"])
}
wantLeafFP := sha256.Sum256(v.leaf.Raw)
if rep.Extra["nitro.leaf_fingerprint"] != hex.EncodeToString(wantLeafFP[:]) {
t.Fatalf("extra leaf_fingerprint = %q", rep.Extra["nitro.leaf_fingerprint"])
}
}
func TestNitro_HappyPath_Untagged(t *testing.T) {
v := newNitroVec(t)
// Re-sign as an untagged COSE_Sign1 (no CBOR tag 18) — AWS docs allow both.
signer, err := cose.NewSigner(cose.AlgorithmES384, v.leafPriv)
if err != nil {
t.Fatalf("signer: %v", err)
}
headers := cose.Headers{Protected: cose.ProtectedHeader{cose.HeaderLabelAlgorithm: cose.AlgorithmES384}}
untagged, err := cose.Sign1Untagged(rand.Reader, signer, headers, encodeNitroPayload(t, v.doc), nil)
if err != nil {
t.Fatalf("sign1 untagged: %v", err)
}
if untagged[0] == 0xd2 {
t.Fatal("expected untagged COSE_Sign1 (no 0xd2 prefix)")
}
if _, err := Dispatch(context.Background(), KindNitro, untagged,
WithNitroRoots(v.rootPool), WithNow(v.now)); err != nil {
t.Fatalf("verify untagged: %v", err)
}
}
func TestNitro_PolicyAccepts(t *testing.T) {
v := newNitroVec(t)
// Pin PCR0/1/2, the measurement (PCR0), the nonce, and a generous max-age.
_, err := Dispatch(context.Background(), KindNitro, v.evidence,
WithNitroRoots(v.rootPool), WithNow(v.now.Add(5*time.Minute)),
WithNitroPCRs(map[uint][]byte{0: v.pcr0, 1: v.pcr1, 2: v.pcr2}),
WithExpectedMeasurement(v.pcr0),
WithExpectedReportData(v.nonce),
WithNitroMaxAge(time.Hour))
if err != nil {
t.Fatalf("verify with full policy: %v", err)
}
}
// ---------------------------------------------------------------------------
// Trust-anchor authenticity: the pinned root really is the AWS Nitro root.
// ---------------------------------------------------------------------------
func TestNitro_PinnedRootIsAuthenticAWS(t *testing.T) {
root := NitroRootCertificate()
fp := sha256.Sum256(root.Raw)
if got := hex.EncodeToString(fp[:]); got != NitroRootCAFingerprintSHA256 {
t.Fatalf("pinned root fingerprint = %s, want AWS-documented %s", got, NitroRootCAFingerprintSHA256)
}
if root.Subject.CommonName != "aws.nitro-enclaves" {
t.Fatalf("root CN = %q, want aws.nitro-enclaves", root.Subject.CommonName)
}
if len(root.Subject.Organization) == 0 || root.Subject.Organization[0] != "Amazon" {
t.Fatalf("root O = %v, want [Amazon]", root.Subject.Organization)
}
if root.Subject.String() != root.Issuer.String() {
t.Fatal("root is not self-issued")
}
if err := root.CheckSignatureFrom(root); err != nil {
t.Fatalf("root is not validly self-signed: %v", err)
}
pub, ok := root.PublicKey.(*ecdsa.PublicKey)
if !ok || pub.Curve != elliptic.P384() {
t.Fatalf("root key is not ECDSA P-384")
}
if root.NotBefore.UTC().Year() != 2019 || root.NotAfter.UTC().Year() != 2049 {
t.Fatalf("root validity = %s..%s, want 2019..2049", root.NotBefore.UTC(), root.NotAfter.UTC())
}
if !root.IsCA {
t.Fatal("root is not a CA")
}
}
// ---------------------------------------------------------------------------
// Tamper / refusal matrix
// ---------------------------------------------------------------------------
// TestNitro_RejectsTamperedSignature flips a signature byte: the COSE_Sign1
// signature no longer matches the leaf key.
func TestNitro_RejectsTamperedSignature(t *testing.T) {
v := newNitroVec(t)
msg, err := parseNitroCOSESign1(v.evidence)
if err != nil {
t.Fatalf("parse: %v", err)
}
msg.Signature[0] ^= 0xff
bad, err := msg.MarshalCBOR()
if err != nil {
t.Fatalf("remarshal: %v", err)
}
_, err = Dispatch(context.Background(), KindNitro, bad, WithNitroRoots(v.rootPool), WithNow(v.now))
if !errors.Is(err, ErrSignatureInvalid) {
t.Fatalf("err = %v, want ErrSignatureInvalid", err)
}
}
// TestNitro_RejectsTamperedPayload swaps in a modified payload while keeping the
// original signature — proving the document content is integrity-bound to the
// COSE signature (a forger cannot edit PCRs post-signing).
func TestNitro_RejectsTamperedPayload(t *testing.T) {
v := newNitroVec(t)
msg, err := parseNitroCOSESign1(v.evidence)
if err != nil {
t.Fatalf("parse: %v", err)
}
forged := *v.doc
forged.PCRs = map[uint64][]byte{0: rep48(0xff), 1: v.pcr1, 2: v.pcr2}
msg.Payload = encodeNitroPayload(t, &forged) // signature NOT recomputed
bad, err := msg.MarshalCBOR()
if err != nil {
t.Fatalf("remarshal: %v", err)
}
_, err = Dispatch(context.Background(), KindNitro, bad, WithNitroRoots(v.rootPool), WithNow(v.now))
if !errors.Is(err, ErrSignatureInvalid) {
t.Fatalf("err = %v, want ErrSignatureInvalid", err)
}
}
// TestNitro_RejectsWrongRoot verifies a test-CA-rooted document against the
// PINNED AWS root (no override) — the chain cannot anchor.
func TestNitro_RejectsWrongRoot(t *testing.T) {
v := newNitroVec(t)
_, err := Dispatch(context.Background(), KindNitro, v.evidence, WithNow(v.now))
if !errors.Is(err, ErrChainInvalid) {
t.Fatalf("err = %v, want ErrChainInvalid", err)
}
}
// TestNitro_RejectsUnrelatedRoot verifies against a *different* generated root.
func TestNitro_RejectsUnrelatedRoot(t *testing.T) {
v := newNitroVec(t)
otherPool, _, _, _ := genNitroChain(t, elliptic.P384(), v.now.Add(-time.Hour), v.now.Add(3*time.Hour))
_, err := Dispatch(context.Background(), KindNitro, v.evidence, WithNitroRoots(otherPool), WithNow(v.now))
if !errors.Is(err, ErrChainInvalid) {
t.Fatalf("err = %v, want ErrChainInvalid", err)
}
}
// TestNitro_RejectsExpiredChain pins the clock outside the leaf's validity.
func TestNitro_RejectsExpiredChain(t *testing.T) {
v := newNitroVec(t)
_, err := Dispatch(context.Background(), KindNitro, v.evidence,
WithNitroRoots(v.rootPool), WithNow(v.now.Add(10*time.Hour)))
if !errors.Is(err, ErrChainInvalid) {
t.Fatalf("err = %v, want ErrChainInvalid (expired leaf)", err)
}
}
// TestNitro_RejectsBrokenChain drops the intermediate from the cabundle.
func TestNitro_RejectsBrokenChain(t *testing.T) {
v := newNitroVec(t)
broken := *v.doc
broken.CABundle = [][]byte{v.cabundle[0]} // root only, no intermediate
ev := signDoc(t, v.leafPriv, &broken)
_, err := Dispatch(context.Background(), KindNitro, ev, WithNitroRoots(v.rootPool), WithNow(v.now))
if !errors.Is(err, ErrChainInvalid) {
t.Fatalf("err = %v, want ErrChainInvalid (missing intermediate)", err)
}
}
// TestNitro_RejectsWrongPCR pins a PCR value that the document does not carry.
func TestNitro_RejectsWrongPCR(t *testing.T) {
v := newNitroVec(t)
_, err := Dispatch(context.Background(), KindNitro, v.evidence,
WithNitroRoots(v.rootPool), WithNow(v.now),
WithNitroPCRs(map[uint][]byte{0: rep48(0xde)}))
if !errors.Is(err, ErrPolicy) {
t.Fatalf("err = %v, want ErrPolicy (pcr mismatch)", err)
}
}
// TestNitro_RejectsAbsentRequiredPCR pins an index the document omits.
func TestNitro_RejectsAbsentRequiredPCR(t *testing.T) {
v := newNitroVec(t)
_, err := Dispatch(context.Background(), KindNitro, v.evidence,
WithNitroRoots(v.rootPool), WithNow(v.now),
WithNitroPCRs(map[uint][]byte{8: rep48(0x08)}))
if !errors.Is(err, ErrPolicy) {
t.Fatalf("err = %v, want ErrPolicy (absent pcr)", err)
}
}
// TestNitro_RejectsMeasurementMismatch pins a wrong PCR0 via the generic option.
func TestNitro_RejectsMeasurementMismatch(t *testing.T) {
v := newNitroVec(t)
_, err := Dispatch(context.Background(), KindNitro, v.evidence,
WithNitroRoots(v.rootPool), WithNow(v.now),
WithExpectedMeasurement(rep48(0x00)))
if !errors.Is(err, ErrPolicy) {
t.Fatalf("err = %v, want ErrPolicy (measurement)", err)
}
}
// TestNitro_RejectsNonceMismatch pins a wrong nonce via the generic option.
func TestNitro_RejectsNonceMismatch(t *testing.T) {
v := newNitroVec(t)
wrong := make([]byte, 32)
wrong[0] = 0xFF
_, err := Dispatch(context.Background(), KindNitro, v.evidence,
WithNitroRoots(v.rootPool), WithNow(v.now),
WithExpectedReportData(wrong))
if !errors.Is(err, ErrPolicy) {
t.Fatalf("err = %v, want ErrPolicy (nonce binding)", err)
}
}
// TestNitro_RejectsStale enforces a max-age the document violates.
func TestNitro_RejectsStale(t *testing.T) {
v := newNitroVec(t)
_, err := Dispatch(context.Background(), KindNitro, v.evidence,
WithNitroRoots(v.rootPool), WithNow(v.now.Add(time.Hour)),
WithNitroMaxAge(time.Minute))
if !errors.Is(err, ErrPolicy) {
t.Fatalf("err = %v, want ErrPolicy (stale)", err)
}
}
// TestNitro_RejectsFutureTimestamp refuses a document timestamped in the future.
func TestNitro_RejectsFutureTimestamp(t *testing.T) {
v := newNitroVec(t)
future := *v.doc
future.Timestamp = uint64(v.now.Add(time.Hour).UnixMilli())
ev := signDoc(t, v.leafPriv, &future)
_, err := Dispatch(context.Background(), KindNitro, ev,
WithNitroRoots(v.rootPool), WithNow(v.now), WithNitroMaxAge(time.Hour))
if !errors.Is(err, ErrPolicy) {
t.Fatalf("err = %v, want ErrPolicy (future timestamp)", err)
}
}
// TestNitro_RejectsWrongDigest refuses a non-SHA384 digest field.
func TestNitro_RejectsWrongDigest(t *testing.T) {
v := newNitroVec(t)
bad := *v.doc
bad.Digest = "SHA256"
ev := signDoc(t, v.leafPriv, &bad)
_, err := Dispatch(context.Background(), KindNitro, ev, WithNitroRoots(v.rootPool), WithNow(v.now))
if !errors.Is(err, ErrInvalidEvidence) {
t.Fatalf("err = %v, want ErrInvalidEvidence (digest)", err)
}
}
// TestNitro_RejectsBadPCRLength refuses a PCR that is not 48 bytes under SHA384.
func TestNitro_RejectsBadPCRLength(t *testing.T) {
v := newNitroVec(t)
bad := *v.doc
bad.PCRs = map[uint64][]byte{0: bytes.Repeat([]byte{0xa0}, 32), 1: v.pcr1}
ev := signDoc(t, v.leafPriv, &bad)
_, err := Dispatch(context.Background(), KindNitro, ev, WithNitroRoots(v.rootPool), WithNow(v.now))
if !errors.Is(err, ErrInvalidEvidence) {
t.Fatalf("err = %v, want ErrInvalidEvidence (pcr length)", err)
}
}
// TestNitro_RejectsNonP384Leaf refuses a leaf whose key is not P-384, even when
// the chain itself is well-formed.
func TestNitro_RejectsNonP384Leaf(t *testing.T) {
v := newNitroVec(t)
// Build a chain whose leaf is P-256; sign the COSE with ES256 so the
// envelope is well-formed. Verify must reject at the curve check, before
// any COSE signature check.
rootPool, leaf, leafPriv, cabundle := genNitroChain(t, elliptic.P256(), v.now.Add(-time.Hour), v.now.Add(3*time.Hour))
doc := *v.doc
doc.Certificate = leaf.Raw
doc.CABundle = cabundle
ev := signNitro(t, leafPriv, cose.AlgorithmES256, encodeNitroPayload(t, &doc))
_, err := Dispatch(context.Background(), KindNitro, ev, WithNitroRoots(rootPool), WithNow(v.now))
if !errors.Is(err, ErrInvalidEvidence) {
t.Fatalf("err = %v, want ErrInvalidEvidence (non-P384 leaf)", err)
}
}
// TestNitro_RejectsAlgSubstitution refuses a document whose protected header
// claims an algorithm other than ES384 (algorithm-confusion defense). We sign
// with a real P-384 ES384 signature but rewrite the protected header to ES512.
func TestNitro_RejectsAlgSubstitution(t *testing.T) {
v := newNitroVec(t)
msg, err := parseNitroCOSESign1(v.evidence)
if err != nil {
t.Fatalf("parse: %v", err)
}
// Force the protected header to a different alg; clear RawProtected so the
// rewritten Protected map is what gets re-encoded.
msg.Headers.RawProtected = nil
msg.Headers.Protected = cose.ProtectedHeader{cose.HeaderLabelAlgorithm: cose.AlgorithmES512}
bad, err := msg.MarshalCBOR()
if err != nil {
t.Fatalf("remarshal: %v", err)
}
_, err = Dispatch(context.Background(), KindNitro, bad, WithNitroRoots(v.rootPool), WithNow(v.now))
if !errors.Is(err, ErrSignatureInvalid) {
t.Fatalf("err = %v, want ErrSignatureInvalid (alg substitution)", err)
}
}
// TestNitro_RejectsBadCBOR refuses non-COSE garbage.
func TestNitro_RejectsBadCBOR(t *testing.T) {
for _, tc := range [][]byte{
nil,
{},
[]byte("not cbor at all"),
{0xd2, 0xff, 0xff}, // tag 18 then garbage
} {
_, err := Dispatch(context.Background(), KindNitro, tc)
if !errors.Is(err, ErrInvalidEvidence) {
t.Fatalf("evidence %x: err = %v, want ErrInvalidEvidence", tc, err)
}
}
}
// TestNitro_RejectsMissingPCR0 refuses a document with no PCR0.
func TestNitro_RejectsMissingPCR0(t *testing.T) {
v := newNitroVec(t)
bad := *v.doc
bad.PCRs = map[uint64][]byte{1: v.pcr1, 2: v.pcr2}
ev := signDoc(t, v.leafPriv, &bad)
_, err := Dispatch(context.Background(), KindNitro, ev, WithNitroRoots(v.rootPool), WithNow(v.now))
if !errors.Is(err, ErrInvalidEvidence) {
t.Fatalf("err = %v, want ErrInvalidEvidence (missing pcr0)", err)
}
}
// ---------------------------------------------------------------------------
// CompositeHash determinism
// ---------------------------------------------------------------------------
func TestNitro_CompositeHashStableAndContentBound(t *testing.T) {
v := newNitroVec(t)
r1, err := Dispatch(context.Background(), KindNitro, v.evidence, WithNitroRoots(v.rootPool), WithNow(v.now))
if err != nil {
t.Fatalf("verify 1: %v", err)
}
r2, err := Dispatch(context.Background(), KindNitro, v.evidence, WithNitroRoots(v.rootPool), WithNow(v.now))
if err != nil {
t.Fatalf("verify 2: %v", err)
}
if r1.CompositeHash != r2.CompositeHash {
t.Fatal("composite hash is not deterministic")
}
// A different PCR0 must change the composite hash.
other := *v.doc
other.PCRs = map[uint64][]byte{0: rep48(0x5a), 1: v.pcr1, 2: v.pcr2}
ev := signDoc(t, v.leafPriv, &other)
r3, err := Dispatch(context.Background(), KindNitro, ev, WithNitroRoots(v.rootPool), WithNow(v.now))
if err != nil {
t.Fatalf("verify 3: %v", err)
}
if r3.CompositeHash == r1.CompositeHash {
t.Fatal("composite hash collided across different PCR0")
}
}
// TestNitro_RejectsDuplicateCBORKey proves the hardened decoder is wired: a
// CBOR map carrying a duplicate key (here "digest" twice) is rejected, so a
// forger cannot smuggle a second value past the verifier. Bytes are hand-built
// because a conforming encoder will not emit duplicate keys.
func TestNitro_RejectsDuplicateCBORKey(t *testing.T) {
dup := []byte{
0xa2, // map, 2 pairs
0x66, 'd', 'i', 'g', 'e', 's', 't', 0x66, 'S', 'H', 'A', '3', '8', '4',
0x66, 'd', 'i', 'g', 'e', 's', 't', 0x66, 'S', 'H', 'A', '2', '5', '6',
}
if _, err := decodeNitroDoc(dup); err == nil {
t.Fatal("decodeNitroDoc accepted a duplicate map key")
}
}
// TestNitro_DispatchWired is a guard that KindNitro is wired into
// Dispatch (not a dangling verifier).
func TestNitro_DispatchWired(t *testing.T) {
v := newNitroVec(t)
// Direct verifier and Dispatch must agree.
direct, errD := Nitro{}.Verify(context.Background(), v.evidence, WithNitroRoots(v.rootPool), WithNow(v.now))
viaDispatch, errR := Dispatch(context.Background(), KindNitro, v.evidence, WithNitroRoots(v.rootPool), WithNow(v.now))
if errD != nil || errR != nil {
t.Fatalf("direct=%v dispatch=%v", errD, errR)
}
if direct.CompositeHash != viaDispatch.CompositeHash {
t.Fatal("Dispatch and direct Verify disagree")
}
}
+17
View File
@@ -20,6 +20,7 @@ const (
KindSEVSNP Kind = "sev_snp" // AMD SEV-SNP raw attestation report (ABI 0x4A0 bytes)
KindTDX Kind = "tdx" // Intel TDX TDREPORT / quote
KindNVTrust Kind = "nvtrust" // NVIDIA GPU CC attestation (local RIM-matched evidence)
KindNitro Kind = "nitro" // AWS Nitro Enclaves COSE_Sign1 attestation document
)
// Errors returned by the verifier package. Callers should switch on
@@ -137,6 +138,20 @@ type config struct {
// Required for KindNVTrust; the verifier refuses an empty set (no
// insecure mode).
nvtrustTrustRoots []nvidia.TrustRoot
// nitroRoots overrides the trust anchor for the KindNitro certificate
// chain. Holds a *x509.CertPool; kept as any so verifier.go need not
// import crypto/x509 (mirrors kdsGetter). nil ⇒ the pinned AWS Nitro
// Enclaves root. Tests set a generated root; production leaves it nil.
nitroRoots any
// nitroExpectedPCRs, if non-empty, pins required KindNitro PCR values by
// index. Every supplied index must be present and equal, or ErrPolicy.
nitroExpectedPCRs map[uint][]byte
// nitroMaxAge, if > 0, bounds KindNitro document freshness against the
// document timestamp. Zero ⇒ no age bound.
nitroMaxAge time.Duration
}
// nowOrWall returns the pinned verification clock when set, else the wall
@@ -233,6 +248,8 @@ func Dispatch(ctx context.Context, kind Kind, evidence []byte, opts ...Option) (
return TDX{}.Verify(ctx, evidence, opts...)
case KindNVTrust:
return NVTrust{}.Verify(ctx, evidence, opts...)
case KindNitro:
return Nitro{}.Verify(ctx, evidence, opts...)
default:
return nil, fmt.Errorf("%w: %q", ErrUnsupportedKind, string(kind))
}
+6 -1
View File
@@ -2,11 +2,16 @@ module github.com/luxfi/cc
go 1.26.4
require github.com/google/go-sev-guest v0.14.1
require (
github.com/fxamacker/cbor/v2 v2.9.2
github.com/google/go-sev-guest v0.14.1
github.com/veraison/go-cose v1.3.0
)
require (
github.com/google/logger v1.1.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sys v0.28.0 // indirect
+6
View File
@@ -1,5 +1,7 @@
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.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-configfs-tsm v0.2.2 h1:YnJ9rXIOj5BYD7/0DNnzs8AOp7UcvjfTvt215EWcs98=
@@ -14,6 +16,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/veraison/go-cose v1.3.0 h1:2/H5w8kdSpQJyVtIhx8gmwPJ2uSz1PkyWFx0idbd7rk=
github.com/veraison/go-cose v1.3.0/go.mod h1:df09OV91aHoQWLmy1KsDdYiagtXgyAwAl8vFeFn1gMc=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=