Skip to content

Feat/add dpop support - #108

Open
jhateley-godaddy wants to merge 24 commits into
mainfrom
feat/add-dpop-support
Open

jhateley-godaddy wants to merge 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 on lines +240 to +271
private static void verifyContentBinding(String proofDigest, byte[] contentSha256, boolean requireBinding)
throws PopException {
boolean bodyPresented = contentSha256 != null;
boolean digestPresent = proofDigest != null;

if (bodyPresented && contentSha256.length != SHA256_BYTES) {
throw new PopException(ErrorType.MISCONFIGURED, "contentSha256 must be exactly 32 bytes");
}
if (!bodyPresented) {
if (digestPresent) {
throw new PopException(ErrorType.CONTENT_BINDING_MISMATCH,
"proof binds request content but no body hash was supplied");
}
return;
}
if (!digestPresent) {
if (requireBinding) {
throw new PopException(ErrorType.CONTENT_BINDING_MISMATCH,
"content binding required but proof carries no ans_content_digest");
}
return;
}
// The body hash arrives pre-hashed, so the expected digest is a straight
// base64url encoding — Proof.contentDigest would hash it a second time.
String expected = Base64Url.encode(contentSha256);
if (!MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
proofDigest.getBytes(StandardCharsets.UTF_8))) {
throw new PopException(ErrorType.CONTENT_BINDING_MISMATCH,
"ans_content_digest does not match request body");
}
}

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.

verifyContentBinding treats ans_content_digest as optional in both directions. With no body hash supplied, which is what CallerOptions.none() and the Spring filter always pass, a proof that omits the claim is accepted with the body unbound, and a proof that carries it is rejected. ANS-6 as merged marks the claim REQUIRED (§7.2), rejects an absent or malformed claim at §7.4 step 2, and expects 47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU for a request without content (§7.13). Today that means every Go pop proof fails here with CONTENT_BINDING_MISMATCH, and a hop behind the TLS terminator can add a body to a bodiless Java request without tripping any check (§11.11). This keeps the current signature so the call at 137-138 still compiles, makes the claim mandatory, and always compares:

Suggested change
private static void verifyContentBinding(String proofDigest, byte[] contentSha256, boolean requireBinding)
throws PopException {
boolean bodyPresented = contentSha256 != null;
boolean digestPresent = proofDigest != null;
if (bodyPresented && contentSha256.length != SHA256_BYTES) {
throw new PopException(ErrorType.MISCONFIGURED, "contentSha256 must be exactly 32 bytes");
}
if (!bodyPresented) {
if (digestPresent) {
throw new PopException(ErrorType.CONTENT_BINDING_MISMATCH,
"proof binds request content but no body hash was supplied");
}
return;
}
if (!digestPresent) {
if (requireBinding) {
throw new PopException(ErrorType.CONTENT_BINDING_MISMATCH,
"content binding required but proof carries no ans_content_digest");
}
return;
}
// The body hash arrives pre-hashed, so the expected digest is a straight
// base64url encoding — Proof.contentDigest would hash it a second time.
String expected = Base64Url.encode(contentSha256);
if (!MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
proofDigest.getBytes(StandardCharsets.UTF_8))) {
throw new PopException(ErrorType.CONTENT_BINDING_MISMATCH,
"ans_content_digest does not match request body");
}
}
private static void verifyContentBinding(String proofDigest, byte[] contentSha256, boolean requireBinding)
throws PopException {
if (proofDigest == null) {
throw new PopException(ErrorType.MALFORMED_PROOF, "ans_content_digest claim is missing");
}
byte[] claimed;
try {
claimed = Base64Url.decode(proofDigest);
} catch (IllegalArgumentException e) {
throw new PopException(ErrorType.MALFORMED_PROOF, "ans_content_digest is not base64url", e);
}
if (claimed.length != SHA256_BYTES) {
throw new PopException(ErrorType.MALFORMED_PROOF, "ans_content_digest must be a SHA-256 digest");
}
if (contentSha256 != null && contentSha256.length != SHA256_BYTES) {
throw new PopException(ErrorType.MISCONFIGURED, "contentSha256 must be exactly 32 bytes");
}
// A request without content binds the digest of the empty octet string
// (ANS-6 §7.13), so the comparison runs on every proof.
byte[] expected = contentSha256 != null ? contentSha256 : sha256(new byte[0]);
if (!MessageDigest.isEqual(expected, claimed)) {
throw new PopException(ErrorType.CONTENT_BINDING_MISMATCH,
"ans_content_digest does not match request content");
}
}

