Repository navigation
Feat/add dpop support - #108
Conversation
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
1c746ba to
8e90820
Compare
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
kperry-godaddy
left a comment
There was a problem hiding this comment.
The Method B pipeline itself holds up: closed JOSE header, jwk matched to x5c[0] before any signature work, certificate validity, the SCITT binding, and a replay cache that fails closed and records the jti last are all in place and read cleanly. What shapes my read is timing. ANS-6 changed under this branch: the text merged on 2026-09-14 (ans-registry a6dcb07, five days after the last commit here) makes ans_content_digest REQUIRED on every proof, with the empty-content digest for bodiless requests, verified after the SCITT binding. The code implements the 2026-08-31 draft where the claim was opt-in, so today the filter rejects every proof from a conformant signer (the Go pop package always emits the claim) while accepting digest-less proofs with the body unbound, and proofs minted through PopHttp.attachIdentity are rejected by the Go verifier. Since CallerOptions, VerifyOptions and PopSigner.sign become published API with this PR, I'd land the digest change here rather than as a breaking follow-up.
Nine things worth a look, all inline. Four carry suggestions you can commit as-is (the verifier method, the signer line, the filter's authority derivation, and the HTTP helper); the other five need a wider change, so they are sketched. Six of the nine are one thread, the content digest seen from the signer, options, verifier, filter and helper, and land best as a single change set together with the inverted tests and the §10 vectors.
Two calls are yours, and the comments depend on them: the error category for a proof with no digest (I lean MALFORMED_PROOF, matching §7.4 step 2 and the Go reference, over CONTENT_BINDING_MISMATCH), and whether a null body hash on the callee side means "no content" (Go's reading) or a misconfiguration.
Spec text as merged: https://github.com/agentnameservice/ans-registry/blob/a6dcb0789b3710c4b367fd53b7f91c5adf120965/spec/ans-6-agent-authentication.md
ANS-6 as merged (ans-registry a6dcb07; §7.2, §7.4 steps 2 and 12, §7.13) makes ans_content_digest REQUIRED on every Method B proof, with the digest of the empty octet string for a request without content, and has the verifier compare it only after the proof is bound to a live agent. This module implemented the earlier draft: the signer emitted the claim only for non-empty content, the verifier treated it as optional behind a requireContentBinding flag, and PopHttp.attachIdentity could not bind a body at all. A conformant caller such as the Go pop package was rejected by the Java verifier, Java-minted proofs were rejected by conformant verifiers, and a hop behind the TLS terminator could add or rewrite content on a first request without failing any check. - PopSigner writes ans_content_digest on every proof; sign(method, url) and the access-token overload bind the empty octet string. - DpopProofVerifier rejects an absent or malformed claim as MALFORMED_PROOF at step 2 and compares the digest against the received content at step 12, after the SCITT binding and expected-peer checks and before the jti is recorded, through a new ContentSource that is read only at that point. An unreadable source is the new CONTENT_UNREADABLE error. - CallerOptions and VerifyOptions replace the pre-hashed byte[] and the requireContentBinding flag with withReceivedContent(ContentSource). - PopHttp.attachIdentity gains a content overload that sets the body from the same bytes it signs, rejects a builder that already carries unbound content, and sets SCITT headers single-valued (§4.6). - Proof rejects a jwk carrying members beyond kty, crv, x, y, or coordinates that are not 32 bytes (§7.2), checked on the header bytes as sent because the JOSE library drops members it does not know. - CallerVerifier refreshes root keys once through a RootKeyRefresher when a receipt or status token names an unknown signing key (§4.5, §9.5), maps a still-unknown key to the new UNKNOWN_SIGNING_KEY error logged at WARN, and logs REPLAY_CACHE_FULL at ERROR because a saturated cache is an authentication outage (§9.6). - Tests cover the §10 vectors: missing, malformed and mismatched digest, content added to an empty request, the empty-content digest fixture, jwk members beyond the four, and the step order (content is not read when an earlier check fails; replay is not recorded on a content mismatch). Addresses the review on PR #108. Assisted-by: Claude Code (claude-fable-5-1) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: kperry <kperry@godaddy.com>
- PopAuthenticationFilter hands CallerVerifier a bounded ContentSource, so the request body is read once, on the verifier's demand after the SCITT binding (ANS-6 §7.4 step 12), and is then replayed to the handler through a request wrapper. Content above the bound (Builder withMaxContentBytes, default 1 MiB) is rejected with 413 before it is hashed. Previously the filter never supplied content, so every proof that carried ans_content_digest was rejected and body-bearing requests were accepted with the body unbound. - The trusted-authority check reads the same URL the proof's htu is compared against instead of the raw Host header (§7.7), so forwarded header processing can no longer make the two diverge. The withTrustedHosts Javadoc now says that behind a TLS-terminating proxy the container's URL is the proxy hop, and withExternalUrl or trusted forwarded-header handling is required. - Builder gains withVerifier(CallerVerifier), mutually exclusive with withPoPSkew, and withRootKeyRefresher(RootKeyRefresher). The example wires the refresher to TransparencyClient.refreshRootKeysIfNeeded. Addresses the review on PR #108. Assisted-by: Claude Code (claude-fable-5-1) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: kperry <kperry@godaddy.com>
Summary
feat(pop): ANS-6 Method B — application-layer proof of possession (DPoP + SCITT)
Summary
This PR adds application-layer, agent-to-agent authentication to the SDK. It
implements ANS-6 Method B. The caller proves that it holds its ANS Identity
Certificate key with an RFC 9449 DPoP proof. The proof travels in an HTTP header
over normal server-authenticated HTTPS. The proof binds to the Transparency Log
SCITT artifacts: the receipt and the status token. No client certificate is in
the TLS handshake, so the proof survives L7 proxies and gateways that terminate
TLS. Method B needs no authorization server and makes no per-request
Transparency Log query.
New modules
ans-sdk-popans-sdk-pop-springans-sdk-pop-spring/examples/dpop-scitt-authCaller side (mint and send)
PopSignermints proofs. The header holds exactlytyp,alg(ES256),jwk, andx5c(one leaf). The claims arehtm,htu,iat,jti(128-bit), and
ans_profile=1, plus optionalath(§7.8) andans_content_digest(§7.13). At build time it checks that the private keymatches the certificate key.
PopHttp.attachIdentityattaches theDPoPheader and the SCITT headers.accessTokenFromAuthorizationgives the callee one parser for theAuthorization: DPoP <token>value.Callee side (verify)
CallerVerifiercomposes the three proofs — possession (DPoP), liveness(status token), and identity (receipt) — in the §7.4 order. It runs the §7.5
binding:
validIdentityCerts.ans://SAN host must equal the status token host.It records the
jtilast, only after every other check passes (fail-closed).DpopProofVerifierdecodes the header strictly. It comparesjwktox5c[0]byte-for-byte before any signature work, checks certificate validity,and accepts ES256 only.
CaffeineReplayCacheis bounded and fails closed at capacity. It uses anatomic check-and-store and stores a digest of the
jti.PopAuthenticationFilterenforces the §7.7 authority requirement. It failsstartup when neither
withExternalUrl(...)norwithTrustedHosts(...)is set,so
htunever comes from the clientHostheader. It rejects duplicatesecurity headers and supports a peer allowlist through
CallerPolicy.Build
ans-sdk-popandans-sdk-pop-springas publishable modules (the90% coverage rule applies).
Scope
This PR is 44 files, +5,533 / −3 against the merge base (
d3c7328), across 13commits. All commits are GPG-signed and carry the DCO
Signed-off-bytrailer.The four modified files are all build wiring (
settings.gradle.kts,build.gradle.kts,gradle.properties,ans-sdk-spring-boot-starter/build.gradle.kts). Everything else is new.Testing
Unit tests cover the signer, verifier, proof, replay cache, HTTP helpers, policy,
and the Spring filter.
Local end to end tests carried out against RA/TL ref implementation
AI assistance
Checklist
git commit -s) certifying the DCO