From c1c6d6ac9fc4d53cf55f32c8fa7013f26ae8ff52 Mon Sep 17 00:00:00 2001 From: James Hateley Date: Thu, 27 Aug 2026 14:30:00 +1000 Subject: [PATCH 01/14] feat(pop): add module scaffold Signed-off-by: James Hateley --- ans-sdk-pop/build.gradle.kts | 33 +++++++++++++++++++++++++++++++++ build.gradle.kts | 1 + settings.gradle.kts | 1 + 3 files changed, 35 insertions(+) create mode 100644 ans-sdk-pop/build.gradle.kts diff --git a/ans-sdk-pop/build.gradle.kts b/ans-sdk-pop/build.gradle.kts new file mode 100644 index 0000000..fb460cc --- /dev/null +++ b/ans-sdk-pop/build.gradle.kts @@ -0,0 +1,33 @@ +val nimbusJoseVersion: String by project +val slf4jVersion: String by project +val cborVersion: String by project +val junitVersion: String by project +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")) + + // 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") + + // Logging + implementation("org.slf4j:slf4j-api:$slf4jVersion") + + // Testing + testImplementation("org.junit.jupiter:junit-jupiter:$junitVersion") + testImplementation("org.mockito:mockito-core:$mockitoVersion") + testImplementation("org.mockito:mockito-junit-jupiter:$mockitoVersion") + testImplementation("org.assertj:assertj-core:$assertjVersion") + testImplementation("com.upokecenter:cbor:$cborVersion") + testRuntimeOnly("org.slf4j:slf4j-simple:$slf4jVersion") +} diff --git a/build.gradle.kts b/build.gradle.kts index 72e1b12..1facae6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,6 +20,7 @@ val publishableModules = setOf( "ans-sdk-discovery", "ans-sdk-agent-client", "ans-sdk-transparency", + "ans-sdk-pop", "ans-sdk-spring-boot-starter" ) diff --git a/settings.gradle.kts b/settings.gradle.kts index f9067be..d6a1fe8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,6 +7,7 @@ include("ans-sdk-registration") include("ans-sdk-discovery") include("ans-sdk-agent-client") include("ans-sdk-transparency") +include("ans-sdk-pop") include("ans-sdk-spring-boot-starter") // Examples - not published to Maven, but useful for users of the SDK to reference and run locally From dea365070bb133642f8b98451fb643f5293a173c Mon Sep 17 00:00:00 2001 From: James Hateley Date: Thu, 27 Aug 2026 14:55:47 +1000 Subject: [PATCH 02/14] feat(pop): add jws parse, sign, verify Signed-off-by: James Hateley --- .../com/godaddy/ans/sdk/pop/Base64Url.java | 23 +++ .../com/godaddy/ans/sdk/pop/ErrorType.java | 21 +++ .../java/com/godaddy/ans/sdk/pop/Jws.java | 69 ++++++++ .../com/godaddy/ans/sdk/pop/PopException.java | 24 +++ .../godaddy/ans/sdk/pop/Base64UrlTest.java | 32 ++++ .../java/com/godaddy/ans/sdk/pop/JwsTest.java | 149 ++++++++++++++++++ 6 files changed, 318 insertions(+) create mode 100644 ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Base64Url.java create mode 100644 ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ErrorType.java create mode 100644 ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Jws.java create mode 100644 ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopException.java create mode 100644 ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/Base64UrlTest.java create mode 100644 ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/JwsTest.java diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Base64Url.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Base64Url.java new file mode 100644 index 0000000..25ce67d --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Base64Url.java @@ -0,0 +1,23 @@ +package com.godaddy.ans.sdk.pop; + +import java.util.Base64; +import java.util.Objects; + +public final class Base64Url { + + private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder DECODER = Base64.getUrlDecoder(); + + private Base64Url() { + } + + public static String encode(byte[] data) { + Objects.requireNonNull(data, "data"); + return ENCODER.encodeToString(data); + } + + public static byte[] decode(String value) { + Objects.requireNonNull(value, "value"); + return DECODER.decode(value); + } +} diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ErrorType.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ErrorType.java new file mode 100644 index 0000000..6e09de5 --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ErrorType.java @@ -0,0 +1,21 @@ +package com.godaddy.ans.sdk.pop; + +public enum ErrorType { + MALFORMED_PROOF, + UNSUPPORTED_ALG, + HTTP_BINDING_MISMATCH, + PROOF_STALE, + REPLAY, + REPLAY_CACHE_FULL, + SIGNATURE_INVALID, + CERT_INVALID, + KEY_MISMATCH, + TOKEN_BINDING_MISMATCH, + BINDING_FAILED, + STATUS_INVALID, + RECEIPT_INVALID, + MISSING_HEADERS, + SCITT_HEADER_INVALID, + MISCONFIGURED, + EXPECTED_PEER_MISMATCH +} diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Jws.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Jws.java new file mode 100644 index 0000000..032c61b --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Jws.java @@ -0,0 +1,69 @@ +package com.godaddy.ans.sdk.pop; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSObject; +import com.nimbusds.jose.Payload; +import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jose.crypto.ECDSAVerifier; + +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.text.ParseException; +import java.util.Set; + +final class Jws { + + static final JOSEObjectType DPOP_TYP = new JOSEObjectType("dpop+jwt"); + + static final Set ALLOWED_HEADER_PARAMS = Set.of("typ", "alg", "jwk", "x5c"); + + private Jws() { + } + + static JWSObject strictParse(String compactJws) throws PopException { + JWSObject jws; + try { + jws = JWSObject.parse(compactJws); + } catch (ParseException e) { + throw new PopException(ErrorType.MALFORMED_PROOF, "proof is not a valid compact JWS", e); + } + + JWSHeader header = jws.getHeader(); + + if (!ALLOWED_HEADER_PARAMS.equals(header.getIncludedParams())) { + throw new PopException(ErrorType.MALFORMED_PROOF, + "proof header params must be exactly {typ, alg, jwk, x5c}"); + } + + if (!DPOP_TYP.equals(header.getType())) { + throw new PopException(ErrorType.MALFORMED_PROOF, "proof typ must be dpop+jwt"); + } + + if (!JWSAlgorithm.ES256.equals(header.getAlgorithm())) { + throw new PopException(ErrorType.UNSUPPORTED_ALG, "proof alg must be ES256"); + } + + return jws; + } + + static String sign(JWSHeader header, Payload payload, ECPrivateKey key) throws PopException { + try { + JWSObject jws = new JWSObject(header, payload); + jws.sign(new ECDSASigner(key)); + return jws.serialize(); + } catch (JOSEException e) { + throw new PopException(ErrorType.MISCONFIGURED, "failed to sign DPoP proof", e); + } + } + + static boolean verify(JWSObject jws, ECPublicKey key) throws PopException { + try { + return jws.verify(new ECDSAVerifier(key)); + } catch (JOSEException e) { + throw new PopException(ErrorType.SIGNATURE_INVALID, "failed to verify DPoP proof signature", e); + } + } +} diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopException.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopException.java new file mode 100644 index 0000000..379e117 --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopException.java @@ -0,0 +1,24 @@ +package com.godaddy.ans.sdk.pop; + +import java.util.Objects; + +public class PopException extends Exception { + + private static final long serialVersionUID = 1L; + + private final ErrorType category; + + public PopException(ErrorType category, String message) { + super(message); + this.category = Objects.requireNonNull(category, "category"); + } + + public PopException(ErrorType category, String message, Throwable cause) { + super(message, cause); + this.category = Objects.requireNonNull(category, "category"); + } + + public ErrorType category() { + return category; + } +} diff --git a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/Base64UrlTest.java b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/Base64UrlTest.java new file mode 100644 index 0000000..fc9b620 --- /dev/null +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/Base64UrlTest.java @@ -0,0 +1,32 @@ +package com.godaddy.ans.sdk.pop; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class Base64UrlTest { + + @Test + void roundTripsArbitraryBytes() { + byte[] data = new byte[256]; + for (int i = 0; i < data.length; i++) { + data[i] = (byte) i; + } + + String encoded = Base64Url.encode(data); + + assertThat(Base64Url.decode(encoded)).isEqualTo(data); + } + + @Test + void encodesUrlSafeWithoutPadding() { + byte[] data = "some data.".getBytes(StandardCharsets.UTF_8); + + String encoded = Base64Url.encode(data); + + assertThat(encoded).doesNotContain("=").doesNotContain("+").doesNotContain("/"); + assertThat(Base64Url.decode(encoded)).isEqualTo(data); + } +} diff --git a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/JwsTest.java b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/JwsTest.java new file mode 100644 index 0000000..84ed669 --- /dev/null +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/JwsTest.java @@ -0,0 +1,149 @@ +package com.godaddy.ans.sdk.pop; + +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSObject; +import com.nimbusds.jose.Payload; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.util.Base64; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +class JwsTest { + + private static ECPrivateKey p256Private; + private static ECPublicKey p256Public; + private static ECPublicKey otherPublic; + private static ECKey p256Jwk; + private static Base64 x5c; + + private static ECPrivateKey p384Private; + private static ECKey p384Jwk; + + @BeforeAll + static void keys() throws Exception { + KeyPair a = generate("secp256r1"); + p256Private = (ECPrivateKey) a.getPrivate(); + p256Public = (ECPublicKey) a.getPublic(); + p256Jwk = new ECKey.Builder(Curve.P_256, p256Public).build().toPublicJWK(); + x5c = Base64.encode(p256Public.getEncoded()); + + otherPublic = (ECPublicKey) generate("secp256r1").getPublic(); + + KeyPair b = generate("secp384r1"); + p384Private = (ECPrivateKey) b.getPrivate(); + p384Jwk = new ECKey.Builder(Curve.P_384, (ECPublicKey) b.getPublic()).build().toPublicJWK(); + } + + private static KeyPair generate(String curve) throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); + kpg.initialize(new ECGenParameterSpec(curve)); + return kpg.generateKeyPair(); + } + + private static JWSHeader.Builder dpopHeader(JWSAlgorithm alg, ECKey jwk) { + return new JWSHeader.Builder(alg) + .type(Jws.DPOP_TYP) + .jwk(jwk) + .x509CertChain(List.of(x5c)); + } + + private static String signed(JWSHeader header, ECPrivateKey key) throws Exception { + return Jws.sign(header, new Payload("{}"), key); + } + + @Test + void strictParseAcceptsValidDpopHeader() throws Exception { + String compact = signed(dpopHeader(JWSAlgorithm.ES256, p256Jwk).build(), p256Private); + + JWSObject jws = Jws.strictParse(compact); + + assertThat(jws.getHeader().getIncludedParams()) + .isEqualTo(Jws.ALLOWED_HEADER_PARAMS); + } + + @Test + void strictParseRejectsExtraHeaderParam() throws Exception { + String compact = signed( + dpopHeader(JWSAlgorithm.ES256, p256Jwk).customParam("nonce", "abc").build(), + p256Private); + + PopException ex = catchThrowableOfType(() -> Jws.strictParse(compact), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MALFORMED_PROOF); + } + + @Test + void strictParseRejectsMissingHeaderParam() throws Exception { + JWSHeader noX5c = new JWSHeader.Builder(JWSAlgorithm.ES256) + .type(Jws.DPOP_TYP) + .jwk(p256Jwk) + .build(); + String compact = signed(noX5c, p256Private); + + PopException ex = catchThrowableOfType(() -> Jws.strictParse(compact), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MALFORMED_PROOF); + } + + @Test + void strictParseRejectsWrongTyp() throws Exception { + JWSHeader wrongTyp = new JWSHeader.Builder(JWSAlgorithm.ES256) + .type(new JOSEObjectType("jwt")) + .jwk(p256Jwk) + .x509CertChain(List.of(x5c)) + .build(); + String compact = signed(wrongTyp, p256Private); + + PopException ex = catchThrowableOfType(() -> Jws.strictParse(compact), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MALFORMED_PROOF); + } + + @Test + void strictParseRejectsWrongAlg() throws Exception { + String compact = signed(dpopHeader(JWSAlgorithm.ES384, p384Jwk).build(), p384Private); + + PopException ex = catchThrowableOfType(() -> Jws.strictParse(compact), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.UNSUPPORTED_ALG); + } + + @Test + void strictParseRejectsNonJws() { + assertThatThrownBy(() -> Jws.strictParse("this-is-not-a-jws")) + .isInstanceOf(PopException.class) + .extracting(e -> ((PopException) e).category()) + .isEqualTo(ErrorType.MALFORMED_PROOF); + } + + @Test + void verifyRoundTripsUnderMatchedKey() throws Exception { + String compact = signed(dpopHeader(JWSAlgorithm.ES256, p256Jwk).build(), p256Private); + JWSObject jws = Jws.strictParse(compact); + + assertThat(Jws.verify(jws, p256Public)).isTrue(); + } + + @Test + void verifyFailsUnderWrongKey() throws Exception { + String compact = signed(dpopHeader(JWSAlgorithm.ES256, p256Jwk).build(), p256Private); + JWSObject jws = Jws.strictParse(compact); + + assertThat(Jws.verify(jws, otherPublic)).isFalse(); + } +} From 109e7ec182b947e606b7ea2e975bfc28ab649951 Mon Sep 17 00:00:00 2001 From: James Hateley Date: Thu, 27 Aug 2026 16:33:13 +1000 Subject: [PATCH 03/14] feat(pop): add claims parsing and proof validation Signed-off-by: James Hateley --- ans-sdk-pop/build.gradle.kts | 3 + .../java/com/godaddy/ans/sdk/pop/Proof.java | 222 +++++++++ .../com/godaddy/ans/sdk/pop/ProofTest.java | 453 ++++++++++++++++++ gradle.properties | 2 +- 4 files changed, 679 insertions(+), 1 deletion(-) create mode 100644 ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Proof.java create mode 100644 ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/ProofTest.java diff --git a/ans-sdk-pop/build.gradle.kts b/ans-sdk-pop/build.gradle.kts index fb460cc..3d52757 100644 --- a/ans-sdk-pop/build.gradle.kts +++ b/ans-sdk-pop/build.gradle.kts @@ -1,4 +1,5 @@ val nimbusJoseVersion: String by project +val bouncyCastleVersion: String by project val slf4jVersion: String by project val cborVersion: String by project val junitVersion: String by project @@ -28,6 +29,8 @@ dependencies { testImplementation("org.mockito:mockito-core:$mockitoVersion") testImplementation("org.mockito:mockito-junit-jupiter:$mockitoVersion") testImplementation("org.assertj:assertj-core:$assertjVersion") + testImplementation("org.bouncycastle:bcpkix-jdk18on:$bouncyCastleVersion") + testImplementation("org.bouncycastle:bcprov-jdk18on:$bouncyCastleVersion") testImplementation("com.upokecenter:cbor:$cborVersion") testRuntimeOnly("org.slf4j:slf4j-simple:$slf4jVersion") } diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Proof.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Proof.java new file mode 100644 index 0000000..72c4fa8 --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Proof.java @@ -0,0 +1,222 @@ +package com.godaddy.ans.sdk.pop; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSObject; +import com.nimbusds.jose.Payload; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.util.Base64; + +import java.io.ByteArrayInputStream; +import java.math.BigInteger; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECPoint; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +final class Proof { + + static final int P256_FIELD_BYTES = 32; + + private Proof() { + } + + record Header(JWSObject jws, ECKey jwk, X509Certificate cert, ECPublicKey publicKey) { + } + + record Claims(String htm, String htu, Instant iat, String jti, String ath) { + } + + static Header acceptES256DPoP(String compactJws) throws PopException { + JWSObject jws = Jws.strictParse(compactJws); + JWSHeader header = jws.getHeader(); + + ECKey jwk = extractPublicEcKey(header); + X509Certificate cert = extractLeafCertificate(header); + ECPublicKey matched = matchJWKToCert(jwk, cert); + + return new Header(jws, jwk, cert, matched); + } + + static ECPublicKey matchJWKToCert(ECKey jwk, X509Certificate cert) throws PopException { + ECPublicKey jwkKey; + try { + jwkKey = jwk.toECPublicKey(); + } catch (JOSEException e) { + throw new PopException(ErrorType.MALFORMED_PROOF, "jwk is not a usable EC public key", e); + } + + if (!(cert.getPublicKey() instanceof ECPublicKey certKey)) { + throw new PopException(ErrorType.CERT_INVALID, "x5c leaf key is not EC"); + } + + if (!Arrays.equals(coordinates(jwkKey), coordinates(certKey))) { + throw new PopException(ErrorType.KEY_MISMATCH, "jwk coordinates do not match x5c leaf key"); + } + + return jwkKey; + } + + static byte[] coordinates(ECPublicKey key) { + ECPoint point = key.getW(); + byte[] x = fieldElement(point.getAffineX()); + byte[] y = fieldElement(point.getAffineY()); + byte[] out = new byte[P256_FIELD_BYTES * 2]; + System.arraycopy(x, 0, out, 0, P256_FIELD_BYTES); + System.arraycopy(y, 0, out, P256_FIELD_BYTES, P256_FIELD_BYTES); + return out; + } + + static String jkt(ECKey jwk) throws PopException { + try { + return jwk.computeThumbprint().toString(); + } catch (JOSEException e) { + throw new PopException(ErrorType.MISCONFIGURED, "failed to compute jwk thumbprint", e); + } + } + + static String accessTokenHash(String accessToken) { + return Base64Url.encode(sha256(accessToken.getBytes(StandardCharsets.UTF_8))); + } + + static String normalizeHTU(String rawUrl) throws PopException { + URI uri; + try { + uri = new URI(rawUrl); + } catch (URISyntaxException e) { + throw new PopException(ErrorType.HTTP_BINDING_MISMATCH, "htu is not a valid URI", e); + } + + String scheme = uri.getScheme(); + String host = uri.getHost(); + if (scheme == null || host == null) { + throw new PopException(ErrorType.HTTP_BINDING_MISMATCH, "htu must have scheme and host"); + } + scheme = scheme.toLowerCase(Locale.ROOT); + host = host.toLowerCase(Locale.ROOT); + + int port = uri.getPort(); + boolean defaultPort = port == -1 + || (scheme.equals("http") && port == 80) + || (scheme.equals("https") && port == 443); + + String path = uri.getRawPath(); + if (path == null || path.isEmpty()) { + path = "/"; + } + + StringBuilder sb = new StringBuilder(scheme).append("://").append(host); + if (!defaultPort) { + sb.append(':').append(port); + } + return sb.append(path).toString(); + } + + static Claims parseClaims(Payload payload) throws PopException { + Map map = payload.toJSONObject(); + if (map == null) { + throw new PopException(ErrorType.MALFORMED_PROOF, "proof payload is not a JSON object"); + } + return new Claims( + stringClaim(map, "htm"), + stringClaim(map, "htu"), + instantClaim(map, "iat"), + stringClaim(map, "jti"), + stringClaim(map, "ath")); + } + + private static ECKey extractPublicEcKey(JWSHeader header) throws PopException { + JWK jwk = header.getJWK(); + if (!(jwk instanceof ECKey ecKey)) { + throw new PopException(ErrorType.MALFORMED_PROOF, "jwk must be an EC key"); + } + if (!Curve.P_256.equals(ecKey.getCurve())) { + throw new PopException(ErrorType.MALFORMED_PROOF, "jwk curve must be P-256"); + } + if (ecKey.isPrivate()) { + throw new PopException(ErrorType.MALFORMED_PROOF, "jwk must not contain a private key"); + } + return ecKey; + } + + private static X509Certificate extractLeafCertificate(JWSHeader header) throws PopException { + List chain = header.getX509CertChain(); + if (chain == null || chain.size() != 1) { + throw new PopException(ErrorType.CERT_INVALID, "x5c must contain exactly one certificate"); + } + + X509Certificate cert; + try { + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + cert = (X509Certificate) factory.generateCertificate( + new ByteArrayInputStream(chain.get(0).decode())); + } catch (CertificateException e) { + throw new PopException(ErrorType.CERT_INVALID, "x5c leaf is not a valid X.509 certificate", e); + } + + if (!(cert.getPublicKey() instanceof ECPublicKey ecPublicKey)) { + throw new PopException(ErrorType.CERT_INVALID, "x5c leaf key is not EC"); + } + if (!Curve.P_256.equals(Curve.forECParameterSpec(ecPublicKey.getParams()))) { + throw new PopException(ErrorType.CERT_INVALID, "x5c leaf key must be P-256"); + } + return cert; + } + + private static byte[] fieldElement(BigInteger value) { + byte[] raw = value.toByteArray(); + if (raw.length == P256_FIELD_BYTES) { + return raw; + } + byte[] out = new byte[P256_FIELD_BYTES]; + if (raw.length > P256_FIELD_BYTES) { + System.arraycopy(raw, raw.length - P256_FIELD_BYTES, out, 0, P256_FIELD_BYTES); + } else { + System.arraycopy(raw, 0, out, P256_FIELD_BYTES - raw.length, raw.length); + } + return out; + } + + private static String stringClaim(Map map, String name) throws PopException { + Object value = map.get(name); + if (value == null) { + return null; + } + if (!(value instanceof String s)) { + throw new PopException(ErrorType.MALFORMED_PROOF, "claim " + name + " must be a string"); + } + return s; + } + + private static Instant instantClaim(Map map, String name) throws PopException { + Object value = map.get(name); + if (value == null) { + return null; + } + if (!(value instanceof Number n)) { + throw new PopException(ErrorType.MALFORMED_PROOF, "claim " + name + " must be a number"); + } + return Instant.ofEpochSecond(n.longValue()); + } + + private static byte[] sha256(byte[] input) { + try { + return MessageDigest.getInstance("SHA-256").digest(input); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } +} diff --git a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/ProofTest.java b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/ProofTest.java new file mode 100644 index 0000000..4ea35ea --- /dev/null +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/ProofTest.java @@ -0,0 +1,453 @@ +package com.godaddy.ans.sdk.pop; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.Payload; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.util.Base64; +import com.nimbusds.jose.util.Base64URL; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.security.spec.ECParameterSpec; +import java.security.spec.ECPoint; +import java.time.Instant; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +class ProofTest { + + private static KeyPair p256A; + private static KeyPair p256B; + private static ECKey jwkA; + private static X509Certificate certA; + private static X509Certificate certB; + private static Base64 x5cA; + + private static KeyPair p384; + private static ECKey jwk384; + private static X509Certificate cert384; + + private static X509Certificate rsaCert; + + @BeforeAll + static void setUp() throws Exception { + Security.addProvider(new BouncyCastleProvider()); + + p256A = ec("secp256r1"); + p256B = ec("secp256r1"); + jwkA = publicEcJwk(p256A, Curve.P_256); + certA = selfSigned(p256A, "SHA256withECDSA"); + certB = selfSigned(p256B, "SHA256withECDSA"); + x5cA = Base64.encode(certA.getEncoded()); + + p384 = ec("secp384r1"); + jwk384 = publicEcJwk(p384, Curve.P_384); + cert384 = selfSigned(p384, "SHA384withECDSA"); + + KeyPairGenerator rsaGen = KeyPairGenerator.getInstance("RSA"); + rsaGen.initialize(2048); + rsaCert = selfSigned(rsaGen.generateKeyPair(), "SHA256withRSA"); + } + + @Test + void acceptReturnsMatchedKeyOnHappyPath() throws Exception { + String compact = signedProof(header(JWSAlgorithm.ES256, jwkA, List.of(x5cA)), p256A); + + Proof.Header result = Proof.acceptES256DPoP(compact); + + assertThat(result.cert()).isEqualTo(certA); + assertThat(Proof.coordinates(result.publicKey())) + .isEqualTo(Proof.coordinates((ECPublicKey) p256A.getPublic())); + } + + @Test + void acceptRejectsWrongAlg() throws Exception { + String compact = signedProof(header(JWSAlgorithm.ES384, jwk384, List.of(x5cA)), p384); + + PopException ex = catchThrowableOfType(() -> Proof.acceptES256DPoP(compact), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.UNSUPPORTED_ALG); + } + + @Test + void acceptRejectsWrongTyp() throws Exception { + JWSHeader wrongTyp = new JWSHeader.Builder(JWSAlgorithm.ES256) + .type(new com.nimbusds.jose.JOSEObjectType("jwt")) + .jwk(jwkA) + .x509CertChain(List.of(x5cA)) + .build(); + String compact = signedProof(wrongTyp, p256A); + + PopException ex = catchThrowableOfType(() -> Proof.acceptES256DPoP(compact), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MALFORMED_PROOF); + } + + @Test + void acceptRejectsPrivateJwk() { + ECKey privateJwk = new ECKey.Builder(Curve.P_256, (ECPublicKey) p256A.getPublic()) + .privateKey((ECPrivateKey) p256A.getPrivate()) + .build(); + String headerJson = "{\"typ\":\"dpop+jwt\",\"alg\":\"ES256\",\"jwk\":" + + privateJwk.toJSONString() + ",\"x5c\":[\"" + x5cA.toString() + "\"]}"; + String compact = Base64URL.encode(headerJson) + "." + Base64URL.encode("{}") + + "." + Base64URL.encode(new byte[]{1, 2, 3}); + + PopException ex = catchThrowableOfType(() -> Proof.acceptES256DPoP(compact), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MALFORMED_PROOF); + } + + @Test + void acceptRejectsNonEcJwk() throws Exception { + KeyPairGenerator rsaGen = KeyPairGenerator.getInstance("RSA"); + rsaGen.initialize(2048); + RSAKey rsaJwk = new RSAKey.Builder((RSAPublicKey) rsaGen.generateKeyPair().getPublic()).build(); + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.ES256) + .type(Jws.DPOP_TYP) + .jwk(rsaJwk) + .x509CertChain(List.of(x5cA)) + .build(); + String compact = signedProof(header, p256A); + + PopException ex = catchThrowableOfType(() -> Proof.acceptES256DPoP(compact), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MALFORMED_PROOF); + } + + @Test + void acceptRejectsNonP256Jwk() throws Exception { + String compact = signedProof(header(JWSAlgorithm.ES256, jwk384, List.of(x5cA)), p256A); + + PopException ex = catchThrowableOfType(() -> Proof.acceptES256DPoP(compact), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MALFORMED_PROOF); + } + + @Test + void acceptRejectsMultipleCerts() throws Exception { + Base64 x5cB = Base64.encode(certB.getEncoded()); + String compact = signedProof(header(JWSAlgorithm.ES256, jwkA, List.of(x5cA, x5cB)), p256A); + + PopException ex = catchThrowableOfType(() -> Proof.acceptES256DPoP(compact), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.CERT_INVALID); + } + + @Test + void acceptRejectsNonP256LeafCert() throws Exception { + Base64 x5c384 = Base64.encode(cert384.getEncoded()); + String compact = signedProof(header(JWSAlgorithm.ES256, jwkA, List.of(x5c384)), p256A); + + PopException ex = catchThrowableOfType(() -> Proof.acceptES256DPoP(compact), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.CERT_INVALID); + } + + @Test + void acceptRejectsNonEcLeafCert() throws Exception { + Base64 x5cRsa = Base64.encode(rsaCert.getEncoded()); + String compact = signedProof(header(JWSAlgorithm.ES256, jwkA, List.of(x5cRsa)), p256A); + + PopException ex = catchThrowableOfType(() -> Proof.acceptES256DPoP(compact), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.CERT_INVALID); + } + + @Test + void acceptRejectsCoordMismatch() throws Exception { + Base64 x5cB = Base64.encode(certB.getEncoded()); + String compact = signedProof(header(JWSAlgorithm.ES256, jwkA, List.of(x5cB)), p256A); + + PopException ex = catchThrowableOfType(() -> Proof.acceptES256DPoP(compact), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.KEY_MISMATCH); + } + + @Test + void matchReturnsKeyForMatchingPair() throws Exception { + ECPublicKey matched = Proof.matchJWKToCert(jwkA, certA); + + assertThat(Proof.coordinates(matched)) + .isEqualTo(Proof.coordinates((ECPublicKey) p256A.getPublic())); + } + + @Test + void matchRejectsNonEcCert() { + PopException ex = catchThrowableOfType(() -> Proof.matchJWKToCert(jwkA, rsaCert), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.CERT_INVALID); + } + + @Test + void acceptRejectsUnparseableLeafCert() throws Exception { + Base64 badCert = Base64.encode(new byte[]{1, 2, 3}); + String compact = signedProof(header(JWSAlgorithm.ES256, jwkA, List.of(badCert)), p256A); + + PopException ex = catchThrowableOfType(() -> Proof.acceptES256DPoP(compact), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.CERT_INVALID); + } + + @Test + void coordinatesDifferForDifferentKeys() { + byte[] a = Proof.coordinates((ECPublicKey) p256A.getPublic()); + byte[] b = Proof.coordinates((ECPublicKey) p256B.getPublic()); + + assertThat(a).hasSize(64); + assertThat(a).isNotEqualTo(b); + } + + @Test + void coordinatesNormalizeOversizedAndUndersizedFieldElements() { + // X = 2^256 - 1 -> toByteArray() is 33 bytes (leading sign byte) -> trim path. + // Y = 1 -> toByteArray() is 1 byte -> left-pad path. + BigInteger oversizedX = BigInteger.ONE.shiftLeft(256).subtract(BigInteger.ONE); + ECPublicKey key = fixedCoordinateKey(oversizedX, BigInteger.ONE); + + byte[] coords = Proof.coordinates(key); + + assertThat(coords).hasSize(64); + byte[] expectedX = new byte[32]; + Arrays.fill(expectedX, (byte) 0xFF); + assertThat(Arrays.copyOfRange(coords, 0, 32)).isEqualTo(expectedX); + byte[] expectedY = new byte[32]; + expectedY[31] = 1; + assertThat(Arrays.copyOfRange(coords, 32, 64)).isEqualTo(expectedY); + } + + @Test + void normalizeHtuLowercasesSchemeAndHost() throws Exception { + assertThat(Proof.normalizeHTU("HTTPS://API.Example.COM/Agents")) + .isEqualTo("https://api.example.com/Agents"); + } + + @Test + void normalizeHtuDropsDefaultHttpsPort() throws Exception { + assertThat(Proof.normalizeHTU("https://api.example.com:443/x")) + .isEqualTo("https://api.example.com/x"); + } + + @Test + void normalizeHtuDropsDefaultHttpPort() throws Exception { + assertThat(Proof.normalizeHTU("http://api.example.com:80/x")) + .isEqualTo("http://api.example.com/x"); + } + + @Test + void normalizeHtuKeepsNonDefaultPort() throws Exception { + assertThat(Proof.normalizeHTU("https://api.example.com:8443/x")) + .isEqualTo("https://api.example.com:8443/x"); + } + + @Test + void normalizeHtuDropsQueryAndFragment() throws Exception { + assertThat(Proof.normalizeHTU("https://api.example.com/x?a=1&b=2#frag")) + .isEqualTo("https://api.example.com/x"); + } + + @Test + void normalizeHtuEmptyPathBecomesSlash() throws Exception { + assertThat(Proof.normalizeHTU("https://api.example.com")).isEqualTo("https://api.example.com/"); + } + + @Test + void normalizeHtuPreservesPathCase() throws Exception { + assertThat(Proof.normalizeHTU("https://api.example.com/Mixed/Case/Path")) + .isEqualTo("https://api.example.com/Mixed/Case/Path"); + } + + @Test + void normalizeHtuDoesNotCanonicalizeDotSegments() throws Exception { + assertThat(Proof.normalizeHTU("https://api.example.com/a/../b")) + .isEqualTo("https://api.example.com/a/../b"); + } + + @Test + void normalizeHtuRejectsMissingScheme() { + PopException ex = catchThrowableOfType(() -> Proof.normalizeHTU("//api.example.com/x"), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.HTTP_BINDING_MISMATCH); + } + + @Test + void normalizeHtuRejectsInvalidUri() { + PopException ex = catchThrowableOfType(() -> Proof.normalizeHTU("http://exa mple.com/x"), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.HTTP_BINDING_MISMATCH); + } + + @Test + void jktMatchesNimbusThumbprint() throws Exception { + String jkt = Proof.jkt(jwkA); + + assertThat(jkt).isEqualTo(jwkA.computeThumbprint().toString()); + assertThat(jkt).doesNotContain("="); + } + + @Test + void accessTokenHashKnownVector() { + String token = "Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU"; + + assertThat(Proof.accessTokenHash(token)).isEqualTo("fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo"); + } + + @Test + void parseClaimsRoundTrips() throws Exception { + Payload payload = new Payload(Map.of( + "htm", "POST", + "htu", "https://api.example.com/x", + "iat", 1700000000L, + "jti", "unique-id", + "ath", "abc")); + + Proof.Claims claims = Proof.parseClaims(payload); + + assertThat(claims.htm()).isEqualTo("POST"); + assertThat(claims.htu()).isEqualTo("https://api.example.com/x"); + assertThat(claims.iat()).isEqualTo(Instant.ofEpochSecond(1700000000L)); + assertThat(claims.jti()).isEqualTo("unique-id"); + assertThat(claims.ath()).isEqualTo("abc"); + } + + @Test + void parseClaimsToleratesExtraClaimsAndMissingAth() throws Exception { + Payload payload = new Payload(Map.of( + "htm", "GET", + "htu", "https://api.example.com/", + "iat", 1700000000L, + "jti", "id", + "extra", "ignored")); + + Proof.Claims claims = Proof.parseClaims(payload); + + assertThat(claims.ath()).isNull(); + assertThat(claims.htm()).isEqualTo("GET"); + } + + @Test + void parseClaimsRejectsNonStringJti() { + Payload payload = new Payload(Map.of("jti", 123)); + + PopException ex = catchThrowableOfType(() -> Proof.parseClaims(payload), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MALFORMED_PROOF); + } + + @Test + void parseClaimsRejectsNonNumberIat() { + Payload payload = new Payload(Map.of("iat", "not-a-number")); + + PopException ex = catchThrowableOfType(() -> Proof.parseClaims(payload), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MALFORMED_PROOF); + } + + @Test + void parseClaimsRejectsNonObjectPayload() { + Payload payload = new Payload("not a json object"); + + PopException ex = catchThrowableOfType(() -> Proof.parseClaims(payload), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MALFORMED_PROOF); + } + + private static KeyPair ec(String curve) throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); + kpg.initialize(new ECGenParameterSpec(curve)); + return kpg.generateKeyPair(); + } + + private static ECKey publicEcJwk(KeyPair pair, Curve curve) { + return new ECKey.Builder(curve, (ECPublicKey) pair.getPublic()).build().toPublicJWK(); + } + + private static ECPublicKey fixedCoordinateKey(BigInteger x, BigInteger y) { + ECPoint point = new ECPoint(x, y); + return new ECPublicKey() { + @Override + public ECPoint getW() { + return point; + } + + @Override + public ECParameterSpec getParams() { + return null; + } + + @Override + public String getAlgorithm() { + return "EC"; + } + + @Override + public String getFormat() { + return null; + } + + @Override + public byte[] getEncoded() { + return null; + } + }; + } + + private static JWSHeader header(JWSAlgorithm alg, com.nimbusds.jose.jwk.JWK jwk, List x5c) { + return new JWSHeader.Builder(alg) + .type(Jws.DPOP_TYP) + .jwk(jwk) + .x509CertChain(x5c) + .build(); + } + + private static String signedProof(JWSHeader header, KeyPair signingPair) throws Exception { + return Jws.sign(header, new Payload("{}"), (ECPrivateKey) signingPair.getPrivate()); + } + + private static X509Certificate selfSigned(KeyPair pair, String sigAlg) throws Exception { + X500Name subject = new X500Name("CN=test"); + BigInteger serial = BigInteger.valueOf(1); + Date notBefore = new Date(1_600_000_000_000L); + Date notAfter = new Date(4_100_000_000_000L); + + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + subject, serial, notBefore, notAfter, subject, pair.getPublic()); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false)); + + ContentSigner signer = new JcaContentSignerBuilder(sigAlg) + .setProvider(BouncyCastleProvider.PROVIDER_NAME) + .build(pair.getPrivate()); + + return new JcaX509CertificateConverter() + .setProvider(BouncyCastleProvider.PROVIDER_NAME) + .getCertificate(builder.build(signer)); + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index ca9ed5a..386bbad 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ # Project versions jacksonVersion=2.16.1 slf4jVersion=2.0.9 -bouncyCastleVersion=1.79 +bouncyCastleVersion=1.84 reactorVersion=3.6.0 mcpSdkVersion=1.1.0 caffeineVersion=3.1.8 From bc08c5e3846106fdc43e5bb6782b2099e292ab39 Mon Sep 17 00:00:00 2001 From: James Hateley Date: Mon, 31 Aug 2026 08:56:55 +1000 Subject: [PATCH 04/14] feat(pop): add replay cache, signer and verifier Signed-off-by: James Hateley --- .../ans/sdk/pop/CaffeineReplayCache.java | 39 ++ .../godaddy/ans/sdk/pop/CallerIdentity.java | 14 + .../godaddy/ans/sdk/pop/CallerOptions.java | 45 ++ .../godaddy/ans/sdk/pop/CallerVerifier.java | 256 +++++++++++ .../ans/sdk/pop/DpopProofVerifier.java | 155 +++++++ .../com/godaddy/ans/sdk/pop/PopSigner.java | 127 ++++++ .../com/godaddy/ans/sdk/pop/ProofResult.java | 15 + .../com/godaddy/ans/sdk/pop/ReplayCache.java | 8 + .../godaddy/ans/sdk/pop/VerifyOptions.java | 24 ++ .../ans/sdk/pop/CaffeineReplayCacheTest.java | 121 ++++++ .../ans/sdk/pop/CallerVerifierTest.java | 345 +++++++++++++++ .../ans/sdk/pop/DpopProofVerifierTest.java | 404 ++++++++++++++++++ .../godaddy/ans/sdk/pop/PopSignerTest.java | 226 ++++++++++ 13 files changed, 1779 insertions(+) create mode 100644 ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CaffeineReplayCache.java create mode 100644 ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerIdentity.java create mode 100644 ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerOptions.java create mode 100644 ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java create mode 100644 ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/DpopProofVerifier.java create mode 100644 ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopSigner.java create mode 100644 ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ProofResult.java create mode 100644 ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ReplayCache.java create mode 100644 ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/VerifyOptions.java create mode 100644 ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CaffeineReplayCacheTest.java create mode 100644 ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerVerifierTest.java create mode 100644 ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/DpopProofVerifierTest.java create mode 100644 ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/PopSignerTest.java diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CaffeineReplayCache.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CaffeineReplayCache.java new file mode 100644 index 0000000..b1f7251 --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CaffeineReplayCache.java @@ -0,0 +1,39 @@ +package com.godaddy.ans.sdk.pop; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Expiry; +import com.github.benmanes.caffeine.cache.Ticker; + +import java.time.Duration; +import java.util.Objects; + +public final class CaffeineReplayCache implements ReplayCache { + + private final Cache cache; + + private CaffeineReplayCache(Cache cache) { + this.cache = cache; + } + + public static CaffeineReplayCache create(int maxEntries) { + return create(maxEntries, Ticker.systemTicker()); + } + + static CaffeineReplayCache create(int maxEntries, Ticker ticker) { + Objects.requireNonNull(ticker, "ticker"); + Cache cache = Caffeine.newBuilder() + .maximumSize(maxEntries) + .expireAfter(Expiry.creating((String key, Duration ttl) -> ttl)) + .ticker(ticker) + .build(); + return new CaffeineReplayCache(cache); + } + + @Override + public boolean checkAndStore(String key, Duration ttl) { + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(ttl, "ttl"); + return cache.asMap().putIfAbsent(key, ttl) != null; + } +} \ No newline at end of file diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerIdentity.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerIdentity.java new file mode 100644 index 0000000..0bb32a4 --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerIdentity.java @@ -0,0 +1,14 @@ +package com.godaddy.ans.sdk.pop; + +import java.util.HexFormat; + +public record CallerIdentity( + String ansName, + String agentId, + byte[] fingerprint, + String jkt) { + + public String fingerprintHex() { + return HexFormat.of().formatHex(fingerprint); + } +} \ No newline at end of file diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerOptions.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerOptions.java new file mode 100644 index 0000000..89084de --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerOptions.java @@ -0,0 +1,45 @@ +package com.godaddy.ans.sdk.pop; + +import java.time.Instant; +import java.util.Objects; + +public final class CallerOptions { + + private final String accessToken; + private final String expectedPeer; + private final Instant clock; + + private CallerOptions(String accessToken, String expectedPeer, Instant clock) { + this.accessToken = accessToken; + this.expectedPeer = expectedPeer; + this.clock = clock; + } + + public static CallerOptions none() { + return new CallerOptions(null, null, null); + } + + public CallerOptions withAccessToken(String token) { + return new CallerOptions(Objects.requireNonNull(token, "token"), expectedPeer, clock); + } + + public CallerOptions withExpectedPeer(String peer) { + return new CallerOptions(accessToken, Objects.requireNonNull(peer, "peer"), clock); + } + + public CallerOptions withClock(Instant now) { + return new CallerOptions(accessToken, expectedPeer, Objects.requireNonNull(now, "now")); + } + + String accessToken() { + return accessToken; + } + + String expectedPeer() { + return expectedPeer; + } + + Instant clock() { + return clock; + } +} \ No newline at end of file diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java new file mode 100644 index 0000000..b2005c4 --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java @@ -0,0 +1,256 @@ +package com.godaddy.ans.sdk.pop; + +import com.godaddy.ans.sdk.crypto.CertificateUtils; +import com.godaddy.ans.sdk.transparency.scitt.DefaultScittVerifier; +import com.godaddy.ans.sdk.transparency.scitt.ScittExpectation; +import com.godaddy.ans.sdk.transparency.scitt.ScittHeaders; +import com.godaddy.ans.sdk.transparency.scitt.ScittParseException; +import com.godaddy.ans.sdk.transparency.scitt.ScittReceipt; +import com.godaddy.ans.sdk.transparency.scitt.ScittVerifier; +import com.godaddy.ans.sdk.transparency.scitt.StatusToken; +import com.nimbusds.jose.util.JSONObjectUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.security.PublicKey; +import java.text.ParseException; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +public final class CallerVerifier { + + private static final Logger LOG = LoggerFactory.getLogger(CallerVerifier.class); + + private final DpopProofVerifier proofVerifier = new DpopProofVerifier(); + private final ScittVerifier scittVerifier; + private final Duration popSkew; + + private CallerVerifier(String expectedIssuer, Duration scittClockSkew, Duration popSkew) { + Objects.requireNonNull(expectedIssuer, "expectedIssuer"); + this.scittVerifier = new DefaultScittVerifier( + Objects.requireNonNull(scittClockSkew, "scittClockSkew"), expectedIssuer); + this.popSkew = Objects.requireNonNull(popSkew, "popSkew"); + } + + CallerVerifier(ScittVerifier scittVerifier, Duration popSkew) { + this.scittVerifier = Objects.requireNonNull(scittVerifier, "scittVerifier"); + this.popSkew = Objects.requireNonNull(popSkew, "popSkew"); + } + + public static CallerVerifier create(String expectedIssuer) { + return new CallerVerifier(expectedIssuer, StatusToken.DEFAULT_CLOCK_SKEW, DpopProofVerifier.DEFAULT_SKEW); + } + + public static CallerVerifier create(String expectedIssuer, Duration scittClockSkew, Duration popSkew) { + return new CallerVerifier(expectedIssuer, scittClockSkew, popSkew); + } + + public CallerIdentity verifyCaller(String proofJWS, Map> headers, String method, + String url, Map rootKeys, ReplayCache replay, + CallerOptions options) throws PopException { + Objects.requireNonNull(headers, "headers"); + ScittReceipt receipt; + StatusToken token; + try { + receipt = parseReceipt(headers); + token = parseStatusToken(headers); + } catch (PopException e) { + logRejection(e); + throw e; + } + return verifyParsed(proofJWS, receipt, token, method, url, rootKeys, replay, options); + } + + CallerIdentity verifyParsed(String proofJWS, ScittReceipt receipt, StatusToken token, String method, + String url, Map rootKeys, ReplayCache replay, + CallerOptions options) throws PopException { + try { + if (replay == null) { + throw new PopException(ErrorType.MISCONFIGURED, "replay cache must not be null"); + } + if (rootKeys == null) { + throw new PopException(ErrorType.MISCONFIGURED, "root keys must not be null"); + } + + CallerOptions effectiveOptions = options != null ? options : CallerOptions.none(); + + DpopProofVerifier.Verified verified = verifyPossession(proofJWS, method, url, effectiveOptions); + ProofResult proof = verified.result(); + + ScittExpectation expectation = scittVerifier.verify(receipt, token, rootKeys); + if (!expectation.isVerified()) { + throw mapExpectation(expectation); + } + + 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"); + } + + proofVerifier.recordReplay(verified, replay); + + CallerIdentity identity = new CallerIdentity( + token.ansName(), token.agentId(), proof.fingerprint(), proof.jkt()); + LOG.debug("caller authenticated: ansName={} agentId={}", identity.ansName(), identity.agentId()); + return identity; + } catch (PopException e) { + logRejection(e); + throw e; + } + } + + private static void logRejection(PopException e) { + if (e.category() == ErrorType.MISCONFIGURED) { + LOG.error("caller rejected: {} - {}", e.category(), e.getMessage()); + } else { + LOG.info("caller rejected: {} - {}", e.category(), e.getMessage()); + } + } + + private DpopProofVerifier.Verified verifyPossession(String proofJWS, String method, String url, + CallerOptions options) throws PopException { + VerifyOptions verifyOptions = options.accessToken() != null + ? VerifyOptions.withAccessToken(options.accessToken()) + : VerifyOptions.none(); + Instant now = options.clock() != null ? options.clock() : Instant.now(); + return proofVerifier.verifyUnrecorded(proofJWS, method, url, now, popSkew, verifyOptions); + } + + private void verifyBinding(ProofResult proof, ScittReceipt receipt, StatusToken token) throws PopException { + String proofFingerprint = CertificateUtils.computeSha256Fingerprint(proof.cert()); + boolean fingerprintMatched = false; + for (String expected : token.identityCertFingerprints()) { + if (CertificateUtils.fingerprintMatches(proofFingerprint, expected)) { + fingerprintMatched = true; + break; + } + } + if (!fingerprintMatched) { + throw new PopException(ErrorType.BINDING_FAILED, + "proof certificate is not in status token identity fingerprints"); + } + + Optional certAnsName = CertificateUtils.extractAnsName(proof.cert()); + if (certAnsName.isEmpty()) { + throw new PopException(ErrorType.BINDING_FAILED, "proof certificate has no ans name SAN"); + } + if (!ansHost(certAnsName.get()).equals(ansHost(token.ansName()))) { + throw new PopException(ErrorType.BINDING_FAILED, + "proof ans host does not match status token ans host"); + } + + verifyReceiptAgent(receipt, token); + } + + 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 event; + try { + event = 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); + } + Object agentId = event.get("agentId"); + if (!(agentId instanceof String eventAgentId) || eventAgentId.isBlank()) { + throw new PopException(ErrorType.BINDING_FAILED, "receipt event payload has no agent id"); + } + if (!eventAgentId.equals(token.agentId())) { + throw new PopException(ErrorType.BINDING_FAILED, + "receipt agent does not match status token agent"); + } + } + + private ScittReceipt parseReceipt(Map> headers) throws PopException { + String encoded = requireSingleHeader(headers, ScittHeaders.SCITT_RECEIPT_HEADER); + byte[] decoded = decodeHeader(encoded, "receipt"); + try { + return ScittReceipt.parse(decoded); + } catch (ScittParseException e) { + throw new PopException(ErrorType.RECEIPT_INVALID, "receipt could not be parsed", e); + } + } + + private StatusToken parseStatusToken(Map> headers) throws PopException { + String encoded = requireSingleHeader(headers, ScittHeaders.STATUS_TOKEN_HEADER); + byte[] decoded = decodeHeader(encoded, "status token"); + try { + return StatusToken.parse(decoded); + } catch (ScittParseException e) { + throw new PopException(ErrorType.STATUS_INVALID, "status token could not be parsed", e); + } + } + + private static byte[] decodeHeader(String encoded, String label) throws PopException { + try { + return Base64.getDecoder().decode(encoded.trim()); + } catch (IllegalArgumentException e) { + throw new PopException(ErrorType.SCITT_HEADER_INVALID, label + " header is not valid base64", e); + } + } + + private static String requireSingleHeader(Map> headers, String name) throws PopException { + List values = null; + for (Map.Entry> entry : headers.entrySet()) { + if (entry.getKey() != null && entry.getKey().equalsIgnoreCase(name)) { + values = entry.getValue(); + break; + } + } + if (values == null || values.isEmpty()) { + throw new PopException(ErrorType.MISSING_HEADERS, "missing header " + name); + } + if (values.size() > 1) { + throw new PopException(ErrorType.SCITT_HEADER_INVALID, "duplicate header " + name); + } + String value = values.get(0); + if (value == null || value.isBlank()) { + throw new PopException(ErrorType.MISSING_HEADERS, "empty header " + name); + } + return value; + } + + static String ansHost(String ansName) throws PopException { + if (ansName == null || ansName.isBlank()) { + throw new PopException(ErrorType.BINDING_FAILED, "ans name is missing"); + } + String authority; + if (ansName.toLowerCase(Locale.ROOT).startsWith("ans://")) { + try { + URI uri = new URI(ansName); + authority = uri.getHost() != null ? uri.getHost() : uri.getAuthority(); + } catch (URISyntaxException e) { + throw new PopException(ErrorType.BINDING_FAILED, "ans name is not a valid URI", e); + } + } else { + authority = ansName; + } + if (authority == null || authority.isBlank()) { + throw new PopException(ErrorType.BINDING_FAILED, "ans name has no authority"); + } + return authority.toLowerCase(Locale.ROOT).replaceFirst("^v\\d+\\.\\d+\\.\\d+\\.", ""); + } + + 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()); + } +} \ No newline at end of file diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/DpopProofVerifier.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/DpopProofVerifier.java new file mode 100644 index 0000000..33e49cc --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/DpopProofVerifier.java @@ -0,0 +1,155 @@ +package com.godaddy.ans.sdk.pop; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; + +public final class DpopProofVerifier { + + static final int MAX_PROOF_SIZE = 8 * 1024; + static final int MAX_JTI_BYTES = 128; + static final Duration DEFAULT_SKEW = Duration.ofSeconds(120); + static final Duration REPLAY_GRACE = Duration.ofSeconds(5); + + private static final Logger LOG = LoggerFactory.getLogger(DpopProofVerifier.class); + + record Verified(ProofResult result, String replayKey, Duration replayTtl) { + } + + public ProofResult verify(String proofJWS, String method, String url, Instant now, + Duration skew, ReplayCache replay, VerifyOptions options) throws PopException { + if (replay == null) { + LOG.error("DPoP proof rejected: replay cache is not configured"); + throw new PopException(ErrorType.MISCONFIGURED, "replay cache must not be null"); + } + try { + Verified verified = verifyUnrecorded(proofJWS, method, url, now, skew, options); + recordReplay(verified, replay); + LOG.debug("DPoP proof accepted: jti={} htu={}", verified.result().jti(), verified.result().htu()); + return verified.result(); + } catch (PopException e) { + if (e.category() == ErrorType.MISCONFIGURED) { + LOG.error("DPoP proof rejected: {} - {}", e.category(), e.getMessage()); + } else { + LOG.info("DPoP proof rejected: {} - {}", e.category(), e.getMessage()); + } + throw e; + } + } + + Verified verifyUnrecorded(String proofJWS, String method, String url, Instant now, + Duration skew, VerifyOptions options) throws PopException { + Objects.requireNonNull(proofJWS, "proofJWS"); + Objects.requireNonNull(method, "method"); + Objects.requireNonNull(url, "url"); + Objects.requireNonNull(now, "now"); + + Duration effectiveSkew = skew != null ? skew : DEFAULT_SKEW; + VerifyOptions effectiveOptions = options != null ? options : VerifyOptions.none(); + + if (proofJWS.length() > MAX_PROOF_SIZE) { + throw new PopException(ErrorType.MALFORMED_PROOF, "proof exceeds maximum size"); + } + + Proof.Header header = Proof.acceptES256DPoP(proofJWS); + + if (!Jws.verify(header.jws(), header.publicKey())) { + throw new PopException(ErrorType.SIGNATURE_INVALID, "proof signature is invalid"); + } + + Proof.Claims claims = Proof.parseClaims(header.jws().getPayload()); + + if (!method.equals(claims.htm())) { + throw new PopException(ErrorType.HTTP_BINDING_MISMATCH, "htm does not match request method"); + } + + String normalizedHtu = Proof.normalizeHTU(url); + if (!normalizedHtu.equals(claims.htu())) { + throw new PopException(ErrorType.HTTP_BINDING_MISMATCH, "htu does not match request url"); + } + + verifyAth(claims.ath(), effectiveOptions.accessToken()); + + Instant iat = claims.iat(); + if (iat == null) { + throw new PopException(ErrorType.MALFORMED_PROOF, "iat claim is missing"); + } + if (iat.isBefore(now.minus(effectiveSkew)) || iat.isAfter(now.plus(effectiveSkew))) { + throw new PopException(ErrorType.PROOF_STALE, "iat is outside the acceptable window"); + } + + String jti = claims.jti(); + if (jti == null || jti.isEmpty()) { + throw new PopException(ErrorType.MALFORMED_PROOF, "jti claim is missing"); + } + if (jti.getBytes(StandardCharsets.UTF_8).length > MAX_JTI_BYTES) { + throw new PopException(ErrorType.MALFORMED_PROOF, "jti exceeds maximum size"); + } + + String replayKey = Base64Url.encode(sha256(jti.getBytes(StandardCharsets.UTF_8))); + Duration replayTtl = Duration.between(now, iat.plus(effectiveSkew).plus(REPLAY_GRACE)); + + ProofResult result = new ProofResult( + header.cert(), + header.publicKey(), + certFingerprint(header.cert()), + Proof.jkt(header.jwk()), + jti, + normalizedHtu, + iat); + + return new Verified(result, replayKey, replayTtl); + } + + void recordReplay(Verified verified, ReplayCache replay) throws PopException { + Objects.requireNonNull(verified, "verified"); + if (replay == null) { + throw new PopException(ErrorType.MISCONFIGURED, "replay cache must not be null"); + } + if (replay.checkAndStore(verified.replayKey(), verified.replayTtl())) { + throw new PopException(ErrorType.REPLAY, "jti has already been used"); + } + } + + private static void verifyAth(String proofAth, String accessToken) throws PopException { + boolean tokenPresented = accessToken != null; + boolean athPresent = proofAth != null; + if (tokenPresented != athPresent) { + throw new PopException(ErrorType.TOKEN_BINDING_MISMATCH, + "ath presence does not match presented access token"); + } + if (!tokenPresented) { + return; + } + String expected = Proof.accessTokenHash(accessToken); + if (!MessageDigest.isEqual( + expected.getBytes(StandardCharsets.UTF_8), + proofAth.getBytes(StandardCharsets.UTF_8))) { + throw new PopException(ErrorType.TOKEN_BINDING_MISMATCH, "ath does not match presented access token"); + } + } + + private static byte[] certFingerprint(X509Certificate cert) throws PopException { + try { + return sha256(cert.getEncoded()); + } catch (CertificateEncodingException e) { + throw new PopException(ErrorType.CERT_INVALID, "failed to encode certificate", e); + } + } + + private static byte[] sha256(byte[] input) { + try { + return MessageDigest.getInstance("SHA-256").digest(input); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } +} \ No newline at end of file diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopSigner.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopSigner.java new file mode 100644 index 0000000..ed370e9 --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopSigner.java @@ -0,0 +1,127 @@ +package com.godaddy.ans.sdk.pop; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSObject; +import com.nimbusds.jose.Payload; +import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jose.crypto.ECDSAVerifier; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.util.Base64; + +import java.io.ByteArrayInputStream; +import java.security.SecureRandom; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public final class PopSigner { + + private static final int JTI_BYTES = 16; + private static final SecureRandom RANDOM = new SecureRandom(); + + private final ECPrivateKey privateKey; + private final byte[] certDer; + private final ECKey jwk; + + private PopSigner(ECPrivateKey privateKey, byte[] certDer, ECKey jwk) { + this.privateKey = privateKey; + this.certDer = certDer; + this.jwk = jwk; + } + + public static PopSigner create(ECPrivateKey key, byte[] certDER) throws PopException { + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(certDER, "certDER"); + + X509Certificate cert = parseCertificate(certDER); + ECPublicKey certKey = ecP256PublicKey(cert); + assertKeyPairMatches(key, certKey); + + ECKey publicJwk = new ECKey.Builder(Curve.P_256, certKey).build().toPublicJWK(); + return new PopSigner(key, certDER.clone(), publicJwk); + } + + public String sign(String method, String url) throws PopException { + return signInternal(method, url, null); + } + + public String sign(String method, String url, String accessToken) throws PopException { + Objects.requireNonNull(accessToken, "accessToken"); + return signInternal(method, url, accessToken); + } + + public String jkt() throws PopException { + return Proof.jkt(jwk); + } + + private String signInternal(String method, String url, String accessToken) throws PopException { + Objects.requireNonNull(method, "method"); + Objects.requireNonNull(url, "url"); + + String htu = Proof.normalizeHTU(url); + + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.ES256) + .type(Jws.DPOP_TYP) + .jwk(jwk) + .x509CertChain(List.of(Base64.encode(certDer))) + .build(); + + Map claims = new LinkedHashMap<>(); + claims.put("htm", method); + claims.put("htu", htu); + claims.put("iat", Instant.now().getEpochSecond()); + claims.put("jti", newJti()); + if (accessToken != null) { + claims.put("ath", Proof.accessTokenHash(accessToken)); + } + + return Jws.sign(header, new Payload(claims), privateKey); + } + + private static String newJti() { + byte[] raw = new byte[JTI_BYTES]; + RANDOM.nextBytes(raw); + return Base64Url.encode(raw); + } + + private static X509Certificate parseCertificate(byte[] certDER) throws PopException { + try { + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + return (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(certDER)); + } catch (CertificateException e) { + throw new PopException(ErrorType.CERT_INVALID, "certDER is not a valid X.509 certificate", e); + } + } + + private static ECPublicKey ecP256PublicKey(X509Certificate cert) throws PopException { + if (!(cert.getPublicKey() instanceof ECPublicKey ecPublicKey)) { + throw new PopException(ErrorType.CERT_INVALID, "certificate key is not EC"); + } + if (!Curve.P_256.equals(Curve.forECParameterSpec(ecPublicKey.getParams()))) { + throw new PopException(ErrorType.CERT_INVALID, "certificate key must be P-256"); + } + return ecPublicKey; + } + + private static void assertKeyPairMatches(ECPrivateKey key, ECPublicKey certKey) throws PopException { + try { + JWSObject probe = new JWSObject(new JWSHeader(JWSAlgorithm.ES256), new Payload("pop-key-check")); + probe.sign(new ECDSASigner(key)); + if (!probe.verify(new ECDSAVerifier(certKey))) { + throw new PopException(ErrorType.KEY_MISMATCH, "private key does not match certificate public key"); + } + } catch (JOSEException e) { + throw new PopException(ErrorType.KEY_MISMATCH, "private key does not match certificate public key", e); + } + } +} \ No newline at end of file diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ProofResult.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ProofResult.java new file mode 100644 index 0000000..8144781 --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ProofResult.java @@ -0,0 +1,15 @@ +package com.godaddy.ans.sdk.pop; + +import java.security.cert.X509Certificate; +import java.security.interfaces.ECPublicKey; +import java.time.Instant; + +public record ProofResult( + X509Certificate cert, + ECPublicKey key, + byte[] fingerprint, + String jkt, + String jti, + String htu, + Instant issuedAt) { +} \ No newline at end of file diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ReplayCache.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ReplayCache.java new file mode 100644 index 0000000..27a86ed --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ReplayCache.java @@ -0,0 +1,8 @@ +package com.godaddy.ans.sdk.pop; + +import java.time.Duration; + +public interface ReplayCache { + + boolean checkAndStore(String key, Duration ttl); +} \ No newline at end of file diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/VerifyOptions.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/VerifyOptions.java new file mode 100644 index 0000000..6304af2 --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/VerifyOptions.java @@ -0,0 +1,24 @@ +package com.godaddy.ans.sdk.pop; + +import java.util.Objects; + +public final class VerifyOptions { + + private final String accessToken; + + private VerifyOptions(String accessToken) { + this.accessToken = accessToken; + } + + public static VerifyOptions none() { + return new VerifyOptions(null); + } + + public static VerifyOptions withAccessToken(String accessToken) { + return new VerifyOptions(Objects.requireNonNull(accessToken, "accessToken")); + } + + String accessToken() { + return accessToken; + } +} \ No newline at end of file diff --git a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CaffeineReplayCacheTest.java b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CaffeineReplayCacheTest.java new file mode 100644 index 0000000..adc0a17 --- /dev/null +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CaffeineReplayCacheTest.java @@ -0,0 +1,121 @@ +package com.godaddy.ans.sdk.pop; + +import com.github.benmanes.caffeine.cache.Ticker; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +class CaffeineReplayCacheTest { + + private static final Duration TTL = Duration.ofSeconds(245); + + @Test + void freshKeyReturnsFalse() { + CaffeineReplayCache cache = CaffeineReplayCache.create(128); + + assertThat(cache.checkAndStore("jti-1", TTL)).isFalse(); + } + + @Test + void secondCallSameKeyReturnsSeen() { + CaffeineReplayCache cache = CaffeineReplayCache.create(128); + + assertThat(cache.checkAndStore("jti-1", TTL)).isFalse(); + assertThat(cache.checkAndStore("jti-1", TTL)).isTrue(); + } + + @Test + void overCapacityEvictsAndNeverThrows() { + CaffeineReplayCache cache = CaffeineReplayCache.create(1); + + assertThatCode(() -> { + for (int i = 0; i < 10_000; i++) { + cache.checkAndStore("jti-" + i, TTL); + } + }).doesNotThrowAnyException(); + } + + @Test + void afterTtlKeyReadmitted() { + AtomicLong nanos = new AtomicLong(0); + Ticker ticker = nanos::get; + CaffeineReplayCache cache = CaffeineReplayCache.create(128, ticker); + + assertThat(cache.checkAndStore("jti-1", TTL)).isFalse(); + assertThat(cache.checkAndStore("jti-1", TTL)).isTrue(); + + nanos.set(Duration.ofSeconds(246).toNanos()); + + assertThat(cache.checkAndStore("jti-1", TTL)).isFalse(); + } + + @Test + void withinTtlStillSeen() { + AtomicLong nanos = new AtomicLong(0); + Ticker ticker = nanos::get; + CaffeineReplayCache cache = CaffeineReplayCache.create(128, ticker); + + assertThat(cache.checkAndStore("jti-1", TTL)).isFalse(); + + nanos.set(Duration.ofSeconds(244).toNanos()); + + assertThat(cache.checkAndStore("jti-1", TTL)).isTrue(); + } + + @Test + void perEntryTtlHonored() { + AtomicLong nanos = new AtomicLong(0); + Ticker ticker = nanos::get; + CaffeineReplayCache cache = CaffeineReplayCache.create(128, ticker); + + assertThat(cache.checkAndStore("jti-short", Duration.ofSeconds(100))).isFalse(); + assertThat(cache.checkAndStore("jti-long", Duration.ofSeconds(300))).isFalse(); + + nanos.set(Duration.ofSeconds(150).toNanos()); + + assertThat(cache.checkAndStore("jti-short", Duration.ofSeconds(100))).isFalse(); + assertThat(cache.checkAndStore("jti-long", Duration.ofSeconds(300))).isTrue(); + } + + @Test + void repeatedCallDoesNotRefreshExpiry() { + AtomicLong nanos = new AtomicLong(0); + Ticker ticker = nanos::get; + CaffeineReplayCache cache = CaffeineReplayCache.create(128, ticker); + + assertThat(cache.checkAndStore("jti-1", TTL)).isFalse(); + + nanos.set(Duration.ofSeconds(100).toNanos()); + assertThat(cache.checkAndStore("jti-1", TTL)).isTrue(); + + nanos.set(Duration.ofSeconds(246).toNanos()); + assertThat(cache.checkAndStore("jti-1", TTL)).isFalse(); + } + + @Test + void nullTickerRejected() { + assertThatNullPointerException() + .isThrownBy(() -> CaffeineReplayCache.create(128, null)); + } + + @Test + void nullKeyRejected() { + CaffeineReplayCache cache = CaffeineReplayCache.create(128); + + assertThatNullPointerException() + .isThrownBy(() -> cache.checkAndStore(null, TTL)); + } + + @Test + void nullTtlRejected() { + CaffeineReplayCache cache = CaffeineReplayCache.create(128); + + assertThatNullPointerException() + .isThrownBy(() -> cache.checkAndStore("jti-1", null)); + } +} \ No newline at end of file diff --git a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerVerifierTest.java b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerVerifierTest.java new file mode 100644 index 0000000..43f131a --- /dev/null +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerVerifierTest.java @@ -0,0 +1,345 @@ +package com.godaddy.ans.sdk.pop; + +import com.godaddy.ans.sdk.crypto.CertificateUtils; +import com.godaddy.ans.sdk.transparency.model.CertType; +import com.godaddy.ans.sdk.transparency.model.CertificateInfo; +import com.godaddy.ans.sdk.transparency.scitt.ScittExpectation; +import com.godaddy.ans.sdk.transparency.scitt.ScittHeaders; +import com.godaddy.ans.sdk.transparency.scitt.ScittReceipt; +import com.godaddy.ans.sdk.transparency.scitt.ScittVerifier; +import com.godaddy.ans.sdk.transparency.scitt.StatusToken; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PublicKey; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.security.interfaces.ECPrivateKey; +import java.security.spec.ECGenParameterSpec; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +class CallerVerifierTest { + + private static final String METHOD = "POST"; + private static final String URL = "https://rp.example.com/verify"; + private static final String AGENT_ID = "agent-123"; + private static final String ANS_NAME = "ans://agent.example.com"; + + private static KeyPair keyPair; + private static X509Certificate cert; + private static String certFingerprint; + private static String proofJws; + + private static KeyPair noSanKeyPair; + private static X509Certificate noSanCert; + private static String noSanFingerprint; + private static String noSanProofJws; + + @BeforeAll + static void setUp() throws Exception { + Security.addProvider(new BouncyCastleProvider()); + + keyPair = ec(); + cert = selfSigned(keyPair, ANS_NAME); + certFingerprint = CertificateUtils.computeSha256Fingerprint(cert); + proofJws = PopSigner.create((ECPrivateKey) keyPair.getPrivate(), cert.getEncoded()).sign(METHOD, URL); + + noSanKeyPair = ec(); + noSanCert = selfSigned(noSanKeyPair, null); + noSanFingerprint = CertificateUtils.computeSha256Fingerprint(noSanCert); + noSanProofJws = PopSigner.create((ECPrivateKey) noSanKeyPair.getPrivate(), noSanCert.getEncoded()) + .sign(METHOD, URL); + } + + @Test + void happyPathReturnsIdentity() throws Exception { + CountingReplay replay = new CountingReplay(false); + CallerIdentity identity = verifier().verifyParsed( + proofJws, receipt(AGENT_ID, ANS_NAME), token(ANS_NAME, AGENT_ID, certFingerprint), + METHOD, URL, Map.of(), replay, CallerOptions.none()); + + assertThat(identity.ansName()).isEqualTo(ANS_NAME); + assertThat(identity.agentId()).isEqualTo(AGENT_ID); + assertThat(identity.jkt()).isNotBlank(); + assertThat(identity.fingerprintHex()).hasSize(64); + assertThat(replay.calls).isEqualTo(1); + } + + @Test + void bindingRejectsFingerprintNotInStatusToken() { + CountingReplay replay = new CountingReplay(false); + PopException ex = catchThrowableOfType(() -> verifier().verifyParsed( + proofJws, receipt(AGENT_ID, ANS_NAME), token(ANS_NAME, AGENT_ID, "SHA256:deadbeef"), + METHOD, URL, Map.of(), replay, CallerOptions.none()), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.BINDING_FAILED); + assertThat(replay.calls).isZero(); + } + + @Test + void bindingRejectsAnsHostMismatch() { + PopException ex = catchThrowableOfType(() -> verifier().verifyParsed( + proofJws, receipt(AGENT_ID, "ans://other.example.com"), + token("ans://other.example.com", AGENT_ID, certFingerprint), + METHOD, URL, Map.of(), new CountingReplay(false), CallerOptions.none()), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.BINDING_FAILED); + } + + @Test + void bindingAcceptsVersionLabelHost() throws Exception { + String versioned = "ans://v1.2.3.agent.example.com"; + CallerIdentity identity = verifier().verifyParsed( + proofJws, receipt(AGENT_ID, versioned), token(versioned, AGENT_ID, certFingerprint), + METHOD, URL, Map.of(), new CountingReplay(false), CallerOptions.none()); + + assertThat(identity.ansName()).isEqualTo(versioned); + } + + @Test + void bindingRejectsNoSan() { + PopException ex = catchThrowableOfType(() -> verifier().verifyParsed( + noSanProofJws, receipt(AGENT_ID, ANS_NAME), token(ANS_NAME, AGENT_ID, noSanFingerprint), + METHOD, URL, Map.of(), new CountingReplay(false), CallerOptions.none()), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.BINDING_FAILED); + } + + @Test + void bindingRejectsReceiptAgentMismatch() { + PopException ex = catchThrowableOfType(() -> verifier().verifyParsed( + proofJws, receipt("other-agent", ANS_NAME), token(ANS_NAME, AGENT_ID, certFingerprint), + METHOD, URL, Map.of(), new CountingReplay(false), CallerOptions.none()), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.BINDING_FAILED); + } + + @Test + void bindingRejectsMissingReceiptAgent() { + ScittReceipt receipt = new ScittReceipt(null, null, null, + "{\"ansName\":\"ans://agent.example.com\"}".getBytes(StandardCharsets.UTF_8), null); + PopException ex = catchThrowableOfType(() -> verifier().verifyParsed( + proofJws, receipt, token(ANS_NAME, AGENT_ID, certFingerprint), + METHOD, URL, Map.of(), new CountingReplay(false), CallerOptions.none()), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.BINDING_FAILED); + } + + @Test + void replayNotConsumedWhenLaterCheckFails() { + CountingReplay replay = new CountingReplay(false); + catchThrowableOfType(() -> verifier().verifyParsed( + proofJws, receipt(AGENT_ID, ANS_NAME), token(ANS_NAME, AGENT_ID, certFingerprint), + METHOD, URL, Map.of(), replay, + CallerOptions.none().withExpectedPeer("ans://other.example.com")), PopException.class); + + assertThat(replay.calls).isZero(); + } + + @Test + void replayDetectedRejects() { + PopException ex = catchThrowableOfType(() -> verifier().verifyParsed( + proofJws, receipt(AGENT_ID, ANS_NAME), token(ANS_NAME, AGENT_ID, certFingerprint), + METHOD, URL, Map.of(), new CountingReplay(true), CallerOptions.none()), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.REPLAY); + } + + @Test + void expectedPeerMatchAccepts() throws Exception { + CallerIdentity identity = verifier().verifyParsed( + proofJws, receipt(AGENT_ID, ANS_NAME), token(ANS_NAME, AGENT_ID, certFingerprint), + METHOD, URL, Map.of(), new CountingReplay(false), + CallerOptions.none().withExpectedPeer("ans://agent.example.com")); + + assertThat(identity.agentId()).isEqualTo(AGENT_ID); + } + + @Test + void expectedPeerMismatchRejects() { + PopException ex = catchThrowableOfType(() -> verifier().verifyParsed( + proofJws, receipt(AGENT_ID, ANS_NAME), token(ANS_NAME, AGENT_ID, certFingerprint), + METHOD, URL, Map.of(), new CountingReplay(false), + CallerOptions.none().withExpectedPeer("ans://other.example.com")), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.EXPECTED_PEER_MISMATCH); + } + + @Test + void nullReplayCacheRejected() { + PopException ex = catchThrowableOfType(() -> verifier().verifyParsed( + proofJws, receipt(AGENT_ID, ANS_NAME), token(ANS_NAME, AGENT_ID, certFingerprint), + METHOD, URL, Map.of(), null, CallerOptions.none()), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MISCONFIGURED); + } + + @Test + void nullRootKeysRejected() { + PopException ex = catchThrowableOfType(() -> verifier().verifyParsed( + proofJws, receipt(AGENT_ID, ANS_NAME), token(ANS_NAME, AGENT_ID, certFingerprint), + METHOD, URL, null, new CountingReplay(false), CallerOptions.none()), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MISCONFIGURED); + } + + @Test + void expiredStatusTokenMapsToStatusInvalid() { + CallerVerifier verifier = new CallerVerifier(new FakeScitt(ScittExpectation.expired()), DEFAULT_SKEW); + PopException ex = catchThrowableOfType(() -> verifier.verifyParsed( + proofJws, receipt(AGENT_ID, ANS_NAME), token(ANS_NAME, AGENT_ID, certFingerprint), + METHOD, URL, Map.of(), new CountingReplay(false), CallerOptions.none()), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.STATUS_INVALID); + } + + @Test + void invalidReceiptMapsToReceiptInvalid() { + CallerVerifier verifier = new CallerVerifier( + new FakeScitt(ScittExpectation.invalidReceipt("bad")), DEFAULT_SKEW); + PopException ex = catchThrowableOfType(() -> verifier.verifyParsed( + proofJws, receipt(AGENT_ID, ANS_NAME), token(ANS_NAME, AGENT_ID, certFingerprint), + METHOD, URL, Map.of(), new CountingReplay(false), CallerOptions.none()), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.RECEIPT_INVALID); + } + + @Test + void missingHeadersRejected() { + PopException ex = catchThrowableOfType(() -> verifier().verifyCaller( + proofJws, Map.of(), METHOD, URL, Map.of(), new CountingReplay(false), CallerOptions.none()), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MISSING_HEADERS); + } + + @Test + void duplicateScittHeaderRejected() { + Map> headers = Map.of( + ScittHeaders.SCITT_RECEIPT_HEADER, List.of("a", "b")); + PopException ex = catchThrowableOfType(() -> verifier().verifyCaller( + proofJws, headers, METHOD, URL, Map.of(), new CountingReplay(false), CallerOptions.none()), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.SCITT_HEADER_INVALID); + } + + @Test + void invalidBase64HeaderRejected() { + Map> headers = Map.of( + ScittHeaders.SCITT_RECEIPT_HEADER, List.of("!!!not-base64!!!")); + PopException ex = catchThrowableOfType(() -> verifier().verifyCaller( + proofJws, headers, METHOD, URL, Map.of(), new CountingReplay(false), CallerOptions.none()), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.SCITT_HEADER_INVALID); + } + + @Test + void unparseableReceiptRejected() { + Map> headers = Map.of( + ScittHeaders.SCITT_RECEIPT_HEADER, + List.of(Base64.getEncoder().encodeToString("garbage".getBytes(StandardCharsets.UTF_8)))); + PopException ex = catchThrowableOfType(() -> verifier().verifyCaller( + proofJws, headers, METHOD, URL, Map.of(), new CountingReplay(false), CallerOptions.none()), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.RECEIPT_INVALID); + } + + private static final Duration DEFAULT_SKEW = Duration.ofSeconds(120); + + private static CallerVerifier verifier() { + return new CallerVerifier(new FakeScitt(ScittExpectation.verified( + List.of(), List.of(), ANS_NAME, Map.of(), null)), DEFAULT_SKEW); + } + + private static StatusToken token(String ansName, String agentId, String identityFingerprint) { + Instant now = Instant.now(); + return new StatusToken(agentId, StatusToken.Status.ACTIVE, now, now.plusSeconds(3600), ansName, + List.of(new CertificateInfo(identityFingerprint, CertType.X509_EV_CLIENT)), + List.of(), Map.of(), null); + } + + private static ScittReceipt receipt(String agentId, String ansName) { + String json = "{\"agentId\":\"" + agentId + "\",\"ansName\":\"" + ansName + "\"}"; + return new ScittReceipt(null, null, null, json.getBytes(StandardCharsets.UTF_8), null); + } + + private static KeyPair ec() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC", BouncyCastleProvider.PROVIDER_NAME); + generator.initialize(new ECGenParameterSpec("secp256r1")); + return generator.generateKeyPair(); + } + + private static X509Certificate selfSigned(KeyPair keyPair, String ansUri) throws Exception { + X500Name dn = new X500Name("CN=test"); + Instant now = Instant.now(); + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + dn, BigInteger.ONE, Date.from(now.minusSeconds(60)), Date.from(now.plusSeconds(3600)), + dn, keyPair.getPublic()); + if (ansUri != null) { + GeneralNames san = new GeneralNames(new GeneralName(GeneralName.uniformResourceIdentifier, ansUri)); + builder.addExtension(Extension.subjectAlternativeName, false, san); + } + ContentSigner signer = new JcaContentSignerBuilder("SHA256withECDSA").build(keyPair.getPrivate()); + return new JcaX509CertificateConverter().setProvider(BouncyCastleProvider.PROVIDER_NAME) + .getCertificate(builder.build(signer)); + } + + private static final class FakeScitt implements ScittVerifier { + private final ScittExpectation expectation; + + private FakeScitt(ScittExpectation expectation) { + this.expectation = expectation; + } + + @Override + public ScittExpectation verify(ScittReceipt receipt, StatusToken token, Map rootKeys) { + return expectation; + } + + @Override + public ScittVerificationResult postVerify(String hostname, X509Certificate serverCert, + ScittExpectation expectation) { + return null; + } + } + + private static final class CountingReplay implements ReplayCache { + private final boolean seen; + private int calls; + + private CountingReplay(boolean seen) { + this.seen = seen; + } + + @Override + public boolean checkAndStore(String key, Duration ttl) { + calls++; + return seen; + } + } +} \ No newline at end of file diff --git a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/DpopProofVerifierTest.java b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/DpopProofVerifierTest.java new file mode 100644 index 0000000..4baae21 --- /dev/null +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/DpopProofVerifierTest.java @@ -0,0 +1,404 @@ +package com.godaddy.ans.sdk.pop; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSObject; +import com.nimbusds.jose.Payload; +import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.util.Base64; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +class DpopProofVerifierTest { + + private static final String METHOD = "POST"; + private static final String URL = "https://api.example.com/agents"; + + private static KeyPair keyA; + private static KeyPair keyB; + private static X509Certificate certA; + private static byte[] certAder; + + private final DpopProofVerifier verifier = new DpopProofVerifier(); + + @BeforeAll + static void setUp() throws Exception { + Security.addProvider(new BouncyCastleProvider()); + keyA = ec("secp256r1"); + keyB = ec("secp256r1"); + certA = selfSigned(keyA); + certAder = certA.getEncoded(); + } + + private ReplayCache cache() { + return CaffeineReplayCache.create(1024); + } + + private PopSigner signerA() throws Exception { + return PopSigner.create((ECPrivateKey) keyA.getPrivate(), certAder); + } + + @Test + void roundTripVerifies() throws Exception { + String proof = signerA().sign(METHOD, URL); + + ProofResult result = verifier.verify(proof, METHOD, URL, Instant.now(), + DpopProofVerifier.DEFAULT_SKEW, cache(), null); + + assertThat(result).isNotNull(); + assertThat(result.htu()).isEqualTo("https://api.example.com/agents"); + } + + @Test + void proofResultFieldsPopulated() throws Exception { + String proof = signerA().sign(METHOD, URL); + + ProofResult result = verifier.verify(proof, METHOD, URL, Instant.now(), + null, cache(), VerifyOptions.none()); + + assertThat(result.cert()).isEqualTo(certA); + assertThat(Proof.coordinates(result.key())) + .isEqualTo(Proof.coordinates((ECPublicKey) keyA.getPublic())); + assertThat(result.fingerprint()).hasSize(32); + assertThat(result.fingerprint()).isEqualTo(sha256(certAder)); + assertThat(result.jkt()).isEqualTo(signerA().jkt()); + assertThat(result.jti()).isNotBlank(); + assertThat(result.issuedAt()).isNotNull(); + } + + @Test + void rejectsTamperedSignature() throws Exception { + Map claims = baseClaims(Instant.now()); + String proof = craft(claims, (ECPrivateKey) keyB.getPrivate()); + + PopException ex = catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), null), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.SIGNATURE_INVALID); + } + + @Test + void rejectsHtmMismatch() throws Exception { + String proof = signerA().sign("GET", URL); + + PopException ex = catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), null), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.HTTP_BINDING_MISMATCH); + } + + @Test + void rejectsHtuMismatch() throws Exception { + String proof = signerA().sign(METHOD, "https://evil.example.com/agents"); + + PopException ex = catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), null), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.HTTP_BINDING_MISMATCH); + } + + @Test + void rejectsStaleIat() throws Exception { + Instant now = Instant.now(); + Map claims = baseClaims(now.minusSeconds(3600)); + String proof = craft(claims, (ECPrivateKey) keyA.getPrivate()); + + PopException ex = catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, now, null, cache(), null), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.PROOF_STALE); + } + + @Test + void rejectsFutureIat() throws Exception { + Instant now = Instant.now(); + Map claims = baseClaims(now.plusSeconds(3600)); + String proof = craft(claims, (ECPrivateKey) keyA.getPrivate()); + + PopException ex = catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, now, null, cache(), null), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.PROOF_STALE); + } + + @Test + void rejectsMissingIat() throws Exception { + Map claims = baseClaims(Instant.now()); + claims.remove("iat"); + String proof = craft(claims, (ECPrivateKey) keyA.getPrivate()); + + PopException ex = catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), null), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MALFORMED_PROOF); + } + + @Test + void rejectsMissingJti() throws Exception { + Map claims = baseClaims(Instant.now()); + claims.remove("jti"); + String proof = craft(claims, (ECPrivateKey) keyA.getPrivate()); + + PopException ex = catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), null), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MALFORMED_PROOF); + } + + @Test + void rejectsEmptyJti() throws Exception { + Map claims = baseClaims(Instant.now()); + claims.put("jti", ""); + String proof = craft(claims, (ECPrivateKey) keyA.getPrivate()); + + PopException ex = catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), null), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MALFORMED_PROOF); + } + + @Test + void acceptsIatAtWindowEdges() throws Exception { + Instant now = Instant.parse("2026-08-28T12:00:00Z"); + + String earliest = craft(baseClaims(now.minus(DpopProofVerifier.DEFAULT_SKEW)), + (ECPrivateKey) keyA.getPrivate()); + assertThat(verifier.verify(earliest, METHOD, URL, now, + DpopProofVerifier.DEFAULT_SKEW, cache(), null)).isNotNull(); + + String latest = craft(baseClaims(now.plus(DpopProofVerifier.DEFAULT_SKEW)), + (ECPrivateKey) keyA.getPrivate()); + assertThat(verifier.verify(latest, METHOD, URL, now, + DpopProofVerifier.DEFAULT_SKEW, cache(), null)).isNotNull(); + } + + @Test + void rejectsOversizeJti() throws Exception { + Map claims = baseClaims(Instant.now()); + claims.put("jti", "x".repeat(DpopProofVerifier.MAX_JTI_BYTES + 1)); + String proof = craft(claims, (ECPrivateKey) keyA.getPrivate()); + + PopException ex = catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), null), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MALFORMED_PROOF); + } + + @Test + void rejectsReplay() throws Exception { + ReplayCache replay = cache(); + String proof = signerA().sign(METHOD, URL); + + verifier.verify(proof, METHOD, URL, Instant.now(), null, replay, null); + PopException ex = catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, Instant.now(), null, replay, null), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.REPLAY); + } + + @Test + void nullReplayCacheIsMisconfigured() throws Exception { + String proof = signerA().sign(METHOD, URL); + + PopException ex = catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, Instant.now(), null, null, null), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MISCONFIGURED); + } + + @Test + void rejectsOversizeProof() { + String proof = "a".repeat(DpopProofVerifier.MAX_PROOF_SIZE + 1); + + PopException ex = catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), null), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MALFORMED_PROOF); + } + + @Test + void rejectsTokenWithoutAth() throws Exception { + String proof = signerA().sign(METHOD, URL); + + PopException ex = catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), + VerifyOptions.withAccessToken("some-token")), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.TOKEN_BINDING_MISMATCH); + } + + @Test + void rejectsAthWithoutToken() throws Exception { + String proof = signerA().sign(METHOD, URL, "some-token"); + + PopException ex = catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), VerifyOptions.none()), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.TOKEN_BINDING_MISMATCH); + } + + @Test + void rejectsAthMismatch() throws Exception { + String proof = signerA().sign(METHOD, URL, "the-real-token"); + + PopException ex = catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), + VerifyOptions.withAccessToken("a-different-token")), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.TOKEN_BINDING_MISMATCH); + } + + @Test + void acceptsMatchingAth() throws Exception { + String token = "Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU"; + String proof = signerA().sign(METHOD, URL, token); + + ProofResult result = verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), + VerifyOptions.withAccessToken(token)); + + assertThat(result).isNotNull(); + } + + @Test + void replayKeyIsHashedJti() throws Exception { + CapturingCache replay = new CapturingCache(); + Map claims = baseClaims(Instant.now()); + claims.put("jti", "fixed-jti-value"); + String proof = craft(claims, (ECPrivateKey) keyA.getPrivate()); + + verifier.verify(proof, METHOD, URL, Instant.now(), null, replay, null); + + String expected = Base64Url.encode(sha256("fixed-jti-value".getBytes(StandardCharsets.UTF_8))); + assertThat(replay.lastKey).isEqualTo(expected); + } + + @Test + void replayTtlComputedFromInjectedNow() throws Exception { + CapturingCache replay = new CapturingCache(); + Instant now = Instant.parse("2026-08-28T12:00:00Z"); + Instant iat = now.minusSeconds(30); + Map claims = baseClaims(iat); + String proof = craft(claims, (ECPrivateKey) keyA.getPrivate()); + + verifier.verify(proof, METHOD, URL, now, DpopProofVerifier.DEFAULT_SKEW, replay, null); + + Duration expected = Duration.between(now, + iat.plus(DpopProofVerifier.DEFAULT_SKEW).plus(DpopProofVerifier.REPLAY_GRACE)); + assertThat(replay.lastTtl).isEqualTo(expected); + } + + @Test + void toleratesExtraPayloadClaim() throws Exception { + Map claims = baseClaims(Instant.now()); + claims.put("extra", "ignored"); + String proof = craft(claims, (ECPrivateKey) keyA.getPrivate()); + + ProofResult result = verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), null); + + assertThat(result).isNotNull(); + } + + private static Map baseClaims(Instant iat) { + Map claims = new LinkedHashMap<>(); + claims.put("htm", METHOD); + claims.put("htu", "https://api.example.com/agents"); + claims.put("iat", iat.getEpochSecond()); + claims.put("jti", "test-jti-" + iat.getEpochSecond()); + return claims; + } + + private static String craft(Map claims, ECPrivateKey signingKey) throws Exception { + ECKey jwk = new ECKey.Builder(Curve.P_256, (ECPublicKey) keyA.getPublic()).build().toPublicJWK(); + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.ES256) + .type(Jws.DPOP_TYP) + .jwk(jwk) + .x509CertChain(List.of(Base64.encode(certAder))) + .build(); + JWSObject jws = new JWSObject(header, new Payload(claims)); + jws.sign(new ECDSASigner(signingKey)); + return jws.serialize(); + } + + private static byte[] sha256(byte[] input) throws Exception { + return MessageDigest.getInstance("SHA-256").digest(input); + } + + private static KeyPair ec(String curve) throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); + kpg.initialize(new ECGenParameterSpec(curve)); + return kpg.generateKeyPair(); + } + + private static X509Certificate selfSigned(KeyPair pair) throws Exception { + X500Name subject = new X500Name("CN=test"); + Date notBefore = new Date(1_600_000_000_000L); + Date notAfter = new Date(4_100_000_000_000L); + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + subject, BigInteger.valueOf(1), notBefore, notAfter, subject, pair.getPublic()); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false)); + ContentSigner signer = new JcaContentSignerBuilder("SHA256withECDSA") + .setProvider(BouncyCastleProvider.PROVIDER_NAME) + .build(pair.getPrivate()); + return new JcaX509CertificateConverter() + .setProvider(BouncyCastleProvider.PROVIDER_NAME) + .getCertificate(builder.build(signer)); + } + + private static final class CapturingCache implements ReplayCache { + private String lastKey; + private Duration lastTtl; + + @Override + public boolean checkAndStore(String key, Duration ttl) { + this.lastKey = key; + this.lastTtl = ttl; + return false; + } + } +} \ No newline at end of file diff --git a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/PopSignerTest.java b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/PopSignerTest.java new file mode 100644 index 0000000..c0436e5 --- /dev/null +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/PopSignerTest.java @@ -0,0 +1,226 @@ +package com.godaddy.ans.sdk.pop; + +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.time.Instant; +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +class PopSignerTest { + + private static KeyPair p256A; + private static KeyPair p256B; + private static KeyPair p384; + private static X509Certificate certA; + private static X509Certificate certB; + private static X509Certificate cert384; + private static X509Certificate rsaCert; + + @BeforeAll + static void setUp() throws Exception { + Security.addProvider(new BouncyCastleProvider()); + + p256A = ec("secp256r1"); + p256B = ec("secp256r1"); + p384 = ec("secp384r1"); + + certA = selfSigned(p256A, "SHA256withECDSA"); + certB = selfSigned(p256B, "SHA256withECDSA"); + cert384 = selfSigned(p384, "SHA384withECDSA"); + + KeyPairGenerator rsaGen = KeyPairGenerator.getInstance("RSA"); + rsaGen.initialize(2048); + rsaCert = selfSigned(rsaGen.generateKeyPair(), "SHA256withRSA"); + } + + @Test + void createSucceedsForMatchingPair() throws Exception { + PopSigner signer = PopSigner.create((ECPrivateKey) p256A.getPrivate(), certA.getEncoded()); + + assertThat(signer).isNotNull(); + } + + @Test + void createRejectsKeyCertMismatch() { + PopException ex = catchThrowableOfType( + () -> PopSigner.create((ECPrivateKey) p256A.getPrivate(), certB.getEncoded()), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.KEY_MISMATCH); + } + + @Test + void createRejectsWrongCurvePrivateKey() { + PopException ex = catchThrowableOfType( + () -> PopSigner.create((ECPrivateKey) p384.getPrivate(), certA.getEncoded()), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.KEY_MISMATCH); + } + + @Test + void createRejectsNonEcCert() { + PopException ex = catchThrowableOfType( + () -> PopSigner.create((ECPrivateKey) p256A.getPrivate(), rsaCert.getEncoded()), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.CERT_INVALID); + } + + @Test + void createRejectsNonP256Cert() { + PopException ex = catchThrowableOfType( + () -> PopSigner.create((ECPrivateKey) p384.getPrivate(), cert384.getEncoded()), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.CERT_INVALID); + } + + @Test + void createRejectsUnparseableCert() { + PopException ex = catchThrowableOfType( + () -> PopSigner.create((ECPrivateKey) p256A.getPrivate(), new byte[]{1, 2, 3}), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.CERT_INVALID); + } + + @Test + void signRoundTripsThroughAccept() throws Exception { + PopSigner signer = PopSigner.create((ECPrivateKey) p256A.getPrivate(), certA.getEncoded()); + + String compact = signer.sign("POST", "https://api.example.com/agents"); + + Proof.Header header = Proof.acceptES256DPoP(compact); + assertThat(Proof.coordinates(header.publicKey())) + .isEqualTo(Proof.coordinates((ECPublicKey) p256A.getPublic())); + assertThat(header.jws().verify( + new com.nimbusds.jose.crypto.ECDSAVerifier((ECPublicKey) p256A.getPublic()))).isTrue(); + } + + @Test + void signHtuMatchesNormalizeHtu() throws Exception { + PopSigner signer = PopSigner.create((ECPrivateKey) p256A.getPrivate(), certA.getEncoded()); + String url = "HTTPS://API.Example.COM:443/X?q=1#frag"; + + String compact = signer.sign("GET", url); + + Proof.Claims claims = Proof.parseClaims(Proof.acceptES256DPoP(compact).jws().getPayload()); + assertThat(claims.htu()).isEqualTo(Proof.normalizeHTU(url)); + assertThat(claims.htu()).isEqualTo("https://api.example.com/X"); + } + + @Test + void signSetsHtmAndFreshIat() throws Exception { + PopSigner signer = PopSigner.create((ECPrivateKey) p256A.getPrivate(), certA.getEncoded()); + Instant before = Instant.now().minusSeconds(2); + + String compact = signer.sign("DELETE", "https://api.example.com/x"); + + Proof.Claims claims = Proof.parseClaims(Proof.acceptES256DPoP(compact).jws().getPayload()); + assertThat(claims.htm()).isEqualTo("DELETE"); + assertThat(claims.jti()).isNotBlank(); + assertThat(claims.iat()).isAfterOrEqualTo(before); + assertThat(claims.iat()).isBeforeOrEqualTo(Instant.now().plusSeconds(2)); + } + + @Test + void signGeneratesUniqueJti() throws Exception { + PopSigner signer = PopSigner.create((ECPrivateKey) p256A.getPrivate(), certA.getEncoded()); + + Proof.Claims first = Proof.parseClaims( + Proof.acceptES256DPoP(signer.sign("GET", "https://api.example.com/x")).jws().getPayload()); + Proof.Claims second = Proof.parseClaims( + Proof.acceptES256DPoP(signer.sign("GET", "https://api.example.com/x")).jws().getPayload()); + + assertThat(first.jti()).isNotEqualTo(second.jti()); + } + + @Test + void signWithAccessTokenAddsAth() throws Exception { + PopSigner signer = PopSigner.create((ECPrivateKey) p256A.getPrivate(), certA.getEncoded()); + String token = "Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU"; + + String compact = signer.sign("POST", "https://api.example.com/x", token); + + Proof.Claims claims = Proof.parseClaims(Proof.acceptES256DPoP(compact).jws().getPayload()); + assertThat(claims.ath()).isEqualTo(Proof.accessTokenHash(token)); + } + + @Test + void signWithoutTokenHasNoAth() throws Exception { + PopSigner signer = PopSigner.create((ECPrivateKey) p256A.getPrivate(), certA.getEncoded()); + + String compact = signer.sign("POST", "https://api.example.com/x"); + + Proof.Claims claims = Proof.parseClaims(Proof.acceptES256DPoP(compact).jws().getPayload()); + assertThat(claims.ath()).isNull(); + } + + @Test + void signRejectsInvalidUrl() throws Exception { + PopSigner signer = PopSigner.create((ECPrivateKey) p256A.getPrivate(), certA.getEncoded()); + + PopException ex = catchThrowableOfType( + () -> signer.sign("GET", "//api.example.com/x"), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.HTTP_BINDING_MISMATCH); + } + + @Test + void jktMatchesCertKeyThumbprint() throws Exception { + PopSigner signer = PopSigner.create((ECPrivateKey) p256A.getPrivate(), certA.getEncoded()); + ECKey certJwk = new ECKey.Builder(Curve.P_256, (ECPublicKey) p256A.getPublic()).build().toPublicJWK(); + + assertThat(signer.jkt()).isEqualTo(certJwk.computeThumbprint().toString()); + assertThat(signer.jkt()).doesNotContain("="); + } + + private static KeyPair ec(String curve) throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); + kpg.initialize(new ECGenParameterSpec(curve)); + return kpg.generateKeyPair(); + } + + private static X509Certificate selfSigned(KeyPair pair, String sigAlg) throws Exception { + X500Name subject = new X500Name("CN=test"); + BigInteger serial = BigInteger.valueOf(1); + Date notBefore = new Date(1_600_000_000_000L); + Date notAfter = new Date(4_100_000_000_000L); + + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + subject, serial, notBefore, notAfter, subject, pair.getPublic()); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false)); + + ContentSigner signer = new JcaContentSignerBuilder(sigAlg) + .setProvider(BouncyCastleProvider.PROVIDER_NAME) + .build(pair.getPrivate()); + + return new JcaX509CertificateConverter() + .setProvider(BouncyCastleProvider.PROVIDER_NAME) + .getCertificate(builder.build(signer)); + } +} \ No newline at end of file From b8c5214f4571ef34eda69b7193568de8a12a97d1 Mon Sep 17 00:00:00 2001 From: James Hateley Date: Mon, 31 Aug 2026 10:18:17 +1000 Subject: [PATCH 05/14] feat(pop): helper to attach identity (pop,scitt) headers Signed-off-by: James Hateley --- .../java/com/godaddy/ans/sdk/pop/PopHttp.java | 69 +++++++ .../com/godaddy/ans/sdk/pop/PopHttpTest.java | 182 ++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopHttp.java create mode 100644 ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/PopHttpTest.java diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopHttp.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopHttp.java new file mode 100644 index 0000000..96c3b0c --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopHttp.java @@ -0,0 +1,69 @@ +package com.godaddy.ans.sdk.pop; + +import java.net.http.HttpRequest; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +public final class PopHttp { + + public static final String DPOP_HEADER = "DPoP"; + + private static final String DPOP_SCHEME = "DPoP"; + + private PopHttp() { + } + + public static void attachIdentity(HttpRequest.Builder req, PopSigner signer, + Map> 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> entry : scittHeaders.entrySet()) { + for (String value : entry.getValue()) { + req.header(entry.getKey(), value); + } + } + } + + public static Optional accessTokenFromAuthorization(String value) { + if (value == null || value.length() <= DPOP_SCHEME.length()) { + return Optional.empty(); + } + if (!value.regionMatches(true, 0, DPOP_SCHEME, 0, DPOP_SCHEME.length())) { + return Optional.empty(); + } + char separator = value.charAt(DPOP_SCHEME.length()); + if (separator != ' ' && separator != '\t') { + return Optional.empty(); + } + String token = trimSpaceTab(value.substring(DPOP_SCHEME.length())); + if (token.isEmpty()) { + return Optional.empty(); + } + return Optional.of(token); + } + + private static String trimSpaceTab(String value) { + int start = 0; + int end = value.length(); + while (start < end && (value.charAt(start) == ' ' || value.charAt(start) == '\t')) { + start++; + } + while (end > start && (value.charAt(end - 1) == ' ' || value.charAt(end - 1) == '\t')) { + end--; + } + return value.substring(start, end); + } +} \ No newline at end of file diff --git a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/PopHttpTest.java b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/PopHttpTest.java new file mode 100644 index 0000000..c51cb7f --- /dev/null +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/PopHttpTest.java @@ -0,0 +1,182 @@ +package com.godaddy.ans.sdk.pop; + +import com.godaddy.ans.sdk.transparency.scitt.ScittHeaders; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.net.URI; +import java.net.http.HttpRequest; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.security.interfaces.ECPrivateKey; +import java.security.spec.ECGenParameterSpec; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class PopHttpTest { + + private static PopSigner signer; + + @BeforeAll + static void setUp() throws Exception { + Security.addProvider(new BouncyCastleProvider()); + + KeyPair pair = ec("secp256r1"); + X509Certificate cert = selfSigned(pair, "SHA256withECDSA"); + signer = PopSigner.create((ECPrivateKey) pair.getPrivate(), cert.getEncoded()); + } + + @Test + void attachIdentitySetsDpopAndScittHeaders() throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create("https://api.example.com/agents")) + .POST(HttpRequest.BodyPublishers.noBody()); + Map> scitt = Map.of( + ScittHeaders.SCITT_RECEIPT_HEADER, List.of("receipt-bytes"), + ScittHeaders.STATUS_TOKEN_HEADER, List.of("token-bytes")); + + PopHttp.attachIdentity(builder, signer, scitt, null); + + HttpRequest request = builder.build(); + assertThat(request.headers().firstValue(PopHttp.DPOP_HEADER)).isPresent(); + assertThat(request.headers().firstValue(ScittHeaders.SCITT_RECEIPT_HEADER)) + .contains("receipt-bytes"); + assertThat(request.headers().firstValue(ScittHeaders.STATUS_TOKEN_HEADER)) + .contains("token-bytes"); + } + + @Test + void attachIdentityProofBindsMethodAndUrl() throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create("HTTPS://API.Example.COM:443/X?q=1#frag")) + .DELETE(); + + PopHttp.attachIdentity(builder, signer, Map.of(), null); + + Proof.Claims claims = decodeProof(builder); + assertThat(claims.htm()).isEqualTo("DELETE"); + assertThat(claims.htu()).isEqualTo("https://api.example.com/X"); + } + + @Test + void attachIdentityWithTokenBindsAth() throws Exception { + String token = "Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU"; + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create("https://api.example.com/x")) + .GET(); + + PopHttp.attachIdentity(builder, signer, Map.of(), token); + + assertThat(decodeProof(builder).ath()).isEqualTo(Proof.accessTokenHash(token)); + } + + @Test + void attachIdentityWithoutTokenHasNoAth() throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create("https://api.example.com/x")) + .GET(); + + PopHttp.attachIdentity(builder, signer, Map.of(), null); + + assertThat(decodeProof(builder).ath()).isNull(); + } + + @Test + void attachIdentityDoesNotSniffAuthorizationHeader() throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create("https://api.example.com/x")) + .header("Authorization", "DPoP some-token") + .GET(); + + PopHttp.attachIdentity(builder, signer, Map.of(), null); + + assertThat(decodeProof(builder).ath()).isNull(); + } + + @Test + void accessTokenFromAuthorizationParsesDpopScheme() { + assertThat(PopHttp.accessTokenFromAuthorization("DPoP abc123")).contains("abc123"); + } + + @Test + void accessTokenFromAuthorizationCaseInsensitiveScheme() { + assertThat(PopHttp.accessTokenFromAuthorization("dpop abc123")).contains("abc123"); + } + + @Test + void accessTokenFromAuthorizationTabSeparatorAndTrim() { + assertThat(PopHttp.accessTokenFromAuthorization("DPoP\t abc123 \t")).contains("abc123"); + } + + @Test + void accessTokenFromAuthorizationRejectsBearer() { + assertThat(PopHttp.accessTokenFromAuthorization("Bearer abc123")).isEmpty(); + } + + @Test + void accessTokenFromAuthorizationRejectsSchemeOnly() { + assertThat(PopHttp.accessTokenFromAuthorization("DPoP")).isEmpty(); + } + + @Test + void accessTokenFromAuthorizationRejectsMissingSeparator() { + assertThat(PopHttp.accessTokenFromAuthorization("DPoPabc")).isEmpty(); + } + + @Test + void accessTokenFromAuthorizationRejectsBlankToken() { + assertThat(PopHttp.accessTokenFromAuthorization("DPoP ")).isEmpty(); + } + + @Test + void accessTokenFromAuthorizationRejectsNull() { + assertThat(PopHttp.accessTokenFromAuthorization(null)).isEmpty(); + } + + private static Proof.Claims decodeProof(HttpRequest.Builder builder) throws Exception { + Optional proof = builder.build().headers().firstValue(PopHttp.DPOP_HEADER); + assertThat(proof).isPresent(); + return Proof.parseClaims(Proof.acceptES256DPoP(proof.get()).jws().getPayload()); + } + + private static KeyPair ec(String curve) throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); + kpg.initialize(new ECGenParameterSpec(curve)); + return kpg.generateKeyPair(); + } + + private static X509Certificate selfSigned(KeyPair pair, String sigAlg) throws Exception { + X500Name subject = new X500Name("CN=test"); + BigInteger serial = BigInteger.valueOf(1); + Date notBefore = new Date(1_600_000_000_000L); + Date notAfter = new Date(4_100_000_000_000L); + + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + subject, serial, notBefore, notAfter, subject, pair.getPublic()); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false)); + + ContentSigner contentSigner = new JcaContentSignerBuilder(sigAlg) + .setProvider(BouncyCastleProvider.PROVIDER_NAME) + .build(pair.getPrivate()); + + return new JcaX509CertificateConverter() + .setProvider(BouncyCastleProvider.PROVIDER_NAME) + .getCertificate(builder.build(contentSigner)); + } +} \ No newline at end of file From f625e87006b2424364f51b0dc05bf7ad83fbe8d7 Mon Sep 17 00:00:00 2001 From: James Hateley Date: Mon, 31 Aug 2026 13:05:51 +1000 Subject: [PATCH 06/14] feat(pop): boost test coverage Signed-off-by: James Hateley --- ans-sdk-pop/build.gradle.kts | 4 ++ .../godaddy/ans/sdk/pop/CallerVerifier.java | 2 +- .../ans/sdk/pop/CallerOptionsTest.java | 61 +++++++++++++++++ .../ans/sdk/pop/CallerVerifierTest.java | 67 +++++++++++++++++++ 4 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerOptionsTest.java diff --git a/ans-sdk-pop/build.gradle.kts b/ans-sdk-pop/build.gradle.kts index 3d52757..f47a1c9 100644 --- a/ans-sdk-pop/build.gradle.kts +++ b/ans-sdk-pop/build.gradle.kts @@ -1,4 +1,5 @@ val nimbusJoseVersion: String by project +val caffeineVersion: String by project val bouncyCastleVersion: String by project val slf4jVersion: String by project val cborVersion: String by project @@ -21,6 +22,9 @@ dependencies { // 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") diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java index b2005c4..2970673 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java @@ -224,7 +224,7 @@ private static String requireSingleHeader(Map> headers, Str return value; } - static String ansHost(String ansName) throws PopException { + public static String ansHost(String ansName) throws PopException { if (ansName == null || ansName.isBlank()) { throw new PopException(ErrorType.BINDING_FAILED, "ans name is missing"); } diff --git a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerOptionsTest.java b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerOptionsTest.java new file mode 100644 index 0000000..c9e50f8 --- /dev/null +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerOptionsTest.java @@ -0,0 +1,61 @@ +package com.godaddy.ans.sdk.pop; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +class CallerOptionsTest { + + @Test + void noneHasNullFields() { + CallerOptions options = CallerOptions.none(); + + assertThat(options.accessToken()).isNull(); + assertThat(options.expectedPeer()).isNull(); + assertThat(options.clock()).isNull(); + } + + @Test + void withAccessTokenSetsTokenAndPreservesOthers() { + Instant now = Instant.parse("2026-08-28T12:00:00Z"); + CallerOptions options = CallerOptions.none() + .withExpectedPeer("ans://peer.example.com") + .withClock(now) + .withAccessToken("access-token"); + + assertThat(options.accessToken()).isEqualTo("access-token"); + assertThat(options.expectedPeer()).isEqualTo("ans://peer.example.com"); + assertThat(options.clock()).isEqualTo(now); + } + + @Test + void withClockSetsClockAndPreservesOthers() { + Instant now = Instant.parse("2026-08-28T12:00:00Z"); + CallerOptions options = CallerOptions.none() + .withAccessToken("access-token") + .withExpectedPeer("ans://peer.example.com") + .withClock(now); + + assertThat(options.clock()).isEqualTo(now); + assertThat(options.accessToken()).isEqualTo("access-token"); + assertThat(options.expectedPeer()).isEqualTo("ans://peer.example.com"); + } + + @Test + void withAccessTokenRejectsNull() { + assertThatNullPointerException().isThrownBy(() -> CallerOptions.none().withAccessToken(null)); + } + + @Test + void withClockRejectsNull() { + assertThatNullPointerException().isThrownBy(() -> CallerOptions.none().withClock(null)); + } + + @Test + void withExpectedPeerRejectsNull() { + assertThatNullPointerException().isThrownBy(() -> CallerOptions.none().withExpectedPeer(null)); + } +} \ No newline at end of file diff --git a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerVerifierTest.java b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerVerifierTest.java index 43f131a..75a7cae 100644 --- a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerVerifierTest.java +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerVerifierTest.java @@ -269,6 +269,73 @@ void unparseableReceiptRejected() { assertThat(ex.category()).isEqualTo(ErrorType.RECEIPT_INVALID); } + @Test + void createBuildsVerifier() { + assertThat(CallerVerifier.create("https://tl.example.com")).isNotNull(); + assertThat(CallerVerifier.create("https://tl.example.com", + Duration.ofSeconds(30), Duration.ofSeconds(90))).isNotNull(); + } + + @Test + void accessTokenBindingAndInjectedClockAccepted() throws Exception { + String token = "Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU"; + String proofWithAth = PopSigner.create((ECPrivateKey) keyPair.getPrivate(), cert.getEncoded()) + .sign(METHOD, URL, token); + + CallerIdentity identity = verifier().verifyParsed( + proofWithAth, receipt(AGENT_ID, ANS_NAME), token(ANS_NAME, AGENT_ID, certFingerprint), + METHOD, URL, Map.of(), new CountingReplay(false), + CallerOptions.none().withAccessToken(token).withClock(Instant.now())); + + assertThat(identity.agentId()).isEqualTo(AGENT_ID); + } + + @Test + void receiptEventPayloadNotJsonRejected() { + ScittReceipt receipt = new ScittReceipt(null, null, null, + "not-json".getBytes(StandardCharsets.UTF_8), null); + PopException ex = catchThrowableOfType(() -> verifier().verifyParsed( + proofJws, receipt, token(ANS_NAME, AGENT_ID, certFingerprint), + METHOD, URL, Map.of(), new CountingReplay(false), CallerOptions.none()), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.BINDING_FAILED); + } + + @Test + void ansHostRejectsNull() { + PopException ex = catchThrowableOfType(() -> CallerVerifier.ansHost(null), PopException.class); + assertThat(ex.category()).isEqualTo(ErrorType.BINDING_FAILED); + } + + @Test + void ansHostRejectsBlank() { + PopException ex = catchThrowableOfType(() -> CallerVerifier.ansHost(" "), PopException.class); + assertThat(ex.category()).isEqualTo(ErrorType.BINDING_FAILED); + } + + @Test + void ansHostAcceptsBareHostWithoutScheme() throws Exception { + assertThat(CallerVerifier.ansHost("Agent.Example.COM")).isEqualTo("agent.example.com"); + } + + @Test + void ansHostStripsVersionLabel() throws Exception { + assertThat(CallerVerifier.ansHost("ans://v1.2.3.agent.example.com")).isEqualTo("agent.example.com"); + } + + @Test + void ansHostRejectsInvalidUri() { + PopException ex = catchThrowableOfType( + () -> CallerVerifier.ansHost("ans://bad host with spaces"), PopException.class); + assertThat(ex.category()).isEqualTo(ErrorType.BINDING_FAILED); + } + + @Test + void ansHostRejectsMissingAuthority() { + PopException ex = catchThrowableOfType(() -> CallerVerifier.ansHost("ans:///path"), PopException.class); + assertThat(ex.category()).isEqualTo(ErrorType.BINDING_FAILED); + } + private static final Duration DEFAULT_SKEW = Duration.ofSeconds(120); private static CallerVerifier verifier() { From e68b5afb5395c6f1a33660412e585a572d1740c6 Mon Sep 17 00:00:00 2001 From: James Hateley Date: Mon, 31 Aug 2026 13:32:29 +1000 Subject: [PATCH 07/14] feat(pop): add spring-boot example Signed-off-by: James Hateley --- ans-sdk-spring-boot-starter/build.gradle.kts | 14 + .../examples/a2a-no-mtls/README.md | 62 +++ .../examples/a2a-no-mtls/build.gradle.kts | 17 + .../examples/a2anomtls/PopClientExample.java | 85 ++++ .../examples/a2anomtls/PopSecurityConfig.java | 47 ++ .../a2anomtls/PopServerApplication.java | 12 + .../a2anomtls/ProtectedController.java | 32 ++ .../src/main/resources/application.yml | 6 + .../ans/sdk/spring/PopAuthentication.java | 24 + .../sdk/spring/PopAuthenticationFilter.java | 332 +++++++++++++ .../spring/PopAuthenticationFilterTest.java | 441 ++++++++++++++++++ .../ans/sdk/spring/PopAuthenticationTest.java | 47 ++ 12 files changed, 1119 insertions(+) create mode 100644 ans-sdk-spring-boot-starter/examples/a2a-no-mtls/README.md create mode 100644 ans-sdk-spring-boot-starter/examples/a2a-no-mtls/build.gradle.kts create mode 100644 ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopClientExample.java create mode 100644 ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopSecurityConfig.java create mode 100644 ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopServerApplication.java create mode 100644 ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/ProtectedController.java create mode 100644 ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/resources/application.yml create mode 100644 ans-sdk-spring-boot-starter/src/main/java/com/godaddy/ans/sdk/spring/PopAuthentication.java create mode 100644 ans-sdk-spring-boot-starter/src/main/java/com/godaddy/ans/sdk/spring/PopAuthenticationFilter.java create mode 100644 ans-sdk-spring-boot-starter/src/test/java/com/godaddy/ans/sdk/spring/PopAuthenticationFilterTest.java create mode 100644 ans-sdk-spring-boot-starter/src/test/java/com/godaddy/ans/sdk/spring/PopAuthenticationTest.java diff --git a/ans-sdk-spring-boot-starter/build.gradle.kts b/ans-sdk-spring-boot-starter/build.gradle.kts index 8581a94..3d20547 100644 --- a/ans-sdk-spring-boot-starter/build.gradle.kts +++ b/ans-sdk-spring-boot-starter/build.gradle.kts @@ -1,5 +1,6 @@ val junitVersion: String by project val assertjVersion: String by project +val slf4jVersion: String by project val springBootVersion = "4.1.1" @@ -8,15 +9,28 @@ dependencies { api(project(":ans-sdk-core")) api(project(":ans-sdk-registration")) api(project(":ans-sdk-discovery")) + api(project(":ans-sdk-pop")) // Spring Boot auto-configuration + implementation(platform("org.springframework.boot:spring-boot-dependencies:$springBootVersion")) implementation("org.springframework.boot:spring-boot-autoconfigure:$springBootVersion") + // Servlet filter surface (provided by the consuming web application) + compileOnly("org.springframework:spring-web") + compileOnly("jakarta.servlet:jakarta.servlet-api") + + // Logging + implementation("org.slf4j:slf4j-api:$slf4jVersion") + // Optional annotation processor for configuration metadata annotationProcessor("org.springframework.boot:spring-boot-configuration-processor:$springBootVersion") // Testing + testImplementation(platform("org.springframework.boot:spring-boot-dependencies:$springBootVersion")) testImplementation("org.junit.jupiter:junit-jupiter:$junitVersion") testImplementation("org.assertj:assertj-core:$assertjVersion") testImplementation("org.springframework.boot:spring-boot-starter-test:$springBootVersion") + // Servlet filter surface under test (compileOnly in main, so declare for tests) + testImplementation("org.springframework:spring-web") + testImplementation("jakarta.servlet:jakarta.servlet-api") } diff --git a/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/README.md b/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/README.md new file mode 100644 index 0000000..7529a61 --- /dev/null +++ b/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/README.md @@ -0,0 +1,62 @@ +# A2A no-mTLS Example + +This example shows sender-constrained caller authentication for ANS agent-to-agent (A2A) traffic without mutual TLS. The client proves that it holds its private key with a DPoP proof (RFC 9449). It sends the proof over a normal server-authenticated HTTPS connection. The server verifies the proof and the ANS identity with the `PopAuthenticationFilter`. + +DPoP works together with SCITT. SCITT gives identity and liveness. The DPoP proof binds the request to the key that the caller holds. DPoP does not replace SCITT. + +## What it shows + +- **Client attach** (`PopClientExample`) — makes a DPoP proof with `PopSigner`. It attaches the proof and the SCITT identity headers to an outbound request with `PopHttp.attachIdentity`. +- **Server filter** (`PopSecurityConfig` + `ProtectedController`) — registers `PopAuthenticationFilter` to verify the caller. It then reads the resolved `CallerIdentity` with `PopAuthentication.fromRequest`. + +## Prerequisites + +- A PKCS12 keystore that holds the caller EC P-256 private key and its leaf certificate. The certificate must carry an `ans://` SAN. +- The agent must be registered in the ANS transparency log. The server fetches a receipt and a status token for the agent. +- Java 17 or later. + +## Run the server + +The server listens on port 8443 and protects `/a2a/*`. Set the trusted host to the public authority that clients use to reach the server. This pins the source of the `htu` binding. + +```bash +export POP_TRUSTED_HOST=server.example.com:8443 +./gradlew :ans-sdk-spring-boot-starter:examples:a2a-no-mtls:bootRun +``` + +The configuration is in `application.yml`: + +- `pop.expected-issuer` — the transparency log domain that issues the status token. +- `pop.trusted-host` — the authority that the filter trusts for the request URL. + +## Run the client + +```bash +./gradlew :ans-sdk-spring-boot-starter:examples:a2a-no-mtls:runClient \ + --args="https://server.example.com:8443/a2a/whoami client.p12 changeit agent-key my-agent-id" +``` + +The arguments, in order: + +1. `serverUrl` — the full URL of the protected endpoint. +2. `keystorePath` — the path to the PKCS12 keystore. +3. `keystorePassword` — the keystore password. +4. `keyAlias` — the alias of the key entry in the keystore. +5. `agentId` — the agent ID that fetches the SCITT receipt and status token. + +On success the server returns the caller identity: + +```json +{ + "ansName": "ans://my-agent.example.com", + "agentId": "my-agent-id", + "fingerprint": "…", + "jkt": "…" +} +``` + +## Notes + +- The channel is server-authenticated HTTPS. The client does not present a certificate in the TLS handshake. +- The filter fails closed. A missing, duplicate, or invalid header returns `401`. +- This module is an example. Coverage checks do not include it. \ No newline at end of file diff --git a/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/build.gradle.kts b/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/build.gradle.kts new file mode 100644 index 0000000..d1fcc70 --- /dev/null +++ b/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id("org.springframework.boot") version "4.1.0" + id("io.spring.dependency-management") version "1.1.7" +} + +dependencies { + implementation(project(":ans-sdk-spring-boot-starter")) + implementation(project(":ans-sdk-pop")) + implementation("org.springframework.boot:spring-boot-starter-web") +} + +tasks.register("runClient") { + group = "application" + description = "Runs the DPoP client that attaches identity headers to an outbound request" + mainClass.set("com.godaddy.ans.examples.a2anomtls.PopClientExample") + classpath = sourceSets["main"].runtimeClasspath +} \ No newline at end of file diff --git a/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopClientExample.java b/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopClientExample.java new file mode 100644 index 0000000..080d591 --- /dev/null +++ b/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopClientExample.java @@ -0,0 +1,85 @@ +package com.godaddy.ans.examples.a2anomtls; + +import com.godaddy.ans.sdk.pop.PopHttp; +import com.godaddy.ans.sdk.pop.PopSigner; +import com.godaddy.ans.sdk.transparency.TransparencyClient; +import com.godaddy.ans.sdk.transparency.scitt.ScittHeaders; + +import java.io.FileInputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.security.KeyStore; +import java.security.cert.X509Certificate; +import java.security.interfaces.ECPrivateKey; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +public final class PopClientExample { + + private PopClientExample() { + } + + public static void main(String[] args) throws Exception { + if (args.length < 5) { + System.out.println("Usage: runClient " + + " "); + System.out.println("Example: runClient https://server.example.com:8443/a2a/whoami " + + "client.p12 changeit agent-key my-agent-id"); + System.exit(1); + } + + String serverUrl = args[0]; + String keystorePath = args[1]; + String keystorePassword = args[2]; + String keyAlias = args[3]; + String agentId = args[4]; + + System.out.println("==========================================="); + System.out.println("ANS SDK - A2A no-mTLS Client (DPoP over server-auth HTTPS)"); + System.out.println("==========================================="); + System.out.println("Target: " + serverUrl); + + PopSigner signer = loadSigner(keystorePath, keystorePassword, keyAlias); + System.out.println("DPoP signer ready. jkt=" + signer.jkt()); + + Map> scittHeaders = fetchScittHeaders(agentId); + System.out.println("SCITT headers fetched for agent " + agentId); + + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(serverUrl)) + .GET(); + + PopHttp.attachIdentity(builder, signer, scittHeaders, null); + System.out.println("Attached DPoP proof and SCITT identity headers"); + + HttpClient client = HttpClient.newHttpClient(); + HttpResponse response = client.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + + System.out.println("Response status: " + response.statusCode()); + System.out.println("Response body: " + response.body()); + } + + private static PopSigner loadSigner(String keystorePath, String keystorePassword, String keyAlias) + throws Exception { + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + try (FileInputStream in = new FileInputStream(keystorePath)) { + keyStore.load(in, keystorePassword.toCharArray()); + } + ECPrivateKey privateKey = (ECPrivateKey) keyStore.getKey(keyAlias, keystorePassword.toCharArray()); + X509Certificate cert = (X509Certificate) keyStore.getCertificate(keyAlias); + return PopSigner.create(privateKey, cert.getEncoded()); + } + + private static Map> fetchScittHeaders(String agentId) { + try (TransparencyClient transparency = TransparencyClient.createOte()) { + String receipt = Base64.getEncoder().encodeToString(transparency.getReceipt(agentId)); + String statusToken = Base64.getEncoder().encodeToString(transparency.getStatusToken(agentId)); + return Map.of( + ScittHeaders.SCITT_RECEIPT_HEADER, List.of(receipt), + ScittHeaders.STATUS_TOKEN_HEADER, List.of(statusToken)); + } + } +} \ No newline at end of file diff --git a/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopSecurityConfig.java b/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopSecurityConfig.java new file mode 100644 index 0000000..8d6d0eb --- /dev/null +++ b/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopSecurityConfig.java @@ -0,0 +1,47 @@ +package com.godaddy.ans.examples.a2anomtls; + +import com.godaddy.ans.sdk.pop.CaffeineReplayCache; +import com.godaddy.ans.sdk.pop.ReplayCache; +import com.godaddy.ans.sdk.spring.PopAuthenticationFilter; +import com.godaddy.ans.sdk.transparency.TransparencyClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.security.PublicKey; +import java.util.Map; +import java.util.function.Supplier; + +@Configuration +public class PopSecurityConfig { + + @Bean + public TransparencyClient transparencyClient() { + return TransparencyClient.createOte(); + } + + @Bean + public ReplayCache replayCache() { + return CaffeineReplayCache.create(100_000); + } + + @Bean + public FilterRegistrationBean popAuthenticationFilter( + TransparencyClient transparencyClient, + ReplayCache replayCache, + @Value("${pop.expected-issuer}") String expectedIssuer, + @Value("${pop.trusted-host}") String trustedHost) { + + Supplier> rootKeys = () -> transparencyClient.getRootKeysAsync().join(); + + PopAuthenticationFilter filter = PopAuthenticationFilter + .builder(expectedIssuer, rootKeys, replayCache) + .withTrustedHosts(trustedHost) + .build(); + + FilterRegistrationBean registration = new FilterRegistrationBean<>(filter); + registration.addUrlPatterns("/a2a/*"); + return registration; + } +} \ No newline at end of file diff --git a/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopServerApplication.java b/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopServerApplication.java new file mode 100644 index 0000000..cfe469c --- /dev/null +++ b/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopServerApplication.java @@ -0,0 +1,12 @@ +package com.godaddy.ans.examples.a2anomtls; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class PopServerApplication { + + public static void main(String[] args) { + SpringApplication.run(PopServerApplication.class, args); + } +} \ No newline at end of file diff --git a/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/ProtectedController.java b/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/ProtectedController.java new file mode 100644 index 0000000..89adf0b --- /dev/null +++ b/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/ProtectedController.java @@ -0,0 +1,32 @@ +package com.godaddy.ans.examples.a2anomtls; + +import com.godaddy.ans.sdk.pop.CallerIdentity; +import com.godaddy.ans.sdk.spring.PopAuthentication; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; +import java.util.Optional; + +@RestController +@RequestMapping("/a2a") +public class ProtectedController { + + @GetMapping("/whoami") + public ResponseEntity> whoami(HttpServletRequest request) { + Optional caller = PopAuthentication.fromRequest(request); + if (caller.isEmpty()) { + return ResponseEntity.status(401).build(); + } + CallerIdentity identity = caller.get(); + return ResponseEntity.ok(Map.of( + "ansName", identity.ansName(), + "agentId", identity.agentId(), + "fingerprint", identity.fingerprintHex(), + "jkt", identity.jkt() + )); + } +} \ No newline at end of file diff --git a/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/resources/application.yml b/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/resources/application.yml new file mode 100644 index 0000000..18e9c0a --- /dev/null +++ b/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/resources/application.yml @@ -0,0 +1,6 @@ +server: + port: 8443 + +pop: + expected-issuer: transparency.ans.ote-godaddy.com + trusted-host: ${POP_TRUSTED_HOST:server.example.com:8443} \ No newline at end of file diff --git a/ans-sdk-spring-boot-starter/src/main/java/com/godaddy/ans/sdk/spring/PopAuthentication.java b/ans-sdk-spring-boot-starter/src/main/java/com/godaddy/ans/sdk/spring/PopAuthentication.java new file mode 100644 index 0000000..7218110 --- /dev/null +++ b/ans-sdk-spring-boot-starter/src/main/java/com/godaddy/ans/sdk/spring/PopAuthentication.java @@ -0,0 +1,24 @@ +package com.godaddy.ans.sdk.spring; + +import com.godaddy.ans.sdk.pop.CallerIdentity; +import jakarta.servlet.http.HttpServletRequest; + +import java.util.Objects; +import java.util.Optional; + +public final class PopAuthentication { + + public static final String CALLER_ATTRIBUTE = PopAuthentication.class.getName() + ".caller"; + + private PopAuthentication() { + } + + public static Optional fromRequest(HttpServletRequest request) { + Objects.requireNonNull(request, "request"); + Object value = request.getAttribute(CALLER_ATTRIBUTE); + if (value instanceof CallerIdentity identity) { + return Optional.of(identity); + } + return Optional.empty(); + } +} \ No newline at end of file diff --git a/ans-sdk-spring-boot-starter/src/main/java/com/godaddy/ans/sdk/spring/PopAuthenticationFilter.java b/ans-sdk-spring-boot-starter/src/main/java/com/godaddy/ans/sdk/spring/PopAuthenticationFilter.java new file mode 100644 index 0000000..fc67e45 --- /dev/null +++ b/ans-sdk-spring-boot-starter/src/main/java/com/godaddy/ans/sdk/spring/PopAuthenticationFilter.java @@ -0,0 +1,332 @@ +package com.godaddy.ans.sdk.spring; + +import com.godaddy.ans.sdk.pop.CallerIdentity; +import com.godaddy.ans.sdk.pop.CallerOptions; +import com.godaddy.ans.sdk.pop.CallerVerifier; +import com.godaddy.ans.sdk.pop.PopException; +import com.godaddy.ans.sdk.pop.PopHttp; +import com.godaddy.ans.sdk.pop.ReplayCache; +import com.godaddy.ans.sdk.transparency.scitt.ScittHeaders; +import com.godaddy.ans.sdk.transparency.scitt.StatusToken; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; +import java.net.URI; +import java.net.URISyntaxException; +import java.security.PublicKey; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Supplier; + +public final class PopAuthenticationFilter extends OncePerRequestFilter { + + private static final Logger LOG = LoggerFactory.getLogger(PopAuthenticationFilter.class); + + private static final List SECURITY_HEADERS = List.of( + PopHttp.DPOP_HEADER, "Authorization", + ScittHeaders.SCITT_RECEIPT_HEADER, ScittHeaders.STATUS_TOKEN_HEADER); + + private final CallerVerifier verifier; + private final Supplier> rootKeys; + private final ReplayCache replay; + private final Function externalUrl; + private final Set trustedHosts; + private final Set allowedHosts; + + // Package-private for tests: lets a test inject a stubbed verifier and pre-resolved host sets. + PopAuthenticationFilter(CallerVerifier verifier, Supplier> rootKeys, + ReplayCache replay, Function externalUrl, + Set trustedHosts, Set allowedHosts) { + this.verifier = verifier; + this.rootKeys = rootKeys; + this.replay = replay; + this.externalUrl = externalUrl; + this.trustedHosts = trustedHosts; + this.allowedHosts = allowedHosts; + } + + public static Builder builder(String expectedIssuer, Supplier> rootKeys, + ReplayCache replay) { + return new Builder(expectedIssuer, rootKeys, replay); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + for (String header : SECURITY_HEADERS) { + if (countHeaders(request, header) > 1) { + LOG.info("caller rejected: MALFORMED_PROOF - duplicate {} header", header); + reject(response); + return; + } + } + + if (!checkAuthority(request)) { + LOG.info("caller rejected: HTTP_BINDING_MISMATCH - request authority is not trusted"); + reject(response); + return; + } + + String proof = request.getHeader(PopHttp.DPOP_HEADER); + if (proof == null || proof.isBlank()) { + LOG.info("caller rejected: MISSING_HEADERS - no DPoP proof on request"); + reject(response); + return; + } + + CallerOptions options = CallerOptions.none(); + Optional accessToken = PopHttp.accessTokenFromAuthorization(request.getHeader("Authorization")); + if (accessToken.isPresent()) { + options = options.withAccessToken(accessToken.get()); + } + + Map 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, headerMap(request), request.getMethod(), + resolveUrl(request), keys, replay, options); + } catch (PopException e) { + reject(response); + return; + } + + if (!allowedHosts.isEmpty() && !allowedByHost(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); + } + + private boolean allowedByHost(CallerIdentity identity) { + try { + return allowedHosts.contains(CallerVerifier.ansHost(identity.ansName())); + } catch (PopException e) { + return false; + } + } + + private boolean checkAuthority(HttpServletRequest request) { + if (trustedHosts.isEmpty()) { + return true; + } + String authority; + if (externalUrl != null) { + try { + authority = new URI(externalUrl.apply(request)).getAuthority(); + } catch (URISyntaxException e) { + return false; + } + } else { + authority = request.getHeader("Host"); + if (authority == null) { + authority = request.getServerName(); + } + } + if (authority == null) { + return false; + } + return trustedHosts.contains(normalizeAuthority(authority)); + } + + 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 static Map> headerMap(HttpServletRequest request) { + Map> headers = new HashMap<>(); + Enumeration names = request.getHeaderNames(); + while (names.hasMoreElements()) { + String name = names.nextElement(); + List values = new ArrayList<>(); + Enumeration headerValues = request.getHeaders(name); + while (headerValues.hasMoreElements()) { + values.add(headerValues.nextElement()); + } + headers.put(name, values); + } + return headers; + } + + private static int countHeaders(HttpServletRequest request, String name) { + Enumeration values = request.getHeaders(name); + int count = 0; + while (values != null && values.hasMoreElements()) { + values.nextElement(); + count++; + } + return count; + } + + private static void reject(HttpServletResponse response) throws IOException { + response.setHeader("WWW-Authenticate", PopHttp.DPOP_HEADER); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "unauthorized"); + } + + private static String normalizeAuthority(String host) { + String normalized = host.trim().toLowerCase(Locale.ROOT); + if (normalized.endsWith(":443")) { + return normalized.substring(0, normalized.length() - 4); + } + if (normalized.endsWith(":80")) { + return normalized.substring(0, normalized.length() - 3); + } + return normalized; + } + + public static final class Builder { + + private final String expectedIssuer; + private final Supplier> rootKeys; + private final ReplayCache replay; + private final Set trustedHosts = new HashSet<>(); + private final List allowedNames = new ArrayList<>(); + private Function externalUrl; + private Duration popSkew; + + private Builder(String expectedIssuer, Supplier> rootKeys, ReplayCache replay) { + this.expectedIssuer = Objects.requireNonNull(expectedIssuer, "expectedIssuer"); + this.rootKeys = Objects.requireNonNull(rootKeys, "rootKeys"); + this.replay = Objects.requireNonNull(replay, "replay"); + } + + public Builder withExternalUrl(Function externalUrl) { + this.externalUrl = Objects.requireNonNull(externalUrl, "externalUrl"); + return this; + } + + public Builder withTrustedHosts(String... hosts) { + for (String host : hosts) { + if (host == null) { + continue; + } + String normalized = normalizeAuthority(host); + if (!normalized.isEmpty()) { + trustedHosts.add(normalized); + } + } + if (hosts.length > 0 && trustedHosts.isEmpty()) { + throw new IllegalArgumentException("withTrustedHosts: every supplied host was empty"); + } + return this; + } + + public Builder withExpectedAnsName(String ansName) { + allowedNames.add(Objects.requireNonNull(ansName, "ansName")); + return this; + } + + public Builder withAllowedAnsNames(String... ansNames) { + for (String ansName : ansNames) { + allowedNames.add(Objects.requireNonNull(ansName, "ansName")); + } + return this; + } + + public Builder withPoPSkew(Duration popSkew) { + this.popSkew = Objects.requireNonNull(popSkew, "popSkew"); + return this; + } + + public PopAuthenticationFilter build() { + if (externalUrl != null) { + probeExternalUrl(externalUrl); + } + if (externalUrl == null && trustedHosts.isEmpty()) { + LOG.warn("htu will be derived from the client-controlled Host header; " + + "set withExternalUrl or withTrustedHosts before production"); + } + Set resolvedAllowed = new HashSet<>(); + for (String ansName : allowedNames) { + try { + resolvedAllowed.add(CallerVerifier.ansHost(ansName)); + } catch (PopException e) { + throw new IllegalArgumentException("invalid allowed ans name: " + ansName, e); + } + } + CallerVerifier verifier = popSkew != null + ? CallerVerifier.create(expectedIssuer, StatusToken.DEFAULT_CLOCK_SKEW, popSkew) + : CallerVerifier.create(expectedIssuer); + return new PopAuthenticationFilter(verifier, rootKeys, replay, externalUrl, + Set.copyOf(trustedHosts), resolvedAllowed); + } + + private static void probeExternalUrl(Function fn) { + String first = fn.apply(probeRequest("/pop-probe-a")); + String second = fn.apply(probeRequest("/pop-probe-b")); + if (Objects.equals(first, second)) { + throw new IllegalArgumentException("withExternalUrl function ignores the request path; " + + "htu would not bind the request target - append request.getRequestURI() to the authority"); + } + } + + private static HttpServletRequest probeRequest(String path) { + InvocationHandler handler = (proxy, method, args) -> { + switch (method.getName()) { + case "getRequestURI": + case "getServletPath": + return path; + case "getRequestURL": + return new StringBuffer("https://probe.invalid").append(path); + case "getMethod": + return "GET"; + case "getScheme": + return "https"; + case "getServerName": + return "probe.invalid"; + case "getServerPort": + return 443; + default: + break; + } + Class returnType = method.getReturnType(); + if (returnType.equals(boolean.class)) { + return false; + } + if (returnType.equals(int.class)) { + return 0; + } + if (returnType.equals(long.class)) { + return 0L; + } + return null; + }; + return (HttpServletRequest) Proxy.newProxyInstance( + PopAuthenticationFilter.class.getClassLoader(), + new Class[] {HttpServletRequest.class}, handler); + } + } +} \ No newline at end of file diff --git a/ans-sdk-spring-boot-starter/src/test/java/com/godaddy/ans/sdk/spring/PopAuthenticationFilterTest.java b/ans-sdk-spring-boot-starter/src/test/java/com/godaddy/ans/sdk/spring/PopAuthenticationFilterTest.java new file mode 100644 index 0000000..5cb48b5 --- /dev/null +++ b/ans-sdk-spring-boot-starter/src/test/java/com/godaddy/ans/sdk/spring/PopAuthenticationFilterTest.java @@ -0,0 +1,441 @@ +package com.godaddy.ans.sdk.spring; + +import com.godaddy.ans.sdk.pop.CallerIdentity; +import com.godaddy.ans.sdk.pop.CallerVerifier; +import com.godaddy.ans.sdk.pop.ErrorType; +import com.godaddy.ans.sdk.pop.PopException; +import com.godaddy.ans.sdk.pop.PopHttp; +import com.godaddy.ans.sdk.pop.ReplayCache; +import com.godaddy.ans.sdk.transparency.scitt.ScittHeaders; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.security.PublicKey; +import java.time.Duration; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Supplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link PopAuthenticationFilter}. + */ +class PopAuthenticationFilterTest { + + private static final String ANS_NAME = "ans://agent.example.com"; + private static final Supplier> ROOT_KEYS = Map::of; + private static final ReplayCache REPLAY = (key, ttl) -> false; + + private static CallerIdentity identity(String ansName) { + return new CallerIdentity(ansName, "agent-1", new byte[] {1, 2, 3}, "jkt"); + } + + private static CallerVerifier verifierReturning(CallerIdentity identity) throws PopException { + CallerVerifier verifier = mock(CallerVerifier.class); + when(verifier.verifyCaller(any(), any(), any(), any(), any(), any(), any())).thenReturn(identity); + return verifier; + } + + private static CallerVerifier verifierRejecting() throws PopException { + CallerVerifier verifier = mock(CallerVerifier.class); + when(verifier.verifyCaller(any(), any(), any(), any(), any(), any(), any())) + .thenThrow(new PopException(ErrorType.MALFORMED_PROOF, "bad proof")); + return verifier; + } + + private static PopAuthenticationFilter filter(CallerVerifier verifier, Set trustedHosts, + Set allowedHosts) { + return filter(verifier, ROOT_KEYS, null, trustedHosts, allowedHosts); + } + + private static PopAuthenticationFilter filter(CallerVerifier verifier, + Supplier> rootKeys, + Function externalUrl, + Set trustedHosts, Set allowedHosts) { + return new PopAuthenticationFilter(verifier, rootKeys, REPLAY, externalUrl, trustedHosts, allowedHosts); + } + + private static MockHttpServletRequest request() { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/verify"); + request.addHeader(PopHttp.DPOP_HEADER, "proof-token"); + return request; + } + + private static void assertRejected(MockHttpServletResponse response, MockFilterChain chain) { + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getErrorMessage()).isEqualTo("unauthorized"); + assertThat(response.getHeader("WWW-Authenticate")).isEqualTo(PopHttp.DPOP_HEADER); + assertThat(chain.getRequest()).as("filter chain should not be invoked").isNull(); + } + + // ==================== doFilterInternal - success ==================== + + @Test + void authenticatesCallerAndPopulatesAttribute() throws Exception { + PopAuthenticationFilter filter = filter(verifierReturning(identity(ANS_NAME)), Set.of(), Set.of()); + MockHttpServletRequest request = request(); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertThat(chain.getRequest()).isSameAs(request); + assertThat(PopAuthentication.fromRequest(request)).map(CallerIdentity::ansName).contains(ANS_NAME); + } + + @Test + void authenticatesWhenAnsHostIsInAllowedSet() throws Exception { + PopAuthenticationFilter filter = filter( + verifierReturning(identity(ANS_NAME)), Set.of(), Set.of("agent.example.com")); + MockHttpServletRequest request = request(); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertThat(chain.getRequest()).isSameAs(request); + } + + @Test + void passesAccessTokenFromAuthorizationHeader() throws Exception { + PopAuthenticationFilter filter = filter(verifierReturning(identity(ANS_NAME)), Set.of(), Set.of()); + MockHttpServletRequest request = request(); + request.addHeader("Authorization", "DPoP access-token-value"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertThat(chain.getRequest()).isSameAs(request); + } + + // ==================== doFilterInternal - rejections ==================== + + @Test + void rejectsDuplicateSecurityHeader() throws Exception { + PopAuthenticationFilter filter = filter(verifierReturning(identity(ANS_NAME)), Set.of(), Set.of()); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/verify"); + request.addHeader(PopHttp.DPOP_HEADER, "proof-a"); + request.addHeader(PopHttp.DPOP_HEADER, "proof-b"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertRejected(response, chain); + } + + @Test + void rejectsWhenAuthorityNotTrusted() throws Exception { + PopAuthenticationFilter filter = filter( + verifierReturning(identity(ANS_NAME)), Set.of("rp.example.com"), Set.of()); + MockHttpServletRequest request = request(); + request.addHeader("Host", "evil.example.com"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertRejected(response, chain); + } + + @Test + void acceptsWhenAuthorityIsTrusted() throws Exception { + PopAuthenticationFilter filter = filter( + verifierReturning(identity(ANS_NAME)), Set.of("rp.example.com"), Set.of()); + MockHttpServletRequest request = request(); + request.addHeader("Host", "rp.example.com"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertThat(chain.getRequest()).isSameAs(request); + } + + @Test + void trustsServerNameWhenHostHeaderAbsent() throws Exception { + PopAuthenticationFilter filter = filter( + verifierReturning(identity(ANS_NAME)), Set.of("localhost"), Set.of()); + MockHttpServletRequest request = request(); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertThat(chain.getRequest()).isSameAs(request); + } + + @Test + void rejectsWhenProofMissing() throws Exception { + PopAuthenticationFilter filter = filter(verifierReturning(identity(ANS_NAME)), Set.of(), Set.of()); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/verify"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertRejected(response, chain); + } + + @Test + void rejectsWhenProofBlank() throws Exception { + PopAuthenticationFilter filter = filter(verifierReturning(identity(ANS_NAME)), Set.of(), Set.of()); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/verify"); + request.addHeader(PopHttp.DPOP_HEADER, " "); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertRejected(response, chain); + } + + @Test + void rejectsWhenRootKeysUnavailable() throws Exception { + Supplier> failing = () -> { + throw new IllegalStateException("keys down"); + }; + PopAuthenticationFilter filter = filter( + verifierReturning(identity(ANS_NAME)), failing, null, Set.of(), Set.of()); + MockHttpServletRequest request = request(); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertRejected(response, chain); + } + + @Test + void rejectsWhenVerifierThrows() throws Exception { + PopAuthenticationFilter filter = filter(verifierRejecting(), Set.of(), Set.of()); + MockHttpServletRequest request = request(); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertRejected(response, chain); + } + + @Test + void rejectsWhenAnsHostNotInAllowedSet() throws Exception { + PopAuthenticationFilter filter = filter( + verifierReturning(identity(ANS_NAME)), Set.of(), Set.of("other.example.com")); + MockHttpServletRequest request = request(); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertRejected(response, chain); + } + + @Test + void rejectsWhenAnsNameIsUnparseable() throws Exception { + PopAuthenticationFilter filter = filter( + verifierReturning(identity(" ")), Set.of(), Set.of("agent.example.com")); + MockHttpServletRequest request = request(); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertRejected(response, chain); + } + + // ==================== externalUrl resolution ==================== + + @Test + void usesExternalUrlForAuthorityAndTarget() throws Exception { + Function externalUrl = + req -> "https://gateway.example.com" + req.getRequestURI(); + PopAuthenticationFilter filter = filter( + verifierReturning(identity(ANS_NAME)), ROOT_KEYS, externalUrl, + Set.of("gateway.example.com"), Set.of()); + MockHttpServletRequest request = request(); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertThat(chain.getRequest()).isSameAs(request); + } + + @Test + void rejectsWhenExternalUrlAuthorityIsInvalid() throws Exception { + Function externalUrl = req -> ":::not a uri:::"; + PopAuthenticationFilter filter = filter( + verifierReturning(identity(ANS_NAME)), ROOT_KEYS, externalUrl, + Set.of("gateway.example.com"), Set.of()); + MockHttpServletRequest request = request(); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertRejected(response, chain); + } + + @Test + void resolvesTargetWithQueryString() throws Exception { + PopAuthenticationFilter filter = filter(verifierReturning(identity(ANS_NAME)), Set.of(), Set.of()); + MockHttpServletRequest request = request(); + request.setQueryString("v=1"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertThat(chain.getRequest()).isSameAs(request); + } + + // ==================== Builder ==================== + + @Test + void builderBuildsWithDefaults() { + PopAuthenticationFilter filter = PopAuthenticationFilter + .builder("issuer.example.com", ROOT_KEYS, REPLAY) + .build(); + + assertThat(filter).isNotNull(); + } + + @Test + void builderBuildsWithPopSkew() { + PopAuthenticationFilter filter = PopAuthenticationFilter + .builder("issuer.example.com", ROOT_KEYS, REPLAY) + .withTrustedHosts("rp.example.com:443", "other.example.com:80") + .withPoPSkew(Duration.ofSeconds(30)) + .build(); + + assertThat(filter).isNotNull(); + } + + @Test + void builderResolvesExpectedAndAllowedAnsNames() { + PopAuthenticationFilter filter = PopAuthenticationFilter + .builder("issuer.example.com", ROOT_KEYS, REPLAY) + .withExpectedAnsName("ans://a.example.com") + .withAllowedAnsNames("ans://b.example.com", "ans://c.example.com") + .build(); + + assertThat(filter).isNotNull(); + } + + @Test + void builderAcceptsPathDependentExternalUrl() { + PopAuthenticationFilter filter = PopAuthenticationFilter + .builder("issuer.example.com", ROOT_KEYS, REPLAY) + .withExternalUrl(req -> "https://gateway.example.com" + req.getRequestURI()) + .build(); + + assertThat(filter).isNotNull(); + } + + @Test + void builderProbesExternalUrlAgainstAllRequestAccessors() { + // Exercises every branch of the probe request proxy: switch cases and primitive-return defaults. + Function externalUrl = req -> { + req.getRequestURL(); + req.getServletPath(); + req.getMethod(); + req.getScheme(); + req.getServerName(); + req.getServerPort(); + req.isSecure(); + req.getContentLength(); + req.getContentLengthLong(); + return "https://gateway.example.com" + req.getRequestURI(); + }; + PopAuthenticationFilter filter = PopAuthenticationFilter + .builder("issuer.example.com", ROOT_KEYS, REPLAY) + .withExternalUrl(externalUrl) + .build(); + + assertThat(filter).isNotNull(); + } + + @Test + void builderRejectsPathIndependentExternalUrl() { + assertThatIllegalArgumentException().isThrownBy(() -> PopAuthenticationFilter + .builder("issuer.example.com", ROOT_KEYS, REPLAY) + .withExternalUrl(req -> "https://gateway.example.com/fixed") + .build()) + .withMessageContaining("ignores the request path"); + } + + @Test + void builderRejectsInvalidAllowedAnsName() { + assertThatIllegalArgumentException().isThrownBy(() -> PopAuthenticationFilter + .builder("issuer.example.com", ROOT_KEYS, REPLAY) + .withExpectedAnsName("ans://") + .build()) + .withMessageContaining("invalid allowed ans name"); + } + + @Test + void builderIgnoresNullAndBlankTrustedHosts() { + PopAuthenticationFilter filter = PopAuthenticationFilter + .builder("issuer.example.com", ROOT_KEYS, REPLAY) + .withTrustedHosts("rp.example.com", null, " ") + .build(); + + assertThat(filter).isNotNull(); + } + + @Test + void builderRejectsWhenAllTrustedHostsEmpty() { + assertThatIllegalArgumentException().isThrownBy(() -> PopAuthenticationFilter + .builder("issuer.example.com", ROOT_KEYS, REPLAY) + .withTrustedHosts(" ", "")) + .withMessageContaining("every supplied host was empty"); + } + + @Test + void builderRejectsNullConstructorArguments() { + assertThatNullPointerException() + .isThrownBy(() -> PopAuthenticationFilter.builder(null, ROOT_KEYS, REPLAY)); + assertThatNullPointerException() + .isThrownBy(() -> PopAuthenticationFilter.builder("issuer", null, REPLAY)); + assertThatNullPointerException() + .isThrownBy(() -> PopAuthenticationFilter.builder("issuer", ROOT_KEYS, null)); + } + + @Test + void builderRejectsNullSetters() { + PopAuthenticationFilter.Builder builder = + PopAuthenticationFilter.builder("issuer", ROOT_KEYS, REPLAY); + + assertThatNullPointerException().isThrownBy(() -> builder.withExternalUrl(null)); + assertThatNullPointerException().isThrownBy(() -> builder.withExpectedAnsName(null)); + assertThatNullPointerException().isThrownBy(() -> builder.withAllowedAnsNames((String) null)); + assertThatNullPointerException().isThrownBy(() -> builder.withPoPSkew(null)); + } + + // Sanity: the SCITT header names the filter guards against duplicates for are what we expect. + @Test + void rejectsDuplicateScittReceiptHeader() throws Exception { + PopAuthenticationFilter filter = filter(verifierReturning(identity(ANS_NAME)), Set.of(), Set.of()); + MockHttpServletRequest request = request(); + request.addHeader(ScittHeaders.SCITT_RECEIPT_HEADER, "a"); + request.addHeader(ScittHeaders.SCITT_RECEIPT_HEADER, "b"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertRejected(response, chain); + } +} \ No newline at end of file diff --git a/ans-sdk-spring-boot-starter/src/test/java/com/godaddy/ans/sdk/spring/PopAuthenticationTest.java b/ans-sdk-spring-boot-starter/src/test/java/com/godaddy/ans/sdk/spring/PopAuthenticationTest.java new file mode 100644 index 0000000..339d81d --- /dev/null +++ b/ans-sdk-spring-boot-starter/src/test/java/com/godaddy/ans/sdk/spring/PopAuthenticationTest.java @@ -0,0 +1,47 @@ +package com.godaddy.ans.sdk.spring; + +import com.godaddy.ans.sdk.pop.CallerIdentity; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +/** + * Tests for {@link PopAuthentication}. + */ +class PopAuthenticationTest { + + private static CallerIdentity identity() { + return new CallerIdentity("ans://agent.example.com", "agent-1", new byte[] {1, 2, 3}, "jkt"); + } + + @Test + void returnsIdentityWhenAttributePresent() { + MockHttpServletRequest request = new MockHttpServletRequest(); + CallerIdentity identity = identity(); + request.setAttribute(PopAuthentication.CALLER_ATTRIBUTE, identity); + + assertThat(PopAuthentication.fromRequest(request)).contains(identity); + } + + @Test + void returnsEmptyWhenAttributeMissing() { + assertThat(PopAuthentication.fromRequest(new MockHttpServletRequest())).isEmpty(); + } + + @Test + void returnsEmptyWhenAttributeWrongType() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(PopAuthentication.CALLER_ATTRIBUTE, "not-an-identity"); + + assertThat(PopAuthentication.fromRequest(request)).isEmpty(); + } + + @Test + void rejectsNullRequest() { + assertThatNullPointerException() + .isThrownBy(() -> PopAuthentication.fromRequest(null)) + .withMessage("request"); + } +} \ No newline at end of file From 90913b1dee90dc3d1987bb539fe6fd93a4df7f79 Mon Sep 17 00:00:00 2001 From: James Hateley Date: Tue, 1 Sep 2026 13:32:46 +1000 Subject: [PATCH 08/14] docs(pop): improve comments Signed-off-by: James Hateley --- .../com/godaddy/ans/sdk/pop/Base64Url.java | 1 + .../godaddy/ans/sdk/pop/CallerIdentity.java | 18 ++++ .../godaddy/ans/sdk/pop/CallerOptions.java | 8 ++ .../godaddy/ans/sdk/pop/CallerVerifier.java | 25 +++++ .../ans/sdk/pop/DpopProofVerifier.java | 60 ++++++++++++ .../com/godaddy/ans/sdk/pop/ErrorType.java | 67 +++++++++++++- .../java/com/godaddy/ans/sdk/pop/Jws.java | 8 ++ .../java/com/godaddy/ans/sdk/pop/PopHttp.java | 19 ++++ .../com/godaddy/ans/sdk/pop/PopSigner.java | 25 +++++ .../java/com/godaddy/ans/sdk/pop/Proof.java | 35 +++++++ .../com/godaddy/ans/sdk/pop/ProofResult.java | 18 ++++ .../godaddy/ans/sdk/pop/VerifyOptions.java | 8 ++ .../com/godaddy/ans/sdk/pop/package-info.java | 92 +++++++++++++++++++ 13 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/package-info.java diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Base64Url.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Base64Url.java index 25ce67d..44e4b54 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Base64Url.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Base64Url.java @@ -3,6 +3,7 @@ import java.util.Base64; import java.util.Objects; +/** base64url without padding (RFC 7515 §2 / RFC 4648 §5), used for JWS segments. */ public final class Base64Url { private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerIdentity.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerIdentity.java index 0bb32a4..41d1260 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerIdentity.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerIdentity.java @@ -2,12 +2,30 @@ import java.util.HexFormat; +/** + * The authenticated identity of an A2A caller. + * + *

