mirror of
https://github.com/luxfi/cc.git
synced 2026-09-24 00:24:54 +00:00
Fold Dispatch's hard Kind switch into the init() registry SGX introduced.
The registry (registry map, registerVerifier, RegisteredVerifier) moves out
of sgx.go into verifier.go as framework; Dispatch becomes one registry
lookup, fail-closed (ErrUnsupportedKind) for an unregistered Kind. The
switch — including Nitro's added case — is deleted: there is now exactly one
routing path.
Each Kind file is self-contained: its string const, its Verifier impl, its
per-call options, and its 'func init() { registerVerifier(...) }' live
together. SEVSNP/TDX/NVTrust/Nitro consts move out of verifier.go beside
their verifiers (SGX already did this); NVTrust's per-call WithNVTrustRIM /
WithNVTrustTrustRoots move to nvtrust.go for symmetry with WithNitro*. The
shared config keeps the per-Kind per-call policy fields (kdsGetter is shared
by SEV+TDX; nvtrust RIM/roots; nitro roots/PCRs/maxAge); NVTrust's
deployment trust anchors stay on the struct via NewNVTrust. NVTrust
registers its fail-closed zero value, matching prior Dispatch semantics.
registerVerifier panics on a duplicate Kind — a duplicate registration is a
programmer error that must fail loud at startup, never silently shadow a
verifier on the trust path.
doc.go: drop the now-false TDX/NVTrust STUB labels (both real), document all
five shipped Kinds + the registry + the pending ARMCCA slot, and the TDX
MRTD+RTMR measurement fold. verifier_test.go: retire the stub tests (done by
the TDX merge) and add TestRegistry_RoutesEveryKind asserting registry
routing for all five Kinds and fail-closed for the unregistered armcca slot.
go build/vet/test + go test -race ./attest/... all green; gofmt clean.
515 lines
19 KiB
Go
515 lines
19 KiB
Go
// 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"
|
|
)
|
|
|
|
// KindNitro is the AWS Nitro Enclaves framing: a CBOR-encoded COSE_Sign1
|
|
// attestation document produced by the Nitro Security Module.
|
|
const KindNitro Kind = "nitro"
|
|
|
|
// 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
|
|
}
|
|
|
|
func init() { registerVerifier(KindNitro, Nitro{}) }
|
|
|
|
// Compile-time guard: Nitro satisfies Verifier.
|
|
var _ Verifier = Nitro{}
|