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-pop-spring/examples/dpop-scitt-auth/README.md b/ans-sdk-pop-spring/examples/dpop-scitt-auth/README.md new file mode 100644 index 0000000..b5550fe --- /dev/null +++ b/ans-sdk-pop-spring/examples/dpop-scitt-auth/README.md @@ -0,0 +1,62 @@ +# DPoP-SCITT Example + +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. + +## 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 `/*`. 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-pop-spring:examples:dpop-scitt-auth: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-pop-spring:examples:dpop-scitt-auth:runClient \ + --args="https://server.example.com:8443/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-pop-spring/examples/dpop-scitt-auth/build.gradle.kts b/ans-sdk-pop-spring/examples/dpop-scitt-auth/build.gradle.kts new file mode 100644 index 0000000..60ae94c --- /dev/null +++ b/ans-sdk-pop-spring/examples/dpop-scitt-auth/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + id("org.springframework.boot") version "4.1.0" + id("io.spring.dependency-management") version "1.1.7" +} + +dependencies { + 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.dpopscittauth.PopClientExample") + classpath = sourceSets["main"].runtimeClasspath +} \ No newline at end of file diff --git a/ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/PopClientExample.java b/ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/PopClientExample.java new file mode 100644 index 0000000..d7c9498 --- /dev/null +++ b/ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/PopClientExample.java @@ -0,0 +1,85 @@ +package com.godaddy.ans.examples.dpopscittauth; + +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/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 - DPOP-SCITT 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-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 new file mode 100644 index 0000000..dbe7450 --- /dev/null +++ b/ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/PopSecurityConfig.java @@ -0,0 +1,58 @@ +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.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 +public class PopSecurityConfig { + + @Bean + public TransparencyClient transparencyClient(@Value("${pop.tl-url:}") String tlUrl) { + if (tlUrl != null && !tlUrl.isBlank()) { + return TransparencyClient.builder().baseUrl(tlUrl).build(); + } + return TransparencyClient.createOte(); + } + + @Bean + public ReplayCache replayCache() { + return CaffeineReplayCache.create(100_000); + } + + @Bean + ApplicationRunner warmRootKeys(TransparencyClient client) { + return args -> client.getRootKeysAsync().join(); + } + + @Bean + public FilterRegistrationBean popAuthenticationFilter( + TransparencyClient transparencyClient, + ReplayCache replayCache, + @Value("${pop.expected-issuer}") String expectedIssuer, + @Value("${pop.trusted-host}") String trustedHost) { + + Supplier> rootKeys = + () -> transparencyClient.getRootKeysAsync().orTimeout(2, TimeUnit.SECONDS).join(); + + PopAuthenticationFilter filter = PopAuthenticationFilter + .builder(expectedIssuer, rootKeys, replayCache) + .withTrustedHosts(trustedHost) + .build(); + + FilterRegistrationBean registration = new FilterRegistrationBean<>(filter); + registration.addUrlPatterns("/*"); + return registration; + } +} \ No newline at end of file diff --git a/ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/PopServerApplication.java b/ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/PopServerApplication.java new file mode 100644 index 0000000..0c36266 --- /dev/null +++ b/ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/PopServerApplication.java @@ -0,0 +1,12 @@ +package com.godaddy.ans.examples.dpopscittauth; + +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-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/ProtectedController.java b/ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/ProtectedController.java new file mode 100644 index 0000000..73112e1 --- /dev/null +++ b/ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/java/com/godaddy/ans/examples/dpopscittauth/ProtectedController.java @@ -0,0 +1,32 @@ +package com.godaddy.ans.examples.dpopscittauth; + +import com.godaddy.ans.sdk.pop.CallerIdentity; +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; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; +import java.util.Optional; + +@RestController +@RequestMapping("/") +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-pop-spring/examples/dpop-scitt-auth/src/main/resources/application.yml b/ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/resources/application.yml new file mode 100644 index 0000000..f6cffd1 --- /dev/null +++ b/ans-sdk-pop-spring/examples/dpop-scitt-auth/src/main/resources/application.yml @@ -0,0 +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: ${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/ans-sdk-pop-spring/src/main/java/com/godaddy/ans/sdk/pop/spring/PopAuthentication.java b/ans-sdk-pop-spring/src/main/java/com/godaddy/ans/sdk/pop/spring/PopAuthentication.java new file mode 100644 index 0000000..5854b6e --- /dev/null +++ b/ans-sdk-pop-spring/src/main/java/com/godaddy/ans/sdk/pop/spring/PopAuthentication.java @@ -0,0 +1,24 @@ +package com.godaddy.ans.sdk.pop.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-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 new file mode 100644 index 0000000..433510e --- /dev/null +++ b/ans-sdk-pop-spring/src/main/java/com/godaddy/ans/sdk/pop/spring/PopAuthenticationFilter.java @@ -0,0 +1,237 @@ +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.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.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.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +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 final CallerVerifier verifier; + private final Supplier> rootKeys; + private final ReplayCache replay; + private final Function externalUrl; + private final CallerPolicy policy; + + // 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) { + this.verifier = verifier; + this.rootKeys = rootKeys; + this.replay = replay; + this.externalUrl = externalUrl; + this.policy = policy; + } + + 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 { + 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)) { + 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, headers, request.getMethod(), + resolveUrl(request), keys, replay, options); + } catch (PopException e) { + LOG.info("caller rejected: {} - {}", e.category(), e.getMessage()); + reject(response); + return; + } catch (RuntimeException e) { + LOG.error("caller rejected: unexpected verification error", e); + reject(response); + return; + } + + if (!policy.callerAllowed(identity)) { + LOG.info("caller rejected: EXPECTED_PEER_MISMATCH - caller ans host is not in the accepted set"); + reject(response); + return; + } + + request.setAttribute(PopAuthentication.CALLER_ATTRIBUTE, identity); + filterChain.doFilter(request, response); + } + + private boolean checkAuthority(HttpServletRequest request) { + if (policy.trustsAnyAuthority()) { + return true; + } + return policy.authorityTrusted(deriveAuthority(request)); + } + + private String deriveAuthority(HttpServletRequest request) { + if (externalUrl != null) { + try { + return new URI(externalUrl.apply(request)).getAuthority(); + } catch (URISyntaxException e) { + return null; + } + } + String authority = request.getHeader("Host"); + return authority != null ? authority : request.getServerName(); + } + + private String resolveUrl(HttpServletRequest request) { + if (externalUrl != null) { + return externalUrl.apply(request); + } + StringBuffer url = request.getRequestURL(); + String query = request.getQueryString(); + return query == null ? url.toString() : url.append('?').append(query).toString(); + } + + private 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 void reject(HttpServletResponse response) throws IOException { + response.setHeader("WWW-Authenticate", PopHttp.DPOP_HEADER); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "unauthorized"); + } + + public static final class Builder { + + private final String expectedIssuer; + private final Supplier> rootKeys; + private final ReplayCache replay; + private final CallerPolicy.Builder policy = CallerPolicy.builder(); + private boolean trustedHostsSet; + 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"); + } + + /** + * 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) { + policy.trustedHosts(hosts); + if (hosts.length > 0) { + trustedHostsSet = true; + } + return this; + } + + public Builder withAllowedAnsNames(String... ansNames) { + policy.allowedAnsNames(ansNames); + return this; + } + + public Builder withPoPSkew(Duration popSkew) { + this.popSkew = Objects.requireNonNull(popSkew, "popSkew"); + 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()); + } + + } +} \ 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 new file mode 100644 index 0000000..10e4045 --- /dev/null +++ b/ans-sdk-pop-spring/src/test/java/com/godaddy/ans/sdk/pop/spring/PopAuthenticationFilterTest.java @@ -0,0 +1,415 @@ +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; +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) { + 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() { + 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) + .withTrustedHosts("rp.example.com:443") + .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 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(); + } + + @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 builderRejectsInvalidAllowedAnsName() { + assertThatIllegalArgumentException().isThrownBy(() -> PopAuthenticationFilter + .builder("issuer.example.com", ROOT_KEYS, REPLAY) + .withTrustedHosts("rp.example.com:443") + .withAllowedAnsNames("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.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-pop-spring/src/test/java/com/godaddy/ans/sdk/pop/spring/PopAuthenticationTest.java b/ans-sdk-pop-spring/src/test/java/com/godaddy/ans/sdk/pop/spring/PopAuthenticationTest.java new file mode 100644 index 0000000..989d876 --- /dev/null +++ b/ans-sdk-pop-spring/src/test/java/com/godaddy/ans/sdk/pop/spring/PopAuthenticationTest.java @@ -0,0 +1,47 @@ +package com.godaddy.ans.sdk.pop.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 diff --git a/ans-sdk-pop/build.gradle.kts b/ans-sdk-pop/build.gradle.kts new file mode 100644 index 0000000..f47a1c9 --- /dev/null +++ b/ans-sdk-pop/build.gradle.kts @@ -0,0 +1,40 @@ +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 +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") + + // Caffeine-backed replay cache (bounded jti single-use store) + implementation("com.github.ben-manes.caffeine:caffeine:$caffeineVersion") + + // 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("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/Base64Url.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Base64Url.java new file mode 100644 index 0000000..44e4b54 --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Base64Url.java @@ -0,0 +1,24 @@ +package com.godaddy.ans.sdk.pop; + +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(); + 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/CaffeineReplayCache.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CaffeineReplayCache.java new file mode 100644 index 0000000..7a5cdb8 --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CaffeineReplayCache.java @@ -0,0 +1,84 @@ +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; +import java.util.concurrent.ConcurrentMap; + +/** + * 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. 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 final Cache cache; + private final int maxEntries; + + private CaffeineReplayCache(Cache cache, int maxEntries) { + this.cache = cache; + this.maxEntries = maxEntries; + } + + 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() + .expireAfter(Expiry.creating((String key, Duration ttl) -> ttl)) + .ticker(ticker) + .build(); + return new CaffeineReplayCache(cache, maxEntries); + } + + @Override + public boolean checkAndStore(String key, Duration ttl) throws PopException { + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(ttl, "ttl"); + 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/CallerIdentity.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerIdentity.java new file mode 100644 index 0000000..41d1260 --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerIdentity.java @@ -0,0 +1,32 @@ +package com.godaddy.ans.sdk.pop; + +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); + } +} \ 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..d1be5ca --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerOptions.java @@ -0,0 +1,86 @@ +package com.godaddy.ans.sdk.pop; + +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; + // The SHA-256 of the request body (32 bytes), or null when no body is bound. + private final byte[] contentSha256; + // Whether the proof MUST carry an ans_content_digest. + private final boolean requireContentBinding; + + private CallerOptions(String accessToken, String expectedPeer, Instant clock, + byte[] contentSha256, boolean requireContentBinding) { + this.accessToken = accessToken; + this.expectedPeer = expectedPeer; + this.clock = clock; + this.contentSha256 = contentSha256; + this.requireContentBinding = requireContentBinding; + } + + public static CallerOptions none() { + return new CallerOptions(null, null, null, null, false); + } + + public CallerOptions withAccessToken(String token) { + return new CallerOptions(Objects.requireNonNull(token, "token"), expectedPeer, clock, + contentSha256, requireContentBinding); + } + + /** + * Restricts accepted callers to this ans:// name. When no expected peer is + * set, any proven agent authenticates, and the callee authorizes downstream. + */ + public CallerOptions withExpectedPeer(String peer) { + return new CallerOptions(accessToken, Objects.requireNonNull(peer, "peer"), clock, + contentSha256, requireContentBinding); + } + + public CallerOptions withClock(Instant now) { + return new CallerOptions(accessToken, expectedPeer, Objects.requireNonNull(now, "now"), + contentSha256, requireContentBinding); + } + + /** + * Binds the request body: the proof's ans_content_digest must match the + * SHA-256 of the body (ANS-6 §7.13). The caller hashes the body; the digest + * must be exactly 32 bytes. The array is copied defensively. + */ + public CallerOptions withContentSha256(byte[] contentSha256) { + Objects.requireNonNull(contentSha256, "contentSha256"); + return new CallerOptions(accessToken, expectedPeer, clock, contentSha256.clone(), requireContentBinding); + } + + /** Requires the proof to carry an ans_content_digest (ANS-6 §7.13). */ + public CallerOptions withRequiredContentBinding() { + return new CallerOptions(accessToken, expectedPeer, clock, contentSha256, true); + } + + String accessToken() { + return accessToken; + } + + String expectedPeer() { + return 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/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/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..a07edc4 --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/CallerVerifier.java @@ -0,0 +1,306 @@ +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; + +/** + * 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); + + 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(); + + // 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 + && !ansHost(effectiveOptions.expectedPeer()).equals(ansHost(token.ansName()))) { + throw new PopException(ErrorType.EXPECTED_PEER_MISMATCH, + "status token peer does not match expected peer"); + } + + // Single-use: recorded last, once the proof is known to belong to an + // agent the transparency log vouches for. Recording earlier would let + // anyone with a self-signed certificate consume the bounded cache and + // fail authentication for every legitimate caller. + proofVerifier.recordReplay(verified, replay); + + 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(); + 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); + } + + // 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()) { + 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"); + } + + // 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"); + } + if (!ansHost(certAnsName.get()).equals(ansHost(token.ansName()))) { + throw new PopException(ErrorType.BINDING_FAILED, + "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); + } + + // 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) { + throw new PopException(ErrorType.BINDING_FAILED, "receipt has no event payload"); + } + Map envelope; + try { + envelope = JSONObjectUtils.parse(new String(payload, StandardCharsets.UTF_8)); + } catch (ParseException e) { + throw new PopException(ErrorType.BINDING_FAILED, "receipt event payload is not valid JSON", e); + } + 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"); + } + if (!eventAgentId.equals(token.agentId())) { + throw new PopException(ErrorType.BINDING_FAILED, + "receipt agent does not match status token agent"); + } + } + + // 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"); + 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; + } + + // 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"); + } + 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..02adf9b --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/DpopProofVerifier.java @@ -0,0 +1,297 @@ +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 { + + /** + * 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); + // 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) { + } + + /** + * 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) { + 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; + } + } + + /** + * 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"); + 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); + + // §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"); + } + + 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"); + } + + 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()); + + verifyContentBinding(claims.ansContentDigest(), effectiveOptions.contentSha256(), + effectiveOptions.requireContentBinding()); + + 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"); + } + + // 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( + header.cert(), + header.publicKey(), + certFingerprint(header.cert()), + Proof.jkt(header.jwk()), + jti, + normalizedHtu, + iat); + + 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) { + 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"); + } + } + + /** + * 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, + * 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; + 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"); + } + } + + /** + * 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()); + } 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/ErrorType.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ErrorType.java new file mode 100644 index 0000000..ce84cb9 --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ErrorType.java @@ -0,0 +1,98 @@ +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, + /** + * 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. */ + 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, + /** + * 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, + /** + * 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/Jws.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Jws.java new file mode 100644 index 0000000..c070637 --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Jws.java @@ -0,0 +1,77 @@ +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; + +// 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"); + + static final Set ALLOWED_HEADER_PARAMS = Set.of("typ", "alg", "jwk", "x5c"); + + 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 { + 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/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..b58244d --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopHttp.java @@ -0,0 +1,88 @@ +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; + +/** 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"; + + 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"); + 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); + } + } + } + + /** + * 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(); + } + 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/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..b355e8a --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/PopSigner.java @@ -0,0 +1,180 @@ +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; + +/** + * 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; + // 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; + 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; + } + + /** + * 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"); + + 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, 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, 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); + } + + /** + * 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); + } + + private String signInternal(String method, String url, String accessToken, byte[] content) 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()); + claims.put("ans_profile", ANS_PROFILE_REVISION); + 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); + } + + 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/Proof.java b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Proof.java new file mode 100644 index 0000000..e4a9ca9 --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/Proof.java @@ -0,0 +1,289 @@ +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() { + } + + // 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, 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. + 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 + // 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(); + + ECKey jwk = extractPublicEcKey(header); + X509Certificate cert = extractLeafCertificate(header); + ECPublicKey matched = matchJWKToCert(jwk, cert); + + 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 { + 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; + } + + // 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(); + } catch (JOSEException e) { + throw new PopException(ErrorType.MISCONFIGURED, "failed to compute jwk thumbprint", e); + } + } + + // 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))); + } + + // 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 + // §6.2.3. + 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"), + stringClaim(map, "ans_content_digest"), + profileClaim(map, "ans_profile")); + } + + 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; + } + + // 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) { + 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()); + } + + // 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); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } +} 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..c48e27f --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ProofResult.java @@ -0,0 +1,33 @@ +package com.godaddy.ans.sdk.pop; + +import java.security.cert.X509Certificate; +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, + 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..620bb11 --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/ReplayCache.java @@ -0,0 +1,16 @@ +package com.godaddy.ans.sdk.pop; + +import java.time.Duration; + +public interface ReplayCache { + + /** + * 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/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..2541872 --- /dev/null +++ b/ans-sdk-pop/src/main/java/com/godaddy/ans/sdk/pop/VerifyOptions.java @@ -0,0 +1,66 @@ +package com.godaddy.ans.sdk.pop; + +import java.util.Objects; + +/** Options for a single {@link DpopProofVerifier#verify} call. */ +public final class VerifyOptions { + + private final String accessToken; + private final byte[] contentSha256; + private final boolean requireContentBinding; + + 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, null, false); + } + + /** + * 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"), 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/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/CaffeineReplayCacheTest.java b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CaffeineReplayCacheTest.java new file mode 100644 index 0000000..e654550 --- /dev/null +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CaffeineReplayCacheTest.java @@ -0,0 +1,134 @@ +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.assertThatNullPointerException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CaffeineReplayCacheTest { + + private static final Duration TTL = Duration.ofSeconds(245); + + @Test + void freshKeyReturnsFalse() throws Exception { + CaffeineReplayCache cache = CaffeineReplayCache.create(128); + + assertThat(cache.checkAndStore("jti-1", TTL)).isFalse(); + } + + @Test + void secondCallSameKeyReturnsSeen() throws Exception { + CaffeineReplayCache cache = CaffeineReplayCache.create(128); + + assertThat(cache.checkAndStore("jti-1", TTL)).isFalse(); + assertThat(cache.checkAndStore("jti-1", TTL)).isTrue(); + } + + @Test + void atCapacityFailsClosed() throws Exception { + CaffeineReplayCache cache = CaffeineReplayCache.create(1); + + 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() throws Exception { + 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() throws Exception { + 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() throws Exception { + 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() throws Exception { + 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/CallerOptionsTest.java b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerOptionsTest.java new file mode 100644 index 0000000..c8f9464 --- /dev/null +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerOptionsTest.java @@ -0,0 +1,97 @@ +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(); + 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 + 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/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-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..70de10f --- /dev/null +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/CallerVerifierTest.java @@ -0,0 +1,491 @@ +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, + "{\"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); + + 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); + 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); + } + + @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 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, + "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() { + 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) { + // 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); + } + + 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")); + 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..fe5641f --- /dev/null +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/DpopProofVerifierTest.java @@ -0,0 +1,555 @@ +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(); + } + + @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); + } + + @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); + 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 { + 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"); + 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/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(); + } +} 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 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..5358337 --- /dev/null +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/PopSignerTest.java @@ -0,0 +1,271 @@ +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 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 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()); + + 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 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..8e5ffe6 --- /dev/null +++ b/ans-sdk-pop/src/test/java/com/godaddy/ans/sdk/pop/ProofTest.java @@ -0,0 +1,483 @@ +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); + } + + @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)); + 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/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 diff --git a/ans-sdk-spring-boot-starter/build.gradle.kts b/ans-sdk-spring-boot-starter/build.gradle.kts index 8581a94..8222d75 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" @@ -10,12 +11,17 @@ dependencies { api(project(":ans-sdk-discovery")) // Spring Boot auto-configuration + implementation(platform("org.springframework.boot:spring-boot-dependencies:$springBootVersion")) implementation("org.springframework.boot:spring-boot-autoconfigure:$springBootVersion") + // 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") diff --git a/build.gradle.kts b/build.gradle.kts index 72e1b12..16c54ad 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,6 +20,8 @@ val publishableModules = setOf( "ans-sdk-discovery", "ans-sdk-agent-client", "ans-sdk-transparency", + "ans-sdk-pop", + "ans-sdk-pop-spring", "ans-sdk-spring-boot-starter" ) diff --git a/gradle.properties b/gradle.properties index ca9ed5a..a522f69 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,10 +1,10 @@ # 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 +caffeineVersion=3.2.0 cborVersion=4.5.4 nimbusJoseVersion=10.9.1 diff --git a/settings.gradle.kts b/settings.gradle.kts index f9067be..990803d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,6 +7,8 @@ include("ans-sdk-registration") 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 @@ -14,4 +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-pop-spring:examples:dpop-scitt-auth") include("ans-sdk-agent-client:examples:mcp-server-spring") \ No newline at end of file