From c1c6d6ac9fc4d53cf55f32c8fa7013f26ae8ff52 Mon Sep 17 00:00:00 2001 From: James Hateley Date: Thu, 27 Aug 2026 14:30:00 +1000 Subject: [PATCH 01/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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 From 906e3661d4342bd192127e2696801f1083e32b72 Mon Sep 17 00:00:00 2001 From: James Hateley Date: Wed, 9 Sep 2026 14:17:33 +1000 Subject: [PATCH 15/23] feat(pop): make fingerprint array immutable Signed-off-by: James Hateley --- .../java/com/godaddy/ans/sdk/pop/CallerIdentity.java | 9 +++++++++ 1 file changed, 9 insertions(+) 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 41d1260..b8aa17e 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 @@ -25,6 +25,15 @@ public record CallerIdentity( byte[] fingerprint, String jkt) { + public CallerIdentity { + fingerprint = fingerprint.clone(); + } + + @Override + public byte[] fingerprint() { + return fingerprint.clone(); + } + /** Returns the identity-certificate fingerprint as lowercase hex. */ public String fingerprintHex() { return HexFormat.of().formatHex(fingerprint); From 35c016d8f1a591cf184be94e74502b776215aa05 Mon Sep 17 00:00:00 2001 From: James Hateley Date: Wed, 9 Sep 2026 14:22:29 +1000 Subject: [PATCH 16/23] feat(pop): validate CaffeineReplayCache maxEntries Signed-off-by: James Hateley --- .../godaddy/ans/sdk/pop/CaffeineReplayCache.java | 3 +++ .../ans/sdk/pop/CaffeineReplayCacheTest.java | 15 +++++++++++++++ 2 files changed, 18 insertions(+) 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 7a5cdb8..a7d169a 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 @@ -38,6 +38,9 @@ public static CaffeineReplayCache create(int maxEntries) { } static CaffeineReplayCache create(int maxEntries, Ticker ticker) { + if (maxEntries <= 0) { + throw new IllegalArgumentException("maxEntries must be positive: " + maxEntries); + } Objects.requireNonNull(ticker, "ticker"); Cache cache = Caffeine.newBuilder() .expireAfter(Expiry.creating((String key, Duration ttl) -> ttl)) 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 e654550..b309fa5 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,6 +7,7 @@ import java.util.concurrent.atomic.AtomicLong; 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.assertj.core.api.Assertions.assertThatThrownBy; @@ -110,6 +111,20 @@ void repeatedCallDoesNotRefreshExpiry() throws Exception { assertThat(cache.checkAndStore("jti-1", TTL)).isFalse(); } + @Test + void zeroMaxEntriesRejected() { + assertThatIllegalArgumentException() + .isThrownBy(() -> CaffeineReplayCache.create(0)) + .withMessageContaining("maxEntries must be positive"); + } + + @Test + void negativeMaxEntriesRejected() { + assertThatIllegalArgumentException() + .isThrownBy(() -> CaffeineReplayCache.create(-1)) + .withMessageContaining("maxEntries must be positive"); + } + @Test void nullTickerRejected() { assertThatNullPointerException() From bb7d1077037e097e0f93d258673175ef3d96b13b Mon Sep 17 00:00:00 2001 From: James Hateley Date: Wed, 9 Sep 2026 14:25:11 +1000 Subject: [PATCH 17/23] docs(pop): clarify warning for proxied deployments Signed-off-by: James Hateley --- .../ans/sdk/pop/spring/PopAuthenticationFilter.java | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 433510e..cbbd802 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 @@ -203,6 +203,17 @@ public Builder withExternalUrl(Function externalUrl) return this; } + /** + * Validates the {@code htu} host against a fixed list of trusted hosts. + * + *

Warning: if you set only trusted hosts and do not set {@link #withExternalUrl}, + * the {@code htu} authority comes from the client-controlled {@code Host} header. For + * proxied or multi-tenant deployments, use {@link #withExternalUrl} to pin the authority + * independent of client headers. + * + * @param hosts trusted host authorities + * @return this builder + */ public Builder withTrustedHosts(String... hosts) { policy.trustedHosts(hosts); if (hosts.length > 0) { From f7a978052308af2d4a34067e101e3d2e420337f8 Mon Sep 17 00:00:00 2001 From: James Hateley Date: Wed, 9 Sep 2026 14:26:28 +1000 Subject: [PATCH 18/23] feat(pop): bump caffiene 3.2.0 -> 3.2.4 Signed-off-by: James Hateley --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index a522f69..8c542cd 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.2.0 +caffeineVersion=3.2.4 cborVersion=4.5.4 nimbusJoseVersion=10.9.1 From b9e51f66853426c06521d2b72aa4fc2e51871ebd Mon Sep 17 00:00:00 2001 From: James Hateley Date: Wed, 9 Sep 2026 14:30:31 +1000 Subject: [PATCH 19/23] feat(pop): fix dependency scope Signed-off-by: James Hateley --- ans-sdk-pop-spring/build.gradle.kts | 1 + .../examples/dpop-scitt-auth/build.gradle.kts | 1 + ans-sdk-pop/build.gradle.kts | 13 ++++--------- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/ans-sdk-pop-spring/build.gradle.kts b/ans-sdk-pop-spring/build.gradle.kts index 59f7163..c8ca0fd 100644 --- a/ans-sdk-pop-spring/build.gradle.kts +++ b/ans-sdk-pop-spring/build.gradle.kts @@ -8,6 +8,7 @@ val springBootVersion = "4.1.0" dependencies { // POP protocol (transitively exposes core/crypto/api/transparency types) api(project(":ans-sdk-pop")) + implementation(project(":ans-sdk-transparency")) // Spring Boot BOM aligns spring-web / servlet-api versions compileOnly(platform("org.springframework.boot:spring-boot-dependencies:$springBootVersion")) diff --git a/ans-sdk-pop-spring/examples/dpop-scitt-auth/build.gradle.kts b/ans-sdk-pop-spring/examples/dpop-scitt-auth/build.gradle.kts index 60ae94c..91da9ea 100644 --- a/ans-sdk-pop-spring/examples/dpop-scitt-auth/build.gradle.kts +++ b/ans-sdk-pop-spring/examples/dpop-scitt-auth/build.gradle.kts @@ -5,6 +5,7 @@ plugins { dependencies { implementation(project(":ans-sdk-pop-spring")) + implementation(project(":ans-sdk-transparency")) implementation("org.springframework.boot:spring-boot-starter-web") } diff --git a/ans-sdk-pop/build.gradle.kts b/ans-sdk-pop/build.gradle.kts index f47a1c9..150b7fc 100644 --- a/ans-sdk-pop/build.gradle.kts +++ b/ans-sdk-pop/build.gradle.kts @@ -8,16 +8,11 @@ val mockitoVersion: String by project val assertjVersion: String by project dependencies { - // Core, crypto, generated models - api(project(":ans-sdk-core")) - api(project(":ans-sdk-crypto")) - api(project(":ans-sdk-api")) + // Crypto for CertificateUtils, internal to CallerVerifier + implementation(project(":ans-sdk-crypto")) - // Transparency for StatusToken/ScittReceipt/RootKeyManager/DefaultScittVerifier reuse - api(project(":ans-sdk-transparency")) - - // Agent-client for verification/trust surface reuse - api(project(":ans-sdk-agent-client")) + // Transparency for StatusToken/ScittReceipt/DefaultScittVerifier reuse, consumed internally + implementation(project(":ans-sdk-transparency")) // Nimbus JOSE + JWT for ES256 DPoP proof sign/verify implementation("com.nimbusds:nimbus-jose-jwt:$nimbusJoseVersion") From eecc7f5b486c351ee64df38c609a84f56882b396 Mon Sep 17 00:00:00 2001 From: James Hateley Date: Wed, 9 Sep 2026 14:40:09 +1000 Subject: [PATCH 20/23] feat(pop): test safety guard for htu in PopAuthenticationFilter.Builder Signed-off-by: James Hateley --- .../ans/sdk/pop/spring/PopAuthenticationFilterTest.java | 9 +++++++++ 1 file changed, 9 insertions(+) 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 10e4045..82cec63 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 @@ -23,6 +23,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.assertj.core.api.Assertions.assertThatNullPointerException; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -378,6 +379,14 @@ void builderRejectsWhenAllTrustedHostsEmpty() { .withMessageContaining("every supplied host was empty"); } + @Test + void builderRejectsWhenNeitherExternalUrlNorTrustedHostsSet() { + assertThatIllegalStateException().isThrownBy(() -> PopAuthenticationFilter + .builder("issuer.example.com", ROOT_KEYS, REPLAY) + .build()) + .withMessageContaining("Host header"); + } + @Test void builderRejectsNullConstructorArguments() { assertThatNullPointerException() From 88c76ff98ca1e07ade3126e9eac7d235fded11fa Mon Sep 17 00:00:00 2001 From: James Hateley Date: Wed, 9 Sep 2026 15:03:57 +1000 Subject: [PATCH 21/23] feat(pop): missing tests for CallerVerifier Signed-off-by: James Hateley --- .../godaddy/ans/sdk/pop/CallerVerifier.java | 4 +- .../ans/sdk/pop/CallerVerifierTest.java | 43 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) 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 a07edc4..1d67a59 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 @@ -299,7 +299,9 @@ 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; + case PARSE_ERROR, NOT_PRESENT -> ErrorType.SCITT_HEADER_INVALID; + // Unreachable: mapExpectation runs only when !expectation.isVerified(). + case VERIFIED -> throw new IllegalStateException("mapExpectation called on a verified expectation"); }; return new PopException(type, expectation.failureReason()); } 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 70de10f..1c4a6ee 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 @@ -271,6 +271,38 @@ proofJws, receipt(AGENT_ID, ANS_NAME), token(ANS_NAME, AGENT_ID, certFingerprint assertThat(ex.category()).isEqualTo(ErrorType.RECEIPT_INVALID); } + @Test + void revokedAgentMapsToStatusInvalid() { + CallerVerifier verifier = new CallerVerifier(new FakeScitt(ScittExpectation.revoked(ANS_NAME)), 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 inactiveAgentMapsToStatusInvalid() { + CallerVerifier verifier = new CallerVerifier( + new FakeScitt(ScittExpectation.inactive(StatusToken.Status.DEPRECATED, ANS_NAME)), 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 keyNotFoundMapsToStatusInvalid() { + CallerVerifier verifier = new CallerVerifier( + new FakeScitt(ScittExpectation.keyNotFound("no key")), 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 missingHeadersRejected() { PopException ex = catchThrowableOfType(() -> verifier().verifyCaller( @@ -363,6 +395,17 @@ proofWithContent, receipt(AGENT_ID, ANS_NAME), token(ANS_NAME, AGENT_ID, certFin assertThat(ex.category()).isEqualTo(ErrorType.CONTENT_BINDING_MISMATCH); } + @Test + void receiptNullPayloadRejected() { + ScittReceipt nullPayloadReceipt = new ScittReceipt(null, null, null, null, null); + PopException ex = catchThrowableOfType(() -> verifier().verifyParsed( + proofJws, nullPayloadReceipt, token(ANS_NAME, AGENT_ID, certFingerprint), + METHOD, URL, Map.of(), new CountingReplay(false), CallerOptions.none()), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.BINDING_FAILED); + assertThat(ex.getMessage()).contains("receipt has no event payload"); + } + @Test void receiptEventPayloadNotJsonRejected() { ScittReceipt receipt = new ScittReceipt(null, null, null, From 4fded3e7ae737ed13ad4fca02b4b599cb8c7b025 Mon Sep 17 00:00:00 2001 From: kperry Date: Thu, 17 Sep 2026 16:30:11 -0500 Subject: [PATCH 22/23] fix(pop): require ans_content_digest, verify content after SCITT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ANS-6 as merged (ans-registry a6dcb07; §7.2, §7.4 steps 2 and 12, §7.13) makes ans_content_digest REQUIRED on every Method B proof, with the digest of the empty octet string for a request without content, and has the verifier compare it only after the proof is bound to a live agent. This module implemented the earlier draft: the signer emitted the claim only for non-empty content, the verifier treated it as optional behind a requireContentBinding flag, and PopHttp.attachIdentity could not bind a body at all. A conformant caller such as the Go pop package was rejected by the Java verifier, Java-minted proofs were rejected by conformant verifiers, and a hop behind the TLS terminator could add or rewrite content on a first request without failing any check. - PopSigner writes ans_content_digest on every proof; sign(method, url) and the access-token overload bind the empty octet string. - DpopProofVerifier rejects an absent or malformed claim as MALFORMED_PROOF at step 2 and compares the digest against the received content at step 12, after the SCITT binding and expected-peer checks and before the jti is recorded, through a new ContentSource that is read only at that point. An unreadable source is the new CONTENT_UNREADABLE error. - CallerOptions and VerifyOptions replace the pre-hashed byte[] and the requireContentBinding flag with withReceivedContent(ContentSource). - PopHttp.attachIdentity gains a content overload that sets the body from the same bytes it signs, rejects a builder that already carries unbound content, and sets SCITT headers single-valued (§4.6). - Proof rejects a jwk carrying members beyond kty, crv, x, y, or coordinates that are not 32 bytes (§7.2), checked on the header bytes as sent because the JOSE library drops members it does not know. - CallerVerifier refreshes root keys once through a RootKeyRefresher when a receipt or status token names an unknown signing key (§4.5, §9.5), maps a still-unknown key to the new UNKNOWN_SIGNING_KEY error logged at WARN, and logs REPLAY_CACHE_FULL at ERROR because a saturated cache is an authentication outage (§9.6). - Tests cover the §10 vectors: missing, malformed and mismatched digest, content added to an empty request, the empty-content digest fixture, jwk members beyond the four, and the step order (content is not read when an earlier check fails; replay is not recorded on a content mismatch). Addresses the review on PR #108. Assisted-by: Claude Code (claude-fable-5-1) Co-Authored-By: Claude Fable 5.1 Signed-off-by: kperry --- .../godaddy/ans/sdk/pop/CallerOptions.java | 54 ++--- .../godaddy/ans/sdk/pop/CallerVerifier.java | 79 +++++-- .../godaddy/ans/sdk/pop/ContentSource.java | 22 ++ .../ans/sdk/pop/DpopProofVerifier.java | 129 ++++++----- .../com/godaddy/ans/sdk/pop/ErrorType.java | 23 +- .../java/com/godaddy/ans/sdk/pop/PopHttp.java | 44 +++- .../com/godaddy/ans/sdk/pop/PopSigner.java | 35 +-- .../java/com/godaddy/ans/sdk/pop/Proof.java | 40 +++- .../godaddy/ans/sdk/pop/RootKeyRefresher.java | 26 +++ .../godaddy/ans/sdk/pop/VerifyOptions.java | 52 ++--- .../ans/sdk/pop/CallerOptionsTest.java | 46 ++-- .../ans/sdk/pop/CallerVerifierTest.java | 205 ++++++++++++++++-- .../ans/sdk/pop/DpopProofVerifierTest.java | 138 ++++++++++-- .../com/godaddy/ans/sdk/pop/PopHttpTest.java | 101 +++++++++ .../godaddy/ans/sdk/pop/PopSignerTest.java | 17 +- .../com/godaddy/ans/sdk/pop/ProofTest.java | 64 ++++++ .../ans/sdk/pop/VerifyOptionsTest.java | 41 ++-- 17 files changed, 869 insertions(+), 247 deletions(-) create mode 100644 ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ContentSource.java create mode 100644 ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/RootKeyRefresher.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 d1be5ca..94e4163 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,27 +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; + // The request content, or null when the request carried none. + private final ContentSource receivedContent; - private CallerOptions(String accessToken, String expectedPeer, Instant clock, - byte[] contentSha256, boolean requireContentBinding) { + private CallerOptions(String accessToken, String expectedPeer, Instant clock, ContentSource receivedContent) { this.accessToken = accessToken; this.expectedPeer = expectedPeer; this.clock = clock; - this.contentSha256 = contentSha256; - this.requireContentBinding = requireContentBinding; + this.receivedContent = receivedContent; } + /** + * No access token, any proven agent accepted, the current time, and no + * request content: the proof's ans_content_digest must be the digest of the + * empty octet string (ANS-6 §7.13). + */ public static CallerOptions none() { - return new CallerOptions(null, null, null, null, false); + return new CallerOptions(null, null, null, null); } public CallerOptions withAccessToken(String token) { - return new CallerOptions(Objects.requireNonNull(token, "token"), expectedPeer, clock, - contentSha256, requireContentBinding); + return new CallerOptions(Objects.requireNonNull(token, "token"), expectedPeer, clock, receivedContent); } /** @@ -40,28 +40,20 @@ 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, - contentSha256, requireContentBinding); + return new CallerOptions(accessToken, Objects.requireNonNull(peer, "peer"), clock, receivedContent); } public CallerOptions withClock(Instant now) { - return new CallerOptions(accessToken, expectedPeer, Objects.requireNonNull(now, "now"), - contentSha256, requireContentBinding); + return new CallerOptions(accessToken, expectedPeer, Objects.requireNonNull(now, "now"), receivedContent); } /** - * 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. + * Supplies the request content the proof binds (ANS-6 §7.13). It is read once, + * after the proof is bound to a live ANS identity and before the jti is + * recorded, and its SHA-256 must equal the proof's ans_content_digest. */ - 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); + public CallerOptions withReceivedContent(ContentSource content) { + return new CallerOptions(accessToken, expectedPeer, clock, Objects.requireNonNull(content, "content")); } String accessToken() { @@ -76,11 +68,7 @@ Instant clock() { return clock; } - byte[] contentSha256() { - return contentSha256; - } - - boolean requireContentBinding() { - return requireContentBinding; + public ContentSource receivedContent() { + return receivedContent; } -} \ 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 1d67a59..63952ce 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 @@ -39,17 +39,21 @@ public final class CallerVerifier { private final DpopProofVerifier proofVerifier = new DpopProofVerifier(); private final ScittVerifier scittVerifier; private final Duration popSkew; + private final RootKeyRefresher keyRefresher; 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"); + this(new DefaultScittVerifier(Objects.requireNonNull(scittClockSkew, "scittClockSkew"), + Objects.requireNonNull(expectedIssuer, "expectedIssuer")), popSkew, null); } CallerVerifier(ScittVerifier scittVerifier, Duration popSkew) { + this(scittVerifier, popSkew, null); + } + + private CallerVerifier(ScittVerifier scittVerifier, Duration popSkew, RootKeyRefresher keyRefresher) { this.scittVerifier = Objects.requireNonNull(scittVerifier, "scittVerifier"); this.popSkew = Objects.requireNonNull(popSkew, "popSkew"); + this.keyRefresher = keyRefresher; } public static CallerVerifier create(String expectedIssuer) { @@ -60,6 +64,16 @@ public static CallerVerifier create(String expectedIssuer, Duration scittClockSk return new CallerVerifier(expectedIssuer, scittClockSkew, popSkew); } + /** + * Returns a verifier that, when a receipt or status token names a signing key + * it does not hold, asks {@code refresher} for fresh root keys and retries + * verification once (ANS-6 §4.5, §9.5). Without a refresher an unknown key is + * rejected as {@link ErrorType#UNKNOWN_SIGNING_KEY}. + */ + public CallerVerifier withRootKeyRefresher(RootKeyRefresher refresher) { + return new CallerVerifier(scittVerifier, popSkew, Objects.requireNonNull(refresher, "refresher")); + } + public CallerIdentity verifyCaller(String proofJWS, Map> headers, String method, String url, Map rootKeys, ReplayCache replay, CallerOptions options) throws PopException { @@ -96,7 +110,7 @@ CallerIdentity verifyParsed(String proofJWS, ScittReceipt receipt, StatusToken t // 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); + ScittExpectation expectation = verifyScitt(receipt, token, rootKeys); if (!expectation.isVerified()) { throw mapExpectation(expectation); } @@ -110,6 +124,11 @@ CallerIdentity verifyParsed(String proofJWS, ScittReceipt receipt, StatusToken t "status token peer does not match expected peer"); } + // Content is read and hashed only now that the proof is bound to a + // live agent (ANS-6 §7.4 step 12), so unauthenticated callers cannot + // make the callee buffer and hash arbitrary bodies. + proofVerifier.verifyContent(verified, effectiveOptions.receivedContent()); + // 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 @@ -127,10 +146,11 @@ CallerIdentity verifyParsed(String proofJWS, ScittReceipt receipt, StatusToken t } 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()); + switch (e.category()) { + case MISCONFIGURED, REPLAY_CACHE_FULL -> LOG.error("caller rejected: {} - {}", e.category(), + e.getMessage()); + case UNKNOWN_SIGNING_KEY -> LOG.warn("caller rejected: {} - {}", e.category(), e.getMessage()); + default -> LOG.info("caller rejected: {} - {}", e.category(), e.getMessage()); } } @@ -139,16 +159,42 @@ 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); } + // An unknown signing key is usually a stale root-key cache after the log added + // a key, so refresh once, cooldown-gated by the refresher, and retry before + // rejecting (ANS-6 §4.5, §9.5). A failing refresher keeps the rejection. + private ScittExpectation verifyScitt(ScittReceipt receipt, StatusToken token, Map rootKeys) { + ScittExpectation expectation = scittVerifier.verify(receipt, token, rootKeys); + if (!expectation.isKeyNotFound() || keyRefresher == null) { + return expectation; + } + Instant issuedAt = artifactIssuedAt(receipt, token); + if (issuedAt == null) { + return expectation; + } + Optional> refreshed; + try { + refreshed = keyRefresher.refresh(issuedAt); + } catch (RuntimeException e) { + LOG.warn("root key refresh failed; keeping rejection: {}", e.getMessage()); + return expectation; + } + return refreshed.map(keys -> scittVerifier.verify(receipt, token, keys)).orElse(expectation); + } + + private static Instant artifactIssuedAt(ScittReceipt receipt, StatusToken token) { + if (token.issuedAt() != null) { + return token.issuedAt(); + } + if (receipt.protectedHeader() != null && receipt.protectedHeader().cwtClaims() != null) { + return receipt.protectedHeader().cwtClaims().issuedAtTime(); + } + return null; + } + // verifyBinding ties a verified proof, status token, and receipt to one // agent. private void verifyBinding(ProofResult proof, ScittReceipt receipt, StatusToken token) throws PopException { @@ -298,7 +344,8 @@ public static String ansHost(String ansName) throws PopException { 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 INVALID_TOKEN, TOKEN_EXPIRED, AGENT_REVOKED, AGENT_INACTIVE -> ErrorType.STATUS_INVALID; + case KEY_NOT_FOUND -> ErrorType.UNKNOWN_SIGNING_KEY; case PARSE_ERROR, NOT_PRESENT -> ErrorType.SCITT_HEADER_INVALID; // Unreachable: mapExpectation runs only when !expectation.isVerified(). case VERIFIED -> throw new IllegalStateException("mapExpectation called on a verified expectation"); diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ContentSource.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ContentSource.java new file mode 100644 index 0000000..a1037a2 --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ContentSource.java @@ -0,0 +1,22 @@ +package com.godaddy.ans.sdk.pop; + +import java.io.IOException; + +/** + * Supplies the request content a DPoP proof binds through {@code ans_content_digest} + * (ANS-6 §7.13). The verifier reads it once, after the proof is bound to a live ANS + * identity and before the proof id is recorded, so an unauthenticated caller never + * causes content to be read or hashed. The bytes must be the content octets exactly as + * received: after transfer codings are removed, with any content coding still applied + * (RFC 9110 §6.4). A request without content is an absent source or an empty array. + */ +@FunctionalInterface +public interface ContentSource { + + /** + * Returns the received content octets; an empty array for a request without content. + * + * @throws IOException when the content cannot be read, including when it exceeds a caller's bound + */ + byte[] read() throws IOException; +} 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 02adf9b..5aa5dfe 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 @@ -3,6 +3,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -37,14 +38,13 @@ 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; // 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); - record Verified(ProofResult result, String replayKey, Duration replayTtl) { + record Verified(ProofResult result, byte[] contentDigest, String replayKey, Duration replayTtl) { } /** @@ -53,10 +53,12 @@ record Verified(ProofResult result, String replayKey, Duration replayTtl) { * {@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. + * jwk↔x5c key equality, signature under that single key, required + * ans_content_digest shape, htm, normalized htu, ath vs presented token, iat + * window, jti bounds, then the received content against ans_content_digest + * and finally the jti single-use commit. Content is read and replay is + * recorded LAST, so only proofs that pass every other check consume work or + * 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 @@ -71,25 +73,28 @@ public ProofResult verify(String proofJWS, String method, String url, Instant no LOG.error("DPoP proof rejected: replay cache is not configured"); throw new PopException(ErrorType.MISCONFIGURED, "replay cache must not be null"); } + VerifyOptions effectiveOptions = options != null ? options : VerifyOptions.none(); try { - Verified verified = verifyUnrecorded(proofJWS, method, url, now, skew, options); + Verified verified = verifyUnrecorded(proofJWS, method, url, now, skew, effectiveOptions); + verifyContent(verified, effectiveOptions.receivedContent()); 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()); + switch (e.category()) { + case MISCONFIGURED, REPLAY_CACHE_FULL -> LOG.error("DPoP proof rejected: {} - {}", e.category(), + e.getMessage()); + default -> LOG.info("DPoP proof rejected: {} - {}", e.category(), e.getMessage()); } throw e; } } /** - * 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. + * Runs every proof check except the content comparison and the replay + * commit, so a caller that has more trust checks to perform can defer reading + * content and 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 { @@ -123,6 +128,8 @@ Verified verifyUnrecorded(String proofJWS, String method, String url, Instant no verifyProfileRevision(claims.ansProfile()); + byte[] contentDigest = decodeContentDigest(claims.ansContentDigest()); + if (!method.equals(claims.htm())) { throw new PopException(ErrorType.HTTP_BINDING_MISMATCH, "htm does not match request method"); } @@ -134,9 +141,6 @@ 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"); @@ -171,7 +175,31 @@ Verified verifyUnrecorded(String proofJWS, String method, String url, Instant no normalizedHtu, iat); - return new Verified(result, replayKey, replayTtl); + return new Verified(result, contentDigest, replayKey, replayTtl); + } + + /** + * Compares the received request content with the proof's ans_content_digest + * (ANS-6 §7.4 step 12). An absent source means the request carried no + * content, which binds the empty octet string. Call this only once the proof + * is otherwise trusted: reading content is the one step whose cost the + * caller controls, and the spec defers it until after the identity binding. + */ + void verifyContent(Verified verified, ContentSource content) throws PopException { + Objects.requireNonNull(verified, "verified"); + byte[] received; + try { + received = content != null ? content.read() : Proof.EMPTY_CONTENT; + } catch (IOException e) { + throw new PopException(ErrorType.CONTENT_UNREADABLE, "request content could not be read", e); + } + if (received == null) { + throw new PopException(ErrorType.MISCONFIGURED, "content source returned null"); + } + if (!MessageDigest.isEqual(sha256(received), verified.contentDigest())) { + throw new PopException(ErrorType.CONTENT_BINDING_MISMATCH, + "ans_content_digest does not match request content"); + } } /** @@ -206,6 +234,28 @@ private static void verifyProfileRevision(Long ansProfile) throws PopException { "ans_profile revision " + ansProfile + " is not supported"); } + /** + * Decodes the required ans_content_digest claim (ANS-6 §7.2, §7.4 step 2): the + * unpadded base64url of a SHA-256 digest. Its value is compared against the + * received content only at step 12, in {@link #verifyContent}. + */ + private static byte[] decodeContentDigest(String claim) throws PopException { + if (claim == null) { + throw new PopException(ErrorType.MALFORMED_PROOF, "ans_content_digest claim is missing"); + } + byte[] digest; + try { + digest = Base64Url.decode(claim); + } catch (IllegalArgumentException e) { + throw new PopException(ErrorType.MALFORMED_PROOF, "ans_content_digest is not base64url", e); + } + if (digest.length != SHA256_BYTES || claim.indexOf('=') >= 0) { + throw new PopException(ErrorType.MALFORMED_PROOF, + "ans_content_digest must be an unpadded base64url SHA-256 digest"); + } + return digest; + } + /** * Enforces ath vs presented access token, strictly in both directions: a * proof minted for a token-bound context is not accepted without its token, @@ -229,47 +279,6 @@ 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(); @@ -294,4 +303,4 @@ private static byte[] sha256(byte[] input) { 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/ErrorType.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ErrorType.java index ce84cb9..ffe1ae9 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 @@ -7,8 +7,9 @@ */ public enum ErrorType { /** - * A structurally invalid DPoP proof (bad compact JWS, base64, JSON, or a - * missing required header or claim). + * A structurally invalid DPoP proof (bad compact JWS, base64, JSON, a missing + * required header or claim, or an {@code ans_content_digest} that is not the + * base64url of a SHA-256 digest). */ MALFORMED_PROOF, /** @@ -84,11 +85,23 @@ public enum ErrorType { */ 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. + * The SHA-256 of the received request content does not equal the proof's + * {@code ans_content_digest} (ANS-6 §7.13): content was added to, removed + * from, or rewritten in the request after it was signed. */ CONTENT_BINDING_MISMATCH, + /** + * The request content could not be read while checking + * {@code ans_content_digest}: an I/O failure, or content larger than the + * callee's bound. The proof is rejected without recording its jti. + */ + CONTENT_UNREADABLE, + /** + * The receipt or status token is signed by a key the verifier does not hold + * and no refresh produced it (ANS-6 §4.5, §9.5). Usually a stale root-key + * cache after the Transparency Log added a key, not a bad caller. + */ + UNKNOWN_SIGNING_KEY, /** * 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 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 b58244d..0edcde6 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 @@ -18,29 +18,59 @@ 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). + * Signs a DPoP proof for a request without content, attaches it as the + * {@code DPoP} header, then sets the SCITT headers. A request that already + * carries content is rejected, because the proof would not bind it; use + * {@link #attachIdentity(HttpRequest.Builder, PopSigner, Map, String, byte[])}. */ public static void attachIdentity(HttpRequest.Builder req, PopSigner signer, Map> scittHeaders, String accessToken) throws PopException { + attachIdentity(req, signer, scittHeaders, accessToken, Proof.EMPTY_CONTENT); + } + + /** + * Signs a DPoP proof binding the request and {@code content}, attaches it as + * the {@code DPoP} header, then sets the SCITT headers. {@code content} must be + * the exact octets the request transmits (RFC 9110 §6.4: after transfer + * coding, with any content coding still applied); the request body is set + * from the same array so the two cannot diverge, and a request that already + * carries content the proof would not bind is rejected. Pass an empty array + * for a request without content. When {@code accessToken} is non-null, the + * proof also binds it via ath (RFC 9449 §4.2 / §7.1). + * + *

Security headers are single-valued (ANS-6 §4.6): each SCITT header is + * set, replacing any value already on the builder, and a multi-valued entry + * is rejected. + */ + public static void attachIdentity(HttpRequest.Builder req, PopSigner signer, + Map> scittHeaders, String accessToken, byte[] content) throws PopException { Objects.requireNonNull(req, "req"); Objects.requireNonNull(signer, "signer"); Objects.requireNonNull(scittHeaders, "scittHeaders"); + Objects.requireNonNull(content, "content"); HttpRequest snapshot = req.build(); String method = snapshot.method(); String url = snapshot.uri().toString(); + if (content.length > 0) { + req.method(method, HttpRequest.BodyPublishers.ofByteArray(content)); + } else if (snapshot.bodyPublisher().map(HttpRequest.BodyPublisher::contentLength).orElse(0L) != 0) { + throw new PopException(ErrorType.MISCONFIGURED, + "request carries content the proof would not bind; pass it as content"); + } String proof = accessToken != null - ? signer.sign(method, url, accessToken) - : signer.sign(method, url); + ? signer.sign(method, url, accessToken, content) + : signer.sign(method, url, content); req.setHeader(DPOP_HEADER, proof); for (Map.Entry> entry : scittHeaders.entrySet()) { - for (String value : entry.getValue()) { - req.header(entry.getKey(), value); + List values = entry.getValue(); + if (values == null || values.size() != 1) { + throw new PopException(ErrorType.MISCONFIGURED, + "security header " + entry.getKey() + " must have exactly one value"); } + req.setHeader(entry.getKey(), values.get(0)); } } 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 b355e8a..f5dc090 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 @@ -66,26 +66,34 @@ public static PopSigner create(ECPrivateKey key, byte[] certDER) throws PopExcep return new PopSigner(key, certDER.clone(), publicJwk); } + /** + * Signs a proof for a request without content. Every proof binds the request + * content through ans_content_digest (ANS-6 §7.13); this overload carries + * the digest of the empty octet string. + */ public String sign(String method, String url) throws PopException { - return signInternal(method, url, null, null); + return signInternal(method, url, null, Proof.EMPTY_CONTENT); } /** - * 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. + * Signs a proof for a request without content 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, null); + return signInternal(method, url, accessToken, Proof.EMPTY_CONTENT); } /** - * 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. + * Signs a proof binding the request content via ans_content_digest = + * base64url(SHA-256(content)) per ANS-6 §7.13. {@code content} must be the + * octets the request transmits: after transfer coding, with any content + * coding still applied (RFC 9110 §6.4). An empty array binds the empty octet + * string. A verifier rejects a mismatch, so a hop that rewrites the content + * invalidates the proof. */ public String sign(String method, String url, byte[] content) throws PopException { Objects.requireNonNull(content, "content"); @@ -94,8 +102,7 @@ public String sign(String method, String url, byte[] content) throws PopExceptio /** * 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. + * the request content (ans_content_digest, ANS-6 §7.13). */ public String sign(String method, String url, String accessToken, byte[] content) throws PopException { Objects.requireNonNull(accessToken, "accessToken"); @@ -134,9 +141,7 @@ private String signInternal(String method, String url, String accessToken, byte[ if (accessToken != null) { claims.put("ath", Proof.accessTokenHash(accessToken)); } - if (content != null && content.length > 0) { - claims.put("ans_content_digest", Proof.contentDigest(content)); - } + 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 e4a9ca9..88bbc10 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 @@ -8,6 +8,8 @@ import com.nimbusds.jose.jwk.ECKey; import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jose.util.Base64; +import com.nimbusds.jose.util.Base64URL; +import com.nimbusds.jose.util.JSONObjectUtils; import java.io.ByteArrayInputStream; import java.math.BigInteger; @@ -21,16 +23,25 @@ import java.security.cert.X509Certificate; import java.security.interfaces.ECPublicKey; import java.security.spec.ECPoint; +import java.text.ParseException; import java.time.Instant; import java.util.Arrays; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; final class Proof { static final int P256_FIELD_BYTES = 32; + // The content a request without a body binds (ANS-6 §7.13). + static final byte[] EMPTY_CONTENT = new byte[0]; + + // ANS-6 §7.2: the jwk is exactly the bare public key. Any other member — a + // library-added kid, alg or use, or a private d — fails closed. + private static final Set BARE_JWK_MEMBERS = Set.of("kty", "crv", "x", "y"); + private Proof() { } @@ -47,11 +58,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, the access-token hash (ath) present only when the - // 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), 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. + // request also presents an OAuth2 access token, the request-content hash + // (ans_content_digest) carried by every proof (ANS-6 §7.13; a request + // without content binds the empty octet string), 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) { } @@ -183,6 +195,12 @@ static Claims parseClaims(Payload payload) throws PopException { } private static ECKey extractPublicEcKey(JWSHeader header) throws PopException { + // Nimbus drops members it does not know, so the closed member set is + // checked on the header bytes as sent, not on the parsed key. + if (!(rawHeaderMember(header, "jwk") instanceof Map members) + || !BARE_JWK_MEMBERS.equals(members.keySet())) { + throw new PopException(ErrorType.MALFORMED_PROOF, "jwk must contain exactly kty, crv, x, y"); + } JWK jwk = header.getJWK(); if (!(jwk instanceof ECKey ecKey)) { throw new PopException(ErrorType.MALFORMED_PROOF, "jwk must be an EC key"); @@ -193,9 +211,21 @@ private static ECKey extractPublicEcKey(JWSHeader header) throws PopException { if (ecKey.isPrivate()) { throw new PopException(ErrorType.MALFORMED_PROOF, "jwk must not contain a private key"); } + if (ecKey.getX().decode().length != P256_FIELD_BYTES || ecKey.getY().decode().length != P256_FIELD_BYTES) { + throw new PopException(ErrorType.MALFORMED_PROOF, "jwk coordinates must be full-width (32 bytes)"); + } return ecKey; } + private static Object rawHeaderMember(JWSHeader header, String name) throws PopException { + Base64URL encoded = header.getParsedBase64URL(); + try { + return JSONObjectUtils.parse(encoded.decodeToString()).get(name); + } catch (ParseException e) { + throw new PopException(ErrorType.MALFORMED_PROOF, "proof header is not a JSON object", e); + } + } + // 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 diff --git a/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/RootKeyRefresher.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/RootKeyRefresher.java new file mode 100644 index 0000000..f56f7cb --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/RootKeyRefresher.java @@ -0,0 +1,26 @@ +package com.godaddy.ans.sdk.pop; + +import java.security.PublicKey; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; + +/** + * Refreshes the Transparency Log root keys when a receipt or status token is signed by a + * key the verifier does not hold (ANS-6 §4.5, §9.5). {@code artifactIssuedAt} is the issue + * time of the artifact that named the unknown key, so an implementation can refuse to + * refresh for artifacts older than its cache. Implementations must rate-limit refreshes + * and bound their wait: the verifier calls this on the request path. Return the refreshed + * key set when a refresh happened and the verifier retries verification once with it; + * return empty to keep the rejection. + */ +@FunctionalInterface +public interface RootKeyRefresher { + + /** + * Attempts a rate-limited root-key refresh for an artifact issued at {@code artifactIssuedAt}. + * + * @return the refreshed keys keyed by key id when a refresh happened, empty otherwise + */ + Optional> refresh(Instant artifactIssuedAt); +} 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 2541872..a018d2e 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,18 +6,20 @@ public final class VerifyOptions { private final String accessToken; - private final byte[] contentSha256; - private final boolean requireContentBinding; + private final ContentSource receivedContent; - private VerifyOptions(String accessToken, byte[] contentSha256, boolean requireContentBinding) { + private VerifyOptions(String accessToken, ContentSource receivedContent) { this.accessToken = accessToken; - this.contentSha256 = contentSha256; - this.requireContentBinding = requireContentBinding; + this.receivedContent = receivedContent; } - /** No access token was presented, so the proof must carry no ath. */ + /** + * No access token was presented, so the proof must carry no ath, and the + * request carried no content, so its ans_content_digest must be the digest of + * the empty octet string (ANS-6 §7.13). + */ public static VerifyOptions none() { - return new VerifyOptions(null, null, false); + return new VerifyOptions(null, null); } /** @@ -27,40 +29,24 @@ 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"), 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); + return new VerifyOptions(Objects.requireNonNull(accessToken, "accessToken"), null); } /** - * 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). + * Supplies the request content the proof binds (ANS-6 §7.13). The verifier + * reads it only after every other check has passed and compares its SHA-256 + * with the proof's ans_content_digest; a mismatch rejects the proof before + * its jti is recorded. */ - public VerifyOptions withRequiredContentBinding() { - return new VerifyOptions(accessToken, contentSha256, true); + public VerifyOptions withReceivedContent(ContentSource content) { + return new VerifyOptions(accessToken, Objects.requireNonNull(content, "content")); } String accessToken() { return accessToken; } - byte[] contentSha256() { - return contentSha256; - } - - boolean requireContentBinding() { - return requireContentBinding; + public ContentSource receivedContent() { + return receivedContent; } -} \ 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 c8f9464..1f01db4 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,55 +16,46 @@ void noneHasNullFields() { assertThat(options.accessToken()).isNull(); assertThat(options.expectedPeer()).isNull(); assertThat(options.clock()).isNull(); - assertThat(options.contentSha256()).isNull(); - assertThat(options.requireContentBinding()).isFalse(); + assertThat(options.receivedContent()).isNull(); } @Test - void withContentSha256CopiesArrayAndPreservesOthers() { + void withReceivedContentSetsSourceAndPreservesOthers() throws Exception { Instant now = Instant.parse("2026-08-28T12:00:00Z"); - byte[] hash = new byte[32]; - hash[0] = 1; + byte[] body = {1, 2, 3}; + ContentSource source = () -> body; CallerOptions options = CallerOptions.none() .withAccessToken("token") .withExpectedPeer("ans://peer.example.com") .withClock(now) - .withContentSha256(hash); + .withReceivedContent(source); - hash[0] = 2; - - assertThat(options.contentSha256()[0]).isEqualTo((byte) 1); + assertThat(options.receivedContent()).isSameAs(source); + assertThat(options.receivedContent().read()).isEqualTo(body); 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)); + void withReceivedContentRejectsNull() { + assertThatNullPointerException().isThrownBy(() -> CallerOptions.none().withReceivedContent(null)); } @Test void withAccessTokenSetsTokenAndPreservesOthers() { Instant now = Instant.parse("2026-08-28T12:00:00Z"); + ContentSource source = () -> new byte[0]; CallerOptions options = CallerOptions.none() .withExpectedPeer("ans://peer.example.com") .withClock(now) + .withReceivedContent(source) .withAccessToken("access-token"); assertThat(options.accessToken()).isEqualTo("access-token"); assertThat(options.expectedPeer()).isEqualTo("ans://peer.example.com"); assertThat(options.clock()).isEqualTo(now); + assertThat(options.receivedContent()).isSameAs(source); } @Test @@ -80,6 +71,17 @@ void withClockSetsClockAndPreservesOthers() { assertThat(options.expectedPeer()).isEqualTo("ans://peer.example.com"); } + @Test + void withExpectedPeerPreservesContent() { + ContentSource source = () -> new byte[0]; + CallerOptions options = CallerOptions.none() + .withReceivedContent(source) + .withExpectedPeer("ans://peer.example.com"); + + assertThat(options.expectedPeer()).isEqualTo("ans://peer.example.com"); + assertThat(options.receivedContent()).isSameAs(source); + } + @Test void withAccessTokenRejectsNull() { assertThatNullPointerException().isThrownBy(() -> CallerOptions.none().withAccessToken(null)); @@ -94,4 +96,4 @@ void withClockRejectsNull() { 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 1c4a6ee..d645658 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 @@ -3,6 +3,8 @@ 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.CoseProtectedHeader; +import com.godaddy.ans.sdk.transparency.scitt.CwtClaims; import com.godaddy.ans.sdk.transparency.scitt.ScittExpectation; import com.godaddy.ans.sdk.transparency.scitt.ScittHeaders; import com.godaddy.ans.sdk.transparency.scitt.ScittReceipt; @@ -36,8 +38,10 @@ import java.util.Date; import java.util.List; import java.util.Map; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; import static org.assertj.core.api.Assertions.catchThrowableOfType; class CallerVerifierTest { @@ -293,14 +297,118 @@ proofJws, receipt(AGENT_ID, ANS_NAME), token(ANS_NAME, AGENT_ID, certFingerprint } @Test - void keyNotFoundMapsToStatusInvalid() { + void keyNotFoundMapsToUnknownSigningKey() { CallerVerifier verifier = new CallerVerifier( new FakeScitt(ScittExpectation.keyNotFound("no key")), 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.UNKNOWN_SIGNING_KEY); + } + + @Test + void unknownSigningKeyIsRefreshedAndVerificationRetried() throws Exception { + FakeScitt scitt = new FakeScitt(ScittExpectation.keyNotFound("no key"), verifiedExpectation()); + Map fresh = Map.of("k2", keyPair.getPublic()); + RecordingRefresher refresher = new RecordingRefresher(Optional.of(fresh)); + CallerVerifier verifier = new CallerVerifier(scitt, DEFAULT_SKEW).withRootKeyRefresher(refresher); + StatusToken token = token(ANS_NAME, AGENT_ID, certFingerprint); + + CallerIdentity identity = verifier.verifyParsed(proofJws, receipt(AGENT_ID, ANS_NAME), token, + METHOD, URL, Map.of(), new CountingReplay(false), CallerOptions.none()); + + assertThat(identity.agentId()).isEqualTo(AGENT_ID); + assertThat(scitt.calls).isEqualTo(2); + assertThat(scitt.lastKeys).isSameAs(fresh); + assertThat(refresher.calls).isEqualTo(1); + assertThat(refresher.lastIssuedAt).isEqualTo(token.issuedAt()); + } + + @Test + void unknownSigningKeyStaysRejectedWhenRefreshDeclines() { + FakeScitt scitt = new FakeScitt(ScittExpectation.keyNotFound("no key")); + RecordingRefresher refresher = new RecordingRefresher(Optional.empty()); + CallerVerifier verifier = new CallerVerifier(scitt, DEFAULT_SKEW).withRootKeyRefresher(refresher); + + 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.UNKNOWN_SIGNING_KEY); + assertThat(scitt.calls).isEqualTo(1); + assertThat(refresher.calls).isEqualTo(1); + } + + @Test + void unknownSigningKeyStaysRejectedWhenRefresherFails() { + FakeScitt scitt = new FakeScitt(ScittExpectation.keyNotFound("no key")); + CallerVerifier verifier = new CallerVerifier(scitt, DEFAULT_SKEW) + .withRootKeyRefresher(issuedAt -> { + throw new IllegalStateException("transparency log unreachable"); + }); + + 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.UNKNOWN_SIGNING_KEY); + assertThat(scitt.calls).isEqualTo(1); + } + + @Test + void refresherIsNotConsultedForOtherScittFailures() { + RecordingRefresher refresher = new RecordingRefresher(Optional.of(Map.of())); + CallerVerifier verifier = new CallerVerifier(new FakeScitt(ScittExpectation.expired()), DEFAULT_SKEW) + .withRootKeyRefresher(refresher); + + 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); + assertThat(refresher.calls).isZero(); + } + + @Test + void refreshIsSkippedWhenNoArtifactCarriesAnIssueTime() { + RecordingRefresher refresher = new RecordingRefresher(Optional.of(Map.of())); + FakeScitt scitt = new FakeScitt(ScittExpectation.keyNotFound("no key")); + CallerVerifier verifier = new CallerVerifier(scitt, DEFAULT_SKEW).withRootKeyRefresher(refresher); + StatusToken undated = new StatusToken(AGENT_ID, StatusToken.Status.ACTIVE, null, + Instant.now().plusSeconds(3600), ANS_NAME, + List.of(new CertificateInfo(certFingerprint, CertType.X509_EV_CLIENT)), List.of(), Map.of(), null); + + PopException ex = catchThrowableOfType(() -> verifier.verifyParsed( + proofJws, receipt(AGENT_ID, ANS_NAME), undated, + METHOD, URL, Map.of(), new CountingReplay(false), CallerOptions.none()), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.UNKNOWN_SIGNING_KEY); + assertThat(refresher.calls).isZero(); + } + + @Test + void refreshUsesTheReceiptIssueTimeWhenTheTokenHasNone() { + RecordingRefresher refresher = new RecordingRefresher(Optional.empty()); + FakeScitt scitt = new FakeScitt(ScittExpectation.keyNotFound("no key")); + CallerVerifier verifier = new CallerVerifier(scitt, DEFAULT_SKEW).withRootKeyRefresher(refresher); + StatusToken undated = new StatusToken(AGENT_ID, StatusToken.Status.ACTIVE, null, + Instant.now().plusSeconds(3600), ANS_NAME, + List.of(new CertificateInfo(certFingerprint, CertType.X509_EV_CLIENT)), List.of(), Map.of(), null); + CwtClaims claims = new CwtClaims("transparency-log.example.com", null, null, null, null, 1_787_529_600L); + ScittReceipt dated = new ScittReceipt(new CoseProtectedHeader(-7, null, 1, claims, null), null, null, + receipt(AGENT_ID, ANS_NAME).eventPayload(), null); + + catchThrowableOfType(() -> verifier.verifyParsed(proofJws, dated, undated, + METHOD, URL, Map.of(), new CountingReplay(false), CallerOptions.none()), PopException.class); + + assertThat(refresher.calls).isEqualTo(1); + assertThat(refresher.lastIssuedAt).isEqualTo(Instant.ofEpochSecond(1_787_529_600L)); + } + + @Test + void withRootKeyRefresherRejectsNull() { + assertThatNullPointerException().isThrownBy(() -> verifier().withRootKeyRefresher(null)); } @Test @@ -372,27 +480,57 @@ 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); + CountingSource source = new CountingSource(body); + CountingReplay replay = new CountingReplay(false); 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()); + METHOD, URL, Map.of(), replay, CallerOptions.none().withReceivedContent(source)); assertThat(identity.agentId()).isEqualTo(AGENT_ID); + assertThat(source.reads).isEqualTo(1); + assertThat(replay.calls).isEqualTo(1); } @Test - void contentBindingMismatchRejectedThroughCaller() throws Exception { + void contentBindingMismatchRejectedBeforeReplayIsRecorded() throws Exception { String proofWithContent = PopSigner.create((ECPrivateKey) keyPair.getPrivate(), cert.getEncoded()) .sign(METHOD, URL, "real-body".getBytes(StandardCharsets.UTF_8)); + CountingReplay replay = new CountingReplay(false); 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)))), + METHOD, URL, Map.of(), replay, + CallerOptions.none().withReceivedContent(() -> "tampered-body".getBytes(StandardCharsets.UTF_8))), PopException.class); assertThat(ex.category()).isEqualTo(ErrorType.CONTENT_BINDING_MISMATCH); + assertThat(replay.calls).isZero(); + } + + @Test + void contentIsNotReadWhenTheIdentityBindingFails() { + CountingSource source = new CountingSource(new byte[0]); + + catchThrowableOfType(() -> verifier().verifyParsed( + proofJws, receipt(AGENT_ID, ANS_NAME), token(ANS_NAME, AGENT_ID, "SHA256:deadbeef"), + METHOD, URL, Map.of(), new CountingReplay(false), + CallerOptions.none().withReceivedContent(source)), PopException.class); + + assertThat(source.reads).isZero(); + } + + @Test + void contentIsNotReadWhenTheStatusTokenFails() { + CountingSource source = new CountingSource(new byte[0]); + CallerVerifier verifier = new CallerVerifier(new FakeScitt(ScittExpectation.expired()), DEFAULT_SKEW); + + catchThrowableOfType(() -> verifier.verifyParsed( + proofJws, receipt(AGENT_ID, ANS_NAME), token(ANS_NAME, AGENT_ID, certFingerprint), + METHOD, URL, Map.of(), new CountingReplay(false), + CallerOptions.none().withReceivedContent(source)), PopException.class); + + assertThat(source.reads).isZero(); } @Test @@ -455,8 +593,11 @@ void ansHostRejectsMissingAuthority() { 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); + return new CallerVerifier(new FakeScitt(verifiedExpectation()), DEFAULT_SKEW); + } + + private static ScittExpectation verifiedExpectation() { + return ScittExpectation.verified(List.of(), List.of(), ANS_NAME, Map.of(), null); } private static StatusToken token(String ansName, String agentId, String identityFingerprint) { @@ -473,10 +614,6 @@ 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")); @@ -498,15 +635,21 @@ private static X509Certificate selfSigned(KeyPair keyPair, String ansUri) throws .getCertificate(builder.build(signer)); } + // Answers verify() with the given expectations in order, repeating the last one. private static final class FakeScitt implements ScittVerifier { - private final ScittExpectation expectation; + private final ScittExpectation[] expectations; + private int calls; + private Map lastKeys; - private FakeScitt(ScittExpectation expectation) { - this.expectation = expectation; + private FakeScitt(ScittExpectation... expectations) { + this.expectations = expectations; } @Override public ScittExpectation verify(ScittReceipt receipt, StatusToken token, Map rootKeys) { + lastKeys = rootKeys; + ScittExpectation expectation = expectations[Math.min(calls, expectations.length - 1)]; + calls++; return expectation; } @@ -517,6 +660,38 @@ public ScittVerificationResult postVerify(String hostname, X509Certificate serve } } + private static final class RecordingRefresher implements RootKeyRefresher { + private final Optional> result; + private int calls; + private Instant lastIssuedAt; + + private RecordingRefresher(Optional> result) { + this.result = result; + } + + @Override + public Optional> refresh(Instant artifactIssuedAt) { + calls++; + lastIssuedAt = artifactIssuedAt; + return result; + } + } + + private static final class CountingSource implements ContentSource { + private final byte[] content; + private int reads; + + private CountingSource(byte[] content) { + this.content = content; + } + + @Override + public byte[] read() { + reads++; + return content; + } + } + private static final class CountingReplay implements ReplayCache { private final boolean seen; private int calls; 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 fe5641f..66e5029 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 @@ -19,7 +19,10 @@ import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import java.io.IOException; import java.math.BigInteger; import java.nio.charset.StandardCharsets; import java.security.KeyPair; @@ -36,6 +39,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowableOfType; @@ -44,6 +48,7 @@ class DpopProofVerifierTest { private static final String METHOD = "POST"; private static final String URL = "https://api.example.com/agents"; + private static final String EMPTY_CONTENT_DIGEST = "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"; private static KeyPair keyA; private static KeyPair keyB; @@ -375,35 +380,69 @@ void rejectsNotYetValidCertificate() throws Exception { } @Test - void rejectsContentDigestWithoutOption() throws Exception { - String proof = signerA().sign(METHOD, URL, "body".getBytes(StandardCharsets.UTF_8)); + void rejectsMissingContentDigest() throws Exception { + Map claims = baseClaims(Instant.now()); + claims.remove("ans_content_digest"); + String proof = craft(claims, (ECPrivateKey) keyA.getPrivate()); PopException ex = catchThrowableOfType( - () -> verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), VerifyOptions.none()), + () -> verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), null), PopException.class); - assertThat(ex.category()).isEqualTo(ErrorType.CONTENT_BINDING_MISMATCH); + assertThat(ex.category()).isEqualTo(ErrorType.MALFORMED_PROOF); + assertThat(ex.getMessage()).contains("ans_content_digest"); + } + + @ParameterizedTest + @MethodSource("malformedContentDigests") + void rejectsMalformedContentDigest(String malformed) throws Exception { + Map claims = baseClaims(Instant.now()); + claims.put("ans_content_digest", malformed); + 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); + } + + static Stream malformedContentDigests() { + return Stream.of( + "not-base64url!", + Base64Url.encode(new byte[31]), + Base64Url.encode(new byte[33]), + EMPTY_CONTENT_DIGEST + "="); } @Test - void acceptsMissingContentWhenNotRequired() throws Exception { + void acceptsEmptyContentDigestForRequestWithoutContent() 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)); + VerifyOptions.none()); assertThat(result).isNotNull(); } @Test - void rejectsMissingContentWhenRequired() throws Exception { + void rejectsContentAddedToRequestSignedWithoutContent() 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()), + VerifyOptions.none().withReceivedContent(() -> "injected".getBytes(StandardCharsets.UTF_8))), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.CONTENT_BINDING_MISMATCH); + } + + @Test + void rejectsContentRemovedFromSignedRequest() 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); @@ -415,7 +454,7 @@ void acceptsMatchingContent() throws Exception { String proof = signerA().sign(METHOD, URL, body); ProofResult result = verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), - VerifyOptions.none().withContentSha256(sha256(body)).withRequiredContentBinding()); + VerifyOptions.none().withReceivedContent(() -> body)); assertThat(result).isNotNull(); } @@ -423,28 +462,81 @@ void acceptsMatchingContent() throws Exception { @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)), + VerifyOptions.none().withReceivedContent(() -> "tampered-body".getBytes(StandardCharsets.UTF_8))), PopException.class); assertThat(ex.category()).isEqualTo(ErrorType.CONTENT_BINDING_MISMATCH); } @Test - void rejectsBadLengthContentSha256() throws Exception { + void rejectsUnreadableContent() 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])), + VerifyOptions.none().withReceivedContent(() -> { + throw new IOException("connection reset"); + })), + PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.CONTENT_UNREADABLE); + } + + @Test + void nullContentFromSourceIsMisconfigured() throws Exception { + String proof = signerA().sign(METHOD, URL); + + PopException ex = catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), + VerifyOptions.none().withReceivedContent(() -> null)), PopException.class); assertThat(ex.category()).isEqualTo(ErrorType.MISCONFIGURED); } + @Test + void contentIsNotReadWhenAnEarlierCheckFails() throws Exception { + String proof = signerA().sign("GET", URL); + CountingSource source = new CountingSource(new byte[0]); + + catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, Instant.now(), null, cache(), + VerifyOptions.none().withReceivedContent(source)), + PopException.class); + + assertThat(source.reads).isZero(); + } + + @Test + void contentIsReadOnceBeforeReplayIsRecorded() throws Exception { + byte[] body = "body".getBytes(StandardCharsets.UTF_8); + String proof = signerA().sign(METHOD, URL, body); + CountingSource source = new CountingSource(body); + CapturingCache replay = new CapturingCache(); + + verifier.verify(proof, METHOD, URL, Instant.now(), null, replay, + VerifyOptions.none().withReceivedContent(source)); + + assertThat(source.reads).isEqualTo(1); + assertThat(replay.lastKey).isNotNull(); + } + + @Test + void contentMismatchDoesNotRecordReplay() throws Exception { + String proof = signerA().sign(METHOD, URL, "real".getBytes(StandardCharsets.UTF_8)); + CapturingCache replay = new CapturingCache(); + + catchThrowableOfType( + () -> verifier.verify(proof, METHOD, URL, Instant.now(), null, replay, + VerifyOptions.none().withReceivedContent(() -> "fake".getBytes(StandardCharsets.UTF_8))), + PopException.class); + + assertThat(replay.lastKey).isNull(); + } + @Test void acceptsAbsentProfile() throws Exception { Map claims = baseClaims(Instant.now()); @@ -499,6 +591,7 @@ private static Map baseClaims(Instant iat) { claims.put("htu", "https://api.example.com/agents"); claims.put("iat", iat.getEpochSecond()); claims.put("jti", "test-jti-" + iat.getEpochSecond()); + claims.put("ans_content_digest", EMPTY_CONTENT_DIGEST); return claims; } @@ -541,6 +634,21 @@ private static X509Certificate selfSigned(KeyPair pair, Date notBefore, Date not .getCertificate(builder.build(signer)); } + private static final class CountingSource implements ContentSource { + private final byte[] content; + private int reads; + + private CountingSource(byte[] content) { + this.content = content; + } + + @Override + public byte[] read() { + reads++; + return content; + } + } + private static final class CapturingCache implements ReplayCache { private String lastKey; private Duration lastTtl; 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 index c51cb7f..82714f2 100644 --- 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 @@ -16,6 +16,7 @@ import java.math.BigInteger; import java.net.URI; import java.net.http.HttpRequest; +import java.nio.charset.StandardCharsets; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.Security; @@ -23,14 +24,18 @@ import java.security.interfaces.ECPrivateKey; import java.security.spec.ECGenParameterSpec; import java.util.Date; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; class PopHttpTest { + private static final String EMPTY_CONTENT_DIGEST = "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"; + private static PopSigner signer; @BeforeAll @@ -109,6 +114,102 @@ void attachIdentityDoesNotSniffAuthorizationHeader() throws Exception { assertThat(decodeProof(builder).ath()).isNull(); } + @Test + void attachIdentityWithoutContentCarriesEmptyContentDigest() 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).ansContentDigest()).isEqualTo(EMPTY_CONTENT_DIGEST); + } + + @Test + void attachIdentityWithContentBindsDigestAndSetsBody() throws Exception { + byte[] body = "{\"task\":\"reconcile-ledger\",\"amount\":\"1000.00\"}".getBytes(StandardCharsets.UTF_8); + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create("https://api.example.com/x")) + .POST(HttpRequest.BodyPublishers.noBody()); + + PopHttp.attachIdentity(builder, signer, Map.of(), null, body); + + HttpRequest request = builder.build(); + assertThat(decodeProof(builder).ansContentDigest()).isEqualTo("wT8MhptL9zBd-WXZkYTjY7AHo1vNNfPYZzVifJEzPJc"); + assertThat(request.method()).isEqualTo("POST"); + assertThat(request.bodyPublisher()).map(HttpRequest.BodyPublisher::contentLength).contains((long) body.length); + } + + @Test + void attachIdentityWithContentAndTokenBindsBoth() throws Exception { + String token = "Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU"; + byte[] body = "payload".getBytes(StandardCharsets.UTF_8); + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create("https://api.example.com/x")) + .PUT(HttpRequest.BodyPublishers.noBody()); + + PopHttp.attachIdentity(builder, signer, Map.of(), token, body); + + Proof.Claims claims = decodeProof(builder); + assertThat(claims.ath()).isEqualTo(Proof.accessTokenHash(token)); + assertThat(claims.ansContentDigest()).isEqualTo(Proof.contentDigest(body)); + assertThat(claims.htm()).isEqualTo("PUT"); + } + + @Test + void attachIdentityRejectsRequestContentTheProofWouldNotBind() { + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create("https://api.example.com/x")) + .POST(HttpRequest.BodyPublishers.ofString("payload")); + + PopException ex = catchThrowableOfType( + () -> PopHttp.attachIdentity(builder, signer, Map.of(), null), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MISCONFIGURED); + } + + @Test + void attachIdentityReplacesExistingScittHeaders() throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create("https://api.example.com/x")) + .header(ScittHeaders.SCITT_RECEIPT_HEADER, "stale") + .header(PopHttp.DPOP_HEADER, "stale-proof") + .GET(); + + PopHttp.attachIdentity(builder, signer, Map.of(ScittHeaders.SCITT_RECEIPT_HEADER, List.of("fresh")), null); + + HttpRequest request = builder.build(); + assertThat(request.headers().allValues(ScittHeaders.SCITT_RECEIPT_HEADER)).containsExactly("fresh"); + assertThat(request.headers().allValues(PopHttp.DPOP_HEADER)).hasSize(1).doesNotContain("stale-proof"); + } + + @Test + void attachIdentityRejectsMultiValuedScittHeader() { + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create("https://api.example.com/x")) + .GET(); + Map> scitt = Map.of(ScittHeaders.SCITT_RECEIPT_HEADER, List.of("a", "b")); + + PopException ex = catchThrowableOfType( + () -> PopHttp.attachIdentity(builder, signer, scitt, null), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MISCONFIGURED); + } + + @Test + void attachIdentityRejectsScittHeaderWithoutValues() { + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create("https://api.example.com/x")) + .GET(); + Map> scitt = new HashMap<>(); + scitt.put(ScittHeaders.STATUS_TOKEN_HEADER, null); + + PopException ex = catchThrowableOfType( + () -> PopHttp.attachIdentity(builder, signer, scitt, null), PopException.class); + + assertThat(ex.category()).isEqualTo(ErrorType.MISCONFIGURED); + } + @Test void accessTokenFromAuthorizationParsesDpopScheme() { assertThat(PopHttp.accessTokenFromAuthorization("DPoP abc123")).contains("abc123"); 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 5358337..14a0f29 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 @@ -192,13 +192,26 @@ void signWithContentAddsDigest() throws Exception { } @Test - void signWithEmptyContentHasNoDigest() throws Exception { + void signWithEmptyContentCarriesEmptyContentDigest() 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(); + assertThat(claims.ansContentDigest()).isEqualTo("47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"); + } + + @Test + void signWithoutContentCarriesEmptyContentDigest() throws Exception { + PopSigner signer = PopSigner.create((ECPrivateKey) p256A.getPrivate(), certA.getEncoded()); + + Proof.Claims plain = Proof.parseClaims( + Proof.acceptES256DPoP(signer.sign("GET", "https://api.example.com/x")).jws().getPayload()); + Proof.Claims withToken = Proof.parseClaims( + Proof.acceptES256DPoP(signer.sign("GET", "https://api.example.com/x", "token")).jws().getPayload()); + + assertThat(plain.ansContentDigest()).isEqualTo("47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"); + assertThat(withToken.ansContentDigest()).isEqualTo("47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"); } @Test 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 8e5ffe6..8827d3d 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 @@ -19,8 +19,11 @@ import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.math.BigInteger; +import java.nio.charset.StandardCharsets; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.Security; @@ -320,6 +323,53 @@ void accessTokenHashKnownVector() { assertThat(Proof.accessTokenHash(token)).isEqualTo("fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo"); } + @Test + void contentDigestKnownVectors() { + byte[] body = "{\"task\":\"reconcile-ledger\",\"amount\":\"1000.00\"}".getBytes(StandardCharsets.UTF_8); + + assertThat(Proof.contentDigest(new byte[0])).isEqualTo("47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"); + assertThat(Proof.contentDigest(body)).isEqualTo("wT8MhptL9zBd-WXZkYTjY7AHo1vNNfPYZzVifJEzPJc"); + } + + @ParameterizedTest + @ValueSource(strings = { + "\"kid\":\"k1\"", + "\"use\":\"sig\",\"alg\":\"ES256\"", + "\"key_ops\":[\"verify\"]", + "\"x5u\":\"https://keys.example.com/agent.jwk\"", + "\"foo\":1" + }) + void acceptRejectsJwkWithExtraMembers(String extraMember) { + String bareJwk = "\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"" + jwkA.getX() + "\",\"y\":\"" + jwkA.getY() + "\""; + String headerJson = "{\"typ\":\"dpop+jwt\",\"alg\":\"ES256\",\"jwk\":{" + bareJwk + "," + extraMember + + "},\"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); + assertThat(ex.getMessage()).contains("exactly kty, crv, x, y"); + } + + @Test + void acceptRejectsJwkWithShortCoordinate() throws Exception { + KeyPair shortX = keyPairWithLeadingZeroX(); + ECKey full = publicEcJwk(shortX, Curve.P_256); + byte[] x = full.getX().decode(); + String shortXEncoded = Base64URL.encode(Arrays.copyOfRange(x, 1, x.length)).toString(); + String headerJson = "{\"typ\":\"dpop+jwt\",\"alg\":\"ES256\",\"jwk\":{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"" + + shortXEncoded + "\",\"y\":\"" + full.getY() + "\"},\"x5c\":[\"" + + Base64.encode(selfSigned(shortX, "SHA256withECDSA").getEncoded()) + "\"]}"; + 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); + assertThat(ex.getMessage()).contains("full-width"); + } + @Test void parseClaimsRoundTrips() throws Exception { Payload payload = new Payload(Map.of( @@ -420,6 +470,20 @@ private static ECKey publicEcJwk(KeyPair pair, Curve curve) { return new ECKey.Builder(curve, (ECPublicKey) pair.getPublic()).build().toPublicJWK(); } + // Roughly one P-256 key in 256 has an x coordinate whose full-width encoding + // starts with a zero byte, which is the only way to build an on-curve jwk with + // a 31-byte coordinate. + private static KeyPair keyPairWithLeadingZeroX() throws Exception { + for (int attempt = 0; attempt < 10_000; attempt++) { + KeyPair pair = ec("secp256r1"); + byte[] x = publicEcJwk(pair, Curve.P_256).getX().decode(); + if (x.length == 32 && x[0] == 0) { + return pair; + } + } + throw new AssertionError("no P-256 key with a leading-zero x coordinate found"); + } + private static ECPublicKey fixedCoordinateKey(BigInteger x, BigInteger y) { ECPoint point = new ECPoint(x, y); return new ECPublicKey() { 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 index f7fb3dc..9ad45a6 100644 --- 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 @@ -12,39 +12,42 @@ void noneHasNoBindings() { VerifyOptions options = VerifyOptions.none(); assertThat(options.accessToken()).isNull(); - assertThat(options.contentSha256()).isNull(); - assertThat(options.requireContentBinding()).isFalse(); + assertThat(options.receivedContent()).isNull(); } @Test - void withContentSha256CopiesArray() { - byte[] hash = new byte[32]; - hash[0] = 1; - VerifyOptions options = VerifyOptions.none().withContentSha256(hash); + void withAccessTokenSetsTokenOnly() { + VerifyOptions options = VerifyOptions.withAccessToken("token"); - hash[0] = 2; + assertThat(options.accessToken()).isEqualTo("token"); + assertThat(options.receivedContent()).isNull(); + } - assertThat(options.contentSha256()[0]).isEqualTo((byte) 1); + @Test + void withReceivedContentSetsSource() throws Exception { + byte[] body = {1, 2, 3}; + ContentSource source = () -> body; + VerifyOptions options = VerifyOptions.none().withReceivedContent(source); + + assertThat(options.receivedContent()).isSameAs(source); + assertThat(options.receivedContent().read()).isEqualTo(body); } @Test - void withContentSha256PreservesAccessToken() { - VerifyOptions options = VerifyOptions.withAccessToken("token").withContentSha256(new byte[32]); + void withReceivedContentPreservesAccessToken() { + VerifyOptions options = VerifyOptions.withAccessToken("token").withReceivedContent(() -> new byte[0]); assertThat(options.accessToken()).isEqualTo("token"); - assertThat(options.contentSha256()).hasSize(32); + assertThat(options.receivedContent()).isNotNull(); } @Test - void withRequiredContentBindingSetsFlag() { - VerifyOptions options = VerifyOptions.none().withContentSha256(new byte[32]).withRequiredContentBinding(); - - assertThat(options.requireContentBinding()).isTrue(); - assertThat(options.contentSha256()).hasSize(32); + void withReceivedContentRejectsNull() { + assertThatNullPointerException().isThrownBy(() -> VerifyOptions.none().withReceivedContent(null)); } @Test - void withContentSha256RejectsNull() { - assertThatNullPointerException().isThrownBy(() -> VerifyOptions.none().withContentSha256(null)); + void withAccessTokenRejectsNull() { + assertThatNullPointerException().isThrownBy(() -> VerifyOptions.withAccessToken(null)); } -} \ No newline at end of file +} From d2ebbe9d7b048a0b5776950ee8727e134900a245 Mon Sep 17 00:00:00 2001 From: kperry Date: Thu, 17 Sep 2026 16:30:12 -0500 Subject: [PATCH 23/23] fix(pop-spring): bind request content, single-source htu authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PopAuthenticationFilter hands CallerVerifier a bounded ContentSource, so the request body is read once, on the verifier's demand after the SCITT binding (ANS-6 §7.4 step 12), and is then replayed to the handler through a request wrapper. Content above the bound (Builder withMaxContentBytes, default 1 MiB) is rejected with 413 before it is hashed. Previously the filter never supplied content, so every proof that carried ans_content_digest was rejected and body-bearing requests were accepted with the body unbound. - The trusted-authority check reads the same URL the proof's htu is compared against instead of the raw Host header (§7.7), so forwarded header processing can no longer make the two diverge. The withTrustedHosts Javadoc now says that behind a TLS-terminating proxy the container's URL is the proxy hop, and withExternalUrl or trusted forwarded-header handling is required. - Builder gains withVerifier(CallerVerifier), mutually exclusive with withPoPSkew, and withRootKeyRefresher(RootKeyRefresher). The example wires the refresher to TransparencyClient.refreshRootKeysIfNeeded. Addresses the review on PR #108. Assisted-by: Claude Code (claude-fable-5-1) Co-Authored-By: Claude Fable 5.1 Signed-off-by: kperry --- .../dpopscittauth/PopSecurityConfig.java | 12 + .../pop/spring/PopAuthenticationFilter.java | 243 +++++++++++++-- .../spring/PopAuthenticationFilterTest.java | 281 +++++++++++++++++- 3 files changed, 503 insertions(+), 33 deletions(-) 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 dbe7450..37ce3c6 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 @@ -4,6 +4,7 @@ import com.godaddy.ans.sdk.pop.ReplayCache; import com.godaddy.ans.sdk.pop.spring.PopAuthenticationFilter; import com.godaddy.ans.sdk.transparency.TransparencyClient; +import com.godaddy.ans.sdk.transparency.scitt.RefreshDecision; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.ApplicationRunner; import org.springframework.boot.web.servlet.FilterRegistrationBean; @@ -11,7 +12,9 @@ import org.springframework.context.annotation.Configuration; import java.security.PublicKey; +import java.time.Instant; import java.util.Map; +import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.function.Supplier; @@ -49,10 +52,19 @@ public FilterRegistrationBean popAuthenticationFilter( PopAuthenticationFilter filter = PopAuthenticationFilter .builder(expectedIssuer, rootKeys, replayCache) .withTrustedHosts(trustedHost) + .withRootKeyRefresher(artifactIssuedAt -> refreshRootKeys(transparencyClient, artifactIssuedAt)) .build(); FilterRegistrationBean registration = new FilterRegistrationBean<>(filter); registration.addUrlPatterns("/*"); return registration; } + + private static Optional> refreshRootKeys(TransparencyClient client, + Instant artifactIssuedAt) { + RefreshDecision decision = client.refreshRootKeysIfNeeded(artifactIssuedAt) + .orTimeout(2, TimeUnit.SECONDS) + .join(); + return decision.isRefreshed() ? Optional.of(decision.keys()) : Optional.empty(); + } } \ No newline at end of file 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 cbbd802..29ad432 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 @@ -4,21 +4,31 @@ 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.ContentSource; 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.pop.RootKeyRefresher; import com.godaddy.ans.sdk.transparency.scitt.StatusToken; import jakarta.servlet.FilterChain; +import jakarta.servlet.ReadListener; import jakarta.servlet.ServletException; +import jakarta.servlet.ServletInputStream; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.filter.OncePerRequestFilter; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStreamReader; import java.net.URI; import java.net.URISyntaxException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.security.PublicKey; import java.time.Duration; import java.util.ArrayList; @@ -33,6 +43,13 @@ public final class PopAuthenticationFilter extends OncePerRequestFilter { + /** + * Default bound on the request content read for {@code ans_content_digest} + * verification (ANS-6 §7.13): 1 MiB. + */ + public static final long DEFAULT_MAX_CONTENT_BYTES = 1024 * 1024; + + private static final int SC_CONTENT_TOO_LARGE = 413; private static final Logger LOG = LoggerFactory.getLogger(PopAuthenticationFilter.class); private final CallerVerifier verifier; @@ -40,16 +57,18 @@ public final class PopAuthenticationFilter extends OncePerRequestFilter { private final ReplayCache replay; private final Function externalUrl; private final CallerPolicy policy; + private final long maxContentBytes; // 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, - CallerPolicy policy) { + ReplayCache replay, Function externalUrl, + CallerPolicy policy, long maxContentBytes) { this.verifier = verifier; this.rootKeys = rootKeys; this.replay = replay; this.externalUrl = externalUrl; this.policy = policy; + this.maxContentBytes = maxContentBytes; } public static Builder builder(String expectedIssuer, Supplier> rootKeys, @@ -69,7 +88,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse return; } - if (!checkAuthority(request)) { + String url = resolveUrl(request); + if (!checkAuthority(url)) { LOG.info("caller rejected: HTTP_BINDING_MISMATCH - request authority is not trusted"); reject(response); return; @@ -82,7 +102,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse return; } - CallerOptions options = CallerOptions.none(); + BoundedBody body = new BoundedBody(request, maxContentBytes); + CallerOptions options = CallerOptions.none().withReceivedContent(body); Optional accessToken = PopHttp.accessTokenFromAuthorization(request.getHeader("Authorization")); if (accessToken.isPresent()) { options = options.withAccessToken(accessToken.get()); @@ -99,9 +120,13 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse CallerIdentity identity; try { - identity = verifier.verifyCaller(proof, headers, request.getMethod(), - resolveUrl(request), keys, replay, options); + identity = verifier.verifyCaller(proof, headers, request.getMethod(), url, keys, replay, options); } catch (PopException e) { + if (e.getCause() instanceof ContentTooLargeException) { + LOG.info("caller rejected: request content exceeds {} bytes", maxContentBytes); + response.sendError(SC_CONTENT_TOO_LARGE, "payload too large"); + return; + } LOG.info("caller rejected: {} - {}", e.category(), e.getMessage()); reject(response); return; @@ -118,35 +143,31 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } request.setAttribute(PopAuthentication.CALLER_ATTRIBUTE, identity); - filterChain.doFilter(request, response); + filterChain.doFilter(body.replay(request), response); } - private boolean checkAuthority(HttpServletRequest request) { + private boolean checkAuthority(String url) { if (policy.trustsAnyAuthority()) { return true; } - return policy.authorityTrusted(deriveAuthority(request)); + return policy.authorityTrusted(authorityOf(url)); } - private String deriveAuthority(HttpServletRequest request) { - if (externalUrl != null) { - try { - return new URI(externalUrl.apply(request)).getAuthority(); - } catch (URISyntaxException e) { - return null; - } + // The allowlist is checked on the same URL the proof's htu is compared against, so + // the two can never read different sources (ANS-6 §7.7). + private static String authorityOf(String url) { + try { + return new URI(url).getAuthority(); + } catch (URISyntaxException e) { + return null; } - String authority = request.getHeader("Host"); - return authority != null ? authority : request.getServerName(); } private String resolveUrl(HttpServletRequest request) { if (externalUrl != null) { return externalUrl.apply(request); } - StringBuffer url = request.getRequestURL(); - String query = request.getQueryString(); - return query == null ? url.toString() : url.append('?').append(query).toString(); + return request.getRequestURL().toString(); } private static Map> headerMap(HttpServletRequest request) { @@ -169,6 +190,117 @@ private static void reject(HttpServletResponse response) throws IOException { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "unauthorized"); } + /** + * Reads the request content once, bounded, when the verifier asks for it + * (ANS-6 §7.4 step 12), and afterwards hands the handler a request that + * replays the same bytes. + */ + static final class BoundedBody implements ContentSource { + + private final HttpServletRequest request; + private final long maxBytes; + private byte[] content; + + BoundedBody(HttpServletRequest request, long maxBytes) { + this.request = request; + this.maxBytes = maxBytes; + } + + @Override + public byte[] read() throws IOException { + if (content == null) { + if (request.getContentLengthLong() > maxBytes) { + throw new ContentTooLargeException(maxBytes); + } + byte[] read = request.getInputStream().readNBytes((int) maxBytes + 1); + if (read.length > maxBytes) { + throw new ContentTooLargeException(maxBytes); + } + content = read; + } + return content; + } + + HttpServletRequest replay(HttpServletRequest original) { + return content == null ? original : new BufferedContentRequest(original, content); + } + } + + static final class ContentTooLargeException extends IOException { + + private static final long serialVersionUID = 1L; + + ContentTooLargeException(long maxBytes) { + super("request content exceeds " + maxBytes + " bytes"); + } + } + + static final class BufferedContentRequest extends HttpServletRequestWrapper { + + private final byte[] content; + + BufferedContentRequest(HttpServletRequest request, byte[] content) { + super(request); + this.content = content; + } + + @Override + public ServletInputStream getInputStream() { + return new ByteArrayServletInputStream(content); + } + + @Override + public BufferedReader getReader() { + String encoding = getCharacterEncoding(); + Charset charset = encoding != null ? Charset.forName(encoding) : StandardCharsets.ISO_8859_1; + return new BufferedReader(new InputStreamReader(getInputStream(), charset)); + } + + @Override + public int getContentLength() { + return content.length; + } + + @Override + public long getContentLengthLong() { + return content.length; + } + } + + private static final class ByteArrayServletInputStream extends ServletInputStream { + + private final ByteArrayInputStream in; + + ByteArrayServletInputStream(byte[] content) { + this.in = new ByteArrayInputStream(content); + } + + @Override + public int read() { + return in.read(); + } + + @Override + public int read(byte[] b, int off, int len) { + return in.read(b, off, len); + } + + @Override + public boolean isFinished() { + return in.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener listener) { + throw new UnsupportedOperationException("asynchronous reads are not supported"); + } + } + public static final class Builder { private final String expectedIssuer; @@ -178,6 +310,9 @@ public static final class Builder { private boolean trustedHostsSet; private Function externalUrl; private Duration popSkew; + private CallerVerifier verifier; + private RootKeyRefresher rootKeyRefresher; + private long maxContentBytes = DEFAULT_MAX_CONTENT_BYTES; private Builder(String expectedIssuer, Supplier> rootKeys, ReplayCache replay) { this.expectedIssuer = Objects.requireNonNull(expectedIssuer, "expectedIssuer"); @@ -187,7 +322,8 @@ private Builder(String expectedIssuer, Supplier> rootKeys /** * 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 {@code htu} (HTTP target URI) that PoP proofs bind to, and checks its authority + * against the trusted hosts when both are configured. * *

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 @@ -204,12 +340,14 @@ public Builder withExternalUrl(Function externalUrl) } /** - * Validates the {@code htu} host against a fixed list of trusted hosts. + * Validates the request authority against a fixed list of trusted hosts. * - *

Warning: if you set only trusted hosts and do not set {@link #withExternalUrl}, - * the {@code htu} authority comes from the client-controlled {@code Host} header. For - * proxied or multi-tenant deployments, use {@link #withExternalUrl} to pin the authority - * independent of client headers. + *

Without {@link #withExternalUrl}, both the authority and the {@code htu} the proof + * binds to come from the container's view of the request URL (scheme, server name and + * port). Behind a TLS-terminating proxy that view is the proxy-to-application hop, so + * callers that signed the public {@code https} URL fail the {@code htu} check; use + * {@link #withExternalUrl}, or configure forwarded-header handling that only trusts the + * proxy, for such deployments. * * @param hosts trusted host authorities * @return this builder @@ -232,17 +370,58 @@ public Builder withPoPSkew(Duration popSkew) { return this; } + /** + * Uses a pre-built verifier, for a non-default SCITT clock skew or an injected SCITT + * verifier. Mutually exclusive with {@link #withPoPSkew}, which configures the default + * verifier. + */ + public Builder withVerifier(CallerVerifier verifier) { + this.verifier = Objects.requireNonNull(verifier, "verifier"); + return this; + } + + /** + * Refreshes the Transparency Log root keys once, and retries verification, when a receipt + * or status token is signed by a key the callee does not hold (ANS-6 §4.5, §9.5). + */ + public Builder withRootKeyRefresher(RootKeyRefresher refresher) { + this.rootKeyRefresher = Objects.requireNonNull(refresher, "refresher"); + return this; + } + + /** + * Bounds the request content read for {@code ans_content_digest} verification. Larger + * requests are rejected with 413 before any content is hashed. Defaults to + * {@link #DEFAULT_MAX_CONTENT_BYTES}. + */ + public Builder withMaxContentBytes(long maxContentBytes) { + if (maxContentBytes < 1 || maxContentBytes >= Integer.MAX_VALUE) { + throw new IllegalArgumentException( + "maxContentBytes must be between 1 and " + (Integer.MAX_VALUE - 1)); + } + this.maxContentBytes = maxContentBytes; + return this; + } + public PopAuthenticationFilter build() { if (externalUrl == null && !trustedHostsSet) { throw new IllegalStateException( "htu would be derived from the client-controlled Host header; " + "call withExternalUrl(...) or withTrustedHosts(...) before build()"); } - CallerVerifier verifier = popSkew != null - ? CallerVerifier.create(expectedIssuer, StatusToken.DEFAULT_CLOCK_SKEW, popSkew) - : CallerVerifier.create(expectedIssuer); - return new PopAuthenticationFilter(verifier, rootKeys, replay, externalUrl, policy.build()); + if (verifier != null && popSkew != null) { + throw new IllegalStateException("withVerifier(...) and withPoPSkew(...) are mutually exclusive"); + } + CallerVerifier effective = verifier != null ? verifier + : popSkew != null + ? CallerVerifier.create(expectedIssuer, StatusToken.DEFAULT_CLOCK_SKEW, popSkew) + : CallerVerifier.create(expectedIssuer); + if (rootKeyRefresher != null) { + effective = effective.withRootKeyRefresher(rootKeyRefresher); + } + return new PopAuthenticationFilter(effective, rootKeys, replay, externalUrl, policy.build(), + maxContentBytes); } } -} \ No newline at end of file +} 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 82cec63..97f4358 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 @@ -1,22 +1,28 @@ 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.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.pop.RootKeyRefresher; import com.godaddy.ans.sdk.transparency.scitt.ScittHeaders; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; 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.io.IOException; +import java.nio.charset.StandardCharsets; import java.security.PublicKey; import java.time.Duration; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.function.Function; import java.util.function.Supplier; @@ -25,8 +31,11 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.assertj.core.api.Assertions.assertThatNullPointerException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -55,6 +64,22 @@ private static CallerVerifier verifierRejecting() throws PopException { return verifier; } + // Mimics the real verifier's step 12: reads the supplied content source, and reports a read + // failure as CONTENT_UNREADABLE with the I/O cause attached. + private static CallerVerifier verifierReadingContent(CallerIdentity identity) throws PopException { + CallerVerifier verifier = mock(CallerVerifier.class); + when(verifier.verifyCaller(any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation -> { + CallerOptions options = invocation.getArgument(6); + try { + options.receivedContent().read(); + } catch (IOException e) { + throw new PopException(ErrorType.CONTENT_UNREADABLE, "request content could not be read", e); + } + return identity; + }); + return verifier; + } + private static PopAuthenticationFilter filter(CallerVerifier verifier, Set trustedHosts, Set allowedHosts) { return filter(verifier, ROOT_KEYS, null, trustedHosts, allowedHosts); @@ -64,11 +89,29 @@ private static PopAuthenticationFilter filter(CallerVerifier verifier, Supplier> rootKeys, Function externalUrl, Set trustedHosts, Set allowedHosts) { + return filter(verifier, rootKeys, externalUrl, trustedHosts, allowedHosts, + PopAuthenticationFilter.DEFAULT_MAX_CONTENT_BYTES); + } + + private static PopAuthenticationFilter filter(CallerVerifier verifier, + Supplier> rootKeys, + Function externalUrl, + Set trustedHosts, Set allowedHosts, + long maxContentBytes) { 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); + return new PopAuthenticationFilter(verifier, rootKeys, REPLAY, externalUrl, policy, maxContentBytes); + } + + private static HttpServletRequest withRequestUrl(HttpServletRequest request, String url) { + return new HttpServletRequestWrapper(request) { + @Override + public StringBuffer getRequestURL() { + return new StringBuffer(url); + } + }; } private static MockHttpServletRequest request() { @@ -125,6 +168,165 @@ void passesAccessTokenFromAuthorizationHeader() throws Exception { assertThat(chain.getRequest()).isSameAs(request); } + // ==================== doFilterInternal - request content ==================== + + @Test + void readsContentOnVerifierDemandAndReplaysItDownstream() throws Exception { + PopAuthenticationFilter filter = filter(verifierReadingContent(identity(ANS_NAME)), Set.of(), Set.of()); + MockHttpServletRequest request = request(); + request.setContent("hello".getBytes(StandardCharsets.UTF_8)); + request.setCharacterEncoding("UTF-8"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + HttpServletRequest downstream = (HttpServletRequest) chain.getRequest(); + assertThat(downstream).isNotSameAs(request); + assertThat(downstream.getInputStream().readAllBytes()).isEqualTo("hello".getBytes(StandardCharsets.UTF_8)); + assertThat(downstream.getReader().readLine()).isEqualTo("hello"); + assertThat(downstream.getContentLength()).isEqualTo(5); + assertThat(downstream.getContentLengthLong()).isEqualTo(5L); + assertThat(PopAuthentication.fromRequest(downstream)).map(CallerIdentity::ansName).contains(ANS_NAME); + } + + @Test + void replayedReaderDefaultsToLatin1WithoutARequestEncoding() throws Exception { + PopAuthenticationFilter filter = filter(verifierReadingContent(identity(ANS_NAME)), Set.of(), Set.of()); + MockHttpServletRequest request = request(); + request.setContent("héllo".getBytes(StandardCharsets.ISO_8859_1)); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + HttpServletRequest downstream = (HttpServletRequest) chain.getRequest(); + assertThat(downstream.getReader().readLine()).isEqualTo("héllo"); + assertThat(downstream.getInputStream().isFinished()).isFalse(); + assertThat(downstream.getInputStream().isReady()).isTrue(); + } + + @Test + void replayedRequestWithoutContentReportsFinishedStream() throws Exception { + PopAuthenticationFilter filter = filter(verifierReadingContent(identity(ANS_NAME)), Set.of(), Set.of()); + MockHttpServletRequest request = request(); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + HttpServletRequest downstream = (HttpServletRequest) chain.getRequest(); + assertThat(downstream.getInputStream().isFinished()).isTrue(); + assertThat(downstream.getInputStream().read()).isEqualTo(-1); + assertThat(downstream.getContentLength()).isZero(); + assertThatThrownBy(() -> downstream.getInputStream().setReadListener(null)) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void contentIsReadFromTheContainerOnlyOnce() throws Exception { + CallerVerifier verifier = mock(CallerVerifier.class); + when(verifier.verifyCaller(any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation -> { + CallerOptions options = invocation.getArgument(6); + byte[] first = options.receivedContent().read(); + byte[] second = options.receivedContent().read(); + assertThat(second).isSameAs(first); + return identity(ANS_NAME); + }); + PopAuthenticationFilter filter = filter(verifier, Set.of(), Set.of()); + MockHttpServletRequest request = request(); + request.setContent("once".getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertThat(chain.getRequest().getInputStream().readAllBytes()) + .isEqualTo("once".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void rejectsWhenVerifierFailsUnexpectedly() throws Exception { + CallerVerifier verifier = mock(CallerVerifier.class); + when(verifier.verifyCaller(any(), any(), any(), any(), any(), any(), any())) + .thenThrow(new IllegalStateException("boom")); + PopAuthenticationFilter filter = filter(verifier, 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 rejectsContentAboveDeclaredBoundWith413() throws Exception { + PopAuthenticationFilter filter = filter( + verifierReadingContent(identity(ANS_NAME)), ROOT_KEYS, null, Set.of(), Set.of(), 4); + MockHttpServletRequest request = request(); + request.setContent("hello".getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertThat(response.getStatus()).isEqualTo(413); + assertThat(response.getErrorMessage()).isEqualTo("payload too large"); + assertThat(chain.getRequest()).isNull(); + } + + @Test + void rejectsContentAboveBoundWithoutDeclaredLengthWith413() throws Exception { + PopAuthenticationFilter filter = filter( + verifierReadingContent(identity(ANS_NAME)), ROOT_KEYS, null, Set.of(), Set.of(), 4); + MockHttpServletRequest request = request(); + request.setContent("hello".getBytes(StandardCharsets.UTF_8)); + HttpServletRequest unknownLength = new HttpServletRequestWrapper(request) { + @Override + public long getContentLengthLong() { + return -1; + } + }; + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(unknownLength, response, chain); + + assertThat(response.getStatus()).isEqualTo(413); + assertThat(chain.getRequest()).isNull(); + } + + @Test + void acceptsContentAtTheBound() throws Exception { + PopAuthenticationFilter filter = filter( + verifierReadingContent(identity(ANS_NAME)), ROOT_KEYS, null, Set.of(), Set.of(), 5); + MockHttpServletRequest request = request(); + request.setContent("hello".getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertThat(chain.getRequest()).isNotNull(); + assertThat(chain.getRequest().getInputStream().readAllBytes()).hasSize(5); + } + + @Test + void rejectsUnreadableContentWith401() throws Exception { + CallerVerifier verifier = mock(CallerVerifier.class); + when(verifier.verifyCaller(any(), any(), any(), any(), any(), any(), any())) + .thenThrow(new PopException(ErrorType.CONTENT_UNREADABLE, "reset", new IOException("reset"))); + PopAuthenticationFilter filter = filter(verifier, Set.of(), Set.of()); + MockHttpServletRequest request = request(); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertRejected(response, chain); + } + // ==================== doFilterInternal - rejections ==================== @Test @@ -182,6 +384,36 @@ void trustsServerNameWhenHostHeaderAbsent() throws Exception { assertThat(chain.getRequest()).isSameAs(request); } + @Test + void checksAuthorityOnTheUrlHandedToTheVerifierNotTheHostHeader() 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(withRequestUrl(request, "https://evil.example.com/verify"), response, chain); + + assertRejected(response, chain); + } + + @Test + void trustsTheRequestUrlAuthorityEvenWhenTheHostHeaderDiffers() throws Exception { + CallerVerifier verifier = verifierReturning(identity(ANS_NAME)); + PopAuthenticationFilter filter = filter(verifier, Set.of("gateway.example.com"), Set.of()); + MockHttpServletRequest request = request(); + request.addHeader("Host", "evil.example.com"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(withRequestUrl(request, "https://gateway.example.com/verify"), response, chain); + + assertThat(chain.getRequest()).isNotNull(); + verify(verifier).verifyCaller(any(), any(), eq("POST"), eq("https://gateway.example.com/verify"), + any(), any(), any()); + } + @Test void rejectsWhenProofMissing() throws Exception { PopAuthenticationFilter filter = filter(verifierReturning(identity(ANS_NAME)), Set.of(), Set.of()); @@ -379,6 +611,51 @@ void builderRejectsWhenAllTrustedHostsEmpty() { .withMessageContaining("every supplied host was empty"); } + @Test + void builderUsesSuppliedVerifier() { + CallerVerifier verifier = CallerVerifier.create("issuer.example.com", Duration.ofSeconds(30), + Duration.ofSeconds(90)); + PopAuthenticationFilter filter = PopAuthenticationFilter + .builder("issuer.example.com", ROOT_KEYS, REPLAY) + .withTrustedHosts("rp.example.com:443") + .withVerifier(verifier) + .build(); + + assertThat(filter).isNotNull(); + } + + @Test + void builderRejectsVerifierCombinedWithPopSkew() { + assertThatIllegalStateException().isThrownBy(() -> PopAuthenticationFilter + .builder("issuer.example.com", ROOT_KEYS, REPLAY) + .withTrustedHosts("rp.example.com:443") + .withVerifier(CallerVerifier.create("issuer.example.com")) + .withPoPSkew(Duration.ofSeconds(30)) + .build()) + .withMessageContaining("mutually exclusive"); + } + + @Test + void builderAcceptsRootKeyRefresherAndContentBound() { + RootKeyRefresher refresher = artifactIssuedAt -> Optional.empty(); + PopAuthenticationFilter filter = PopAuthenticationFilter + .builder("issuer.example.com", ROOT_KEYS, REPLAY) + .withTrustedHosts("rp.example.com:443") + .withRootKeyRefresher(refresher) + .withMaxContentBytes(64) + .build(); + + assertThat(filter).isNotNull(); + } + + @Test + void builderRejectsOutOfRangeMaxContentBytes() { + PopAuthenticationFilter.Builder builder = PopAuthenticationFilter.builder("issuer", ROOT_KEYS, REPLAY); + + assertThatIllegalArgumentException().isThrownBy(() -> builder.withMaxContentBytes(0)); + assertThatIllegalArgumentException().isThrownBy(() -> builder.withMaxContentBytes(Integer.MAX_VALUE)); + } + @Test void builderRejectsWhenNeitherExternalUrlNorTrustedHostsSet() { assertThatIllegalStateException().isThrownBy(() -> PopAuthenticationFilter @@ -405,6 +682,8 @@ void builderRejectsNullSetters() { assertThatNullPointerException().isThrownBy(() -> builder.withExternalUrl(null)); assertThatNullPointerException().isThrownBy(() -> builder.withAllowedAnsNames((String) null)); assertThatNullPointerException().isThrownBy(() -> builder.withPoPSkew(null)); + assertThatNullPointerException().isThrownBy(() -> builder.withVerifier(null)); + assertThatNullPointerException().isThrownBy(() -> builder.withRootKeyRefresher(null)); } // Sanity: the SCITT header names the filter guards against duplicates for are what we expect.