This is the result of AUTHENTICATION, not authorization. A returned + * {@code CallerIdentity} means the request provably came from this agent. The + * callee must still decide whether this agent may perform the requested action. + * + * @param ansName the caller's ans:// name, from the verified status token + * @param agentId the caller's agent id, from the verified status token + * @param fingerprint SHA-256 of the identity certificate that signed the proof + * @param jkt the RFC 7638 thumbprint of the key that signed the proof. A + * callee that also accepts a DPoP-bound OAuth2 access token + * must compare this to the token's cnf.jkt claim to complete + * RFC 9449 §4.3 token binding. The ath check alone proves only + * that proof and token were presented together, not that the + * token was issued to this key. + */ 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); } diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerOptions.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerOptions.java index 89084de..c4974d0 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerOptions.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerOptions.java @@ -3,10 +3,14 @@ import java.time.Instant; import java.util.Objects; +/** Options for a single {@link CallerVerifier#verifyCaller} call. */ public final class CallerOptions { + // The OAuth2 access token presented on the request, or null. private final String accessToken; + // The ans:// name the callee will accept, or null to accept any proven agent. private final String expectedPeer; + // A fixed verification time, or null to use the current time. private final Instant clock; private CallerOptions(String accessToken, String expectedPeer, Instant clock) { @@ -23,6 +27,10 @@ public CallerOptions withAccessToken(String token) { return new CallerOptions(Objects.requireNonNull(token, "token"), expectedPeer, clock); } + /** + * 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); } diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java index 2970673..38613ef 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java @@ -26,6 +26,12 @@ import java.util.Objects; import java.util.Optional; +/** + * Authenticates a caller from its DPoP proof and SCITT headers, and returns + * the proven {@link CallerIdentity}. It composes the three proofs — possession + * (the DPoP proof), liveness (the status token), and identity (the receipt) — + * and binds them to one identity certificate. + */ public final class CallerVerifier { private static final Logger LOG = LoggerFactory.getLogger(CallerVerifier.class); @@ -83,14 +89,19 @@ CallerIdentity verifyParsed(String proofJWS, ScittReceipt receipt, StatusToken t CallerOptions effectiveOptions = options != null ? options : CallerOptions.none(); + // Possession: the caller holds the identity key, for this request. + // The jti is NOT recorded yet — see the recordReplay call below. 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 @@ -99,6 +110,10 @@ CallerIdentity verifyParsed(String proofJWS, ScittReceipt receipt, StatusToken t "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); CallerIdentity identity = new CallerIdentity( @@ -128,7 +143,10 @@ private DpopProofVerifier.Verified verifyPossession(String proofJWS, String meth return proofVerifier.verifyUnrecorded(proofJWS, method, url, now, popSkew, verifyOptions); } + // verifyBinding ties a verified proof, status token, and receipt to one + // agent. private void verifyBinding(ProofResult proof, ScittReceipt receipt, StatusToken token) throws PopException { + // 1. The proof's certificate fingerprint must be a vouched identity cert. String proofFingerprint = CertificateUtils.computeSha256Fingerprint(proof.cert()); boolean fingerprintMatched = false; for (String expected : token.identityCertFingerprints()) { @@ -142,6 +160,8 @@ private void verifyBinding(ProofResult proof, ScittReceipt receipt, StatusToken "proof certificate is not in status token identity fingerprints"); } + // 2. The certificate's own ans:// SAN must equal the status token ans + // name. Fail closed if the cert carries no ans:// SAN. Optional certAnsName = CertificateUtils.extractAnsName(proof.cert()); if (certAnsName.isEmpty()) { throw new PopException(ErrorType.BINDING_FAILED, "proof certificate has no ans name SAN"); @@ -151,9 +171,11 @@ private void verifyBinding(ProofResult proof, ScittReceipt receipt, StatusToken "proof ans host does not match status token ans host"); } + // 3. The receipt's leaf must name the same agent as the status token. verifyReceiptAgent(receipt, token); } + // leaf event must name the same agent the status token does. private static void verifyReceiptAgent(ScittReceipt receipt, StatusToken token) throws PopException { byte[] payload = receipt.eventPayload(); if (payload == null) { @@ -224,6 +246,9 @@ private static String requireSingleHeader(Map> headers, Str return value; } + // ansHost extracts the lowercased host from an ans:// name and strips a + // leading version label (vMAJOR.MINOR.PATCH.), so binding compares agents by + // host regardless of the version prefix. public static String ansHost(String ansName) throws PopException { if (ansName == null || ansName.isBlank()) { throw new PopException(ErrorType.BINDING_FAILED, "ans name is missing"); diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/DpopProofVerifier.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/DpopProofVerifier.java index 33e49cc..299931f 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/DpopProofVerifier.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/DpopProofVerifier.java @@ -14,9 +14,28 @@ public final class DpopProofVerifier { + /** + * Bounds the compact DPoP proof length to limit parser work on untrusted + * input. + */ static final int MAX_PROOF_SIZE = 8 * 1024; + /** + * Bounds the jti claim. RFC 9449 §11.1 calls for rejecting "unnecessarily + * large jti values" precisely because a verifier stores them: without this, + * a cache bounded by entry COUNT is unbounded in BYTES. 128 bytes is ample + * for any collision-resistant identifier. + */ static final int MAX_JTI_BYTES = 128; + /** + * The freshness window for a proof's iat. A possession proof is single-use + * and short-lived, so this window is deliberately tight. + */ static final Duration DEFAULT_SKEW = Duration.ofSeconds(120); + /** + * Keeps a jti in the replay cache slightly past the freshness window, so a + * replay the freshness check would still accept is always caught by the + * cache (no boundary gap). Cache retention = iat + skew + grace. + */ static final Duration REPLAY_GRACE = Duration.ofSeconds(5); private static final Logger LOG = LoggerFactory.getLogger(DpopProofVerifier.class); @@ -24,6 +43,24 @@ public final class DpopProofVerifier { record Verified(ProofResult result, String replayKey, Duration replayTtl) { } + /** + * Verifies a compact DPoP proof against an HTTP method and URL at time + * {@code now}, with freshness window {@code skew} and replay protection via + * {@code replay}. + * + *

