Skip to content

feat(ra): bundled signed agent attestation — GET /v2/ans/agents/{agentId}/attestation#40

Open
nicknacnic wants to merge 8 commits into
godaddy:mainfrom
nicknacnic:feat/agent-attestation-endpoint
Open

feat(ra): bundled signed agent attestation — GET /v2/ans/agents/{agentId}/attestation#40
nicknacnic wants to merge 8 commits into
godaddy:mainfrom
nicknacnic:feat/agent-attestation-endpoint

Conversation

@nicknacnic

Copy link
Copy Markdown
Contributor

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 AttestationPayload value type with byte-pinned CBOR tags:

iss, sub, did, iat, exp,
identity_cert_spki_sha256, server_cert_spki_sha256,
dns { verified_at, tlsa_records, dnssec_validated },
tl  { log_url, leaf_hash, tree_size, receipt },
trust_scheme (optional, TRAIN)
  • sub is 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.
  • did is the lossless did:web: alias of sub; derived server-side so callers can't ship an inconsistent (sub, did) pair.
  • dns.tlsa_records is the TLSA bundle the RA's DNS verifier captured at registration / latest renewal, so a verifier can compare against what it independently resolves.
  • tl.receipt is 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 embedded tl.receipt is 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/cose package 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_UNCOMMITTED retries fast, TL_NOT_REACHABLE retries slow) so operators can tell a transient TL race from infrastructure trouble.

CLI. New ans-verify attest subcommand encapsulates the verification flow: outer-signature against an RA pubkey PEM, embedded receipt against TL /root-keys, and a leaf_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.sh registers 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 runs ans-verify attest offline. Every successful run cryptographically proves both signatures.

Commits

Eight commits, landing in dependency order:

  1. OpenAPI spec for the route (contract first; downstream code encodes against this)
  2. Extract COSE_Sign1 envelope assembly into internal/crypto/cose
  3. domain/AttestationPayload value type with strict validation
  4. tlclient.GetReceipt for pulling the SCITT proof
  5. service/AttestationService (composes domain + crypto + tlclient)
  6. HTTP handler with the full error model
  7. Wire the route into ans-ra (anonymous read, shared producer key)
  8. ans-verify attest subcommand + end-to-end integration script

Why anonymous read

The attestation IS the document a third-party verifier fetches. Putting it behind bearerAuth would 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 sibling GET /ans/agents/{agentId} remains owner-scoped for operator management. Auth adapter gains WithAnonymousPathSuffix so 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:

Key Purpose Default
`attestation.issuer_url` Externally-reachable RA base URL, written into `iss` + CWT issuer claim falls back to `http://server.host:server.port\` for local dev
`attestation.ttl` Payload `exp` lifetime; matches `Cache-Control: max-age` so HTTP cache and crypto expiry decay together `1h`
`attestation.trust_scheme` Optional TRAIN trust-scheme DNS name unset → field omitted from wire

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.

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>
@nicknacnic

Copy link
Copy Markdown
Contributor Author

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 + cap-sha256) plus an audit-log-anchored attestation (this PR) plus the wallet ecosystem's existing OID4VP/OID4VCI flows is enough to ship verifiable agent identity end-to-end.

Agent Identity Without a New Authority (2026-06-03)

Why it's relevant to this PR specifically:

  • The post sketches a credential-issuance event type for the audit log (VC_ISSUED) that fits directly on top of the COSE_Sign1 envelope this PR introduces — same wire-format primitive, just a new eventType discriminant. Logging the credential hash (not the credential body) keeps the holder's claim data out of the log while still letting verifiers check inclusion.

  • The sub field landing in this PR is the same flat-FQDN that appears in an OID4VP x509_san_dns chain — when a wallet validates a verifier's request object against the SAN, the attestation here is the corroborating substrate-layer document. One name across DNS, x.509 SAN, ANS attestation, and the OID4VP trust flow.

  • The two-key verification topology (RA producer key + TL checkpoint key) is what lets a wallet check the substrate layer without trusting either the RA's HTTP endpoint or the TL's HTTP endpoint unilaterally — which is the property OpenDID-style deployments get from their TAS + Trust Repository split.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant