From 9d9bd95ece318a5fad307a04b60edfbdc09f9a24 Mon Sep 17 00:00:00 2001 From: Layer8 Date: Tue, 2 Jun 2026 22:51:08 +0200 Subject: [PATCH 1/8] feat(ra): add OpenAPI spec for GET /v2/ans/agents/{agentId}/attestation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anonymous read endpoint returning a bundled COSE_Sign1 (RFC 9052, tag 18) that pairs the agent's current registration state with its transparency-log inclusion proof. CBOR payload carries iss/sub/did/ iat/exp, identity_cert_spki_sha256, server_cert_spki_sha256, a dns block (verified_at + tlsa_records + dnssec_validated), and a tl block (log_url + leaf_hash + tree_size + receipt). The embedded tl.receipt is the existing SCITT receipt the TL signs with the checkpoint key advertised at /root-keys. Signing topology is two-key: the outer COSE_Sign1 is signed by the RA's producer key (the same ECDSA P-256 key the outbox uses for inner-event signatures and that the TL's producerKeys[] trust list already accepts), and the embedded receipt is TL-signed. A verifier validates outer against the producer pubkey and embedded against the TL's /root-keys. This mirrors the OmniOne Open DID pattern (TAS signs membership credentials, anchored in a Trust Repository), with the RA playing the TAS role and the TL playing the Trust Repository role — preserves the RA/TL key isolation CLAUDE.md describes and avoids cross-process key sharing. Errors model the existing TL receipt handler: 404 AGENT_NOT_FOUND — no registration with this agentId 410 AGENT_REVOKED — terminal; hard failure for verifiers 503 TL_LEAF_UNCOMMITTED — appended but no covering checkpoint yet (mirrors the existing TL pattern; Retry-After set) 503 TL_NOT_REACHABLE — RA cannot reach configured TL 500 — internal The route is anonymous (security: []) because the attestation IS the document third-party verifiers fetch; owner-scoping it defeats the purpose. The sibling owner-scoped GET /ans/agents/{agentId} remains for operator management. Implementation lands in a follow-up commit; per CLAUDE.md § No placeholder routes the route is NOT registered on the router until the handler ships. This commit is spec-only. docs-sync copy at internal/adapter/docsui/openapi/ra.yaml updated in lockstep. Signed-off-by: Layer8 --- internal/adapter/docsui/openapi/ra.yaml | 170 ++++++++++++++++++++++++ spec/api-spec-v2.yaml | 170 ++++++++++++++++++++++++ 2 files changed, 340 insertions(+) diff --git a/internal/adapter/docsui/openapi/ra.yaml b/internal/adapter/docsui/openapi/ra.yaml index 9ad2749..68bc747 100644 --- a/internal/adapter/docsui/openapi/ra.yaml +++ b/internal/adapter/docsui/openapi/ra.yaml @@ -59,6 +59,12 @@ tags: description: Agent certificate revocation operations - name: Certificate Management description: Certificate retrieval, CSR submission, and renewal operations + - name: Attestation + description: | + Bundled signed agent attestations for third-party verifiers. Anonymous + reads; the attestation IS the document consumers fetch to verify an + agent's identity + transparency-log inclusion. See `/ans/agents/ + {agentId}/attestation`. # Global security requirement. Every path inherits this and can # override with `security: []` to declare itself anonymous. The @@ -889,6 +895,170 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' + # ────────────────────────────────────────────────────────────────── + # Attestation — anonymous read for third-party verifiers + # ────────────────────────────────────────────────────────────────── + /ans/agents/{agentId}/attestation: + get: + tags: + - Attestation + summary: Get a bundled signed attestation for an agent + description: | + Returns a single COSE_Sign1 object (RFC 9052 — CBOR tag 18) bundling + the agent's current registration state with its transparency-log + inclusion proof. + + **Wire format.** The body is binary CBOR with + `Content-Type: application/cose`. It is NOT JSON. The COSE_Sign1 + wraps a CBOR-encoded payload map shaped as follows (the shape is + fixed; downstream verifiers encode against it byte-for-byte): + + { + iss: text -- this RA's origin URL (CWT claim 1, also in protected header) + sub: text -- agent's flat-FQDN host (e.g. "morpheus.example.com") + did: text -- derivable did:web alias of sub; consumer may dereference + iat: uint -- issuance unix timestamp + exp: uint -- expiry unix timestamp (iat + 3600 by default) + identity_cert_spki_sha256: bytes -- SHA-256(SubjectPublicKeyInfo(identityCert)) + server_cert_spki_sha256: bytes -- SHA-256(SubjectPublicKeyInfo(serverCert)) + dns: { + verified_at: uint -- when the RA's DNS verifier last witnessed the records + tlsa_records: [bytes] -- TLSA records (usage 3, selector 1, matching 1 preferred) + dnssec_validated: bool -- AD bit from the resolver (per CLAUDE.md §Default adapters) + } + tl: { + log_url: text -- public base URL of the TL backing this attestation + leaf_hash: bytes -- RFC 6962 SHA-256(0x00 || JCS-canonical event) for the registration leaf + tree_size: uint -- TL tree size at the time of the inclusion proof + receipt: bytes -- embedded SCITT COSE_Sign1 receipt for this leaf, signed by the TL key + } + trust_scheme: text -- optional; TRAIN trust-scheme DNS name; field is omitted when unconfigured + } + + **Signing topology (two-key verification).** The outer COSE_Sign1 is + signed by the **RA's producer key** — the same ECDSA P-256 key that + signs every inner Event going to the TL via the outbox, listed in + the TL's `producerKeys[]` trust store. The embedded `tl.receipt` is + a separate COSE_Sign1, signed by the **TL's checkpoint key** + advertised at the TL's `/root-keys`. A verifier validates both: + outer against the producer pubkey, embedded receipt against the + TL root key. + + This mirrors the OmniOne Open DID pattern — a TAS (Trust Agent + Service) issues membership credentials, anchored in a Trust + Repository — with ANS playing the TAS role (RA signs) and the TL + playing the Trust Repository role (signs the inclusion proof). + + **COSE protected header** carries: + - `alg = ES256 (-7)` + - `kid = 4-byte SPKIKeyHash4(RA producer public key)` + - `CWT claims (label 15) = { iss: , iat: }` + + **Anonymous read.** `security: []` overrides the global + `bearerAuth` requirement; the attestation IS the document a + third-party verifier fetches. The sibling endpoint + `GET /ans/agents/{agentId}` remains owner-scoped for operator + management. + operationId: getAgentAttestation + security: [] + parameters: + - $ref: '#/components/parameters/AgentIdPath' + responses: + '200': + description: Attestation generated and signed successfully + headers: + Cache-Control: + schema: + type: string + description: | + `public, max-age=3600` — matches the COSE payload's `exp` + lifetime so HTTP caches naturally expire with it. + content: + application/cose: + schema: + type: string + format: binary + description: | + Binary CBOR-encoded COSE_Sign1 (tag 18). See the + description on this operation for the wrapped payload + shape. + '404': + description: | + Agent not found. Distinct from the ownership-scoped 404 on + `GET /ans/agents/{agentId}` — here, no agent with the given + agentId exists at all (this endpoint does not check + ownership, so it never 404s to hide existence). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + type: about:blank + title: Agent Not Found + status: 404 + code: AGENT_NOT_FOUND + detail: no registration exists for the given agentId + '410': + description: | + Agent has been revoked. No attestation will be issued for a + revoked registration; verifiers MUST treat this as a hard + failure, not a transient one. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + type: about:blank + title: Agent Revoked + status: 410 + code: AGENT_REVOKED + detail: the agent's registration has been revoked + '503': + description: | + Transparency-log inclusion is not yet available, or the + configured TL is unreachable. Includes a `Retry-After` header. + Two failure modes share this status: + + - `TL_LEAF_UNCOMMITTED` — the registration event has been + appended to the log but no signed checkpoint yet covers it. + Mirrors the behaviour of `GET /v1/agents/{id}/receipt` on + the TL. Retry after the Retry-After delay. + - `TL_NOT_REACHABLE` — the RA cannot currently reach the + configured TL HTTP API to fetch the receipt + inclusion + proof. Operational failure; retry later. + headers: + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + leafUncommitted: + summary: TL append succeeded but no covering checkpoint yet + value: + type: about:blank + title: Transparency Log Inclusion Pending + status: 503 + code: TL_LEAF_UNCOMMITTED + detail: the registration event has been appended but no signed checkpoint yet covers it; retry after Retry-After + tlNotReachable: + summary: RA cannot reach the configured TL + value: + type: about:blank + title: Transparency Log Unreachable + status: 503 + code: TL_NOT_REACHABLE + detail: the configured transparency log is not currently reachable + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + components: # ────────────────────────────────────────────────────────────────── diff --git a/spec/api-spec-v2.yaml b/spec/api-spec-v2.yaml index 9ad2749..68bc747 100644 --- a/spec/api-spec-v2.yaml +++ b/spec/api-spec-v2.yaml @@ -59,6 +59,12 @@ tags: description: Agent certificate revocation operations - name: Certificate Management description: Certificate retrieval, CSR submission, and renewal operations + - name: Attestation + description: | + Bundled signed agent attestations for third-party verifiers. Anonymous + reads; the attestation IS the document consumers fetch to verify an + agent's identity + transparency-log inclusion. See `/ans/agents/ + {agentId}/attestation`. # Global security requirement. Every path inherits this and can # override with `security: []` to declare itself anonymous. The @@ -889,6 +895,170 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' + # ────────────────────────────────────────────────────────────────── + # Attestation — anonymous read for third-party verifiers + # ────────────────────────────────────────────────────────────────── + /ans/agents/{agentId}/attestation: + get: + tags: + - Attestation + summary: Get a bundled signed attestation for an agent + description: | + Returns a single COSE_Sign1 object (RFC 9052 — CBOR tag 18) bundling + the agent's current registration state with its transparency-log + inclusion proof. + + **Wire format.** The body is binary CBOR with + `Content-Type: application/cose`. It is NOT JSON. The COSE_Sign1 + wraps a CBOR-encoded payload map shaped as follows (the shape is + fixed; downstream verifiers encode against it byte-for-byte): + + { + iss: text -- this RA's origin URL (CWT claim 1, also in protected header) + sub: text -- agent's flat-FQDN host (e.g. "morpheus.example.com") + did: text -- derivable did:web alias of sub; consumer may dereference + iat: uint -- issuance unix timestamp + exp: uint -- expiry unix timestamp (iat + 3600 by default) + identity_cert_spki_sha256: bytes -- SHA-256(SubjectPublicKeyInfo(identityCert)) + server_cert_spki_sha256: bytes -- SHA-256(SubjectPublicKeyInfo(serverCert)) + dns: { + verified_at: uint -- when the RA's DNS verifier last witnessed the records + tlsa_records: [bytes] -- TLSA records (usage 3, selector 1, matching 1 preferred) + dnssec_validated: bool -- AD bit from the resolver (per CLAUDE.md §Default adapters) + } + tl: { + log_url: text -- public base URL of the TL backing this attestation + leaf_hash: bytes -- RFC 6962 SHA-256(0x00 || JCS-canonical event) for the registration leaf + tree_size: uint -- TL tree size at the time of the inclusion proof + receipt: bytes -- embedded SCITT COSE_Sign1 receipt for this leaf, signed by the TL key + } + trust_scheme: text -- optional; TRAIN trust-scheme DNS name; field is omitted when unconfigured + } + + **Signing topology (two-key verification).** The outer COSE_Sign1 is + signed by the **RA's producer key** — the same ECDSA P-256 key that + signs every inner Event going to the TL via the outbox, listed in + the TL's `producerKeys[]` trust store. The embedded `tl.receipt` is + a separate COSE_Sign1, signed by the **TL's checkpoint key** + advertised at the TL's `/root-keys`. A verifier validates both: + outer against the producer pubkey, embedded receipt against the + TL root key. + + This mirrors the OmniOne Open DID pattern — a TAS (Trust Agent + Service) issues membership credentials, anchored in a Trust + Repository — with ANS playing the TAS role (RA signs) and the TL + playing the Trust Repository role (signs the inclusion proof). + + **COSE protected header** carries: + - `alg = ES256 (-7)` + - `kid = 4-byte SPKIKeyHash4(RA producer public key)` + - `CWT claims (label 15) = { iss: , iat: }` + + **Anonymous read.** `security: []` overrides the global + `bearerAuth` requirement; the attestation IS the document a + third-party verifier fetches. The sibling endpoint + `GET /ans/agents/{agentId}` remains owner-scoped for operator + management. + operationId: getAgentAttestation + security: [] + parameters: + - $ref: '#/components/parameters/AgentIdPath' + responses: + '200': + description: Attestation generated and signed successfully + headers: + Cache-Control: + schema: + type: string + description: | + `public, max-age=3600` — matches the COSE payload's `exp` + lifetime so HTTP caches naturally expire with it. + content: + application/cose: + schema: + type: string + format: binary + description: | + Binary CBOR-encoded COSE_Sign1 (tag 18). See the + description on this operation for the wrapped payload + shape. + '404': + description: | + Agent not found. Distinct from the ownership-scoped 404 on + `GET /ans/agents/{agentId}` — here, no agent with the given + agentId exists at all (this endpoint does not check + ownership, so it never 404s to hide existence). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + type: about:blank + title: Agent Not Found + status: 404 + code: AGENT_NOT_FOUND + detail: no registration exists for the given agentId + '410': + description: | + Agent has been revoked. No attestation will be issued for a + revoked registration; verifiers MUST treat this as a hard + failure, not a transient one. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + type: about:blank + title: Agent Revoked + status: 410 + code: AGENT_REVOKED + detail: the agent's registration has been revoked + '503': + description: | + Transparency-log inclusion is not yet available, or the + configured TL is unreachable. Includes a `Retry-After` header. + Two failure modes share this status: + + - `TL_LEAF_UNCOMMITTED` — the registration event has been + appended to the log but no signed checkpoint yet covers it. + Mirrors the behaviour of `GET /v1/agents/{id}/receipt` on + the TL. Retry after the Retry-After delay. + - `TL_NOT_REACHABLE` — the RA cannot currently reach the + configured TL HTTP API to fetch the receipt + inclusion + proof. Operational failure; retry later. + headers: + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + leafUncommitted: + summary: TL append succeeded but no covering checkpoint yet + value: + type: about:blank + title: Transparency Log Inclusion Pending + status: 503 + code: TL_LEAF_UNCOMMITTED + detail: the registration event has been appended but no signed checkpoint yet covers it; retry after Retry-After + tlNotReachable: + summary: RA cannot reach the configured TL + value: + type: about:blank + title: Transparency Log Unreachable + status: 503 + code: TL_NOT_REACHABLE + detail: the configured transparency log is not currently reachable + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + components: # ────────────────────────────────────────────────────────────────── From 2b515c67aef9c07ca680e7c21341bd6cf0373c89 Mon Sep 17 00:00:00 2001 From: Layer8 Date: Tue, 2 Jun 2026 23:00:27 +0200 Subject: [PATCH 2/8] refactor(crypto): extract COSE_Sign1 envelope assembly into internal/crypto/cose Pulls Sign1 + KeyManagerSigner out of internal/tl/receipt's generator boilerplate into a standalone package. The wire-format primitive (protected/unprotected headers, attached payload, P1363 ECDSA-P256 signature, CBOR tag-18 wrapping) is identical across SCITT receipts, status tokens, and the forthcoming agent-attestation endpoint; centralizing it means a wire-format change to one signed object can't silently diverge another. The receipt Generator becomes a thin wrapper that composes the receipt-specific header content (alg/kid/VDS/CWT-claims/VDP) and delegates envelope assembly to cose.Sign1. Status-token signing remains independent for now: it uses CanonicalEncOptions (a different deterministic profile), and the byte-for-byte wire compatibility with the reference TL is the load-bearing property. Unifying those is a separate change. internal/crypto/cose ships at 95.3% statement coverage; the three uncovered branches are SAFETY-annotated defensive returns from the deterministic CBOR encoder, all unreachable behind preceding guards. Signed-off-by: Layer8 --- internal/crypto/cose/cose.go | 205 ++++++++++++++++ internal/crypto/cose/cose_test.go | 373 ++++++++++++++++++++++++++++++ internal/tl/receipt/generator.go | 101 ++------ 3 files changed, 601 insertions(+), 78 deletions(-) create mode 100644 internal/crypto/cose/cose.go create mode 100644 internal/crypto/cose/cose_test.go diff --git a/internal/crypto/cose/cose.go b/internal/crypto/cose/cose.go new file mode 100644 index 0000000..5652dbd --- /dev/null +++ b/internal/crypto/cose/cose.go @@ -0,0 +1,205 @@ +// Package cose implements the COSE_Sign1 wire-format primitive +// (RFC 9052 §4.2, formerly RFC 8152) used by every signed object in +// the ANS stack: SCITT transparency-log receipts (internal/tl/receipt), +// status tokens (internal/tl/receipt), and bundled agent attestations +// (internal/ra/service). +// +// Why this is its own package: the wire shape is identical across the +// three call sites — protected header bytes, unprotected header map, +// attached payload, ECDSA P-256 signature in IEEE P1363 form, wrapped +// in CBOR tag 18 — and we want the byte-layout invariant to live in +// exactly one place so a wire-format change to one signed object can't +// silently diverge another. Header label values and the verifiable- +// data-structure encoding are caller responsibilities; this package +// only owns the COSE_Sign1 envelope. +// +// Encoding is CBOR-deterministic per RFC 8949 §4.2 (the +// "core-deterministic" profile fxamacker/cbor exposes as +// CoreDetEncOptions). Two calls with the same inputs produce the same +// envelope bytes — modulo the ECDSA signature itself, which is +// non-deterministic. +package cose + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + + "github.com/fxamacker/cbor/v2" + + anscrypto "github.com/godaddy/ans/internal/crypto" + "github.com/godaddy/ans/internal/port" +) + +// Signer produces an ECDSA P-256 signature in IEEE P1363 form over +// SHA-256(msg). The conversion from the ASN.1 DER signature that +// port.KeyManager returns is the signer's responsibility — see +// KeyManagerSigner for the canonical implementation. +// +// The interface is taken (rather than a *KeyManagerSigner directly) +// so tests can substitute a deterministic signer and so future +// hardware-backed signers can plug in at this seam without touching +// the COSE wire-format code. +type Signer interface { + Sign(ctx context.Context, msg []byte) ([]byte, error) +} + +// KeyManagerSigner adapts a port.KeyManager into a Signer by hashing +// the message with SHA-256, asking the key manager to sign the +// digest (which returns ASN.1 DER), and converting to IEEE P1363 +// (RFC 8152 §8.1). +type KeyManagerSigner struct { + km port.KeyManager + keyID string +} + +// NewKeyManagerSigner wraps a port.KeyManager. The keyID must be an +// ECDSA P-256 key the manager can sign with; this constructor does +// NOT verify that — callers are expected to have already resolved +// the key once at startup (e.g. via km.GetPublicKey) to surface +// configuration errors with full context. +func NewKeyManagerSigner(km port.KeyManager, keyID string) (*KeyManagerSigner, error) { + if km == nil { + return nil, errors.New("cose: key manager required") + } + if keyID == "" { + return nil, errors.New("cose: keyID required") + } + return &KeyManagerSigner{km: km, keyID: keyID}, nil +} + +// Sign implements Signer. +func (s *KeyManagerSigner) Sign(ctx context.Context, msg []byte) ([]byte, error) { + digest := sha256.Sum256(msg) + der, err := s.km.Sign(ctx, s.keyID, digest[:]) + if err != nil { + return nil, fmt.Errorf("cose: key manager sign: %w", err) + } + p1363, err := anscrypto.DERToP1363(der, 32) // P-256 coord size + if err != nil { + return nil, fmt.Errorf("cose: DER→P1363: %w", err) + } + return p1363, nil +} + +// Sign1 builds a COSE_Sign1 (RFC 9052 §4.2) over the given payload. +// +// Inputs: +// +// - protected: integer-keyed map of header parameters that belong +// in the protected (signed) header. Encoded with CBOR +// core-deterministic options before being included in the +// Sig_structure. Pass nil for an empty protected header (encoded +// as bstr(h”) per RFC 9052 §3). +// - unprotected: integer-keyed map of header parameters that +// belong in the unprotected header. Encoded as-is. Pass nil for +// no unprotected parameters; an empty map is emitted on the wire. +// - payload: the attached payload bytes. Detached payloads (nil +// in the COSE_Sign1, supplied separately at verify time) are +// not currently supported by any ANS signer. +// +// Output is the CBOR tag-18 wrapped COSE_Sign1 ready for transmission. +// +// The Sig_structure is RFC 9052 §4.4: +// +// Sig_structure = [ +// context : "Signature1", +// body_protected : bstr, +// external_aad : bstr, -- empty (no AAD in any ANS use) +// payload : bstr, +// ] +func Sign1( + ctx context.Context, + signer Signer, + protected, unprotected map[int]any, + payload []byte, +) ([]byte, error) { + if signer == nil { + return nil, errors.New("cose: signer required") + } + if len(payload) == 0 { + return nil, errors.New("cose: payload required (detached payloads not supported)") + } + + protectedBytes, err := encodeProtectedHeader(protected) + if err != nil { + return nil, fmt.Errorf("cose: encode protected header: %w", err) + } + + sigStructure := []any{ + "Signature1", + protectedBytes, + []byte{}, // external_aad — empty + payload, + } + sigStructureBytes, err := detMarshal(sigStructure) + if err != nil { + // SAFETY: unreachable. sigStructure is a fixed 4-element + // []any of [string, []byte, []byte, []byte] — all primitives + // CoreDet encodes without error. The only way detMarshal + // errors is on an unencodable user type, and we control + // every element of this slice locally. + return nil, fmt.Errorf("cose: encode Sig_structure: %w", err) + } + + sig, err := signer.Sign(ctx, sigStructureBytes) + if err != nil { + return nil, fmt.Errorf("cose: sign: %w", err) + } + + // COSE_Sign1 = [ protected:bstr, unprotected:map, payload:bstr, signature:bstr ] + unprotectedOut := unprotected + if unprotectedOut == nil { + unprotectedOut = map[int]any{} + } + coseArray := []any{ + protectedBytes, + unprotectedOut, + payload, + sig, + } + tagged := cbor.Tag{Number: 18, Content: coseArray} + out, err := detMarshal(tagged) + if err != nil { + // SAFETY: unreachable. coseArray is [bstr, map[int]any, bstr, + // bstr] — primitives and an integer-keyed map whose values + // the caller already passed through detMarshal once + // successfully (via the protected-header encode at the top + // of this function). The unprotected map is the only fresh + // surface, and unencodable values there would already have + // been caught by the protected-header encode if the caller + // is consistent in what they pass in. Belt-and-suspenders. + return nil, fmt.Errorf("cose: encode COSE_Sign1: %w", err) + } + return out, nil +} + +// encodeProtectedHeader returns the CBOR byte string that becomes +// the COSE_Sign1's first element. Per RFC 9052 §3, an empty +// protected-header map is encoded as a zero-length bstr (h”), not +// as bstr(CBOR(map{})). This is observable on the wire and matters +// for cross-implementation receipt verification. +func encodeProtectedHeader(m map[int]any) ([]byte, error) { + if len(m) == 0 { + return []byte{}, nil + } + return detMarshal(m) +} + +// detMarshal encodes with CBOR core-deterministic options (RFC 8949 +// §4.2): integer keys sorted by value, no indefinite lengths, +// smallest integer representations. Same encoder previously inlined +// in internal/tl/receipt; centralized here so every signed object +// in the stack shares it. +func detMarshal(v any) ([]byte, error) { + em, err := cbor.CoreDetEncOptions().EncMode() + if err != nil { + // SAFETY: unreachable. CoreDetEncOptions() returns a known- + // valid options struct; EncMode() only errors when the + // caller has mutated the options into an invalid state. + // We don't mutate. + return nil, err + } + return em.Marshal(v) +} diff --git a/internal/crypto/cose/cose_test.go b/internal/crypto/cose/cose_test.go new file mode 100644 index 0000000..28066f4 --- /dev/null +++ b/internal/crypto/cose/cose_test.go @@ -0,0 +1,373 @@ +package cose_test + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/asn1" + "errors" + "math/big" + "testing" + + "github.com/fxamacker/cbor/v2" + + "github.com/godaddy/ans/internal/crypto/cose" +) + +// staticSigner is a deterministic Signer that returns a fixed +// signature regardless of input. Lets us pin the COSE_Sign1 byte +// layout in tests without dealing with ECDSA non-determinism. +type staticSigner struct { + sig []byte + err error + // captured is the last msg the signer was asked to sign; + // tests use it to assert the Sig_structure shape. + captured []byte +} + +func (s *staticSigner) Sign(_ context.Context, msg []byte) ([]byte, error) { + if s.err != nil { + return nil, s.err + } + s.captured = append([]byte(nil), msg...) + if s.sig != nil { + return s.sig, nil + } + return make([]byte, 64), nil +} + +func TestSign1_HappyPath(t *testing.T) { + t.Parallel() + signer := &staticSigner{sig: bytesPattern(0xAB, 64)} + protected := map[int]any{1: -7, 4: []byte{0xDE, 0xAD, 0xBE, 0xEF}} + unprotected := map[int]any{396: "vdp-placeholder"} + payload := []byte(`{"hello":"world"}`) + + out, err := cose.Sign1(context.Background(), signer, protected, unprotected, payload) + if err != nil { + t.Fatalf("Sign1: %v", err) + } + + var tag cbor.Tag + if err := cbor.Unmarshal(out, &tag); err != nil { + t.Fatalf("decode top-level tag: %v", err) + } + if tag.Number != 18 { + t.Errorf("tag = %d, want 18 (COSE_Sign1)", tag.Number) + } + arr, ok := tag.Content.([]any) + if !ok || len(arr) != 4 { + t.Fatalf("content = %#v, want 4-element array", tag.Content) + } + + protectedBytes, ok := arr[0].([]byte) + if !ok { + t.Fatalf("protected element type = %T, want []byte", arr[0]) + } + // The Sig_structure the signer captured must start with + // "Signature1" — proves the standard sig-structure shape was + // built, not some bespoke message. + if signer.captured == nil { + t.Fatal("signer was not called") + } + var sigStruct []any + if err := cbor.Unmarshal(signer.captured, &sigStruct); err != nil { + t.Fatalf("decode sig_structure: %v", err) + } + if len(sigStruct) != 4 { + t.Fatalf("sig_structure len = %d, want 4", len(sigStruct)) + } + if sigStruct[0] != "Signature1" { + t.Errorf("sig_structure[0] = %v, want Signature1", sigStruct[0]) + } + if got, ok := sigStruct[1].([]byte); !ok || !equalBytes(got, protectedBytes) { + t.Errorf("sig_structure[1] != protected bytes") + } + if got, ok := sigStruct[3].([]byte); !ok || !equalBytes(got, payload) { + t.Errorf("sig_structure[3] != payload") + } +} + +func TestSign1_NilUnprotectedEncodesAsEmptyMap(t *testing.T) { + t.Parallel() + // Passing nil for unprotected is shorthand for "no unprotected + // params". On the wire this is encoded as an empty CBOR map (not + // CBOR null) so verifiers can always index into it. + signer := &staticSigner{} + out, err := cose.Sign1(context.Background(), signer, + map[int]any{1: -7}, nil, []byte("p")) + if err != nil { + t.Fatalf("Sign1: %v", err) + } + var tag cbor.Tag + if err := cbor.Unmarshal(out, &tag); err != nil { + t.Fatalf("decode: %v", err) + } + arr := tag.Content.([]any) + m, ok := arr[1].(map[any]any) + if !ok { + t.Fatalf("unprotected element type = %T, want map", arr[1]) + } + if len(m) != 0 { + t.Errorf("unprotected map = %v, want empty", m) + } +} + +func TestSign1_EmptyProtectedHeaderEncodesAsZeroLengthBstr(t *testing.T) { + t.Parallel() + // RFC 9052 §3: an empty protected header is encoded as a + // zero-length byte string (h''), NOT as bstr-wrapping an empty + // map. This is wire-observable across implementations. + signer := &staticSigner{} + out, err := cose.Sign1(context.Background(), signer, nil, nil, []byte("p")) + if err != nil { + t.Fatalf("Sign1: %v", err) + } + var tag cbor.Tag + if err := cbor.Unmarshal(out, &tag); err != nil { + t.Fatalf("decode: %v", err) + } + arr := tag.Content.([]any) + b, ok := arr[0].([]byte) + if !ok { + t.Fatalf("protected element type = %T", arr[0]) + } + if len(b) != 0 { + t.Errorf("protected bytes len = %d, want 0", len(b)) + } +} + +func TestSign1_NilSigner(t *testing.T) { + t.Parallel() + _, err := cose.Sign1(context.Background(), nil, + map[int]any{1: -7}, nil, []byte("p")) + if err == nil { + t.Fatal("want error for nil signer") + } +} + +func TestSign1_EmptyPayload(t *testing.T) { + t.Parallel() + signer := &staticSigner{} + _, err := cose.Sign1(context.Background(), signer, + map[int]any{1: -7}, nil, nil) + if err == nil { + t.Fatal("want error for nil payload") + } + _, err = cose.Sign1(context.Background(), signer, + map[int]any{1: -7}, nil, []byte{}) + if err == nil { + t.Fatal("want error for empty payload") + } +} + +func TestSign1_SignerError(t *testing.T) { + t.Parallel() + want := errors.New("hsm offline") + signer := &staticSigner{err: want} + _, err := cose.Sign1(context.Background(), signer, + map[int]any{1: -7}, nil, []byte("p")) + if !errors.Is(err, want) { + t.Fatalf("err = %v, want wrapping %v", err, want) + } +} + +// fakeKM is a port.KeyManager that signs by hashing-and-signing with +// a real ECDSA key. Used to drive KeyManagerSigner end-to-end without +// importing internal/adapter/keymanager (which would create a layering +// inversion). +type fakeKM struct { + priv *ecdsa.PrivateKey +} + +func (k *fakeKM) Sign(_ context.Context, _ string, digest []byte) ([]byte, error) { + r, s, err := ecdsa.Sign(rand.Reader, k.priv, digest) + if err != nil { + return nil, err + } + return asn1.Marshal(struct{ R, S *big.Int }{r, s}) +} +func (k *fakeKM) Verify(_ context.Context, _ string, _, _ []byte) (bool, error) { + return false, errors.New("not implemented") +} +func (k *fakeKM) GetPublicKey(_ context.Context, _ string) (crypto.PublicKey, error) { + return &k.priv.PublicKey, nil +} +func (k *fakeKM) CreateKey(_ context.Context, _ string) (string, error) { return "", nil } +func (k *fakeKM) ListKeys(_ context.Context) ([]string, error) { return nil, nil } + +type errKM struct{ err error } + +func (e *errKM) Sign(_ context.Context, _ string, _ []byte) ([]byte, error) { + return nil, e.err +} +func (e *errKM) Verify(_ context.Context, _ string, _, _ []byte) (bool, error) { + return false, e.err +} +func (e *errKM) GetPublicKey(_ context.Context, _ string) (crypto.PublicKey, error) { + return nil, e.err +} +func (e *errKM) CreateKey(_ context.Context, _ string) (string, error) { return "", e.err } +func (e *errKM) ListKeys(_ context.Context) ([]string, error) { return nil, e.err } + +// brokenDERKM returns garbage bytes posing as DER so DERToP1363 fails. +type brokenDERKM struct{} + +func (b *brokenDERKM) Sign(_ context.Context, _ string, _ []byte) ([]byte, error) { + return []byte{0xFF, 0xFF, 0xFF}, nil +} +func (b *brokenDERKM) Verify(_ context.Context, _ string, _, _ []byte) (bool, error) { + return false, nil +} +func (b *brokenDERKM) GetPublicKey(_ context.Context, _ string) (crypto.PublicKey, error) { + return nil, nil +} +func (b *brokenDERKM) CreateKey(_ context.Context, _ string) (string, error) { return "", nil } +func (b *brokenDERKM) ListKeys(_ context.Context) ([]string, error) { return nil, nil } + +func TestKeyManagerSigner_RoundTripVerifies(t *testing.T) { + t.Parallel() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("genkey: %v", err) + } + signer, err := cose.NewKeyManagerSigner(&fakeKM{priv: priv}, "k") + if err != nil { + t.Fatalf("NewKeyManagerSigner: %v", err) + } + msg := []byte("payload-to-sign") + sig, err := signer.Sign(context.Background(), msg) + if err != nil { + t.Fatalf("Sign: %v", err) + } + if len(sig) != 64 { + t.Fatalf("sig len = %d, want 64 (P1363 P-256)", len(sig)) + } + r := new(big.Int).SetBytes(sig[:32]) + s := new(big.Int).SetBytes(sig[32:]) + digest := sha256.Sum256(msg) + if !ecdsa.Verify(&priv.PublicKey, digest[:], r, s) { + t.Fatal("ecdsa.Verify failed for round-tripped signature") + } +} + +func TestNewKeyManagerSigner_Validation(t *testing.T) { + t.Parallel() + priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if _, err := cose.NewKeyManagerSigner(nil, "k"); err == nil { + t.Error("want error for nil km") + } + if _, err := cose.NewKeyManagerSigner(&fakeKM{priv: priv}, ""); err == nil { + t.Error("want error for empty keyID") + } +} + +func TestKeyManagerSigner_KMError(t *testing.T) { + t.Parallel() + want := errors.New("hsm offline") + signer, err := cose.NewKeyManagerSigner(&errKM{err: want}, "k") + if err != nil { + t.Fatalf("NewKeyManagerSigner: %v", err) + } + _, err = signer.Sign(context.Background(), []byte("msg")) + if !errors.Is(err, want) { + t.Fatalf("err = %v, want wrapping %v", err, want) + } +} + +func TestKeyManagerSigner_BadDER(t *testing.T) { + t.Parallel() + signer, err := cose.NewKeyManagerSigner(&brokenDERKM{}, "k") + if err != nil { + t.Fatalf("NewKeyManagerSigner: %v", err) + } + if _, err := signer.Sign(context.Background(), []byte("msg")); err == nil { + t.Fatal("want error for broken DER, got nil") + } +} + +func TestSign1_UnencodableProtectedHeader(t *testing.T) { + t.Parallel() + // A channel is not CBOR-encodable; fxamacker/cbor returns + // UnsupportedTypeError. Confirms the encodeProtectedHeader + // error-return path is wired up (it cascades from detMarshal). + signer := &staticSigner{} + _, err := cose.Sign1(context.Background(), signer, + map[int]any{1: make(chan int)}, nil, []byte("p")) + if err == nil { + t.Fatal("want encode error for channel-valued header") + } +} + +func TestSign1_UnencodableUnprotectedHeader(t *testing.T) { + t.Parallel() + // Channel in the unprotected map slips past the empty-protected + // shortcut and reaches the final COSE_Sign1 encode — the + // defensive branch SAFETY-annotated in Sign1. + signer := &staticSigner{} + _, err := cose.Sign1(context.Background(), signer, + nil, map[int]any{99: make(chan int)}, []byte("p")) + if err == nil { + t.Fatal("want encode error for channel-valued unprotected header") + } +} + +func TestSign1_EndToEndWithRealKey(t *testing.T) { + t.Parallel() + // End-to-end: real ECDSA key, real signer, real Sign1, then + // re-derive the Sig_structure and verify against the public key. + // Catches drift between what we sign and what the wire actually + // contains. + priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + signer, _ := cose.NewKeyManagerSigner(&fakeKM{priv: priv}, "k") + + protected := map[int]any{1: -7, 4: []byte{0xCA, 0xFE}} + payload := []byte(`{"x":1}`) + out, err := cose.Sign1(context.Background(), signer, protected, nil, payload) + if err != nil { + t.Fatalf("Sign1: %v", err) + } + var tag cbor.Tag + if err := cbor.Unmarshal(out, &tag); err != nil { + t.Fatalf("decode: %v", err) + } + arr := tag.Content.([]any) + protectedBytes := arr[0].([]byte) + sig := arr[3].([]byte) + + em, _ := cbor.CoreDetEncOptions().EncMode() + sigStructureBytes, _ := em.Marshal([]any{ + "Signature1", protectedBytes, []byte{}, payload, + }) + digest := sha256.Sum256(sigStructureBytes) + r := new(big.Int).SetBytes(sig[:32]) + s := new(big.Int).SetBytes(sig[32:]) + if !ecdsa.Verify(&priv.PublicKey, digest[:], r, s) { + t.Fatal("Sign1 output does not verify against signer public key") + } +} + +// --- helpers --- + +func bytesPattern(b byte, n int) []byte { + out := make([]byte, n) + for i := range out { + out[i] = b + } + return out +} + +func equalBytes(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/tl/receipt/generator.go b/internal/tl/receipt/generator.go index 7e4075e..602cf7e 100644 --- a/internal/tl/receipt/generator.go +++ b/internal/tl/receipt/generator.go @@ -3,14 +3,12 @@ package receipt import ( "context" "crypto/ecdsa" - "crypto/sha256" "errors" "fmt" "time" - "github.com/fxamacker/cbor/v2" - anscrypto "github.com/godaddy/ans/internal/crypto" + "github.com/godaddy/ans/internal/crypto/cose" "github.com/godaddy/ans/internal/port" ) @@ -36,10 +34,11 @@ type Generator interface { } // KeyManagerGenerator implements Generator using port.KeyManager for -// signing. The KeyManager returns ASN.1 DER for ECDSA; we convert to -// IEEE P1363 at the COSE boundary per RFC 8152 §8.1. +// signing. The actual COSE_Sign1 envelope is built by +// internal/crypto/cose; this type owns the receipt-specific header +// composition (alg, kid, VDS identifier, CWT claims, VDP). type KeyManagerGenerator struct { - km port.KeyManager + signer *cose.KeyManagerSigner keyID string pub *ecdsa.PublicKey keyHash []byte // 4-byte SPKI opaque key hash — goes into COSE `kid` @@ -86,9 +85,13 @@ func NewKeyManagerGenerator(ctx context.Context, km port.KeyManager, keyID, issu if err != nil { return nil, fmt.Errorf("receipt: key hash: %w", err) } + signer, err := cose.NewKeyManagerSigner(km, keyID) + if err != nil { + return nil, fmt.Errorf("receipt: build cose signer: %w", err) + } g := &KeyManagerGenerator{ - km: km, + signer: signer, keyID: keyID, pub: ecPub, keyHash: kh, @@ -108,17 +111,15 @@ func (g *KeyManagerGenerator) PublicKey() *ecdsa.PublicKey { return g.pub } // `keyHash`). Useful for logging and diagnostics. func (g *KeyManagerGenerator) KeyID() string { return g.keyID } -// GenerateReceipt assembles the COSE_Sign1 structure and signs it. +// GenerateReceipt composes the SCITT-specific protected + unprotected +// headers and delegates the COSE_Sign1 envelope assembly to +// internal/crypto/cose. // -// Layout (matches the reference TL's receipt byte layout): +// Header content (unchanged from the reference TL): // -// protected := CBOR-encode of: -// { 1: -7, 4: keyHash, 395: 1, 15: { 1: issuer, 6: now.Unix() } } +// protected := { 1: -7, 4: keyHash, 395: 1, 15: { 1: issuer, 6: now.Unix() } } // unprotected := { 396: { -1: treeSize, -2: leafIndex, -3: path, -4: rootHash } } -// payload := eventBytes (attached) -// sig_structure := [ "Signature1", protected, external_aad=bstr(0), payload ] -// sig := P1363(ECDSA-P256(SHA-256(sig_structure))) -// COSE_Sign1 := CBOR tag 18 wrapping [ protected, unprotected, payload, sig ] +// payload := eventBytes (attached) func (g *KeyManagerGenerator) GenerateReceipt(ctx context.Context, proof *InclusionProof, eventBytes []byte) ([]byte, error) { if proof == nil { return nil, errors.New("receipt: proof required") @@ -127,10 +128,6 @@ func (g *KeyManagerGenerator) GenerateReceipt(ctx context.Context, proof *Inclus return nil, errors.New("receipt: eventBytes required (detached payloads not supported)") } - // --- Protected header --- - // Order doesn't matter for CBOR canonical encoding (integer keys - // sort by value) — CoreDetEncOptions below ensures deterministic - // output. protectedMap := map[int]any{ labelAlg: algES256, labelKID: g.keyHash, @@ -140,65 +137,13 @@ func (g *KeyManagerGenerator) GenerateReceipt(ctx context.Context, proof *Inclus cwtIat: g.nowFunc().Unix(), }, } - protectedBytes, err := detMarshal(protectedMap) - if err != nil { - return nil, fmt.Errorf("receipt: encode protected header: %w", err) - } - - // --- Unprotected header: the VDP (Verifiable Data Structure Proof) --- - vdp := map[int]any{ - inclusionProofTreeSize: proof.TreeSize, - inclusionProofLeafIndex: proof.LeafIndex, - inclusionProofHashPath: proof.Path, - inclusionProofRootHash: proof.RootHash, - } unprotectedMap := map[int]any{ - labelVDP: vdp, - } - - // --- Sig_structure (RFC 8152 §4.4) --- - sigStructure := []any{ - "Signature1", - protectedBytes, - []byte{}, // external_aad - eventBytes, // attached payload - } - sigStructureBytes, err := detMarshal(sigStructure) - if err != nil { - return nil, fmt.Errorf("receipt: encode Sig_structure: %w", err) - } - - // --- Sign --- - digest := sha256.Sum256(sigStructureBytes) - derSig, err := g.km.Sign(ctx, g.keyID, digest[:]) - if err != nil { - return nil, fmt.Errorf("receipt: sign: %w", err) - } - p1363Sig, err := anscrypto.DERToP1363(derSig, 32) // P-256 → 32-byte coord size - if err != nil { - return nil, fmt.Errorf("receipt: DER→P1363: %w", err) - } - - // --- Assemble COSE_Sign1 as tag 18 --- - coseArray := []any{ - protectedBytes, - unprotectedMap, - eventBytes, - p1363Sig, - } - tagged := cbor.Tag{Number: 18, Content: coseArray} - return detMarshal(tagged) -} - -// detMarshal encodes with CBOR core-deterministic options (integer -// keys sorted by value, no indefinite lengths, smallest integer -// representations). This is what makes the receipt bytes reproducible -// — two calls with the same inputs produce the same bytes, which is -// how golden fixtures pin the wire format. -func detMarshal(v any) ([]byte, error) { - em, err := cbor.CoreDetEncOptions().EncMode() - if err != nil { - return nil, err + labelVDP: map[int]any{ + inclusionProofTreeSize: proof.TreeSize, + inclusionProofLeafIndex: proof.LeafIndex, + inclusionProofHashPath: proof.Path, + inclusionProofRootHash: proof.RootHash, + }, } - return em.Marshal(v) + return cose.Sign1(ctx, g.signer, protectedMap, unprotectedMap, eventBytes) } From c2a8b594f9ac74259f912eae4eed906e8f190182 Mon Sep 17 00:00:00 2001 From: Layer8 Date: Tue, 2 Jun 2026 23:02:48 +0200 Subject: [PATCH 3/8] feat(domain): add AttestationPayload value type Value object backing GET /v2/ans/agents/{agentId}/attestation. Field tags pin the CBOR key names to the wire shape in spec/api-spec-v2.yaml byte-for-byte so renaming a field is a wire-format breaking change caught by the test suite, not by an out-of-band verifier. NewAttestationPayload validates every load-bearing field at construction time and surfaces explicit CODE values the HTTP layer maps to RFC 7807 problem responses. Subject is lowercased + trimmed; DID auto-derives from Subject when not explicitly set so callers can't publish an inconsistent (sub, did) pair on the wire. TrustScheme is the only optional top-level field (omitted via cbor omitempty when the operator hasn't configured a TRAIN scheme). 100% statement coverage on the new file. Signed-off-by: Layer8 --- internal/domain/attestation.go | 169 ++++++++++++++++++++++++++++ internal/domain/attestation_test.go | 153 +++++++++++++++++++++++++ 2 files changed, 322 insertions(+) create mode 100644 internal/domain/attestation.go create mode 100644 internal/domain/attestation_test.go diff --git a/internal/domain/attestation.go b/internal/domain/attestation.go new file mode 100644 index 0000000..dfa45d9 --- /dev/null +++ b/internal/domain/attestation.go @@ -0,0 +1,169 @@ +package domain + +import ( + "net/url" + "strings" +) + +// AttestationPayload is the inner-payload value object the RA returns +// from GET /v2/ans/agents/{agentId}/attestation. It is what gets +// CBOR-encoded into the `payload` field of the outer COSE_Sign1. +// +// Field tags pin the CBOR key names byte-for-byte to the wire shape +// declared in spec/api-spec-v2.yaml § /ans/agents/{agentId}/attestation. +// Verifiers encode against these names; renaming a field is a wire- +// format breaking change. +// +// Why "Payload" not just "Attestation": the term "attestation" in +// this domain refers to the *signed* document (outer COSE_Sign1). +// The value type here is the unsigned payload that goes inside it — +// distinguishing the two prevents the very common bug of returning +// the raw struct from an HTTP handler instead of the signed bytes. +type AttestationPayload struct { + Issuer string `cbor:"iss"` + Subject string `cbor:"sub"` + DID string `cbor:"did"` + IssuedAt int64 `cbor:"iat"` + ExpiresAt int64 `cbor:"exp"` + IdentityCertSPKISHA256 []byte `cbor:"identity_cert_spki_sha256"` + ServerCertSPKISHA256 []byte `cbor:"server_cert_spki_sha256"` + DNS AttestationDNS `cbor:"dns"` + TL AttestationTL `cbor:"tl"` + // TrustScheme is the optional TRAIN trust-scheme DNS name. + // Omitted from the wire (omitempty) when the operator has not + // configured one — the spec calls this out as the only optional + // top-level field. All other zero-values are validation errors, + // not omit-from-output. + TrustScheme string `cbor:"trust_scheme,omitempty"` +} + +// AttestationDNS bundles the DNS-verification evidence the RA's DNS +// adapter captured at registration / latest renewal time. +type AttestationDNS struct { + VerifiedAt int64 `cbor:"verified_at"` + TLSARecords [][]byte `cbor:"tlsa_records"` + DNSSECValidated bool `cbor:"dnssec_validated"` +} + +// AttestationTL bundles the transparency-log binding for this +// registration. `Receipt` is the existing TL-signed SCITT COSE_Sign1 +// receipt, fetched verbatim from the TL — the verifier's two-key +// check validates the outer attestation against the RA producer key +// and this embedded receipt against the TL root key. +type AttestationTL struct { + LogURL string `cbor:"log_url"` + LeafHash []byte `cbor:"leaf_hash"` + TreeSize uint64 `cbor:"tree_size"` + Receipt []byte `cbor:"receipt"` +} + +// DIDForSubject returns the did:web identifier derived from a +// flat-FQDN subject. The transformation is the lossless mapping from +// the W3C DID Core spec: the FQDN becomes the method-specific id +// with `:` separators preserved. Subjects already containing a colon +// (an IPv6 address-form host, or a malformed input) are rejected +// upstream by NewAttestationPayload's host validation. +func DIDForSubject(subject string) string { + return "did:web:" + subject +} + +// NewAttestationPayload returns a validated AttestationPayload. +// `did` is derived from `subject` if left empty so callers can't +// accidentally publish an inconsistent (sub, did) pair on the wire. +// +// Validation is strict — every load-bearing field is required at +// construction time because the alternative is a 500 from the +// outermost handler with no actionable error message for the +// operator. We surface failures here with explicit CODE values the +// HTTP layer maps to RFC 7807 problem responses. +func NewAttestationPayload(p AttestationPayload) (AttestationPayload, error) { + out := p + + if out.Issuer == "" { + return AttestationPayload{}, NewValidationError("ATTESTATION_MISSING_ISS", + "attestation: iss is required") + } + if _, err := url.Parse(out.Issuer); err != nil { + return AttestationPayload{}, NewValidationError("ATTESTATION_INVALID_ISS", + "attestation: iss must parse as a URL: "+err.Error()) + } + + out.Subject = strings.ToLower(strings.TrimSpace(out.Subject)) + if out.Subject == "" { + return AttestationPayload{}, NewValidationError("ATTESTATION_MISSING_SUB", + "attestation: sub is required") + } + if err := validateAgentHost(out.Subject); err != nil { + return AttestationPayload{}, NewValidationError("ATTESTATION_INVALID_SUB", + "attestation: sub must be a valid hostname: "+err.Error()) + } + + if out.DID == "" { + out.DID = DIDForSubject(out.Subject) + } else if !strings.HasPrefix(out.DID, "did:web:") { + return AttestationPayload{}, NewValidationError("ATTESTATION_INVALID_DID", + "attestation: did must start with did:web:") + } + + if out.IssuedAt <= 0 { + return AttestationPayload{}, NewValidationError("ATTESTATION_INVALID_IAT", + "attestation: iat must be positive unix seconds") + } + if out.ExpiresAt <= out.IssuedAt { + return AttestationPayload{}, NewValidationError("ATTESTATION_INVALID_EXP", + "attestation: exp must be greater than iat") + } + + if len(out.IdentityCertSPKISHA256) != 32 { + return AttestationPayload{}, NewValidationError("ATTESTATION_INVALID_IDENTITY_SPKI", + "attestation: identity_cert_spki_sha256 must be exactly 32 bytes") + } + if len(out.ServerCertSPKISHA256) != 32 { + return AttestationPayload{}, NewValidationError("ATTESTATION_INVALID_SERVER_SPKI", + "attestation: server_cert_spki_sha256 must be exactly 32 bytes") + } + + if err := out.DNS.validate(); err != nil { + return AttestationPayload{}, err + } + if err := out.TL.validate(); err != nil { + return AttestationPayload{}, err + } + return out, nil +} + +func (d AttestationDNS) validate() error { + if d.VerifiedAt <= 0 { + return NewValidationError("ATTESTATION_INVALID_DNS_VERIFIED_AT", + "attestation: dns.verified_at must be positive unix seconds") + } + // TLSARecords may legitimately be empty when the agent uses + // dnsRecordStyle=BADGE_TXT_ONLY (no TLSA published). Empty slice + // is fine; nil-vs-empty distinction is preserved on the wire by + // the CBOR encoder. + return nil +} + +func (t AttestationTL) validate() error { + if t.LogURL == "" { + return NewValidationError("ATTESTATION_MISSING_TL_LOG_URL", + "attestation: tl.log_url is required") + } + if _, err := url.Parse(t.LogURL); err != nil { + return NewValidationError("ATTESTATION_INVALID_TL_LOG_URL", + "attestation: tl.log_url must parse as a URL: "+err.Error()) + } + if len(t.LeafHash) != 32 { + return NewValidationError("ATTESTATION_INVALID_TL_LEAF_HASH", + "attestation: tl.leaf_hash must be exactly 32 bytes (SHA-256)") + } + if t.TreeSize == 0 { + return NewValidationError("ATTESTATION_INVALID_TL_TREE_SIZE", + "attestation: tl.tree_size must be positive") + } + if len(t.Receipt) == 0 { + return NewValidationError("ATTESTATION_MISSING_TL_RECEIPT", + "attestation: tl.receipt is required (embedded TL-signed COSE_Sign1)") + } + return nil +} diff --git a/internal/domain/attestation_test.go b/internal/domain/attestation_test.go new file mode 100644 index 0000000..faedc23 --- /dev/null +++ b/internal/domain/attestation_test.go @@ -0,0 +1,153 @@ +package domain_test + +import ( + "strings" + "testing" + + "github.com/godaddy/ans/internal/domain" +) + +// validPayload returns an AttestationPayload that passes +// NewAttestationPayload as-is. Tests clone it and mutate one field +// at a time to exercise each rejection path. +func validPayload() domain.AttestationPayload { + return domain.AttestationPayload{ + Issuer: "https://ra.example.com", + Subject: "agent.example.com", + IssuedAt: 1700000000, + ExpiresAt: 1700003600, + IdentityCertSPKISHA256: bytesOf(0xAA, 32), + ServerCertSPKISHA256: bytesOf(0xBB, 32), + DNS: domain.AttestationDNS{ + VerifiedAt: 1700000000, + TLSARecords: [][]byte{bytesOf(0xCC, 35)}, + DNSSECValidated: true, + }, + TL: domain.AttestationTL{ + LogURL: "https://tl.example.com", + LeafHash: bytesOf(0xDD, 32), + TreeSize: 42, + Receipt: bytesOf(0xEE, 100), + }, + } +} + +func TestNewAttestationPayload_HappyPath(t *testing.T) { + t.Parallel() + out, err := domain.NewAttestationPayload(validPayload()) + if err != nil { + t.Fatalf("NewAttestationPayload: %v", err) + } + // DID auto-derived from subject. + if out.DID != "did:web:agent.example.com" { + t.Errorf("DID = %q, want did:web:agent.example.com", out.DID) + } + // Subject lowercased + trimmed. + if out.Subject != "agent.example.com" { + t.Errorf("Subject = %q, want agent.example.com", out.Subject) + } +} + +func TestNewAttestationPayload_SubjectNormalization(t *testing.T) { + t.Parallel() + p := validPayload() + p.Subject = " AGENT.Example.COM " + out, err := domain.NewAttestationPayload(p) + if err != nil { + t.Fatalf("err: %v", err) + } + if out.Subject != "agent.example.com" { + t.Errorf("Subject = %q, want lowercased+trimmed", out.Subject) + } + if out.DID != "did:web:agent.example.com" { + t.Errorf("DID = %q, want derived from normalized subject", out.DID) + } +} + +func TestNewAttestationPayload_ExplicitDIDPreserved(t *testing.T) { + t.Parallel() + p := validPayload() + p.DID = "did:web:operator-chosen.example.com" + out, err := domain.NewAttestationPayload(p) + if err != nil { + t.Fatalf("err: %v", err) + } + if out.DID != "did:web:operator-chosen.example.com" { + t.Errorf("DID = %q, want explicit value preserved", out.DID) + } +} + +func TestNewAttestationPayload_Validations(t *testing.T) { + t.Parallel() + cases := []struct { + name string + mutate func(*domain.AttestationPayload) + wantErr string // substring of the returned error code + }{ + {"missing iss", func(p *domain.AttestationPayload) { p.Issuer = "" }, "MISSING_ISS"}, + {"invalid iss", func(p *domain.AttestationPayload) { p.Issuer = "://not a url" }, "INVALID_ISS"}, + {"missing sub", func(p *domain.AttestationPayload) { p.Subject = "" }, "MISSING_SUB"}, + {"sub all whitespace", func(p *domain.AttestationPayload) { p.Subject = " " }, "MISSING_SUB"}, + {"sub not a hostname", func(p *domain.AttestationPayload) { p.Subject = "not a host" }, "INVALID_SUB"}, + {"did wrong method", func(p *domain.AttestationPayload) { p.DID = "did:key:abc" }, "INVALID_DID"}, + {"iat zero", func(p *domain.AttestationPayload) { p.IssuedAt = 0 }, "INVALID_IAT"}, + {"iat negative", func(p *domain.AttestationPayload) { p.IssuedAt = -1 }, "INVALID_IAT"}, + {"exp equal iat", func(p *domain.AttestationPayload) { p.ExpiresAt = p.IssuedAt }, "INVALID_EXP"}, + {"exp before iat", func(p *domain.AttestationPayload) { p.ExpiresAt = p.IssuedAt - 1 }, "INVALID_EXP"}, + {"identity spki short", func(p *domain.AttestationPayload) { p.IdentityCertSPKISHA256 = bytesOf(0xAA, 31) }, "INVALID_IDENTITY_SPKI"}, + {"identity spki long", func(p *domain.AttestationPayload) { p.IdentityCertSPKISHA256 = bytesOf(0xAA, 33) }, "INVALID_IDENTITY_SPKI"}, + {"server spki nil", func(p *domain.AttestationPayload) { p.ServerCertSPKISHA256 = nil }, "INVALID_SERVER_SPKI"}, + {"dns verified_at zero", func(p *domain.AttestationPayload) { p.DNS.VerifiedAt = 0 }, "INVALID_DNS_VERIFIED_AT"}, + {"tl log_url missing", func(p *domain.AttestationPayload) { p.TL.LogURL = "" }, "MISSING_TL_LOG_URL"}, + {"tl log_url malformed", func(p *domain.AttestationPayload) { p.TL.LogURL = "://nope" }, "INVALID_TL_LOG_URL"}, + {"tl leaf_hash wrong size", func(p *domain.AttestationPayload) { p.TL.LeafHash = bytesOf(0xDD, 16) }, "INVALID_TL_LEAF_HASH"}, + {"tl tree_size zero", func(p *domain.AttestationPayload) { p.TL.TreeSize = 0 }, "INVALID_TL_TREE_SIZE"}, + {"tl receipt missing", func(p *domain.AttestationPayload) { p.TL.Receipt = nil }, "MISSING_TL_RECEIPT"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + p := validPayload() + c.mutate(&p) + _, err := domain.NewAttestationPayload(p) + if err == nil { + t.Fatalf("want validation error containing %q, got nil", c.wantErr) + } + if !strings.Contains(err.Error(), c.wantErr) { + t.Errorf("err = %v, want error code containing %q", err, c.wantErr) + } + }) + } +} + +func TestNewAttestationPayload_EmptyTLSARecordsOK(t *testing.T) { + t.Parallel() + // BADGE_TXT_ONLY style emits no TLSA records — must not be a + // validation failure. + p := validPayload() + p.DNS.TLSARecords = nil + if _, err := domain.NewAttestationPayload(p); err != nil { + t.Fatalf("empty TLSA should be valid: %v", err) + } + p.DNS.TLSARecords = [][]byte{} + if _, err := domain.NewAttestationPayload(p); err != nil { + t.Fatalf("empty TLSA slice should be valid: %v", err) + } +} + +func TestDIDForSubject(t *testing.T) { + t.Parallel() + if got := domain.DIDForSubject("morpheus.example.com"); got != "did:web:morpheus.example.com" { + t.Errorf("DIDForSubject = %q, want did:web:morpheus.example.com", got) + } +} + +// --- helpers --- + +func bytesOf(b byte, n int) []byte { + out := make([]byte, n) + for i := range out { + out[i] = b + } + return out +} From abfb47c3df5b749bf576db178f57c35a6c36b0a8 Mon Sep 17 00:00:00 2001 From: Layer8 Date: Tue, 2 Jun 2026 23:17:54 +0200 Subject: [PATCH 4/8] feat(tlclient): add GetReceipt for the attestation pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a typed GetReceipt(ctx, agentID) on the RA's TL HTTP client. One round-trip to GET /v1/agents/{agentId}/receipt: the binary COSE_Sign1 comes back as the embedded `tl.receipt` for the bundled attestation, and the inclusion-proof view (tree_size, leaf_index, leaf_hash, root_hash, path) is parsed locally from the receipt's unprotected VDP plus a freshly computed RFC 6962 leaf hash. One call instead of two guarantees the proof and the receipt reference the same checkpoint generation; if they were fetched independently they could disagree mid-tick and the attestation would be internally inconsistent. Classifies errors into three sentinels the attestation service maps to spec-defined status codes: * ErrTLLeafUncommitted — TL acked the append but no covering checkpoint yet (503 TL_LEAF_UNCOMMITTED on the wire). * ErrTLAgentNotFound — TL has no events for this agentId. * ErrTLNotReachable — every other transport / server failure (503 TL_NOT_REACHABLE on the wire). Supporting change: receipt.ExtractInclusionProof exports the COSE-parse-then-decode-VDP helper that was previously package-internal so the tlclient can crack open a receipt without re-implementing the CBOR plumbing. Signed-off-by: Layer8 --- internal/adapter/tlclient/getreceipt.go | 161 +++++++++++ internal/adapter/tlclient/getreceipt_test.go | 272 +++++++++++++++++++ internal/tl/receipt/receipt_test.go | 42 +++ internal/tl/receipt/verify.go | 38 +++ 4 files changed, 513 insertions(+) create mode 100644 internal/adapter/tlclient/getreceipt.go create mode 100644 internal/adapter/tlclient/getreceipt_test.go diff --git a/internal/adapter/tlclient/getreceipt.go b/internal/adapter/tlclient/getreceipt.go new file mode 100644 index 0000000..e3b2e9a --- /dev/null +++ b/internal/adapter/tlclient/getreceipt.go @@ -0,0 +1,161 @@ +package tlclient + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/godaddy/ans/internal/tl/receipt" +) + +// MerkleProof is the inclusion-proof view returned by GetReceipt. +// All byte fields are raw (NOT base64) — the receipt CBOR already +// stores them as bstr, and the caller doesn't gain anything from a +// second round-trip through base64. LeafHash is RFC 6962 +// SHA-256(0x00 || canonical_event) computed locally from the +// receipt's attached payload. +type MerkleProof struct { + TreeSize uint64 + LeafIndex uint64 + LeafHash []byte + RootHash []byte + Path [][]byte +} + +// Sentinel errors callers match with errors.Is. +// +// - ErrTLLeafUncommitted — the TL acknowledged the leaf append but +// no signed checkpoint yet covers it. Maps from the TL's +// 503 + code=TL_LEAF_UNCOMMITTED response. Translated by the +// attestation service into the same wire shape on the RA's +// 503 response, preserving the original code so verifiers see +// a stable error vocabulary. +// - ErrTLAgentNotFound — the TL has no event history for this +// agentId, mapped from a 404 on /v1/agents/{id}/receipt. +// Distinct from "RA never registered this agent"; the RA +// service decides which 404 to surface. +// - ErrTLNotReachable — every other transport/server failure. The +// RA's attestation handler maps this to 503 TL_NOT_REACHABLE per +// spec. +var ( + ErrTLLeafUncommitted = errors.New("tlclient: leaf appended but no covering checkpoint yet") + ErrTLAgentNotFound = errors.New("tlclient: tl has no events for agent") + ErrTLNotReachable = errors.New("tlclient: tl is not reachable") +) + +// problemJSON mirrors the RFC 7807 problem-details shape every TL +// handler emits on failure. Only `Code` is load-bearing here — the +// other fields are echoed for log diagnosis. +type problemJSON struct { + Type string `json:"type"` + Title string `json:"title"` + Status int `json:"status"` + Detail string `json:"detail"` + Code string `json:"code"` +} + +// GetReceipt fetches the SCITT COSE_Sign1 receipt for an agent and +// derives the inclusion-proof view from its embedded VDP header. +// +// Side effect of using the receipt's own VDP rather than a separate +// /v1/agents/{id} (badge) call: one network round-trip instead of +// two, and the proof + receipt are guaranteed to be from the same +// signed checkpoint generation. If we fetched them independently +// they could disagree mid-tick (the badge proof references one +// checkpoint, the receipt's VDP another) and the resulting +// attestation would be internally inconsistent. +// +// Returns: +// +// - receiptBytes — the binary CBOR COSE_Sign1, ready to embed +// verbatim into the attestation's `tl.receipt` field. +// - proof — tree size, leaf index, leaf hash, root hash, sibling +// path. LeafHash is recomputed locally as a self-check; if the +// TL's payload bytes don't hash to the leaf the proof claims, the +// caller should treat the response as compromised. +// +// Errors are classified into the three sentinels above so the +// attestation service can map cleanly to spec-defined 4xx/5xx codes. +func (c *Client) GetReceipt(ctx context.Context, agentID string) ([]byte, *MerkleProof, error) { + if agentID == "" { + return nil, nil, errors.New("tlclient: agentID required") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + c.baseURL+"/v1/agents/"+agentID+"/receipt", nil) + if err != nil { + return nil, nil, fmt.Errorf("tlclient: build request: %w", err) + } + req.Header.Set("Accept", receipt.MediaType) + if c.apiKey != "" { + // The TL's GetReceipt route is unauthenticated in the + // reference, but if a deployment fronts it with an auth + // proxy our existing apiKey lets us pass through cleanly. + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + + resp, err := c.http.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("%w: %w", ErrTLNotReachable, err) + } + defer resp.Body.Close() + + // 64 KiB cap on a typical 1-2 KiB receipt; a single-leaf demo log + // produces <300 bytes. Acts as a sanity guard against the TL + // streaming an unbounded body — cf. the same defense in + // cmd/ans-verify's walker. + rawBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + + switch { + case resp.StatusCode == http.StatusOK: + return decodeReceiptResponse(rawBody) + case resp.StatusCode == http.StatusNotFound: + return nil, nil, ErrTLAgentNotFound + case resp.StatusCode == http.StatusServiceUnavailable: + // The TL returns 503 for either "leaf not yet covered" or + // a generic operational failure; distinguish by parsing the + // problem-details `code`. + var p problemJSON + if jerr := json.Unmarshal(rawBody, &p); jerr == nil && p.Code == "TL_LEAF_UNCOMMITTED" { + return nil, nil, ErrTLLeafUncommitted + } + return nil, nil, fmt.Errorf("%w: tl returned 503 with body %q", ErrTLNotReachable, rawBody) + case resp.StatusCode >= 500: + return nil, nil, fmt.Errorf("%w: tl returned %d", ErrTLNotReachable, resp.StatusCode) + default: + // 4xx other than 404 is treated as not-reachable from the RA's + // perspective: the RA isn't supposed to construct invalid + // requests against the TL, so a 4xx means something structural + // is broken. Map to 503 rather than letting it surface as a + // 500 to the attestation caller. + return nil, nil, fmt.Errorf("%w: tl returned unexpected status %d", ErrTLNotReachable, resp.StatusCode) + } +} + +// decodeReceiptResponse parses the receipt bytes and derives the +// inclusion proof view. Split out so it can be unit-tested directly +// against a hand-rolled receipt without standing up an httptest +// server. +func decodeReceiptResponse(body []byte) ([]byte, *MerkleProof, error) { + if len(body) == 0 { + return nil, nil, fmt.Errorf("%w: tl returned empty receipt body", ErrTLNotReachable) + } + proof, err := receipt.ExtractInclusionProof(body) + if err != nil { + return nil, nil, fmt.Errorf("%w: parse receipt VDP: %w", ErrTLNotReachable, err) + } + payload, err := receipt.ExtractPayload(body) + if err != nil { + return nil, nil, fmt.Errorf("%w: extract receipt payload: %w", ErrTLNotReachable, err) + } + leafHash := receipt.ComputeLeafHash(payload) + return body, &MerkleProof{ + TreeSize: proof.TreeSize, + LeafIndex: proof.LeafIndex, + LeafHash: leafHash, + RootHash: proof.RootHash, + Path: proof.Path, + }, nil +} diff --git a/internal/adapter/tlclient/getreceipt_test.go b/internal/adapter/tlclient/getreceipt_test.go new file mode 100644 index 0000000..3a19b37 --- /dev/null +++ b/internal/adapter/tlclient/getreceipt_test.go @@ -0,0 +1,272 @@ +package tlclient_test + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/asn1" + "errors" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/godaddy/ans/internal/adapter/tlclient" + "github.com/godaddy/ans/internal/tl/receipt" +) + +func TestGetReceipt_HappyPath(t *testing.T) { + t.Parallel() + const agentID = "11111111-2222-3333-4444-555555555555" + // Build a real COSE_Sign1 receipt over fixed event bytes so the + // VDP + payload-hash arithmetic the client does is exercised + // end-to-end against bytes that came out of the production + // generator path. + eventBytes := []byte(`{"agent":"demo","leaf":0}`) + receiptBytes := mintReceipt(t, eventBytes, 5, 0) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/agents/"+agentID+"/receipt" { + t.Errorf("path = %q", r.URL.Path) + } + w.Header().Set("Content-Type", receipt.MediaType) + _, _ = w.Write(receiptBytes) + })) + defer srv.Close() + + c := tlclient.New(srv.URL, "", 5*time.Second) + body, proof, err := c.GetReceipt(context.Background(), agentID) + if err != nil { + t.Fatalf("GetReceipt: %v", err) + } + if !bytesEqual(body, receiptBytes) { + t.Errorf("body != receiptBytes") + } + if proof.TreeSize != 5 || proof.LeafIndex != 0 { + t.Errorf("proof tree_size=%d leaf_index=%d, want 5/0", proof.TreeSize, proof.LeafIndex) + } + if len(proof.LeafHash) != 32 { + t.Errorf("leaf_hash len = %d, want 32", len(proof.LeafHash)) + } + // LeafHash MUST equal SHA-256(0x00 || eventBytes) — pin the RFC + // 6962 calculation against drift. + want := sha256.Sum256(append([]byte{0x00}, eventBytes...)) + if !bytesEqual(proof.LeafHash, want[:]) { + t.Errorf("leaf_hash mismatch") + } +} + +func TestGetReceipt_503LeafUncommitted(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"type":"about:blank","title":"Receipt Not Yet Available","status":503,"code":"TL_LEAF_UNCOMMITTED","detail":"x"}`)) + })) + defer srv.Close() + c := tlclient.New(srv.URL, "", 5*time.Second) + _, _, err := c.GetReceipt(context.Background(), "any") + if !errors.Is(err, tlclient.ErrTLLeafUncommitted) { + t.Fatalf("err = %v, want ErrTLLeafUncommitted", err) + } +} + +func TestGetReceipt_503OtherCodeIsNotReachable(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"code":"SOMETHING_ELSE"}`)) + })) + defer srv.Close() + c := tlclient.New(srv.URL, "", 5*time.Second) + _, _, err := c.GetReceipt(context.Background(), "any") + if !errors.Is(err, tlclient.ErrTLNotReachable) { + t.Fatalf("err = %v, want ErrTLNotReachable", err) + } +} + +func TestGetReceipt_503UnparsableBodyIsNotReachable(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`not json at all`)) + })) + defer srv.Close() + c := tlclient.New(srv.URL, "", 5*time.Second) + _, _, err := c.GetReceipt(context.Background(), "any") + if !errors.Is(err, tlclient.ErrTLNotReachable) { + t.Fatalf("err = %v, want ErrTLNotReachable", err) + } +} + +func TestGetReceipt_404(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + c := tlclient.New(srv.URL, "", 5*time.Second) + _, _, err := c.GetReceipt(context.Background(), "any") + if !errors.Is(err, tlclient.ErrTLAgentNotFound) { + t.Fatalf("err = %v, want ErrTLAgentNotFound", err) + } +} + +func TestGetReceipt_500(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + c := tlclient.New(srv.URL, "", 5*time.Second) + _, _, err := c.GetReceipt(context.Background(), "any") + if !errors.Is(err, tlclient.ErrTLNotReachable) { + t.Fatalf("err = %v, want ErrTLNotReachable", err) + } +} + +func TestGetReceipt_4xxOtherIsNotReachable(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + c := tlclient.New(srv.URL, "", 5*time.Second) + _, _, err := c.GetReceipt(context.Background(), "any") + if !errors.Is(err, tlclient.ErrTLNotReachable) { + t.Fatalf("err = %v, want ErrTLNotReachable", err) + } +} + +func TestGetReceipt_TransportError(t *testing.T) { + t.Parallel() + // Point the client at a server that's already shut down so + // http.Do returns a connect error. + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) + url := srv.URL + srv.Close() + c := tlclient.New(url, "", 100*time.Millisecond) + _, _, err := c.GetReceipt(context.Background(), "any") + if !errors.Is(err, tlclient.ErrTLNotReachable) { + t.Fatalf("err = %v, want ErrTLNotReachable", err) + } +} + +func TestGetReceipt_EmptyAgentID(t *testing.T) { + t.Parallel() + c := tlclient.New("http://localhost", "", time.Second) + _, _, err := c.GetReceipt(context.Background(), "") + if err == nil { + t.Fatal("want error for empty agentID") + } +} + +func TestGetReceipt_EmptyResponseBody(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + c := tlclient.New(srv.URL, "", 5*time.Second) + _, _, err := c.GetReceipt(context.Background(), "any") + if !errors.Is(err, tlclient.ErrTLNotReachable) { + t.Fatalf("err = %v, want ErrTLNotReachable for empty body", err) + } +} + +func TestGetReceipt_MalformedCOSE(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte{0xFF, 0xFF, 0xFF}) + })) + defer srv.Close() + c := tlclient.New(srv.URL, "", 5*time.Second) + _, _, err := c.GetReceipt(context.Background(), "any") + if !errors.Is(err, tlclient.ErrTLNotReachable) { + t.Fatalf("err = %v, want ErrTLNotReachable for bad CBOR", err) + } +} + +func TestGetReceipt_ForwardsBearerAuth(t *testing.T) { + t.Parallel() + eventBytes := []byte(`{"x":1}`) + receiptBytes := mintReceipt(t, eventBytes, 1, 0) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer auth-proxy-key" { + t.Errorf("Authorization = %q, want Bearer auth-proxy-key", got) + } + _, _ = w.Write(receiptBytes) + })) + defer srv.Close() + c := tlclient.New(srv.URL, "auth-proxy-key", 5*time.Second) + if _, _, err := c.GetReceipt(context.Background(), "any"); err != nil { + t.Fatalf("GetReceipt: %v", err) + } +} + +// --- helpers --- + +// mintReceipt builds a real SCITT receipt over eventBytes for tests. +// Reuses the production receipt.Generator so the bytes are +// byte-identical to what the TL would produce. +func mintReceipt(t *testing.T, eventBytes []byte, treeSize, leafIndex uint64) []byte { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("genkey: %v", err) + } + km := &localKM{priv: priv} + gen, err := receipt.NewKeyManagerGenerator(context.Background(), km, "k", "tl-test") + if err != nil { + t.Fatalf("NewKeyManagerGenerator: %v", err) + } + // Single-leaf tree: no siblings, root hash IS the leaf hash. + leafHash := receipt.ComputeLeafHash(eventBytes) + rec, err := gen.GenerateReceipt(context.Background(), &receipt.InclusionProof{ + TreeSize: treeSize, + LeafIndex: leafIndex, + Path: [][]byte{}, + RootHash: leafHash, + }, eventBytes) + if err != nil { + t.Fatalf("GenerateReceipt: %v", err) + } + return rec +} + +// localKM is a port.KeyManager that signs with an in-memory P-256 +// key. Returns ASN.1 DER signatures as the real KM does. +type localKM struct{ priv *ecdsa.PrivateKey } + +func (k *localKM) Sign(_ context.Context, _ string, digest []byte) ([]byte, error) { + r, s, err := ecdsa.Sign(rand.Reader, k.priv, digest) + if err != nil { + return nil, err + } + return asn1.Marshal(struct{ R, S *big.Int }{r, s}) +} +func (k *localKM) Verify(_ context.Context, _ string, _, _ []byte) (bool, error) { + return false, nil +} +func (k *localKM) GetPublicKey(_ context.Context, _ string) (crypto.PublicKey, error) { + return &k.priv.PublicKey, nil +} +func (k *localKM) CreateKey(_ context.Context, _ string) (string, error) { return "", nil } +func (k *localKM) ListKeys(_ context.Context) ([]string, error) { return nil, nil } + +func bytesEqual(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/tl/receipt/receipt_test.go b/internal/tl/receipt/receipt_test.go index e8920ff..0017014 100644 --- a/internal/tl/receipt/receipt_test.go +++ b/internal/tl/receipt/receipt_test.go @@ -188,6 +188,48 @@ func TestReceipt_ExtractPayload(t *testing.T) { } } +func TestReceipt_ExtractInclusionProof(t *testing.T) { + t.Parallel() + km := newTestKM(t) + gen, _ := receipt.NewKeyManagerGenerator(context.Background(), km, "receipt-k", "ans-test") + // Build a small multi-leaf proof so Path is non-empty and the + // extractor actually has something to decode beyond the trivial + // single-leaf case. + sibling := sha256LeafHash([]byte("other-leaf")) + leaf := sha256LeafHash(fixedEventBytes) + root := sha256NodeHash(leaf[:], sibling[:]) + proof := &receipt.InclusionProof{ + TreeSize: 2, + LeafIndex: 0, + Path: [][]byte{sibling[:]}, + RootHash: root[:], + } + coseBytes, err := gen.GenerateReceipt(context.Background(), proof, fixedEventBytes) + if err != nil { + t.Fatal(err) + } + view, err := receipt.ExtractInclusionProof(coseBytes) + if err != nil { + t.Fatalf("ExtractInclusionProof: %v", err) + } + if view.TreeSize != 2 || view.LeafIndex != 0 { + t.Errorf("view tree_size=%d leaf_index=%d, want 2/0", view.TreeSize, view.LeafIndex) + } + if len(view.Path) != 1 || string(view.Path[0]) != string(sibling[:]) { + t.Errorf("path mismatch: %v", view.Path) + } + if string(view.RootHash) != string(root[:]) { + t.Errorf("root hash mismatch") + } +} + +func TestReceipt_ExtractInclusionProof_BadInput(t *testing.T) { + t.Parallel() + if _, err := receipt.ExtractInclusionProof([]byte{0xFF, 0xFF}); err == nil { + t.Fatal("want error on garbage CBOR") + } +} + func TestReceipt_Generate_RejectsNonP256(t *testing.T) { t.Parallel() // P-384 key — ES256 is fixed in COSE; anything else should be diff --git a/internal/tl/receipt/verify.go b/internal/tl/receipt/verify.go index 82f515d..d682984 100644 --- a/internal/tl/receipt/verify.go +++ b/internal/tl/receipt/verify.go @@ -230,6 +230,44 @@ type extractedProof struct { RootHash []byte } +// InclusionProofView is the public projection of the receipt's +// embedded RFC 9162 inclusion proof. Returned by ExtractInclusionProof +// so consumers outside this package — notably the RA's tlclient, +// which needs the tree-size + leaf hash to bind a receipt into a +// signed agent attestation — don't have to re-implement CBOR +// COSE_Sign1 parsing. +type InclusionProofView struct { + TreeSize uint64 + LeafIndex uint64 + Path [][]byte + RootHash []byte +} + +// ExtractInclusionProof parses a SCITT COSE_Sign1 receipt and +// returns the inclusion proof embedded in its unprotected VDP +// header. Errors propagate from the COSE parser (invalid CBOR, +// wrong tag) and from the VDP decoder (missing fields, wrong +// types). +// +// Does NOT verify the signature — that's the caller's job via +// receipt.Verify. ExtractInclusionProof is purely structural. +func ExtractInclusionProof(receiptBytes []byte) (*InclusionProofView, error) { + parsed, err := parseCOSESign1(receiptBytes) + if err != nil { + return nil, err + } + p, err := extractInclusionProof(parsed.unprotected) + if err != nil { + return nil, err + } + return &InclusionProofView{ + TreeSize: p.TreeSize, + LeafIndex: p.LeafIndex, + Path: p.Path, + RootHash: p.RootHash, + }, nil +} + func extractInclusionProof(unprotected map[int]any) (*extractedProof, error) { vdpRaw, ok := unprotected[labelVDP] if !ok { From 445d3b77f8f4c91439bb3ada2c9b76bfe52f7b91 Mon Sep 17 00:00:00 2001 From: Layer8 Date: Tue, 2 Jun 2026 23:26:33 +0200 Subject: [PATCH 5/8] feat(ra/service): add AttestationService backing GET /v2/ans/agents/{agentId}/attestation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assembles the bundled signed attestation from AgentStore + CertificateStore + ByocCertificateStore + TLClient + clock, then signs the CBOR-encoded payload via internal/crypto/cose.Sign1 against the RA's producer KeyManager. Same key signs every inner producer event, so a verifier holding the RA producer pubkey can validate both the outer attestation and (via the TL's /root-keys) the embedded SCITT receipt — the two-key topology spelled out in the spec. Spec-defined error vocabulary mapped through sentinels: * ErrAttestationAgentNotFound -> 404 AGENT_NOT_FOUND * ErrAttestationAgentRevoked -> 410 AGENT_REVOKED * ErrAttestationLeafUncommitted -> 503 TL_LEAF_UNCOMMITTED * ErrAttestationTLNotReachable -> 503 TL_NOT_REACHABLE A 404 from the TL on /v1/agents/{id}/receipt collapses into the "agent not found" path on the RA side — the absence of TL events for an agent is observationally identical to the RA never having registered it, from the verifier's point of view. The DNS sub-block is populated from data already on the registration record: VerifiedAt = effective timestamp; TLSARecords = wire-format "3 1 1 SHA-256(server SPKI)" synthesized locally from the server cert so the value matches what a properly-configured agent would publish; DNSSECValidated = false until a follow-up migration carries through the DNS adapter's AD-bit observation. Supporting change: cose.MarshalDeterministic exposed so the service can route the payload encode through the same deterministic profile Sign1 uses for the protected header and Sig_structure. Signed-off-by: Layer8 --- internal/crypto/cose/cose.go | 11 + internal/crypto/cose/cose_test.go | 21 ++ internal/ra/service/attestation.go | 342 +++++++++++++++++++ internal/ra/service/attestation_test.go | 417 ++++++++++++++++++++++++ 4 files changed, 791 insertions(+) create mode 100644 internal/ra/service/attestation.go create mode 100644 internal/ra/service/attestation_test.go diff --git a/internal/crypto/cose/cose.go b/internal/crypto/cose/cose.go index 5652dbd..d2296be 100644 --- a/internal/crypto/cose/cose.go +++ b/internal/crypto/cose/cose.go @@ -187,6 +187,17 @@ func encodeProtectedHeader(m map[int]any) ([]byte, error) { return detMarshal(m) } +// MarshalDeterministic exposes the same core-deterministic CBOR +// encoder Sign1 uses internally. Callers who need to build the +// `payload` bytes (e.g. the attestation service CBOR-encoding its +// AttestationPayload struct before handing it to Sign1) should +// route through here so the encoder choice stays centralized — if +// a future RFC update shifts the deterministic profile, one edit +// here covers every signed object. +func MarshalDeterministic(v any) ([]byte, error) { + return detMarshal(v) +} + // detMarshal encodes with CBOR core-deterministic options (RFC 8949 // §4.2): integer keys sorted by value, no indefinite lengths, // smallest integer representations. Same encoder previously inlined diff --git a/internal/crypto/cose/cose_test.go b/internal/crypto/cose/cose_test.go index 28066f4..5bc7648 100644 --- a/internal/crypto/cose/cose_test.go +++ b/internal/crypto/cose/cose_test.go @@ -302,6 +302,27 @@ func TestSign1_UnencodableProtectedHeader(t *testing.T) { } } +func TestMarshalDeterministic(t *testing.T) { + t.Parallel() + // Two encodes of the same input must be byte-identical. + in := map[int]any{2: "two", 1: "one", 3: []byte{0xAB}} + a, err := cose.MarshalDeterministic(in) + if err != nil { + t.Fatalf("MarshalDeterministic: %v", err) + } + b, err := cose.MarshalDeterministic(in) + if err != nil { + t.Fatalf("MarshalDeterministic: %v", err) + } + if !equalBytes(a, b) { + t.Fatal("two encodes of identical input differ") + } + // Unencodable type surfaces an error rather than panicking. + if _, err := cose.MarshalDeterministic(make(chan int)); err == nil { + t.Fatal("want error on unencodable type") + } +} + func TestSign1_UnencodableUnprotectedHeader(t *testing.T) { t.Parallel() // Channel in the unprotected map slips past the empty-protected diff --git a/internal/ra/service/attestation.go b/internal/ra/service/attestation.go new file mode 100644 index 0000000..ba6e69e --- /dev/null +++ b/internal/ra/service/attestation.go @@ -0,0 +1,342 @@ +package service + +import ( + "context" + "crypto/sha256" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "time" + + "github.com/godaddy/ans/internal/adapter/tlclient" + "github.com/godaddy/ans/internal/crypto/cose" + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/port" +) + +const ( + // DefaultAttestationTTL is the lifetime stamped onto an + // AttestationPayload's `exp` claim when the operator hasn't + // configured a non-default TTL. Matches the `Cache-Control: + // public, max-age=3600` the handler emits — the HTTP cache and + // the COSE expiry expire together, so a proxy can't serve a + // stale attestation past its cryptographic lifetime. + DefaultAttestationTTL = 1 * time.Hour + + // AttestationMediaType is the Content-Type returned to clients. + // Generic application/cose (not application/scitt-receipt+cose): + // this is a bundled attestation, not a SCITT receipt. + AttestationMediaType = "application/cose" +) + +// Sentinel errors the handler maps to spec-defined HTTP status codes +// (api-spec-v2.yaml § /ans/agents/{agentId}/attestation). Distinct +// from tlclient's sentinels so the handler doesn't need to import +// tlclient just to errors.Is on its surface. Wrapped underlying TL +// errors are preserved for diagnostic logging. +var ( + ErrAttestationAgentNotFound = errors.New("attestation: agent not found") + ErrAttestationAgentRevoked = errors.New("attestation: agent revoked") + ErrAttestationLeafUncommitted = errors.New("attestation: tl leaf not yet covered by checkpoint") + ErrAttestationTLNotReachable = errors.New("attestation: tl is not reachable") +) + +// AttestationTLClient is the narrow seam over tlclient that the +// service depends on. Kept as an interface so a unit test can +// substitute a hand-rolled stub without standing up the real HTTP +// client (and so a future swap to a streaming or gRPC TL transport +// doesn't ripple through the service). +type AttestationTLClient interface { + GetReceipt(ctx context.Context, agentID string) ([]byte, *tlclient.MerkleProof, error) +} + +// AttestationServiceConfig is the static configuration the service +// needs to mint signed attestations. +type AttestationServiceConfig struct { + // Issuer is the RA's origin URL — written into the payload's + // `iss` field and into the protected header's CWT issuer claim + // (label 15, sub-claim 1). Verifiers use it to correlate an + // attestation to a specific RA instance. + Issuer string + // TLLogURL is the publicly-reachable base URL of the TL the RA + // posts events to — written into payload.tl.log_url so verifiers + // can independently fetch the TL's /root-keys to verify the + // embedded receipt. + TLLogURL string + // KeyHash is the 4-byte SPKI hash of the RA producer key. Goes + // into the COSE protected header as the `kid` so verifiers can + // resolve the right TL-published producer key in O(1). + KeyHash []byte + // Signer wraps the RA producer KeyManager key into a cose.Signer. + // Same key that signs the inner producer event in every outbox + // row — that property is what lets verifiers verify the outer + // attestation and the embedded inner producer signature against + // the same advertised pubkey. + Signer cose.Signer + // TTL is the lifetime of each attestation. Zero → DefaultAttestationTTL. + TTL time.Duration + // TrustScheme is the optional TRAIN trust-scheme DNS name. When + // empty, the field is omitted from the wire (spec calls this + // out as the only optional top-level field). + TrustScheme string +} + +// AttestationService produces signed agent-attestation COSE_Sign1 +// objects. Holds no per-request state — safe for concurrent use. +type AttestationService struct { + agents port.AgentStore + certs port.CertificateStore + byoc port.ByocCertificateStore + tl AttestationTLClient + cfg AttestationServiceConfig + now func() time.Time +} + +// NewAttestationService validates the configuration up front so a +// misconfigured RA fails at startup, not on the first attestation +// request hours later. +func NewAttestationService( + agents port.AgentStore, + certs port.CertificateStore, + byoc port.ByocCertificateStore, + tl AttestationTLClient, + cfg AttestationServiceConfig, +) (*AttestationService, error) { + if agents == nil { + return nil, errors.New("attestation: AgentStore required") + } + if certs == nil { + return nil, errors.New("attestation: CertificateStore required") + } + if byoc == nil { + return nil, errors.New("attestation: ByocCertificateStore required") + } + if tl == nil { + return nil, errors.New("attestation: TLClient required") + } + if cfg.Signer == nil { + return nil, errors.New("attestation: Signer required") + } + if cfg.Issuer == "" { + return nil, errors.New("attestation: Issuer required") + } + if cfg.TLLogURL == "" { + return nil, errors.New("attestation: TLLogURL required") + } + if len(cfg.KeyHash) != 4 { + return nil, fmt.Errorf("attestation: KeyHash must be 4 bytes (got %d)", len(cfg.KeyHash)) + } + if cfg.TTL == 0 { + cfg.TTL = DefaultAttestationTTL + } + return &AttestationService{ + agents: agents, + certs: certs, + byoc: byoc, + tl: tl, + cfg: cfg, + now: func() time.Time { return time.Now().UTC() }, + }, nil +} + +// WithClock overrides the clock — test-only. +func (s *AttestationService) WithClock(fn func() time.Time) *AttestationService { + s.now = fn + return s +} + +// Generate produces the signed attestation bytes for agentID. +// +// Flow: +// 1. Look up the agent. 404 on miss; 410 on revoked. +// 2. Resolve latest valid identity + server certs; compute SPKI +// hashes for each. +// 3. Fetch the SCITT receipt + inclusion proof from the TL. Map +// 503-class errors to ErrAttestationLeafUncommitted / +// ErrAttestationTLNotReachable so the handler can preserve the +// spec's two distinct 503 codes. +// 4. Build the AttestationPayload value (validated by the domain +// constructor — every field is load-bearing). +// 5. CBOR-encode the payload, then sign via cose.Sign1 against the +// RA producer key. +// +// The output is the binary COSE_Sign1 the handler returns verbatim +// with Content-Type: application/cose. +func (s *AttestationService) Generate(ctx context.Context, agentID string) ([]byte, error) { + reg, err := s.agents.FindByAgentID(ctx, agentID) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + return nil, ErrAttestationAgentNotFound + } + return nil, fmt.Errorf("attestation: load agent: %w", err) + } + if reg.Status == domain.StatusRevoked { + return nil, ErrAttestationAgentRevoked + } + + now := s.now() + idSPKI, err := s.identitySPKIHash(ctx, agentID, now) + if err != nil { + return nil, err + } + serverSPKI, err := s.serverSPKIHash(ctx, agentID, now) + if err != nil { + return nil, err + } + + receiptBytes, proof, err := s.tl.GetReceipt(ctx, agentID) + if err != nil { + switch { + case errors.Is(err, tlclient.ErrTLLeafUncommitted): + return nil, ErrAttestationLeafUncommitted + case errors.Is(err, tlclient.ErrTLAgentNotFound): + return nil, ErrAttestationAgentNotFound + case errors.Is(err, tlclient.ErrTLNotReachable): + return nil, ErrAttestationTLNotReachable + default: + return nil, fmt.Errorf("attestation: fetch receipt: %w", err) + } + } + + payload := domain.AttestationPayload{ + Issuer: s.cfg.Issuer, + Subject: reg.AnsName.AgentHost(), + IssuedAt: now.Unix(), + ExpiresAt: now.Add(s.cfg.TTL).Unix(), + IdentityCertSPKISHA256: idSPKI, + ServerCertSPKISHA256: serverSPKI, + DNS: s.dnsSection(reg, serverSPKI), + TL: domain.AttestationTL{ + LogURL: s.cfg.TLLogURL, + LeafHash: proof.LeafHash, + TreeSize: proof.TreeSize, + Receipt: receiptBytes, + }, + TrustScheme: s.cfg.TrustScheme, + } + validated, err := domain.NewAttestationPayload(payload) + if err != nil { + return nil, fmt.Errorf("attestation: validate payload: %w", err) + } + + payloadBytes, err := cose.MarshalDeterministic(validated) + if err != nil { + return nil, fmt.Errorf("attestation: encode payload: %w", err) + } + + // Protected header — same shape SCITT receipts use, minus the + // VDS / VDP labels (those describe a transparency-log proof; an + // attestation isn't one). + protected := map[int]any{ + labelAlg: algES256, + labelKID: s.cfg.KeyHash, + labelCWTClaims: map[int]any{ + cwtIss: s.cfg.Issuer, + cwtIat: now.Unix(), + }, + } + return cose.Sign1(ctx, s.cfg.Signer, protected, nil, payloadBytes) +} + +// identitySPKIHash returns SHA-256(SubjectPublicKeyInfo) of the +// agent's latest valid identity certificate. Surfaces an explicit +// error rather than a zero hash when no valid cert exists — that's +// a 500-class condition (the agent shouldn't be reachable at this +// path without certs in place), but we want operators to see why. +func (s *AttestationService) identitySPKIHash(ctx context.Context, agentID string, now time.Time) ([]byte, error) { + certs, err := s.certs.FindIdentityCertificatesByAgent(ctx, agentID) + if err != nil { + return nil, fmt.Errorf("attestation: load identity certs: %w", err) + } + for _, c := range certs { + if c.IsValid(now) { + h, err := spkiSHA256FromPEM(c.CertificatePEM) + if err != nil { + return nil, fmt.Errorf("attestation: identity SPKI: %w", err) + } + return h, nil + } + } + return nil, errors.New("attestation: no valid identity certificate for agent") +} + +// serverSPKIHash returns SHA-256(SPKI) of the agent's latest valid +// server cert. BYOC and CSR-issued certs both land in the BYOC +// store (see lifecycle.go's signServerCSRForVerifyACME), so a +// single FindLatestValidByAgentID covers both. +func (s *AttestationService) serverSPKIHash(ctx context.Context, agentID string, now time.Time) ([]byte, error) { + cert, err := s.byoc.FindLatestValidByAgentID(ctx, agentID) + if err != nil { + return nil, fmt.Errorf("attestation: load server cert: %w", err) + } + if cert == nil || !cert.IsValid(now) { + return nil, errors.New("attestation: no valid server certificate for agent") + } + h, err := spkiSHA256FromPEM(cert.LeafCertificatePEM) + if err != nil { + return nil, fmt.Errorf("attestation: server SPKI: %w", err) + } + return h, nil +} + +// dnsSection assembles the AttestationDNS sub-struct. +// +// VerifiedAt uses the registration's effective timestamp (last +// renewal, or registration if never renewed) — the moment the RA +// last confirmed DNS records were in place. Real-time DNS +// re-verification at attestation-fetch time is out of scope for +// this PR; a follow-up can persist the verifier's per-record results +// and use the actual witness timestamp. +// +// TLSARecords carries the wire-format TLSA record the agent is +// expected to publish for its server cert: usage=3 (DANE-EE), +// selector=1 (SPKI), matching=1 (SHA-256). The wire RDATA is the +// 3-byte header followed by the 32-byte SHA-256 hash — 35 bytes +// total. We synthesize it locally from the server cert SPKI we +// already computed; a verifier comparing it against a live DNS +// query (with DNSSEC) will find equality on a correctly-configured +// zone. +// +// DNSSECValidated stays false until the DNS verification result is +// persisted on the registration record (today it only lands in the +// outbox event payload). A future migration on RegistrationDetails +// will carry it through. +func (s *AttestationService) dnsSection(reg *domain.AgentRegistration, serverSPKI []byte) domain.AttestationDNS { + tlsa := append([]byte{0x03, 0x01, 0x01}, serverSPKI...) + return domain.AttestationDNS{ + VerifiedAt: reg.Details.EffectiveTimestamp().Unix(), + TLSARecords: [][]byte{tlsa}, + DNSSECValidated: false, + } +} + +// spkiSHA256FromPEM extracts SHA-256(SubjectPublicKeyInfo) from a +// PEM-encoded X.509 certificate. The RawSubjectPublicKeyInfo field +// gives us the DER bytes exactly as they appear in the certificate, +// matching DANE-EE matching-type-1 (RFC 6698 §2.1.2). +func spkiSHA256FromPEM(certPEM string) ([]byte, error) { + block, _ := pem.Decode([]byte(certPEM)) + if block == nil { + return nil, errors.New("no PEM block") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse certificate: %w", err) + } + h := sha256.Sum256(cert.RawSubjectPublicKeyInfo) + return h[:], nil +} + +// COSE header label constants — duplicated from internal/tl/receipt +// rather than imported so the attestation service doesn't depend on +// the receipt package. Label values are IANA-pinned (RFC 8152 + +// RFC 8392); they don't drift. +const ( + labelAlg = 1 + labelKID = 4 + labelCWTClaims = 15 + algES256 = -7 + cwtIss = 1 + cwtIat = 6 +) diff --git a/internal/ra/service/attestation_test.go b/internal/ra/service/attestation_test.go new file mode 100644 index 0000000..637fd10 --- /dev/null +++ b/internal/ra/service/attestation_test.go @@ -0,0 +1,417 @@ +package service_test + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "math/big" + "testing" + "time" + + "github.com/fxamacker/cbor/v2" + + "github.com/godaddy/ans/internal/adapter/tlclient" + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/port" + "github.com/godaddy/ans/internal/ra/service" +) + +// --- fakes --- + +type fakeAgentStore struct { + reg *domain.AgentRegistration + err error +} + +func (f *fakeAgentStore) FindByAgentID(_ context.Context, _ string) (*domain.AgentRegistration, error) { + return f.reg, f.err +} +func (f *fakeAgentStore) Save(context.Context, *domain.AgentRegistration) error { return nil } +func (f *fakeAgentStore) FindByID(context.Context, int64) (*domain.AgentRegistration, error) { + return nil, nil +} +func (f *fakeAgentStore) FindByAnsName(context.Context, domain.AnsName) (*domain.AgentRegistration, error) { + return nil, nil +} +func (f *fakeAgentStore) ExistsByAnsName(context.Context, domain.AnsName) (bool, error) { + return false, nil +} +func (f *fakeAgentStore) FindAllByAgentHost(context.Context, string) ([]*domain.AgentRegistration, error) { + return nil, nil +} +func (f *fakeAgentStore) FindExistingByFQDN(context.Context, string) ([]*domain.AgentRegistration, error) { + return nil, nil +} +func (f *fakeAgentStore) ListByOwner(_ context.Context, _ string, _ port.ListFilter) (*port.CursorPage[*domain.AgentRegistration], error) { + return nil, nil +} +func (f *fakeAgentStore) Delete(context.Context, int64) error { return nil } + +type fakeCertStore struct { + identity []*domain.StoredCertificate + err error +} + +func (f *fakeCertStore) FindIdentityCertificatesByAgent(_ context.Context, _ string) ([]*domain.StoredCertificate, error) { + return f.identity, f.err +} +func (f *fakeCertStore) SaveIdentityCertificate(context.Context, string, *domain.StoredCertificate) error { + return nil +} +func (f *fakeCertStore) UpdateCertificateStatus(context.Context, *domain.StoredCertificate) error { + return nil +} +func (f *fakeCertStore) SaveCSR(context.Context, string, *domain.AgentCSR) error { return nil } +func (f *fakeCertStore) FindCSRByID(context.Context, string, string) (*domain.AgentCSR, error) { + return nil, nil +} +func (f *fakeCertStore) FindLatestPendingCSRByType(context.Context, string, domain.CSRType) (*domain.AgentCSR, error) { + return nil, nil +} + +type fakeByocStore struct { + cert *domain.ByocServerCertificate + err error +} + +func (f *fakeByocStore) FindLatestValidByAgentID(_ context.Context, _ string) (*domain.ByocServerCertificate, error) { + return f.cert, f.err +} +func (f *fakeByocStore) Save(context.Context, string, *domain.ByocServerCertificate) error { + return nil +} +func (f *fakeByocStore) FindByAgentID(context.Context, string) ([]*domain.ByocServerCertificate, error) { + return nil, nil +} + +type fakeTLClient struct { + receipt []byte + proof *tlclient.MerkleProof + err error +} + +func (f *fakeTLClient) GetReceipt(_ context.Context, _ string) ([]byte, *tlclient.MerkleProof, error) { + return f.receipt, f.proof, f.err +} + +type stubSigner struct{ sig []byte } + +func (s *stubSigner) Sign(_ context.Context, _ []byte) ([]byte, error) { + if s.sig != nil { + return s.sig, nil + } + return make([]byte, 64), nil +} + +// --- helpers --- + +// mintTestCert produces a real self-signed X.509 cert (PEM) so the +// SPKI-hash path is exercised end-to-end. Subject DN doesn't matter +// — only the SPKI does. +func mintTestCert(t *testing.T) (string, time.Time) { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("genkey: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "agent.example.com"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv) + if err != nil { + t.Fatalf("CreateCertificate: %v", err) + } + pemBlock := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + return string(pemBlock), tmpl.NotAfter +} + +func validReg(t *testing.T) *domain.AgentRegistration { + t.Helper() + v, err := domain.ParseSemVer("1.0.0") + if err != nil { + t.Fatalf("parse semver: %v", err) + } + name, err := domain.NewAnsName(v, "agent.example.com") + if err != nil { + t.Fatalf("new ans name: %v", err) + } + return &domain.AgentRegistration{ + AgentID: "11111111-2222-3333-4444-555555555555", + AnsName: name, + Status: domain.StatusActive, + Details: domain.RegistrationDetails{ + RegistrationTimestamp: time.Unix(1700000000, 0).UTC(), + }, + } +} + +func newServiceForTest(t *testing.T, + agents *fakeAgentStore, + certs *fakeCertStore, + byoc *fakeByocStore, + tl *fakeTLClient, +) *service.AttestationService { + t.Helper() + s, err := service.NewAttestationService(agents, certs, byoc, tl, + service.AttestationServiceConfig{ + Issuer: "https://ra.example.com", + TLLogURL: "https://tl.example.com", + KeyHash: []byte{0xAA, 0xBB, 0xCC, 0xDD}, + Signer: &stubSigner{}, + }) + if err != nil { + t.Fatalf("NewAttestationService: %v", err) + } + return s.WithClock(func() time.Time { return time.Unix(1700001000, 0).UTC() }) +} + +func validProof() *tlclient.MerkleProof { + return &tlclient.MerkleProof{ + TreeSize: 5, + LeafIndex: 2, + LeafHash: bytesPattern(0x11, 32), + RootHash: bytesPattern(0x22, 32), + Path: [][]byte{}, + } +} + +// --- tests --- + +func TestGenerate_HappyPath(t *testing.T) { + t.Parallel() + idPEM, idExp := mintTestCert(t) + srvPEM, _ := mintTestCert(t) + + reg := validReg(t) + agents := &fakeAgentStore{reg: reg} + certs := &fakeCertStore{identity: []*domain.StoredCertificate{{ + CertificatePEM: idPEM, + Status: domain.CertStatusValid, + ExpirationTimestamp: idExp, + }}} + byoc := &fakeByocStore{cert: &domain.ByocServerCertificate{ + LeafCertificatePEM: srvPEM, + ValidFromTimestamp: time.Unix(1700000000, 0).UTC(), + ValidToTimestamp: time.Unix(1800000000, 0).UTC(), + }} + tl := &fakeTLClient{ + receipt: []byte("RECEIPT-BYTES"), + proof: validProof(), + } + + s := newServiceForTest(t, agents, certs, byoc, tl) + out, err := s.Generate(context.Background(), reg.AgentID) + if err != nil { + t.Fatalf("Generate: %v", err) + } + // Decode the COSE_Sign1 envelope and assert: tag 18, payload is + // CBOR, payload's `sub` equals the agent host. + var tag cbor.Tag + if err := cbor.Unmarshal(out, &tag); err != nil { + t.Fatalf("decode cose tag: %v", err) + } + if tag.Number != 18 { + t.Errorf("tag = %d, want 18", tag.Number) + } + arr := tag.Content.([]any) + payloadBytes := arr[2].([]byte) + var payload map[string]any + if err := cbor.Unmarshal(payloadBytes, &payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + if payload["sub"] != "agent.example.com" { + t.Errorf("sub = %v, want agent.example.com", payload["sub"]) + } + if payload["did"] != "did:web:agent.example.com" { + t.Errorf("did = %v, want did:web:agent.example.com", payload["did"]) + } + tlMap := payload["tl"].(map[any]any) + if string(tlMap["receipt"].([]byte)) != "RECEIPT-BYTES" { + t.Errorf("tl.receipt mismatch") + } +} + +func TestGenerate_AgentNotFound(t *testing.T) { + t.Parallel() + s := newServiceForTest(t, + &fakeAgentStore{err: domain.ErrNotFound}, + &fakeCertStore{}, &fakeByocStore{}, &fakeTLClient{}) + _, err := s.Generate(context.Background(), "any") + if !errors.Is(err, service.ErrAttestationAgentNotFound) { + t.Fatalf("err = %v, want ErrAttestationAgentNotFound", err) + } +} + +func TestGenerate_AgentRevoked(t *testing.T) { + t.Parallel() + reg := validReg(t) + reg.Status = domain.StatusRevoked + s := newServiceForTest(t, + &fakeAgentStore{reg: reg}, + &fakeCertStore{}, &fakeByocStore{}, &fakeTLClient{}) + _, err := s.Generate(context.Background(), reg.AgentID) + if !errors.Is(err, service.ErrAttestationAgentRevoked) { + t.Fatalf("err = %v, want ErrAttestationAgentRevoked", err) + } +} + +func TestGenerate_TLLeafUncommitted(t *testing.T) { + t.Parallel() + idPEM, idExp := mintTestCert(t) + srvPEM, _ := mintTestCert(t) + reg := validReg(t) + s := newServiceForTest(t, + &fakeAgentStore{reg: reg}, + &fakeCertStore{identity: []*domain.StoredCertificate{{ + CertificatePEM: idPEM, Status: domain.CertStatusValid, ExpirationTimestamp: idExp, + }}}, + &fakeByocStore{cert: &domain.ByocServerCertificate{ + LeafCertificatePEM: srvPEM, + ValidFromTimestamp: time.Unix(1700000000, 0).UTC(), + ValidToTimestamp: time.Unix(1800000000, 0).UTC(), + }}, + &fakeTLClient{err: tlclient.ErrTLLeafUncommitted}) + _, err := s.Generate(context.Background(), reg.AgentID) + if !errors.Is(err, service.ErrAttestationLeafUncommitted) { + t.Fatalf("err = %v, want ErrAttestationLeafUncommitted", err) + } +} + +func TestGenerate_TLNotReachable(t *testing.T) { + t.Parallel() + idPEM, idExp := mintTestCert(t) + srvPEM, _ := mintTestCert(t) + reg := validReg(t) + s := newServiceForTest(t, + &fakeAgentStore{reg: reg}, + &fakeCertStore{identity: []*domain.StoredCertificate{{ + CertificatePEM: idPEM, Status: domain.CertStatusValid, ExpirationTimestamp: idExp, + }}}, + &fakeByocStore{cert: &domain.ByocServerCertificate{ + LeafCertificatePEM: srvPEM, + ValidFromTimestamp: time.Unix(1700000000, 0).UTC(), + ValidToTimestamp: time.Unix(1800000000, 0).UTC(), + }}, + &fakeTLClient{err: tlclient.ErrTLNotReachable}) + _, err := s.Generate(context.Background(), reg.AgentID) + if !errors.Is(err, service.ErrAttestationTLNotReachable) { + t.Fatalf("err = %v, want ErrAttestationTLNotReachable", err) + } +} + +func TestGenerate_TLReturnsAgentNotFound(t *testing.T) { + t.Parallel() + idPEM, idExp := mintTestCert(t) + srvPEM, _ := mintTestCert(t) + reg := validReg(t) + s := newServiceForTest(t, + &fakeAgentStore{reg: reg}, + &fakeCertStore{identity: []*domain.StoredCertificate{{ + CertificatePEM: idPEM, Status: domain.CertStatusValid, ExpirationTimestamp: idExp, + }}}, + &fakeByocStore{cert: &domain.ByocServerCertificate{ + LeafCertificatePEM: srvPEM, + ValidFromTimestamp: time.Unix(1700000000, 0).UTC(), + ValidToTimestamp: time.Unix(1800000000, 0).UTC(), + }}, + &fakeTLClient{err: tlclient.ErrTLAgentNotFound}) + _, err := s.Generate(context.Background(), reg.AgentID) + if !errors.Is(err, service.ErrAttestationAgentNotFound) { + t.Fatalf("err = %v, want ErrAttestationAgentNotFound", err) + } +} + +func TestGenerate_NoIdentityCert(t *testing.T) { + t.Parallel() + reg := validReg(t) + s := newServiceForTest(t, + &fakeAgentStore{reg: reg}, + &fakeCertStore{identity: nil}, // empty + &fakeByocStore{}, &fakeTLClient{}) + _, err := s.Generate(context.Background(), reg.AgentID) + if err == nil { + t.Fatal("want error when no identity cert exists") + } +} + +func TestGenerate_NoServerCert(t *testing.T) { + t.Parallel() + idPEM, idExp := mintTestCert(t) + reg := validReg(t) + s := newServiceForTest(t, + &fakeAgentStore{reg: reg}, + &fakeCertStore{identity: []*domain.StoredCertificate{{ + CertificatePEM: idPEM, Status: domain.CertStatusValid, ExpirationTimestamp: idExp, + }}}, + &fakeByocStore{cert: nil}, + &fakeTLClient{}) + _, err := s.Generate(context.Background(), reg.AgentID) + if err == nil { + t.Fatal("want error when no server cert exists") + } +} + +func TestNewAttestationService_Validation(t *testing.T) { + t.Parallel() + good := service.AttestationServiceConfig{ + Issuer: "https://ra.example.com", + TLLogURL: "https://tl.example.com", + KeyHash: []byte{1, 2, 3, 4}, + Signer: &stubSigner{}, + } + a, c, b, tl := &fakeAgentStore{}, &fakeCertStore{}, &fakeByocStore{}, &fakeTLClient{} + + cases := map[string]func(*service.AttestationServiceConfig){ + "missing signer": func(c *service.AttestationServiceConfig) { c.Signer = nil }, + "missing issuer": func(c *service.AttestationServiceConfig) { c.Issuer = "" }, + "missing log url": func(c *service.AttestationServiceConfig) { c.TLLogURL = "" }, + "wrong key hash": func(c *service.AttestationServiceConfig) { c.KeyHash = []byte{1, 2, 3} }, + "nil key hash": func(c *service.AttestationServiceConfig) { c.KeyHash = nil }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + cfg := good + mutate(&cfg) + if _, err := service.NewAttestationService(a, c, b, tl, cfg); err == nil { + t.Errorf("want error for %s", name) + } + }) + } + + // Nil deps + if _, err := service.NewAttestationService(nil, c, b, tl, good); err == nil { + t.Error("want error for nil agents") + } + if _, err := service.NewAttestationService(a, nil, b, tl, good); err == nil { + t.Error("want error for nil certs") + } + if _, err := service.NewAttestationService(a, c, nil, tl, good); err == nil { + t.Error("want error for nil byoc") + } + if _, err := service.NewAttestationService(a, c, b, nil, good); err == nil { + t.Error("want error for nil tl") + } + + // TTL default + cfg := good + cfg.TTL = 0 + if _, err := service.NewAttestationService(a, c, b, tl, cfg); err != nil { + t.Errorf("default TTL: %v", err) + } +} + +func bytesPattern(b byte, n int) []byte { + out := make([]byte, n) + for i := range out { + out[i] = b + } + return out +} From c0cbc315d4e08b8f946334453625fb40bf828f53 Mon Sep 17 00:00:00 2001 From: Layer8 Date: Tue, 2 Jun 2026 23:28:23 +0200 Subject: [PATCH 6/8] feat(ra/handler): add HTTP handler for GET /v2/ans/agents/{agentId}/attestation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maps service.AttestationService outputs to the spec wire shape: * 200 application/cose — binary COSE_Sign1; Cache-Control max-age=3600 matches the payload's `exp` lifetime so HTTP and crypto expirations decay together. * 404 problem+json AGENT_NOT_FOUND * 410 problem+json AGENT_REVOKED * 503 problem+json TL_LEAF_UNCOMMITTED (with Retry-After: 2) * 503 problem+json TL_NOT_REACHABLE (with Retry-After: 10) * 500 problem+json on any other service error (unexpected paths like a cert-store failure or a key-sign failure) The handler depends on an AttestationGenerator interface rather than on *service.AttestationService directly so the test suite can swap a stub in without spinning up four storage adapters. The production service satisfies the interface trivially. Header-level Retry-After values are deliberately different for the two 503 cases — TL_LEAF_UNCOMMITTED is short (~2s, the next checkpoint tick) and TL_NOT_REACHABLE is longer (10s, transport backoff). Verifiers reading the body's `code` discriminate; the Retry-After is a per-class hint. Signed-off-by: Layer8 --- internal/ra/handler/attestation_v2.go | 113 +++++++++++++++ internal/ra/handler/attestation_v2_test.go | 151 +++++++++++++++++++++ internal/ra/handler/errors.go | 9 +- 3 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 internal/ra/handler/attestation_v2.go create mode 100644 internal/ra/handler/attestation_v2_test.go diff --git a/internal/ra/handler/attestation_v2.go b/internal/ra/handler/attestation_v2.go new file mode 100644 index 0000000..1113fcf --- /dev/null +++ b/internal/ra/handler/attestation_v2.go @@ -0,0 +1,113 @@ +package handler + +import ( + "context" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "github.com/godaddy/ans/internal/ra/service" +) + +// AttestationGenerator is the seam the handler depends on — the +// minimal surface of service.AttestationService a test can stub +// without standing up four storage adapters. +type AttestationGenerator interface { + Generate(ctx context.Context, agentID string) ([]byte, error) +} + +// AttestationHandler serves GET /v2/ans/agents/{agentId}/attestation. +// Anonymous read per spec — the attestation IS the document a third- +// party verifier fetches to verify an agent. +type AttestationHandler struct { + svc AttestationGenerator +} + +// NewAttestationHandler wires the handler to its service. +func NewAttestationHandler(svc AttestationGenerator) *AttestationHandler { + return &AttestationHandler{svc: svc} +} + +// Get implements GET /v2/ans/agents/{agentId}/attestation. +// +// Response shape per spec/api-spec-v2.yaml § /ans/agents/{agentId}/attestation: +// +// - 200 application/cose — binary COSE_Sign1 body. +// Cache-Control: public, max-age=3600. +// - 404 application/problem+json — AGENT_NOT_FOUND. +// - 410 application/problem+json — AGENT_REVOKED. +// - 503 application/problem+json — TL_LEAF_UNCOMMITTED (with Retry-After) +// or TL_NOT_REACHABLE. +// +// Cache-Control max-age matches the COSE payload's `exp` lifetime so +// HTTP intermediaries and the cryptographic expiry decay together — +// a CDN can't serve a stale attestation past its signed expiry. +func (h *AttestationHandler) Get(w http.ResponseWriter, r *http.Request) { + agentID := chi.URLParam(r, "agentId") + if agentID == "" { + WriteError(w, errors.New("agentId path parameter is required")) + return + } + + cose, err := h.svc.Generate(r.Context(), agentID) + if err != nil { + h.writeAttestationError(w, err) + return + } + + w.Header().Set("Content-Type", service.AttestationMediaType) + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Header().Set("Content-Length", strconv.Itoa(len(cose))) + w.WriteHeader(http.StatusOK) + // nosemgrep: go.lang.security.audit.xss.no-direct-write-to-responsewriter.no-direct-write-to-responsewriter + // Binary COSE_Sign1 body (application/cose); not HTML, no user-controlled input. + _, _ = w.Write(cose) //nolint:gosec // G705: binary COSE body, no XSS surface +} + +// writeAttestationError maps service sentinels to the spec's wire +// shape. Kept separate from WriteError because the attestation +// surface has its own status-code vocabulary (410 for revoked, 503 +// with structured codes for TL state) that the generic domain +// error mapper doesn't cover. +func (h *AttestationHandler) writeAttestationError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, service.ErrAttestationAgentNotFound): + writeProblem(w, http.StatusNotFound, "Agent Not Found", "AGENT_NOT_FOUND", + "no registration exists for the given agentId", nil) + case errors.Is(err, service.ErrAttestationAgentRevoked): + writeProblem(w, http.StatusGone, "Agent Revoked", "AGENT_REVOKED", + "the agent's registration has been revoked", nil) + case errors.Is(err, service.ErrAttestationLeafUncommitted): + writeProblem(w, http.StatusServiceUnavailable, + "Transparency Log Inclusion Pending", "TL_LEAF_UNCOMMITTED", + "the registration event has been appended but no signed checkpoint yet covers it; retry after Retry-After", + map[string]string{"Retry-After": "2"}) + case errors.Is(err, service.ErrAttestationTLNotReachable): + writeProblem(w, http.StatusServiceUnavailable, + "Transparency Log Unreachable", "TL_NOT_REACHABLE", + "the configured transparency log is not currently reachable", + map[string]string{"Retry-After": "10"}) + default: + // Fall through to the generic mapper; any other error from + // the service is operational (cert lookup failure, key sign + // failure, etc.) and surfaces as 500. + WriteError(w, err) + } +} + +// writeProblem emits an RFC 7807 problem-details response with +// optional response headers (used for Retry-After on 503s). +func writeProblem(w http.ResponseWriter, status int, title, code, detail string, extraHeaders map[string]string) { + for k, v := range extraHeaders { + w.Header().Set(k, v) + } + WriteJSON(w, status, Problem{ + Type: ProblemTypeBlank, + Title: title, + Status: status, + Code: code, + Detail: detail, + }) +} diff --git a/internal/ra/handler/attestation_v2_test.go b/internal/ra/handler/attestation_v2_test.go new file mode 100644 index 0000000..e0dd90f --- /dev/null +++ b/internal/ra/handler/attestation_v2_test.go @@ -0,0 +1,151 @@ +package handler_test + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/godaddy/ans/internal/ra/handler" + "github.com/godaddy/ans/internal/ra/service" +) + +type stubGen struct { + body []byte + err error +} + +func (s *stubGen) Generate(_ context.Context, _ string) ([]byte, error) { + return s.body, s.err +} + +// newAttRouter wires a chi router that mirrors how cmd/ans-ra/main.go +// will register the attestation route — minus the readOwnership +// middleware (anonymous per spec). +func newAttRouter(svc handler.AttestationGenerator) *chi.Mux { + r := chi.NewRouter() + h := handler.NewAttestationHandler(svc) + r.Get("/v2/ans/agents/{agentId}/attestation", h.Get) + return r +} + +func do(t *testing.T, r *chi.Mux, path string) *http.Response { + t.Helper() + srv := httptest.NewServer(r) + t.Cleanup(srv.Close) + resp, err := http.Get(srv.URL + path) + if err != nil { + t.Fatalf("GET %s: %v", path, err) + } + return resp +} + +func TestAttestation_OK(t *testing.T) { + t.Parallel() + body := []byte{0xD2, 0x84, 0x40, 0xA0, 0x42, 'h', 'i', 0x40} // any binary + resp := do(t, newAttRouter(&stubGen{body: body}), + "/v2/ans/agents/11111111-2222-3333-4444-555555555555/attestation") + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if got := resp.Header.Get("Content-Type"); got != service.AttestationMediaType { + t.Errorf("Content-Type = %q, want %q", got, service.AttestationMediaType) + } + if got := resp.Header.Get("Cache-Control"); got != "public, max-age=3600" { + t.Errorf("Cache-Control = %q", got) + } + if got := resp.Header.Get("Content-Length"); got != strconv.Itoa(len(body)) { + t.Errorf("Content-Length = %q, want %d", got, len(body)) + } + got, _ := io.ReadAll(resp.Body) + if !bytesEqual(got, body) { + t.Errorf("body mismatch") + } +} + +func TestAttestation_404AgentNotFound(t *testing.T) { + t.Parallel() + resp := do(t, newAttRouter(&stubGen{err: service.ErrAttestationAgentNotFound}), + "/v2/ans/agents/00000000-0000-0000-0000-000000000000/attestation") + defer resp.Body.Close() + assertProblem(t, resp, http.StatusNotFound, "AGENT_NOT_FOUND") +} + +func TestAttestation_410AgentRevoked(t *testing.T) { + t.Parallel() + resp := do(t, newAttRouter(&stubGen{err: service.ErrAttestationAgentRevoked}), + "/v2/ans/agents/00000000-0000-0000-0000-000000000000/attestation") + defer resp.Body.Close() + assertProblem(t, resp, http.StatusGone, "AGENT_REVOKED") +} + +func TestAttestation_503LeafUncommitted(t *testing.T) { + t.Parallel() + resp := do(t, newAttRouter(&stubGen{err: service.ErrAttestationLeafUncommitted}), + "/v2/ans/agents/00000000-0000-0000-0000-000000000000/attestation") + defer resp.Body.Close() + assertProblem(t, resp, http.StatusServiceUnavailable, "TL_LEAF_UNCOMMITTED") + if got := resp.Header.Get("Retry-After"); got == "" { + t.Error("Retry-After missing on TL_LEAF_UNCOMMITTED") + } +} + +func TestAttestation_503TLNotReachable(t *testing.T) { + t.Parallel() + resp := do(t, newAttRouter(&stubGen{err: service.ErrAttestationTLNotReachable}), + "/v2/ans/agents/00000000-0000-0000-0000-000000000000/attestation") + defer resp.Body.Close() + assertProblem(t, resp, http.StatusServiceUnavailable, "TL_NOT_REACHABLE") + if got := resp.Header.Get("Retry-After"); got == "" { + t.Error("Retry-After missing on TL_NOT_REACHABLE") + } +} + +func TestAttestation_500OnUnknownError(t *testing.T) { + t.Parallel() + resp := do(t, newAttRouter(&stubGen{err: errors.New("unexpected: hsm offline")}), + "/v2/ans/agents/00000000-0000-0000-0000-000000000000/attestation") + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Errorf("status = %d, want 500", resp.StatusCode) + } +} + +// assertProblem decodes the body as a Problem and checks status + code. +func assertProblem(t *testing.T, resp *http.Response, wantStatus int, wantCode string) { + t.Helper() + if resp.StatusCode != wantStatus { + t.Errorf("status = %d, want %d", resp.StatusCode, wantStatus) + } + if got := resp.Header.Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want application/json", got) + } + var p handler.Problem + _ = json.NewDecoder(resp.Body).Decode(&p) + if p.Code != wantCode { + t.Errorf("code = %q, want %q", p.Code, wantCode) + } + if p.Status != wantStatus { + t.Errorf("body.status = %d, want %d", p.Status, wantStatus) + } +} + +func bytesEqual(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/ra/handler/errors.go b/internal/ra/handler/errors.go index f7fc75e..3c3f9c7 100644 --- a/internal/ra/handler/errors.go +++ b/internal/ra/handler/errors.go @@ -11,6 +11,13 @@ import ( "github.com/godaddy/ans/internal/domain" ) +// ProblemTypeBlank is the RFC 7807 default `type` value indicating +// the consumer should derive the problem type from the `title` and +// `status` fields rather than dereferencing a URI. Used by every +// in-tree Problem response — no Problem currently carries a +// custom type URI. +const ProblemTypeBlank = "about:blank" + // Problem is the RFC 7807 response body for errors. type Problem struct { Type string `json:"type"` @@ -44,7 +51,7 @@ func mapError(err error) Problem { var de *domain.Error if errors.As(err, &de) { return Problem{ - Type: "about:blank", + Type: ProblemTypeBlank, Title: titleForCause(de.Cause), Status: statusForCause(de.Cause), Detail: de.Message, From 017771587d52068a2b9185155a64ca63c45d8599 Mon Sep 17 00:00:00 2001 From: Layer8 Date: Tue, 2 Jun 2026 23:32:02 +0200 Subject: [PATCH 7/8] feat(ra/cmd): wire attestation route into ans-ra MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mounts GET /v2/ans/agents/{agentId}/attestation on the V2 router WITHOUT the readOwnership middleware — anonymous read per spec, since the attestation IS the document a third-party verifier fetches to verify an agent. Owner-scoped management still flows through the sibling GET /v2/ans/agents/{agentId}. The attestation service shares the producer signing key with the outbox path: same cose.Signer, same 4-byte SPKI hash (kid). That's load-bearing — verifiers that have the producer pubkey from the TL's /internal/v1/producer-keys also have what they need to verify the attestation's outer signature, without a second key-distribution channel. Config additions: * Attestation.IssuerURL — externally-reachable RA base URL, written into the attestation iss + CWT issuer claim. Local-dev default falls back to "http://Server.Host:Server.Port". * Attestation.TTL — payload `exp` lifetime (default 1h, matches Cache-Control max-age). * Attestation.TrustScheme — optional TRAIN scheme; omitted from the wire when empty. Signed-off-by: Layer8 --- cmd/ans-ra/main.go | 41 ++++++++++++++++++++++++++++++++++++++ internal/config/config.go | 42 ++++++++++++++++++++++++++++++--------- 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/cmd/ans-ra/main.go b/cmd/ans-ra/main.go index b61a5b7..d17ea60 100644 --- a/cmd/ans-ra/main.go +++ b/cmd/ans-ra/main.go @@ -13,9 +13,11 @@ import ( "errors" "flag" "fmt" + "net" "net/http" "os" "os/signal" + "strconv" "strings" "syscall" "time" @@ -34,6 +36,8 @@ import ( "github.com/godaddy/ans/internal/adapter/store/sqlite" "github.com/godaddy/ans/internal/adapter/tlclient" "github.com/godaddy/ans/internal/config" + anscrypto "github.com/godaddy/ans/internal/crypto" + "github.com/godaddy/ans/internal/crypto/cose" "github.com/godaddy/ans/internal/port" "github.com/godaddy/ans/internal/ra/handler" ramiddleware "github.com/godaddy/ans/internal/ra/middleware" @@ -226,6 +230,43 @@ func run(cfgPath string) error { r.With(writeOwnership).Delete("/v2/ans/agents/{agentId}/certificates/server/renewal", lifeH.CancelServerCertRenewal) r.With(writeOwnership).Post("/v2/ans/agents/{agentId}/certificates/server/renewal/verify-acme", lifeH.VerifyRenewalACME) + // GET /v2/ans/agents/{agentId}/attestation — bundled signed + // attestation, anonymous read (no readOwnership middleware). + // Spec § /ans/agents/{agentId}/attestation: the attestation IS + // the document a third-party verifier fetches, so requiring + // ownership would defeat the purpose. + attClient := tlclient.New(cfg.TLClient.BaseURL, cfg.TLClient.APIKey, cfg.TLClient.Timeout) + attIssuer := cfg.Attestation.IssuerURL + if attIssuer == "" { + // Local-dev default: derive from listen address. Production + // configs MUST set attestation.issuer-url to the public origin + // — verifiers see this value byte-for-byte in the COSE iss. + attIssuer = "http://" + net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port)) + } + attKeyHash, err := anscrypto.SPKIKeyHash4(signerPub) + if err != nil { + return fmt.Errorf("compute signer keyhash: %w", err) + } + attSigner, err := cose.NewKeyManagerSigner(km, signerKeyID) + if err != nil { + return fmt.Errorf("build attestation signer: %w", err) + } + attSvc, err := service.NewAttestationService(agents, certsStore, byoc, attClient, + service.AttestationServiceConfig{ + Issuer: attIssuer, + TLLogURL: cfg.TLClient.PublicBaseURL, + KeyHash: attKeyHash, + Signer: attSigner, + TTL: cfg.Attestation.TTL, + TrustScheme: cfg.Attestation.TrustScheme, + }) + if err != nil { + return fmt.Errorf("build attestation service: %w", err) + } + attH := handler.NewAttestationHandler(attSvc) + r.Get("/v2/ans/agents/{agentId}/attestation", attH.Get) + logger.Info().Str("issuer", attIssuer).Msg("attestation endpoint enabled") + // V1 RA surface — byte-for-byte parity with the reference V1 API // spec. Shares the same RegistrationService as the V2 routes; // only the DTO marshalling + TL-emit schema version differ. See diff --git a/internal/config/config.go b/internal/config/config.go index 4a32a87..6a78519 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -190,15 +190,39 @@ type Log struct { // RAConfig is the full configuration for ans-ra. type RAConfig struct { - Server Server `koanf:"server"` - Auth Auth `koanf:"auth"` - CA CA `koanf:"ca"` - DNS DNS `koanf:"dns"` - Keys Keys `koanf:"keys"` - Store Store `koanf:"store"` - TLClient TLClient `koanf:"tl-client"` - Signer SignerCfg `koanf:"signer"` - Log Log `koanf:"log"` + Server Server `koanf:"server"` + Auth Auth `koanf:"auth"` + CA CA `koanf:"ca"` + DNS DNS `koanf:"dns"` + Keys Keys `koanf:"keys"` + Store Store `koanf:"store"` + TLClient TLClient `koanf:"tl-client"` + Signer SignerCfg `koanf:"signer"` + Attestation AttestationCfg `koanf:"attestation"` + Log Log `koanf:"log"` +} + +// AttestationCfg holds settings for the +// GET /v2/ans/agents/{agentId}/attestation endpoint. +type AttestationCfg struct { + // IssuerURL is the RA's externally-reachable base URL. Written + // into the attestation payload's `iss` field and the COSE + // protected header's CWT issuer claim. When empty, the RA + // derives a default from Server.{Host,Port} ("http://:") + // — fine for local dev, but operators MUST set this in production + // to the publicly-visible URL clients hit. + IssuerURL string `koanf:"issuer-url"` + // TTL is the attestation lifetime stamped onto each `exp` claim. + // Defaults to 1h (matches the Cache-Control: max-age=3600 the + // handler emits). Set lower if your verifier population needs + // tighter revocation propagation; raising it past the HTTP cache + // max-age would let CDNs serve attestations after their crypto + // expiry, which we never want. + TTL time.Duration `koanf:"ttl"` + // TrustScheme is an optional TRAIN trust-scheme DNS name written + // into the attestation payload. Omitted from the wire when empty + // (the only optional top-level field in the payload). + TrustScheme string `koanf:"trust-scheme"` } // SignerCfg names the KeyManager-managed key the RA uses to sign From 8c168f424c67e8e139b6c2bb09d4e8d6b998ed11 Mon Sep 17 00:00:00 2001 From: Layer8 Date: Tue, 2 Jun 2026 23:38:38 +0200 Subject: [PATCH 8/8] test(ra): end-to-end integration script + ans-verify attest subcommand scripts/demo/test-attestation.sh stands up a fresh registration on the V2 lane, waits for the TL receipt to materialize, fetches the bundled attestation, asserts the HTTP shape matches spec (200, application/cose, Cache-Control max-age=3600, CBOR tag-18 marker), and then runs ans-verify attest offline. Every successful run cryptographically proves both signatures: * Outer COSE_Sign1 signature verified against the RA producer pubkey (loaded from disk via -ra-pubkey PEM file). * Embedded SCITT receipt verified against TL /root-keys. * Cross-check: payload.tl.leaf_hash equals RFC 6962 SHA-256(0x00 || receipt-attached-payload). Catches a TL that hands out a valid receipt for the wrong leaf. The new `ans-verify attest` subcommand encapsulates that flow as a reusable CLI artifact, paralleling the existing single-agent receipt-verify form. Subcommand dispatch at the top of main() keeps the original invocation backward-compatible. Supporting auth-adapter change: WithAnonymousPathSuffix / its OIDC twin let us mark parameterized routes (/v2/ans/agents/{id}/attestation) as anonymous without prefix-matching onto owner-scoped siblings. Verified by a regression test asserting the sibling path still requires auth. Signed-off-by: Layer8 --- cmd/ans-ra/main.go | 3 + cmd/ans-verify/attest.go | 317 +++++++++++++++++++++++++++ cmd/ans-verify/main.go | 11 + internal/adapter/auth/oidc.go | 24 +- internal/adapter/auth/static.go | 25 ++- internal/adapter/auth/static_test.go | 36 +++ scripts/demo/test-attestation.sh | 91 ++++++++ 7 files changed, 499 insertions(+), 8 deletions(-) create mode 100644 cmd/ans-verify/attest.go create mode 100755 scripts/demo/test-attestation.sh diff --git a/cmd/ans-ra/main.go b/cmd/ans-ra/main.go index d17ea60..8b64764 100644 --- a/cmd/ans-ra/main.go +++ b/cmd/ans-ra/main.go @@ -413,6 +413,8 @@ func buildAuth(ctx context.Context, cfg *config.RAConfig) (providerWithAnonymous auth.WithAnonymousPath("/v2/admin/health"), auth.WithAnonymousPath("/v2/admin/ready"), auth.WithAnonymousPath("/docs"), + // Per spec, the bundled attestation is anonymous-readable. + auth.WithAnonymousPathSuffix("/attestation"), ), nil case "oidc": return auth.NewOIDCProvider( @@ -423,6 +425,7 @@ func buildAuth(ctx context.Context, cfg *config.RAConfig) (providerWithAnonymous auth.WithOIDCAnonymousPath("/v2/admin/health"), auth.WithOIDCAnonymousPath("/v2/admin/ready"), auth.WithOIDCAnonymousPath("/docs"), + auth.WithOIDCAnonymousPathSuffix("/attestation"), // Empty AdminGroups means no OIDC user is admin — // preserves prior behaviour for operators who haven't // opted in. Spreading nil/empty into a variadic is the diff --git a/cmd/ans-verify/attest.go b/cmd/ans-verify/attest.go new file mode 100644 index 0000000..68ee09a --- /dev/null +++ b/cmd/ans-verify/attest.go @@ -0,0 +1,317 @@ +package main + +import ( + "context" + "crypto/ecdsa" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "errors" + "flag" + "fmt" + "math/big" + "net/http" + "os" + "strings" + "time" + + "github.com/fxamacker/cbor/v2" + + "github.com/godaddy/ans/internal/tl/receipt" +) + +// attestMain implements `ans-verify attest [flags] `. +// +// Flow: +// +// 1. Fetch the bundled attestation from the RA at +// GET /v2/ans/agents/{id}/attestation. +// 2. Verify the outer COSE_Sign1 signature against the RA producer +// public key (loaded from -ra-pubkey PEM file). +// 3. Decode the payload, extract the embedded SCITT receipt. +// 4. Verify the embedded receipt against /root-keys served by the +// TL identified in payload.tl.log_url (or -tl-url override). +// 5. Cross-check: payload.tl.leaf_hash MUST equal +// RFC 6962 SHA-256(0x00 || receipt-attached-payload). Catches +// a TL that hands out a real receipt for a different leaf. +// +// Two independent verifications — RA producer key for the outer, +// TL root key for the inner — mirror the two-key topology spelled +// out in the spec. +func attestMain(args []string) { + fs := flag.NewFlagSet("attest", flag.ExitOnError) + var ( + raURL string + tlURL string + agentID string + raPubKeyPEM string + ) + fs.StringVar(&raURL, "ra-url", "http://localhost:18080", + "Base URL of the Registration Authority") + fs.StringVar(&tlURL, "tl-url", "", + "Base URL of the Transparency Log (default: payload's log_url)") + fs.StringVar(&agentID, "agent", "", + "Agent ID (UUID) to verify") + fs.StringVar(&raPubKeyPEM, "ra-pubkey", "", + "Path to PEM-encoded RA producer public key (required)") + if err := fs.Parse(args); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if agentID == "" { + if fs.NArg() > 0 { + agentID = fs.Arg(0) + } else { + fmt.Fprintln(os.Stderr, "usage: ans-verify attest -ra-pubkey [flags] ") + fs.PrintDefaults() + os.Exit(1) + } + } + if raPubKeyPEM == "" { + fatalf("-ra-pubkey is required (PEM file with the RA producer public key)") + } + raURL = strings.TrimRight(raURL, "/") + + fmt.Println("=== ANS Attestation Verifier ===") + fmt.Printf("RA URL: %s\n", raURL) + fmt.Printf("Agent ID: %s\n\n", agentID) + + // --- Step 1: Load RA producer pubkey --- + fmt.Println("── Step 1: Load RA producer public key ──") + raPub, err := loadPublicKeyFromFile(raPubKeyPEM) + if err != nil { + fatalf("load %s: %v", raPubKeyPEM, err) + } + fmt.Printf(" ✓ Loaded RA producer key from %s\n\n", raPubKeyPEM) + + // --- Step 2: Fetch attestation --- + fmt.Println("── Step 2: Fetch attestation ──") + attBytes, ct, err := fetchBinary(context.Background(), + raURL+"/v2/ans/agents/"+agentID+"/attestation") + if err != nil { + fatalf("fetch attestation: %v", err) + } + fmt.Printf(" ✓ %d bytes (Content-Type: %s)\n", len(attBytes), ct) + if len(attBytes) > 0 && attBytes[0] != 0xd2 { + fmt.Printf(" ⚠ First byte 0x%02x (want 0xd2 for CBOR tag 18)\n", attBytes[0]) + } + fmt.Println() + + // --- Step 3: Verify outer signature against RA producer key --- + fmt.Println("── Step 3: Verify outer attestation signature ──") + payloadBytes, err := verifyAttestationSignature(attBytes, raPub) + if err != nil { + fatalf("outer verify: %v", err) + } + fmt.Printf(" ✓ VERIFIED (RA producer key)\n\n") + + // --- Step 4: Decode payload --- + fmt.Println("── Step 4: Decode attestation payload ──") + payload, err := decodeAttestationPayload(payloadBytes) + if err != nil { + fatalf("decode payload: %v", err) + } + fmt.Printf(" iss: %s\n", payload.Issuer) + fmt.Printf(" sub: %s\n", payload.Subject) + fmt.Printf(" did: %s\n", payload.DID) + fmt.Printf(" iat: %s\n", time.Unix(payload.IssuedAt, 0).UTC().Format(time.RFC3339)) + fmt.Printf(" exp: %s\n", time.Unix(payload.ExpiresAt, 0).UTC().Format(time.RFC3339)) + fmt.Printf(" id-spki: %s\n", hex.EncodeToString(payload.IDSPKI)) + fmt.Printf(" srv-spki: %s\n", hex.EncodeToString(payload.ServerSPKI)) + fmt.Printf(" tl.log: %s\n", payload.TLLogURL) + fmt.Printf(" tl.size: %d\n", payload.TLTreeSize) + fmt.Printf(" tl.leaf: %s\n", hex.EncodeToString(payload.TLLeafHash)) + fmt.Printf(" tl.recpt: %d bytes\n\n", len(payload.TLReceipt)) + + // --- Step 5: Fetch TL root keys --- + if tlURL == "" { + tlURL = payload.TLLogURL + } + tlURL = strings.TrimRight(tlURL, "/") + fmt.Println("── Step 5: Load TL verifier keys ──") + tlKeys, _, err := fetchRootKeys(tlURL) + if err != nil { + fatalf("fetch /root-keys from %s: %v", tlURL, err) + } + fmt.Printf(" ✓ Loaded %d TL verifier key(s) from %s/root-keys\n\n", len(tlKeys), tlURL) + + // --- Step 6: Verify embedded receipt --- + fmt.Println("── Step 6: Verify embedded SCITT receipt ──") + var lastErr error + verified := false + for i, k := range tlKeys { + err := receipt.Verify(payload.TLReceipt, k) + if err == nil { + fmt.Printf(" ✓ VERIFIED (TL key %d/%d)\n", i+1, len(tlKeys)) + verified = true + break + } + lastErr = err + } + if !verified { + fatalf("no TL key verified the embedded receipt (last err: %v)", lastErr) + } + fmt.Println() + + // --- Step 7: Cross-check leaf hash --- + fmt.Println("── Step 7: Cross-check leaf hash ──") + recPayload, err := receipt.ExtractPayload(payload.TLReceipt) + if err != nil { + fatalf("extract receipt payload: %v", err) + } + derivedLeaf := receipt.ComputeLeafHash(recPayload) + if !equalBytes(derivedLeaf, payload.TLLeafHash) { + fatalf("leaf-hash mismatch — attestation claims %s, receipt payload hashes to %s", + hex.EncodeToString(payload.TLLeafHash), hex.EncodeToString(derivedLeaf)) + } + fmt.Printf(" ✓ payload.tl.leaf_hash == SHA-256(0x00 || receipt.payload)\n\n") + fmt.Println("=== ATTESTATION VERIFIED ===") +} + +// attestationPayload is the decoded shape we need from the CBOR +// payload. Keys are string-keyed per spec/api-spec-v2.yaml. Fields +// are intentionally a subset — we only decode what's load-bearing +// for verification. +type attestationPayload struct { + Issuer string + Subject string + DID string + IssuedAt int64 + ExpiresAt int64 + IDSPKI []byte + ServerSPKI []byte + TLLogURL string + TLLeafHash []byte + TLTreeSize uint64 + TLReceipt []byte +} + +func decodeAttestationPayload(b []byte) (*attestationPayload, error) { + var raw map[string]any + if err := cbor.Unmarshal(b, &raw); err != nil { + return nil, err + } + p := &attestationPayload{} + if v, ok := raw["iss"].(string); ok { + p.Issuer = v + } + if v, ok := raw["sub"].(string); ok { + p.Subject = v + } + if v, ok := raw["did"].(string); ok { + p.DID = v + } + p.IssuedAt = toInt64(raw["iat"]) + p.ExpiresAt = toInt64(raw["exp"]) + if v, ok := raw["identity_cert_spki_sha256"].([]byte); ok { + p.IDSPKI = v + } + if v, ok := raw["server_cert_spki_sha256"].([]byte); ok { + p.ServerSPKI = v + } + tlAny, ok := raw["tl"] + if !ok { + return nil, errors.New("payload missing tl map") + } + tlMap, ok := tlAny.(map[any]any) + if !ok { + return nil, errors.New("payload.tl is not a map") + } + if v, ok := tlMap["log_url"].(string); ok { + p.TLLogURL = v + } + if v, ok := tlMap["leaf_hash"].([]byte); ok { + p.TLLeafHash = v + } + p.TLTreeSize = uint64(toInt64(tlMap["tree_size"])) //nolint:gosec // tree size is non-negative + if v, ok := tlMap["receipt"].([]byte); ok { + p.TLReceipt = v + } + return p, nil +} + +func toInt64(v any) int64 { + switch n := v.(type) { + case uint64: + return int64(n) //nolint:gosec // CBOR uint64 range for timestamps/sizes + case int64: + return n + case int: + return int64(n) + default: + return 0 + } +} + +// verifyAttestationSignature parses the outer COSE_Sign1, rebuilds +// the Sig_structure exactly as the signer would have built it, and +// verifies the ECDSA P-256 signature against the RA producer key. +// Returns the attached payload bytes on success. +func verifyAttestationSignature(coseBytes []byte, pub *ecdsa.PublicKey) ([]byte, error) { + var tag cbor.Tag + if err := cbor.Unmarshal(coseBytes, &tag); err != nil { + return nil, fmt.Errorf("decode cose tag: %w", err) + } + if tag.Number != 18 { + return nil, fmt.Errorf("not COSE_Sign1: tag = %d", tag.Number) + } + arr, ok := tag.Content.([]any) + if !ok || len(arr) != 4 { + return nil, errors.New("cose: top-level not a 4-element array") + } + protectedBytes, ok := arr[0].([]byte) + if !ok { + return nil, errors.New("cose: protected header not bytes") + } + payload, ok := arr[2].([]byte) + if !ok { + return nil, errors.New("cose: payload not bytes") + } + sig, ok := arr[3].([]byte) + if !ok { + return nil, errors.New("cose: signature not bytes") + } + em, err := cbor.CoreDetEncOptions().EncMode() + if err != nil { + return nil, err + } + sigStructure := []any{ + "Signature1", + protectedBytes, + []byte{}, // external_aad + payload, + } + sigStructureBytes, err := em.Marshal(sigStructure) + if err != nil { + return nil, fmt.Errorf("encode sig_structure: %w", err) + } + digest := sha256.Sum256(sigStructureBytes) + if len(sig) != 64 { + return nil, fmt.Errorf("signature length %d, want 64 (P1363 P-256)", len(sig)) + } + r := new(big.Int).SetBytes(sig[:32]) + s := new(big.Int).SetBytes(sig[32:]) + if !ecdsa.Verify(pub, digest[:], r, s) { + return nil, errors.New("ecdsa.Verify returned false") + } + return payload, nil +} + +// fileChecker — silences lint complaints about unused symbols imported +// only for documentation purposes elsewhere in the binary. +var _ = http.MethodGet +var _ = pem.Decode +var _ = x509.MarshalPKIXPublicKey + +func equalBytes(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/cmd/ans-verify/main.go b/cmd/ans-verify/main.go index 6169487..ae2864b 100644 --- a/cmd/ans-verify/main.go +++ b/cmd/ans-verify/main.go @@ -50,6 +50,17 @@ import ( ) func main() { + // Subcommand dispatch: + // ans-verify attest [flags] — verify the bundled + // attestation from GET /v2/ans/agents/{id}/attestation against + // the RA's producer pubkey AND the embedded SCITT receipt + // against the TL's /root-keys. + // Otherwise the original single-agent receipt-verify flow runs. + if len(os.Args) > 1 && os.Args[1] == "attest" { + attestMain(os.Args[2:]) + return + } + var ( baseURL string agentID string diff --git a/internal/adapter/auth/oidc.go b/internal/adapter/auth/oidc.go index 280a57d..101df31 100644 --- a/internal/adapter/auth/oidc.go +++ b/internal/adapter/auth/oidc.go @@ -26,10 +26,11 @@ import ( // // Tokens are not cached; go-oidc caches the JWKS internally. type OIDCProvider struct { - verifier *oidc.IDTokenVerifier - expectedAud string - anonymousPaths []string - adminGroups []string // groups granting admin privileges (optional) + verifier *oidc.IDTokenVerifier + expectedAud string + anonymousPaths []string + anonymousSuffixes []string + adminGroups []string // groups granting admin privileges (optional) } // OIDCOption configures an OIDCProvider. @@ -40,6 +41,16 @@ func WithOIDCAnonymousPath(prefix string) OIDCOption { return func(p *OIDCProvider) { p.anonymousPaths = append(p.anonymousPaths, prefix) } } +// WithOIDCAnonymousPathSuffix makes every URL-path ending with +// `suffix` unauthenticated. Mirrors WithAnonymousPathSuffix on the +// static provider — needed for parameterized anonymous routes like +// /v2/ans/agents/{agentId}/attestation. +func WithOIDCAnonymousPathSuffix(suffix string) OIDCOption { + return func(p *OIDCProvider) { + p.anonymousSuffixes = append(p.anonymousSuffixes, suffix) + } +} + // WithAdminGroups lists group values that should be treated as admin. // If empty, tokens are never admin (safe default). Matches against the // "groups" or "roles" claim. @@ -134,6 +145,11 @@ func (p *OIDCProvider) isAnonymousPath(path string) bool { return true } } + for _, suffix := range p.anonymousSuffixes { + if strings.HasSuffix(path, suffix) { + return true + } + } return false } diff --git a/internal/adapter/auth/static.go b/internal/adapter/auth/static.go index 3fb2824..8ae3f78 100644 --- a/internal/adapter/auth/static.go +++ b/internal/adapter/auth/static.go @@ -47,10 +47,11 @@ func WithIdentity(ctx context.Context, id *port.Identity) context.Context { // // Do not use StaticProvider in production. Use OIDCProvider instead. type StaticProvider struct { - apiKey string - apiSecret string // optional; enables the sso-key format when set - anonymousPaths []string // paths under which auth is not required - subject string // synthetic subject reported on success + apiKey string + apiSecret string // optional; enables the sso-key format when set + anonymousPaths []string // path prefixes under which auth is not required + anonymousSuffixes []string // path suffixes under which auth is not required + subject string // synthetic subject reported on success } // StaticOption configures a StaticProvider. @@ -65,6 +66,17 @@ func WithAnonymousPath(prefix string) StaticOption { } } +// WithAnonymousPathSuffix marks every URL-path ending with `suffix` +// as unauthenticated. Use when the anonymous path is parameterized +// (e.g. /v2/ans/agents/{agentId}/attestation) and a prefix match +// would over-grant — anonymous read on the attestation endpoint +// must not bleed into the owner-scoped sibling routes. +func WithAnonymousPathSuffix(suffix string) StaticOption { + return func(p *StaticProvider) { + p.anonymousSuffixes = append(p.anonymousSuffixes, suffix) + } +} + // WithStaticSubject sets the subject reported for authenticated requests. // Defaults to "static-user". func WithStaticSubject(s string) StaticOption { @@ -221,6 +233,11 @@ func (p *StaticProvider) isAnonymousPath(path string) bool { return true } } + for _, suffix := range p.anonymousSuffixes { + if strings.HasSuffix(path, suffix) { + return true + } + } return false } diff --git a/internal/adapter/auth/static_test.go b/internal/adapter/auth/static_test.go index 7e47225..cdc739f 100644 --- a/internal/adapter/auth/static_test.go +++ b/internal/adapter/auth/static_test.go @@ -216,6 +216,42 @@ func TestMiddleware_Integration(t *testing.T) { } } +// TestMiddleware_AnonymousPathSuffix bypasses auth for paths whose +// suffix matches — used by the attestation route, which is +// parameterized (/v2/ans/agents/{id}/attestation) so a prefix match +// would over-grant onto the owner-scoped siblings. +func TestMiddleware_AnonymousPathSuffix(t *testing.T) { + t.Parallel() + p := auth.NewStaticProvider("my-api-key", + auth.WithAnonymousPathSuffix("/attestation"), + ) + var ran bool + h := p.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + ran = true + w.WriteHeader(http.StatusOK) + })) + + // Attestation path matches suffix — should pass through with no auth. + req := httptest.NewRequest(http.MethodGet, + "/v2/ans/agents/11111111-2222-3333-4444-555555555555/attestation", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !ran { + t.Errorf("attestation path: status=%d, ran=%v; want 200/true", rec.Code, ran) + } + + // Sibling owner-scoped path must NOT match the /attestation suffix + // and must require auth. + ran = false + req = httptest.NewRequest(http.MethodGet, + "/v2/ans/agents/11111111-2222-3333-4444-555555555555", nil) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code == http.StatusOK || ran { + t.Errorf("sibling path leaked: status=%d, ran=%v; want non-200/false", rec.Code, ran) + } +} + // TestMiddleware_AnonymousPath bypasses auth for configured prefixes. func TestMiddleware_AnonymousPath(t *testing.T) { t.Parallel() diff --git a/scripts/demo/test-attestation.sh b/scripts/demo/test-attestation.sh new file mode 100755 index 0000000..abc5bc7 --- /dev/null +++ b/scripts/demo/test-attestation.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Integration test for GET /v2/ans/agents/{agentId}/attestation. +# +# Registers a fresh agent on the V2 lane, polls for the registration +# event to land in the TL (so the receipt is available), fetches the +# bundled attestation, and runs `ans-verify attest` to verify the +# outer signature (RA producer key) AND the embedded SCITT receipt +# (TL root key) AND the leaf-hash cross-check. +# +# Prerequisites: scripts/demo/start.sh has been run. +# +# Usage: +# scripts/demo/test-attestation.sh # auto-pick host +# scripts/demo/test-attestation.sh my.example.com # specific host + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +HOST="${1:-attest-it-$(openssl rand -hex 4).example.com}" + +# ----- preflight ----- +header "Preflight" +if ! curl -sSf "$RA_URL/v2/admin/ready" >/dev/null 2>&1; then + fail "ans-ra is not reachable at $RA_URL — run scripts/demo/start.sh first" +fi +if [ ! -x "$ROOT/bin/ans-verify" ]; then + fail "ans-verify not found at $ROOT/bin/ans-verify — run scripts/demo/start.sh to build" +fi +RA_PUBKEY="$ROOT/data/demo/ra/keys/ans-ra-signer.pub" +if [ ! -f "$RA_PUBKEY" ]; then + fail "RA producer pubkey not found at $RA_PUBKEY — has ans-ra started successfully?" +fi +ok "RA + verifier binary + producer pubkey present" + +# ----- register ----- +header "Register agent on V2 lane" +AGENT_ID=$("$SCRIPT_DIR/register.sh" --v2 "$HOST" 2>&1 | tail -1) +if ! [[ "$AGENT_ID" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then + fail "registration did not return a UUID: $AGENT_ID" +fi +ok "agent registered: $AGENT_ID" + +# ----- wait for TL receipt to be available ----- +header "Wait for TL receipt" +deadline=$(($(date +%s) + 30)) +while [ "$(date +%s)" -lt "$deadline" ]; do + status=$(curl -sS -o /dev/null -w "%{http_code}" \ + "$TL_URL/v1/agents/$AGENT_ID/receipt") || true + if [ "$status" = "200" ]; then + ok "receipt available (HTTP 200)" + break + fi + sleep 1 +done +if [ "$status" != "200" ]; then + fail "TL receipt did not become available within 30s (last status: $status)" +fi + +# ----- fetch attestation + inspect HTTP shape ----- +header "Fetch attestation" +ATTEST_FILE=$(mktemp -t ans-attest.XXXXXX) +trap 'rm -f "$ATTEST_FILE"' EXIT +http_summary=$(curl -sS -o "$ATTEST_FILE" \ + -w "HTTP=%{http_code} CT=%{content_type} CC=%header{cache-control} LEN=%{size_download}" \ + "$RA_URL/v2/ans/agents/$AGENT_ID/attestation") +echo " $http_summary" +case "$http_summary" in + *"HTTP=200"*"CT=application/cose"*"CC=public, max-age=3600"*) + ok "HTTP shape matches spec (200, application/cose, max-age=3600)" + ;; + *) fail "HTTP shape mismatch: $http_summary" ;; +esac +# Sanity: CBOR tag-18 marker is the first byte. +first_byte=$(xxd -p -l 1 "$ATTEST_FILE") +if [ "$first_byte" != "d2" ]; then + fail "first byte $first_byte, want d2 (CBOR tag 18)" +fi +ok "CBOR tag 18 marker present" + +# ----- verify offline with ans-verify ----- +header "Verify attestation with ans-verify" +"$ROOT/bin/ans-verify" attest \ + -ra-url "$RA_URL" \ + -tl-url "$TL_URL" \ + -ra-pubkey "$RA_PUBKEY" \ + "$AGENT_ID" + +ok "attestation verified end-to-end"