feat(ra): bundled signed agent attestation — GET /v2/ans/agents/{agentId}/attestation - #40
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. |
|
The payload has no lifecycle state, and the embedded TL receipt establishes inclusion of the registration leaf. It cannot exclude a later revocation event. That window matters most for key compromise, which Was the one-hour revocation delay intentional? If the COSE document should stay valid for an hour while shared caches refresh more often, a shorter One related question: the attestation route defines 410 for |
csnitker-godaddy
left a comment
There was a problem hiding this comment.
Thanks for this, and sorry for the slow reply.
My main concern is the anonymous read on the RA. The RA is the management interface, so every agent route is owner-scoped on purpose, and the public read side is the TL. This route would be the first exception to that and I don't think we want to open it up for that purpose.
On the three reads in your description, I don't think that's the path today. Agent metadata from the RA is owner-scoped, so an external verifier can't read it. And the producer key isn't something an external verifier needs at all: the TL verifies the producer signature at ingest and rejects anything it can't attribute to a key in its trust store, so its signature already stands behind that check. /internal/v1/producer-keys is the admin route for managing that store, not a distribution point.
So it's already a single read on the TL side, and the only key involved is the TL's. ans-verify covers that today:
ans-verify -url https://tl.example.com -agent <agentId>
Is there something in the marketplace or gateway cases that this doesn't cover? If there is, I'd rather fix it there than on the RA.
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.