Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions ans-sdk-pop-spring/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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")
}
62 changes: 62 additions & 0 deletions ans-sdk-pop-spring/examples/dpop-scitt-auth/README.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions ans-sdk-pop-spring/examples/dpop-scitt-auth/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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<JavaExec>("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
}
Original file line number Diff line number Diff line change
@@ -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 <serverUrl> <keystorePath> <keystorePassword> "
+ "<keyAlias> <agentId>");
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<String, List<String>> 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<String> 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<String, List<String>> 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));
}
}
}
Original file line number Diff line number Diff line change
@@ -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> popAuthenticationFilter(
TransparencyClient transparencyClient,
ReplayCache replayCache,
@Value("${pop.expected-issuer}") String expectedIssuer,
@Value("${pop.trusted-host}") String trustedHost) {

Supplier<Map<String, PublicKey>> rootKeys =
() -> transparencyClient.getRootKeysAsync().orTimeout(2, TimeUnit.SECONDS).join();

PopAuthenticationFilter filter = PopAuthenticationFilter
.builder(expectedIssuer, rootKeys, replayCache)
.withTrustedHosts(trustedHost)
.build();

FilterRegistrationBean<PopAuthenticationFilter> registration = new FilterRegistrationBean<>(filter);
registration.addUrlPatterns("/*");
return registration;
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<Map<String, String>> whoami(HttpServletRequest request) {
Optional<CallerIdentity> 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()
));
}
}
Original file line number Diff line number Diff line change
@@ -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:}
Original file line number Diff line number Diff line change
@@ -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<CallerIdentity> 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();
}
}
Loading
Loading