Worth fixing before this ships: VerifyOptions and CallerOptions become published API with this PR, so the optional model turns into a breaking change later. It has to land with the signer change (otherwise Java-to-Java calls start failing), requireBinding becomes dead and can go with the option flag, and acceptsMissingContentWhenNotRequired / rejectsContentDigestWithoutOption in DpopProofVerifierTest need inverting plus the §10 vectors (missing, malformed and mismatched digest; the empty-content digest verifies; content added to an empty request rejects).

Comment on lines +85 to +121
CallerOptions options = CallerOptions.none();
Optional<String> accessToken = PopHttp.accessTokenFromAuthorization(request.getHeader("Authorization"));
if (accessToken.isPresent()) {
options = options.withAccessToken(accessToken.get());
}

Map<String, PublicKey> keys;
try {
keys = rootKeys.get();
} catch (RuntimeException e) {
LOG.error("caller rejected: MISCONFIGURED - root keys unavailable: {}", e.getMessage());
reject(response);
return;
}

CallerIdentity identity;
try {
identity = verifier.verifyCaller(proof, headers, request.getMethod(),
resolveUrl(request), keys, replay, options);
} catch (PopException e) {
LOG.info("caller rejected: {} - {}", e.category(), e.getMessage());
reject(response);
return;
} catch (RuntimeException e) {
LOG.error("caller rejected: unexpected verification error", e);
reject(response);
return;
}

if (!policy.callerAllowed(identity)) {
LOG.info("caller rejected: EXPECTED_PEER_MISMATCH - caller ans host is not in the accepted set");
reject(response);
return;
}

request.setAttribute(PopAuthentication.CALLER_ATTRIBUTE, identity);
filterChain.doFilter(request, response);

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.

