Feat/add dpop support - #108
Conversation
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
Signed-off-by: James Hateley <jhateley@godaddy.com>
1c746ba to
8e90820
Compare
| public record CallerIdentity( | ||
| String ansName, | ||
| String agentId, | ||
| byte[] fingerprint, | ||
| String jkt) { | ||
|
|
||
| /** Returns the identity-certificate fingerprint as lowercase hex. */ | ||
| public String fingerprintHex() { | ||
| return HexFormat.of().formatHex(fingerprint); | ||
| } | ||
| } No newline at end of file |
There was a problem hiding this comment.
CallerIdentity is a public record. The compiler-generated fingerprint() accessor returns the internal array directly. Any holder can mutate the fingerprint bytes after the fact.
Recommended fix:
public record CallerIdentity(String ansName, String agentId, byte[] fingerprint, String jkt) {
public CallerIdentity {
fingerprint = fingerprint.clone();
}
@Override
public byte[] fingerprint() {
return fingerprint.clone();
}
public String fingerprintHex() {
return HexFormat.of().formatHex(fingerprint);
}
}Alternatively, since fingerprintHex() is the only practical consumer, drop the raw byte[] from the record and store only String fingerprintHex — eliminates the mutability concern entirely.
| public static CaffeineReplayCache create(int maxEntries) { | ||
| return create(maxEntries, Ticker.systemTicker()); | ||
| } |
There was a problem hiding this comment.
Missing validation - create(int maxEntries) accepts zero or negative values silently. With maxEntries <= 0, the guard map.size() >= maxEntries is always true: every new proof immediately triggers cleanUp() and then fails with REPLAY_CACHE_FULL, rejecting all legitimate callers.
Recommended fix:
public static CaffeineReplayCache create(int maxEntries) {
if (maxEntries <= 0) {
throw new IllegalArgumentException("maxEntries must be positive: " + maxEntries);
}
return create(maxEntries, Ticker.systemTicker());
}No test documents this behaviour or triggers the constructor-level failure path.
@Test void zeroMaxEntriesFailsClosedImmediately() {
CaffeineReplayCache cache = CaffeineReplayCache.create(0);
assertThatThrownBy(() -> cache.checkAndStore("any", TTL))
.isInstanceOf(PopException.class)
.extracting(e -> ((PopException) e).category())
.isEqualTo(ErrorType.REPLAY_CACHE_FULL);
}A separate test (or an assertion in create) should also document the intent for negative values.
| 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.
Host-header-derived htu when only withTrustedHosts is configured
When externalUrl == null, resolveUrl calls request.getRequestURL() (line 147), which in the Servlet API incorporates the client-controlled Host header. This means a caller who controls the Host header also controls the htu value the filter validates the proof against.
The build() guard blocks the case where neither externalUrl nor trustedHosts is set, but when only withTrustedHosts is used, an attacker who can inject a Host header matching a configured trusted host can present a proof minted for that host. In proxied deployments where the upstream load balancer does not canonicalize the Host header, this bypasses htu binding.
This is not a bug in single-host deployments where the Host header always matches the one configured trusted host, but it is a real bypass in multi-tenant or improperly proxied environments.
Recommended fix: No code change required. Add a javadoc warning to withTrustedHosts:
/**
* ...existing docs...
*
* <p><b>Warning:</b> when only trusted hosts are configured (no
* {@link #withExternalUrl}), the request URL — including its authority — is
* reconstructed from the Servlet API, which typically reads the {@code Host}
* header. Behind a reverse proxy, call {@code withExternalUrl} instead to
* pin the authority independently of client-supplied headers.
*/
public Builder withTrustedHosts(String... hosts) { ... }| reactorVersion=3.6.0 | ||
| mcpSdkVersion=1.1.0 | ||
| caffeineVersion=3.1.8 | ||
| caffeineVersion=3.2.0 |
There was a problem hiding this comment.
The version can go up to 3.2.4 (https://mvnrepository.com/artifact/com.github.ben-manes.caffeine/caffeine)
| dependencies { | ||
| // Core, crypto, generated models | ||
| api(project(":ans-sdk-core")) | ||
| api(project(":ans-sdk-crypto")) | ||
| api(project(":ans-sdk-api")) | ||
|
|
||
| // Transparency for StatusToken/ScittReceipt/RootKeyManager/DefaultScittVerifier reuse | ||
| api(project(":ans-sdk-transparency")) | ||
|
|
||
| // Agent-client for verification/trust surface reuse | ||
| api(project(":ans-sdk-agent-client")) | ||
|
|
||
| // Nimbus JOSE + JWT for ES256 DPoP proof sign/verify | ||
| implementation("com.nimbusds:nimbus-jose-jwt:$nimbusJoseVersion") | ||
|
|
||
| // Caffeine-backed replay cache (bounded jti single-use store) | ||
| implementation("com.github.ben-manes.caffeine:caffeine:$caffeineVersion") | ||
|
|
||
| // Logging | ||
| implementation("org.slf4j:slf4j-api:$slf4jVersion") |
There was a problem hiding this comment.
Lines 11–20 — five api declarations are either unused or wrongly scoped.
Remove — no imports anywhere in the module (main or test)
api(project(":ans-sdk-core"))api(project(":ans-sdk-api"))api(project(":ans-sdk-agent-client"))
Change to implementation — used internally; no types from these modules appear in any public method signature
api(project(":ans-sdk-crypto"))— onlyCertificateUtilsimported, insideCallerVerifierinternallyapi(project(":ans-sdk-transparency"))—ScittReceipt,StatusTokenetc. are consumed internally; all public methods onCallerVerifieraccept/return only JDK andcom.godaddy.ans.sdk.poptypes
Keeping unused or over-exposed api dependencies silently forces every consumer of ans-sdk-pop to pull in those transitive graphs. Verified locally: build and all tests pass after the three removals and two scope changes.
Suggested fix
diff --git a/ans-sdk-pop/build.gradle.kts b/ans-sdk-pop/build.gradle.kts
index f47a1c9..c5f661d 100644
--- a/ans-sdk-pop/build.gradle.kts
+++ b/ans-sdk-pop/build.gradle.kts
@@ -8,16 +8,11 @@ val mockitoVersion: String by project
val assertjVersion: String by project
dependencies {
- // Core, crypto, generated models
- api(project(":ans-sdk-core"))
- api(project(":ans-sdk-crypto"))
- api(project(":ans-sdk-api"))
+ // Crypto utils (internal use only)
+ implementation(project(":ans-sdk-crypto"))
// Transparency for StatusToken/ScittReceipt/RootKeyManager/DefaultScittVerifier reuse
- api(project(":ans-sdk-transparency"))
-
- // Agent-client for verification/trust surface reuse
- api(project(":ans-sdk-agent-client"))
+ implementation(project(":ans-sdk-transparency"))
// Nimbus JOSE + JWT for ES256 DPoP proof sign/verify
implementation("com.nimbusds:nimbus-jose-jwt:$nimbusJoseVersion")| 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.
PopAuthenticationFilter.Builder — safety guard not tested
File: PopAuthenticationFilterTest | Source: PopAuthenticationFilter.java:224-228
Builder.build() throws IllegalStateException when neither withExternalUrl(...) nor withTrustedHosts(...) is called. This guard prevents the HTU from being derived from the client-controlled Host header — a security misconfiguration the javadoc calls a "security defect". The guard is never invoked in any test. A future refactor could remove it silently.
// Missing test:
assertThatIllegalStateException()
.isThrownBy(() -> PopAuthenticationFilter
.builder("issuer.example.com", ROOT_KEYS, REPLAY)
.build())
.withMessageContaining("Host header");| 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, VERIFIED -> ErrorType.SCITT_HEADER_INVALID; | ||
| }; | ||
| return new PopException(type, expectation.failureReason()); | ||
| } |
There was a problem hiding this comment.
CallerVerifier.mapExpectation — AGENT_REVOKED / AGENT_INACTIVE paths not covered
File: CallerVerifierTest | Source: CallerVerifier.java:299-304
Two of the five STATUS_INVALID mappings are exercised (TOKEN_EXPIRED, INVALID_TOKEN indirectly). AGENT_REVOKED and AGENT_INACTIVE — the most security-critical status values, representing live revocation — have no test. KEY_NOT_FOUND is also absent. If the mapping switch were refactored, a revoked agent's proof could be accepted or map to the wrong error type without detection.
The existing FakeScitt helper makes adding these trivial:
// AGENT_REVOKED → STATUS_INVALID
new CallerVerifier(new FakeScitt(ScittExpectation.revoked()), DEFAULT_SKEW)
.verifyParsed(...) → ErrorType.STATUS_INVALIDIn addition, mapExpectation is only called when !expectation.isVerified(). Its switch includes VERIFIED → SCITT_HEADER_INVALID, which can never be reached. This is dead code.
It will never fail a test because it can never execute, but it creates confusion about whether VERIFIED was intentionally treated as an error. Worth removing or documenting.
| private static void verifyReceiptAgent(ScittReceipt receipt, StatusToken token) throws PopException { | ||
| byte[] payload = receipt.eventPayload(); | ||
| if (payload == null) { | ||
| throw new PopException(ErrorType.BINDING_FAILED, "receipt has no event payload"); | ||
| } | ||
| Map<String, Object> envelope; | ||
| try { | ||
| envelope = JSONObjectUtils.parse(new String(payload, StandardCharsets.UTF_8)); | ||
| } catch (ParseException e) { | ||
| throw new PopException(ErrorType.BINDING_FAILED, "receipt event payload is not valid JSON", e); | ||
| } |
There was a problem hiding this comment.
CallerVerifier.verifyReceiptAgent — null event payload not tested
File: CallerVerifierTest | Source: CallerVerifier.java:188-194
The if (payload == null) guard at the top of verifyReceiptAgent is never hit. All existing tests construct ScittReceipt with a non-null payload. This is the very first check in the method and the simplest to trigger:
ScittReceipt nullPayloadReceipt = new ScittReceipt(null, null, null, null, null);
// → BINDING_FAILED: "receipt has no event payload"
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