Order: size cap, pinned typ/alg plus required jwk/x5c, x5c P-256 leaf, + * jwk↔x5c key equality, signature under that single key, htm, normalized + * htu, ath vs presented token, iat window, then jti single-use. Replay is + * recorded LAST, so only proofs that pass every other check consume a cache + * slot. + * + *

A proof verified here is cryptographically well-formed but NOT yet + * trusted: nothing has established that its certificate belongs to a live + * ANS agent (there is no chain validation). Use + * {@link CallerVerifier#verifyCaller} for the full three-proof check — it + * records the jti only after the status-token binding succeeds, so an + * untrusted flood cannot consume replay-cache capacity. + */ public ProofResult verify(String proofJWS, String method, String url, Instant now, Duration skew, ReplayCache replay, VerifyOptions options) throws PopException { if (replay == null) { @@ -45,6 +82,11 @@ public ProofResult verify(String proofJWS, String method, String url, Instant no } } + /** + * Runs every proof check except the replay commit, so a caller that has more + * trust checks to perform can defer consuming a cache slot until the proof is + * known to belong to a vouched agent. + */ Verified verifyUnrecorded(String proofJWS, String method, String url, Instant now, Duration skew, VerifyOptions options) throws PopException { Objects.requireNonNull(proofJWS, "proofJWS"); @@ -94,7 +136,13 @@ Verified verifyUnrecorded(String proofJWS, String method, String url, Instant no throw new PopException(ErrorType.MALFORMED_PROOF, "jti exceeds maximum size"); } + // Store a fixed-width digest of the jti rather than the jti itself, so a + // cache bounded by entry count is also bounded in bytes (RFC 9449 §11.1 + // sanctions storing "only a hash thereof"). SHA-256 collision resistance + // preserves single-use semantics. String replayKey = Base64Url.encode(sha256(jti.getBytes(StandardCharsets.UTF_8))); + // Retain the jti until iat + skew + grace, so any replay still inside the + // freshness window is caught by the cache. Duration replayTtl = Duration.between(now, iat.plus(effectiveSkew).plus(REPLAY_GRACE)); ProofResult result = new ProofResult( @@ -109,6 +157,13 @@ Verified verifyUnrecorded(String proofJWS, String method, String url, Instant no return new Verified(result, replayKey, replayTtl); } + /** + * Records the jti single-use, retaining it until the proof's replay + * expiry. Call this only once a proof is trusted: the cache is a bounded, + * shared resource, so recording an unvouched proof lets anyone who can reach + * the port exhaust capacity and fail authentication for every legitimate + * caller. + */ void recordReplay(Verified verified, ReplayCache replay) throws PopException { Objects.requireNonNull(verified, "verified"); if (replay == null) { @@ -119,6 +174,11 @@ void recordReplay(Verified verified, ReplayCache replay) throws PopException { } } + /** + * Enforces ath vs presented access token, strictly in both directions: a + * proof minted for a token-bound context is not accepted without its token, + * and a presented token demands a matching ath (RFC 9449 §4.3). + */ private static void verifyAth(String proofAth, String accessToken) throws PopException { boolean tokenPresented = accessToken != null; boolean athPresent = proofAth != null; diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ErrorType.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ErrorType.java index 6e09de5..42c8150 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ErrorType.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ErrorType.java @@ -1,21 +1,86 @@ package com.godaddy.ans.sdk.pop; +/** + * Classifies a proof-of-possession verification failure, so callers (and the + * HTTP layer) can branch on a stable category rather than a message string. + * Every failure on the verify path carries one of these. + */ public enum ErrorType { + /** + * A structurally invalid DPoP proof (bad compact JWS, base64, JSON, or a + * missing required header or claim). + */ MALFORMED_PROOF, + /** + * A proof whose alg or typ is not the pinned ES256 / {@code dpop+jwt} pair + * (this covers the alg:"none" downgrade), or a jwk that is not EC/P-256. + */ UNSUPPORTED_ALG, + /** An htm or htu that does not match the request. */ HTTP_BINDING_MISMATCH, + /** + * An iat outside the accepted freshness window (too old or too far in the + * future). + */ PROOF_STALE, + /** A jti already seen within the freshness window. */ REPLAY, + /** + * Reserved: the replay cache is at capacity and cannot record the proof id. + * The in-process cache does not raise this today — see the replay-cache + * capacity finding. + */ REPLAY_CACHE_FULL, + /** A proof whose signature does not verify under the x5c leaf key. */ SIGNATURE_INVALID, + /** + * A missing or unparseable x5c, or a leaf key that is not ECDSA P-256. + */ CERT_INVALID, + /** + * The header's jwk and x5c leaf do not present the same public key — the + * dual-header consistency invariant failed. + */ KEY_MISMATCH, + /** + * The proof's ath claim and the presented OAuth2 access token disagree: ath + * present with no token presented, absent when one was, or a hash mismatch + * (RFC 9449 §4.3 / §7.1). + */ TOKEN_BINDING_MISMATCH, + /** + * A verified proof and a verified status token do not describe the same + * agent (fingerprint, {@code ans://} SAN, or receipt agent mismatch). + */ BINDING_FAILED, + /** + * The SCITT status token failed verification (bad signature, expired, + * terminal status, or malformed). + */ STATUS_INVALID, + /** + * The SCITT receipt failed verification, or its leaf event could not be + * decoded. + */ RECEIPT_INVALID, + /** + * The request carried no SCITT receipt or status token, or no DPoP proof. + */ MISSING_HEADERS, + /** + * The X-SCITT-Receipt or X-ANS-Status-Token header could not be extracted + * (missing, duplicated, or not valid base64). + */ SCITT_HEADER_INVALID, + /** + * A required dependency or argument was not supplied (a null replay cache, + * root keys, or signer). This is a wiring error, not attacker-influenced + * input. Verification fails closed. + */ MISCONFIGURED, + /** + * The proven caller is not the peer the callee was configured to accept + * (see {@link CallerOptions#withExpectedPeer(String)}). + */ EXPECTED_PEER_MISMATCH -} +} \ No newline at end of file diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Jws.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Jws.java index 032c61b..c070637 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Jws.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Jws.java @@ -14,6 +14,12 @@ import java.text.ParseException; import java.util.Set; +// Compact-JWS mechanics for DPoP proofs, pinned to this profile: proof type +// "dpop+jwt", algorithm ES256, and no JOSE header parameter beyond +// {typ, alg, jwk, x5c}. These pins plus the jwk and x5c requirements are the +// whole downgrade policy — a proof with alg:"none", RS256, a smuggled "kid" or +// "crit", or a private-key "d" member fails closed here, before any signature +// work. final class Jws { static final JOSEObjectType DPOP_TYP = new JOSEObjectType("dpop+jwt"); @@ -23,6 +29,8 @@ final class Jws { private Jws() { } + // parses a compact JWS and rejects any header that is not + // exactly {typ, alg, jwk, x5c} with typ=dpop+jwt and alg=ES256. static JWSObject strictParse(String compactJws) throws PopException { JWSObject jws; try { diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopHttp.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopHttp.java index 96c3b0c..b58244d 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopHttp.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopHttp.java @@ -6,8 +6,10 @@ import java.util.Objects; import java.util.Optional; +/** HTTP helpers for attaching and reading pop credentials on requests. */ public final class PopHttp { + /** The HTTP header that carries the compact DPoP proof (RFC 9449). */ public static final String DPOP_HEADER = "DPoP"; private static final String DPOP_SCHEME = "DPoP"; @@ -15,6 +17,11 @@ public final class PopHttp { private PopHttp() { } + /** + * Signs a DPoP proof for the request and attaches it as the {@code DPoP} + * header, then copies the SCITT headers. 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> scittHeaders, String accessToken) throws PopException { Objects.requireNonNull(req, "req"); @@ -37,6 +44,18 @@ public static void attachIdentity(HttpRequest.Builder req, PopSigner signer, } } + /** + * Returns the access token when an Authorization header value presents one + * under the DPoP auth scheme (RFC 9449 §7.1). Scheme comparison is + * case-insensitive (RFC 9110 §11.1). A Bearer or absent Authorization yields + * empty: such a token is not sender-constrained, so the proof must carry no + * ath. + * + *

A callee that completes token binding must use this rather than parsing + * the header itself. The verifier checks the proof's ath against exactly the + * bytes this returns. A second, subtly different parser in the handler would + * let the two halves of RFC 9449 §4.3 operate on different values. + */ public static Optional accessTokenFromAuthorization(String value) { if (value == null || value.length() <= DPOP_SCHEME.length()) { return Optional.empty(); diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopSigner.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopSigner.java index ed370e9..8348699 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopSigner.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopSigner.java @@ -24,8 +24,15 @@ import java.util.Map; import java.util.Objects; +/** + * Mints DPoP proofs for an agent's outbound A2A requests. It holds the agent's + * identity private key and the DER of the matching identity certificate — the + * certificate whose fingerprint the agent's status token vouches for. Build one + * with {@link #create(java.security.interfaces.ECPrivateKey, byte[])}. + */ public final class PopSigner { + // jti entropy size (128 bits). private static final int JTI_BYTES = 16; private static final SecureRandom RANDOM = new SecureRandom(); @@ -39,6 +46,12 @@ private PopSigner(ECPrivateKey privateKey, byte[] certDer, ECKey jwk) { this.jwk = jwk; } + /** + * Builds a signer from a P-256 private key and the DER of the identity + * certificate that binds the matching public key. It verifies the + * certificate's public key equals the private key's public key, so a signer + * can never emit a proof whose jwk or x5c disagrees with its signing key. + */ public static PopSigner create(ECPrivateKey key, byte[] certDER) throws PopException { Objects.requireNonNull(key, "key"); Objects.requireNonNull(certDER, "certDER"); @@ -55,11 +68,23 @@ public String sign(String method, String url) throws PopException { return signInternal(method, url, null); } + /** + * Signs a proof and binds an OAuth2 access token via ath = + * base64url(SHA-256(token)) per RFC 9449 §4.2. Use this when the request + * presents the token as {@code Authorization: DPoP } (RFC 9449 §7.1). + * A verifier enforces ath vs presented token in both directions. + */ public String sign(String method, String url, String accessToken) throws PopException { Objects.requireNonNull(accessToken, "accessToken"); return signInternal(method, url, accessToken); } + /** + * Returns the RFC 7638 thumbprint of the signer's public key — the value an + * authorization server records as an access token's cnf.jkt confirmation + * claim (RFC 9449 §6), and the value a callee compares against + * {@link CallerIdentity#jkt()}. + */ public String jkt() throws PopException { return Proof.jkt(jwk); } diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Proof.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Proof.java index 72c4fa8..35209c3 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Proof.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Proof.java @@ -34,12 +34,29 @@ final class Proof { private Proof() { } + // Header is the protected header of a DPoP proof in this profile. It is + // exactly {typ, alg, jwk, x5c}: jwk is the bare public key RFC 9449 §4.2 + // requires, so the proof is wire-conformant DPoP. x5c[0] is the ANS identity + // certificate tying the same key to the agent's ans:// name. The two MUST + // present the same key (matchJWKToCert), so the signature is only ever + // checked under one key. Header parsing rejects unknown fields — any other + // JOSE header parameter fails closed. record Header(JWSObject jws, ECKey jwk, X509Certificate cert, ECPublicKey publicKey) { } + // Claims holds the DPoP claims this profile binds: the HTTP method and + // normalized target URI (htm/htu), the issued-at (iat), a unique id (jti) + // for replay detection, and — only when the request also presents an OAuth2 + // access token — that token's hash (ath). Additional claims are tolerated on + // the payload (DPoP permits them). Only the header is strictly decoded. record Claims(String htm, String htu, Instant iat, String jti, String ath) { } + // acceptES256DPoP decides which proofs this profile accepts: the pinned + // typ/alg pair, an EC/P-256 jwk, and exactly one x5c certificate. Trust + // comes from the status token, not a chain, so extra x5c entries are never + // consulted — accepting them silently would let a chain-walking verifier + // reach a different conclusion from this one over the same bytes. static Header acceptES256DPoP(String compactJws) throws PopException { JWSObject jws = Jws.strictParse(compactJws); JWSHeader header = jws.getHeader(); @@ -51,6 +68,11 @@ static Header acceptES256DPoP(String compactJws) throws PopException { return new Header(jws, jwk, cert, matched); } + // matchJWKToCert enforces the dual-header invariant: the jwk and the x5c + // leaf certificate must present the same public key. The jwk's point is not + // validated independently — byte-equality with the parsed certificate key IS + // the validation, and verifying the signature under the certificate key is + // then also verifying it under the jwk key (RFC 9449 §4.3). static ECPublicKey matchJWKToCert(ECKey jwk, X509Certificate cert) throws PopException { ECPublicKey jwkKey; try { @@ -80,6 +102,9 @@ static byte[] coordinates(ECPublicKey key) { return out; } + // jkt returns the RFC 7638 thumbprint of the public key. This is the value a + // DPoP-bound OAuth2 access token carries in its cnf.jkt confirmation claim + // (RFC 9449 §6), so a resource server compares it to complete token binding. static String jkt(ECKey jwk) throws PopException { try { return jwk.computeThumbprint().toString(); @@ -88,10 +113,16 @@ static String jkt(ECKey jwk) throws PopException { } } + // accessTokenHash is the RFC 9449 §4.2 ath value for an access token: + // base64url(SHA-256(token)). static String accessTokenHash(String accessToken) { return Base64Url.encode(sha256(accessToken.getBytes(StandardCharsets.UTF_8))); } + // normalizeHTU returns the RFC 9449 §4.3 htu form of rawUrl: scheme and host + // lowercased, the default port (:443 for https, :80 for http) dropped, query + // and fragment removed, and an empty path normalized to "/" (RFC 3986 + // §6.2.3. static String normalizeHTU(String rawUrl) throws PopException { URI uri; try { @@ -152,6 +183,10 @@ private static ECKey extractPublicEcKey(JWSHeader header) throws PopException { return ecKey; } + // extractLeafCertificate decodes and validates the x5c leaf — the caller's + // identity certificate. Only the leaf is consulted. There is no chain walk, + // because trust comes from the status token, not a CA chain. The leaf key + // must be ECDSA P-256. private static X509Certificate extractLeafCertificate(JWSHeader header) throws PopException { List chain = header.getX509CertChain(); if (chain == null || chain.size() != 1) { diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ProofResult.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ProofResult.java index 8144781..c48e27f 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ProofResult.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ProofResult.java @@ -4,6 +4,24 @@ import java.security.interfaces.ECPublicKey; import java.time.Instant; +/** + * A verified DPoP proof: the caller's identity certificate and key, the SHA-256 + * fingerprint the status-token binding matches on, the key's RFC 7638 thumbprint + * for OAuth2 cnf.jkt confirmation, and the proof's jti/htu/iat for the caller + * binding and structured logging. + * + * @param cert the caller's identity certificate (x5c leaf) + * @param key the certificate's P-256 public key + * @param fingerprint SHA-256 of the certificate DER + * @param jkt the RFC 7638 thumbprint of {@code key}. A resource server + * holding a DPoP-bound access token must compare it to the + * token's cnf.jkt claim to complete RFC 9449 §4.3 token + * binding. The ath check alone does not establish + * sender-constraint. + * @param jti the proof's unique id, for replay detection + * @param htu the normalized target URI the proof is bound to + * @param issuedAt the proof's iat + */ public record ProofResult( X509Certificate cert, ECPublicKey key, diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/VerifyOptions.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/VerifyOptions.java index 6304af2..68f0aa0 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/VerifyOptions.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/VerifyOptions.java @@ -2,6 +2,7 @@ import java.util.Objects; +/** Options for a single {@link DpopProofVerifier#verify} call. */ public final class VerifyOptions { private final String accessToken; @@ -10,10 +11,17 @@ private VerifyOptions(String accessToken) { this.accessToken = accessToken; } + /** No access token was presented, so the proof must carry no ath. */ public static VerifyOptions none() { return new VerifyOptions(null); } + /** + * Tells the verifier the request presented this OAuth2 access token + * ({@code Authorization: DPoP }, RFC 9449 §7.1), which requires the + * proof's ath to hash-match it. Without this, a proof carrying ath is + * rejected — the profile enforces ath vs presented token in both directions. + */ public static VerifyOptions withAccessToken(String accessToken) { return new VerifyOptions(Objects.requireNonNull(accessToken, "accessToken")); } diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/package-info.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/package-info.java new file mode 100644 index 0000000..1878b3f --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/package-info.java @@ -0,0 +1,92 @@ +/** + * Sender-constrained, application-layer caller authentication for ANS + * agent-to-agent (A2A) traffic — proof-of-possession without mutual TLS. + * + *

Why

+ * Today an ANS caller proves its identity to a callee with an mTLS client + * certificate. mTLS breaks through L7 proxies and gateways (which terminate TLS + * and drop the client identity), carries no delegation semantics, and is + * operationally heavy. This package moves the caller's proof to the application + * layer as a DPoP proof (RFC 9449) — the RFC-stable form of the IETF WIMSE + * Workload Proof Token. The proof travels in a standard {@code DPoP} HTTP header + * over ordinary server-authenticated HTTPS. The handshake presents no client + * certificate. + * + *

The three-proof model

+ * A2A caller authentication is three independent proofs, all bound to one + * identity certificate: + *
    + *
  • Identity — the caller's name and identity certificate are in the + * transparency log. The SCITT receipt supplies this.
  • + *
  • Liveness — that certificate is currently valid (ACTIVE, not + * revoked). The status token's valid identity fingerprints supply this.
  • + *
  • Possession — the caller holds the certificate's private key, for + * THIS request. The DPoP proof in this package supplies this. This is the + * proof that replaces the mTLS handshake.
  • + *
+ * This package composes with SCITT. It does not replace it. The receipt and + * status token are verified unchanged. This package adds the possession proof + * and binds all three to the same certificate. + * + *

Binding

+ * The proof header carries both the bare public key (jwk, required by RFC 9449 + * §4.2) and the caller's identity certificate (x5c, RFC 7515 §4.1.6) — which + * MUST present the same key. A verifier (a) confirms that equality and verifies + * the JWS under the single key, (b) confirms SHA-256(cert) is among the status + * token's valid identity fingerprints, and (c) confirms the certificate's own + * {@code ans://} URI SAN equals the status token's ANS name. To pass, a caller + * must hold the private key for a certificate its own transparency-log-signed + * status token vouches for. The status token (TL-signed) is the trust + * statement — there is no CA-chain validation, and the certificate's own + * validity dates, key usage, and cert-type entry are deliberately not consulted. + * A captured receipt and status token (both public) are useless without the key, + * and the proof's jti/htm/htu/iat defeat replay and redirection. + * + *

The htu binding is only as trustworthy as the URL the callee compares + * against. The callee MUST derive that URL from its own externally-visible + * origin, not from a client-controlled Host header, so a proof captured from a + * call to another origin cannot be replayed here with a spoofed Host. Note also + * that htu excludes the query string (RFC 9449 §4.2), so a proof does not bind + * request parameters. + * + *

RFC 9449 conformance and OAuth 2.0

+ * Proofs are wire-conformant RFC 9449 DPoP: a textbook §4.3 verifier validates + * them via the jwk header and ignores the x5c. The profile adds two restrictions + * the RFC permits a deployment to impose: ES256 only, and no JOSE header + * parameters beyond {@code {typ, alg, jwk, x5c}} (strict decoding, so a + * private-key "d" member or any extra field fails closed). + * + *

OAuth 2.0 composes on top, unchanged from the RFC. When a request presents + * a DPoP-bound access token ({@code Authorization: DPoP }, RFC 9449 §7.1), + * the proof binds it via the ath claim. The rule is strict in both directions: + * ath is present exactly when a token is presented. Without OAuth there is no + * access token and no ath — the SCITT receipt and status token are the + * credential, and the proof's absence of ath is itself RFC-conformant (ath is + * required only when a token is presented). + * + *

Authentication is not authorization

+ * {@link com.godaddy.ans.sdk.pop.CallerVerifier} AUTHENTICATES the caller — it + * returns the cryptographically proven identity. It does NOT authorize it. A + * returned {@link com.godaddy.ans.sdk.pop.CallerIdentity} means "this request + * genuinely came from ans://…X", never "X is allowed to do this." The callee + * MUST apply its own authorization to the returned identity. Use + * {@link com.godaddy.ans.sdk.pop.CallerOptions#withExpectedPeer(String)} to pin + * a specific peer when the callee only accepts a known caller. + * + *

What dropping mTLS gives up

+ * DPoP provides sender-constraint (possession), but not the channel binding, + * mutual endpoint authentication, or credential confidentiality that mTLS + * provided. The channel is still server-authenticated HTTPS. A caller induced to + * connect to a hostile callee discloses its (public) receipt and status token + * and a single-use, htu-bound proof. Deployments that need channel binding or + * mutual endpoint auth keep mTLS or add token binding. + * + *

Scope

+ * This package implements the autonomous A2A model (no Authorization Server): + * the callee verifies the three proofs and authorizes locally. It does NOT + * implement delegation (an agent acting on behalf of a user across a call + * chain) — that is a separate, higher-risk concern. It reuses the caller's + * existing identity certificate and the status token, minting no new credential + * and adding no wire format. + */ +package com.godaddy.ans.sdk.pop; \ No newline at end of file From 98ab151f0615a48dea5cae0f5fe583f29fe8bc45 Mon Sep 17 00:00:00 2001 From: James Hateley Date: Tue, 1 Sep 2026 21:31:57 +1000 Subject: [PATCH 09/14] feat(pop): fix receipt parse, add example module, bump caffeine Signed-off-by: James Hateley --- .../godaddy/ans/sdk/pop/CallerVerifier.java | 21 ++++++++++++++----- .../ans/sdk/pop/CallerVerifierTest.java | 7 +++++-- .../examples/a2anomtls/PopSecurityConfig.java | 5 ++++- .../src/main/resources/application.yml | 12 +++++++++-- gradle.properties | 2 +- settings.gradle.kts | 1 + 6 files changed, 37 insertions(+), 11 deletions(-) diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java index 38613ef..25f088d 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java @@ -175,20 +175,24 @@ private void verifyBinding(ProofResult proof, ScittReceipt receipt, StatusToken verifyReceiptAgent(receipt, token); } - // leaf event must name the same agent the status token does. + // The receipt's leaf event must name the same agent as the status token. The signed payload is the + // full transparency-log envelope; the agent UUID lives at payload.producer.event.ansId and matches + // StatusToken.agentId (see the reference TL V1/V2 event schema). 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 event; + Map envelope; try { - event = JSONObjectUtils.parse(new String(payload, StandardCharsets.UTF_8)); + 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); } - Object agentId = event.get("agentId"); - if (!(agentId instanceof String eventAgentId) || eventAgentId.isBlank()) { + Map event = nestedObject(nestedObject(nestedObject(envelope, "payload"), + "producer"), "event"); + Object ansId = event.get("ansId"); + if (!(ansId instanceof String eventAgentId) || eventAgentId.isBlank()) { throw new PopException(ErrorType.BINDING_FAILED, "receipt event payload has no agent id"); } if (!eventAgentId.equals(token.agentId())) { @@ -197,6 +201,13 @@ private static void verifyReceiptAgent(ScittReceipt receipt, StatusToken token) } } + // Returns the nested JSON object at key, or an empty map when it is absent or not an object, so a + // missing branch surfaces through the identity checks rather than as a null dereference. + @SuppressWarnings("unchecked") + private static Map nestedObject(Map parent, String key) { + return parent.get(key) instanceof Map child ? (Map) child : Map.of(); + } + private ScittReceipt parseReceipt(Map> headers) throws PopException { String encoded = requireSingleHeader(headers, ScittHeaders.SCITT_RECEIPT_HEADER); byte[] decoded = decodeHeader(encoded, "receipt"); diff --git a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerVerifierTest.java b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerVerifierTest.java index 75a7cae..82df227 100644 --- a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerVerifierTest.java +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerVerifierTest.java @@ -139,7 +139,8 @@ proofJws, receipt("other-agent", ANS_NAME), token(ANS_NAME, AGENT_ID, certFinger @Test void bindingRejectsMissingReceiptAgent() { ScittReceipt receipt = new ScittReceipt(null, null, null, - "{\"ansName\":\"ans://agent.example.com\"}".getBytes(StandardCharsets.UTF_8), null); + "{\"payload\":{\"producer\":{\"event\":{\"ansName\":\"ans://agent.example.com\"}}}}" + .getBytes(StandardCharsets.UTF_8), null); PopException ex = catchThrowableOfType(() -> verifier().verifyParsed( proofJws, receipt, token(ANS_NAME, AGENT_ID, certFingerprint), METHOD, URL, Map.of(), new CountingReplay(false), CallerOptions.none()), PopException.class); @@ -351,7 +352,9 @@ private static StatusToken token(String ansName, String agentId, String identity } private static ScittReceipt receipt(String agentId, String ansName) { - String json = "{\"agentId\":\"" + agentId + "\",\"ansName\":\"" + ansName + "\"}"; + // Mirrors the reference TL envelope: the agent id (ansId) is nested at payload.producer.event. + String json = "{\"payload\":{\"producer\":{\"event\":" + + "{\"ansId\":\"" + agentId + "\",\"ansName\":\"" + ansName + "\"}}}}"; return new ScittReceipt(null, null, null, json.getBytes(StandardCharsets.UTF_8), null); } diff --git a/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopSecurityConfig.java b/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopSecurityConfig.java index 8d6d0eb..d570fdf 100644 --- a/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopSecurityConfig.java +++ b/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopSecurityConfig.java @@ -17,7 +17,10 @@ public class PopSecurityConfig { @Bean - public TransparencyClient transparencyClient() { + public TransparencyClient transparencyClient(@Value("${pop.tl-url:}") String tlUrl) { + if (tlUrl != null && !tlUrl.isBlank()) { + return TransparencyClient.builder().baseUrl(tlUrl).build(); + } return TransparencyClient.createOte(); } diff --git a/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/resources/application.yml b/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/resources/application.yml index 18e9c0a..f6cffd1 100644 --- a/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/resources/application.yml +++ b/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/resources/application.yml @@ -1,6 +1,14 @@ server: port: 8443 +# This server only verifies incoming DPoP requests (PopSecurityConfig). It does not +# register or discover agents, so the ANS starter auto-config — which requires +# ans.credentials.type — is switched off. +ans: + enabled: false + pop: - expected-issuer: transparency.ans.ote-godaddy.com - trusted-host: ${POP_TRUSTED_HOST:server.example.com:8443} \ No newline at end of file + expected-issuer: ${POP_EXPECTED_ISSUER:transparency.ans.ote-godaddy.com} + trusted-host: ${POP_TRUSTED_HOST:server.example.com:8443} + # Blank keeps the OTE default log; the e2e container points this at a local transparency log. + tl-url: ${POP_TL_URL:} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 386bbad..a522f69 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,7 +4,7 @@ slf4jVersion=2.0.9 bouncyCastleVersion=1.84 reactorVersion=3.6.0 mcpSdkVersion=1.1.0 -caffeineVersion=3.1.8 +caffeineVersion=3.2.0 cborVersion=4.5.4 nimbusJoseVersion=10.9.1 diff --git a/settings.gradle.kts b/settings.gradle.kts index d6a1fe8..862ba04 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,4 +15,5 @@ include("ans-sdk-agent-client:examples:http-api") include("ans-sdk-agent-client:examples:mcp-client") include("ans-sdk-agent-client:examples:a2a-client") include("ans-sdk-spring-boot-starter:examples:spring-boot-app") +include("ans-sdk-spring-boot-starter:examples:a2a-no-mtls") include("ans-sdk-agent-client:examples:mcp-server-spring") \ No newline at end of file From 15230b23387037ea37335b02fa9a45c56d84ab03 Mon Sep 17 00:00:00 2001 From: James Hateley Date: Wed, 2 Sep 2026 21:24:35 +1000 Subject: [PATCH 10/14] feat(pop): move spring example to own module Signed-off-by: James Hateley --- ans-sdk-pop-spring/build.gradle.kts | 35 ++++ .../examples/dpop-scitt-auth}/README.md | 12 +- .../dpop-scitt-auth}/build.gradle.kts | 5 +- .../dpopscittauth}/PopClientExample.java | 6 +- .../dpopscittauth}/PopSecurityConfig.java | 6 +- .../dpopscittauth}/PopServerApplication.java | 2 +- .../dpopscittauth}/ProtectedController.java | 6 +- .../src/main/resources/application.yml | 0 .../sdk/pop}/spring/PopAuthentication.java | 2 +- .../pop}/spring/PopAuthenticationFilter.java | 196 +++++------------- .../spring/PopAuthenticationFilterTest.java | 49 +---- .../pop}/spring/PopAuthenticationTest.java | 2 +- .../ans/sdk/pop/CaffeineReplayCache.java | 24 +++ .../com/godaddy/ans/sdk/pop/CallerPolicy.java | 166 +++++++++++++++ .../godaddy/ans/sdk/pop/CallerPolicyTest.java | 132 ++++++++++++ ans-sdk-spring-boot-starter/build.gradle.kts | 8 - build.gradle.kts | 1 + settings.gradle.kts | 3 +- 18 files changed, 440 insertions(+), 215 deletions(-) create mode 100644 ans-sdk-pop-spring/build.gradle.kts rename {ans-sdk-spring-boot-starter/examples/a2a-no-mtls => ans-sdk-pop-spring/examples/dpop-scitt-auth}/README.md (73%) rename {ans-sdk-spring-boot-starter/examples/a2a-no-mtls => ans-sdk-pop-spring/examples/dpop-scitt-auth}/build.gradle.kts (70%) rename {ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls => ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth}/PopClientExample.java (95%) rename {ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls => ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth}/PopSecurityConfig.java (91%) rename {ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls => ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth}/PopServerApplication.java (86%) rename {ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls => ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth}/ProtectedController.java (88%) rename {ans-sdk-spring-boot-starter/examples/a2a-no-mtls => ans-sdk-pop-spring/examples/dpop-scitt-auth}/src/main/resources/application.yml (100%) rename {ans-sdk-spring-boot-starter/src/main/java/com/godaddy/ans/sdk => ans-sdk-pop-spring/src/main/java/com/godaddy/ans/sdk/pop}/spring/PopAuthentication.java (94%) rename {ans-sdk-spring-boot-starter/src/main/java/com/godaddy/ans/sdk => ans-sdk-pop-spring/src/main/java/com/godaddy/ans/sdk/pop}/spring/PopAuthenticationFilter.java (53%) rename {ans-sdk-spring-boot-starter/src/test/java/com/godaddy/ans/sdk => ans-sdk-pop-spring/src/test/java/com/godaddy/ans/sdk/pop}/spring/PopAuthenticationFilterTest.java (90%) rename {ans-sdk-spring-boot-starter/src/test/java/com/godaddy/ans/sdk => ans-sdk-pop-spring/src/test/java/com/godaddy/ans/sdk/pop}/spring/PopAuthenticationTest.java (97%) create mode 100644 ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerPolicy.java create mode 100644 ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerPolicyTest.java diff --git a/ans-sdk-pop-spring/build.gradle.kts b/ans-sdk-pop-spring/build.gradle.kts new file mode 100644 index 0000000..59f7163 --- /dev/null +++ b/ans-sdk-pop-spring/build.gradle.kts @@ -0,0 +1,35 @@ +val junitVersion: String by project +val mockitoVersion: String by project +val assertjVersion: String by project +val slf4jVersion: String by project + +val springBootVersion = "4.1.0" + +dependencies { + // POP protocol (transitively exposes core/crypto/api/transparency types) + api(project(":ans-sdk-pop")) + + // Spring Boot BOM aligns spring-web / servlet-api versions + compileOnly(platform("org.springframework.boot:spring-boot-dependencies:$springBootVersion")) + + // Servlet filter surface + Spring OncePerRequestFilter base (provided by the consuming web app) + compileOnly("org.springframework:spring-web") + compileOnly("org.springframework:spring-context") + compileOnly("jakarta.servlet:jakarta.servlet-api") + + // Logging + implementation("org.slf4j:slf4j-api:$slf4jVersion") + + // Testing + testImplementation(platform("org.springframework.boot:spring-boot-dependencies:$springBootVersion")) + testImplementation("org.junit.jupiter:junit-jupiter:$junitVersion") + testImplementation("org.mockito:mockito-core:$mockitoVersion") + testImplementation("org.mockito:mockito-junit-jupiter:$mockitoVersion") + testImplementation("org.assertj:assertj-core:$assertjVersion") + // Servlet + Spring mock-web helpers under test (compileOnly in main) + testImplementation("org.springframework:spring-web") + testImplementation("org.springframework:spring-context") + testImplementation("org.springframework:spring-test") + testImplementation("jakarta.servlet:jakarta.servlet-api") + testRuntimeOnly("org.slf4j:slf4j-simple:$slf4jVersion") +} diff --git a/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/README.md b/ans-sdk-pop-spring/examples/dpop-scitt-auth/README.md similarity index 73% rename from ans-sdk-spring-boot-starter/examples/a2a-no-mtls/README.md rename to ans-sdk-pop-spring/examples/dpop-scitt-auth/README.md index 7529a61..fedceca 100644 --- a/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/README.md +++ b/ans-sdk-pop-spring/examples/dpop-scitt-auth/README.md @@ -1,6 +1,6 @@ -# A2A no-mTLS Example +# DPoP-SCITT Example -This example shows sender-constrained caller authentication for ANS agent-to-agent (A2A) traffic without mutual TLS. The client proves that it holds its private key with a DPoP proof (RFC 9449). It sends the proof over a normal server-authenticated HTTPS connection. The server verifies the proof and the ANS identity with the `PopAuthenticationFilter`. +This example shows sender-constrained caller authentication for ANS agent-to-agent traffic without mutual TLS. The client proves that it holds its private key with a DPoP proof (RFC 9449). It sends the proof over a normal server-authenticated HTTPS connection. The server verifies the proof and the ANS identity with the `PopAuthenticationFilter`. DPoP works together with SCITT. SCITT gives identity and liveness. The DPoP proof binds the request to the key that the caller holds. DPoP does not replace SCITT. @@ -17,11 +17,11 @@ DPoP works together with SCITT. SCITT gives identity and liveness. The DPoP proo ## Run the server -The server listens on port 8443 and protects `/a2a/*`. Set the trusted host to the public authority that clients use to reach the server. This pins the source of the `htu` binding. +The server listens on port 8443 and protects `/*`. Set the trusted host to the public authority that clients use to reach the server. This pins the source of the `htu` binding. ```bash export POP_TRUSTED_HOST=server.example.com:8443 -./gradlew :ans-sdk-spring-boot-starter:examples:a2a-no-mtls:bootRun +./gradlew :ans-sdk-spring-boot-starter:examples:dpop-scitt-auth:bootRun ``` The configuration is in `application.yml`: @@ -32,8 +32,8 @@ The configuration is in `application.yml`: ## Run the client ```bash -./gradlew :ans-sdk-spring-boot-starter:examples:a2a-no-mtls:runClient \ - --args="https://server.example.com:8443/a2a/whoami client.p12 changeit agent-key my-agent-id" +./gradlew :ans-sdk-spring-boot-starter:examples:dpop-scitt-auth:runClient \ + --args="https://server.example.com:8443/whoami client.p12 changeit agent-key my-agent-id" ``` The arguments, in order: diff --git a/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/build.gradle.kts b/ans-sdk-pop-spring/examples/dpop-scitt-auth/build.gradle.kts similarity index 70% rename from ans-sdk-spring-boot-starter/examples/a2a-no-mtls/build.gradle.kts rename to ans-sdk-pop-spring/examples/dpop-scitt-auth/build.gradle.kts index d1fcc70..60ae94c 100644 --- a/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/build.gradle.kts +++ b/ans-sdk-pop-spring/examples/dpop-scitt-auth/build.gradle.kts @@ -4,14 +4,13 @@ plugins { } dependencies { - implementation(project(":ans-sdk-spring-boot-starter")) - implementation(project(":ans-sdk-pop")) + implementation(project(":ans-sdk-pop-spring")) implementation("org.springframework.boot:spring-boot-starter-web") } tasks.register("runClient") { group = "application" description = "Runs the DPoP client that attaches identity headers to an outbound request" - mainClass.set("com.godaddy.ans.examples.a2anomtls.PopClientExample") + mainClass.set("com.godaddy.ans.examples.dpopscittauth.PopClientExample") classpath = sourceSets["main"].runtimeClasspath } \ No newline at end of file diff --git a/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopClientExample.java b/ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/PopClientExample.java similarity index 95% rename from ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopClientExample.java rename to ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/PopClientExample.java index 080d591..d7c9498 100644 --- a/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopClientExample.java +++ b/ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/PopClientExample.java @@ -1,4 +1,4 @@ -package com.godaddy.ans.examples.a2anomtls; +package com.godaddy.ans.examples.dpopscittauth; import com.godaddy.ans.sdk.pop.PopHttp; import com.godaddy.ans.sdk.pop.PopSigner; @@ -26,7 +26,7 @@ public static void main(String[] args) throws Exception { if (args.length < 5) { System.out.println("Usage: runClient " + " "); - System.out.println("Example: runClient https://server.example.com:8443/a2a/whoami " + System.out.println("Example: runClient https://server.example.com:8443/whoami " + "client.p12 changeit agent-key my-agent-id"); System.exit(1); } @@ -38,7 +38,7 @@ public static void main(String[] args) throws Exception { String agentId = args[4]; System.out.println("==========================================="); - System.out.println("ANS SDK - A2A no-mTLS Client (DPoP over server-auth HTTPS)"); + System.out.println("ANS SDK - DPOP-SCITT Client (DPoP over server-auth HTTPS)"); System.out.println("==========================================="); System.out.println("Target: " + serverUrl); diff --git a/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopSecurityConfig.java b/ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/PopSecurityConfig.java similarity index 91% rename from ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopSecurityConfig.java rename to ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/PopSecurityConfig.java index d570fdf..ce4ca6f 100644 --- a/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopSecurityConfig.java +++ b/ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/PopSecurityConfig.java @@ -1,8 +1,8 @@ -package com.godaddy.ans.examples.a2anomtls; +package com.godaddy.ans.examples.dpopscittauth; import com.godaddy.ans.sdk.pop.CaffeineReplayCache; import com.godaddy.ans.sdk.pop.ReplayCache; -import com.godaddy.ans.sdk.spring.PopAuthenticationFilter; +import com.godaddy.ans.sdk.pop.spring.PopAuthenticationFilter; import com.godaddy.ans.sdk.transparency.TransparencyClient; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.web.servlet.FilterRegistrationBean; @@ -44,7 +44,7 @@ public FilterRegistrationBean popAuthenticationFilter( .build(); FilterRegistrationBean registration = new FilterRegistrationBean<>(filter); - registration.addUrlPatterns("/a2a/*"); + registration.addUrlPatterns("/*"); return registration; } } \ No newline at end of file diff --git a/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopServerApplication.java b/ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/PopServerApplication.java similarity index 86% rename from ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopServerApplication.java rename to ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/PopServerApplication.java index cfe469c..0c36266 100644 --- a/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/PopServerApplication.java +++ b/ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/PopServerApplication.java @@ -1,4 +1,4 @@ -package com.godaddy.ans.examples.a2anomtls; +package com.godaddy.ans.examples.dpopscittauth; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; diff --git a/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/ProtectedController.java b/ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/ProtectedController.java similarity index 88% rename from ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/ProtectedController.java rename to ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/ProtectedController.java index 89adf0b..73112e1 100644 --- a/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/java/com/godaddy/ans/examples/a2anomtls/ProtectedController.java +++ b/ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/ProtectedController.java @@ -1,7 +1,7 @@ -package com.godaddy.ans.examples.a2anomtls; +package com.godaddy.ans.examples.dpopscittauth; import com.godaddy.ans.sdk.pop.CallerIdentity; -import com.godaddy.ans.sdk.spring.PopAuthentication; +import com.godaddy.ans.sdk.pop.spring.PopAuthentication; import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -12,7 +12,7 @@ import java.util.Optional; @RestController -@RequestMapping("/a2a") +@RequestMapping("/") public class ProtectedController { @GetMapping("/whoami") diff --git a/ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/resources/application.yml b/ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/resources/application.yml similarity index 100% rename from ans-sdk-spring-boot-starter/examples/a2a-no-mtls/src/main/resources/application.yml rename to ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/resources/application.yml diff --git a/ans-sdk-spring-boot-starter/src/main/java/com/godaddy/ans/sdk/spring/PopAuthentication.java b/ans-sdk-pop-spring/src/main/java/com/godaddy/ans/sdk/pop/spring/PopAuthentication.java similarity index 94% rename from ans-sdk-spring-boot-starter/src/main/java/com/godaddy/ans/sdk/spring/PopAuthentication.java rename to ans-sdk-pop-spring/src/main/java/com/godaddy/ans/sdk/pop/spring/PopAuthentication.java index 7218110..5854b6e 100644 --- a/ans-sdk-spring-boot-starter/src/main/java/com/godaddy/ans/sdk/spring/PopAuthentication.java +++ b/ans-sdk-pop-spring/src/main/java/com/godaddy/ans/sdk/pop/spring/PopAuthentication.java @@ -1,4 +1,4 @@ -package com.godaddy.ans.sdk.spring; +package com.godaddy.ans.sdk.pop.spring; import com.godaddy.ans.sdk.pop.CallerIdentity; import jakarta.servlet.http.HttpServletRequest; diff --git a/ans-sdk-spring-boot-starter/src/main/java/com/godaddy/ans/sdk/spring/PopAuthenticationFilter.java b/ans-sdk-pop-spring/src/main/java/com/godaddy/ans/sdk/pop/spring/PopAuthenticationFilter.java similarity index 53% rename from ans-sdk-spring-boot-starter/src/main/java/com/godaddy/ans/sdk/spring/PopAuthenticationFilter.java rename to ans-sdk-pop-spring/src/main/java/com/godaddy/ans/sdk/pop/spring/PopAuthenticationFilter.java index fc67e45..a9d3e71 100644 --- a/ans-sdk-spring-boot-starter/src/main/java/com/godaddy/ans/sdk/spring/PopAuthenticationFilter.java +++ b/ans-sdk-pop-spring/src/main/java/com/godaddy/ans/sdk/pop/spring/PopAuthenticationFilter.java @@ -1,12 +1,12 @@ -package com.godaddy.ans.sdk.spring; +package com.godaddy.ans.sdk.pop.spring; import com.godaddy.ans.sdk.pop.CallerIdentity; import com.godaddy.ans.sdk.pop.CallerOptions; +import com.godaddy.ans.sdk.pop.CallerPolicy; import com.godaddy.ans.sdk.pop.CallerVerifier; import com.godaddy.ans.sdk.pop.PopException; import com.godaddy.ans.sdk.pop.PopHttp; import com.godaddy.ans.sdk.pop.ReplayCache; -import com.godaddy.ans.sdk.transparency.scitt.ScittHeaders; import com.godaddy.ans.sdk.transparency.scitt.StatusToken; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -17,8 +17,6 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; -import java.lang.reflect.InvocationHandler; -import java.lang.reflect.Proxy; import java.net.URI; import java.net.URISyntaxException; import java.security.PublicKey; @@ -26,13 +24,10 @@ import java.util.ArrayList; import java.util.Enumeration; import java.util.HashMap; -import java.util.HashSet; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.Set; import java.util.function.Function; import java.util.function.Supplier; @@ -40,27 +35,21 @@ public final class PopAuthenticationFilter extends OncePerRequestFilter { private static final Logger LOG = LoggerFactory.getLogger(PopAuthenticationFilter.class); - private static final List SECURITY_HEADERS = List.of( - PopHttp.DPOP_HEADER, "Authorization", - ScittHeaders.SCITT_RECEIPT_HEADER, ScittHeaders.STATUS_TOKEN_HEADER); - private final CallerVerifier verifier; private final Supplier> rootKeys; private final ReplayCache replay; private final Function externalUrl; - private final Set trustedHosts; - private final Set allowedHosts; + private final CallerPolicy policy; - // Package-private for tests: lets a test inject a stubbed verifier and pre-resolved host sets. + // Package-private for tests: lets a test inject a stubbed verifier and a pre-built policy. PopAuthenticationFilter(CallerVerifier verifier, Supplier> rootKeys, ReplayCache replay, Function externalUrl, - Set trustedHosts, Set allowedHosts) { + CallerPolicy policy) { this.verifier = verifier; this.rootKeys = rootKeys; this.replay = replay; this.externalUrl = externalUrl; - this.trustedHosts = trustedHosts; - this.allowedHosts = allowedHosts; + this.policy = policy; } public static Builder builder(String expectedIssuer, Supplier> rootKeys, @@ -71,12 +60,13 @@ public static Builder builder(String expectedIssuer, Supplier 1) { - LOG.info("caller rejected: MALFORMED_PROOF - duplicate {} header", header); - reject(response); - return; - } + Map> headers = headerMap(request); + + Optional duplicate = policy.duplicateSecurityHeader(headers); + if (duplicate.isPresent()) { + LOG.info("caller rejected: MALFORMED_PROOF - duplicate {} header", duplicate.get()); + reject(response); + return; } if (!checkAuthority(request)) { @@ -109,14 +99,19 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse CallerIdentity identity; try { - identity = verifier.verifyCaller(proof, headerMap(request), request.getMethod(), + 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 (!allowedHosts.isEmpty() && !allowedByHost(identity)) { + if (!policy.callerAllowed(identity)) { LOG.info("caller rejected: EXPECTED_PEER_MISMATCH - caller ans host is not in the accepted set"); reject(response); return; @@ -126,35 +121,23 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse filterChain.doFilter(request, response); } - private boolean allowedByHost(CallerIdentity identity) { - try { - return allowedHosts.contains(CallerVerifier.ansHost(identity.ansName())); - } catch (PopException e) { - return false; - } - } - private boolean checkAuthority(HttpServletRequest request) { - if (trustedHosts.isEmpty()) { + if (policy.trustsAnyAuthority()) { return true; } - String authority; + return policy.authorityTrusted(deriveAuthority(request)); + } + + private String deriveAuthority(HttpServletRequest request) { if (externalUrl != null) { try { - authority = new URI(externalUrl.apply(request)).getAuthority(); + return new URI(externalUrl.apply(request)).getAuthority(); } catch (URISyntaxException e) { - return false; - } - } else { - authority = request.getHeader("Host"); - if (authority == null) { - authority = request.getServerName(); + return null; } } - if (authority == null) { - return false; - } - return trustedHosts.contains(normalizeAuthority(authority)); + String authority = request.getHeader("Host"); + return authority != null ? authority : request.getServerName(); } private String resolveUrl(HttpServletRequest request) { @@ -181,39 +164,18 @@ private static Map> headerMap(HttpServletRequest request) { return headers; } - private static int countHeaders(HttpServletRequest request, String name) { - Enumeration values = request.getHeaders(name); - int count = 0; - while (values != null && values.hasMoreElements()) { - values.nextElement(); - count++; - } - return count; - } - private static void reject(HttpServletResponse response) throws IOException { response.setHeader("WWW-Authenticate", PopHttp.DPOP_HEADER); response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "unauthorized"); } - private static String normalizeAuthority(String host) { - String normalized = host.trim().toLowerCase(Locale.ROOT); - if (normalized.endsWith(":443")) { - return normalized.substring(0, normalized.length() - 4); - } - if (normalized.endsWith(":80")) { - return normalized.substring(0, normalized.length() - 3); - } - return normalized; - } - public static final class Builder { private final String expectedIssuer; private final Supplier> rootKeys; private final ReplayCache replay; - private final Set trustedHosts = new HashSet<>(); - private final List allowedNames = new ArrayList<>(); + private final CallerPolicy.Builder policy = CallerPolicy.builder(); + private boolean trustedHostsSet; private Function externalUrl; private Duration popSkew; @@ -223,36 +185,34 @@ private Builder(String expectedIssuer, Supplier> rootKeys this.replay = Objects.requireNonNull(replay, "replay"); } + /** + * Sets a function that maps a request to its external URL. The filter uses the result as + * the {@code htu} (HTTP target URI) that PoP proofs bind to. + * + *

The function must vary with the request path. Append the request path - for example + * {@code request.getRequestURI()} - to the external authority. A function that returns a + * constant URL, or ignores the path, breaks {@code htu} binding. Every request then + * produces the same {@code htu}, so a proof no longer binds to a specific request target. + * This is a security defect. + * + * @param externalUrl maps a request to its full external URL, including the request path + * @return this builder + */ public Builder withExternalUrl(Function externalUrl) { this.externalUrl = Objects.requireNonNull(externalUrl, "externalUrl"); return this; } public Builder withTrustedHosts(String... hosts) { - for (String host : hosts) { - if (host == null) { - continue; - } - String normalized = normalizeAuthority(host); - if (!normalized.isEmpty()) { - trustedHosts.add(normalized); - } - } - if (hosts.length > 0 && trustedHosts.isEmpty()) { - throw new IllegalArgumentException("withTrustedHosts: every supplied host was empty"); + policy.trustedHosts(hosts); + if (hosts.length > 0) { + trustedHostsSet = true; } return this; } - public Builder withExpectedAnsName(String ansName) { - allowedNames.add(Objects.requireNonNull(ansName, "ansName")); - return this; - } - public Builder withAllowedAnsNames(String... ansNames) { - for (String ansName : ansNames) { - allowedNames.add(Objects.requireNonNull(ansName, "ansName")); - } + policy.allowedAnsNames(ansNames); return this; } @@ -262,71 +222,15 @@ public Builder withPoPSkew(Duration popSkew) { } public PopAuthenticationFilter build() { - if (externalUrl != null) { - probeExternalUrl(externalUrl); - } - if (externalUrl == null && trustedHosts.isEmpty()) { + if (externalUrl == null && !trustedHostsSet) { LOG.warn("htu will be derived from the client-controlled Host header; " + "set withExternalUrl or withTrustedHosts before production"); } - Set resolvedAllowed = new HashSet<>(); - for (String ansName : allowedNames) { - try { - resolvedAllowed.add(CallerVerifier.ansHost(ansName)); - } catch (PopException e) { - throw new IllegalArgumentException("invalid allowed ans name: " + ansName, e); - } - } CallerVerifier verifier = popSkew != null ? CallerVerifier.create(expectedIssuer, StatusToken.DEFAULT_CLOCK_SKEW, popSkew) : CallerVerifier.create(expectedIssuer); - return new PopAuthenticationFilter(verifier, rootKeys, replay, externalUrl, - Set.copyOf(trustedHosts), resolvedAllowed); + return new PopAuthenticationFilter(verifier, rootKeys, replay, externalUrl, policy.build()); } - private static void probeExternalUrl(Function fn) { - String first = fn.apply(probeRequest("/pop-probe-a")); - String second = fn.apply(probeRequest("/pop-probe-b")); - if (Objects.equals(first, second)) { - throw new IllegalArgumentException("withExternalUrl function ignores the request path; " - + "htu would not bind the request target - append request.getRequestURI() to the authority"); - } - } - - private static HttpServletRequest probeRequest(String path) { - InvocationHandler handler = (proxy, method, args) -> { - switch (method.getName()) { - case "getRequestURI": - case "getServletPath": - return path; - case "getRequestURL": - return new StringBuffer("https://probe.invalid").append(path); - case "getMethod": - return "GET"; - case "getScheme": - return "https"; - case "getServerName": - return "probe.invalid"; - case "getServerPort": - return 443; - default: - break; - } - Class returnType = method.getReturnType(); - if (returnType.equals(boolean.class)) { - return false; - } - if (returnType.equals(int.class)) { - return 0; - } - if (returnType.equals(long.class)) { - return 0L; - } - return null; - }; - return (HttpServletRequest) Proxy.newProxyInstance( - PopAuthenticationFilter.class.getClassLoader(), - new Class[] {HttpServletRequest.class}, handler); - } } } \ No newline at end of file diff --git a/ans-sdk-spring-boot-starter/src/test/java/com/godaddy/ans/sdk/spring/PopAuthenticationFilterTest.java b/ans-sdk-pop-spring/src/test/java/com/godaddy/ans/sdk/pop/spring/PopAuthenticationFilterTest.java similarity index 90% rename from ans-sdk-spring-boot-starter/src/test/java/com/godaddy/ans/sdk/spring/PopAuthenticationFilterTest.java rename to ans-sdk-pop-spring/src/test/java/com/godaddy/ans/sdk/pop/spring/PopAuthenticationFilterTest.java index 5cb48b5..0c171ad 100644 --- a/ans-sdk-spring-boot-starter/src/test/java/com/godaddy/ans/sdk/spring/PopAuthenticationFilterTest.java +++ b/ans-sdk-pop-spring/src/test/java/com/godaddy/ans/sdk/pop/spring/PopAuthenticationFilterTest.java @@ -1,6 +1,7 @@ -package com.godaddy.ans.sdk.spring; +package com.godaddy.ans.sdk.pop.spring; import com.godaddy.ans.sdk.pop.CallerIdentity; +import com.godaddy.ans.sdk.pop.CallerPolicy; import com.godaddy.ans.sdk.pop.CallerVerifier; import com.godaddy.ans.sdk.pop.ErrorType; import com.godaddy.ans.sdk.pop.PopException; @@ -62,7 +63,11 @@ private static PopAuthenticationFilter filter(CallerVerifier verifier, Supplier> rootKeys, Function externalUrl, Set trustedHosts, Set allowedHosts) { - return new PopAuthenticationFilter(verifier, rootKeys, REPLAY, externalUrl, trustedHosts, allowedHosts); + CallerPolicy policy = CallerPolicy.builder() + .trustedHosts(trustedHosts.toArray(new String[0])) + .allowedAnsNames(allowedHosts.toArray(new String[0])) + .build(); + return new PopAuthenticationFilter(verifier, rootKeys, REPLAY, externalUrl, policy); } private static MockHttpServletRequest request() { @@ -324,11 +329,10 @@ void builderBuildsWithPopSkew() { } @Test - void builderResolvesExpectedAndAllowedAnsNames() { + void builderResolvesAllowedAnsNames() { PopAuthenticationFilter filter = PopAuthenticationFilter .builder("issuer.example.com", ROOT_KEYS, REPLAY) - .withExpectedAnsName("ans://a.example.com") - .withAllowedAnsNames("ans://b.example.com", "ans://c.example.com") + .withAllowedAnsNames("ans://a.example.com", "ans://b.example.com", "ans://c.example.com") .build(); assertThat(filter).isNotNull(); @@ -344,43 +348,11 @@ void builderAcceptsPathDependentExternalUrl() { assertThat(filter).isNotNull(); } - @Test - void builderProbesExternalUrlAgainstAllRequestAccessors() { - // Exercises every branch of the probe request proxy: switch cases and primitive-return defaults. - Function externalUrl = req -> { - req.getRequestURL(); - req.getServletPath(); - req.getMethod(); - req.getScheme(); - req.getServerName(); - req.getServerPort(); - req.isSecure(); - req.getContentLength(); - req.getContentLengthLong(); - return "https://gateway.example.com" + req.getRequestURI(); - }; - PopAuthenticationFilter filter = PopAuthenticationFilter - .builder("issuer.example.com", ROOT_KEYS, REPLAY) - .withExternalUrl(externalUrl) - .build(); - - assertThat(filter).isNotNull(); - } - - @Test - void builderRejectsPathIndependentExternalUrl() { - assertThatIllegalArgumentException().isThrownBy(() -> PopAuthenticationFilter - .builder("issuer.example.com", ROOT_KEYS, REPLAY) - .withExternalUrl(req -> "https://gateway.example.com/fixed") - .build()) - .withMessageContaining("ignores the request path"); - } - @Test void builderRejectsInvalidAllowedAnsName() { assertThatIllegalArgumentException().isThrownBy(() -> PopAuthenticationFilter .builder("issuer.example.com", ROOT_KEYS, REPLAY) - .withExpectedAnsName("ans://") + .withAllowedAnsNames("ans://") .build()) .withMessageContaining("invalid allowed ans name"); } @@ -419,7 +391,6 @@ void builderRejectsNullSetters() { PopAuthenticationFilter.builder("issuer", ROOT_KEYS, REPLAY); assertThatNullPointerException().isThrownBy(() -> builder.withExternalUrl(null)); - assertThatNullPointerException().isThrownBy(() -> builder.withExpectedAnsName(null)); assertThatNullPointerException().isThrownBy(() -> builder.withAllowedAnsNames((String) null)); assertThatNullPointerException().isThrownBy(() -> builder.withPoPSkew(null)); } diff --git a/ans-sdk-spring-boot-starter/src/test/java/com/godaddy/ans/sdk/spring/PopAuthenticationTest.java b/ans-sdk-pop-spring/src/test/java/com/godaddy/ans/sdk/pop/spring/PopAuthenticationTest.java similarity index 97% rename from ans-sdk-spring-boot-starter/src/test/java/com/godaddy/ans/sdk/spring/PopAuthenticationTest.java rename to ans-sdk-pop-spring/src/test/java/com/godaddy/ans/sdk/pop/spring/PopAuthenticationTest.java index 339d81d..989d876 100644 --- a/ans-sdk-spring-boot-starter/src/test/java/com/godaddy/ans/sdk/spring/PopAuthenticationTest.java +++ b/ans-sdk-pop-spring/src/test/java/com/godaddy/ans/sdk/pop/spring/PopAuthenticationTest.java @@ -1,4 +1,4 @@ -package com.godaddy.ans.sdk.spring; +package com.godaddy.ans.sdk.pop.spring; import com.godaddy.ans.sdk.pop.CallerIdentity; import org.junit.jupiter.api.Test; diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CaffeineReplayCache.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CaffeineReplayCache.java index b1f7251..f0be5a9 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CaffeineReplayCache.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CaffeineReplayCache.java @@ -3,13 +3,31 @@ import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.Expiry; +import com.github.benmanes.caffeine.cache.RemovalCause; import com.github.benmanes.caffeine.cache.Ticker; import java.time.Duration; import java.util.Objects; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * In-JVM replay cache backed by Caffeine. + * + *

Scope: replay protection is per-process. Entries are not shared across + * replicas, so a proof replayed to a different instance within its freshness window + * is accepted. Multi-replica deployments need a distributed {@link ReplayCache} + * (for example Redis) or sticky routing to keep a single-use guarantee. + * + *

Sizing: {@code maxEntries} bounds memory. Size-based eviction can drop a + * still-fresh jti under load and reopen a replay window for it. Set {@code maxEntries} + * above the peak count of vouched requests within one freshness window, with headroom. + */ public final class CaffeineReplayCache implements ReplayCache { + private static final Logger LOG = LoggerFactory.getLogger(CaffeineReplayCache.class); + private final Cache cache; private CaffeineReplayCache(Cache cache) { @@ -26,6 +44,12 @@ static CaffeineReplayCache create(int maxEntries, Ticker ticker) { .maximumSize(maxEntries) .expireAfter(Expiry.creating((String key, Duration ttl) -> ttl)) .ticker(ticker) + .evictionListener((String key, Duration ttl, RemovalCause cause) -> { + if (cause == RemovalCause.SIZE) { + LOG.warn("replay cache evicted a live entry due to size; " + + "increase maxEntries to preserve replay protection"); + } + }) .build(); return new CaffeineReplayCache(cache); } diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerPolicy.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerPolicy.java new file mode 100644 index 0000000..3da2ac3 --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerPolicy.java @@ -0,0 +1,166 @@ +package com.godaddy.ans.sdk.pop; + +import com.godaddy.ans.sdk.transparency.scitt.ScittHeaders; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * Transport-agnostic admission policy for an authenticated caller. + * + *

It holds the two host-based decisions a callee makes around + * {@link CallerVerifier}, plus the single-value-header rule, without any + * dependency on a specific HTTP stack. An adapter (for example a Servlet + * filter) extracts the request authority, header map, and {@link CallerIdentity} + * and asks this policy to decide; a second adapter reuses the same policy rather + * than reimplementing it. + * + *

Both host sets may be empty. An empty set means "no restriction": every + * request authority is trusted, or every proven caller is accepted. + */ +public final class CallerPolicy { + + /** + * The security headers that must appear at most once on a request (RFC 9449 + * §4.3). A duplicate lets an attacker smuggle a second value past one parser. + */ + public static final List SINGLE_VALUE_HEADERS = List.of( + PopHttp.DPOP_HEADER, "Authorization", + ScittHeaders.SCITT_RECEIPT_HEADER, ScittHeaders.STATUS_TOKEN_HEADER); + + private final Set trustedHosts; + private final Set allowedHosts; + + private CallerPolicy(Set trustedHosts, Set allowedHosts) { + this.trustedHosts = trustedHosts; + this.allowedHosts = allowedHosts; + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Returns {@code true} when no trusted-authority restriction is configured, + * so the caller can skip deriving the request authority. + */ + public boolean trustsAnyAuthority() { + return trustedHosts.isEmpty(); + } + + /** + * Returns {@code true} when the request authority may present pop + * credentials: either no restriction is set, or the normalized authority is + * in the trusted set. A null authority fails a configured check. + */ + public boolean authorityTrusted(String authority) { + if (trustedHosts.isEmpty()) { + return true; + } + return authority != null && trustedHosts.contains(normalizeAuthority(authority)); + } + + /** + * Returns {@code true} when the proven caller is accepted: either no + * restriction is set, or the caller's ans host is in the allowed set. An + * unparseable ans name fails a configured check. + */ + public boolean callerAllowed(CallerIdentity identity) { + if (allowedHosts.isEmpty()) { + return true; + } + try { + return allowedHosts.contains(CallerVerifier.ansHost(identity.ansName())); + } catch (PopException e) { + return false; + } + } + + /** + * Returns the name of the first {@link #SINGLE_VALUE_HEADERS} entry that the + * request carries more than once, or empty when none is duplicated. Header + * names are matched case-insensitively. + */ + public Optional duplicateSecurityHeader(Map> headers) { + for (String name : SINGLE_VALUE_HEADERS) { + if (countHeader(headers, name) > 1) { + return Optional.of(name); + } + } + return Optional.empty(); + } + + private static int countHeader(Map> headers, String name) { + for (Map.Entry> entry : headers.entrySet()) { + if (entry.getKey() != null && entry.getKey().equalsIgnoreCase(name)) { + return entry.getValue() == null ? 0 : entry.getValue().size(); + } + } + return 0; + } + + /** + * Lowercases an authority and drops the default HTTPS/HTTP port, so trust + * comparison ignores case and an explicit {@code :443}/{@code :80}. + */ + public static String normalizeAuthority(String host) { + String normalized = host.trim().toLowerCase(Locale.ROOT); + if (normalized.endsWith(":443")) { + return normalized.substring(0, normalized.length() - 4); + } + if (normalized.endsWith(":80")) { + return normalized.substring(0, normalized.length() - 3); + } + return normalized; + } + + public static final class Builder { + + private final Set trustedHosts = new HashSet<>(); + private final List allowedNames = new ArrayList<>(); + + private Builder() { + } + + public Builder trustedHosts(String... hosts) { + for (String host : hosts) { + if (host == null) { + continue; + } + String normalized = normalizeAuthority(host); + if (!normalized.isEmpty()) { + trustedHosts.add(normalized); + } + } + if (hosts.length > 0 && trustedHosts.isEmpty()) { + throw new IllegalArgumentException("trustedHosts: every supplied host was empty"); + } + return this; + } + + public Builder allowedAnsNames(String... ansNames) { + for (String ansName : ansNames) { + allowedNames.add(Objects.requireNonNull(ansName, "ansName")); + } + return this; + } + + public CallerPolicy build() { + Set resolvedAllowed = new HashSet<>(); + for (String ansName : allowedNames) { + try { + resolvedAllowed.add(CallerVerifier.ansHost(ansName)); + } catch (PopException e) { + throw new IllegalArgumentException("invalid allowed ans name: " + ansName, e); + } + } + return new CallerPolicy(Set.copyOf(trustedHosts), resolvedAllowed); + } + } +} diff --git a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerPolicyTest.java b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerPolicyTest.java new file mode 100644 index 0000000..219696a --- /dev/null +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerPolicyTest.java @@ -0,0 +1,132 @@ +package com.godaddy.ans.sdk.pop; + +import com.godaddy.ans.sdk.transparency.scitt.ScittHeaders; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +class CallerPolicyTest { + + private static CallerIdentity identity(String ansName) { + return new CallerIdentity(ansName, "agent-1", new byte[] {1, 2, 3}, "jkt"); + } + + // ==================== normalizeAuthority ==================== + + @Test + void normalizeLowercasesAndDropsDefaultPorts() { + assertThat(CallerPolicy.normalizeAuthority(" RP.Example.com ")).isEqualTo("rp.example.com"); + assertThat(CallerPolicy.normalizeAuthority("rp.example.com:443")).isEqualTo("rp.example.com"); + assertThat(CallerPolicy.normalizeAuthority("rp.example.com:80")).isEqualTo("rp.example.com"); + assertThat(CallerPolicy.normalizeAuthority("rp.example.com:8443")).isEqualTo("rp.example.com:8443"); + } + + // ==================== authority trust ==================== + + @Test + void trustsAnyAuthorityWhenNoTrustedHostsConfigured() { + CallerPolicy policy = CallerPolicy.builder().build(); + + assertThat(policy.trustsAnyAuthority()).isTrue(); + assertThat(policy.authorityTrusted("anything.example.com")).isTrue(); + assertThat(policy.authorityTrusted(null)).isTrue(); + } + + @Test + void authorityTrustedMatchesNormalizedTrustedHost() { + CallerPolicy policy = CallerPolicy.builder().trustedHosts("rp.example.com").build(); + + assertThat(policy.trustsAnyAuthority()).isFalse(); + assertThat(policy.authorityTrusted("RP.example.com:443")).isTrue(); + assertThat(policy.authorityTrusted("evil.example.com")).isFalse(); + assertThat(policy.authorityTrusted(null)).isFalse(); + } + + // ==================== caller allowed ==================== + + @Test + void allowsAnyCallerWhenNoAllowedNamesConfigured() { + CallerPolicy policy = CallerPolicy.builder().build(); + + assertThat(policy.callerAllowed(identity("ans://agent.example.com"))).isTrue(); + } + + @Test + void callerAllowedMatchesResolvedAnsHost() { + CallerPolicy policy = CallerPolicy.builder().allowedAnsNames("ans://agent.example.com").build(); + + assertThat(policy.callerAllowed(identity("ans://agent.example.com"))).isTrue(); + assertThat(policy.callerAllowed(identity("ans://other.example.com"))).isFalse(); + } + + @Test + void callerAllowedRejectsUnparseableAnsName() { + CallerPolicy policy = CallerPolicy.builder().allowedAnsNames("ans://agent.example.com").build(); + + assertThat(policy.callerAllowed(identity(" "))).isFalse(); + } + + // ==================== duplicate security header ==================== + + @Test + void duplicateSecurityHeaderReturnsEmptyWhenAllSingleValued() { + CallerPolicy policy = CallerPolicy.builder().build(); + Map> headers = Map.of( + PopHttp.DPOP_HEADER, List.of("proof"), + "content-type", List.of("a", "b")); + + assertThat(policy.duplicateSecurityHeader(headers)).isEmpty(); + } + + @Test + void duplicateSecurityHeaderDetectsDuplicateDpop() { + CallerPolicy policy = CallerPolicy.builder().build(); + Map> headers = Map.of(PopHttp.DPOP_HEADER, List.of("a", "b")); + + assertThat(policy.duplicateSecurityHeader(headers)).contains(PopHttp.DPOP_HEADER); + } + + @Test + void duplicateSecurityHeaderMatchesNameCaseInsensitively() { + CallerPolicy policy = CallerPolicy.builder().build(); + Map> headers = Map.of( + ScittHeaders.SCITT_RECEIPT_HEADER.toUpperCase(Locale.ROOT), List.of("a", "b")); + + assertThat(policy.duplicateSecurityHeader(headers)).contains(ScittHeaders.SCITT_RECEIPT_HEADER); + } + + // ==================== builder validation ==================== + + @Test + void trustedHostsSkipsNullAndBlank() { + CallerPolicy policy = CallerPolicy.builder().trustedHosts("rp.example.com", null, " ").build(); + + assertThat(policy.authorityTrusted("rp.example.com")).isTrue(); + } + + @Test + void trustedHostsRejectsWhenEverySuppliedHostEmpty() { + assertThatIllegalArgumentException() + .isThrownBy(() -> CallerPolicy.builder().trustedHosts(" ", "")) + .withMessageContaining("every supplied host was empty"); + } + + @Test + void allowedAnsNamesRejectsNull() { + assertThatNullPointerException() + .isThrownBy(() -> CallerPolicy.builder().allowedAnsNames((String) null)); + } + + @Test + void buildRejectsInvalidAllowedAnsName() { + assertThatIllegalArgumentException() + .isThrownBy(() -> CallerPolicy.builder().allowedAnsNames("ans://").build()) + .withMessageContaining("invalid allowed ans name"); + } +} diff --git a/ans-sdk-spring-boot-starter/build.gradle.kts b/ans-sdk-spring-boot-starter/build.gradle.kts index 3d20547..8222d75 100644 --- a/ans-sdk-spring-boot-starter/build.gradle.kts +++ b/ans-sdk-spring-boot-starter/build.gradle.kts @@ -9,16 +9,11 @@ dependencies { api(project(":ans-sdk-core")) api(project(":ans-sdk-registration")) api(project(":ans-sdk-discovery")) - api(project(":ans-sdk-pop")) // Spring Boot auto-configuration implementation(platform("org.springframework.boot:spring-boot-dependencies:$springBootVersion")) implementation("org.springframework.boot:spring-boot-autoconfigure:$springBootVersion") - // Servlet filter surface (provided by the consuming web application) - compileOnly("org.springframework:spring-web") - compileOnly("jakarta.servlet:jakarta.servlet-api") - // Logging implementation("org.slf4j:slf4j-api:$slf4jVersion") @@ -30,7 +25,4 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter:$junitVersion") testImplementation("org.assertj:assertj-core:$assertjVersion") testImplementation("org.springframework.boot:spring-boot-starter-test:$springBootVersion") - // Servlet filter surface under test (compileOnly in main, so declare for tests) - testImplementation("org.springframework:spring-web") - testImplementation("jakarta.servlet:jakarta.servlet-api") } diff --git a/build.gradle.kts b/build.gradle.kts index 1facae6..16c54ad 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,6 +21,7 @@ val publishableModules = setOf( "ans-sdk-agent-client", "ans-sdk-transparency", "ans-sdk-pop", + "ans-sdk-pop-spring", "ans-sdk-spring-boot-starter" ) diff --git a/settings.gradle.kts b/settings.gradle.kts index 862ba04..990803d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -8,6 +8,7 @@ include("ans-sdk-discovery") include("ans-sdk-agent-client") include("ans-sdk-transparency") include("ans-sdk-pop") +include("ans-sdk-pop-spring") include("ans-sdk-spring-boot-starter") // Examples - not published to Maven, but useful for users of the SDK to reference and run locally @@ -15,5 +16,5 @@ include("ans-sdk-agent-client:examples:http-api") include("ans-sdk-agent-client:examples:mcp-client") include("ans-sdk-agent-client:examples:a2a-client") include("ans-sdk-spring-boot-starter:examples:spring-boot-app") -include("ans-sdk-spring-boot-starter:examples:a2a-no-mtls") +include("ans-sdk-pop-spring:examples:dpop-scitt-auth") include("ans-sdk-agent-client:examples:mcp-server-spring") \ No newline at end of file From d6d12ec9af35e4876e161b84eb1ccb471e2039b3 Mon Sep 17 00:00:00 2001 From: James Hateley Date: Thu, 3 Sep 2026 11:14:49 +1000 Subject: [PATCH 11/14] feat(pop): fail-closed on full replay cache, minor fixes Signed-off-by: James Hateley --- .../examples/dpop-scitt-auth/README.md | 4 +- .../dpopscittauth/PopSecurityConfig.java | 10 ++- .../pop/spring/PopAuthenticationFilter.java | 5 +- .../spring/PopAuthenticationFilterTest.java | 3 + .../ans/sdk/pop/CaffeineReplayCache.java | 61 +++++++++++++------ .../com/godaddy/ans/sdk/pop/ErrorType.java | 6 +- .../com/godaddy/ans/sdk/pop/ReplayCache.java | 10 ++- .../ans/sdk/pop/CaffeineReplayCacheTest.java | 39 ++++++++---- 8 files changed, 96 insertions(+), 42 deletions(-) diff --git a/ans-sdk-pop-spring/examples/dpop-scitt-auth/README.md b/ans-sdk-pop-spring/examples/dpop-scitt-auth/README.md index fedceca..b5550fe 100644 --- a/ans-sdk-pop-spring/examples/dpop-scitt-auth/README.md +++ b/ans-sdk-pop-spring/examples/dpop-scitt-auth/README.md @@ -21,7 +21,7 @@ The server listens on port 8443 and protects `/*`. Set the trusted host to the p ```bash export POP_TRUSTED_HOST=server.example.com:8443 -./gradlew :ans-sdk-spring-boot-starter:examples:dpop-scitt-auth:bootRun +./gradlew :ans-sdk-pop-spring:examples:dpop-scitt-auth:bootRun ``` The configuration is in `application.yml`: @@ -32,7 +32,7 @@ The configuration is in `application.yml`: ## Run the client ```bash -./gradlew :ans-sdk-spring-boot-starter:examples:dpop-scitt-auth:runClient \ +./gradlew :ans-sdk-pop-spring:examples:dpop-scitt-auth:runClient \ --args="https://server.example.com:8443/whoami client.p12 changeit agent-key my-agent-id" ``` diff --git a/ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/PopSecurityConfig.java b/ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/PopSecurityConfig.java index ce4ca6f..dbe7450 100644 --- a/ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/PopSecurityConfig.java +++ b/ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/PopSecurityConfig.java @@ -5,12 +5,14 @@ import com.godaddy.ans.sdk.pop.spring.PopAuthenticationFilter; import com.godaddy.ans.sdk.transparency.TransparencyClient; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationRunner; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.security.PublicKey; import java.util.Map; +import java.util.concurrent.TimeUnit; import java.util.function.Supplier; @Configuration @@ -29,6 +31,11 @@ public ReplayCache replayCache() { return CaffeineReplayCache.create(100_000); } + @Bean + ApplicationRunner warmRootKeys(TransparencyClient client) { + return args -> client.getRootKeysAsync().join(); + } + @Bean public FilterRegistrationBean popAuthenticationFilter( TransparencyClient transparencyClient, @@ -36,7 +43,8 @@ public FilterRegistrationBean popAuthenticationFilter( @Value("${pop.expected-issuer}") String expectedIssuer, @Value("${pop.trusted-host}") String trustedHost) { - Supplier> rootKeys = () -> transparencyClient.getRootKeysAsync().join(); + Supplier> rootKeys = + () -> transparencyClient.getRootKeysAsync().orTimeout(2, TimeUnit.SECONDS).join(); PopAuthenticationFilter filter = PopAuthenticationFilter .builder(expectedIssuer, rootKeys, replayCache) diff --git a/ans-sdk-pop-spring/src/main/java/com/godaddy/ans/sdk/pop/spring/PopAuthenticationFilter.java b/ans-sdk-pop-spring/src/main/java/com/godaddy/ans/sdk/pop/spring/PopAuthenticationFilter.java index a9d3e71..433510e 100644 --- a/ans-sdk-pop-spring/src/main/java/com/godaddy/ans/sdk/pop/spring/PopAuthenticationFilter.java +++ b/ans-sdk-pop-spring/src/main/java/com/godaddy/ans/sdk/pop/spring/PopAuthenticationFilter.java @@ -223,8 +223,9 @@ public Builder withPoPSkew(Duration popSkew) { public PopAuthenticationFilter build() { if (externalUrl == null && !trustedHostsSet) { - LOG.warn("htu will be derived from the client-controlled Host header; " - + "set withExternalUrl or withTrustedHosts before production"); + 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) diff --git a/ans-sdk-pop-spring/src/test/java/com/godaddy/ans/sdk/pop/spring/PopAuthenticationFilterTest.java b/ans-sdk-pop-spring/src/test/java/com/godaddy/ans/sdk/pop/spring/PopAuthenticationFilterTest.java index 0c171ad..10e4045 100644 --- a/ans-sdk-pop-spring/src/test/java/com/godaddy/ans/sdk/pop/spring/PopAuthenticationFilterTest.java +++ b/ans-sdk-pop-spring/src/test/java/com/godaddy/ans/sdk/pop/spring/PopAuthenticationFilterTest.java @@ -312,6 +312,7 @@ void resolvesTargetWithQueryString() throws Exception { void builderBuildsWithDefaults() { PopAuthenticationFilter filter = PopAuthenticationFilter .builder("issuer.example.com", ROOT_KEYS, REPLAY) + .withTrustedHosts("rp.example.com:443") .build(); assertThat(filter).isNotNull(); @@ -333,6 +334,7 @@ void builderResolvesAllowedAnsNames() { PopAuthenticationFilter filter = PopAuthenticationFilter .builder("issuer.example.com", ROOT_KEYS, REPLAY) .withAllowedAnsNames("ans://a.example.com", "ans://b.example.com", "ans://c.example.com") + .withTrustedHosts("rp.example.com:443") .build(); assertThat(filter).isNotNull(); @@ -352,6 +354,7 @@ void builderAcceptsPathDependentExternalUrl() { void builderRejectsInvalidAllowedAnsName() { assertThatIllegalArgumentException().isThrownBy(() -> PopAuthenticationFilter .builder("issuer.example.com", ROOT_KEYS, REPLAY) + .withTrustedHosts("rp.example.com:443") .withAllowedAnsNames("ans://") .build()) .withMessageContaining("invalid allowed ans name"); diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CaffeineReplayCache.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CaffeineReplayCache.java index f0be5a9..7a5cdb8 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CaffeineReplayCache.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CaffeineReplayCache.java @@ -3,14 +3,11 @@ import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.Expiry; -import com.github.benmanes.caffeine.cache.RemovalCause; import com.github.benmanes.caffeine.cache.Ticker; import java.time.Duration; import java.util.Objects; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import java.util.concurrent.ConcurrentMap; /** * In-JVM replay cache backed by Caffeine. @@ -20,18 +17,20 @@ * is accepted. Multi-replica deployments need a distributed {@link ReplayCache} * (for example Redis) or sticky routing to keep a single-use guarantee. * - *

Sizing: {@code maxEntries} bounds memory. Size-based eviction can drop a - * still-fresh jti under load and reopen a replay window for it. Set {@code maxEntries} - * above the peak count of vouched requests within one freshness window, with headroom. + *

Sizing: {@code maxEntries} bounds memory. Eviction is by TTL only; the + * cache never drops a still-fresh jti to make room. At capacity it fails closed — + * {@link #checkAndStore} throws {@link ErrorType#REPLAY_CACHE_FULL} rather than admit + * an id it cannot record. Set {@code maxEntries} above the peak count of vouched + * requests within one freshness window, with headroom, or callers are rejected. */ public final class CaffeineReplayCache implements ReplayCache { - private static final Logger LOG = LoggerFactory.getLogger(CaffeineReplayCache.class); - private final Cache cache; + private final int maxEntries; - private CaffeineReplayCache(Cache cache) { + private CaffeineReplayCache(Cache cache, int maxEntries) { this.cache = cache; + this.maxEntries = maxEntries; } public static CaffeineReplayCache create(int maxEntries) { @@ -41,23 +40,45 @@ public static CaffeineReplayCache create(int maxEntries) { static CaffeineReplayCache create(int maxEntries, Ticker ticker) { Objects.requireNonNull(ticker, "ticker"); Cache cache = Caffeine.newBuilder() - .maximumSize(maxEntries) .expireAfter(Expiry.creating((String key, Duration ttl) -> ttl)) .ticker(ticker) - .evictionListener((String key, Duration ttl, RemovalCause cause) -> { - if (cause == RemovalCause.SIZE) { - LOG.warn("replay cache evicted a live entry due to size; " - + "increase maxEntries to preserve replay protection"); - } - }) .build(); - return new CaffeineReplayCache(cache); + return new CaffeineReplayCache(cache, maxEntries); } @Override - public boolean checkAndStore(String key, Duration ttl) { + public boolean checkAndStore(String key, Duration ttl) throws PopException { Objects.requireNonNull(key, "key"); Objects.requireNonNull(ttl, "ttl"); - return cache.asMap().putIfAbsent(key, ttl) != null; + ConcurrentMap map = cache.asMap(); + if (map.containsKey(key)) { + return true; + } + // ponytail: approximate gate — a concurrent burst may seat a few entries + // over maxEntries. That is the safe direction (over-retention); memory + // stays bounded by TTL. cleanUp() purges expired ids before we reject. + if (map.size() >= maxEntries) { + cache.cleanUp(); + if (map.size() >= maxEntries) { + throw new PopException(ErrorType.REPLAY_CACHE_FULL, + "replay cache at capacity; cannot record proof id"); + } + } + return map.putIfAbsent(key, ttl) != null; + } + + /** + * Approximate number of ids currently held. Compare to {@link #cap()} to alarm + * on saturation before the cache starts rejecting callers. Purges expired ids + * first, so the count reflects live entries. + */ + public long len() { + cache.cleanUp(); + return cache.estimatedSize(); + } + + /** Configured entry ceiling ({@code maxEntries}). */ + public int cap() { + return maxEntries; } } \ No newline at end of file diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ErrorType.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ErrorType.java index 42c8150..e269e80 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ErrorType.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ErrorType.java @@ -26,9 +26,9 @@ public enum ErrorType { /** A jti already seen within the freshness window. */ REPLAY, /** - * Reserved: the replay cache is at capacity and cannot record the proof id. - * The in-process cache does not raise this today — see the replay-cache - * capacity finding. + * The replay cache is at capacity and cannot record the proof id. The proof + * is rejected (fail closed) rather than admitted, since an unrecorded id + * reopens the replay window it exists to close. */ REPLAY_CACHE_FULL, /** A proof whose signature does not verify under the x5c leaf key. */ diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ReplayCache.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ReplayCache.java index 27a86ed..620bb11 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ReplayCache.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ReplayCache.java @@ -4,5 +4,13 @@ public interface ReplayCache { - boolean checkAndStore(String key, Duration ttl); + /** + * Records {@code key} for single use. Returns {@code true} if it was already + * present within its TTL (a replay), {@code false} if it was newly stored. + * + *

Throws {@link PopException} with {@link ErrorType#REPLAY_CACHE_FULL} when + * the cache is at capacity and cannot record the id. This fails closed: an id + * that cannot be recorded must not be admitted, or the replay window reopens. + */ + boolean checkAndStore(String key, Duration ttl) throws PopException; } \ No newline at end of file diff --git a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CaffeineReplayCacheTest.java b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CaffeineReplayCacheTest.java index adc0a17..e654550 100644 --- a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CaffeineReplayCacheTest.java +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CaffeineReplayCacheTest.java @@ -7,22 +7,22 @@ import java.util.concurrent.atomic.AtomicLong; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatNullPointerException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class CaffeineReplayCacheTest { private static final Duration TTL = Duration.ofSeconds(245); @Test - void freshKeyReturnsFalse() { + void freshKeyReturnsFalse() throws Exception { CaffeineReplayCache cache = CaffeineReplayCache.create(128); assertThat(cache.checkAndStore("jti-1", TTL)).isFalse(); } @Test - void secondCallSameKeyReturnsSeen() { + void secondCallSameKeyReturnsSeen() throws Exception { CaffeineReplayCache cache = CaffeineReplayCache.create(128); assertThat(cache.checkAndStore("jti-1", TTL)).isFalse(); @@ -30,18 +30,31 @@ void secondCallSameKeyReturnsSeen() { } @Test - void overCapacityEvictsAndNeverThrows() { + void atCapacityFailsClosed() throws Exception { CaffeineReplayCache cache = CaffeineReplayCache.create(1); - assertThatCode(() -> { - for (int i = 0; i < 10_000; i++) { - cache.checkAndStore("jti-" + i, TTL); - } - }).doesNotThrowAnyException(); + assertThat(cache.checkAndStore("jti-1", TTL)).isFalse(); + + assertThatThrownBy(() -> cache.checkAndStore("jti-2", TTL)) + .isInstanceOf(PopException.class) + .extracting(e -> ((PopException) e).category()) + .isEqualTo(ErrorType.REPLAY_CACHE_FULL); + } + + @Test + void lenAndCapReportSaturation() throws Exception { + CaffeineReplayCache cache = CaffeineReplayCache.create(128); + + assertThat(cache.cap()).isEqualTo(128); + assertThat(cache.len()).isZero(); + + cache.checkAndStore("jti-1", TTL); + + assertThat(cache.len()).isEqualTo(1); } @Test - void afterTtlKeyReadmitted() { + void afterTtlKeyReadmitted() throws Exception { AtomicLong nanos = new AtomicLong(0); Ticker ticker = nanos::get; CaffeineReplayCache cache = CaffeineReplayCache.create(128, ticker); @@ -55,7 +68,7 @@ void afterTtlKeyReadmitted() { } @Test - void withinTtlStillSeen() { + void withinTtlStillSeen() throws Exception { AtomicLong nanos = new AtomicLong(0); Ticker ticker = nanos::get; CaffeineReplayCache cache = CaffeineReplayCache.create(128, ticker); @@ -68,7 +81,7 @@ void withinTtlStillSeen() { } @Test - void perEntryTtlHonored() { + void perEntryTtlHonored() throws Exception { AtomicLong nanos = new AtomicLong(0); Ticker ticker = nanos::get; CaffeineReplayCache cache = CaffeineReplayCache.create(128, ticker); @@ -83,7 +96,7 @@ void perEntryTtlHonored() { } @Test - void repeatedCallDoesNotRefreshExpiry() { + void repeatedCallDoesNotRefreshExpiry() throws Exception { AtomicLong nanos = new AtomicLong(0); Ticker ticker = nanos::get; CaffeineReplayCache cache = CaffeineReplayCache.create(128, ticker); From 7008a62c00b7d3ff13438c26177e4c2b189e6fc3 Mon Sep 17 00:00:00 2001 From: James Hateley Date: Thu, 3 Sep 2026 13:22:49 +1000 Subject: [PATCH 12/14] feat(pop): content-digest, ansName match Signed-off-by: James Hateley --- .../godaddy/ans/sdk/pop/CallerOptions.java | 43 ++++++- .../godaddy/ans/sdk/pop/CallerVerifier.java | 16 +++ .../ans/sdk/pop/DpopProofVerifier.java | 63 +++++++++++ .../com/godaddy/ans/sdk/pop/ErrorType.java | 8 +- .../com/godaddy/ans/sdk/pop/PopSigner.java | 31 ++++- .../java/com/godaddy/ans/sdk/pop/Proof.java | 20 +++- .../godaddy/ans/sdk/pop/VerifyOptions.java | 40 ++++++- .../ans/sdk/pop/CallerOptionsTest.java | 36 ++++++ .../ans/sdk/pop/CallerVerifierTest.java | 76 +++++++++++++ .../ans/sdk/pop/DpopProofVerifierTest.java | 107 +++++++++++++++++- .../godaddy/ans/sdk/pop/PopSignerTest.java | 35 ++++++ .../ans/sdk/pop/VerifyOptionsTest.java | 50 ++++++++ 12 files changed, 506 insertions(+), 19 deletions(-) create mode 100644 ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/VerifyOptionsTest.java diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerOptions.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerOptions.java index c4974d0..d1be5ca 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerOptions.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerOptions.java @@ -12,19 +12,27 @@ public final class CallerOptions { private final String expectedPeer; // A fixed verification time, or null to use the current time. private final Instant clock; + // 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) { + 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); + return new CallerOptions(null, null, null, null, false); } public CallerOptions withAccessToken(String token) { - return new CallerOptions(Objects.requireNonNull(token, "token"), expectedPeer, clock); + return new CallerOptions(Objects.requireNonNull(token, "token"), expectedPeer, clock, + contentSha256, requireContentBinding); } /** @@ -32,11 +40,28 @@ public CallerOptions withAccessToken(String token) { * set, any proven agent authenticates, and the callee authorizes downstream. */ public CallerOptions withExpectedPeer(String peer) { - return new CallerOptions(accessToken, Objects.requireNonNull(peer, "peer"), clock); + 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")); + 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); } String accessToken() { @@ -50,4 +75,12 @@ String expectedPeer() { Instant clock() { return clock; } + + byte[] contentSha256() { + return contentSha256; + } + + boolean requireContentBinding() { + return requireContentBinding; + } } \ No newline at end of file diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java index 25f088d..a0ad44a 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java @@ -139,6 +139,12 @@ private DpopProofVerifier.Verified verifyPossession(String proofJWS, String meth VerifyOptions verifyOptions = options.accessToken() != null ? VerifyOptions.withAccessToken(options.accessToken()) : VerifyOptions.none(); + if (options.contentSha256() != null) { + verifyOptions = verifyOptions.withContentSha256(options.contentSha256()); + } + if (options.requireContentBinding()) { + verifyOptions = verifyOptions.withRequiredContentBinding(); + } Instant now = options.clock() != null ? options.clock() : Instant.now(); return proofVerifier.verifyUnrecorded(proofJWS, method, url, now, popSkew, verifyOptions); } @@ -191,6 +197,16 @@ private static void verifyReceiptAgent(ScittReceipt receipt, StatusToken token) } Map event = nestedObject(nestedObject(nestedObject(envelope, "payload"), "producer"), "event"); + + Object ansName = event.get("ansName"); + if (!(ansName instanceof String eventAnsName) || eventAnsName.isBlank()) { + throw new PopException(ErrorType.BINDING_FAILED, "receipt event payload has no ans name"); + } + if (!eventAnsName.equalsIgnoreCase(token.ansName())) { + throw new PopException(ErrorType.BINDING_FAILED, + "receipt ans name does not match status token ans name"); + } + Object ansId = event.get("ansId"); if (!(ansId instanceof String eventAgentId) || eventAgentId.isBlank()) { throw new PopException(ErrorType.BINDING_FAILED, "receipt event payload has no agent id"); diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/DpopProofVerifier.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/DpopProofVerifier.java index 299931f..49c731c 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/DpopProofVerifier.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/DpopProofVerifier.java @@ -37,6 +37,8 @@ public final class DpopProofVerifier { * cache (no boundary gap). Cache retention = iat + skew + grace. */ static final Duration REPLAY_GRACE = Duration.ofSeconds(5); + // A pre-hashed request-body digest must be a full SHA-256 (ANS-6 §7.13). + private static final int SHA256_BYTES = 32; private static final Logger LOG = LoggerFactory.getLogger(DpopProofVerifier.class); @@ -103,6 +105,14 @@ Verified verifyUnrecorded(String proofJWS, String method, String url, Instant no Proof.Header header = Proof.acceptES256DPoP(proofJWS); + // §7.4 step 3 / §7.5: the x5c leaf's validity period MUST contain the + // current time. The status token cannot supply this bound — identity-cert + // rotation is additive and a sealed event's validIdentityCerts array is + // immutable, so nothing ever prunes a rotated-away or expired certificate. + // The certificate's own notAfter is the only expiry the system carries for + // it. Allow the §7.4 step 9 skew tolerance at both edges. + verifyCertValidity(header.cert(), now, effectiveSkew); + if (!Jws.verify(header.jws(), header.publicKey())) { throw new PopException(ErrorType.SIGNATURE_INVALID, "proof signature is invalid"); } @@ -120,6 +130,9 @@ Verified verifyUnrecorded(String proofJWS, String method, String url, Instant no verifyAth(claims.ath(), effectiveOptions.accessToken()); + verifyContentBinding(claims.ansContentDigest(), effectiveOptions.contentSha256(), + effectiveOptions.requireContentBinding()); + Instant iat = claims.iat(); if (iat == null) { throw new PopException(ErrorType.MALFORMED_PROOF, "iat claim is missing"); @@ -197,6 +210,56 @@ private static void verifyAth(String proofAth, String accessToken) throws PopExc } } + /** + * Enforces ans_content_digest vs the request body (ANS-6 §7.13), mirroring + * ath. A proof carrying a digest is never accepted without a body hash to + * check it against; a supplied body hash demands a matching digest only when + * {@code requireBinding} is set, so an endpoint that does not require content + * binding still accepts a proof that omits the digest. A wrong-length body + * hash is a wiring error (MISCONFIGURED), not a mismatch. + */ + 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 verifyCertValidity(X509Certificate cert, Instant now, Duration skew) throws PopException { + Instant notBefore = cert.getNotBefore().toInstant(); + Instant notAfter = cert.getNotAfter().toInstant(); + if (now.plus(skew).isBefore(notBefore) || now.minus(skew).isAfter(notAfter)) { + throw new PopException(ErrorType.CERT_INVALID, + "x5c leaf certificate validity period does not contain the current time"); + } + } + private static byte[] certFingerprint(X509Certificate cert) throws PopException { try { return sha256(cert.getEncoded()); diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ErrorType.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ErrorType.java index e269e80..7012086 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ErrorType.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ErrorType.java @@ -82,5 +82,11 @@ public enum ErrorType { * The proven caller is not the peer the callee was configured to accept * (see {@link CallerOptions#withExpectedPeer(String)}). */ - EXPECTED_PEER_MISMATCH + EXPECTED_PEER_MISMATCH, + /** + * The proof's ans_content_digest and the request body disagree: a digest + * present with no body-hash supplied, a required body-hash with no digest, + * or a hash mismatch (ANS-6 §7.13). Mirrors ath binding in both directions. + */ + CONTENT_BINDING_MISMATCH } \ No newline at end of file diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopSigner.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopSigner.java index 8348699..4904dac 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopSigner.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopSigner.java @@ -65,7 +65,7 @@ public static PopSigner create(ECPrivateKey key, byte[] certDER) throws PopExcep } public String sign(String method, String url) throws PopException { - return signInternal(method, url, null); + return signInternal(method, url, null, null); } /** @@ -76,7 +76,29 @@ public String sign(String method, String url) throws PopException { */ public String sign(String method, String url, String accessToken) throws PopException { Objects.requireNonNull(accessToken, "accessToken"); - return signInternal(method, url, accessToken); + return signInternal(method, url, accessToken, null); + } + + /** + * Signs a proof and binds the request body via ans_content_digest = + * base64url(SHA-256(content)) per ANS-6 §7.13. An empty body carries no + * digest claim, so a verifier that does not require content binding still + * accepts it. A verifier enforces the digest vs the body in both directions. + */ + public String sign(String method, String url, byte[] content) throws PopException { + Objects.requireNonNull(content, "content"); + return signInternal(method, url, null, content); + } + + /** + * Signs a proof binding both an OAuth2 access token (ath, RFC 9449 §4.2) and + * the request body (ans_content_digest, ANS-6 §7.13). An empty body carries + * no digest claim. + */ + public String sign(String method, String url, String accessToken, byte[] content) throws PopException { + Objects.requireNonNull(accessToken, "accessToken"); + Objects.requireNonNull(content, "content"); + return signInternal(method, url, accessToken, content); } /** @@ -89,7 +111,7 @@ public String jkt() throws PopException { return Proof.jkt(jwk); } - private String signInternal(String method, String url, String accessToken) throws PopException { + private String signInternal(String method, String url, String accessToken, byte[] content) throws PopException { Objects.requireNonNull(method, "method"); Objects.requireNonNull(url, "url"); @@ -109,6 +131,9 @@ private String signInternal(String method, String url, String accessToken) throw if (accessToken != null) { claims.put("ath", Proof.accessTokenHash(accessToken)); } + if (content != null && content.length > 0) { + claims.put("ans_content_digest", Proof.contentDigest(content)); + } return Jws.sign(header, new Payload(claims), privateKey); } diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Proof.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Proof.java index 35209c3..eb19bb3 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Proof.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Proof.java @@ -46,10 +46,12 @@ record Header(JWSObject jws, ECKey jwk, X509Certificate cert, ECPublicKey public // Claims holds the DPoP claims this profile binds: the HTTP method and // normalized target URI (htm/htu), the issued-at (iat), a unique id (jti) - // for replay detection, and — only when the request also presents an OAuth2 - // access token — that token's hash (ath). Additional claims are tolerated on - // the payload (DPoP permits them). Only the header is strictly decoded. - record Claims(String htm, String htu, Instant iat, String jti, String ath) { + // for replay detection, the access-token hash (ath) present only when the + // request also presents an OAuth2 access token, and the request-body hash + // (ans_content_digest) present only when the caller binds the body (ANS-6 + // §7.13). Additional claims are tolerated on the payload (DPoP permits + // them). Only the header is strictly decoded. + record Claims(String htm, String htu, Instant iat, String jti, String ath, String ansContentDigest) { } // acceptES256DPoP decides which proofs this profile accepts: the pinned @@ -119,6 +121,13 @@ static String accessTokenHash(String accessToken) { return Base64Url.encode(sha256(accessToken.getBytes(StandardCharsets.UTF_8))); } + // contentDigest is the ANS-6 §7.13 ans_content_digest value for a request + // body: base64url(SHA-256(content)). It mirrors ath but binds the body + // rather than an access token. + static String contentDigest(byte[] content) { + return Base64Url.encode(sha256(content)); + } + // normalizeHTU returns the RFC 9449 §4.3 htu form of rawUrl: scheme and host // lowercased, the default port (:443 for https, :80 for http) dropped, query // and fragment removed, and an empty path normalized to "/" (RFC 3986 @@ -166,7 +175,8 @@ static Claims parseClaims(Payload payload) throws PopException { stringClaim(map, "htu"), instantClaim(map, "iat"), stringClaim(map, "jti"), - stringClaim(map, "ath")); + stringClaim(map, "ath"), + stringClaim(map, "ans_content_digest")); } private static ECKey extractPublicEcKey(JWSHeader header) throws PopException { diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/VerifyOptions.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/VerifyOptions.java index 68f0aa0..2541872 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/VerifyOptions.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/VerifyOptions.java @@ -6,14 +6,18 @@ public final class VerifyOptions { private final String accessToken; + private final byte[] contentSha256; + private final boolean requireContentBinding; - private VerifyOptions(String accessToken) { + private VerifyOptions(String accessToken, byte[] contentSha256, boolean requireContentBinding) { this.accessToken = accessToken; + this.contentSha256 = contentSha256; + this.requireContentBinding = requireContentBinding; } /** No access token was presented, so the proof must carry no ath. */ public static VerifyOptions none() { - return new VerifyOptions(null); + return new VerifyOptions(null, null, false); } /** @@ -23,10 +27,40 @@ public static VerifyOptions none() { * rejected — the profile enforces ath vs presented token in both directions. */ public static VerifyOptions withAccessToken(String accessToken) { - return new VerifyOptions(Objects.requireNonNull(accessToken, "accessToken")); + return new VerifyOptions(Objects.requireNonNull(accessToken, "accessToken"), null, false); + } + + /** + * Tells the verifier the request body hashes to this SHA-256 digest, which + * requires the proof's ans_content_digest to match it (ANS-6 §7.13). The + * caller hashes the body; the verifier never sees raw bytes. The digest must + * be exactly 32 bytes, or verification fails MISCONFIGURED. The array is + * copied defensively. + */ + public VerifyOptions withContentSha256(byte[] contentSha256) { + Objects.requireNonNull(contentSha256, "contentSha256"); + return new VerifyOptions(accessToken, contentSha256.clone(), requireContentBinding); + } + + /** + * Requires the proof to carry an ans_content_digest. Without this, a request + * that supplies a body hash still accepts a proof that omits the digest; + * with it, the missing digest is rejected. Use at state-changing endpoints + * where the body MUST be bound (ANS-6 §7.13). + */ + public VerifyOptions withRequiredContentBinding() { + return new VerifyOptions(accessToken, contentSha256, true); } String accessToken() { return accessToken; } + + byte[] contentSha256() { + return contentSha256; + } + + boolean requireContentBinding() { + return requireContentBinding; + } } \ No newline at end of file diff --git a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerOptionsTest.java b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerOptionsTest.java index c9e50f8..c8f9464 100644 --- a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerOptionsTest.java +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerOptionsTest.java @@ -16,6 +16,42 @@ void noneHasNullFields() { assertThat(options.accessToken()).isNull(); assertThat(options.expectedPeer()).isNull(); assertThat(options.clock()).isNull(); + assertThat(options.contentSha256()).isNull(); + assertThat(options.requireContentBinding()).isFalse(); + } + + @Test + void withContentSha256CopiesArrayAndPreservesOthers() { + Instant now = Instant.parse("2026-08-28T12:00:00Z"); + byte[] hash = new byte[32]; + hash[0] = 1; + CallerOptions options = CallerOptions.none() + .withAccessToken("token") + .withExpectedPeer("ans://peer.example.com") + .withClock(now) + .withContentSha256(hash); + + hash[0] = 2; + + assertThat(options.contentSha256()[0]).isEqualTo((byte) 1); + assertThat(options.accessToken()).isEqualTo("token"); + assertThat(options.expectedPeer()).isEqualTo("ans://peer.example.com"); + assertThat(options.clock()).isEqualTo(now); + } + + @Test + void withRequiredContentBindingSetsFlagAndPreservesContent() { + CallerOptions options = CallerOptions.none() + .withContentSha256(new byte[32]) + .withRequiredContentBinding(); + + assertThat(options.requireContentBinding()).isTrue(); + assertThat(options.contentSha256()).hasSize(32); + } + + @Test + void withContentSha256RejectsNull() { + assertThatNullPointerException().isThrownBy(() -> CallerOptions.none().withContentSha256(null)); } @Test diff --git a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerVerifierTest.java b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerVerifierTest.java index 82df227..70de10f 100644 --- a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerVerifierTest.java +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerVerifierTest.java @@ -148,6 +148,50 @@ proofJws, receipt, token(ANS_NAME, AGENT_ID, certFingerprint), assertThat(ex.category()).isEqualTo(ErrorType.BINDING_FAILED); } + @Test + void bindingRejectsReceiptAnsNameMismatch() { + PopException ex = catchThrowableOfType(() -> verifier().verifyParsed( + proofJws, receipt(AGENT_ID, "ans://impostor.example.com"), + token(ANS_NAME, AGENT_ID, certFingerprint), + METHOD, URL, Map.of(), new CountingReplay(false), CallerOptions.none()), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.BINDING_FAILED); + } + + @Test + void bindingRejectsReceiptAnsNameVersionMismatch() { + // Cert SAN and token share the host, so the host check passes; the receipt and token differ only + // in the version segment, which must bind through the full-name comparison. + PopException ex = catchThrowableOfType(() -> verifier().verifyParsed( + proofJws, receipt(AGENT_ID, "ans://v2.0.0.agent.example.com"), + token("ans://v1.2.3.agent.example.com", AGENT_ID, certFingerprint), + METHOD, URL, Map.of(), new CountingReplay(false), CallerOptions.none()), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.BINDING_FAILED); + } + + @Test + void bindingAcceptsReceiptAnsNameCaseInsensitively() throws Exception { + CallerIdentity identity = verifier().verifyParsed( + proofJws, receipt(AGENT_ID, "ANS://Agent.Example.Com"), + token(ANS_NAME, AGENT_ID, certFingerprint), + METHOD, URL, Map.of(), new CountingReplay(false), CallerOptions.none()); + + assertThat(identity.agentId()).isEqualTo(AGENT_ID); + } + + @Test + void bindingRejectsMissingReceiptAnsName() { + ScittReceipt receipt = new ScittReceipt(null, null, null, + ("{\"payload\":{\"producer\":{\"event\":{\"ansId\":\"" + AGENT_ID + "\"}}}}") + .getBytes(StandardCharsets.UTF_8), null); + PopException ex = catchThrowableOfType(() -> verifier().verifyParsed( + proofJws, receipt, token(ANS_NAME, AGENT_ID, certFingerprint), + METHOD, URL, Map.of(), new CountingReplay(false), CallerOptions.none()), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.BINDING_FAILED); + } + @Test void replayNotConsumedWhenLaterCheckFails() { CountingReplay replay = new CountingReplay(false); @@ -291,6 +335,34 @@ proofWithAth, receipt(AGENT_ID, ANS_NAME), token(ANS_NAME, AGENT_ID, certFingerp assertThat(identity.agentId()).isEqualTo(AGENT_ID); } + @Test + void contentBindingAcceptedThroughCaller() throws Exception { + byte[] body = "the-request-body".getBytes(StandardCharsets.UTF_8); + String proofWithContent = PopSigner.create((ECPrivateKey) keyPair.getPrivate(), cert.getEncoded()) + .sign(METHOD, URL, body); + + CallerIdentity identity = verifier().verifyParsed( + proofWithContent, receipt(AGENT_ID, ANS_NAME), token(ANS_NAME, AGENT_ID, certFingerprint), + METHOD, URL, Map.of(), new CountingReplay(false), + CallerOptions.none().withContentSha256(sha256(body)).withRequiredContentBinding()); + + assertThat(identity.agentId()).isEqualTo(AGENT_ID); + } + + @Test + void contentBindingMismatchRejectedThroughCaller() throws Exception { + String proofWithContent = PopSigner.create((ECPrivateKey) keyPair.getPrivate(), cert.getEncoded()) + .sign(METHOD, URL, "real-body".getBytes(StandardCharsets.UTF_8)); + + PopException ex = catchThrowableOfType(() -> verifier().verifyParsed( + proofWithContent, receipt(AGENT_ID, ANS_NAME), token(ANS_NAME, AGENT_ID, certFingerprint), + METHOD, URL, Map.of(), new CountingReplay(false), + CallerOptions.none().withContentSha256(sha256("tampered-body".getBytes(StandardCharsets.UTF_8)))), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.CONTENT_BINDING_MISMATCH); + } + @Test void receiptEventPayloadNotJsonRejected() { ScittReceipt receipt = new ScittReceipt(null, null, null, @@ -358,6 +430,10 @@ private static ScittReceipt receipt(String agentId, String ansName) { return new ScittReceipt(null, null, null, json.getBytes(StandardCharsets.UTF_8), null); } + private static byte[] sha256(byte[] input) throws Exception { + return java.security.MessageDigest.getInstance("SHA-256").digest(input); + } + private static KeyPair ec() throws Exception { KeyPairGenerator generator = KeyPairGenerator.getInstance("EC", BouncyCastleProvider.PROVIDER_NAME); generator.initialize(new ECGenParameterSpec("secp256r1")); diff --git a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/DpopProofVerifierTest.java b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/DpopProofVerifierTest.java index 4baae21..5d0cf80 100644 --- a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/DpopProofVerifierTest.java +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/DpopProofVerifierTest.java @@ -344,6 +344,107 @@ void toleratesExtraPayloadClaim() throws Exception { assertThat(result).isNotNull(); } + @Test + void rejectsExpiredCertificate() throws Exception { + Instant now = Instant.parse("2026-08-28T12:00:00Z"); + X509Certificate expired = selfSigned(keyA, + Date.from(now.minusSeconds(7200)), Date.from(now.minusSeconds(3600))); + String proof = PopSigner.create((ECPrivateKey) keyA.getPrivate(), expired.getEncoded()) + .sign(METHOD, URL); + + PopException ex = catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, now, DpopProofVerifier.DEFAULT_SKEW, cache(), null), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.CERT_INVALID); + } + + @Test + void rejectsNotYetValidCertificate() throws Exception { + Instant now = Instant.parse("2026-08-28T12:00:00Z"); + X509Certificate notYetValid = selfSigned(keyA, + Date.from(now.plusSeconds(3600)), Date.from(now.plusSeconds(7200))); + String proof = PopSigner.create((ECPrivateKey) keyA.getPrivate(), notYetValid.getEncoded()) + .sign(METHOD, URL); + + PopException ex = catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, now, DpopProofVerifier.DEFAULT_SKEW, cache(), null), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.CERT_INVALID); + } + + @Test + void rejectsContentDigestWithoutOption() throws Exception { + String proof = signerA().sign(METHOD, URL, "body".getBytes(StandardCharsets.UTF_8)); + + PopException ex = catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), VerifyOptions.none()), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.CONTENT_BINDING_MISMATCH); + } + + @Test + void acceptsMissingContentWhenNotRequired() throws Exception { + String proof = signerA().sign(METHOD, URL); + byte[] bodyHash = sha256("body".getBytes(StandardCharsets.UTF_8)); + + ProofResult result = verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), + VerifyOptions.none().withContentSha256(bodyHash)); + + assertThat(result).isNotNull(); + } + + @Test + void rejectsMissingContentWhenRequired() throws Exception { + String proof = signerA().sign(METHOD, URL); + byte[] bodyHash = sha256("body".getBytes(StandardCharsets.UTF_8)); + + PopException ex = catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), + VerifyOptions.none().withContentSha256(bodyHash).withRequiredContentBinding()), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.CONTENT_BINDING_MISMATCH); + } + + @Test + void acceptsMatchingContent() throws Exception { + byte[] body = "the-request-body".getBytes(StandardCharsets.UTF_8); + String proof = signerA().sign(METHOD, URL, body); + + ProofResult result = verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), + VerifyOptions.none().withContentSha256(sha256(body)).withRequiredContentBinding()); + + assertThat(result).isNotNull(); + } + + @Test + void rejectsContentMismatch() throws Exception { + String proof = signerA().sign(METHOD, URL, "real-body".getBytes(StandardCharsets.UTF_8)); + byte[] otherHash = sha256("tampered-body".getBytes(StandardCharsets.UTF_8)); + + PopException ex = catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), + VerifyOptions.none().withContentSha256(otherHash)), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.CONTENT_BINDING_MISMATCH); + } + + @Test + void rejectsBadLengthContentSha256() throws Exception { + String proof = signerA().sign(METHOD, URL); + + PopException ex = catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), + VerifyOptions.none().withContentSha256(new byte[16])), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MISCONFIGURED); + } + private static Map baseClaims(Instant iat) { Map claims = new LinkedHashMap<>(); claims.put("htm", METHOD); @@ -376,9 +477,11 @@ private static KeyPair ec(String curve) throws Exception { } private static X509Certificate selfSigned(KeyPair pair) throws Exception { + return selfSigned(pair, new Date(1_600_000_000_000L), new Date(4_100_000_000_000L)); + } + + private static X509Certificate selfSigned(KeyPair pair, Date notBefore, Date notAfter) throws Exception { X500Name subject = new X500Name("CN=test"); - Date notBefore = new Date(1_600_000_000_000L); - Date notAfter = new Date(4_100_000_000_000L); JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( subject, BigInteger.valueOf(1), notBefore, notAfter, subject, pair.getPublic()); builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false)); diff --git a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/PopSignerTest.java b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/PopSignerTest.java index c0436e5..3c8a03f 100644 --- a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/PopSignerTest.java +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/PopSignerTest.java @@ -179,6 +179,41 @@ void signWithoutTokenHasNoAth() throws Exception { assertThat(claims.ath()).isNull(); } + @Test + void signWithContentAddsDigest() throws Exception { + PopSigner signer = PopSigner.create((ECPrivateKey) p256A.getPrivate(), certA.getEncoded()); + byte[] body = "request-body".getBytes(java.nio.charset.StandardCharsets.UTF_8); + + String compact = signer.sign("POST", "https://api.example.com/x", body); + + Proof.Claims claims = Proof.parseClaims(Proof.acceptES256DPoP(compact).jws().getPayload()); + assertThat(claims.ansContentDigest()).isEqualTo(Proof.contentDigest(body)); + assertThat(claims.ath()).isNull(); + } + + @Test + void signWithEmptyContentHasNoDigest() throws Exception { + PopSigner signer = PopSigner.create((ECPrivateKey) p256A.getPrivate(), certA.getEncoded()); + + String compact = signer.sign("POST", "https://api.example.com/x", new byte[0]); + + Proof.Claims claims = Proof.parseClaims(Proof.acceptES256DPoP(compact).jws().getPayload()); + assertThat(claims.ansContentDigest()).isNull(); + } + + @Test + void signWithTokenAndContentAddsBoth() throws Exception { + PopSigner signer = PopSigner.create((ECPrivateKey) p256A.getPrivate(), certA.getEncoded()); + String token = "Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU"; + byte[] body = "request-body".getBytes(java.nio.charset.StandardCharsets.UTF_8); + + String compact = signer.sign("POST", "https://api.example.com/x", token, body); + + Proof.Claims claims = Proof.parseClaims(Proof.acceptES256DPoP(compact).jws().getPayload()); + assertThat(claims.ath()).isEqualTo(Proof.accessTokenHash(token)); + assertThat(claims.ansContentDigest()).isEqualTo(Proof.contentDigest(body)); + } + @Test void signRejectsInvalidUrl() throws Exception { PopSigner signer = PopSigner.create((ECPrivateKey) p256A.getPrivate(), certA.getEncoded()); diff --git a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/VerifyOptionsTest.java b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/VerifyOptionsTest.java new file mode 100644 index 0000000..f7fb3dc --- /dev/null +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/VerifyOptionsTest.java @@ -0,0 +1,50 @@ +package com.godaddy.ans.sdk.pop; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +class VerifyOptionsTest { + + @Test + void noneHasNoBindings() { + VerifyOptions options = VerifyOptions.none(); + + assertThat(options.accessToken()).isNull(); + assertThat(options.contentSha256()).isNull(); + assertThat(options.requireContentBinding()).isFalse(); + } + + @Test + void withContentSha256CopiesArray() { + byte[] hash = new byte[32]; + hash[0] = 1; + VerifyOptions options = VerifyOptions.none().withContentSha256(hash); + + hash[0] = 2; + + assertThat(options.contentSha256()[0]).isEqualTo((byte) 1); + } + + @Test + void withContentSha256PreservesAccessToken() { + VerifyOptions options = VerifyOptions.withAccessToken("token").withContentSha256(new byte[32]); + + assertThat(options.accessToken()).isEqualTo("token"); + assertThat(options.contentSha256()).hasSize(32); + } + + @Test + void withRequiredContentBindingSetsFlag() { + VerifyOptions options = VerifyOptions.none().withContentSha256(new byte[32]).withRequiredContentBinding(); + + assertThat(options.requireContentBinding()).isTrue(); + assertThat(options.contentSha256()).hasSize(32); + } + + @Test + void withContentSha256RejectsNull() { + assertThatNullPointerException().isThrownBy(() -> VerifyOptions.none().withContentSha256(null)); + } +} \ No newline at end of file From 7d0a24de665dcd5c0ea9d7131df216a68b281150 Mon Sep 17 00:00:00 2001 From: James Hateley Date: Thu, 3 Sep 2026 13:36:03 +1000 Subject: [PATCH 13/14] feat(pop): verify ans_profile claim Signed-off-by: James Hateley --- .../ans/sdk/pop/DpopProofVerifier.java | 19 ++++++++ .../com/godaddy/ans/sdk/pop/ErrorType.java | 8 +++- .../com/godaddy/ans/sdk/pop/PopSigner.java | 3 ++ .../java/com/godaddy/ans/sdk/pop/Proof.java | 32 +++++++++++-- .../ans/sdk/pop/DpopProofVerifierTest.java | 48 +++++++++++++++++++ .../godaddy/ans/sdk/pop/PopSignerTest.java | 10 ++++ .../com/godaddy/ans/sdk/pop/ProofTest.java | 30 ++++++++++++ 7 files changed, 144 insertions(+), 6 deletions(-) diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/DpopProofVerifier.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/DpopProofVerifier.java index 49c731c..02adf9b 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/DpopProofVerifier.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/DpopProofVerifier.java @@ -39,6 +39,8 @@ public final class DpopProofVerifier { static final Duration REPLAY_GRACE = Duration.ofSeconds(5); // A pre-hashed request-body digest must be a full SHA-256 (ANS-6 §7.13). private static final int SHA256_BYTES = 32; + // The only ans_profile revision this verifier implements (ANS-6 §7.12). + private static final long ANS_PROFILE_REVISION = 1; private static final Logger LOG = LoggerFactory.getLogger(DpopProofVerifier.class); @@ -119,6 +121,8 @@ Verified verifyUnrecorded(String proofJWS, String method, String url, Instant no Proof.Claims claims = Proof.parseClaims(header.jws().getPayload()); + verifyProfileRevision(claims.ansProfile()); + if (!method.equals(claims.htm())) { throw new PopException(ErrorType.HTTP_BINDING_MISMATCH, "htm does not match request method"); } @@ -187,6 +191,21 @@ void recordReplay(Verified verified, ReplayCache replay) throws PopException { } } + /** + * Enforces the ANS-6 §7.12 profile revision before any HTTP binding check. + * An absent claim means revision 1, and only revision 1 is implemented, so + * any other revision fails closed here rather than being interpreted under + * rules this verifier does not have. A non-integral value is already rejected + * during claim parsing. + */ + private static void verifyProfileRevision(Long ansProfile) throws PopException { + if (ansProfile == null || ansProfile == ANS_PROFILE_REVISION) { + return; + } + throw new PopException(ErrorType.UNSUPPORTED_PROFILE, + "ans_profile revision " + ansProfile + " is not supported"); + } + /** * Enforces ath vs presented access token, strictly in both directions: a * proof minted for a token-bound context is not accepted without its token, diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ErrorType.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ErrorType.java index 7012086..ce84cb9 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ErrorType.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ErrorType.java @@ -88,5 +88,11 @@ public enum ErrorType { * present with no body-hash supplied, a required body-hash with no digest, * or a hash mismatch (ANS-6 §7.13). Mirrors ath binding in both directions. */ - CONTENT_BINDING_MISMATCH + CONTENT_BINDING_MISMATCH, + /** + * The proof's ans_profile claim selects a rule-set revision this verifier + * does not implement (ANS-6 §7.12). Absent means revision 1; only revision 1 + * is accepted. Any other value fails closed before the HTTP binding checks. + */ + UNSUPPORTED_PROFILE } \ No newline at end of file diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopSigner.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopSigner.java index 4904dac..b355e8a 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopSigner.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopSigner.java @@ -34,6 +34,8 @@ public final class PopSigner { // jti entropy size (128 bits). private static final int JTI_BYTES = 16; + // ans_profile revision this signer emits on every proof (ANS-6 §7.12). + private static final int ANS_PROFILE_REVISION = 1; private static final SecureRandom RANDOM = new SecureRandom(); private final ECPrivateKey privateKey; @@ -128,6 +130,7 @@ private String signInternal(String method, String url, String accessToken, byte[ claims.put("htu", htu); claims.put("iat", Instant.now().getEpochSecond()); claims.put("jti", newJti()); + claims.put("ans_profile", ANS_PROFILE_REVISION); if (accessToken != null) { claims.put("ath", Proof.accessTokenHash(accessToken)); } diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Proof.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Proof.java index eb19bb3..e4a9ca9 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Proof.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Proof.java @@ -47,11 +47,13 @@ record Header(JWSObject jws, ECKey jwk, X509Certificate cert, ECPublicKey public // Claims holds the DPoP claims this profile binds: the HTTP method and // normalized target URI (htm/htu), the issued-at (iat), a unique id (jti) // for replay detection, the access-token hash (ath) present only when the - // request also presents an OAuth2 access token, and the request-body hash + // request also presents an OAuth2 access token, the request-body hash // (ans_content_digest) present only when the caller binds the body (ANS-6 - // §7.13). Additional claims are tolerated on the payload (DPoP permits - // them). Only the header is strictly decoded. - record Claims(String htm, String htu, Instant iat, String jti, String ath, String ansContentDigest) { + // §7.13), and the profile revision (ans_profile) that selects the rule set + // (ANS-6 §7.12). Additional claims are tolerated on the payload (DPoP + // permits them). Only the header is strictly decoded. + record Claims(String htm, String htu, Instant iat, String jti, String ath, String ansContentDigest, + Long ansProfile) { } // acceptES256DPoP decides which proofs this profile accepts: the pinned @@ -176,7 +178,8 @@ static Claims parseClaims(Payload payload) throws PopException { instantClaim(map, "iat"), stringClaim(map, "jti"), stringClaim(map, "ath"), - stringClaim(map, "ans_content_digest")); + stringClaim(map, "ans_content_digest"), + profileClaim(map, "ans_profile")); } private static ECKey extractPublicEcKey(JWSHeader header) throws PopException { @@ -257,6 +260,25 @@ private static Instant instantClaim(Map map, String name) throws return Instant.ofEpochSecond(n.longValue()); } + // profileClaim decodes ans_profile as an integral revision number. A + // non-numeric value, or a fractional one like 1.5, is rejected rather than + // truncated — silently reading 1.5 as revision 1 would let a caller signal a + // revision it never asserted (ANS-6 §7.12). + private static Long profileClaim(Map map, String name) throws PopException { + Object value = map.get(name); + if (value == null) { + return null; + } + if (!(value instanceof Number n)) { + throw new PopException(ErrorType.MALFORMED_PROOF, "claim " + name + " must be a number"); + } + long asLong = n.longValue(); + if (n.doubleValue() != (double) asLong) { + throw new PopException(ErrorType.MALFORMED_PROOF, "claim " + name + " must be an integer"); + } + return asLong; + } + private static byte[] sha256(byte[] input) { try { return MessageDigest.getInstance("SHA-256").digest(input); diff --git a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/DpopProofVerifierTest.java b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/DpopProofVerifierTest.java index 5d0cf80..fe5641f 100644 --- a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/DpopProofVerifierTest.java +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/DpopProofVerifierTest.java @@ -445,6 +445,54 @@ void rejectsBadLengthContentSha256() throws Exception { assertThat(ex.category()).isEqualTo(ErrorType.MISCONFIGURED); } + @Test + void acceptsAbsentProfile() throws Exception { + Map claims = baseClaims(Instant.now()); + String proof = craft(claims, (ECPrivateKey) keyA.getPrivate()); + + ProofResult result = verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), null); + + assertThat(result).isNotNull(); + } + + @Test + void acceptsProfileOne() throws Exception { + Map claims = baseClaims(Instant.now()); + claims.put("ans_profile", 1); + String proof = craft(claims, (ECPrivateKey) keyA.getPrivate()); + + ProofResult result = verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), null); + + assertThat(result).isNotNull(); + } + + @Test + void rejectsUnsupportedProfile() throws Exception { + Map claims = baseClaims(Instant.now()); + claims.put("ans_profile", 2); + String proof = craft(claims, (ECPrivateKey) keyA.getPrivate()); + + PopException ex = catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), null), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.UNSUPPORTED_PROFILE); + } + + @Test + void rejectsUnsupportedProfileBeforeHtm() throws Exception { + Map claims = baseClaims(Instant.now()); + claims.put("htm", "GET"); + claims.put("ans_profile", 2); + String proof = craft(claims, (ECPrivateKey) keyA.getPrivate()); + + PopException ex = catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), null), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.UNSUPPORTED_PROFILE); + } + private static Map baseClaims(Instant iat) { Map claims = new LinkedHashMap<>(); claims.put("htm", METHOD); diff --git a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/PopSignerTest.java b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/PopSignerTest.java index 3c8a03f..5358337 100644 --- a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/PopSignerTest.java +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/PopSignerTest.java @@ -214,6 +214,16 @@ void signWithTokenAndContentAddsBoth() throws Exception { assertThat(claims.ansContentDigest()).isEqualTo(Proof.contentDigest(body)); } + @Test + void signEmitsProfileOne() throws Exception { + PopSigner signer = PopSigner.create((ECPrivateKey) p256A.getPrivate(), certA.getEncoded()); + + String compact = signer.sign("POST", "https://api.example.com/x"); + + Proof.Claims claims = Proof.parseClaims(Proof.acceptES256DPoP(compact).jws().getPayload()); + assertThat(claims.ansProfile()).isEqualTo(1L); + } + @Test void signRejectsInvalidUrl() throws Exception { PopSigner signer = PopSigner.create((ECPrivateKey) p256A.getPrivate(), certA.getEncoded()); diff --git a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/ProofTest.java b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/ProofTest.java index 4ea35ea..8e5ffe6 100644 --- a/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/ProofTest.java +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/ProofTest.java @@ -380,6 +380,36 @@ void parseClaimsRejectsNonObjectPayload() { assertThat(ex.category()).isEqualTo(ErrorType.MALFORMED_PROOF); } + @Test + void parseClaimsReadsProfileAndDefaultsToNull() throws Exception { + Proof.Claims withProfile = Proof.parseClaims(new Payload(Map.of( + "htm", "GET", "htu", "https://api.example.com/", "iat", 1700000000L, "jti", "id", + "ans_profile", 1))); + assertThat(withProfile.ansProfile()).isEqualTo(1L); + + Proof.Claims withoutProfile = Proof.parseClaims(new Payload(Map.of( + "htm", "GET", "htu", "https://api.example.com/", "iat", 1700000000L, "jti", "id"))); + assertThat(withoutProfile.ansProfile()).isNull(); + } + + @Test + void parseClaimsRejectsNonIntegralProfile() { + Payload payload = new Payload(Map.of("ans_profile", 1.5)); + + PopException ex = catchThrowableOfType(() -> Proof.parseClaims(payload), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MALFORMED_PROOF); + } + + @Test + void parseClaimsRejectsNonNumberProfile() { + Payload payload = new Payload(Map.of("ans_profile", "one")); + + PopException ex = catchThrowableOfType(() -> Proof.parseClaims(payload), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MALFORMED_PROOF); + } + private static KeyPair ec(String curve) throws Exception { KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); kpg.initialize(new ECGenParameterSpec(curve)); From 8e908206a0e6692f8fa1e39e6b1728b1dbcf7de2 Mon Sep 17 00:00:00 2001 From: James Hateley Date: Thu, 3 Sep 2026 13:58:22 +1000 Subject: [PATCH 14/14] feat(pop): cleanup Signed-off-by: James Hateley --- .../godaddy/ans/sdk/pop/CallerVerifier.java | 4 +- .../com/godaddy/ans/sdk/pop/package-info.java | 92 ------------------- 2 files changed, 1 insertion(+), 95 deletions(-) delete mode 100644 ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/package-info.java diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java index a0ad44a..a07edc4 100644 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java @@ -181,9 +181,7 @@ private void verifyBinding(ProofResult proof, ScittReceipt receipt, StatusToken verifyReceiptAgent(receipt, token); } - // The receipt's leaf event must name the same agent as the status token. The signed payload is the - // full transparency-log envelope; the agent UUID lives at payload.producer.event.ansId and matches - // StatusToken.agentId (see the reference TL V1/V2 event schema). + // The receipt's leaf event must name the same agent as the status token, bound by ansName and agentId. private static void verifyReceiptAgent(ScittReceipt receipt, StatusToken token) throws PopException { byte[] payload = receipt.eventPayload(); if (payload == null) { diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/package-info.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/package-info.java deleted file mode 100644 index 1878b3f..0000000 --- a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/package-info.java +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Sender-constrained, application-layer caller authentication for ANS - * agent-to-agent (A2A) traffic — proof-of-possession without mutual TLS. - * - *

Why

- * Today an ANS caller proves its identity to a callee with an mTLS client - * certificate. mTLS breaks through L7 proxies and gateways (which terminate TLS - * and drop the client identity), carries no delegation semantics, and is - * operationally heavy. This package moves the caller's proof to the application - * layer as a DPoP proof (RFC 9449) — the RFC-stable form of the IETF WIMSE - * Workload Proof Token. The proof travels in a standard {@code DPoP} HTTP header - * over ordinary server-authenticated HTTPS. The handshake presents no client - * certificate. - * - *

The three-proof model

- * A2A caller authentication is three independent proofs, all bound to one - * identity certificate: - *
    - *
  • Identity — the caller's name and identity certificate are in the - * transparency log. The SCITT receipt supplies this.
  • - *
  • Liveness — that certificate is currently valid (ACTIVE, not - * revoked). The status token's valid identity fingerprints supply this.
  • - *
  • Possession — the caller holds the certificate's private key, for - * THIS request. The DPoP proof in this package supplies this. This is the - * proof that replaces the mTLS handshake.
  • - *
- * This package composes with SCITT. It does not replace it. The receipt and - * status token are verified unchanged. This package adds the possession proof - * and binds all three to the same certificate. - * - *

Binding

- * The proof header carries both the bare public key (jwk, required by RFC 9449 - * §4.2) and the caller's identity certificate (x5c, RFC 7515 §4.1.6) — which - * MUST present the same key. A verifier (a) confirms that equality and verifies - * the JWS under the single key, (b) confirms SHA-256(cert) is among the status - * token's valid identity fingerprints, and (c) confirms the certificate's own - * {@code ans://} URI SAN equals the status token's ANS name. To pass, a caller - * must hold the private key for a certificate its own transparency-log-signed - * status token vouches for. The status token (TL-signed) is the trust - * statement — there is no CA-chain validation, and the certificate's own - * validity dates, key usage, and cert-type entry are deliberately not consulted. - * A captured receipt and status token (both public) are useless without the key, - * and the proof's jti/htm/htu/iat defeat replay and redirection. - * - *

The htu binding is only as trustworthy as the URL the callee compares - * against. The callee MUST derive that URL from its own externally-visible - * origin, not from a client-controlled Host header, so a proof captured from a - * call to another origin cannot be replayed here with a spoofed Host. Note also - * that htu excludes the query string (RFC 9449 §4.2), so a proof does not bind - * request parameters. - * - *

RFC 9449 conformance and OAuth 2.0

- * Proofs are wire-conformant RFC 9449 DPoP: a textbook §4.3 verifier validates - * them via the jwk header and ignores the x5c. The profile adds two restrictions - * the RFC permits a deployment to impose: ES256 only, and no JOSE header - * parameters beyond {@code {typ, alg, jwk, x5c}} (strict decoding, so a - * private-key "d" member or any extra field fails closed). - * - *

OAuth 2.0 composes on top, unchanged from the RFC. When a request presents - * a DPoP-bound access token ({@code Authorization: DPoP }, RFC 9449 §7.1), - * the proof binds it via the ath claim. The rule is strict in both directions: - * ath is present exactly when a token is presented. Without OAuth there is no - * access token and no ath — the SCITT receipt and status token are the - * credential, and the proof's absence of ath is itself RFC-conformant (ath is - * required only when a token is presented). - * - *

Authentication is not authorization

- * {@link com.godaddy.ans.sdk.pop.CallerVerifier} AUTHENTICATES the caller — it - * returns the cryptographically proven identity. It does NOT authorize it. A - * returned {@link com.godaddy.ans.sdk.pop.CallerIdentity} means "this request - * genuinely came from ans://…X", never "X is allowed to do this." The callee - * MUST apply its own authorization to the returned identity. Use - * {@link com.godaddy.ans.sdk.pop.CallerOptions#withExpectedPeer(String)} to pin - * a specific peer when the callee only accepts a known caller. - * - *

What dropping mTLS gives up

- * DPoP provides sender-constraint (possession), but not the channel binding, - * mutual endpoint authentication, or credential confidentiality that mTLS - * provided. The channel is still server-authenticated HTTPS. A caller induced to - * connect to a hostile callee discloses its (public) receipt and status token - * and a single-use, htu-bound proof. Deployments that need channel binding or - * mutual endpoint auth keep mTLS or add token binding. - * - *

Scope

- * This package implements the autonomous A2A model (no Authorization Server): - * the callee verifies the three proofs and authorizes locally. It does NOT - * implement delegation (an agent acting on behalf of a user across a call - * chain) — that is a separate, higher-risk concern. It reuses the caller's - * existing identity certificate and the status token, minting no new credential - * and adding no wire format. - */ -package com.godaddy.ans.sdk.pop; \ No newline at end of file