Feat/add dpop support - #108
jhateley-godaddy wants to merge 24 commits into
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
| 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"); | ||
| } | ||
| } |
There was a problem hiding this comment.
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:
| 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).
| 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); |
There was a problem hiding this comment.
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).
| if (content != null && content.length > 0) { | ||
| claims.put("ans_content_digest", Proof.contentDigest(content)); | ||
| } |
There was a problem hiding this comment.
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:
| 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).
| // 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); | ||
| } |
There was a problem hiding this comment.
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.
| 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(); | ||
| } |
There was a problem hiding this comment.
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):
| 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
| 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); |
There was a problem hiding this comment.
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 13Worth 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.
| 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); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
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:
| 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.
| 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()); | ||
| } |
There was a problem hiding this comment.
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 alreadyWorth 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.
| 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()); | ||
| } |
There was a problem hiding this comment.
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.
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