Skip to content

Feat/add dpop support - #108

Merged
jhateley-godaddy merged 24 commits into
mainfrom
feat/add-dpop-support
Oct 8, 2026
Merged

jhateley-godaddy merged 24 commits into
mainfrom
feat/add-dpop-support

Conversation

@jhateley-godaddy

@jhateley-godaddy jhateley-godaddy commented Sep 3, 2026 •

Copy link
Copy Markdown
Collaborator

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

Module Role Published
ans-sdk-pop Core signer and verifier Yes
ans-sdk-pop-spring Spring servlet filter for the callee side Yes
ans-sdk-pop-spring/examples/dpop-scitt-auth Client and server example you can run No

Caller side (mint and send)

  • PopSigner mints proofs. The header holds exactly typ, alg (ES256),
    jwk, and x5c (one leaf). The claims are htm, htu, iat, jti
    (128-bit), and ans_profile=1, plus optional ath (§7.8) and
    ans_content_digest (§7.13). At build time it checks that the private key
    matches the certificate key.
  • PopHttp.attachIdentity attaches the DPoP header and the SCITT headers.
    accessTokenFromAuthorization gives the callee one parser for the
    Authorization: DPoP <token> value.

Callee side (verify)

  • CallerVerifier composes the three proofs — possession (DPoP), liveness
    (status token), and identity (receipt) — in the §7.4 order. It runs the §7.5
    binding:

    • The proof certificate fingerprint must be in the status token
      validIdentityCerts.
    • The certificate ans:// SAN host must equal the status token host.
    • The receipt leaf must name the same agent.

    It records the jti last, only after every other check passes (fail-closed).

  • DpopProofVerifier decodes the header strictly. It compares jwk to
    x5c[0] byte-for-byte before any signature work, checks certificate validity,
    and accepts ES256 only.

  • CaffeineReplayCache is bounded and fails closed at capacity. It uses an
    atomic check-and-store and stores a digest of the jti.

  • PopAuthenticationFilter enforces the §7.7 authority requirement. It fails
    startup when neither withExternalUrl(...) nor withTrustedHosts(...) is set,
    so htu never comes from the client Host header. It rejects duplicate
    security headers and supports a peer allowlist through CallerPolicy.

Build

  • Registers ans-sdk-pop and ans-sdk-pop-spring as publishable modules (the
    90% coverage rule applies).
  • Raises Bouncy Castle to 1.84 and Caffeine to 3.2.0.

Scope

This PR is 44 files, +5,533 / −3 against the merge base (d3c7328), across 13
commits. All commits are GPG-signed and carry the DCO Signed-off-by trailer.
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

  • The PR title follows Conventional Commits — release notes are generated from it
  • Tests cover the change
  • The linked issue above uses a closing keyword
  • Every commit is signed off (git commit -s) certifying the DCO

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>
@jhateley-godaddy
jhateley-godaddy requested review from bchen-godaddy and removed request for bchen-godaddy September 3, 2026 04:20
Comment thread gradle.properties Outdated
Comment thread ans-sdk-pop/build.gradle.kts
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>
@jhateley-godaddy
jhateley-godaddy marked this pull request as ready for review September 9, 2026 05:10
bchen-godaddy
bchen-godaddy previously approved these changes Sep 9, 2026

@bchen-godaddy bchen-godaddy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@kperry-godaddy kperry-godaddy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/DpopProofVerifier.java Outdated
Comment thread ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopSigner.java Outdated
Comment thread ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerOptions.java Outdated
Comment thread ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopHttp.java
kperry-godaddy and others added 2 commits September 17, 2026 16:30
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>
@jhateley-godaddy
jhateley-godaddy merged commit d78ac63 into main Oct 8, 2026
3 checks passed
@jhateley-godaddy
jhateley-godaddy deleted the feat/add-dpop-support branch October 8, 2026 00:23
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.

4 participants