feat(ra): bundled signed agent attestation — GET /v2/ans/agents/{agentId}/attestation#40
feat(ra): bundled signed agent attestation — GET /v2/ans/agents/{agentId}/attestation#40nicknacnic wants to merge 8 commits into
Conversation
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 <NWillAU900@gmail.com>
…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 <NWillAU900@gmail.com>
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 <NWillAU900@gmail.com>
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 <NWillAU900@gmail.com>
…agentId}/attestation
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 <NWillAU900@gmail.com>
…ttestation
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 <NWillAU900@gmail.com>
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 <NWillAU900@gmail.com>
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 <NWillAU900@gmail.com>
|
Some related design context — last week I wrote up the layering question this PR sits inside of: how DNS + ANS + W3C VCs / OID4VC compose into an agent-identity substrate without standing up a new authority. The piece argues, with worked examples, that the substrate-agnostic DNS path (DNS-AID flat owner + TLSA + Agent Identity Without a New Authority (2026-06-03) Why it's relevant to this PR specifically:
Not asking for any of this to be in scope for the present PR — the attestation route is the right unit to ship first. Flagging it because the design context informed the envelope shape and might inform a future-work section in the spec. Happy to walk through any of it during review. |
Summary
Adds
GET /v2/ans/agents/{agentId}/attestation— an anonymous-read RA endpoint that returns a single COSE_Sign1 (RFC 9052, CBOR tag 18) bundling an agent's current registration state with its transparency-log inclusion proof. A third-party verifier fetches one document, validates two signatures (RA producer key + TL checkpoint key), and has cryptographic certainty of both the agent's identity and its TL inclusion — with no second key-distribution channel.Use case: an external verifier (a marketplace, an API gateway, a DNS-AID-aware client resolving a flat-FQDN agent) wants to confirm an agent is genuinely registered with this RA and that the registration is sealed in the TL. Today they need to compose three reads: agent metadata from the RA, a producer key from the TL's
/internal/v1/producer-keys, and the receipt from the TL. This endpoint collapses that to one fetch with the binding baked in.What ships
The envelope. A new
AttestationPayloadvalue type with byte-pinned CBOR tags:subis the agent's flat-FQDN — drops directly onto an x.509 SAN dNSName and onto a draft-mozleywilliams-dnsop-dnsaid-02 flat owner record without translation.didis the losslessdid:web:alias ofsub; derived server-side so callers can't ship an inconsistent (sub, did) pair.dns.tlsa_recordsis the TLSA bundle the RA's DNS verifier captured at registration / latest renewal, so a verifier can compare against what it independently resolves.tl.receiptis the existing TL-signed SCITT COSE_Sign1 receipt, embedded verbatim.Two-key verification topology. The outer COSE_Sign1 is signed by the RA's producer key — the same ECDSA P-256 key that signs every event going to the TL via the outbox, already listed in the TL's
producerKeys[]trust store. The embeddedtl.receiptis signed by the TL's checkpoint key advertised at/root-keys. The shared producer key is load-bearing: a verifier holding it can validate the outer envelope without an extra key fetch.This mirrors the OmniOne Open DID TAS pattern — a Trust Agent Service issues membership credentials, anchored in a Trust Repository — with ANS playing the TAS role and the TL playing the Trust Repository role.
Wire-format consolidation. Extracts the COSE_Sign1 assembly into a new
internal/crypto/cosepackage so the byte-layout invariant lives in exactly one place across the three call sites (SCITT receipts, status tokens, agent attestations). Deterministic CBOR per RFC 8949 §4.2; ECDSA P-256 in IEEE P1363 form; CBOR tag 18.Error model. RFC 7807 problem+json on every non-success path, distinct codes for the two 503 cases (
TL_LEAF_UNCOMMITTEDretries fast,TL_NOT_REACHABLEretries slow) so operators can tell a transient TL race from infrastructure trouble.CLI. New
ans-verify attestsubcommand encapsulates the verification flow: outer-signature against an RA pubkey PEM, embedded receipt against TL/root-keys, and aleaf_hash == RFC 6962 SHA-256(0x00 || receipt-attached-payload)cross-check that catches a TL handing out a valid receipt for the wrong leaf. Existing single-agent verify form unchanged.End-to-end test.
scripts/demo/test-attestation.shregisters a fresh V2 agent, waits for the TL receipt to materialize, fetches the bundled attestation, asserts the HTTP shape (200,application/cose,Cache-Control: public, max-age=3600, CBOR tag-18 marker), then runsans-verify attestoffline. Every successful run cryptographically proves both signatures.Commits
Eight commits, landing in dependency order:
internal/crypto/cosedomain/AttestationPayloadvalue type with strict validationtlclient.GetReceiptfor pulling the SCITT proofservice/AttestationService(composes domain + crypto + tlclient)ans-ra(anonymous read, shared producer key)ans-verify attestsubcommand + end-to-end integration scriptWhy anonymous read
The attestation IS the document a third-party verifier fetches. Putting it behind
bearerAuthwould mean every verifier needs a credential issued by the RA's identity provider — exactly the trust relationship the COSE envelope is supposed to make unnecessary. The siblingGET /ans/agents/{agentId}remains owner-scoped for operator management. Auth adapter gainsWithAnonymousPathSuffixso parameterized routes can opt out of auth without prefix-matching onto owner-scoped siblings; a regression test confirms the sibling still requires auth.Configuration
Three new config keys under
attestation:Companion work
PR #32 (`feat/ans-verify-list-provider`) adds the by-provider enumeration mode on `ans-verify`. The two compose: enumerate every agent under a provider host, fetch each one's attestation, verify offline. Independent diffs; either order is fine.
The DNS-AID draft-02 reference implementation (dns-aid-core 0.24.0, shipped 2026-06-04) now publishes flat-FQDN owner records that this `sub` field aligns with byte-for-byte. A consumer that resolves a DNS-AID record can fetch the matching attestation here and verify with no naming translation.
Local verification
```
go build ./... # clean
go test ./... # 25 packages, all pass
scripts/demo/start.sh # bring up demo stack
scripts/demo/test-attestation.sh # e2e flow (registers, polls TL, verifies)
```
All commits are DCO-signed.