doFilterInternal builds CallerOptions.none() plus the access token and never reads or hashes the request body, so §7.4 step 12 has nowhere to run: a digest-less proof passes with the body unbound, and because verifyContentBinding rejects a digest it has no body hash for, every conformant proof (Go callers, or this PR's own PopSigner on a POST with a body) gets a 401. Only bodiless requests from the Java signer authenticate, which is why the GET-only example works. The filter needs to read the body up to a bound, hash it, always pass the digest, and hand a replaying request to the chain:

// Builder: withMaxContentBytes(long), default 1 MiB (Go pop.DefaultMaxContentBytes)
byte[] content;
try {
    content = readBounded(request.getInputStream(), maxContentBytes);   // throws past the bound
} catch (ContentTooLargeException e) {
    response.sendError(HttpServletResponse.SC_CONTENT_TOO_LARGE, "payload too large");
    return;
}
CallerOptions options = CallerOptions.none().withContentSha256(sha256(content)); // empty body -> empty-content digest
// ... token, root keys, verifyCaller, policy as today ...
HttpServletRequest verified = new BufferedContentRequest(request, content);      // HttpServletRequestWrapper replaying the bytes
verified.setAttribute(PopAuthentication.CALLER_ATTRIBUTE, identity);
filterChain.doFilter(verified, response);

Two notes on the shape. Spring's ContentCachingRequestWrapper does not fit as-is: it caches lazily as downstream reads and its overflow hook is a no-op, so a small wrapper over a byte[] is simpler. And the spec wants the hash computed after the SCITT binding and compared before the jti is committed; with the pre-hashed CallerOptions API the filter has to hash first, so that ordering point sits with the CallerVerifier comment. Worth fixing before this ships: this is the only production integration, and PopAuthenticationFilterTest has no body case today, so body present/absent/tampered/oversize plus an empty-body proof carrying the empty-content digest would lock it in (authenticatesCallerAndPopulatesAttribute asserts isSameAs(request) and will need to read the identity from the wrapped request instead).

Comment on lines +137 to +139
if (content != null && content.length > 0) {
claims.put("ans_content_digest", Proof.contentDigest(content));
}

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 digest is written only for non-empty content, so sign(method, url), sign(method, url, token) and everything PopHttp.attachIdentity mints carry no ans_content_digest. Merged §7.13 is explicit: a signer MUST include the claim in every proof, with the empty-octet-string digest when there is no content, and a conformant verifier rejects its absence as malformed (Go pop/proof.go does), so no Java caller can currently reach the Go reference. Proof.contentDigest(new byte[0]) already yields the right value:

Suggested change
if (content != null && content.length > 0) {
claims.put("ans_content_digest", Proof.contentDigest(content));
}
claims.put("ans_content_digest", Proof.contentDigest(content != null ? content : new byte[0]));

Worth fixing before this ships: it is one line, but it flips PopSignerTest.signWithEmptyContentHasNoDigest (assert 47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU instead) and the Javadoc at 85-88 and 96-98 that says an empty body carries no claim. Once this lands, attachIdentity stamps the empty digest on every request, which is right for bodiless calls but means body-bearing requests through the helper mismatch until it can take the content (see the PopHttp comment).

Comment on lines +15 to +65
// The SHA-256 of the request body (32 bytes), or null when no body is bound.
private final byte[] contentSha256;
// Whether the proof MUST carry an ans_content_digest.
private final boolean requireContentBinding;

private CallerOptions(String accessToken, String expectedPeer, Instant clock,
byte[] contentSha256, boolean requireContentBinding) {
this.accessToken = accessToken;
this.expectedPeer = expectedPeer;
this.clock = clock;
this.contentSha256 = contentSha256;
this.requireContentBinding = requireContentBinding;
}

public static CallerOptions none() {
return new CallerOptions(null, null, null, null, false);
}

public CallerOptions withAccessToken(String token) {
return new CallerOptions(Objects.requireNonNull(token, "token"), expectedPeer, clock,
contentSha256, requireContentBinding);
}

/**
* Restricts accepted callers to this ans:// name. When no expected peer is
* set, any proven agent authenticates, and the callee authorizes downstream.
*/
public CallerOptions withExpectedPeer(String peer) {
return new CallerOptions(accessToken, Objects.requireNonNull(peer, "peer"), clock,
contentSha256, requireContentBinding);
}

public CallerOptions withClock(Instant now) {
return new CallerOptions(accessToken, expectedPeer, Objects.requireNonNull(now, "now"),
contentSha256, requireContentBinding);
}

/**
* Binds the request body: the proof's ans_content_digest must match the
* SHA-256 of the body (ANS-6 §7.13). The caller hashes the body; the digest
* must be exactly 32 bytes. The array is copied defensively.
*/
public CallerOptions withContentSha256(byte[] contentSha256) {
Objects.requireNonNull(contentSha256, "contentSha256");
return new CallerOptions(accessToken, expectedPeer, clock, contentSha256.clone(), requireContentBinding);
}

/** Requires the proof to carry an ans_content_digest (ANS-6 §7.13). */
public CallerOptions withRequiredContentBinding() {
return new CallerOptions(accessToken, expectedPeer, clock, contentSha256, true);
}

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 option model itself encodes the draft: a nullable contentSha256 meaning "skip the check" and a requireContentBinding flag to opt in. Under the merged text there is no unbound state, so the API cannot express what the verifier now has to do ("no content, digest must equal the empty-content digest"), and none() is the path the filter always takes. Since CallerOptions and VerifyOptions go public with this PR, I'd collapse them now rather than break consumers later:

// Every proof binds content (ANS-6 §7.13); no content binds the empty octet string.
private final byte[] contentSha256;

public static CallerOptions none() {
    return new CallerOptions(null, null, null, Proof.EMPTY_CONTENT_SHA256.clone());
}
// withContentSha256(byte[]) stays; withRequiredContentBinding() and requireContentBinding() go.
// Proof: static final byte[] EMPTY_CONTENT_SHA256 = sha256(new byte[0]);
// VerifyOptions: same shape. CallerVerifier.verifyPossession: .withContentSha256(options.contentSha256())

Worth fixing before this ships: it touches VerifyOptions, CallerVerifier.verifyPossession (145-147) and CallerOptionsTest / VerifyOptionsTest, which is why it is not a one-click suggestion. If the deferred-content idea in the CallerVerifier comment is taken, this field becomes a ContentSource rather than a byte[], so that decision comes first.

Comment on lines +131 to +150
private String deriveAuthority(HttpServletRequest request) {
if (externalUrl != null) {
try {
return new URI(externalUrl.apply(request)).getAuthority();
} catch (URISyntaxException e) {
return null;
}
}
String authority = request.getHeader("Host");
return authority != null ? authority : request.getServerName();
}

private String resolveUrl(HttpServletRequest request) {
if (externalUrl != null) {
return externalUrl.apply(request);
}
StringBuffer url = request.getRequestURL();
String query = request.getQueryString();
return query == null ? url.toString() : url.append('?').append(query).toString();
}

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.

In trusted-hosts-only mode the §7.7 allowlist is evaluated on request.getHeader("Host") while the htu handed to verifyCaller comes from getRequestURL(), so the allowlist never constrains the value that is actually compared. The two agree only with no proxy in front. With server.forward-headers-strategy=framework or native, getRequestURL() follows X-Forwarded-Host (Spring's ForwardedHeaderFilter leaves Host untouched and Tomcat's RemoteIpValve rewrites only the server name), so Host: <allowlisted> plus a client-supplied X-Forwarded-Host: evil.example.com passes the gate and matches a proof minted for that other origin, the A.6 replay the allowlist exists to block. Without forwarded-header processing, getRequestURL() is http://... behind a TLS terminator and every proof fails HTTP_BINDING_MISMATCH. Deriving the authority from the exact string the verifier normalizes closes the gap and keeps the existing tests passing (MockHttpServletRequest.getServerName() derives from Host):

Suggested change
private String deriveAuthority(HttpServletRequest request) {
if (externalUrl != null) {
try {
return new URI(externalUrl.apply(request)).getAuthority();
} catch (URISyntaxException e) {
return null;
}
}
String authority = request.getHeader("Host");
return authority != null ? authority : request.getServerName();
}
private String resolveUrl(HttpServletRequest request) {
if (externalUrl != null) {
return externalUrl.apply(request);
}
StringBuffer url = request.getRequestURL();
String query = request.getQueryString();
return query == null ? url.toString() : url.append('?').append(query).toString();
}
private String deriveAuthority(HttpServletRequest request) {
try {
return new URI(resolveUrl(request)).getAuthority();
} catch (URISyntaxException e) {
return null;
}
}
private String resolveUrl(HttpServletRequest request) {
if (externalUrl != null) {
return externalUrl.apply(request);
}
StringBuffer url = request.getRequestURL();
String query = request.getQueryString();
return query == null ? url.toString() : url.append('?').append(query).toString();
}

Worth fixing before this ships: the bypass needs a proxy that passes client X-Forwarded-Host through, which ALB with the native strategy does. Beyond the drop-in, the withTrustedHosts Javadoc (209-216) should say scheme and authority come from the container, so behind a terminator this mode needs withExternalUrl or trusted forwarded-header handling, and a test whose getRequestURL() disagrees with Host would lock it. The query append is dead weight since Proof.normalizeHTU strips it. Forwarded headers: https://docs.spring.io/spring-framework/reference/web/webmvc/filters.html#filters-forwarded-headers

Comment on lines +94 to +117
DpopProofVerifier.Verified verified = verifyPossession(proofJWS, method, url, effectiveOptions);
ProofResult proof = verified.result();

// Liveness and identity: the status token proves the certificate is
// currently valid, and the receipt anchors it in the transparency log.
ScittExpectation expectation = scittVerifier.verify(receipt, token, rootKeys);
if (!expectation.isVerified()) {
throw mapExpectation(expectation);
}

// Bind all three to one agent: fingerprint, ans:// SAN, and receipt.
verifyBinding(proof, receipt, token);

if (effectiveOptions.expectedPeer() != null
&& !ansHost(effectiveOptions.expectedPeer()).equals(ansHost(token.ansName()))) {
throw new PopException(ErrorType.EXPECTED_PEER_MISMATCH,
"status token peer does not match expected peer");
}

// Single-use: recorded last, once the proof is known to belong to an
// agent the transparency log vouches for. Recording earlier would let
// anyone with a self-signed certificate consume the bounded cache and
// fail authentication for every legitimate caller.
proofVerifier.recordReplay(verified, replay);

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 content comparison runs inside verifyPossession (via DpopProofVerifier.verifyUnrecorded, between ath and iat), before the status token, receipt and binding at 99-105, and the only input is a pre-hashed byte[] on the options. Every adapter therefore has to read and hash an unauthenticated body before any signature or status-token check, which is the ordering §7.4 step 12 exists to avoid ("hashing starts only after step 11 succeeds"). The compare-before-jti rule is met; the hash-after-binding one is not, and the PR body claims the §7.4 order. The Go reference does this lazily (checkContent after verifyBinding, before commitReplay, over a read func()), and the Java shape can match:

public interface ContentSource { byte[] read() throws IOException; }   // null = no content

// CallerOptions/VerifyOptions: withReceivedContent(ContentSource) replaces withContentSha256(byte[])
// DpopProofVerifier: Verified carries the proof's contentDigest; verifyContent(verified, source) hashes and compares
verifyBinding(proof, receipt, token);
// expectedPeer check as today ...
proofVerifier.verifyContent(verified, effectiveOptions.receivedContent());   // step 12
proofVerifier.recordReplay(verified, replay);                                // step 13

Worth fixing before this ships rather than after: the input type is public API in a module this PR publishes, so changing it later is breaking, whereas now it touches four new files. A counting ContentSource test asserting read() is never invoked when the fingerprint or expected-peer check fails (Go's caller_test.go has the same "binding failure never reads the content" case) would pin the order.

Comment on lines +25 to +45
public static void attachIdentity(HttpRequest.Builder req, PopSigner signer,
Map<String, List<String>> scittHeaders, String accessToken) throws PopException {
Objects.requireNonNull(req, "req");
Objects.requireNonNull(signer, "signer");
Objects.requireNonNull(scittHeaders, "scittHeaders");

HttpRequest snapshot = req.build();
String method = snapshot.method();
String url = snapshot.uri().toString();

String proof = accessToken != null
? signer.sign(method, url, accessToken)
: signer.sign(method, url);

req.setHeader(DPOP_HEADER, proof);
for (Map.Entry<String, List<String>> entry : scittHeaders.entrySet()) {
for (String value : entry.getValue()) {
req.header(entry.getKey(), value);
}
}
}

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.

attachIdentity only ever calls the two no-content sign overloads, and the body is unrecoverable from HttpRequest.Builder (bodyPublisher() exposes contentLength() and nothing else), so a POST or PUT built through the SDK's one convenience helper is signed without ans_content_digest. A conformant verifier rejects it, and a lenient one accepts a body a TLS-terminating hop can rewrite (§7.10, §11.11). Taking the content octets explicitly and setting the body from the same array keeps the two from diverging, and fails closed when the builder already carries content the proof would not bind. All six existing call sites keep compiling through the no-content overload:

Suggested change
public static void attachIdentity(HttpRequest.Builder req, PopSigner signer,
Map<String, List<String>> scittHeaders, String accessToken) throws PopException {
Objects.requireNonNull(req, "req");
Objects.requireNonNull(signer, "signer");
Objects.requireNonNull(scittHeaders, "scittHeaders");
HttpRequest snapshot = req.build();
String method = snapshot.method();
String url = snapshot.uri().toString();
String proof = accessToken != null
? signer.sign(method, url, accessToken)
: signer.sign(method, url);
req.setHeader(DPOP_HEADER, proof);
for (Map.Entry<String, List<String>> entry : scittHeaders.entrySet()) {
for (String value : entry.getValue()) {
req.header(entry.getKey(), value);
}
}
}
public static void attachIdentity(HttpRequest.Builder req, PopSigner signer,
Map<String, List<String>> scittHeaders, String accessToken) throws PopException {
attachIdentity(req, signer, scittHeaders, accessToken, new byte[0]);
}
/**
* Signs a DPoP proof binding the request and {@code content}, attaches it as
* the {@code DPoP} header, then copies the SCITT headers. {@code content} must
* be the exact octets the request transmits (RFC 9110 §6.4: after transfer
* coding, with any content coding still applied); the request body is set
* from the same array so the two cannot diverge, and a request that already
* carries content the proof would not bind is rejected. Pass an empty array
* for a request without content. When {@code accessToken} is non-null, the
* proof also binds it via ath (RFC 9449 §4.2 / §7.1).
*/
public static void attachIdentity(HttpRequest.Builder req, PopSigner signer,
Map<String, List<String>> scittHeaders, String accessToken, byte[] content) throws PopException {
Objects.requireNonNull(req, "req");
Objects.requireNonNull(signer, "signer");
Objects.requireNonNull(scittHeaders, "scittHeaders");
Objects.requireNonNull(content, "content");
HttpRequest snapshot = req.build();
String method = snapshot.method();
String url = snapshot.uri().toString();
if (content.length > 0) {
req.method(method, HttpRequest.BodyPublishers.ofByteArray(content));
} else if (snapshot.bodyPublisher().map(HttpRequest.BodyPublisher::contentLength).orElse(0L) != 0) {
throw new PopException(ErrorType.MISCONFIGURED,
"request carries content the proof would not bind; pass it as content");
}
String proof = accessToken != null
? signer.sign(method, url, accessToken, content)
: signer.sign(method, url, content);
req.setHeader(DPOP_HEADER, proof);
for (Map.Entry<String, List<String>> entry : scittHeaders.entrySet()) {
for (String value : entry.getValue()) {
req.header(entry.getKey(), value);
}
}
}

Worth fixing before this ships: attachIdentity is the only caller-side entry point the PR documents. Tests to add in PopHttpTest: a POST with a body asserting the digest equals Proof.contentDigest(body) and the built request's contentLength() equals body.length, and a builder that already has ofByteArray(...) content via the 4-arg overload asserting MISCONFIGURED. Separately, the SCITT headers are appended with header() while DPoP uses setHeader(), so re-attaching on the same builder emits duplicates the callee preflight rejects; setHeader there would match the DPoP line.

Comment on lines +298 to +307
private static PopException mapExpectation(ScittExpectation expectation) {
ErrorType type = switch (expectation.status()) {
case INVALID_RECEIPT -> ErrorType.RECEIPT_INVALID;
case INVALID_TOKEN, TOKEN_EXPIRED, AGENT_REVOKED, AGENT_INACTIVE, KEY_NOT_FOUND -> ErrorType.STATUS_INVALID;
case PARSE_ERROR, NOT_PRESENT -> ErrorType.SCITT_HEADER_INVALID;
// Unreachable: mapExpectation runs only when !expectation.isVerified().
case VERIFIED -> throw new IllegalStateException("mapExpectation called on a verified expectation");
};
return new PopException(type, expectation.failureReason());
}

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.

mapExpectation folds KEY_NOT_FOUND into STATUS_INVALID, and verifyParsed calls scittVerifier.verify exactly once, so an artifact signed by a root key this callee has not seen is a plain caller rejection at INFO. ANS-6 §4.5 and §9.5 ask the verifier to refresh /root-keys once, cooldown-gated, before rejecting an unknown kid, and the SDK already has that path in ScittVerifierAdapter.handleKeyNotFound (TransparencyClient.refreshRootKeysIfNeeded, then one retry). Nothing in ans-sdk-pop or the filter reaches it, and RootKeyManager caches for 24 h by default, so a TL key add locks every caller out until the cache expires or the process restarts, with a log line that points at the caller's token rather than the stale trust store:

// CallerVerifier: optional key-refresh collaborator (null keeps today's pinned behaviour)
ScittExpectation expectation = scittVerifier.verify(receipt, token, rootKeys);
if (expectation.isKeyNotFound() && keyRefresh != null) {
    // same refreshRootKeysIfNeeded(artifactIssuedAt) path ScittVerifierAdapter.handleKeyNotFound uses;
    // when it reports refreshed keys, retry scittVerifier.verify(receipt, token, refreshedKeys) once
}
// map KEY_NOT_FOUND to a new ErrorType.UNKNOWN_SIGNING_KEY (WARN in logRejection)
// PopAuthenticationFilter.Builder.withRootKeyRefresh(...) wires it; the example has the TransparencyClient already

Worth fixing before this ships: refresh-on-unknown-kid is a SHOULD and a pinned static deployment may skip it, but this module wires a TL-backed 24 h cache, so the outage is real and multi-hour. CallerVerifierTest.keyNotFoundMapsToStatusInvalid (296) changes with it. One thing to know going in: the existing cooldown is 30 s where §4.5 asks for minutes; that predates this PR.

Comment on lines +235 to +245
public PopAuthenticationFilter build() {
if (externalUrl == null && !trustedHostsSet) {
throw new IllegalStateException(
"htu would be derived from the client-controlled Host header; "
+ "call withExternalUrl(...) or withTrustedHosts(...) before build()");
}
CallerVerifier verifier = popSkew != null
? CallerVerifier.create(expectedIssuer, StatusToken.DEFAULT_CLOCK_SKEW, popSkew)
: CallerVerifier.create(expectedIssuer);
return new PopAuthenticationFilter(verifier, rootKeys, replay, externalUrl, policy.build());
}

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.

build() constructs CallerVerifier itself (and CallerVerifier in turn news up DefaultScittVerifier), the field is typed to the final class, and the only injection point is the package-private test constructor. A consumer who needs a non-default SCITT clock skew (the CallerVerifier.create overload exists but the Builder never exposes it), a different ScittVerifier, the §7.1 reduced mode, or any per-request CallerOptions has to reassemble the filter from the public pieces; ScittVerifierAdapter.Builder in ans-sdk-agent-client exposes both seams, so this is the odd one out. An additive setter keeps the fail-closed htu guard and changes nothing for current callers:

private CallerVerifier verifier;                       // Builder field

public Builder withVerifier(CallerVerifier verifier) {
    this.verifier = Objects.requireNonNull(verifier, "verifier");
    return this;
}

// in build(), replacing 241-244:
if (verifier != null && popSkew != null) {
    throw new IllegalStateException("withVerifier(...) and withPoPSkew(...) are mutually exclusive");
}
CallerVerifier effective = verifier != null ? verifier
    : popSkew != null
        ? CallerVerifier.create(expectedIssuer, StatusToken.DEFAULT_CLOCK_SKEW, popSkew)
        : CallerVerifier.create(expectedIssuer);
return new PopAuthenticationFilter(effective, rootKeys, replay, externalUrl, policy.build());

Worth doing before this ships since the whole file is new and the setter is non-breaking. A withScittClockSkew(Duration) alone is not possible from this module because DpopProofVerifier.DEFAULT_SKEW is package-private. Longer term, a small interface in ans-sdk-pop that CallerVerifier implements, with the filter typed against it, also unblocks the per-request options.

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>
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.

3 participants