diff --git a/go.mod b/go.mod index abce8209..44277ada 100644 --- a/go.mod +++ b/go.mod @@ -40,6 +40,7 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect @@ -70,6 +71,8 @@ require ( github.com/sirupsen/logrus v1.9.4 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect + github.com/veraison/go-cose v1.3.0 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect diff --git a/go.sum b/go.sum index 95bac1ea..391d6edc 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,8 @@ github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +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/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -128,6 +130,10 @@ github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9R github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/valkey-io/valkey-glide/go/v2 v2.3.1 h1:SB4wY7IjhmRh8WIBAgugoXimoW0mw9ZiGxDbKKhSagU= github.com/valkey-io/valkey-glide/go/v2 v2.3.1/go.mod h1:LK5zmODJa5xnxZndarh1trntExb3GVGJXz4GwDCagho= +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= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= diff --git a/tee-attestation/README.md b/tee-attestation/README.md new file mode 100644 index 00000000..69938a38 --- /dev/null +++ b/tee-attestation/README.md @@ -0,0 +1,60 @@ +# tee-attestation (prototype) + +Prototype implementation of the TMP router attestation wire shape proposed in [adcontextprotocol/adcp#5770](https://github.com/adcontextprotocol/adcp/pull/5770). + +**This is not production code.** It exists to answer the question the spec's slot-projection wording glosses over: what does the per-format byte layout for the binding rule look like against a real Nitro attestation document, end-to-end? The answer is the finding in [`nitro/PROJECTION.md`](./nitro/PROJECTION.md) — that's the payload back into the spec's slot-projection registry item. + +## What's here + +- **Top-level (`tee-attestation`)** — envelope shape, failure-mode enum, minimal JWK type, RFC 7638 thumbprint helper. Format-independent; the same types apply once TDX/SEV-SNP/GCP verifier kits arrive. +- **`nitro/`** — AWS Nitro Enclaves format only. + - `nsm.go` — `Nsm` interface (matches the AWS Nitro NSM `AttestationDoc` request shape). + - `mock.go` — mock `Nsm` that generates real COSE_Sign1 documents signed by a caller-owned test CA. Same wire format as a production Nitro document, which means the verifier code path is byte-for-byte identical between mock and prod. + - `nsm_real.go` — placeholder for the real /dev/nsm-backed impl, behind a `nitro` build tag. Wiring lands in a follow-up. + - `document.go` — CBOR payload struct + deterministic-encoding marshaler. + - `emit.go` — `Emit(ctx, nsm, req) → Envelope`. Uses Nitro's dedicated `nonce` and `public_key` fields per PROJECTION.md. + - `verify.go` — the read side. Walks the 9-step verification flow from `docs/trusted-match/router-attestation.mdx` and returns typed `VerifyError`s that name the failure modes from the spec. + +## Round-trip evidence + +`roundtrip_test.go` exercises emit → verify plus every failure mode from the spec's failure-mode table: + +| Test | Failure mode from spec | +|---|---| +| `TestNitroRoundTrip` | *(happy path)* | +| `TestVerifyRejectsNonceMismatch` | `nonce_mismatch` | +| `TestVerifyRejectsExpiredEnvelope` | `envelope_expired` | +| `TestVerifyRejectsUnsupportedFormat` | `unsupported_format` | +| `TestVerifyRejectsTamperedDocument` | `platform_verification_failed` | +| `TestVerifyRejectsSwappedSigningKey` | `signing_key_not_bound` | +| `TestVerifyRejectsWrongRoot` | `platform_verification_failed` | +| `TestVerifyRejectsPolicyDisallow` | `measurement_disallowed` | +| `TestVerifyAcceptsPerRequestPathWithoutNonceEcho` | *(per-request `X-TMP-Attestation` path)* | +| `TestEnvelopeJSONRoundTrip` | *(wire format stability)* | +| `TestJWKThumbprintStable` | *(RFC 7638 canonicalization)* | + +All pass on the mock. Running against a real Nitro instance is the next step (see "Not implemented"). + +## What this tells us about the spec + +See `nitro/PROJECTION.md` for the full write-up. Short version: + +1. **The spec's "same user-data slot" wording is a Nitro-specific category error.** Nitro has three distinct fields (`nonce`, `public_key`, `user_data`); using them directly is cleaner than inventing a synthetic packing convention. The spec should carry a **per-format normative table** (Nitro / TDX / SEV-SNP / GCP), not a single "slot" description. +2. **Raw pubkey bytes in Nitro's `public_key` field is the right projection.** Not a JCS-encoded JWK; not a thumbprint. Nitro's `public_key` field is designed for exactly this use. +3. **The RFC 7638 thumbprint stays in the verification recipe but is no longer load-bearing for Nitro.** With raw pubkey bytes on the wire, byte-comparison is sufficient; thumbprint agreement is a redundant sanity check (implemented in `verify.go` as a canary against future canonicalization drift on either side). + +## Not implemented + +- **Real Nitro NSM.** `nsm_real.go` is a stub behind the `nitro` build tag. Real wiring against `aws-nitro-enclaves-nsm-api` follows this PR. +- **Router integration.** `cmd/router` and `router/` do not use this package. Wiring in lands once the byte layout in the spec is settled — otherwise every wire change ripples into production code. +- **TDX / SEV-SNP / GCP Confidential Space.** Nitro-only for the initial finding. +- **`X-TMP-Attestation` per-request header carrier.** Envelope + verifier first; the header carrier is thin glue on top and lands after the spec-side questions close. +- **KMS-bound key custody.** The mock generates its own keypair. Real in-enclave key generation with KMS release-only-to-attested-workload is separate. + +## Testing + +``` +go test ./tee-attestation/... +``` + +Every test runs against the mock, on any machine — no Nitro tooling required. diff --git a/tee-attestation/doc.go b/tee-attestation/doc.go new file mode 100644 index 00000000..3842a066 --- /dev/null +++ b/tee-attestation/doc.go @@ -0,0 +1,33 @@ +// Package teeattestation is a PROTOTYPE implementation of the TMP router +// attestation wire shape proposed in adcontextprotocol/adcp#5770. +// +// The purpose of this package is NOT production use. It exists to answer the +// question the spec's slot-projection wording glosses over: what does the +// per-format byte layout for the [binding rule] actually look like against a +// real Nitro attestation document, end-to-end? The answer is meant to feed +// back into the spec — see nitro/PROJECTION.md for the finding. +// +// Structure: +// +// - Top-level: envelope shape, failure-mode enum, JWK thumbprint helper. +// Format-independent; the same types would apply once TDX/SEV-SNP/GCP +// verifier kits arrive. +// - nitro/: AWS Nitro Enclaves format. Emit path (Nsm interface, mock impl +// that generates real COSE_Sign1 documents signed by a test CA, and a +// stub real-Nitro impl behind a `nitro` build tag). Verify path +// (full COSE_Sign1 + cert-chain + measurement extraction + binding). +// +// Non-goals for this prototype: +// - Router integration (cmd/router, router/). Prove the shape works +// stand-alone first; wire in once the byte layout is settled. +// - TDX / SEV-SNP / GCP Confidential Space. Nitro-only for the finding. +// - Real Nitro NSM. The real impl behind the `nitro` build tag is a +// placeholder; running against a real Nitro instance lands in a +// follow-up. +// - X-TMP-Attestation header carrier (per-request attestation). Prove +// the envelope+verify path first. +// - KMS-bound key custody. The mock generates its own keypair; the +// real path plugs into an in-enclave key generator later. +// +// [binding rule]: https://github.com/adcontextprotocol/adcp/blob/main/docs/trusted-match/router-attestation.mdx +package teeattestation diff --git a/tee-attestation/envelope.go b/tee-attestation/envelope.go new file mode 100644 index 00000000..a2d2fba4 --- /dev/null +++ b/tee-attestation/envelope.go @@ -0,0 +1,181 @@ +package teeattestation + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "sort" + "time" +) + +// Format identifies one of the canonical externally-defined attestation +// formats the spec's `attestation_format` enum names. Kept as a string type +// so extension values can flow through in `ext` without a type change. +type Format string + +const ( + FormatAWSNitroCOSESign1V1 Format = "aws_nitro_cose_sign1_v1" + FormatIntelTDXQuoteV4 Format = "intel_tdx_quote_v4" + FormatAMDSEVSNPAttestationV1 Format = "amd_sev_snp_attestation_v1" + FormatGCPConfidentialSpaceV1 Format = "gcp_confidential_space_v1" +) + +// KnownFormats returns the v1 enum from the spec. Callers that gate on +// `attestation_requirement.acceptable_formats` should compare against this +// set unless they knowingly accept an experimental value from `ext`. +func KnownFormats() []Format { + return []Format{ + FormatAWSNitroCOSESign1V1, + FormatIntelTDXQuoteV4, + FormatAMDSEVSNPAttestationV1, + FormatGCPConfidentialSpaceV1, + } +} + +// Envelope is the JSON body returned by GET /.well-known/tmp-router-attestation +// per docs/trusted-match/router-attestation.mdx (spec PR +// adcontextprotocol/adcp#5770). +type Envelope struct { + Format Format `json:"attestation_format"` + Document string `json:"attestation_document"` // base64url, no pad + Nonce string `json:"nonce"` // base64url echo + SigningKey JWK `json:"signing_key"` + ExpiresAt time.Time `json:"expires_at"` + Ext json.RawMessage `json:"ext,omitempty"` +} + +// DocumentBytes returns the decoded attestation document. Envelope validation +// (schema checks, expiry, nonce echo) is done by the verifier; this helper +// only decodes. +func (e Envelope) DocumentBytes() ([]byte, error) { + b, err := base64.RawURLEncoding.DecodeString(e.Document) + if err != nil { + return nil, fmt.Errorf("attestation_document is not valid base64url-no-pad: %w", err) + } + return b, nil +} + +// NonceBytes returns the decoded nonce. +func (e Envelope) NonceBytes() ([]byte, error) { + b, err := base64.RawURLEncoding.DecodeString(e.Nonce) + if err != nil { + return nil, fmt.Errorf("nonce is not valid base64url-no-pad: %w", err) + } + if len(b) < 16 || len(b) > 32 { + return nil, fmt.Errorf("nonce must be 16-32 raw bytes, got %d", len(b)) + } + return b, nil +} + +// JWK is the minimal public-key shape the envelope carries. Bokelley's review +// on adcontextprotocol/adcp#5770 flagged that the spec's `$ref` to +// agent-signing-key.json inherits `revoked_at` and other trust-anchor config +// as a second source of truth for revocation. This local JWK keeps the wire +// object minimal — just the fields needed to reconstruct the raw key and +// compute an RFC 7638 thumbprint. `revoked_at` etc. still live on the trust +// anchor the envelope's signing_key resolves against, and the verifier is +// responsible for that resolution — see docs/trusted-match/router-attestation.mdx +// "Interaction with the RFC 9421 signing flow" section. +type JWK struct { + Kty string `json:"kty"` + Crv string `json:"crv,omitempty"` + Alg string `json:"alg,omitempty"` + Use string `json:"use,omitempty"` + Kid string `json:"kid,omitempty"` + X string `json:"x,omitempty"` // OKP / EC x coordinate, base64url no pad + Y string `json:"y,omitempty"` // EC only + N string `json:"n,omitempty"` // RSA only + E string `json:"e,omitempty"` // RSA only +} + +// Ed25519PublicKey returns the raw 32-byte Ed25519 public key when the JWK +// describes one. Returns an error otherwise. The spec allows any JWK; this +// prototype only implements OKP/Ed25519 to keep the surface small. +func (k JWK) Ed25519PublicKey() (ed25519.PublicKey, error) { + if k.Kty != "OKP" || k.Crv != "Ed25519" { + return nil, fmt.Errorf("expected OKP/Ed25519 JWK, got kty=%q crv=%q", k.Kty, k.Crv) + } + raw, err := base64.RawURLEncoding.DecodeString(k.X) + if err != nil { + return nil, fmt.Errorf("JWK.x is not valid base64url-no-pad: %w", err) + } + if len(raw) != ed25519.PublicKeySize { + return nil, fmt.Errorf("JWK.x is %d bytes, expected %d for Ed25519", len(raw), ed25519.PublicKeySize) + } + return ed25519.PublicKey(raw), nil +} + +// Ed25519JWK builds an OKP/Ed25519 JWK from a raw public key. +func Ed25519JWK(pub ed25519.PublicKey, kid string) JWK { + return JWK{ + Kty: "OKP", + Crv: "Ed25519", + Alg: "EdDSA", + Use: "sig", + Kid: kid, + X: base64.RawURLEncoding.EncodeToString(pub), + } +} + +// Thumbprint returns the RFC 7638 JWK thumbprint (SHA-256 of the canonical +// JCS-encoded JWK containing only the type-specific required members). +// This is the canonical form the spec's binding rule allows for comparison. +func (k JWK) Thumbprint() ([]byte, error) { + // RFC 7638 §3 for OKP: {"crv","kty","x"}. Members sorted alphabetically. + // RFC 7638 §3.1 requires no whitespace, no leading zero padding, ASCII + // JSON. `json.Marshal` on a map[string]string with a sorted key list + // produces the same bytes as JCS (RFC 8785) for the flat all-string + // shape a thumbprint requires — no floats, no nested objects, no + // escape ambiguity. + required, err := k.thumbprintMembers() + if err != nil { + return nil, err + } + keys := make([]string, 0, len(required)) + for k := range required { + keys = append(keys, k) + } + sort.Strings(keys) + // Hand-emit canonical JSON to guarantee byte-identity across + // serializer implementations. Same-shape output as RFC 8785 for this + // flat all-string map. + buf := []byte{'{'} + for i, name := range keys { + if i > 0 { + buf = append(buf, ',') + } + nameJSON, _ := json.Marshal(name) + buf = append(buf, nameJSON...) + buf = append(buf, ':') + valJSON, _ := json.Marshal(required[name]) + buf = append(buf, valJSON...) + } + buf = append(buf, '}') + sum := sha256.Sum256(buf) + return sum[:], nil +} + +func (k JWK) thumbprintMembers() (map[string]string, error) { + switch k.Kty { + case "OKP": + if k.Crv == "" || k.X == "" { + return nil, errors.New("OKP JWK missing required members crv/x for thumbprint") + } + return map[string]string{"crv": k.Crv, "kty": k.Kty, "x": k.X}, nil + case "EC": + if k.Crv == "" || k.X == "" || k.Y == "" { + return nil, errors.New("EC JWK missing required members crv/x/y for thumbprint") + } + return map[string]string{"crv": k.Crv, "kty": k.Kty, "x": k.X, "y": k.Y}, nil + case "RSA": + if k.N == "" || k.E == "" { + return nil, errors.New("RSA JWK missing required members n/e for thumbprint") + } + return map[string]string{"e": k.E, "kty": k.Kty, "n": k.N}, nil + default: + return nil, fmt.Errorf("unsupported JWK kty %q for thumbprint", k.Kty) + } +} diff --git a/tee-attestation/errors.go b/tee-attestation/errors.go new file mode 100644 index 00000000..35f43f70 --- /dev/null +++ b/tee-attestation/errors.go @@ -0,0 +1,38 @@ +package teeattestation + +// FailureMode is the closed set of verification-failure names the spec's +// normative page defines. A verifier that returns an HTTP 403 to the router +// on an inbound X-TMP-Attestation header uses these as the `code` in the +// TMP error body. +type FailureMode string + +const ( + FailureNonceMismatch FailureMode = "nonce_mismatch" + FailureEnvelopeExpired FailureMode = "envelope_expired" + FailureEnvelopeStale FailureMode = "envelope_stale" + FailureUnsupportedFormat FailureMode = "unsupported_format" + FailureSlotNonceMismatch FailureMode = "slot_nonce_mismatch" + FailureSigningKeyNotBound FailureMode = "signing_key_not_bound" + FailureMeasurementDisallowed FailureMode = "measurement_disallowed" + FailurePlatformVerification FailureMode = "platform_verification_failed" + FailureNetworkError FailureMode = "network_error" +) + +// VerifyError is the typed failure a verifier returns. Mode maps to the +// spec's failure-mode enum; the wrapped Err carries underlying detail for +// operator logs. The spec's failure-mode table is the operator-visible +// information budget — surfacing wrapped detail to the calling router +// should be done with care. +type VerifyError struct { + Mode FailureMode + Err error +} + +func (e *VerifyError) Error() string { + if e.Err == nil { + return string(e.Mode) + } + return string(e.Mode) + ": " + e.Err.Error() +} + +func (e *VerifyError) Unwrap() error { return e.Err } diff --git a/tee-attestation/nitro/PROJECTION.md b/tee-attestation/nitro/PROJECTION.md new file mode 100644 index 00000000..b81fa975 --- /dev/null +++ b/tee-attestation/nitro/PROJECTION.md @@ -0,0 +1,43 @@ +# Nitro slot projection + +**Finding for adcontextprotocol/adcp#5770's slot-projection registry item.** + +## What the spec says today + +The normative page (`docs/trusted-match/router-attestation.mdx`, "Binding rule" and "Nonce requirements") describes a single "platform user-data slot" that carries both the nonce and the JWK thumbprint, and delegates the byte layout to per-format verifier kits. That framing is a **category mismatch for Nitro** — a real Nitro attestation document has three separate, dedicated fields (`nonce`, `public_key`, `user_data`), not one shared slot. + +## What this package does + +This prototype uses Nitro's dedicated fields directly: + +| Envelope value | Nitro doc field | Why | +|---|---|---| +| `nonce` (raw bytes after base64url-decode) | `nonce` | Nitro's `nonce` field is designed for exactly this — an opaque per-attestation caller-supplied value that the NSM echoes into the signed document. Using it directly avoids inventing a synthetic packing convention. | +| `signing_key` (raw Ed25519 public-key bytes from JWK `x` after base64url-decode; 32 bytes) | `public_key` | Nitro's `public_key` field is documented as "the public key that the enclave wants to have attested." That is exactly what the binding rule anchors against. Raw 32-byte Ed25519 pubkey — the verifier reconstructs the JWK deterministically and compares thumbprints. | +| — | `user_data` | Unused for v1. Reserved so an extension can carry additional bound data (e.g., a workload identifier) without changing the wire shape. | + +## What this means for the spec + +The spec's normative page currently says (roughly): + +> The JWK in `signing_key` MUST appear bound in the platform user-data slot of `attestation_document` alongside the nonce. + +For Nitro, the more accurate normative claim is: + +> The JWK in `signing_key` MUST appear bound in the `public_key` field of the Nitro attestation document; the envelope's `nonce` MUST byte-match the `nonce` field of the Nitro attestation document. + +Two things follow: + +1. **The "same slot" wording needs a per-format table.** Nitro splits nonce and public-key into separate fields. TDX and SEV-SNP have a single 64-byte `REPORTDATA`, which forces packing (nonce(32) ‖ thumbprint(32) is the natural fit). GCP Confidential Space is a JWT with `eat_nonce` and workload-image claims — no raw slot at all, so the projection is a claim-name mapping rather than a byte layout. **One normative registry entry per format** is what the spec needs. +2. **Thumbprint canonicalization stops being load-bearing on Nitro.** Because Nitro `public_key` carries the raw pubkey bytes, the "byte-match after canonical JWK serialization" wording collapses to "reconstruct the JWK from these bytes and check the thumbprint of the envelope's `signing_key` matches." The verifier still uses RFC 7638 thumbprints for the comparison, but the on-wire bytes are the raw key, not the JWK. This is a strictly cleaner story — worth calling out in the spec so implementers don't accidentally embed a JCS-encoded JWK where a raw pubkey is expected. + +## What was tried and rejected + +- **JWK thumbprint (32 bytes) in `user_data`, raw pubkey in `public_key`.** Redundant — the thumbprint is derivable from `public_key`. +- **JCS-encoded JWK bytes in `public_key`.** Fits (JCS Ed25519 JWK is ~150 bytes; Nitro `public_key` allows up to 1024), but conflates JWK-text canonicalization concerns into the platform binding. Raw bytes are simpler. +- **Nonce packed into `user_data` alongside the JWK.** Wastes Nitro's dedicated `nonce` field and forces a synthetic packing convention. + +## Deferred + +- **TDX/SEV-SNP `REPORTDATA` layout** — proposed nonce(32) ‖ SHA-256(canonical Ed25519 pubkey bytes)(32). Not implemented in this prototype; the finding here is Nitro-only. +- **GCP Confidential Space claim mapping** — needs research against the actual token schema. Bokelley's review flagged that `submods.container.image_digest` is the workload claim, not `submods.confidential_space.image_digest`. The nonce carrier is `eat_nonce`. The JWK binding needs a specific claim path (likely a Google-defined custom claim, or piggybacking on `submods.container.workload_labels`). diff --git a/tee-attestation/nitro/document.go b/tee-attestation/nitro/document.go new file mode 100644 index 00000000..c12fef56 --- /dev/null +++ b/tee-attestation/nitro/document.go @@ -0,0 +1,73 @@ +package nitro + +import ( + "fmt" + + "github.com/fxamacker/cbor/v2" +) + +// Document is the CBOR payload of a Nitro attestation COSE_Sign1. Field +// tags follow the map keys AWS uses in the on-wire format — string keys, +// not integer, per the AWS Nitro attestation-document format. +// +// See AWS docs: "Attestation Document" under Nitro Enclaves. +type Document struct { + ModuleID string `cbor:"module_id"` + Timestamp uint64 `cbor:"timestamp"` + Digest string `cbor:"digest"` + PCRs map[uint32][]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"` +} + +// documentAlias is a struct-tagged twin of Document without a MarshalCBOR +// method — used inside Document.MarshalCBOR to break the recursion the +// cbor library would otherwise land in (calling the method to encode the +// value it's already encoding). +type documentAlias struct { + ModuleID string `cbor:"module_id"` + Timestamp uint64 `cbor:"timestamp"` + Digest string `cbor:"digest"` + PCRs map[uint32][]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"` +} + +// MarshalCBOR emits deterministic CBOR (Core Deterministic Encoding). +// AWS's real NSM output is Core Deterministic; matching that in the mock +// keeps the verifier code path byte-for-byte identical. +func (d Document) MarshalCBOR() ([]byte, error) { + enc, err := cbor.CoreDetEncOptions().EncMode() + if err != nil { + return nil, fmt.Errorf("nitro: build CBOR encoder: %w", err) + } + return enc.Marshal(documentAlias(d)) +} + +// UnmarshalDocumentPayload parses the CBOR payload of a Nitro attestation +// document. Called by the verifier after it has cracked open the COSE_Sign1. +func UnmarshalDocumentPayload(payload []byte) (*Document, error) { + var doc Document + if err := cbor.Unmarshal(payload, &doc); err != nil { + return nil, fmt.Errorf("nitro: parse document payload: %w", err) + } + if doc.ModuleID == "" { + return nil, fmt.Errorf("nitro: document missing module_id") + } + if doc.Digest == "" { + return nil, fmt.Errorf("nitro: document missing digest") + } + if len(doc.PCRs) == 0 { + return nil, fmt.Errorf("nitro: document has no PCRs") + } + if len(doc.Certificate) == 0 { + return nil, fmt.Errorf("nitro: document missing certificate") + } + return &doc, nil +} diff --git a/tee-attestation/nitro/emit.go b/tee-attestation/nitro/emit.go new file mode 100644 index 00000000..99b552ef --- /dev/null +++ b/tee-attestation/nitro/emit.go @@ -0,0 +1,57 @@ +package nitro + +import ( + "context" + "encoding/base64" + "fmt" + "time" + + tee "github.com/adcontextprotocol/adcp-go/tee-attestation" +) + +// EmitRequest holds the inputs the emit path needs — the nonce the verifier +// challenged the router with, and the router's in-enclave signing key. +type EmitRequest struct { + // Nonce is the raw bytes the verifier supplied on the fetch. Must be + // 16-32 bytes per the spec. + Nonce []byte + + // SigningKey is the router's per-provider signing key, expressed as a + // JWK. Only OKP/Ed25519 is supported in this prototype (matches the + // existing TMP signing envelope in specification.mdx). + SigningKey tee.JWK + + // ExpiresAt is the router's suggested freshness ceiling. Verifiers may + // enforce shorter via `attestation_requirement.min_freshness_sec`. + ExpiresAt time.Time +} + +// Emit produces a signed envelope by calling the Nsm with fields projected +// per PROJECTION.md — Nitro `nonce` carries the raw nonce; Nitro `public_key` +// carries the raw Ed25519 public-key bytes. +func Emit(ctx context.Context, nsm Nsm, req EmitRequest) (tee.Envelope, error) { + if len(req.Nonce) < 16 || len(req.Nonce) > 32 { + return tee.Envelope{}, fmt.Errorf("nitro emit: nonce must be 16-32 raw bytes, got %d", len(req.Nonce)) + } + pub, err := req.SigningKey.Ed25519PublicKey() + if err != nil { + return tee.Envelope{}, fmt.Errorf("nitro emit: signing_key: %w", err) + } + // See PROJECTION.md for why nonce lands in Nitro.nonce and the raw + // pubkey lands in Nitro.public_key. user_data is intentionally unused + // in v1 — reserved for a later extension. + doc, err := nsm.Attest(ctx, AttestRequest{ + Nonce: req.Nonce, + PublicKey: pub, + }) + if err != nil { + return tee.Envelope{}, fmt.Errorf("nitro emit: NSM Attest: %w", err) + } + return tee.Envelope{ + Format: tee.FormatAWSNitroCOSESign1V1, + Document: base64.RawURLEncoding.EncodeToString(doc), + Nonce: base64.RawURLEncoding.EncodeToString(req.Nonce), + SigningKey: req.SigningKey, + ExpiresAt: req.ExpiresAt, + }, nil +} diff --git a/tee-attestation/nitro/mock.go b/tee-attestation/nitro/mock.go new file mode 100644 index 00000000..dc3af49b --- /dev/null +++ b/tee-attestation/nitro/mock.go @@ -0,0 +1,192 @@ +package nitro + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/sha512" + "crypto/x509" + "crypto/x509/pkix" + "errors" + "fmt" + "math/big" + "time" + + "github.com/veraison/go-cose" +) + +// MockNsm generates real COSE_Sign1 Nitro attestation documents signed by a +// caller-owned test CA. Same wire format as a production Nitro document, +// which means the verifier code path is exercised identically between the +// mock and a real Nitro instance — including COSE_Sign1 parsing, X.509 +// chain verification, ECDSA-P384 signature verification, PCR extraction, +// and the binding rule. +// +// The mock is NOT a security boundary. Its RootCert is public in whatever +// tree it lives in; verifiers built against it are only useful in tests. +// Production verifiers must be constructed with the real AWS Nitro Root CA +// (see verify.go's Verifier struct). +type MockNsm struct { + // RootCert and RootKey are the CA the mock uses to sign the NSM + // certificate. Verifiers use RootCert as the trust anchor. + RootCert *x509.Certificate + RootKey *ecdsa.PrivateKey + + // NsmCert and NsmKey are the "NSM" leaf that signs COSE_Sign1 + // documents. Chains up to RootCert. Regenerated per MockNsm to keep + // runs independent. + NsmCert *x509.Certificate + NsmKey *ecdsa.PrivateKey + + // ModuleID is echoed into every document. Fixed per-mock so tests can + // assert on it. + ModuleID string + + // Now returns the current time for timestamps and certificate + // validity. Test can inject a clock. + Now func() time.Time + + // PCRs is the mock PCR set every document reports. Tests can point + // this at whatever measurements they want to exercise the allowlist + // against. + PCRs map[uint32][]byte +} + +// NewMockNsm builds a MockNsm with a fresh CA and NSM leaf. PCRs default to +// a stable set of "audited router" placeholders; override to test the +// allowlist code path. +func NewMockNsm() (*MockNsm, error) { + now := time.Now + rootKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("mock: gen root key: %w", err) + } + rootTmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "TMP Router Attestation Mock Root CA"}, + NotBefore: now().Add(-time.Hour), + NotAfter: now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + rootDER, err := x509.CreateCertificate(rand.Reader, rootTmpl, rootTmpl, &rootKey.PublicKey, rootKey) + if err != nil { + return nil, fmt.Errorf("mock: create root cert: %w", err) + } + rootCert, err := x509.ParseCertificate(rootDER) + if err != nil { + return nil, fmt.Errorf("mock: parse root cert: %w", err) + } + + nsmKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("mock: gen NSM key: %w", err) + } + nsmTmpl := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "Mock NSM"}, + NotBefore: now().Add(-time.Hour), + NotAfter: now().Add(12 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + } + nsmDER, err := x509.CreateCertificate(rand.Reader, nsmTmpl, rootCert, &nsmKey.PublicKey, rootKey) + if err != nil { + return nil, fmt.Errorf("mock: create NSM cert: %w", err) + } + nsmCert, err := x509.ParseCertificate(nsmDER) + if err != nil { + return nil, fmt.Errorf("mock: parse NSM cert: %w", err) + } + + return &MockNsm{ + RootCert: rootCert, + RootKey: rootKey, + NsmCert: nsmCert, + NsmKey: nsmKey, + ModuleID: "mock-nsm", + Now: now, + PCRs: defaultMockPCRs(), + }, nil +} + +// Attest produces a real COSE_Sign1 Nitro document that verifies against +// MockNsm.RootCert. Same wire format as a production Nitro document. +func (m *MockNsm) Attest(ctx context.Context, req AttestRequest) ([]byte, error) { + if m == nil { + return nil, errors.New("mock: nil MockNsm") + } + if err := ctx.Err(); err != nil { + return nil, err + } + if len(req.Nonce) > 512 { + return nil, fmt.Errorf("mock: nonce %d bytes exceeds 512", len(req.Nonce)) + } + if len(req.PublicKey) > 1024 { + return nil, fmt.Errorf("mock: public_key %d bytes exceeds 1024", len(req.PublicKey)) + } + if len(req.UserData) > 512 { + return nil, fmt.Errorf("mock: user_data %d bytes exceeds 512", len(req.UserData)) + } + + doc := Document{ + ModuleID: m.ModuleID, + Timestamp: uint64(m.Now().UnixMilli()), + Digest: "SHA384", + PCRs: m.PCRs, + Certificate: m.NsmCert.Raw, + CABundle: [][]byte{m.RootCert.Raw}, + PublicKey: req.PublicKey, + UserData: req.UserData, + Nonce: req.Nonce, + } + payload, err := doc.MarshalCBOR() + if err != nil { + return nil, err + } + + // Wrap in COSE_Sign1 with ES384 (matching real Nitro NSM). The + // veraison/go-cose library's Signer interface uses an internal Algorithm + // enum — cose.AlgorithmES384 for ECDSA P-384 + SHA-384. + signer, err := cose.NewSigner(cose.AlgorithmES384, m.NsmKey) + if err != nil { + return nil, fmt.Errorf("mock: build COSE signer: %w", err) + } + msg := cose.NewSign1Message() + msg.Headers.Protected.SetAlgorithm(cose.AlgorithmES384) + msg.Payload = payload + if err := msg.Sign(rand.Reader, nil, signer); err != nil { + return nil, fmt.Errorf("mock: COSE_Sign1 sign: %w", err) + } + return msg.MarshalCBOR() +} + +// defaultMockPCRs returns a placeholder PCR map that looks structurally like +// what a real Nitro EIF produces: SHA-384 (48 bytes) per register, with the +// first few filled and the rest zeroed. Tests that exercise measurement +// allowlisting should override this via MockNsm.PCRs. +func defaultMockPCRs() map[uint32][]byte { + pcrs := make(map[uint32][]byte, 16) + // A stable non-zero PCR0 so an allowlist can key off it. Real PCR0 + // is the SHA-384 of the EIF; here we use a fixed marker so tests + // don't need to reproduce that derivation. + pcr0 := sha512.Sum384([]byte("mock-nsm/audited-tmp-router-v0.0.1")) + pcrs[0] = pcr0[:] + // PCR1 and PCR2 as documented — SHA-384 of the boot kernel + init + // respectively. Different fixed markers so a test that wants to + // deny-on-PCR1 sees a distinguishable value. + pcr1 := sha512.Sum384([]byte("mock-nsm/kernel")) + pcrs[1] = pcr1[:] + pcr2 := sha512.Sum384([]byte("mock-nsm/init")) + pcrs[2] = pcr2[:] + for i := uint32(3); i < 16; i++ { + pcrs[i] = make([]byte, sha256.Size+16) // 48 bytes of zeros + } + return pcrs +} + +// Ensure MockNsm satisfies Nsm. +var _ Nsm = (*MockNsm)(nil) diff --git a/tee-attestation/nitro/nsm.go b/tee-attestation/nitro/nsm.go new file mode 100644 index 00000000..df1bd9b2 --- /dev/null +++ b/tee-attestation/nitro/nsm.go @@ -0,0 +1,54 @@ +// Package nitro implements the AWS Nitro Enclaves branch of the TMP router +// attestation wire shape (spec PR adcontextprotocol/adcp#5770). It contains +// the emit path (Nsm interface + a mock impl that generates real +// COSE_Sign1 documents signed by a test CA) and the verify path (full +// COSE_Sign1 + cert-chain-to-root + measurement extraction + binding rule). +// +// See PROJECTION.md for the byte-layout decisions this package makes for +// Nitro-specific fields (`nonce`, `public_key`, `user_data`) — this is the +// finding meant to feed back into the spec's slot-projection registry. +package nitro + +import ( + "context" + "errors" +) + +// Nsm is the Nitro Security Module surface the emit path calls into. In an +// enclave, the real impl talks to /dev/nsm via `nsm-lib`. Out of an enclave +// (tests, CI, this prototype), the mock impl in mock.go generates equivalent +// COSE_Sign1 documents signed by a test CA — same wire format so the +// verifier code path is byte-for-byte identical between mock and prod. +type Nsm interface { + // Attest requests an attestation document from the NSM. + // + // The three optional fields map to the Nitro NSM Attestation request + // per aws-nitro-enclaves-nsm-api: + // - Nonce — an opaque value the caller supplies; the NSM echoes + // it into the document's `nonce` field. + // - PublicKey — an opaque public-key blob the enclave wants attested; + // the NSM echoes it into the document's `public_key` + // field. Max 1024 bytes. + // - UserData — arbitrary bytes; echoed into `user_data`. + // Max 512 bytes. + // + // The returned bytes are the COSE_Sign1-wrapped Nitro attestation + // document, ready to be base64url-encoded into the envelope's + // `attestation_document` field. + Attest(ctx context.Context, req AttestRequest) ([]byte, error) +} + +// AttestRequest matches the Nitro NSM AttestationDoc request fields. Any of +// the fields may be nil; unset fields are omitted from the resulting +// document. +type AttestRequest struct { + Nonce []byte + PublicKey []byte + UserData []byte +} + +// ErrNotImplemented is returned by the real-Nitro stub when the build was +// not made with the `nitro` build tag. Kept in the interface package so +// callers can errors.Is against it without pulling the real-impl file into +// non-Nitro builds. +var ErrNotImplemented = errors.New("nitro: real NSM impl requires the `nitro` build tag and a Nitro Enclaves host") diff --git a/tee-attestation/nitro/nsm_real.go b/tee-attestation/nitro/nsm_real.go new file mode 100644 index 00000000..fc94267b --- /dev/null +++ b/tee-attestation/nitro/nsm_real.go @@ -0,0 +1,24 @@ +//go:build nitro + +package nitro + +import "context" + +// RealNsm is the /dev/nsm-backed implementation used inside a Nitro Enclave. +// It is intentionally a stub in this prototype — validating the wire shape +// against a real Nitro instance is the follow-up to this package. +// +// Wiring notes (for the follow-up): +// - Depend on aws-nitro-enclaves-nsm-api (Go bindings via CGO, or the Rust +// binary via `nsm-cli`). +// - Attest maps AttestRequest.Nonce/PublicKey/UserData directly onto the +// NSM's AttestationDoc request fields. +// - Real NSM returns the COSE_Sign1 wrapped attestation document; the +// verifier code path here is byte-identical between mock and real. +type RealNsm struct{} + +func (RealNsm) Attest(_ context.Context, _ AttestRequest) ([]byte, error) { + return nil, ErrNotImplemented +} + +var _ Nsm = RealNsm{} diff --git a/tee-attestation/nitro/verify.go b/tee-attestation/nitro/verify.go new file mode 100644 index 00000000..bb7217ae --- /dev/null +++ b/tee-attestation/nitro/verify.go @@ -0,0 +1,254 @@ +package nitro + +import ( + "bytes" + "crypto/ecdsa" + "crypto/x509" + "encoding/base64" + "fmt" + "math" + "time" + + "github.com/veraison/go-cose" + + tee "github.com/adcontextprotocol/adcp-go/tee-attestation" +) + +// Verifier is the read side of the Nitro attestation flow. It walks the +// 9-step verification flow from docs/trusted-match/router-attestation.mdx. +type Verifier struct { + // RootCert is the trust anchor for the NSM certificate chain. In + // production this is the AWS Nitro Root CA; in tests this is the + // MockNsm's RootCert. + RootCert *x509.Certificate + + // Now returns "current time" for expiry checks. Nil defaults to + // time.Now. + Now func() time.Time + + // Policy is applied to extracted measurements after platform-side + // verification succeeds. Nil means "accept any measurement" (useful in + // tests; production callers should always supply an allowlist). + Policy Policy +} + +// Policy decides whether a given set of measurements is acceptable. See the +// spec's failure-mode table entry `measurement_disallowed`. The allowlist is +// deploy-side policy; this package supplies the plumbing, not the values. +type Policy interface { + AllowMeasurements(m Measurements) error +} + +// PolicyFunc is a convenience adapter for callers who don't want to declare +// a type. +type PolicyFunc func(m Measurements) error + +// AllowMeasurements implements Policy. +func (f PolicyFunc) AllowMeasurements(m Measurements) error { return f(m) } + +// Measurements is the extracted set of Nitro PCRs. +type Measurements struct { + Digest string // e.g. "SHA384" + PCRs map[uint32][]byte // by index +} + +// VerifiedEnvelope is what a successful Verify returns — the parsed +// envelope, the raw Nitro document fields for downstream inspection, the +// signing key that is now provably held by the attested binary, and the +// measurements the policy accepted. +type VerifiedEnvelope struct { + Envelope tee.Envelope + Document *Document + SigningKey tee.JWK + Measurements Measurements +} + +// Verify walks the 9-step verification flow. expectedNonce is the raw bytes +// the verifier sent to GET /.well-known/tmp-router-attestation. Pass nil to +// skip the envelope-fetch-path nonce echo (step 1) — that's what the spec +// prescribes on the per-request X-TMP-Attestation code path, where freshness +// comes from `expires_at` + `min_freshness_sec` instead. +func (v *Verifier) Verify(env tee.Envelope, expectedNonce []byte) (*VerifiedEnvelope, error) { + if v == nil || v.RootCert == nil { + return nil, fmt.Errorf("nitro verify: Verifier misconfigured (nil RootCert)") + } + now := time.Now + if v.Now != nil { + now = v.Now + } + + // --- Step 1: nonce echo (envelope-fetch path). Skipped when caller + // passes nil, per the spec's per-request X-TMP-Attestation branch. + envelopeNonce, err := env.NonceBytes() + if err != nil { + return nil, &tee.VerifyError{Mode: tee.FailureNonceMismatch, Err: err} + } + if expectedNonce != nil && !bytes.Equal(envelopeNonce, expectedNonce) { + return nil, &tee.VerifyError{Mode: tee.FailureNonceMismatch, + Err: fmt.Errorf("envelope.nonce did not byte-equal verifier-sent nonce")} + } + + // --- Step 2: expiry. + if !env.ExpiresAt.IsZero() && now().After(env.ExpiresAt) { + return nil, &tee.VerifyError{Mode: tee.FailureEnvelopeExpired, + Err: fmt.Errorf("envelope expired at %s (now %s)", env.ExpiresAt, now())} + } + + // --- Step 3: min-freshness. Verifier-kit-derived per PROJECTION.md + // (Nitro carries `timestamp` in ms since epoch inside the document); + // applied after step 5 below when we've parsed the document. + + // --- Step 4: format support. This kit only handles Nitro v1. + if env.Format != tee.FormatAWSNitroCOSESign1V1 { + return nil, &tee.VerifyError{Mode: tee.FailureUnsupportedFormat, + Err: fmt.Errorf("nitro verifier does not handle format %q", env.Format)} + } + + // --- Step 5: platform document verification. + docBytes, err := env.DocumentBytes() + if err != nil { + return nil, &tee.VerifyError{Mode: tee.FailurePlatformVerification, Err: err} + } + msg := cose.NewSign1Message() + if err := msg.UnmarshalCBOR(docBytes); err != nil { + return nil, &tee.VerifyError{Mode: tee.FailurePlatformVerification, + Err: fmt.Errorf("parse COSE_Sign1: %w", err)} + } + doc, err := UnmarshalDocumentPayload(msg.Payload) + if err != nil { + return nil, &tee.VerifyError{Mode: tee.FailurePlatformVerification, Err: err} + } + // Cert chain to RootCert. + leaf, err := x509.ParseCertificate(doc.Certificate) + if err != nil { + return nil, &tee.VerifyError{Mode: tee.FailurePlatformVerification, + Err: fmt.Errorf("parse NSM certificate: %w", err)} + } + roots := x509.NewCertPool() + roots.AddCert(v.RootCert) + intermediates := x509.NewCertPool() + for _, raw := range doc.CABundle { + if bytes.Equal(raw, v.RootCert.Raw) { + continue // don't add the root as an intermediate + } + c, err := x509.ParseCertificate(raw) + if err != nil { + return nil, &tee.VerifyError{Mode: tee.FailurePlatformVerification, + Err: fmt.Errorf("parse intermediate cert: %w", err)} + } + intermediates.AddCert(c) + } + if _, err := leaf.Verify(x509.VerifyOptions{ + Roots: roots, + Intermediates: intermediates, + CurrentTime: now(), + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning, x509.ExtKeyUsageAny}, + }); err != nil { + return nil, &tee.VerifyError{Mode: tee.FailurePlatformVerification, + Err: fmt.Errorf("cert chain to root: %w", err)} + } + // COSE_Sign1 signature. + leafPub, ok := leaf.PublicKey.(*ecdsa.PublicKey) + if !ok { + return nil, &tee.VerifyError{Mode: tee.FailurePlatformVerification, + Err: fmt.Errorf("NSM cert public key is %T, expected *ecdsa.PublicKey", leaf.PublicKey)} + } + alg, err := msg.Headers.Protected.Algorithm() + if err != nil { + return nil, &tee.VerifyError{Mode: tee.FailurePlatformVerification, + Err: fmt.Errorf("read COSE algorithm: %w", err)} + } + verifier, err := cose.NewVerifier(alg, leafPub) + if err != nil { + return nil, &tee.VerifyError{Mode: tee.FailurePlatformVerification, + Err: fmt.Errorf("build COSE verifier: %w", err)} + } + if err := msg.Verify(nil, verifier); err != nil { + return nil, &tee.VerifyError{Mode: tee.FailurePlatformVerification, + Err: fmt.Errorf("COSE_Sign1 signature: %w", err)} + } + + // --- Step 3 (deferred): min-freshness. + if env.ExpiresAt.IsZero() && doc.Timestamp > 0 { + // If the envelope carries no expires_at, honor a conservative + // default freshness window derived from doc.Timestamp. Cap at + // math.MaxInt64 to satisfy gosec (uint64→int64 overflow) — an + // attestation timestamp near 2^63 ms since epoch is not a real + // value, and treating an out-of-range one as "very far in the + // future" keeps the freshness check conservative. + var docMS int64 = math.MaxInt64 + if doc.Timestamp <= math.MaxInt64 { + docMS = int64(doc.Timestamp) + } + docTime := time.UnixMilli(docMS) + if now().Sub(docTime) > time.Hour { + return nil, &tee.VerifyError{Mode: tee.FailureEnvelopeStale, + Err: fmt.Errorf("document timestamp %s older than 1h default freshness (now %s)", docTime, now())} + } + } + + // --- Step 6: slot-bound nonce. + if !bytes.Equal(doc.Nonce, envelopeNonce) { + return nil, &tee.VerifyError{Mode: tee.FailureSlotNonceMismatch, + Err: fmt.Errorf("nonce in document (%d bytes) does not match envelope.nonce (%d bytes)", len(doc.Nonce), len(envelopeNonce))} + } + + // --- Step 7: binding rule. See PROJECTION.md — Nitro places the raw + // Ed25519 pubkey bytes in doc.PublicKey. Reconstruct a JWK from those + // bytes and compare RFC 7638 thumbprints against envelope.signing_key. + envelopePub, err := env.SigningKey.Ed25519PublicKey() + if err != nil { + return nil, &tee.VerifyError{Mode: tee.FailureSigningKeyNotBound, + Err: fmt.Errorf("envelope.signing_key: %w", err)} + } + if !bytes.Equal(doc.PublicKey, envelopePub) { + return nil, &tee.VerifyError{Mode: tee.FailureSigningKeyNotBound, + Err: fmt.Errorf("document.public_key does not match envelope.signing_key raw bytes")} + } + // Sanity: thumbprints agree. This is redundant given the raw-byte + // check above, but it's the check the spec's normative page names, + // and forcing it locally means a future canonicalization change on + // either side surfaces here rather than in a downstream integration. + docJWK := tee.Ed25519JWK(envelopePub, env.SigningKey.Kid) + docTP, err := docJWK.Thumbprint() + if err != nil { + return nil, &tee.VerifyError{Mode: tee.FailureSigningKeyNotBound, + Err: fmt.Errorf("reconstruct doc JWK thumbprint: %w", err)} + } + envTP, err := env.SigningKey.Thumbprint() + if err != nil { + return nil, &tee.VerifyError{Mode: tee.FailureSigningKeyNotBound, + Err: fmt.Errorf("envelope JWK thumbprint: %w", err)} + } + if !bytes.Equal(docTP, envTP) { + return nil, &tee.VerifyError{Mode: tee.FailureSigningKeyNotBound, + Err: fmt.Errorf("RFC 7638 thumbprint mismatch between envelope and reconstructed doc JWK")} + } + + // --- Step 8: measurement policy. + measurements := Measurements{Digest: doc.Digest, PCRs: doc.PCRs} + if v.Policy != nil { + if err := v.Policy.AllowMeasurements(measurements); err != nil { + return nil, &tee.VerifyError{Mode: tee.FailureMeasurementDisallowed, Err: err} + } + } + + // --- Step 9: caller caches on success. + return &VerifiedEnvelope{ + Envelope: env, + Document: doc, + SigningKey: env.SigningKey, + Measurements: measurements, + }, nil +} + +// EnvelopeThumbprint returns the RFC 7638 thumbprint of the envelope's +// signing key, encoded as base64url-no-pad. Callers use this as a cache key +// per docs/trusted-match/router-attestation.mdx "Caching". +func EnvelopeThumbprint(env tee.Envelope) (string, error) { + tp, err := env.SigningKey.Thumbprint() + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(tp), nil +} diff --git a/tee-attestation/roundtrip_test.go b/tee-attestation/roundtrip_test.go new file mode 100644 index 00000000..59c9e2e5 --- /dev/null +++ b/tee-attestation/roundtrip_test.go @@ -0,0 +1,269 @@ +package teeattestation_test + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "testing" + "time" + + tee "github.com/adcontextprotocol/adcp-go/tee-attestation" + "github.com/adcontextprotocol/adcp-go/tee-attestation/nitro" +) + +// TestNitroRoundTrip walks the full emit → verify path against the mock NSM. +// This is the primary evidence that the wire shape proposed in +// adcontextprotocol/adcp#5770 is realizable and self-consistent. +func TestNitroRoundTrip(t *testing.T) { + ctx := context.Background() + + mock, err := nitro.NewMockNsm() + if err != nil { + t.Fatalf("build mock NSM: %v", err) + } + + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("gen router signing key: %v", err) + } + signingKey := tee.Ed25519JWK(pub, "router-test-2026-07") + + nonce := make([]byte, 32) + if _, err := rand.Read(nonce); err != nil { + t.Fatalf("gen nonce: %v", err) + } + + env, err := nitro.Emit(ctx, mock, nitro.EmitRequest{ + Nonce: nonce, + SigningKey: signingKey, + ExpiresAt: time.Now().Add(5 * time.Minute), + }) + if err != nil { + t.Fatalf("Emit: %v", err) + } + if env.Format != tee.FormatAWSNitroCOSESign1V1 { + t.Errorf("envelope.format = %q, want %q", env.Format, tee.FormatAWSNitroCOSESign1V1) + } + if _, err := env.DocumentBytes(); err != nil { + t.Errorf("envelope.document not base64url-decodable: %v", err) + } + + v := &nitro.Verifier{RootCert: mock.RootCert} + ver, err := v.Verify(env, nonce) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if ver.SigningKey.X != signingKey.X { + t.Errorf("verified signing key does not match emitted key") + } + if len(ver.Measurements.PCRs) == 0 { + t.Errorf("verified measurements have no PCRs") + } +} + +// TestVerifyRejectsNonceMismatch — verifier-sent nonce differs from the one +// echoed in the envelope. Step 1 of the flow. +func TestVerifyRejectsNonceMismatch(t *testing.T) { + env, mock, _ := mustEmit(t) + wrongNonce := make([]byte, 32) + wrongNonce[0] = 0xFF + assertFailure(t, mock, env, wrongNonce, tee.FailureNonceMismatch) +} + +// TestVerifyRejectsExpiredEnvelope — step 2. +func TestVerifyRejectsExpiredEnvelope(t *testing.T) { + env, mock, nonce := mustEmit(t) + env.ExpiresAt = time.Now().Add(-time.Minute) + assertFailure(t, mock, env, nonce, tee.FailureEnvelopeExpired) +} + +// TestVerifyRejectsUnsupportedFormat — step 4. +func TestVerifyRejectsUnsupportedFormat(t *testing.T) { + env, mock, nonce := mustEmit(t) + env.Format = tee.FormatIntelTDXQuoteV4 + assertFailure(t, mock, env, nonce, tee.FailureUnsupportedFormat) +} + +// TestVerifyRejectsTamperedDocument — step 5. Flip a byte in the COSE_Sign1 +// payload; signature verification must fail. +func TestVerifyRejectsTamperedDocument(t *testing.T) { + env, mock, nonce := mustEmit(t) + raw, err := base64.RawURLEncoding.DecodeString(env.Document) + if err != nil { + t.Fatalf("decode document: %v", err) + } + // Flip the last byte — inside the COSE signature. Signature check + // must catch it. + raw[len(raw)-1] ^= 0xFF + env.Document = base64.RawURLEncoding.EncodeToString(raw) + assertFailure(t, mock, env, nonce, tee.FailurePlatformVerification) +} + +// TestVerifyRejectsSwappedSigningKey — step 7, the load-bearing binding +// rule. Attest with one key, then swap envelope.signing_key to a different +// key of the same shape. Verifier must reject. +func TestVerifyRejectsSwappedSigningKey(t *testing.T) { + env, mock, nonce := mustEmit(t) + otherPub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("gen swap key: %v", err) + } + env.SigningKey = tee.Ed25519JWK(otherPub, "attacker-swapped") + assertFailure(t, mock, env, nonce, tee.FailureSigningKeyNotBound) +} + +// TestVerifyRejectsWrongRoot — the envelope is valid but was minted by a +// mock the verifier doesn't trust. Cert-chain verification must fail +// (step 5, `platform_verification_failed`). +func TestVerifyRejectsWrongRoot(t *testing.T) { + env, _, nonce := mustEmit(t) + otherMock, err := nitro.NewMockNsm() + if err != nil { + t.Fatalf("build other mock: %v", err) + } + v := &nitro.Verifier{RootCert: otherMock.RootCert} + _, err = v.Verify(env, nonce) + if err == nil { + t.Fatal("Verify accepted envelope minted by a different root — should have rejected") + } + var ve *tee.VerifyError + if !errors.As(err, &ve) || ve.Mode != tee.FailurePlatformVerification { + t.Errorf("expected FailurePlatformVerification, got %v", err) + } +} + +// TestVerifyRejectsPolicyDisallow — step 8. Policy returns an error. +func TestVerifyRejectsPolicyDisallow(t *testing.T) { + env, mock, nonce := mustEmit(t) + v := &nitro.Verifier{ + RootCert: mock.RootCert, + Policy: nitro.PolicyFunc(func(m nitro.Measurements) error { + return errors.New("test policy rejects everything") + }), + } + _, err := v.Verify(env, nonce) + if err == nil { + t.Fatal("Verify accepted envelope despite policy rejection") + } + var ve *tee.VerifyError + if !errors.As(err, &ve) || ve.Mode != tee.FailureMeasurementDisallowed { + t.Errorf("expected FailureMeasurementDisallowed, got %v", err) + } +} + +// TestVerifyAcceptsPerRequestPathWithoutNonceEcho — the per-request +// X-TMP-Attestation code path calls Verify with expectedNonce=nil. Step 1 +// is skipped; freshness comes from expiry only. Same envelope should still +// verify. +func TestVerifyAcceptsPerRequestPathWithoutNonceEcho(t *testing.T) { + env, mock, _ := mustEmit(t) + v := &nitro.Verifier{RootCert: mock.RootCert} + if _, err := v.Verify(env, nil); err != nil { + t.Fatalf("Verify (per-request path) rejected a valid envelope: %v", err) + } +} + +// TestEnvelopeJSONRoundTrip — the on-wire JSON survives a round-trip +// through encoding/json without losing fields the verifier relies on. +func TestEnvelopeJSONRoundTrip(t *testing.T) { + env, _, _ := mustEmit(t) + body, err := json.Marshal(env) + if err != nil { + t.Fatalf("marshal envelope: %v", err) + } + var back tee.Envelope + if err := json.Unmarshal(body, &back); err != nil { + t.Fatalf("unmarshal envelope: %v", err) + } + if back.Format != env.Format { + t.Errorf("format changed on round-trip: %q vs %q", back.Format, env.Format) + } + if back.Document != env.Document { + t.Errorf("document changed on round-trip") + } + if back.Nonce != env.Nonce { + t.Errorf("nonce changed on round-trip") + } + if back.SigningKey.X != env.SigningKey.X { + t.Errorf("signing key changed on round-trip") + } +} + +// TestJWKThumbprintStable — RFC 7638 thumbprint of the same Ed25519 pubkey +// is stable across constructions with different Kid / Alg / Use metadata. +// The thumbprint excludes those fields by spec. +func TestJWKThumbprintStable(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("gen key: %v", err) + } + a := tee.Ed25519JWK(pub, "kid-a") + b := tee.Ed25519JWK(pub, "kid-b") + b.Alg = "different-alg-marker" + b.Use = "enc" // any non-required metadata + aTP, err := a.Thumbprint() + if err != nil { + t.Fatalf("thumbprint a: %v", err) + } + bTP, err := b.Thumbprint() + if err != nil { + t.Fatalf("thumbprint b: %v", err) + } + if !bytes.Equal(aTP, bTP) { + t.Errorf("thumbprint differs across metadata changes: %x vs %x", aTP, bTP) + } +} + +// --- helpers --- + +// mustEmit builds a mock NSM, emits an envelope with a fresh nonce and +// signing key, and returns everything the failure-mode tests need to +// mutate. Any failure here fails the calling test. +func mustEmit(t *testing.T) (tee.Envelope, *nitro.MockNsm, []byte) { + t.Helper() + ctx := context.Background() + mock, err := nitro.NewMockNsm() + if err != nil { + t.Fatalf("build mock NSM: %v", err) + } + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("gen signing key: %v", err) + } + signingKey := tee.Ed25519JWK(pub, "router-test") + nonce := make([]byte, 32) + if _, err := rand.Read(nonce); err != nil { + t.Fatalf("gen nonce: %v", err) + } + env, err := nitro.Emit(ctx, mock, nitro.EmitRequest{ + Nonce: nonce, + SigningKey: signingKey, + ExpiresAt: time.Now().Add(5 * time.Minute), + }) + if err != nil { + t.Fatalf("Emit: %v", err) + } + return env, mock, nonce +} + +// assertFailure runs Verify and asserts it returned a VerifyError with the +// given Mode. Called by the failure-mode tests. +func assertFailure(t *testing.T, mock *nitro.MockNsm, env tee.Envelope, nonce []byte, want tee.FailureMode) { + t.Helper() + v := &nitro.Verifier{RootCert: mock.RootCert} + _, err := v.Verify(env, nonce) + if err == nil { + t.Fatalf("Verify accepted envelope — expected failure %q", want) + } + var ve *tee.VerifyError + if !errors.As(err, &ve) { + t.Fatalf("expected *tee.VerifyError, got %T: %v", err, err) + } + if ve.Mode != want { + t.Errorf("expected failure %q, got %q (%v)", want, ve.Mode, ve.Err) + } +}