Skip to content

Feat/add dpop support - #108

Draft
jhateley-godaddy wants to merge 14 commits into
mainfrom
feat/add-dpop-support
Draft

Feat/add dpop support#108
jhateley-godaddy wants to merge 14 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 on lines +22 to +32
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

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.

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.

Comment on lines +36 to +38
public static CaffeineReplayCache create(int maxEntries) {
return create(maxEntries, Ticker.systemTicker());
}

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.

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.

Comment on lines +143 to +150
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
Collaborator

Choose a reason for hiding this comment

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

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) { ... }

Comment thread gradle.properties
reactorVersion=3.6.0
mcpSdkVersion=1.1.0
caffeineVersion=3.1.8
caffeineVersion=3.2.0

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.

Comment on lines +10 to +29
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")

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.

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")) — only CertificateUtils imported, inside CallerVerifier internally
  • api(project(":ans-sdk-transparency"))ScittReceipt, StatusToken etc. are consumed internally; all public methods on CallerVerifier accept/return only JDK and com.godaddy.ans.sdk.pop types

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")

Comment on lines +224 to +234
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
Collaborator

Choose a reason for hiding this comment

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

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");

Comment on lines +298 to +305
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());
}

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.

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_INVALID

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

Comment on lines +185 to +195
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);
}

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.

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"

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.

2 participants