Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
60 changes: 60 additions & 0 deletions tee-attestation/README.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions tee-attestation/doc.go
Original file line number Diff line number Diff line change
@@ -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
181 changes: 181 additions & 0 deletions tee-attestation/envelope.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
38 changes: 38 additions & 0 deletions tee-attestation/errors.go
Original file line number Diff line number Diff line change
@@ -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 }
Loading
Loading