From ab1e729bea924dcc728e4ed5f9976d3ca2be6369 Mon Sep 17 00:00:00 2001 From: Lobsterdog Contributors Date: Tue, 16 Jun 2026 22:28:11 -0600 Subject: [PATCH 01/21] feat(signing): add core L1 signing SPI interfaces (AdcpUse, SigningContext, VerificationKeyResolver) --- .../adcp/signing/AdcpUse.java | 42 ++++++++ .../adcp/signing/PrincipalRef.java | 31 ++++++ .../adcp/signing/Signature.java | 49 ++++++++++ .../adcp/signing/SignedInput.java | 22 +++++ .../adcp/signing/SigningContext.java | 56 +++++++++++ .../adcp/signing/SigningException.java | 18 ++++ .../adcp/signing/SigningInput.java | 26 +++++ .../adcp/signing/SigningProvider.java | 11 +++ .../adcp/signing/TenantId.java | 28 ++++++ .../adcp/signing/VerificationException.java | 34 +++++++ .../adcp/signing/VerificationInput.java | 20 ++++ .../adcp/signing/VerificationKey.java | 73 ++++++++++++++ .../adcp/signing/VerificationKeyLookup.java | 41 ++++++++ .../adcp/signing/VerificationKeyResolver.java | 15 +++ .../adcp/signing/package-info.java | 14 +++ .../adcp/signing/AdcpUseTest.java | 45 +++++++++ .../adcp/signing/SigningContextTest.java | 98 +++++++++++++++++++ 17 files changed, 623 insertions(+) create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/signing/AdcpUse.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/signing/PrincipalRef.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/signing/Signature.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/signing/SignedInput.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/signing/SigningContext.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/signing/SigningException.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/signing/SigningInput.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/signing/SigningProvider.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/signing/TenantId.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/signing/VerificationException.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/signing/VerificationInput.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/signing/VerificationKey.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/signing/VerificationKeyLookup.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/signing/VerificationKeyResolver.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/signing/package-info.java create mode 100644 adcp/src/test/java/org/adcontextprotocol/adcp/signing/AdcpUseTest.java create mode 100644 adcp/src/test/java/org/adcontextprotocol/adcp/signing/SigningContextTest.java diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/signing/AdcpUse.java b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/AdcpUse.java new file mode 100644 index 0000000..da3da4d --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/AdcpUse.java @@ -0,0 +1,42 @@ +package org.adcontextprotocol.adcp.signing; + +/** + * The AdCP signing key purpose, matching the {@code adcp_use} JWK parameter. + * + *

Each signing key is provisioned for exactly one purpose. Verification + * enforces that the key's {@code adcp_use} matches the expected operation. + */ +public enum AdcpUse { + REQUEST_SIGNING("adcp_req"), + WEBHOOK_SIGNING("adcp_whk"); + + private final String wireName; + + AdcpUse(String wireName) { + this.wireName = wireName; + } + + /** Wire name matching the {@code adcp_use} JWK parameter value. */ + public String wireName() { + return wireName; + } + + /** + * Resolve an {@code AdcpUse} from its wire name. + * + * @param wireName the {@code adcp_use} JWK value + * @return the matching {@code AdcpUse} + * @throws IllegalArgumentException if the wire name is unrecognized + */ + public static AdcpUse fromWireName(String wireName) { + if (wireName == null) { + throw new NullPointerException("wireName"); + } + for (AdcpUse use : values()) { + if (use.wireName.equals(wireName)) { + return use; + } + } + throw new IllegalArgumentException("Unknown adcp_use: " + wireName); + } +} \ No newline at end of file diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/signing/PrincipalRef.java b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/PrincipalRef.java new file mode 100644 index 0000000..0088009 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/PrincipalRef.java @@ -0,0 +1,31 @@ +package org.adcontextprotocol.adcp.signing; + +import java.util.Objects; + +/** + * On-behalf-of account or principal identity for signing key selection. + * + *

In multi-tenant deployments this may differ from {@link TenantId}: a DSP + * or agency tenant signs on behalf of an advertiser principal. + * + * @param value the principal identifier (must not be null or blank) + */ +public record PrincipalRef(String value) { + + public PrincipalRef { + Objects.requireNonNull(value, "value"); + if (value.isBlank()) { + throw new IllegalArgumentException("PrincipalRef must not be blank"); + } + } + + /** Factory method consistent with the codebase convention. */ + public static PrincipalRef of(String value) { + return new PrincipalRef(value); + } + + @Override + public String toString() { + return value; + } +} \ No newline at end of file diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/signing/Signature.java b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/Signature.java new file mode 100644 index 0000000..3644b6a --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/Signature.java @@ -0,0 +1,49 @@ +package org.adcontextprotocol.adcp.signing; + +import java.util.Arrays; +import java.util.Objects; + +/** + * A completed signature produced by {@link SigningProvider}. + * + * @param label signature label, defaults to {@code "sig1"} per AdCP convention + * @param signatureInput the {@code Signature-Input} header value (RFC 9421 §4.1) + * @param signatureBytes the raw signature bytes + * @param algorithm the JCA algorithm name (e.g. {@code "Ed25519"}) + * @param kid the key identifier used to produce this signature + */ +public record Signature(String label, String signatureInput, byte[] signatureBytes, + String algorithm, String kid) { + + /** Default label per AdCP convention. */ + public static final String DEFAULT_LABEL = "sig1"; + + public Signature { + Objects.requireNonNull(label, "label"); + Objects.requireNonNull(signatureInput, "signatureInput"); + Objects.requireNonNull(signatureBytes, "signatureBytes"); + Objects.requireNonNull(algorithm, "algorithm"); + Objects.requireNonNull(kid, "kid"); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Signature that)) return false; + return label.equals(that.label) + && signatureInput.equals(that.signatureInput) + && Arrays.equals(signatureBytes, that.signatureBytes) + && algorithm.equals(that.algorithm) + && kid.equals(that.kid); + } + + @Override + public int hashCode() { + int result = label.hashCode(); + result = 31 * result + signatureInput.hashCode(); + result = 31 * result + Arrays.hashCode(signatureBytes); + result = 31 * result + algorithm.hashCode(); + result = 31 * result + kid.hashCode(); + return result; + } +} \ No newline at end of file diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/signing/SignedInput.java b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/SignedInput.java new file mode 100644 index 0000000..daf6f0d --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/SignedInput.java @@ -0,0 +1,22 @@ +package org.adcontextprotocol.adcp.signing; + +import java.util.Map; +import java.util.Objects; + +/** + * Raw bytes and parsed headers received from the wire for inbound verification. + * + * @param rawBody the raw request body bytes + * @param headers all request headers (verification selects relevant ones) + * @param method HTTP method of the inbound request + * @param targetUri request target URI of the inbound request + */ +public record SignedInput(byte[] rawBody, Map headers, String method, String targetUri) { + + public SignedInput { + Objects.requireNonNull(rawBody, "rawBody"); + Objects.requireNonNull(headers, "headers"); + Objects.requireNonNull(method, "method"); + Objects.requireNonNull(targetUri, "targetUri"); + } +} \ No newline at end of file diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/signing/SigningContext.java b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/SigningContext.java new file mode 100644 index 0000000..528ff1b --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/SigningContext.java @@ -0,0 +1,56 @@ +package org.adcontextprotocol.adcp.signing; + +import org.jspecify.annotations.Nullable; + +import java.util.Objects; + +/** + * Carries the purpose and tenant/principal context for signing key selection. + * + *

Per D22: {@code use} is always required. {@code tenant} and + * {@code principal} are nullable for single-tenant deployments and caller-side + * signing where no publisher account has been resolved yet. + * + *

There is no {@code forUse(AdcpUse)} shortcut — the context-based surface + * is the only public API. + * + * @param use the signing purpose (required) + * @param tenant operator-side tenant identity, or {@code null} for single-tenant + * @param principal on-behalf-of principal, or {@code null} when not resolved + */ +public record SigningContext(AdcpUse use, @Nullable TenantId tenant, @Nullable PrincipalRef principal) { + + public SigningContext { + Objects.requireNonNull(use, "use"); + } + + /** Start building a {@code SigningContext} with the required purpose. */ + public static Builder builder(AdcpUse use) { + return new Builder(use); + } + + /** Builder for {@link SigningContext}. */ + public static final class Builder { + private final AdcpUse use; + private @Nullable TenantId tenant; + private @Nullable PrincipalRef principal; + + private Builder(AdcpUse use) { + this.use = Objects.requireNonNull(use, "use"); + } + + public Builder tenant(@Nullable TenantId tenant) { + this.tenant = tenant; + return this; + } + + public Builder principal(@Nullable PrincipalRef principal) { + this.principal = principal; + return this; + } + + public SigningContext build() { + return new SigningContext(use, tenant, principal); + } + } +} \ No newline at end of file diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/signing/SigningException.java b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/SigningException.java new file mode 100644 index 0000000..5bf4706 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/SigningException.java @@ -0,0 +1,18 @@ +package org.adcontextprotocol.adcp.signing; + +/** + * Exception thrown when outbound signing fails. + */ +public final class SigningException extends Exception { + + @java.io.Serial + private static final long serialVersionUID = 1L; + + public SigningException(String message) { + super(message); + } + + public SigningException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/signing/SigningInput.java b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/SigningInput.java new file mode 100644 index 0000000..db93378 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/SigningInput.java @@ -0,0 +1,26 @@ +package org.adcontextprotocol.adcp.signing; + +import java.util.Map; + +/** + * Structured input for outbound signing. The canonicalizer uses these fields + * to build the RFC 9421 §2.5 signature base. + */ +public interface SigningInput { + + /** HTTP method (e.g. {@code "POST"}, {@code "GET"}). */ + String method(); + + /** Request target URI (e.g. {@code "/v3/webhooks"}). */ + String targetUri(); + + /** Raw request body bytes. */ + byte[] body(); + + /** + * Request headers relevant to the signature. The canonicalizer picks + * which headers to include in the signature base based on the + * {@code adcp_use} and the AdCP signing profile. + */ + Map headers(); +} \ No newline at end of file diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/signing/SigningProvider.java b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/SigningProvider.java new file mode 100644 index 0000000..04aa759 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/SigningProvider.java @@ -0,0 +1,11 @@ +package org.adcontextprotocol.adcp.signing; + +/** + * Outbound signing SPI. Implementations select a key based on the + * {@link SigningContext} and produce a {@link Signature} over the + * {@link SigningInput}. + */ +@FunctionalInterface +public interface SigningProvider { + Signature sign(SigningContext context, SigningInput input) throws SigningException; +} \ No newline at end of file diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/signing/TenantId.java b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/TenantId.java new file mode 100644 index 0000000..f83d199 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/TenantId.java @@ -0,0 +1,28 @@ +package org.adcontextprotocol.adcp.signing; + +import java.util.Objects; + +/** + * Operator-side tenant identity used for signing key selection. + * + * @param value the tenant identifier (must not be null or blank) + */ +public record TenantId(String value) { + + public TenantId { + Objects.requireNonNull(value, "value"); + if (value.isBlank()) { + throw new IllegalArgumentException("TenantId must not be blank"); + } + } + + /** Factory method consistent with the codebase convention. */ + public static TenantId of(String value) { + return new TenantId(value); + } + + @Override + public String toString() { + return value; + } +} \ No newline at end of file diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/signing/VerificationException.java b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/VerificationException.java new file mode 100644 index 0000000..e4a1194 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/VerificationException.java @@ -0,0 +1,34 @@ +package org.adcontextprotocol.adcp.signing; + +/** + * Exception thrown when inbound signature verification fails. + * + *

Carries an error code from the {@code webhook_signature_*} taxonomy: + *

+ */ +public final class VerificationException extends Exception { + + @java.io.Serial + private static final long serialVersionUID = 1L; + + private final String errorCode; + + public VerificationException(String errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + public VerificationException(String errorCode, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + } + + /** Error code matching the webhook signature error taxonomy. */ + public String errorCode() { + return errorCode; + } +} \ No newline at end of file diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/signing/VerificationInput.java b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/VerificationInput.java new file mode 100644 index 0000000..cd26575 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/VerificationInput.java @@ -0,0 +1,20 @@ +package org.adcontextprotocol.adcp.signing; + +import java.util.Objects; + +/** + * Input for inbound signature verification. Carries the expected purpose, + * the inbound {@code kid}, and the raw signed data. + * + * @param expectedUse the AdCP use the key must satisfy + * @param kid the key identifier from the inbound signature + * @param input the signed input to verify + */ +public record VerificationInput(AdcpUse expectedUse, String kid, SignedInput input) { + + public VerificationInput { + Objects.requireNonNull(expectedUse, "expectedUse"); + Objects.requireNonNull(kid, "kid"); + Objects.requireNonNull(input, "input"); + } +} \ No newline at end of file diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/signing/VerificationKey.java b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/VerificationKey.java new file mode 100644 index 0000000..57b1d16 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/VerificationKey.java @@ -0,0 +1,73 @@ +package org.adcontextprotocol.adcp.signing; + +import org.jspecify.annotations.Nullable; + +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.util.Arrays; +import java.util.Objects; + +/** + * Verification key material for inbound signature verification. + * + *

Carries the raw key bytes and algorithm metadata. The {@link #asJcaKey()} + * method lazily converts to a JCA {@link PublicKey} using {@link KeyFactory}. + * + * @param kid key identifier matching the inbound {@code kid} header + * @param algorithm JCA algorithm name (e.g. {@code "Ed25519"}, {@code "RSA"}) + * @param publicKeyBytes raw public key bytes (DER or raw depending on algorithm) + * @param crv curve name for elliptic algorithms, or {@code null} + */ +public record VerificationKey(String kid, String algorithm, byte[] publicKeyBytes, @Nullable String crv) { + + public VerificationKey { + Objects.requireNonNull(kid, "kid"); + Objects.requireNonNull(algorithm, "algorithm"); + Objects.requireNonNull(publicKeyBytes, "publicKeyBytes"); + } + + /** + * Lazily convert to a JCA {@link PublicKey}. + * + *

Supports: + *

+ * + * @return the JCA public key + * @throws VerificationException if the key cannot be converted + */ + public @Nullable PublicKey asJcaKey() throws VerificationException { + try { + KeyFactory kf = KeyFactory.getInstance(algorithm); + return kf.generatePublic(new java.security.spec.X509EncodedKeySpec(publicKeyBytes)); + } catch (NoSuchAlgorithmException e) { + throw new VerificationException("webhook_signature_invalid", + "Unsupported key algorithm: " + algorithm, e); + } catch (java.security.spec.InvalidKeySpecException e) { + throw new VerificationException("webhook_signature_invalid", + "Invalid key specification for algorithm: " + algorithm, e); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof VerificationKey that)) return false; + return kid.equals(that.kid) + && algorithm.equals(that.algorithm) + && Arrays.equals(publicKeyBytes, that.publicKeyBytes) + && Objects.equals(crv, that.crv); + } + + @Override + public int hashCode() { + int result = kid.hashCode(); + result = 31 * result + algorithm.hashCode(); + result = 31 * result + Arrays.hashCode(publicKeyBytes); + result = 31 * result + Objects.hashCode(crv); + return result; + } +} \ No newline at end of file diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/signing/VerificationKeyLookup.java b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/VerificationKeyLookup.java new file mode 100644 index 0000000..466d7d5 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/VerificationKeyLookup.java @@ -0,0 +1,41 @@ +package org.adcontextprotocol.adcp.signing; + +import org.jspecify.annotations.Nullable; + +/** + * Result of key resolution for inbound verification. Either a key was found + * (with optional tenant/principal metadata) or the {@code kid} is unknown. + * + *

Per the signing-context spec: if {@link Found} returns {@code null} + * tenant, receivers must treat the request as untenanted and reject + * tenant-scoped operations. They must not recover tenant identity from + * unsigned body, header, or query fields. + */ +public sealed interface VerificationKeyLookup { + + /** + * A verification key was found, along with any tenant/principal metadata + * discovered during key lookup. + * + * @param key the verification key + * @param tenant resolved tenant, or {@code null} if untenanted + * @param principal resolved principal, or {@code null} if not available + */ + record Found(VerificationKey key, @Nullable TenantId tenant, @Nullable PrincipalRef principal) + implements VerificationKeyLookup { + public Found { + if (key == null) throw new NullPointerException("key"); + } + } + + /** + * No key found for the given {@code kid}. + * + * @param kid the key identifier that could not be resolved + */ + record Missing(String kid) implements VerificationKeyLookup { + public Missing { + if (kid == null) throw new NullPointerException("kid"); + } + } +} \ No newline at end of file diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/signing/VerificationKeyResolver.java b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/VerificationKeyResolver.java new file mode 100644 index 0000000..c36ed4d --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/VerificationKeyResolver.java @@ -0,0 +1,15 @@ +package org.adcontextprotocol.adcp.signing; + +/** + * Inbound verification SPI. Given a {@link VerificationInput} (which carries + * the inbound {@code kid}), resolves to either a {@link VerificationKeyLookup.Found} + * with key material and optional tenant/principal metadata, or + * {@link VerificationKeyLookup.Missing} if no key matches. + * + *

Per the signing-context spec: verification starts from the inbound {@code kid}. + * Tenant context is derived after key lookup, never before. + */ +@FunctionalInterface +public interface VerificationKeyResolver { + VerificationKeyLookup resolve(VerificationInput input); +} \ No newline at end of file diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/signing/package-info.java b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/package-info.java new file mode 100644 index 0000000..35b3aa6 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/signing/package-info.java @@ -0,0 +1,14 @@ +/** + * AdCP L1 signing SPI — interfaces for request and webhook signing/verification. + * + *

This package defines the core signing surface shared by both the caller + * (verification) and server (signing) sides. Provider implementations live in + * separate modules ({@code adcp-signing-aws-kms}, {@code adcp-signing-gcp-kms}, + * {@code adcp-signing-bouncycastle}). + * + * @see SigningContext + * @see SigningProvider + * @see VerificationKeyResolver + */ +@org.jspecify.annotations.NullMarked +package org.adcontextprotocol.adcp.signing; \ No newline at end of file diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/signing/AdcpUseTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/signing/AdcpUseTest.java new file mode 100644 index 0000000..91393fb --- /dev/null +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/signing/AdcpUseTest.java @@ -0,0 +1,45 @@ +package org.adcontextprotocol.adcp.signing; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class AdcpUseTest { + + @Test + void wireName_requestSigning() { + assertEquals("adcp_req", AdcpUse.REQUEST_SIGNING.wireName()); + } + + @Test + void wireName_webhookSigning() { + assertEquals("adcp_whk", AdcpUse.WEBHOOK_SIGNING.wireName()); + } + + @Test + void fromWireName_roundTrip() { + for (AdcpUse use : AdcpUse.values()) { + assertEquals(use, AdcpUse.fromWireName(use.wireName())); + } + } + + @Test + void fromWireName_unknownThrows() { + assertThrows(IllegalArgumentException.class, + () -> AdcpUse.fromWireName("unknown")); + } + + @Test + void fromWireName_nullThrows() { + assertThrows(NullPointerException.class, + () -> AdcpUse.fromWireName(null)); + } + + @Test + void enumValues() { + AdcpUse[] values = AdcpUse.values(); + assertEquals(2, values.length); + assertEquals(AdcpUse.REQUEST_SIGNING, values[0]); + assertEquals(AdcpUse.WEBHOOK_SIGNING, values[1]); + } +} \ No newline at end of file diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/signing/SigningContextTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/signing/SigningContextTest.java new file mode 100644 index 0000000..65e9eb4 --- /dev/null +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/signing/SigningContextTest.java @@ -0,0 +1,98 @@ +package org.adcontextprotocol.adcp.signing; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class SigningContextTest { + + @Test + void builder_setsUse() { + SigningContext ctx = SigningContext.builder(AdcpUse.REQUEST_SIGNING).build(); + assertEquals(AdcpUse.REQUEST_SIGNING, ctx.use()); + assertNull(ctx.tenant()); + assertNull(ctx.principal()); + } + + @Test + void builder_setsTenantAndPrincipal() { + TenantId tenant = TenantId.of("acme"); + PrincipalRef principal = PrincipalRef.of("user-1"); + SigningContext ctx = SigningContext.builder(AdcpUse.WEBHOOK_SIGNING) + .tenant(tenant) + .principal(principal) + .build(); + assertEquals(AdcpUse.WEBHOOK_SIGNING, ctx.use()); + assertEquals(tenant, ctx.tenant()); + assertEquals(principal, ctx.principal()); + } + + @Test + void builder_rejectsNullUse() { + assertThrows(NullPointerException.class, () -> SigningContext.builder(null)); + } + + @Test + void constructor_rejectsNullUse() { + assertThrows(NullPointerException.class, + () -> new SigningContext(null, null, null)); + } + + @Test + void equality() { + TenantId t = TenantId.of("t1"); + PrincipalRef p = PrincipalRef.of("p1"); + SigningContext a = SigningContext.builder(AdcpUse.REQUEST_SIGNING).tenant(t).principal(p).build(); + SigningContext b = SigningContext.builder(AdcpUse.REQUEST_SIGNING).tenant(t).principal(p).build(); + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + void inequality_differentUse() { + SigningContext a = SigningContext.builder(AdcpUse.REQUEST_SIGNING).build(); + SigningContext b = SigningContext.builder(AdcpUse.WEBHOOK_SIGNING).build(); + assertNotEquals(a, b); + } + + @Test + void tenantId_rejectsBlank() { + assertThrows(IllegalArgumentException.class, () -> TenantId.of("")); + assertThrows(IllegalArgumentException.class, () -> TenantId.of(" ")); + } + + @Test + void tenantId_rejectsNull() { + assertThrows(NullPointerException.class, () -> TenantId.of(null)); + } + + @Test + void principalRef_rejectsBlank() { + assertThrows(IllegalArgumentException.class, () -> PrincipalRef.of("")); + assertThrows(IllegalArgumentException.class, () -> PrincipalRef.of(" ")); + } + + @Test + void principalRef_rejectsNull() { + assertThrows(NullPointerException.class, () -> PrincipalRef.of(null)); + } + + @Test + void nullableFieldsAreNullByDefault() { + SigningContext ctx = SigningContext.builder(AdcpUse.REQUEST_SIGNING).build(); + assertNull(ctx.tenant()); + assertNull(ctx.principal()); + } + + @Test + void builder_tenantAndPrincipalCanBeSetToNull() { + SigningContext ctx = SigningContext.builder(AdcpUse.REQUEST_SIGNING) + .tenant(TenantId.of("t")) + .principal(PrincipalRef.of("p")) + .tenant(null) + .principal(null) + .build(); + assertNull(ctx.tenant()); + assertNull(ctx.principal()); + } +} \ No newline at end of file From 3b3ad1087ec8614335e352c73b508f12e02864b1 Mon Sep 17 00:00:00 2001 From: Lobsterdog Contributors Date: Tue, 16 Jun 2026 22:43:29 -0600 Subject: [PATCH 02/21] feat(signing): add RFC 9421 canonicalizer, signer, verifier, and conformance tests --- .../server/signing/AdcpSignatureProfile.java | 131 +++++ .../adcp/server/signing/ContentDigest.java | 79 +++ .../adcp/server/signing/HeaderNormalizer.java | 43 ++ .../server/signing/Rfc9421Canonicalizer.java | 503 ++++++++++++++++++ .../adcp/server/signing/Rfc9421Signer.java | 135 +++++ .../adcp/server/signing/Rfc9421Verifier.java | 375 +++++++++++++ .../server/signing/SignatureInputBuilder.java | 99 ++++ .../server/signing/VerificationResult.java | 37 ++ .../adcp/server/signing/package-info.java | 18 + .../signing/AdcpSignatureProfileTest.java | 103 ++++ .../server/signing/ContentDigestTest.java | 58 ++ .../server/signing/HeaderNormalizerTest.java | 50 ++ .../signing/Rfc9421CanonicalizerTest.java | 263 +++++++++ .../signing/SignatureInputBuilderTest.java | 68 +++ .../compliance/request-signing/README.md | 219 ++++++++ .../request-signing/canonicalization.json | 241 +++++++++ .../compliance/request-signing/keys.json | 60 +++ .../negative/001-no-signature-header.json | 24 + .../negative/002-wrong-tag.json | 26 + .../negative/003-expired-signature.json | 26 + .../negative/004-window-too-long.json | 26 + .../negative/005-alg-not-allowed.json | 26 + .../006-missing-covered-component.json | 26 + .../negative/007-missing-content-digest.json | 26 + .../negative/008-unknown-keyid.json | 26 + .../negative/009-key-ops-missing-verify.json | 27 + .../negative/010-content-digest-mismatch.json | 33 ++ .../negative/011-malformed-header.json | 27 + .../negative/012-missing-expires-param.json | 26 + .../negative/013-expires-le-created.json | 27 + .../negative/014-missing-nonce-param.json | 27 + .../negative/015-signature-invalid.json | 28 + .../negative/016-replayed-nonce.json | 35 ++ .../negative/017-key-revoked.json | 38 ++ .../018-digest-covered-when-forbidden.json | 28 + ...019-signature-without-signature-input.json | 26 + .../negative/020-rate-abuse.json | 34 ++ .../021-duplicate-signature-input-label.json | 31 ++ .../022-multi-valued-content-type.json | 31 ++ .../023-multi-valued-content-digest.json | 32 ++ .../negative/024-unquoted-string-param.json | 31 ++ .../negative/025-jwk-alg-crv-mismatch.json | 43 ++ .../negative/026-non-ascii-host.json | 31 ++ ...-registration-authentication-unsigned.json | 25 + .../positive/001-basic-post.json | 30 ++ .../002-post-with-content-digest.json | 31 ++ .../positive/003-es256-post.json | 30 ++ .../004-multiple-signature-labels.json | 26 + .../positive/005-default-port-stripped.json | 30 ++ .../positive/006-dot-segment-path.json | 30 ++ .../positive/007-query-byte-preserved.json | 30 ++ .../positive/008-percent-encoded-path.json | 30 ++ ...09-percent-encoded-unreserved-decoded.json | 30 ++ .../010-percent-encoded-slash-preserved.json | 30 ++ .../positive/011-ipv6-authority.json | 30 ++ ...-ipv6-authority-default-port-stripped.json | 30 ++ .../compliance/webhook-signing/README.md | 211 ++++++++ .../compliance/webhook-signing/keys.json | 61 +++ .../negative/001-wrong-tag.json | 26 + .../negative/002-expired-signature.json | 26 + .../negative/003-window-too-long.json | 26 + .../negative/004-alg-not-allowed.json | 26 + .../005-missing-authority-component.json | 26 + .../negative/006-missing-content-digest.json | 25 + .../negative/007-unknown-keyid.json | 26 + .../negative/008-wrong-adcp-use.json | 26 + .../negative/009-content-digest-mismatch.json | 26 + .../010-malformed-signature-input.json | 26 + .../negative/011-signature-without-input.json | 25 + .../negative/012-missing-expires-param.json | 26 + .../negative/013-expires-le-created.json | 26 + .../negative/014-missing-nonce-param.json | 26 + .../negative/015-signature-invalid.json | 26 + .../negative/016-replayed-nonce.json | 37 ++ .../negative/017-key-revoked.json | 32 ++ .../negative/018-rate-abuse.json | 33 ++ .../negative/019-revocation-stale.json | 32 ++ .../negative/020-key-ops-missing-verify.json | 41 ++ .../negative/021-base64-alphabet-mixing.json | 26 + .../positive/001-basic-post.json | 24 + .../positive/002-es256-post.json | 24 + .../003-multiple-signature-labels.json | 24 + .../positive/004-default-port-stripped.json | 24 + .../positive/005-percent-encoded-path.json | 24 + .../positive/006-query-byte-preserved.json | 24 + .../007-body-without-idempotency-key.json | 25 + 86 files changed, 4655 insertions(+) create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/AdcpSignatureProfile.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/ContentDigest.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/HeaderNormalizer.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/Rfc9421Canonicalizer.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/Rfc9421Signer.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/Rfc9421Verifier.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/SignatureInputBuilder.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/VerificationResult.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/package-info.java create mode 100644 adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/AdcpSignatureProfileTest.java create mode 100644 adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/ContentDigestTest.java create mode 100644 adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/HeaderNormalizerTest.java create mode 100644 adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/Rfc9421CanonicalizerTest.java create mode 100644 adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/SignatureInputBuilderTest.java create mode 100644 adcp-server/src/test/resources/compliance/request-signing/README.md create mode 100644 adcp-server/src/test/resources/compliance/request-signing/canonicalization.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/keys.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/001-no-signature-header.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/002-wrong-tag.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/003-expired-signature.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/004-window-too-long.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/005-alg-not-allowed.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/006-missing-covered-component.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/007-missing-content-digest.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/008-unknown-keyid.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/009-key-ops-missing-verify.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/010-content-digest-mismatch.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/011-malformed-header.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/012-missing-expires-param.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/013-expires-le-created.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/014-missing-nonce-param.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/015-signature-invalid.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/016-replayed-nonce.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/017-key-revoked.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/018-digest-covered-when-forbidden.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/019-signature-without-signature-input.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/020-rate-abuse.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/021-duplicate-signature-input-label.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/022-multi-valued-content-type.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/023-multi-valued-content-digest.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/024-unquoted-string-param.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/025-jwk-alg-crv-mismatch.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/026-non-ascii-host.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/negative/027-webhook-registration-authentication-unsigned.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/positive/001-basic-post.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/positive/002-post-with-content-digest.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/positive/003-es256-post.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/positive/004-multiple-signature-labels.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/positive/005-default-port-stripped.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/positive/006-dot-segment-path.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/positive/007-query-byte-preserved.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/positive/008-percent-encoded-path.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/positive/009-percent-encoded-unreserved-decoded.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/positive/010-percent-encoded-slash-preserved.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/positive/011-ipv6-authority.json create mode 100644 adcp-server/src/test/resources/compliance/request-signing/positive/012-ipv6-authority-default-port-stripped.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/README.md create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/keys.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/negative/001-wrong-tag.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/negative/002-expired-signature.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/negative/003-window-too-long.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/negative/004-alg-not-allowed.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/negative/005-missing-authority-component.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/negative/006-missing-content-digest.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/negative/007-unknown-keyid.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/negative/008-wrong-adcp-use.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/negative/009-content-digest-mismatch.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/negative/010-malformed-signature-input.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/negative/011-signature-without-input.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/negative/012-missing-expires-param.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/negative/013-expires-le-created.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/negative/014-missing-nonce-param.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/negative/015-signature-invalid.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/negative/016-replayed-nonce.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/negative/017-key-revoked.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/negative/018-rate-abuse.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/negative/019-revocation-stale.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/negative/020-key-ops-missing-verify.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/negative/021-base64-alphabet-mixing.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/positive/001-basic-post.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/positive/002-es256-post.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/positive/003-multiple-signature-labels.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/positive/004-default-port-stripped.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/positive/005-percent-encoded-path.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/positive/006-query-byte-preserved.json create mode 100644 adcp-server/src/test/resources/compliance/webhook-signing/positive/007-body-without-idempotency-key.json diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/AdcpSignatureProfile.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/AdcpSignatureProfile.java new file mode 100644 index 0000000..8b5091a --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/AdcpSignatureProfile.java @@ -0,0 +1,131 @@ +package org.adcontextprotocol.adcp.server.signing; + +import java.util.List; +import java.util.Set; + +/** + * Constants and validation for the AdCP-specific RFC 9421 profile. + * + *

Defines required covered components per purpose (webhook vs request), + * required signature parameters, allowed algorithms, tag values, + * and the replay window. + */ +public final class AdcpSignatureProfile { + + private AdcpSignatureProfile() {} + + public static final String TAG_WEBHOOK_SIGNING = "adcp/webhook-signing/v1"; + public static final String TAG_REQUEST_SIGNING = "adcp/request-signing/v1"; + + public static final String ALG_ED25519 = "ed25519"; + public static final String ALG_ECDSA_P256_SHA256 = "ecdsa-p256-sha256"; + + public static final Set ALLOWED_ALGORITHMS = Set.of(ALG_ED25519, ALG_ECDSA_P256_SHA256); + + public static final long REPLAY_WINDOW_SECONDS = 300; + + /** + * Required covered components for webhook signing: + * {@code @method}, {@code @target-uri}, {@code @authority}, + * {@code content-type}, {@code content-digest}. + */ + public static final List WEBHOOK_REQUIRED_COMPONENTS = List.of( + "@method", "@target-uri", "@authority", "content-type", "content-digest" + ); + + /** + * Required covered components for request signing: + * {@code @method}, {@code @target-uri}, {@code @authority}, + * {@code content-type}. Content-digest is required only when + * the request has a body and the verifier capability requires it. + */ + public static final List REQUEST_REQUIRED_COMPONENTS = List.of( + "@method", "@target-uri", "@authority", "content-type" + ); + + /** + * Required signature parameters for both purposes: + * {@code created}, {@code expires}, {@code nonce}, {@code keyid}, {@code alg}. + */ + public static final Set REQUIRED_SIGNATURE_PARAMS = Set.of( + "created", "expires", "nonce", "keyid", "alg" + ); + + /** + * Returns the tag value for the given AdCP use. + * + * @param adcpUse the AdCP use + * @return the tag string + * @throws IllegalArgumentException if the use is not recognized + */ + public static String tagForUse(org.adcontextprotocol.adcp.signing.AdcpUse adcpUse) { + return switch (adcpUse) { + case WEBHOOK_SIGNING -> TAG_WEBHOOK_SIGNING; + case REQUEST_SIGNING -> TAG_REQUEST_SIGNING; + }; + } + + /** + * Returns the required covered components for the given AdCP use. + * + * @param adcpUse the AdCP use + * @return the list of required component identifiers + */ + public static List requiredComponentsForUse(org.adcontextprotocol.adcp.signing.AdcpUse adcpUse) { + return switch (adcpUse) { + case WEBHOOK_SIGNING -> WEBHOOK_REQUIRED_COMPONENTS; + case REQUEST_SIGNING -> REQUEST_REQUIRED_COMPONENTS; + }; + } + + /** + * Check if an algorithm is allowed by the AdCP profile. + * + * @param alg the algorithm identifier from the Signature-Input + * @return true if the algorithm is in the allowlist + */ + public static boolean isAlgorithmAllowed(String alg) { + return ALLOWED_ALGORITHMS.contains(alg); + } + + /** + * Validate that all required covered components are present for the given use. + * + * @param adcpUse the AdCP use + * @param coveredComponents the covered components from the Signature-Input + * @return null if valid, or the error code string if validation fails + */ + public static String validateRequiredComponents( + org.adcontextprotocol.adcp.signing.AdcpUse adcpUse, + List coveredComponents) { + List required = requiredComponentsForUse(adcpUse); + for (String component : required) { + if (!coveredComponents.contains(component)) { + return adcpUse == org.adcontextprotocol.adcp.signing.AdcpUse.WEBHOOK_SIGNING + ? "webhook_signature_components_incomplete" + : "request_signature_components_incomplete"; + } + } + if (adcpUse == org.adcontextprotocol.adcp.signing.AdcpUse.WEBHOOK_SIGNING) { + if (!coveredComponents.contains("content-digest")) { + return "webhook_signature_components_incomplete"; + } + } + return null; + } + + /** + * Validate the signature parameters include all required fields. + * + * @param params the signature parameters map (name to value) + * @return null if valid, or the error code string if validation fails + */ + public static String validateRequiredParams(java.util.Map params) { + for (String required : REQUIRED_SIGNATURE_PARAMS) { + if (!params.containsKey(required)) { + return "webhook_signature_params_incomplete"; + } + } + return null; + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/ContentDigest.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/ContentDigest.java new file mode 100644 index 0000000..a1c5d52 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/ContentDigest.java @@ -0,0 +1,79 @@ +package org.adcontextprotocol.adcp.server.signing; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +/** + * Computes the {@code Content-Digest} header value per RFC 9530. + * + *

Produces headers like: {@code Content-Digest: sha-256=:base64:} + * + *

Per the AdCP conformance test vectors, Content-Digest uses standard + * base64 encoding with padding (RFC 9530 dictionary syntax). The + * {@code Signature} header uses base64url without padding (RFC 9421 §3.3.2). + */ +public final class ContentDigest { + + public static final String SHA_256 = "sha-256"; + public static final String SHA_512 = "sha-512"; + + private ContentDigest() {} + + /** + * Compute a Content-Digest value for the given body bytes using SHA-256. + * + * @param body the raw body bytes + * @return the digest value string (e.g. {@code sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:}) + */ + public static String sha256(byte[] body) { + return compute(SHA_256, body); + } + + /** + * Compute a Content-Digest value for the given body bytes using SHA-512. + * + * @param body the raw body bytes + * @return the digest value string (e.g. {@code sha-512=:...:}) + */ + public static String sha512(byte[] body) { + return compute(SHA_512, body); + } + + /** + * Compute a Content-Digest value for the given body bytes. + * + * @param algorithm the digest algorithm ({@code "sha-256"} or {@code "sha-512"}) + * @param body the raw body bytes + * @return the formatted Content-Digest header value + * @throws IllegalArgumentException if the algorithm is not supported + */ + public static String compute(String algorithm, byte[] body) { + String jcaAlgorithm; + if (SHA_256.equals(algorithm)) { + jcaAlgorithm = "SHA-256"; + } else if (SHA_512.equals(algorithm)) { + jcaAlgorithm = "SHA-512"; + } else { + throw new IllegalArgumentException("Unsupported digest algorithm: " + algorithm); + } + MessageDigest digest; + try { + digest = MessageDigest.getInstance(jcaAlgorithm); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("JCA algorithm not available: " + jcaAlgorithm, e); + } + byte[] hash = digest.digest(body); + String encoded = Base64.getEncoder().encodeToString(hash); + return algorithm + "=:" + encoded + ":"; + } + + /** + * Base64url-encode without padding, per RFC 9421 §3.3.2. + * Used for the Signature header value, not for Content-Digest. + */ + static String base64UrlNoPadding(byte[] data) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(data); + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/HeaderNormalizer.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/HeaderNormalizer.java new file mode 100644 index 0000000..966cda3 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/HeaderNormalizer.java @@ -0,0 +1,43 @@ +package org.adcontextprotocol.adcp.server.signing; + +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * RFC 9421 header name and value normalization. + * + *

Per RFC 9421 §2.1 and §4.1: + *

+ */ +public final class HeaderNormalizer { + + private static final Pattern INNER_OWS = Pattern.compile("[ \\t]+"); + + private HeaderNormalizer() {} + + /** + * Normalize a header field name: lowercase ASCII. + * + * @param name the raw header field name + * @return the lowercased name + */ + public static String normalizeName(String name) { + return name.toLowerCase(Locale.ROOT); + } + + /** + * Normalize a header field value: trim leading/trailing OWS, + * collapse inner OWS to single SP. + * + * @param value the raw header field value + * @return the normalized value + */ + public static String normalizeValue(String value) { + String collapsed = INNER_OWS.matcher(value).replaceAll(" "); + return collapsed.strip(); + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/Rfc9421Canonicalizer.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/Rfc9421Canonicalizer.java new file mode 100644 index 0000000..a87364f --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/Rfc9421Canonicalizer.java @@ -0,0 +1,503 @@ +package org.adcontextprotocol.adcp.server.signing; + +import org.adcontextprotocol.adcp.signing.SigningException; + +import java.net.IDN; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * RFC 9421 §2.5 signature base canonicalizer. + * + *

Takes a {@link org.adcontextprotocol.adcp.signing.SigningInput} and produces + * the signature base — the exact byte sequence that gets signed. Each covered + * component is formatted as {@code "@lowercase-component-name": component-value} + * followed by a newline, then {@code @signature-params: (...)} with the signature + * parameters. + * + *

The canonicalizer follows the AdCP RFC 9421 profile for URL canonicalization: + *

    + *
  1. Lowercase the scheme
  2. + *
  3. Lowercase the host (IDN: UTS-46 Nontransitional ToASCII → Punycode A-label)
  4. + *
  5. Strip userinfo
  6. + *
  7. Strip default port (:443 for https, :80 for http)
  8. + *
  9. Remove dot segments per RFC 3986 §5.2.4
  10. + *
  11. Uppercase percent-encoded hex digits; decode unreserved percent-encoding
  12. + *
  13. Preserve query string byte-for-byte
  14. + *
  15. Strip fragment
  16. + *
+ */ +public final class Rfc9421Canonicalizer { + + private Rfc9421Canonicalizer() {} + + /** + * Build the signature base for the given signing input and parameters. + * + * @param method HTTP method (e.g. "POST") + * @param targetUri the request target URI (will be canonicalized) + * @param headers request headers (name → value) + * @param coveredComponents ordered list of covered component identifiers + * @param signatureInput the Signature-Input header value + * @return the signature base string (lines joined by LF, no trailing LF) + * @throws SigningException if the URI is malformed or components cannot be resolved + */ + public static String canonicalize( + String method, + String targetUri, + Map headers, + List coveredComponents, + String signatureInput) throws SigningException { + + Objects.requireNonNull(method, "method"); + Objects.requireNonNull(targetUri, "targetUri"); + Objects.requireNonNull(headers, "headers"); + Objects.requireNonNull(coveredComponents, "coveredComponents"); + Objects.requireNonNull(signatureInput, "signatureInput"); + + String canonicalUri = canonicalizeTargetUri(targetUri); + String authority = extractAuthority(targetUri); + + StringBuilder sb = new StringBuilder(); + + for (int i = 0; i < coveredComponents.size(); i++) { + String component = coveredComponents.get(i); + String value = resolveComponentValue(component, method, canonicalUri, authority, headers); + sb.append('"').append(component).append('"').append(": ").append(value); + sb.append('\n'); + } + + // Per RFC 9421 §2.5, the @signature-params value is the signature-params + // content WITHOUT the label prefix. E.g., for "sig1=(...);params", the + // @signature-params value is "(...);params". + String signatureParamsValue = stripLabelPrefix(signatureInput); + sb.append("\"@signature-params\": ").append(signatureParamsValue); + + return sb.toString(); + } + + /** + * Canonicalize the target URI per the AdCP RFC 9421 profile algorithm. + * + *

Steps: + * 1. Lowercase the scheme + * 2. Lowercase the host (IDN: UTS-46 Nontransitional ToASCII → Punycode A-label) + * 3. Strip userinfo + * 4. Strip default port (:443 for https, :80 for http) + * 5. Remove dot segments (RFC 3986 §5.2.4) + * 6. Uppercase percent-encoded hex; decode unreserved percent-encoding + * 7. Preserve query string byte-for-byte + * 8. Strip fragment + * + * @param uri the raw URI string + * @return the canonicalized URI string + * @throws SigningException if the URI is malformed + */ + public static String canonicalizeTargetUri(String uri) throws SigningException { + URI parsed; + try { + parsed = new URI(uri); + } catch (URISyntaxException e) { + // Try pre-processing: extract the host part and convert IDN + try { + String asciiUri = preprocessIdnUri(uri); + parsed = new URI(asciiUri); + } catch (URISyntaxException | IllegalArgumentException e2) { + throw new SigningException("Malformed target URI: " + uri, e); + } + } + + // If URI parsed but host is null (non-ASCII host), try IDN pre-processing + if (parsed.getHost() == null || parsed.getHost().isEmpty()) { + try { + String asciiUri = preprocessIdnUri(uri); + URI reparsed = new URI(asciiUri); + if (reparsed.getHost() != null && !reparsed.getHost().isEmpty()) { + parsed = reparsed; + } else { + throw new SigningException("URI missing host: " + uri); + } + } catch (URISyntaxException | IllegalArgumentException e2) { + throw new SigningException("URI missing host: " + uri); + } + } + + String scheme = parsed.getScheme(); + if (scheme == null || scheme.isEmpty()) { + throw new SigningException("URI missing scheme: " + uri); + } + scheme = scheme.toLowerCase(java.util.Locale.ROOT); + + String host = parsed.getHost(); + int port = parsed.getPort(); + + // Reject IPv6 zone identifiers (RFC 6874) — they are node-local + // and have no meaning outside the signing host. + if (host != null && host.contains("%")) { + throw new SigningException("IPv6 zone identifier in signed URL: " + uri); + } + + if (host == null || host.isEmpty()) { + throw new SigningException("URI missing host: " + uri); + } + + // Step 2: Lowercase host with IDN ToASCII (Punycode A-label) + String canonicalHost = canonicalizeHost(host); + + // Step 4: Strip default ports + boolean isDefaultPort = false; + if ("https".equals(scheme) && port == 443) { + isDefaultPort = true; + } else if ("http".equals(scheme) && port == 80) { + isDefaultPort = true; + } + + // Step 5: Remove dot segments from path + String path = parsed.getRawPath(); + if (path == null || path.isEmpty()) { + path = "/"; + } else { + path = removeDotSegments(path); + } + + // Step 6: Normalize percent-encoding + path = normalizePercentEncoding(path); + + // Step 7: Preserve query byte-for-byte + String query = parsed.getRawQuery(); + + // Step 8: Strip fragment (already excluded by URI) + + StringBuilder result = new StringBuilder(); + result.append(scheme).append("://"); + if (host.startsWith("[")) { + // IPv6 + result.append(canonicalHost); + if (port != -1 && !isDefaultPort) { + result.append(':').append(port); + } + } else { + result.append(canonicalHost); + if (port != -1 && !isDefaultPort) { + result.append(':').append(port); + } + } + result.append(path); + if (query != null) { + result.append('?').append(query); + } + + return result.toString(); + } + + /** + * Extract the @authority component from a URI per the AdCP profile. + * + *

The authority is the host (lowercased, IDN-to-ASCII) plus any non-default port. + * For IPv6, brackets are preserved. + */ + public static String extractAuthority(String uri) throws SigningException { + URI parsed; + try { + parsed = new URI(uri); + } catch (URISyntaxException e) { + try { + String asciiUri = preprocessIdnUri(uri); + parsed = new URI(asciiUri); + } catch (URISyntaxException | IllegalArgumentException e2) { + throw new SigningException("Malformed URI for authority extraction: " + uri, e2 instanceof SigningException se ? se : null); + } + } + + // If host is null (non-ASCII), try IDN pre-processing + String host = parsed.getHost(); + if ((host == null || host.isEmpty()) && parsed.getScheme() != null) { + try { + String asciiUri = preprocessIdnUri(uri); + URI reparsed = new URI(asciiUri); + if (reparsed.getHost() != null && !reparsed.getHost().isEmpty()) { + parsed = reparsed; + host = parsed.getHost(); + } + } catch (URISyntaxException | IllegalArgumentException e2) { + throw new SigningException("URI missing host for authority: " + uri); + } + } + + if (host == null || host.isEmpty()) { + throw new SigningException("URI missing host for authority: " + uri); + } + + int port = parsed.getPort(); + String scheme = parsed.getScheme(); + if (scheme == null) { + throw new SigningException("URI missing scheme: " + uri); + } + scheme = scheme.toLowerCase(java.util.Locale.ROOT); + + String canonicalHost = canonicalizeHost(host); + + boolean isDefaultPort = ("https".equals(scheme) && port == 443) + || ("http".equals(scheme) && port == 80); + + if (port != -1 && !isDefaultPort) { + return canonicalHost + ":" + port; + } + return canonicalHost; + } + + /** + * Canonicalize a host: lowercase, IDN ToASCII for non-ASCII hosts. + */ + static String canonicalizeHost(String host) { + if (host.startsWith("[") && host.endsWith("]")) { + // IPv6 — lowercase hex digits inside brackets + String inner = host.substring(1, host.length() - 1); + String lowerInner = inner.toLowerCase(java.util.Locale.ROOT); + return "[" + lowerInner + "]"; + } + // Try IDN ToASCII for international domain names + String ascii; + try { + ascii = IDN.toASCII(host, IDN.USE_STD3_ASCII_RULES); + } catch (IllegalArgumentException e) { + // If IDN conversion fails, just lowercase + ascii = host.toLowerCase(java.util.Locale.ROOT); + } + return ascii.toLowerCase(java.util.Locale.ROOT); + } + + /** + * Pre-process a URI with non-ASCII host characters by converting the host + * to its Punycode ASCII representation. Java's URI class rejects non-ASCII + * hosts, so we need to handle IDN conversion before parsing. + */ + static String preprocessIdnUri(String uri) throws SigningException { + int schemeEnd = uri.indexOf("://"); + if (schemeEnd == -1) { + throw new SigningException("URI missing scheme separator: " + uri); + } + String scheme = uri.substring(0, schemeEnd); + String rest = uri.substring(schemeEnd + 3); + + // Find end of authority (host[:port]) + int pathStart = rest.indexOf('/'); + int queryStart = rest.indexOf('?'); + int fragmentStart = rest.indexOf('#'); + int authorityEnd = rest.length(); + if (pathStart != -1) authorityEnd = Math.min(authorityEnd, pathStart); + if (queryStart != -1) authorityEnd = Math.min(authorityEnd, queryStart); + if (fragmentStart != -1) authorityEnd = Math.min(authorityEnd, fragmentStart); + + String authority = rest.substring(0, authorityEnd); + String remainder = rest.substring(authorityEnd); + + // Handle userinfo in authority + String hostPort = authority; + String userinfo = ""; + int atIdx = authority.indexOf('@'); + if (atIdx != -1) { + hostPort = authority.substring(atIdx + 1); + } + + // Handle port + String hostPart; + String portPart = ""; + if (hostPort.startsWith("[")) { + // IPv6 + int bracketEnd = hostPort.indexOf(']'); + hostPart = hostPort.substring(0, bracketEnd + 1); + if (bracketEnd + 1 < hostPort.length() && hostPort.charAt(bracketEnd + 1) == ':') { + portPart = hostPort.substring(bracketEnd + 1); + } + } else { + int colonIdx = hostPort.lastIndexOf(':'); + if (colonIdx != -1) { + hostPart = hostPort.substring(0, colonIdx); + portPart = hostPort.substring(colonIdx); + } else { + hostPart = hostPort; + } + } + + String asciiHost; + try { + asciiHost = IDN.toASCII(hostPart, IDN.USE_STD3_ASCII_RULES).toLowerCase(java.util.Locale.ROOT); + } catch (IllegalArgumentException e) { + throw new SigningException("Invalid host in URI: " + hostPart, e); + } + String newAuthority = asciiHost + portPart; + + return scheme + "://" + newAuthority + remainder; + } + + /** + * RFC 3986 §5.2.4 — Remove dot segments from a path. + */ + static String removeDotSegments(String path) { + // Input buffer + StringBuilder input = new StringBuilder(path); + // Output buffer + StringBuilder output = new StringBuilder(); + + while (input.length() > 0) { + // A: If the input buffer begins with a prefix of "../" or "./" + if (input.indexOf("../") == 0) { + input.delete(0, 3); + } else if (input.indexOf("./") == 0) { + input.delete(0, 2); + } + // B: If the input buffer begins with a prefix of "/./" or "/." + else if (input.indexOf("/./") == 0) { + input.delete(0, 2); + } else if (input.toString().equals("/.")) { + input.replace(0, input.length(), "/"); + } + // C: If the input buffer begins with a prefix of "/../" or "/.." + else if (input.indexOf("/../") == 0) { + input.delete(0, 3); + removeLastSegment(output); + } else if (input.toString().equals("/..")) { + input.replace(0, input.length(), "/"); + removeLastSegment(output); + } + // D: If the input buffer consists only of "." or ".." + else if (input.toString().equals(".") || input.toString().equals("..")) { + input.setLength(0); + } + // E: Move the first path segment (including initial "/" if any) to output + else { + int slashIndex = input.indexOf("/", 1); + if (slashIndex == -1) { + output.append(input); + input.setLength(0); + } else { + output.append(input, 0, slashIndex); + input.delete(0, slashIndex); + } + } + } + + return output.toString(); + } + + private static void removeLastSegment(StringBuilder output) { + int lastSlash = output.lastIndexOf("/"); + if (lastSlash != -1) { + output.setLength(lastSlash); + } + } + + /** + * Normalize percent-encoding per the AdCP profile: + * - Uppercase percent-encoded hex digits (%2f → %2F) + * - Decode unreserved characters per RFC 3986 §2.3 + */ + static String normalizePercentEncoding(String path) { + StringBuilder sb = new StringBuilder(); + int i = 0; + while (i < path.length()) { + char c = path.charAt(i); + if (c == '%' && i + 2 < path.length()) { + char hex1 = path.charAt(i + 1); + char hex2 = path.charAt(i + 2); + if (isHexDigit(hex1) && isHexDigit(hex2)) { + int byteVal = HexFormat.fromHexDigits(path.substring(i + 1, i + 3)); + if (isUnreserved(byteVal)) { + sb.append((char) byteVal); + } else { + sb.append('%') + .append(Character.toUpperCase(hex1)) + .append(Character.toUpperCase(hex2)); + } + i += 3; + } else { + sb.append(c); + i++; + } + } else { + sb.append(c); + i++; + } + } + return sb.toString(); + } + + private static boolean isHexDigit(char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + } + + /** + * RFC 3986 §2.3 unreserved characters: ALPHA, DIGIT, '-', '.', '_', '~'. + */ + private static boolean isUnreserved(int byteVal) { + return (byteVal >= 'A' && byteVal <= 'Z') + || (byteVal >= 'a' && byteVal <= 'z') + || (byteVal >= '0' && byteVal <= '9') + || byteVal == '-' + || byteVal == '.' + || byteVal == '_' + || byteVal == '~'; + } + + /** + * Resolve a covered component to its value in the signature base. + */ + private static String resolveComponentValue( + String component, + String method, + String canonicalUri, + String authority, + Map headers) { + + return switch (component) { + case "@method" -> method; + case "@target-uri" -> canonicalUri; + case "@authority" -> authority; + case "@request-target" -> { + // Per the user's spec: "@request-target" = method SP request-target + // But per RFC 9421 §2.2.3, @request-target is just the request-target + // We support both interpretations + URI uri = URI.create(canonicalUri); + String pathAndQuery = uri.getRawPath(); + if (uri.getRawQuery() != null) { + pathAndQuery = pathAndQuery + "?" + uri.getRawQuery(); + } + yield method + " " + pathAndQuery; + } + default -> { + if (component.startsWith("@")) { + throw new IllegalArgumentException("Unsupported pseudo-header: " + component); + } + // Regular header — look up by lowercase name + String normalizedName = HeaderNormalizer.normalizeName(component); + String value = headers.get(normalizedName); + if (value == null) { + // Try case-insensitive lookup + for (Map.Entry entry : headers.entrySet()) { + if (entry.getKey().equalsIgnoreCase(component)) { + value = entry.getValue(); + break; + } + } + } + if (value == null) { + throw new IllegalArgumentException("Header not found for covered component: " + component); + } + yield HeaderNormalizer.normalizeValue(value); + } + }; + } + + static String stripLabelPrefix(String signatureInput) { + int eqIdx = signatureInput.indexOf('='); + if (eqIdx == -1) { + return signatureInput; + } + return signatureInput.substring(eqIdx + 1); + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/Rfc9421Signer.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/Rfc9421Signer.java new file mode 100644 index 0000000..d618918 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/Rfc9421Signer.java @@ -0,0 +1,135 @@ +package org.adcontextprotocol.adcp.server.signing; + +import org.adcontextprotocol.adcp.signing.AdcpUse; +import org.adcontextprotocol.adcp.signing.SigningContext; +import org.adcontextprotocol.adcp.signing.SigningException; +import org.adcontextprotocol.adcp.signing.SigningInput; +import org.adcontextprotocol.adcp.signing.SigningProvider; +import org.adcontextprotocol.adcp.signing.Signature; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.PrivateKey; +import java.security.SignatureException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * RFC 9421 signing implementation. Takes a {@link SigningContext} + + * {@link SigningInput} + key material and produces the Signature-Input header + * value, the Signature header value, and the Content-Digest header value. + * + *

Uses {@link Rfc9421Canonicalizer} internally to build the signature base. + */ +public final class Rfc9421Signer { + + private Rfc9421Signer() {} + + /** + * Sign the given input and return the complete set of signature headers. + * + * @param context signing context (determines purpose, tag, required components) + * @param input the signing input (method, URI, headers, body) + * @param keyId the key identifier for the Signature-Input header + * @param alg the algorithm identifier (e.g. "ed25519", "ecdsa-p256-sha256") + * @param privateKey the private key bytes (PKCS8 for Ed25519/ECDSA) + * @param created Unix seconds timestamp for signature creation + * @param expires Unix seconds timestamp for signature expiration + * @param nonce the nonce value for replay protection + * @return a SignedOutput containing the three header values + * @throws SigningException if signing fails + */ + public static SignedOutput sign( + SigningContext context, + SigningInput input, + String keyId, + String alg, + byte[] privateKey, + long created, + long expires, + String nonce) throws SigningException { + + AdcpUse use = context.use(); + String tag = AdcpSignatureProfile.tagForUse(use); + List coveredComponents = AdcpSignatureProfile.requiredComponentsForUse(use); + + // Build Signature-Input header + String signatureInputValue = SignatureInputBuilder.create() + .label("sig1") + .coveredComponents(coveredComponents) + .created(created) + .expires(expires) + .nonce(nonce) + .keyid(keyId) + .alg(alg) + .tag(tag) + .build(); + + // Compute Content-Digest if body is present + String contentDigestValue = null; + Map signingHeaders = new LinkedHashMap<>(input.headers()); + if (input.body() != null && input.body().length > 0) { + contentDigestValue = ContentDigest.sha256(input.body()); + signingHeaders.put("content-digest", contentDigestValue); + } + + // Build signature base + String signatureBase = Rfc9421Canonicalizer.canonicalize( + input.method(), + input.targetUri(), + signingHeaders, + coveredComponents, + signatureInputValue); + + // Sign the signature base + byte[] signatureBytes = doSign(alg, privateKey, signatureBase.getBytes(StandardCharsets.UTF_8)); + String signatureEncoded = ContentDigest.base64UrlNoPadding(signatureBytes); + + return new SignedOutput( + signatureInputValue, + "sig1=:" + signatureEncoded + ":", + contentDigestValue + ); + } + + /** + * Perform the cryptographic signature. + */ + private static byte[] doSign(String alg, byte[] privateKeyBytes, byte[] data) throws SigningException { + String jcaAlgorithm; + if (AdcpSignatureProfile.ALG_ED25519.equals(alg)) { + jcaAlgorithm = "Ed25519"; + } else if (AdcpSignatureProfile.ALG_ECDSA_P256_SHA256.equals(alg)) { + jcaAlgorithm = "SHA256withECDSAinP1363Format"; + } else { + throw new SigningException("Unsupported signing algorithm: " + alg); + } + + try { + java.security.KeyFactory kf = java.security.KeyFactory.getInstance(jcaAlgorithm.equals("Ed25519") ? "Ed25519" : "EC"); + PrivateKey privateKey = kf.generatePrivate(new PKCS8EncodedKeySpec(privateKeyBytes)); + java.security.Signature signer = java.security.Signature.getInstance(jcaAlgorithm); + signer.initSign(privateKey); + signer.update(data); + return signer.sign(); + } catch (Exception e) { + throw new SigningException("Failed to sign: " + e.getMessage(), e); + } + } + + /** + * Output of the signing operation: the three headers that need to be sent. + * + * @param signatureInputValue the Signature-Input header value + * @param signatureValue the Signature header value + * @param contentDigestValue the Content-Digest header value, or null if no body + */ + public record SignedOutput( + String signatureInputValue, + String signatureValue, + String contentDigestValue + ) {} +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/Rfc9421Verifier.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/Rfc9421Verifier.java new file mode 100644 index 0000000..f093471 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/Rfc9421Verifier.java @@ -0,0 +1,375 @@ +package org.adcontextprotocol.adcp.server.signing; + +import org.adcontextprotocol.adcp.signing.AdcpUse; +import org.adcontextprotocol.adcp.signing.SignedInput; +import org.adcontextprotocol.adcp.signing.VerificationException; +import org.adcontextprotocol.adcp.signing.VerificationKey; +import org.jspecify.annotations.Nullable; + +import java.nio.charset.StandardCharsets; +import java.security.PublicKey; +import java.security.Signature; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * RFC 9421 verification implementation. Takes a {@link SignedInput} (raw bytes + * and headers from the wire) + a {@link VerificationKey} and verifies the signature. + * + *

Produces a {@link VerificationResult} (sealed interface: {@code Valid} or + * {@code Invalid} with error code). + */ +public final class Rfc9421Verifier { + + private Rfc9421Verifier() {} + + private static final Pattern SIGNATURE_INPUT_PATTERN = Pattern.compile( + "(\\w+)=\\(([^)]*)\\)((?:;[^=]+=\\([^)]*\\)|;\\w+=\"[^\"]*\"|;\\w+=\\d+)+)" + ); + + private static final String WEBCON_TAG = "adcp/webhook-signing/v1"; + private static final String REQ_TAG = "adcp/request-signing/v1"; + + /** + * Verify an inbound webhook signature. + * + * @param input the signed input from the wire + * @param key the verification key + * @param purpose the expected AdCP use (must be WEBHOOK_SIGNING or REQUEST_SIGNING) + * @param referenceNow Unix seconds representing "now" for window validation + * @return a VerificationResult + */ + public static VerificationResult verify( + SignedInput input, + VerificationKey key, + AdcpUse purpose, + long referenceNow) { + + String prefix = purpose == AdcpUse.WEBHOOK_SIGNING ? "webhook_signature_" : "request_signature_"; + + // Step 1: Parse Signature-Input header + String sigInputHeader = firstHeader(input.headers(), "Signature-Input"); + if (sigInputHeader == null) { + return new VerificationResult.Invalid(prefix + "header_malformed", + "Missing Signature-Input header"); + } + + String sigHeader = firstHeader(input.headers(), "Signature"); + if (sigHeader == null) { + return new VerificationResult.Invalid(prefix + "header_malformed", + "Missing Signature header"); + } + + // Parse the label — we look for "sig1" per AdCP convention + ParsedSignatureInput parsed; + try { + parsed = parseSignatureInput(sigInputHeader); + } catch (IllegalArgumentException e) { + return new VerificationResult.Invalid(prefix + "header_malformed", + "Malformed Signature-Input: " + e.getMessage()); + } + + // Step 2: Check required signature parameters + for (String required : AdcpSignatureProfile.REQUIRED_SIGNATURE_PARAMS) { + if (!parsed.params().containsKey(required)) { + return new VerificationResult.Invalid(prefix + "params_incomplete", + "Missing required signature parameter: " + required); + } + } + + // Step 3: Check tag + String tag = parsed.params().get("tag"); + String expectedTag = AdcpSignatureProfile.tagForUse(purpose); + if (tag == null || !expectedTag.equals(tag)) { + return new VerificationResult.Invalid(prefix + "tag_invalid", + "Expected tag=" + expectedTag + ", got: " + tag); + } + + // Step 4: Check alg + String alg = parsed.params().get("alg"); + if (alg == null || !AdcpSignatureProfile.isAlgorithmAllowed(alg)) { + return new VerificationResult.Invalid(prefix + "alg_not_allowed", + "Algorithm not allowed: " + alg); + } + + // Step 5: Check signature window + long created = Long.parseLong(parsed.params().getOrDefault("created", "0")); + long expires = Long.parseLong(parsed.params().getOrDefault("expires", "0")); + if (expires <= created) { + return new VerificationResult.Invalid(prefix + "window_invalid", + "expires <= created"); + } + if (referenceNow > expires || referenceNow < created) { + return new VerificationResult.Invalid(prefix + "window_invalid", + "Signature window expired or not yet valid"); + } + if (expires - created > AdcpSignatureProfile.REPLAY_WINDOW_SECONDS) { + return new VerificationResult.Invalid(prefix + "window_invalid", + "Signature window exceeds " + AdcpSignatureProfile.REPLAY_WINDOW_SECONDS + "s"); + } + + // Step 6: Check required covered components + String componentsError = AdcpSignatureProfile.validateRequiredComponents(purpose, parsed.components()); + if (componentsError != null) { + return new VerificationResult.Invalid(componentsError, + "Missing required covered component(s)"); + } + + // Step 7: Key lookup — handled by caller, key is provided + + // Step 8: Key purpose check — handled by caller via VerificationKeyLookup + + // Steps 9-9a: Revocation and rate checks — stateful, handled by caller + + // Step 10: Verify signature + // Reconstruct the signature base + String signatureBase; + try { + String signatureInputForBase = buildSignatureInputString(parsed); + Map headers = input.headers(); + + // Content-Digest verification (step 11) + String contentDigestHeader = firstHeader(headers, "Content-Digest"); + if (contentDigestHeader != null && input.rawBody() != null && input.rawBody().length > 0) { + String expectedDigest = ContentDigest.sha256(input.rawBody()); + if (!contentDigestHeader.equals(expectedDigest)) { + return new VerificationResult.Invalid(prefix + "digest_mismatch", + "Content-Digest header does not match body"); + } + } + + signatureBase = Rfc9421Canonicalizer.canonicalize( + input.method(), + input.targetUri(), + headers, + parsed.components(), + signatureInputForBase); + } catch (Exception e) { + return new VerificationResult.Invalid(prefix + "header_malformed", + "Failed to build signature base: " + e.getMessage()); + } + + // Extract the signature bytes from the Signature header + byte[] signatureBytes = extractSignatureBytes(sigHeader, parsed.label()); + if (signatureBytes == null) { + return new VerificationResult.Invalid(prefix + "header_malformed", + "Could not extract signature bytes"); + } + + // Verify using the public key + try { + PublicKey publicKey = key.asJcaKey(); + String jcaAlg; + if (AdcpSignatureProfile.ALG_ED25519.equals(alg)) { + jcaAlg = "Ed25519"; + } else if (AdcpSignatureProfile.ALG_ECDSA_P256_SHA256.equals(alg)) { + jcaAlg = "SHA256withECDSAinP1363Format"; + } else { + return new VerificationResult.Invalid(prefix + "alg_not_allowed", + "Unsupported algorithm: " + alg); + } + + Signature verifier = Signature.getInstance(jcaAlg); + verifier.initVerify(publicKey); + verifier.update(signatureBase.getBytes(StandardCharsets.UTF_8)); + boolean valid = verifier.verify(signatureBytes); + if (!valid) { + return new VerificationResult.Invalid(prefix + "invalid", + "Signature verification failed"); + } + } catch (Exception e) { + return new VerificationResult.Invalid(prefix + "invalid", + "Signature verification error: " + e.getMessage()); + } + + return new VerificationResult.Valid(key.kid()); + } + + /** + * Reconstruct the Signature-Input string from parsed components for the signature base. + */ + static String buildSignatureInputString(ParsedSignatureInput parsed) { + StringBuilder sb = new StringBuilder(); + sb.append(parsed.label()).append("=("); + List components = parsed.components(); + for (int i = 0; i < components.size(); i++) { + if (i > 0) sb.append(' '); + sb.append('"').append(components.get(i)).append('"'); + } + sb.append(')'); + sb.append(";created=").append(parsed.params().get("created")); + sb.append(";expires=").append(parsed.params().get("expires")); + sb.append(";nonce=\"").append(parsed.params().get("nonce")).append('"'); + sb.append(";keyid=\"").append(parsed.params().get("keyid")).append('"'); + sb.append(";alg=\"").append(parsed.params().get("alg")).append('"'); + sb.append(";tag=\"").append(parsed.params().get("tag")).append('"'); + return sb.toString(); + } + + private static @Nullable String firstHeader(Map headers, String name) { + for (Map.Entry entry : headers.entrySet()) { + if (entry.getKey().equalsIgnoreCase(name)) { + return entry.getValue(); + } + } + return null; + } + + /** + * Parse the Signature-Input header value. + * + *

Example: {@code sig1=("@method" "@target-uri" "@authority" "content-type" "content-digest");created=1776520800;expires=1776521100;nonce="KXYnfEfJ0PBRZXQyVXfVQA";keyid="test-ed25519-webhook-2026";alg="ed25519";tag="adcp/webhook-signing/v1"} + */ + static ParsedSignatureInput parseSignatureInput(String header) { + // Find the sig1 label specifically (AdCP convention) + // In case of multiple labels: "sig1=(...);..., relay=(...);..." + // We need to extract just the sig1 portion. + String sig1Portion = extractLabelPortion(header, "sig1"); + if (sig1Portion == null) { + // Fall back to first label + sig1Portion = header; + } + + int eqIdx = sig1Portion.indexOf('='); + if (eqIdx == -1) { + throw new IllegalArgumentException("Missing '=' in Signature-Input"); + } + String label = sig1Portion.substring(0, eqIdx).trim(); + + // Find the component list between parens + int openParen = sig1Portion.indexOf('(', eqIdx); + int closeParen = sig1Portion.indexOf(')', openParen); + if (openParen == -1 || closeParen == -1) { + throw new IllegalArgumentException("Missing component list in Signature-Input"); + } + + String componentList = sig1Portion.substring(openParen + 1, closeParen).trim(); + List components = new ArrayList<>(); + for (String component : componentList.split("\\s+")) { + String trimmed = component.trim(); + if (!trimmed.isEmpty()) { + if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) { + trimmed = trimmed.substring(1, trimmed.length() - 1); + } + components.add(trimmed); + } + } + + String paramsStr = sig1Portion.substring(closeParen + 1); + Map params = new java.util.LinkedHashMap<>(); + parseParams(paramsStr, params); + + return new ParsedSignatureInput(label, components, params); + } + + /** + * Extract a specific label's portion from a multi-label Signature-Input header. + * E.g., from "sig1=(...);..., relay=(...);..." extract "sig1=(...);..." + */ + static @Nullable String extractLabelPortion(String header, String targetLabel) { + // Find the target label + int start = header.indexOf(targetLabel + "="); + if (start == -1) return null; + + // Find the end: either a comma followed by another label, or end of string + // The boundary is ", labelName=(" — but we need to skip past quoted strings + // and parenthesized groups. + int pos = start; + int depth = 0; + boolean inQuote = false; + while (pos < header.length()) { + char c = header.charAt(pos); + if (inQuote) { + if (c == '\\' && pos + 1 < header.length()) { + pos += 2; + continue; + } + if (c == '"') { + inQuote = false; + } + pos++; + continue; + } + if (c == '"') { + inQuote = true; + pos++; + continue; + } + if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + } else if (c == ',' && depth == 0) { + // Check if the next non-space is a label (word followed by =) + int nextPos = pos + 1; + while (nextPos < header.length() && header.charAt(nextPos) == ' ') nextPos++; + // This comma separates labels + return header.substring(start, pos); + } + pos++; + } + return header.substring(start); + } + + private static void parseParams(String paramsStr, Map params) { + int i = 0; + while (i < paramsStr.length()) { + if (paramsStr.charAt(i) == ';') { + i++; + } + // Skip whitespace + while (i < paramsStr.length() && paramsStr.charAt(i) == ' ') i++; + if (i >= paramsStr.length()) break; + + // Read param name + int nameStart = i; + while (i < paramsStr.length() && paramsStr.charAt(i) != '=' && paramsStr.charAt(i) != ';') i++; + String name = paramsStr.substring(nameStart, i).trim(); + if (name.isEmpty()) continue; + + if (i < paramsStr.length() && paramsStr.charAt(i) == '=') { + i++; + if (i < paramsStr.length() && paramsStr.charAt(i) == '"') { + // Quoted string + i++; // skip opening quote + int valueStart = i; + while (i < paramsStr.length() && paramsStr.charAt(i) != '"') i++; + String value = paramsStr.substring(valueStart, i); + params.put(name, value); + i++; // skip closing quote + } else { + // Unquoted value (bare integer) + int valueStart = i; + while (i < paramsStr.length() && paramsStr.charAt(i) != ';') i++; + String value = paramsStr.substring(valueStart, i).trim(); + params.put(name, value); + } + } + } + } + + private static @Nullable byte[] extractSignatureBytes(String sigHeader, String label) { + // Format: sig1=:base64url-no-padding: + String prefix = label + "=:"; + int start = sigHeader.indexOf(prefix); + if (start == -1) return null; + start += prefix.length(); + int end = sigHeader.indexOf(':', start); + if (end == -1) return null; + String b64 = sigHeader.substring(start, end); + return Base64.getUrlDecoder().decode(b64); + } + + /** + * Parsed Signature-Input components. + */ + record ParsedSignatureInput( + String label, + List components, + Map params + ) {} +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/SignatureInputBuilder.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/SignatureInputBuilder.java new file mode 100644 index 0000000..df2d61b --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/SignatureInputBuilder.java @@ -0,0 +1,99 @@ +package org.adcontextprotocol.adcp.server.signing; + +import java.util.List; +import java.util.Objects; + +/** + * Builds the RFC 9421 §2.3 {@code Signature-Input} header value string. + * + *

Produces strings like: + *

+ * sig1=("@method" "@target-uri" "@authority" "content-type" "content-digest");created=1776520800;expires=1776521100;nonce="KXYnfEfJ0PBRZXQyVXfVQA";keyid="test-ed25519-webhook-2026";alg="ed25519";tag="adcp/webhook-signing/v1"
+ * 
+ */ +public final class SignatureInputBuilder { + + private String label = "sig1"; + private List coveredComponents = List.of(); + private long created; + private long expires; + private String nonce = ""; + private String keyid = ""; + private String alg = ""; + private String tag = ""; + + private SignatureInputBuilder() {} + + public static SignatureInputBuilder create() { + return new SignatureInputBuilder(); + } + + public SignatureInputBuilder label(String label) { + this.label = Objects.requireNonNull(label); + return this; + } + + public SignatureInputBuilder coveredComponents(List coveredComponents) { + this.coveredComponents = Objects.requireNonNull(coveredComponents); + return this; + } + + public SignatureInputBuilder created(long created) { + this.created = created; + return this; + } + + public SignatureInputBuilder expires(long expires) { + this.expires = expires; + return this; + } + + public SignatureInputBuilder nonce(String nonce) { + this.nonce = Objects.requireNonNull(nonce); + return this; + } + + public SignatureInputBuilder keyid(String keyid) { + this.keyid = Objects.requireNonNull(keyid); + return this; + } + + public SignatureInputBuilder alg(String alg) { + this.alg = Objects.requireNonNull(alg); + return this; + } + + public SignatureInputBuilder tag(String tag) { + this.tag = Objects.requireNonNull(tag); + return this; + } + + /** + * Build the Signature-Input header value per RFC 9421 §2.3. + * + *

Format: {@code label=(component-ids);created=...;expires=...;nonce="...";keyid="...";alg="...";tag="..."} + * + *

Component identifiers are quoted strings. Pseudo-headers use + * the {@code @}-prefixed form (e.g. {@code "@method"}). + * Header field names are lowercased per RFC 9421 §2.1. + */ + public String build() { + StringBuilder sb = new StringBuilder(); + sb.append(label).append("=("); + for (int i = 0; i < coveredComponents.size(); i++) { + if (i > 0) { + sb.append(' '); + } + String component = coveredComponents.get(i); + sb.append('"').append(component).append('"'); + } + sb.append(')'); + sb.append(";created=").append(created); + sb.append(";expires=").append(expires); + sb.append(";nonce=\"").append(nonce).append('"'); + sb.append(";keyid=\"").append(keyid).append('"'); + sb.append(";alg=\"").append(alg).append('"'); + sb.append(";tag=\"").append(tag).append('"'); + return sb.toString(); + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/VerificationResult.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/VerificationResult.java new file mode 100644 index 0000000..4d44ef4 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/VerificationResult.java @@ -0,0 +1,37 @@ +package org.adcontextprotocol.adcp.server.signing; + +import org.jspecify.annotations.Nullable; + +/** + * Sealed interface for verification results. + * + *

{@link Valid} indicates the signature verified successfully. + * {@link Invalid} carries the error code from the spec taxonomy + * (e.g. {@code webhook_signature_invalid}, + * {@code webhook_signature_key_unknown}). + */ +public sealed interface VerificationResult { + + /** + * Successful verification. + * + * @param kid the key identifier that was used + */ + record Valid(String kid) implements VerificationResult { + public Valid { + if (kid == null) throw new NullPointerException("kid"); + } + } + + /** + * Failed verification with an error code from the taxonomy. + * + * @param errorCode the stable error code (e.g. {@code webhook_signature_invalid}) + * @param message human-readable description + */ + record Invalid(String errorCode, @Nullable String message) implements VerificationResult { + public Invalid { + if (errorCode == null) throw new NullPointerException("errorCode"); + } + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/package-info.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/package-info.java new file mode 100644 index 0000000..bf42bfc --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/package-info.java @@ -0,0 +1,18 @@ +/** + * RFC 9421 canonicalizer, signer, verifier, and AdCP profile for the Java SDK. + * + *

This package implements the server-side RFC 9421 signature base construction + * (canonicalizer), Signature-Input header generation (builder), Content-Digest + * computation (RFC 9530), signing, verification, and the AdCP-specific profile + * constraints for webhook and request signing. + * + *

Core SPI types live in {@code org.adcontextprotocol.adcp.signing} (the + * {@code adcp} module). This package provides the concrete implementations. + * + * @see Rfc9421Canonicalizer + * @see Rfc9421Signer + * @see Rfc9421Verifier + * @see AdcpSignatureProfile + */ +@org.jspecify.annotations.NullMarked +package org.adcontextprotocol.adcp.server.signing; \ No newline at end of file diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/AdcpSignatureProfileTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/AdcpSignatureProfileTest.java new file mode 100644 index 0000000..3a15e89 --- /dev/null +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/AdcpSignatureProfileTest.java @@ -0,0 +1,103 @@ +package org.adcontextprotocol.adcp.server.signing; + +import org.adcontextprotocol.adcp.signing.AdcpUse; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class AdcpSignatureProfileTest { + + @Test + void tagForUse_webhook() { + assertEquals("adcp/webhook-signing/v1", AdcpSignatureProfile.tagForUse(AdcpUse.WEBHOOK_SIGNING)); + } + + @Test + void tagForUse_request() { + assertEquals("adcp/request-signing/v1", AdcpSignatureProfile.tagForUse(AdcpUse.REQUEST_SIGNING)); + } + + @Test + void requiredComponentsForUse_webhook() { + List components = AdcpSignatureProfile.requiredComponentsForUse(AdcpUse.WEBHOOK_SIGNING); + assertEquals(List.of("@method", "@target-uri", "@authority", "content-type", "content-digest"), components); + } + + @Test + void requiredComponentsForUse_request() { + List components = AdcpSignatureProfile.requiredComponentsForUse(AdcpUse.REQUEST_SIGNING); + assertEquals(List.of("@method", "@target-uri", "@authority", "content-type"), components); + } + + @Test + void isAlgorithmAllowed_ed25519() { + assertTrue(AdcpSignatureProfile.isAlgorithmAllowed("ed25519")); + } + + @Test + void isAlgorithmAllowed_ecdsa() { + assertTrue(AdcpSignatureProfile.isAlgorithmAllowed("ecdsa-p256-sha256")); + } + + @Test + void isAlgorithmAllowed_rsaRejected() { + assertFalse(AdcpSignatureProfile.isAlgorithmAllowed("rsa-pss-sha512")); + } + + @Test + void validateRequiredComponents_webhookComplete() { + List components = List.of("@method", "@target-uri", "@authority", "content-type", "content-digest"); + assertNull(AdcpSignatureProfile.validateRequiredComponents(AdcpUse.WEBHOOK_SIGNING, components)); + } + + @Test + void validateRequiredComponents_webhookMissingContentDigest() { + List components = List.of("@method", "@target-uri", "@authority", "content-type"); + String error = AdcpSignatureProfile.validateRequiredComponents(AdcpUse.WEBHOOK_SIGNING, components); + assertEquals("webhook_signature_components_incomplete", error); + } + + @Test + void validateRequiredComponents_webhookMissingAuthority() { + List components = List.of("@method", "@target-uri", "content-type", "content-digest"); + String error = AdcpSignatureProfile.validateRequiredComponents(AdcpUse.WEBHOOK_SIGNING, components); + assertEquals("webhook_signature_components_incomplete", error); + } + + @Test + void validateRequiredComponents_requestComplete() { + List components = List.of("@method", "@target-uri", "@authority", "content-type"); + assertNull(AdcpSignatureProfile.validateRequiredComponents(AdcpUse.REQUEST_SIGNING, components)); + } + + @Test + void validateRequiredParams_complete() { + java.util.Map params = java.util.Map.of( + "created", "1776520800", + "expires", "1776521100", + "nonce", "abc", + "keyid", "key1", + "alg", "ed25519" + ); + assertNull(AdcpSignatureProfile.validateRequiredParams(params)); + } + + @Test + void validateRequiredParams_missingNonce() { + java.util.Map params = java.util.Map.of( + "created", "1776520800", + "expires", "1776521100", + "keyid", "key1", + "alg", "ed25519" + ); + String error = AdcpSignatureProfile.validateRequiredParams(params); + assertEquals("webhook_signature_params_incomplete", error); + } + + @Test + void replayWindow() { + assertEquals(300, AdcpSignatureProfile.REPLAY_WINDOW_SECONDS); + } +} \ No newline at end of file diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/ContentDigestTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/ContentDigestTest.java new file mode 100644 index 0000000..adb14b4 --- /dev/null +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/ContentDigestTest.java @@ -0,0 +1,58 @@ +package org.adcontextprotocol.adcp.server.signing; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class ContentDigestTest { + + @Test + void sha256_knownBody() { + // From webhook test vector 001: body has Content-Digest sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=: + String body = "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}"; + String result = ContentDigest.sha256(body.getBytes(StandardCharsets.UTF_8)); + assertEquals("sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", result); + } + + @Test + void sha256_emptyBody() { + String result = ContentDigest.sha256("".getBytes(StandardCharsets.UTF_8)); + assertTrue(result.startsWith("sha-256=:")); + assertTrue(result.endsWith(":")); + // Standard base64 format: may contain +, /, and = padding + String digest = result.substring("sha-256=:".length(), result.length() - 1); + assertFalse(digest.isEmpty()); + } + + @Test + void sha512_knownBody() { + String body = "hello"; + String result = ContentDigest.sha512(body.getBytes(StandardCharsets.UTF_8)); + assertTrue(result.startsWith("sha-512=:")); + assertTrue(result.endsWith(":")); + } + + @Test + void unsupportedAlgorithmThrows() { + assertThrows(IllegalArgumentException.class, () -> ContentDigest.compute("sha-1", "test".getBytes(StandardCharsets.UTF_8))); + } + + @Test + void base64UrlNoPadding_noPadding() { + byte[] data = new byte[]{0x01, 0x02, 0x03}; + String encoded = ContentDigest.base64UrlNoPadding(data); + assertFalse(encoded.contains("=")); + assertFalse(encoded.contains("+")); + assertFalse(encoded.contains("/")); + } + + @Test + void base64UrlNoPadding_roundTrip() { + byte[] data = "test data for encoding".getBytes(StandardCharsets.UTF_8); + String encoded = ContentDigest.base64UrlNoPadding(data); + byte[] decoded = java.util.Base64.getUrlDecoder().decode(encoded); + assertArrayEquals(data, decoded); + } +} \ No newline at end of file diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/HeaderNormalizerTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/HeaderNormalizerTest.java new file mode 100644 index 0000000..8de50cb --- /dev/null +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/HeaderNormalizerTest.java @@ -0,0 +1,50 @@ +package org.adcontextprotocol.adcp.server.signing; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class HeaderNormalizerTest { + + @Test + void normalizeName_lowercases() { + assertEquals("content-type", HeaderNormalizer.normalizeName("Content-Type")); + assertEquals("content-type", HeaderNormalizer.normalizeName("CONTENT-TYPE")); + assertEquals("content-type", HeaderNormalizer.normalizeName("content-type")); + } + + @Test + void normalizeValue_trimsLeadingTrailingOWS() { + assertEquals("application/json", HeaderNormalizer.normalizeValue(" application/json ")); + assertEquals("application/json", HeaderNormalizer.normalizeValue("\tapplication/json\t")); + } + + @Test + void normalizeValue_collapsesInnerOWS() { + assertEquals("application/json", HeaderNormalizer.normalizeValue("application/json")); + assertEquals("a b", HeaderNormalizer.normalizeValue("a b")); + assertEquals("a b", HeaderNormalizer.normalizeValue("a\tb")); + assertEquals("a b", HeaderNormalizer.normalizeValue("a \t b")); + } + + @Test + void normalizeValue_mixedOWS() { + assertEquals("application/json; charset=utf-8", + HeaderNormalizer.normalizeValue(" application/json; \t charset=utf-8 ")); + } + + @Test + void normalizeValue_noChangesNeeded() { + assertEquals("application/json", HeaderNormalizer.normalizeValue("application/json")); + } + + @Test + void normalizeValue_emptyString() { + assertEquals("", HeaderNormalizer.normalizeValue("")); + } + + @Test + void normalizeValue_onlyWhitespace() { + assertEquals("", HeaderNormalizer.normalizeValue(" \t ")); + } +} \ No newline at end of file diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/Rfc9421CanonicalizerTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/Rfc9421CanonicalizerTest.java new file mode 100644 index 0000000..8b8a28e --- /dev/null +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/Rfc9421CanonicalizerTest.java @@ -0,0 +1,263 @@ +package org.adcontextprotocol.adcp.server.signing; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.adcontextprotocol.adcp.signing.SigningException; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +class Rfc9421CanonicalizerTest { + + private static final ObjectMapper OM = new ObjectMapper(); + + @TestFactory + Stream canonicalizationPositiveCases() throws IOException { + JsonNode root = loadResource("/compliance/request-signing/canonicalization.json"); + List tests = new ArrayList<>(); + for (JsonNode caseNode : root.get("cases")) { + if (caseNode.has("reject") && caseNode.get("reject").asBoolean()) continue; + String name = caseNode.get("name").asText(); + String inputUrl = caseNode.get("input_url").asText(); + String expectedTargetUri = caseNode.get("expected_target_uri").asText(); + String expectedAuthority = caseNode.get("expected_authority").asText(); + tests.add(DynamicTest.dynamicTest("canonicalize_" + name, () -> { + String canonicalUri = Rfc9421Canonicalizer.canonicalizeTargetUri(inputUrl); + assertEquals(expectedTargetUri, canonicalUri, "target-uri mismatch for: " + inputUrl); + String authority = Rfc9421Canonicalizer.extractAuthority(inputUrl); + assertEquals(expectedAuthority, authority, "authority mismatch for: " + inputUrl); + })); + } + return tests.stream(); + } + + @TestFactory + Stream canonicalizationRejectCases() throws IOException { + JsonNode root = loadResource("/compliance/request-signing/canonicalization.json"); + List tests = new ArrayList<>(); + for (JsonNode caseNode : root.get("cases")) { + if (!caseNode.has("reject") || !caseNode.get("reject").asBoolean()) continue; + String name = caseNode.get("name").asText(); + String inputUrl = caseNode.get("input_url").asText(); + tests.add(DynamicTest.dynamicTest("reject_" + name, () -> { + assertThrows(SigningException.class, + () -> Rfc9421Canonicalizer.canonicalizeTargetUri(inputUrl), + "Should reject: " + inputUrl); + })); + } + return tests.stream(); + } + + @TestFactory + Stream webhookPositiveSignatureBaseMatches() throws Exception { + List tests = new ArrayList<>(); + + String[] webhookPositiveFiles = { + "/compliance/webhook-signing/positive/001-basic-post.json", + "/compliance/webhook-signing/positive/002-es256-post.json", + "/compliance/webhook-signing/positive/003-multiple-signature-labels.json", + "/compliance/webhook-signing/positive/004-default-port-stripped.json", + "/compliance/webhook-signing/positive/005-percent-encoded-path.json", + "/compliance/webhook-signing/positive/006-query-byte-preserved.json", + "/compliance/webhook-signing/positive/007-body-without-idempotency-key.json" + }; + + for (String resourcePath : webhookPositiveFiles) { + JsonNode vector = loadResource(resourcePath); + String name = vector.get("name").asText(); + tests.add(DynamicTest.dynamicTest("webhook_" + name.replace(' ', '_'), () -> { + String expectedBase = vector.get("expected_signature_base").asText(); + String computedBase = computeSignatureBase(vector); + assertEquals(expectedBase, computedBase, "Signature base mismatch for: " + name); + })); + } + + return tests.stream(); + } + + @TestFactory + Stream requestSigningPositiveSignatureBaseMatches() throws Exception { + String[] requestPositiveFiles = { + "/compliance/request-signing/positive/001-basic-post.json", + "/compliance/request-signing/positive/002-post-with-content-digest.json", + "/compliance/request-signing/positive/003-es256-post.json", + "/compliance/request-signing/positive/004-multiple-signature-labels.json", + "/compliance/request-signing/positive/005-default-port-stripped.json", + "/compliance/request-signing/positive/006-dot-segment-path.json", + "/compliance/request-signing/positive/007-query-byte-preserved.json", + "/compliance/request-signing/positive/008-percent-encoded-path.json" + }; + + List tests = new ArrayList<>(); + for (String resourcePath : requestPositiveFiles) { + try { + JsonNode vector = loadResource(resourcePath); + String name = vector.get("name").asText(); + // Skip vectors without expected_signature_base (e.g. multiple labels) + if (!vector.has("expected_signature_base")) { + continue; + } + tests.add(DynamicTest.dynamicTest("request_" + name.replace(' ', '_'), () -> { + String expectedBase = vector.get("expected_signature_base").asText(); + String computedBase = computeSignatureBase(vector); + assertEquals(expectedBase, computedBase, "Signature base mismatch for: " + name); + })); + } catch (Exception e) { + // Skip missing files gracefully + } + } + return tests.stream(); + } + + @Test + void canonicalizeTargetUri_lowercaseScheme() throws SigningException { + String result = Rfc9421Canonicalizer.canonicalizeTargetUri("HTTPS://example.com/path"); + assertEquals("https://example.com/path", result); + } + + @Test + void canonicalizeTargetUri_lowercaseHost() throws SigningException { + String result = Rfc9421Canonicalizer.canonicalizeTargetUri("https://Seller.Example.COM/path"); + assertEquals("https://seller.example.com/path", result); + } + + @Test + void canonicalizeTargetUri_stripDefaultPort443() throws SigningException { + String result = Rfc9421Canonicalizer.canonicalizeTargetUri("https://example.com:443/path"); + assertEquals("https://example.com/path", result); + } + + @Test + void canonicalizeTargetUri_stripDefaultPort80() throws SigningException { + String result = Rfc9421Canonicalizer.canonicalizeTargetUri("http://example.com:80/path"); + assertEquals("http://example.com/path", result); + } + + @Test + void canonicalizeTargetUri_preserveNonDefaultPort() throws SigningException { + String result = Rfc9421Canonicalizer.canonicalizeTargetUri("https://example.com:8443/path"); + assertEquals("https://example.com:8443/path", result); + } + + @Test + void canonicalizeTargetUri_stripUserInfo() throws SigningException { + String result = Rfc9421Canonicalizer.canonicalizeTargetUri("https://user:pass@example.com/path"); + assertEquals("https://example.com/path", result); + } + + @Test + void canonicalizeTargetUri_uppercasePercentEncoding() throws SigningException { + String result = Rfc9421Canonicalizer.canonicalizeTargetUri("https://example.com/path%2fhere"); + assertEquals("https://example.com/path%2Fhere", result); + } + + @Test + void canonicalizeTargetUri_decodeUnreservedTilde() throws SigningException { + String result = Rfc9421Canonicalizer.canonicalizeTargetUri("https://example.com/%7Efoo"); + assertEquals("https://example.com/~foo", result); + } + + @Test + void canonicalizeTargetUri_preserveQueryString() throws SigningException { + String result = Rfc9421Canonicalizer.canonicalizeTargetUri("https://example.com/p?b=2&a=1&c=3"); + assertEquals("https://example.com/p?b=2&a=1&c=3", result); + } + + @Test + void canonicalizeTargetUri_stripFragment() throws SigningException { + String result = Rfc9421Canonicalizer.canonicalizeTargetUri("https://example.com/p#frag"); + assertEquals("https://example.com/p", result); + } + + @Test + void canonicalizeTargetUri_emptyPathWithAuthority() throws SigningException { + String result = Rfc9421Canonicalizer.canonicalizeTargetUri("https://example.com?x=1"); + assertEquals("https://example.com/?x=1", result); + } + + @Test + void canonicalizeTargetUri_removeDotSegments() throws SigningException { + String result = Rfc9421Canonicalizer.canonicalizeTargetUri("https://example.com/adcp/./create_media_buy"); + assertEquals("https://example.com/adcp/create_media_buy", result); + } + + @Test + void canonicalizeTargetUri_removeDoubleDotSegments() throws SigningException { + String result = Rfc9421Canonicalizer.canonicalizeTargetUri("https://example.com/a/b/../c"); + assertEquals("https://example.com/a/c", result); + } + + @Test + void extractAuthority_basicHost() throws SigningException { + String result = Rfc9421Canonicalizer.extractAuthority("https://buyer.example.com/path"); + assertEquals("buyer.example.com", result); + } + + @Test + void extractAuthority_withNonDefaultPort() throws SigningException { + String result = Rfc9421Canonicalizer.extractAuthority("https://example.com:8443/path"); + assertEquals("example.com:8443", result); + } + + @Test + void extractAuthority_stripsDefaultPort() throws SigningException { + String result = Rfc9421Canonicalizer.extractAuthority("https://example.com:443/path"); + assertEquals("example.com", result); + } + + @Test + void extractAuthority_ipv6() throws SigningException { + String result = Rfc9421Canonicalizer.extractAuthority("https://[2001:DB8::1]/p"); + assertEquals("[2001:db8::1]", result); + } + + @Test + void extractAuthority_ipv6WithPort() throws SigningException { + String result = Rfc9421Canonicalizer.extractAuthority("https://[::1]:8443/p"); + assertEquals("[::1]:8443", result); + } + + private JsonNode loadResource(String path) throws IOException { + try (InputStream is = getClass().getResourceAsStream(path)) { + if (is == null) { + throw new IOException("Test resource not found: " + path); + } + return OM.readTree(is); + } + } + + private String computeSignatureBase(JsonNode vector) throws SigningException { + JsonNode request = vector.get("request"); + String method = request.get("method").asText(); + String url = request.get("url").asText(); + JsonNode headersNode = request.get("headers"); + + Map headers = new java.util.LinkedHashMap<>(); + headersNode.properties().forEach(entry -> { + String normalizedName = HeaderNormalizer.normalizeName(entry.getKey()); + headers.put(normalizedName, entry.getValue().asText()); + }); + + String sigInputHeader = headers.get("signature-input"); + if (sigInputHeader == null) { + sigInputHeader = headers.get("Signature-Input"); + } + assertNotNull(sigInputHeader, "Missing Signature-Input header"); + + Rfc9421Verifier.ParsedSignatureInput parsed = Rfc9421Verifier.parseSignatureInput(sigInputHeader); + + String signatureInputString = Rfc9421Verifier.buildSignatureInputString(parsed); + + return Rfc9421Canonicalizer.canonicalize( + method, url, headers, parsed.components(), signatureInputString); + } +} \ No newline at end of file diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/SignatureInputBuilderTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/SignatureInputBuilderTest.java new file mode 100644 index 0000000..e66f25d --- /dev/null +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/SignatureInputBuilderTest.java @@ -0,0 +1,68 @@ +package org.adcontextprotocol.adcp.server.signing; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class SignatureInputBuilderTest { + + @Test + void buildsWebhookSignatureInput() { + String result = SignatureInputBuilder.create() + .label("sig1") + .coveredComponents(java.util.List.of( + "@method", "@target-uri", "@authority", "content-type", "content-digest")) + .created(1776520800L) + .expires(1776521100L) + .nonce("KXYnfEfJ0PBRZXQyVXfVQA") + .keyid("test-ed25519-webhook-2026") + .alg("ed25519") + .tag("adcp/webhook-signing/v1") + .build(); + + assertEquals( + "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");" + + "created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";" + + "keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + result); + } + + @Test + void buildsRequestSignatureInput() { + String result = SignatureInputBuilder.create() + .label("sig1") + .coveredComponents(java.util.List.of( + "@method", "@target-uri", "@authority", "content-type")) + .created(1776520800L) + .expires(1776521100L) + .nonce("KXYnfEfJ0PBRZXQyVXfVQA") + .keyid("test-ed25519-2026") + .alg("ed25519") + .tag("adcp/request-signing/v1") + .build(); + + assertEquals( + "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");" + + "created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";" + + "keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + result); + } + + @Test + void buildsMinimalSignatureInput() { + String result = SignatureInputBuilder.create() + .label("sig1") + .coveredComponents(java.util.List.of("@method")) + .created(100L) + .expires(200L) + .nonce("abc") + .keyid("key1") + .alg("ed25519") + .tag("adcp/webhook-signing/v1") + .build(); + + assertEquals( + "sig1=(\"@method\");created=100;expires=200;nonce=\"abc\";keyid=\"key1\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + result); + } +} \ No newline at end of file diff --git a/adcp-server/src/test/resources/compliance/request-signing/README.md b/adcp-server/src/test/resources/compliance/request-signing/README.md new file mode 100644 index 0000000..a07b120 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/README.md @@ -0,0 +1,219 @@ +# AdCP Request Signing Conformance Vectors + +Test vectors for the AdCP RFC 9421 request-signing profile. These fixtures drive cross-implementation conformance testing so a signer written in one SDK and a verifier written in another agree on the wire format. + +Specification: [Signed Requests (Transport Layer)](https://adcontextprotocol.org/docs/building/implementation/security#signed-requests-transport-layer) in `docs/building/implementation/security.mdx`. + +**Canonical URLs.** These vectors are served at `https://adcontextprotocol.org/compliance/{version}/test-vectors/request-signing/`, with `{version}` being either a specific release (e.g. `3.0.0`) or `latest` (tracks the most recent GA). Tree preserved — `keys.json`, `negative/*.json`, `positive/*.json` all resolvable. SDKs SHOULD fetch from the versioned CDN path and record the version under test rather than requiring a checkout of the spec repo. Example: `https://adcontextprotocol.org/compliance/latest/test-vectors/request-signing/positive/001-basic-post.json`. + +## Scope + +These vectors exercise the [verifier checklist](https://adcontextprotocol.org/docs/building/implementation/security#verifier-checklist-requests) and the RFC 9421 profile constraints: covered components, signature parameters, tag namespace, alg allowlist, `adcp_use` key-purpose discriminator, replay dedup, revocation, and content-digest semantics. They do not exercise live JWKS fetch, brand.json discovery, or revocation-list polling — those require live endpoints and belong in integration suites. + +## File layout + +``` +test-vectors/request-signing/ +├── README.md this file +├── keys.json test keypairs (Ed25519 + ES256) in JWK format with adcp_use values +├── canonicalization.json pure URL-canonicalization cases (no crypto) — every rule from the @target-uri algorithm + malformed-authority rejections +├── negative/ vectors that MUST fail verification +│ ├── 001-no-signature-header.json → request_signature_required (pre-check 0; op in required_for) +│ ├── 002-wrong-tag.json → request_signature_tag_invalid (step 3) +│ ├── 003-expired-signature.json → request_signature_window_invalid (step 5; expired) +│ ├── 004-window-too-long.json → request_signature_window_invalid (step 5; window > 300s) +│ ├── 005-alg-not-allowed.json → request_signature_alg_not_allowed (step 4) +│ ├── 006-missing-covered-component.json → request_signature_components_incomplete (step 6; @authority missing) +│ ├── 007-missing-content-digest.json → request_signature_components_incomplete (step 6; policy 'required') +│ ├── 008-unknown-keyid.json → request_signature_key_unknown (step 7) +│ ├── 009-key-ops-missing-verify.json → request_signature_key_purpose_invalid (step 8; adcp_use mismatch) +│ ├── 010-content-digest-mismatch.json → request_signature_digest_mismatch (step 11) +│ ├── 011-malformed-header.json → request_signature_header_malformed (step 1; downgrade protection) +│ ├── 012-missing-expires-param.json → request_signature_params_incomplete (step 2) +│ ├── 013-expires-le-created.json → request_signature_window_invalid (step 5; expires ≤ created) +│ ├── 014-missing-nonce-param.json → request_signature_params_incomplete (step 2) +│ ├── 015-signature-invalid.json → request_signature_invalid (step 10; canonicalization catcher) +│ ├── 016-replayed-nonce.json → request_signature_replayed (step 12; requires test_harness_state preload) +│ ├── 017-key-revoked.json → request_signature_key_revoked (step 9; requires test_harness_state preload) +│ ├── 018-digest-covered-when-forbidden.json → request_signature_components_unexpected (step 6; policy 'forbidden') +│ ├── 019-signature-without-signature-input.json → request_signature_header_malformed (pre-check; downgrade loophole) +│ ├── 020-rate-abuse.json → request_signature_rate_abuse (step 9a cap; abuse signal) +│ ├── 021-duplicate-signature-input-label.json → request_signature_header_malformed (step 1; RFC 8941 dict duplicate-key) +│ ├── 022-multi-valued-content-type.json → request_signature_header_malformed (step 1; covered non-list field must be single-valued) +│ ├── 023-multi-valued-content-digest.json → request_signature_header_malformed (step 1; RFC 9530 dict duplicate algorithm) +│ ├── 024-unquoted-string-param.json → request_signature_header_malformed (step 1; RFC 8941 §3.3 string values must be quoted) +│ ├── 025-jwk-alg-crv-mismatch.json → request_signature_key_purpose_invalid (step 8; alg=EdDSA with crv=P-256 is impossible per RFC 8037) +│ ├── 026-non-ascii-host.json → request_signature_header_malformed (step 1; raw IDN U-label on wire; MUST be A-label) +│ └── 027-webhook-registration-authentication-unsigned.json → request_signature_required (webhook-reg with push_notification_config.authentication over bearer on a seller supporting signing; operation NOT in required_for) +└── positive/ vectors that MUST verify successfully + ├── 001-basic-post.json Ed25519, no content-digest + ├── 002-post-with-content-digest.json Ed25519, content-digest covered + ├── 003-es256-post.json ES256, no content-digest + ├── 004-multiple-signature-labels.json Two Signature-Input labels; verifier processes sig1 only + ├── 005-default-port-stripped.json URL has :443; canonical strips it + ├── 006-dot-segment-path.json Path has /./; canonical collapses it + ├── 007-query-byte-preserved.json Query b=2&a=1&c=3 — preserved, not alphabetized + ├── 008-percent-encoded-path.json Path has lowercase %xx; canonical uppercases + ├── 009-percent-encoded-unreserved-decoded.json Path has %7E/%2D/%5F/%2E; canonical decodes unreserved per RFC 3986 §6.2.2.2 + ├── 010-percent-encoded-slash-preserved.json Path has %2F (reserved); stays percent-encoded, not treated as segment separator + ├── 011-ipv6-authority.json IPv6 literal host; brackets preserved in @target-uri and @authority + └── 012-ipv6-authority-default-port-stripped.json IPv6 literal with :443; port stripped, brackets preserved +``` + +## Canonicalization vectors (`canonicalization.json`) + +`canonicalization.json` is a flat set of URL-canonicalization cases that exercise every rule in the `@target-uri` canonicalization algorithm, plus the malformed-authority rejection cases. Independent of cryptographic signing — an SDK can run this file without keys, crypto, or a full verifier harness, which makes it the fastest way to surface cross-implementation divergence. + +Shape: + +```json +{ + "spec_reference": "#adcp-rfc-9421-profile", + "cases": [ + { + "name": "ipv6-host-hex-lowercased", + "rule": "step 2: IPv6 brackets preserved; hex digits inside lowercased", + "input_url": "https://[2001:DB8::1]/p", + "expected_target_uri": "https://[2001:db8::1]/p", + "expected_authority": "[2001:db8::1]" + }, + { + "name": "malformed-port-without-host", + "rule": "step 3: authority with port but no host is malformed", + "input_url": "https://:443/p", + "reject": true, + "reject_reason": "authority missing host", + "expected_error_code": "request_signature_header_malformed" + } + ] +} +``` + +For each case: + +- **Positive case** (no `reject` field): the implementation MUST canonicalize `input_url` and produce byte-for-byte matches on `expected_target_uri` and `expected_authority`. Each case also carries a `rule` string pointing to the specific numbered step in the canonicalization algorithm it exercises — useful when a test fails and you want to know which rule the implementation got wrong. +- **Reject case** (`reject: true`): the implementation MUST refuse to canonicalize `input_url`. Signers refuse to sign; verifiers reject with `expected_error_code`. + +SDKs SHOULD run `canonicalization.json` on every commit alongside a lint pass. Canonicalization divergence is the #1 silent 9421 interop bug, and catching it as a fast unit test (cheap, no network, no crypto) closes that gap. + +## Vector format + +Every vector is a single JSON file with this shape: + +```json +{ + "name": "human-readable description", + "spec_reference": "#anchor in security.mdx (checklist step or pre-check)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(...)", + "Signature": "sig1=:base64url_signature:", + "Content-Digest": "sha-256=:base64url_digest:" + }, + "body": "{\"...\":\"...\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": ["create_media_buy"] + }, + "jwks_ref": ["test-ed25519-2026"], + "jwks_override": { "keys": [ "..." ] }, + "test_harness_state": { + "replay_cache_entries": [ "..." ], + "revocation_list": { "..." } + }, + "expected_signature_base": "\"@method\": POST\n...", + "expected_outcome": { + "success": false, + "error_code": "request_signature_window_invalid", + "failed_step": 5 + }, + "$comment": "optional free-form notes" +} +``` + +### Fields + +- **`name`** — one-line description. +- **`spec_reference`** — anchor in `security.mdx` the vector tests, including the checklist step number. +- **`reference_now`** — Unix seconds. Treat as the wall-clock value the verifier should use when evaluating the signature window. Inject into your test harness rather than using `Date.now()`. +- **`request`** — the raw HTTP request the verifier receives. `headers` is case-insensitive; `body` is the exact byte string on the wire (empty string for GETs). +- **`verifier_capability`** — the `request_signing` block the verifier advertises. Drives expected behavior on content-digest coverage (`"required"` | `"forbidden"` | `"either"`) and whether unsigned requests to the operation are rejected pre-check. +- **`jwks_ref`** — array of `kid` strings from `keys.json`. The test harness builds the verifier's view of the signing agent's JWKS by selecting those entries. Present on most vectors. +- **`jwks_override`** — full JWKS object (`{ keys: [...] }`) that replaces the default `jwks_ref` lookup for this vector. Used when a vector needs a JWK that is NOT in the canonical `keys.json` (e.g., a malformed `key_ops` to test step 8 rejection). Mutually exclusive with `jwks_ref`. +- **`test_harness_state`** — optional. Preloads verifier state BEFORE invoking verification. Harness implementations translate each sub-key into the appropriate concrete preload for their verifier under test. Supported sub-keys: + - `replay_cache_entries` — list of `{ keyid, nonce, ttl_seconds }` to preload into the per-`(keyid, nonce)` replay cache. Used by `016-replayed-nonce.json` to assert the nonce-dedup check at step 12. + - `replay_cache_per_keyid_cap_hit` — object `{ keyid }` signalling the per-keyid entry cap is hit for the named key. Used by `020-rate-abuse.json` to assert the cap check at step 9a. The harness MAY simulate by populating the cache with N placeholder entries (where N equals the verifier's configured cap) or by setting an implementation-private flag — what the vector asserts is the rejection behavior when the cap is hit, not the mechanism of reaching it. + - `revocation_list` — full signed-revocation-list object to preload as the current freshness snapshot. Used by `017-key-revoked.json` to assert the revocation check at step 9. +- **`expected_signature_base`** — present on positive vectors and on `015-signature-invalid.json`. The canonical signature base string per RFC 9421 §2.5. Shape specifics that implementers get wrong: **lines are joined with a single `\n`** (LF, not CRLF); **there is no trailing newline** after the final `@signature-params` line; **components appear in the exact order listed in `Signature-Input`**, followed by `@signature-params` as the last line. The JSON string uses `\n` escapes which parse to real newline bytes at load time. Implementers can diff their computed base against this field BEFORE worrying about signatures — canonicalization disagreements are the #1 source of 9421 interop bugs, and checking the base is how you catch them. +- **`expected_outcome.success`** — `true` for positive vectors, `false` for negative. +- **`expected_outcome.error_code`** — stable code from the [transport error taxonomy](https://adcontextprotocol.org/docs/building/implementation/security#transport-error-taxonomy). Conformance requires **byte-for-byte match** on this code. Negative vectors only. +- **`expected_outcome.failed_step`** — which step of the verifier checklist the rejection occurs at. Integer for numbered steps (`1`–`13`), or a string for lettered sub-steps (e.g. `"9a"` for the per-keyid cap check). Informational only — an implementation that rejects with the correct error code is conformant even if its internal step numbering differs. An implementation that rejects with a DIFFERENT error code is non-conformant (see [Conformance expectations](#conformance-expectations)). Negative vectors only. +- **`$comment`** — free-form clarifying notes. Some vectors use `$comment` to describe test-harness setup or conformance edge cases. + +## Test keypairs + +`keys.json` ships three keypairs used across the vectors: + +| kid | alg | adcp_use | purpose | +|---|---|---|---| +| `test-ed25519-2026` | EdDSA (Ed25519) | `request-signing` | primary signing key for Ed25519 positive vectors | +| `test-es256-2026` | ES256 | `request-signing` | edge-runtime variant; covers ES256 | +| `test-gov-2026` | EdDSA (Ed25519) | `governance-signing` | included to test the cross-purpose rejection rule at checklist step 8 (vector `009-key-ops-missing-verify` presents this key when verifying a request signature) | + +The private-key halves are present in `keys.json` as `_private_d_for_test_only` so implementations can regenerate positive-vector signatures deterministically. **These keypairs are for conformance testing only. They are public knowledge and MUST NOT be used in any production capacity.** + +## Conformance expectations + +An implementation is conformant when, for every vector: + +1. **Negative vectors** produce `expected_outcome.error_code` exactly. The `failed_step` is informational: an implementation that rejects with the correct error code is conformant, even if its internal step numbering differs. An implementation that rejects with a DIFFERENT error code (even at the right step) is non-conformant. +2. **Positive vectors** verify without error. +3. **Signature bytes on positive vectors** match `request.headers.Signature` byte-for-byte when the implementation signs with the corresponding key from `keys.json`. Ed25519 is deterministic (001 and 002 will reproduce byte-exact). ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2; ECDSA is non-deterministic by default, so implementations that use random-k are conformant on VERIFY but may not reproduce the `Signature` byte-for-byte. Verifiers are the protocol surface — reproduction of signer bytes is a convenience check, not a normative requirement. + +## Generating positive-vector signatures + +Positive-vector signatures are computed from the canonical signature base per RFC 9421 §2.5. The base string for each positive vector is in `expected_signature_base` so implementers can check their canonicalization independently of cryptographic signing. + +The shipped signatures were generated from those base strings using the corresponding private keys. Regenerator script (not shipped): `.context/generate-test-vectors.mjs` uses `jose` + Node's `node:crypto` to produce the committed outputs. + +**Cross-implementation commitment check.** Before relying on the shipped signatures, SDK implementers SHOULD independently compute the signature base from the vector inputs (method, URL, headers, body, covered-components list, sig-params) and compare byte-for-byte against `expected_signature_base` in each positive vector. If all three reference SDKs (TypeScript, Go, Python — see adcp#2323 for tracking issues) agree with the committed base, confidence that the committed `Signature` values are canonical is high. If any disagrees, escalate to the spec repo BEFORE the SDK consumes the signatures — locking a canonicalization bug into the committed signatures would be the worst outcome, because every subsequent verifier would inherit it. The `expected_signature_base` field exists specifically to make this check byte-level and implementation-independent. + +## Running vectors against an implementation + +A reference harness is in progress at https://github.com/adcontextprotocol/adcp-client/issues/575. Until it lands, implementers consuming these vectors should: + +1. Parse each vector JSON. +2. Build a `Request` object from `vector.request.method`, `vector.request.url`, `vector.request.headers`, `vector.request.body`. +3. Build the verifier's JWKS from `vector.jwks_ref` (selecting entries from `keys.json`) or `vector.jwks_override` (use as-is). +4. Preload any `test_harness_state` sub-keys into the verifier's replay cache and revocation snapshot. +5. Invoke verification with `reference_now` as the wall clock, `vector.verifier_capability` as the advertised capability, and the operation name derived from the request URL or `expected_outcome.failed_step == 0`'s pre-check expectation. +6. Assert: + - Negative: error code matches `expected_outcome.error_code` exactly. + - Positive: verification returns successfully. + +### Recommended run order + +Run vectors in this order when validating a new implementation — it isolates failure categories so a bug surfaces cleanly instead of as a pile of unrelated red tests: + +1. **Positive vectors first** (`positive/001`, `/002`, `/003`). These exercise the happy path. If `001` fails, your signer or verifier's canonicalization, key loading, or crypto is wrong — fix before touching anything else. The `expected_signature_base` field in each positive vector lets you diff YOUR canonical base against the spec's, independent of whether your crypto works. +2. **Parse-level negatives next** (`001`, `002`, `011`, `012`, `014`, `019`). These fail at the pre-check or early checklist steps without invoking crypto. Passing these means your header parsing and presence checks are correct. +3. **Semantic negatives** (`003`, `004`, `005`, `006`, `007`, `013`, `018`). These exercise specific rules (window, alg allowlist, covered components, content-digest policy) without requiring valid signatures. +4. **Key-path negatives** (`008`, `009`). JWKS resolution + `adcp_use` enforcement. +5. **Stateful pre-crypto negatives** (`017`, `020`). These require preloaded harness state and reject before crypto verify — `017` on revocation (step 9), `020` on the per-keyid cap (step 9a). The committed `Signature` on these vectors is a placeholder and is NOT expected to verify cryptographically; the rejection MUST land on the pre-crypto cheap check. +6. **Crypto / stateful-post negatives last** (`015`, `010`, `016`). These require the verifier to have run most of the checklist before reaching the failure point. `015` specifically catches canonicalization bugs where your implementation computes a different signature base than the spec — if you pass `positive/001` but fail `015`, your canonicalization is still off somewhere and `015` is picking it up. + +## Adding vectors + +Every new vector MUST: + +1. Cite a specific normative requirement in `security.mdx`. +2. Identify the verifier-checklist step it exercises (or the pre-check). +3. Use only keypairs from `keys.json`, OR supply a documented `jwks_override` explaining why a non-canonical key shape is required. +4. Include `expected_signature_base` for positive vectors and for step-10 `request_signature_invalid` catchers. +5. Include `test_harness_state` for any vector that requires preloaded verifier state. diff --git a/adcp-server/src/test/resources/compliance/request-signing/canonicalization.json b/adcp-server/src/test/resources/compliance/request-signing/canonicalization.json new file mode 100644 index 0000000..24570ae --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/canonicalization.json @@ -0,0 +1,241 @@ +{ + "$comment": "URL canonicalization conformance vectors for the AdCP RFC 9421 request-signing profile. Each case asserts the canonical @target-uri and @authority values a signer MUST compute from the as-received URL before emitting the signature base, and that a verifier MUST recompute before verifying. Independent of cryptographic signing — an SDK can run this set without keys, crypto, or a full verifier harness. Cited by docs/building/implementation/security.mdx §AdCP RFC 9421 profile, `@target-uri` canonicalization.", + "version": "3.0", + "spec_reference": "#adcp-rfc-9421-profile", + "cases": [ + { + "name": "scheme-lowercase", + "rule": "step 1: lowercase the scheme", + "input_url": "HTTPS://seller.example.com/adcp/create_media_buy", + "expected_target_uri": "https://seller.example.com/adcp/create_media_buy", + "expected_authority": "seller.example.com" + }, + { + "name": "host-lowercase", + "rule": "step 2: lowercase the host", + "input_url": "https://Seller.Example.COM/p", + "expected_target_uri": "https://seller.example.com/p", + "expected_authority": "seller.example.com" + }, + { + "name": "idn-to-punycode", + "rule": "step 2: UTS-46 Nontransitional ToASCII → Punycode A-label", + "input_url": "https://bücher.example/p", + "expected_target_uri": "https://xn--bcher-kva.example/p", + "expected_authority": "xn--bcher-kva.example", + "$comment": "Punycode of 'bücher' is 'bcher-kva' → ACE label 'xn--bcher-kva'. Verifiable against any UTS-46-conformant IDN tool." + }, + { + "name": "idn-mixed-case-to-punycode", + "rule": "step 2: UTS-46 case-folds non-ASCII before Punycode; naive ASCII-lowercase + Punycode diverges", + "input_url": "https://BÜCHER.Example/p", + "expected_target_uri": "https://xn--bcher-kva.example/p", + "expected_authority": "xn--bcher-kva.example", + "$comment": "Pins UTS-46 Nontransitional behavior. A signer that lowercases to 'bücher' via locale-dependent ASCII lowercasing before ToASCII still produces the same A-label here because 'Ü' → 'ü' is a UTS-46 mapping, but implementations that skip UTS-46 entirely (e.g., raw idnaEncodeLabel) can diverge on other scripts — this case is the canary." + }, + { + "name": "ipv6-host-hex-lowercased", + "rule": "step 2: IPv6 brackets preserved; hex digits inside lowercased", + "input_url": "https://[2001:DB8::1]/p", + "expected_target_uri": "https://[2001:db8::1]/p", + "expected_authority": "[2001:db8::1]" + }, + { + "name": "ipv6-host-with-port", + "rule": "step 2 + step 4: IPv6 brackets + non-default port preserved", + "input_url": "https://[::1]:8443/p", + "expected_target_uri": "https://[::1]:8443/p", + "expected_authority": "[::1]:8443" + }, + { + "name": "userinfo-stripped", + "rule": "step 3: strip userinfo", + "input_url": "https://user:pass@seller.example.com/p", + "expected_target_uri": "https://seller.example.com/p", + "expected_authority": "seller.example.com" + }, + { + "name": "default-port-https-stripped", + "rule": "step 4: strip :443 for https", + "input_url": "https://seller.example.com:443/adcp/create_media_buy", + "expected_target_uri": "https://seller.example.com/adcp/create_media_buy", + "expected_authority": "seller.example.com" + }, + { + "name": "default-port-http-stripped", + "rule": "step 4: strip :80 for http", + "input_url": "http://seller.example.com:80/p", + "expected_target_uri": "http://seller.example.com/p", + "expected_authority": "seller.example.com" + }, + { + "name": "non-default-port-preserved", + "rule": "step 4: preserve non-default ports", + "input_url": "https://seller.example.com:8443/p", + "expected_target_uri": "https://seller.example.com:8443/p", + "expected_authority": "seller.example.com:8443" + }, + { + "name": "dot-segment-collapsed", + "rule": "step 5: remove_dot_segments (RFC 3986 §5.2.4)", + "input_url": "https://seller.example.com/adcp/./create_media_buy", + "expected_target_uri": "https://seller.example.com/adcp/create_media_buy", + "expected_authority": "seller.example.com" + }, + { + "name": "double-dot-segment-collapsed", + "rule": "step 5: remove_dot_segments resolves /a/b/../c to /a/c", + "input_url": "https://seller.example.com/a/b/../c", + "expected_target_uri": "https://seller.example.com/a/c", + "expected_authority": "seller.example.com" + }, + { + "name": "consecutive-slashes-preserved", + "rule": "step 5: consecutive slashes preserved byte-for-byte (NOT collapsed)", + "input_url": "https://seller.example.com/a//b", + "expected_target_uri": "https://seller.example.com/a//b", + "expected_authority": "seller.example.com", + "$comment": "RFC 3986 does not mandate collapsing consecutive slashes. Preserving closes a path-confusion attack surface where the server routes /a//b differently from /a/b; deployments MUST disable slash-folding on signed routes (nginx merge_slashes off; Express: do not pre-normalize; Go http.ServeMux 1.22+: use explicit http.Handler)." + }, + { + "name": "dot-segment-with-consecutive-slashes", + "rule": "step 5: remove_dot_segments + preserve consecutive slashes; /a/.//b → /a//b", + "input_url": "https://seller.example.com/a/.//b", + "expected_target_uri": "https://seller.example.com/a//b", + "expected_authority": "seller.example.com", + "$comment": "remove_dot_segments resolves /./ but does not collapse the following //. Pins the interaction explicitly — some parsers eagerly collapse // during dot-segment processing." + }, + { + "name": "double-dot-segment-with-consecutive-slashes", + "rule": "step 5: remove_dot_segments across a consecutive-slash boundary; /a//../b → /a/b", + "input_url": "https://seller.example.com/a//../b", + "expected_target_uri": "https://seller.example.com/a/b", + "expected_authority": "seller.example.com", + "$comment": "remove_dot_segments treats // as 'segment + empty segment'; /../ pops the empty segment, leaving /a/b. Pins this behavior so parsers that treat // as a single boundary don't emit /b." + }, + { + "name": "empty-path-with-authority-becomes-slash", + "rule": "step 5: empty path with authority → /", + "input_url": "https://seller.example.com?x=1", + "expected_target_uri": "https://seller.example.com/?x=1", + "expected_authority": "seller.example.com" + }, + { + "name": "percent-encoded-hex-uppercased", + "rule": "step 6: uppercase %xx hex digits", + "input_url": "https://seller.example.com/path%2fhere", + "expected_target_uri": "https://seller.example.com/path%2Fhere", + "expected_authority": "seller.example.com", + "$comment": "%2F is a reserved gen-delim and remains percent-encoded; only the hex case is normalized." + }, + { + "name": "percent-encoded-reserved-preserved", + "rule": "step 6: reserved characters remain percent-encoded; only hex case normalized", + "input_url": "https://seller.example.com/a%3Ab%3Fc", + "expected_target_uri": "https://seller.example.com/a%3Ab%3Fc", + "expected_authority": "seller.example.com", + "$comment": "%3A (':') and %3F ('?') are reserved sub-delims / gen-delims and MUST stay encoded. Implementers who over-decode produce an invalid path." + }, + { + "name": "percent-encoded-unreserved-tilde-decoded", + "rule": "step 6: decode percent-encoded unreserved '~' (RFC 3986 §2.3)", + "input_url": "https://seller.example.com/%7Efoo", + "expected_target_uri": "https://seller.example.com/~foo", + "expected_authority": "seller.example.com", + "$comment": "%7E is the unreserved character '~'; RFC 3986 §6.2.2.2 requires decoding. Verifiers that leave it encoded fail step 10 on cross-SDK traffic." + }, + { + "name": "percent-encoded-unreserved-alpha-decoded", + "rule": "step 6: decode percent-encoded unreserved ALPHA (general rule, not just ~)", + "input_url": "https://seller.example.com/%41%42C", + "expected_target_uri": "https://seller.example.com/ABC", + "expected_authority": "seller.example.com", + "$comment": "%41 ('A') and %42 ('B') are unreserved ALPHA. Implementers commonly only decode %7E/%2D/%5F/%2E and miss the general rule — this case catches that bug." + }, + { + "name": "query-byte-preserved", + "rule": "step 7: preserve query string byte-for-byte (no reordering, no re-encoding)", + "input_url": "https://seller.example.com/p?b=2&a=1&c=3", + "expected_target_uri": "https://seller.example.com/p?b=2&a=1&c=3", + "expected_authority": "seller.example.com" + }, + { + "name": "query-plus-not-decoded", + "rule": "step 7: '+' is preserved, NOT interpreted as space", + "input_url": "https://seller.example.com/p?x=a+b", + "expected_target_uri": "https://seller.example.com/p?x=a+b", + "expected_authority": "seller.example.com" + }, + { + "name": "trailing-empty-query-preserved", + "rule": "step 7: trailing '?' with empty query preserved (distinct from no '?')", + "input_url": "https://seller.example.com/p?", + "expected_target_uri": "https://seller.example.com/p?", + "expected_authority": "seller.example.com" + }, + { + "name": "no-query-preserved", + "rule": "step 7: URL with no '?' stays with no '?'", + "input_url": "https://seller.example.com/p", + "expected_target_uri": "https://seller.example.com/p", + "expected_authority": "seller.example.com" + }, + { + "name": "fragment-stripped", + "rule": "step 8: strip fragment (RFC 9421 §2.2.2)", + "input_url": "https://seller.example.com/p#frag", + "expected_target_uri": "https://seller.example.com/p", + "expected_authority": "seller.example.com" + }, + { + "name": "malformed-port-without-host", + "rule": "step 3: authority with port but no host is malformed", + "input_url": "https://:443/p", + "reject": true, + "reject_reason": "authority missing host", + "expected_error_code": "request_target_uri_malformed" + }, + { + "name": "malformed-userinfo-without-host", + "rule": "step 3: authority with userinfo but no host is malformed", + "input_url": "https://user@/p", + "reject": true, + "reject_reason": "authority missing host", + "expected_error_code": "request_target_uri_malformed" + }, + { + "name": "malformed-empty-authority", + "rule": "step 3: empty authority is malformed", + "input_url": "https:///p", + "reject": true, + "reject_reason": "empty authority", + "expected_error_code": "request_target_uri_malformed" + }, + { + "name": "malformed-ipv6-missing-closing-bracket", + "rule": "step 2: bracketed IPv6 host missing closing bracket is malformed", + "input_url": "https://[::1/p", + "reject": true, + "reject_reason": "IPv6 literal missing closing bracket", + "expected_error_code": "request_target_uri_malformed" + }, + { + "name": "malformed-bare-ipv6", + "rule": "step 2: IPv6 address outside brackets is malformed (ambiguous with port)", + "input_url": "https://fe80::1/p", + "reject": true, + "reject_reason": "IPv6 literal not bracketed", + "expected_error_code": "request_target_uri_malformed", + "$comment": "Parsers variously treat '::1' as port or as host; rejecting unambiguously removes the interop divergence." + }, + { + "name": "malformed-ipv6-zone-identifier", + "rule": "step 2: IPv6 zone identifier (RFC 6874) is node-local and MUST be rejected in signed URLs", + "input_url": "https://[fe80::1%25eth0]/p", + "reject": true, + "reject_reason": "IPv6 zone identifier in signed URL", + "expected_error_code": "request_target_uri_malformed", + "$comment": "RFC 6874 §1 defines zone-ids as node-local; they have no meaning outside the signing host. A verifier on a different node cannot interpret them. Rejecting at the signer (MUST NOT sign) and verifier (MUST reject) closes the ambiguity." + } + ] +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/keys.json b/adcp-server/src/test/resources/compliance/request-signing/keys.json new file mode 100644 index 0000000..b883930 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/keys.json @@ -0,0 +1,60 @@ +{ + "$comment": "Test keypairs for AdCP request-signing conformance. These keys are public and MUST NOT be used in production. See README.md.", + "keys": [ + { + "kid": "test-ed25519-2026", + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "use": "sig", + "key_ops": [ + "verify" + ], + "adcp_use": "request-signing", + "x": "gWUqzATUcUco5Q8fZZXn8aWwb7DQbYGBiqUzLiSDDJo", + "_private_d_for_test_only": "A_rC9vrZ2D1xJWLBXGdRW7CYLAh_f83Gqv8nhly7N2M" + }, + { + "kid": "test-es256-2026", + "kty": "EC", + "crv": "P-256", + "alg": "ES256", + "use": "sig", + "key_ops": [ + "verify" + ], + "adcp_use": "request-signing", + "x": "vGSQmjzPN1txgDY-oBb108gMsRETA9J5IPxqlBczQOY", + "y": "JGIbsHoOnHLL_LFqGYUW43BYDAqGYrNRZUylkE7rqSU", + "_private_d_for_test_only": "6QL-bTWRsEbiR2NeE19KnjYUw7CiGxLqLQyBr5MMhKw" + }, + { + "$comment": "Governance-signing key, adcp_use='governance-signing'. Included so negative vector 009 can demonstrate cross-purpose rejection: the vector presents this key when verifying a request signature, and the verifier MUST reject at step 8 because adcp_use is not 'request-signing'.", + "kid": "test-gov-2026", + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "use": "sig", + "key_ops": [ + "verify" + ], + "adcp_use": "governance-signing", + "x": "rkUcKP5oMd7YjV4yy5mVS5S8fA3LDXcf5jk1P1_52EA", + "_private_d_for_test_only": "bag_KLehHhOb-giX2u8kEfm9Djo6Fldl6_dFoRiC_eE" + }, + { + "$comment": "Revoked request-signing key. Dedicated keypair for negative vector 017-key-revoked under the signed-requests-runner test-kit contract. adcp_use is 'request-signing' so that the verifier's purpose check (step 8) passes and the revocation check (step 9) fires. Agents pre-configure their revocation list to include this keyid before the negative phase runs; see test-kits/signed-requests-runner.yaml.", + "kid": "test-revoked-2026", + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "use": "sig", + "key_ops": [ + "verify" + ], + "adcp_use": "request-signing", + "x": "r8wqMpVCLKzLSRNBtNmI1g71pPzQcwkATJHcyHK1lXg", + "_private_d_for_test_only": "PHd2372_ljsWBOUrEyhipyplIkv7feOfpeoU9bb0T_U" + } + ] +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/001-no-signature-header.json b/adcp-server/src/test/resources/compliance/request-signing/negative/001-no-signature-header.json new file mode 100644 index 0000000..cfe17e7 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/001-no-signature-header.json @@ -0,0 +1,24 @@ +{ + "name": "Missing Signature-Input header; operation is in required_for", + "spec_reference": "#verifier-checklist-requests pre-check (required_for enforcement)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": ["create_media_buy"] + }, + "jwks_ref": ["test-ed25519-2026"], + "expected_outcome": { + "success": false, + "error_code": "request_signature_required", + "failed_step": 0 + } +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/002-wrong-tag.json b/adcp-server/src/test/resources/compliance/request-signing/negative/002-wrong-tag.json new file mode 100644 index 0000000..926e94a --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/002-wrong-tag.json @@ -0,0 +1,26 @@ +{ + "name": "Signature tag is not adcp/request-signing/v1", + "spec_reference": "#verifier-checklist-requests step 3", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"AAAAAAAAAAAAAAAAAAAAAA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"example-org/signing/v1\"", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": ["create_media_buy"] + }, + "jwks_ref": ["test-ed25519-2026"], + "expected_outcome": { + "success": false, + "error_code": "request_signature_tag_invalid", + "failed_step": 3 + } +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/003-expired-signature.json b/adcp-server/src/test/resources/compliance/request-signing/negative/003-expired-signature.json new file mode 100644 index 0000000..07639d3 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/003-expired-signature.json @@ -0,0 +1,26 @@ +{ + "name": "Signature expired more than 60 s ago", + "spec_reference": "#verifier-checklist-requests step 5", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520000;expires=1776520300;nonce=\"AAAAAAAAAAAAAAAAAAAAAA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": ["create_media_buy"] + }, + "jwks_ref": ["test-ed25519-2026"], + "expected_outcome": { + "success": false, + "error_code": "request_signature_window_invalid", + "failed_step": 5 + } +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/004-window-too-long.json b/adcp-server/src/test/resources/compliance/request-signing/negative/004-window-too-long.json new file mode 100644 index 0000000..70201c7 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/004-window-too-long.json @@ -0,0 +1,26 @@ +{ + "name": "Signature validity window exceeds 300 s maximum", + "spec_reference": "#verifier-checklist-requests step 5", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776522000;nonce=\"AAAAAAAAAAAAAAAAAAAAAA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": ["create_media_buy"] + }, + "jwks_ref": ["test-ed25519-2026"], + "expected_outcome": { + "success": false, + "error_code": "request_signature_window_invalid", + "failed_step": 5 + } +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/005-alg-not-allowed.json b/adcp-server/src/test/resources/compliance/request-signing/negative/005-alg-not-allowed.json new file mode 100644 index 0000000..c44c0a8 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/005-alg-not-allowed.json @@ -0,0 +1,26 @@ +{ + "name": "Signature alg is rsa-pss-sha512, not in AdCP allowlist", + "spec_reference": "#verifier-checklist-requests step 4", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"AAAAAAAAAAAAAAAAAAAAAA\";keyid=\"test-ed25519-2026\";alg=\"rsa-pss-sha512\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": ["create_media_buy"] + }, + "jwks_ref": ["test-ed25519-2026"], + "expected_outcome": { + "success": false, + "error_code": "request_signature_alg_not_allowed", + "failed_step": 4 + } +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/006-missing-covered-component.json b/adcp-server/src/test/resources/compliance/request-signing/negative/006-missing-covered-component.json new file mode 100644 index 0000000..230dcce --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/006-missing-covered-component.json @@ -0,0 +1,26 @@ +{ + "name": "Covered components missing @authority", + "spec_reference": "#verifier-checklist-requests step 6", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"AAAAAAAAAAAAAAAAAAAAAA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": ["create_media_buy"] + }, + "jwks_ref": ["test-ed25519-2026"], + "expected_outcome": { + "success": false, + "error_code": "request_signature_components_incomplete", + "failed_step": 6 + } +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/007-missing-content-digest.json b/adcp-server/src/test/resources/compliance/request-signing/negative/007-missing-content-digest.json new file mode 100644 index 0000000..731c23b --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/007-missing-content-digest.json @@ -0,0 +1,26 @@ +{ + "name": "Verifier requires content-digest coverage but signature omits it", + "spec_reference": "#verifier-checklist-requests step 6", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"AAAAAAAAAAAAAAAAAAAAAA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "required", + "required_for": ["create_media_buy"] + }, + "jwks_ref": ["test-ed25519-2026"], + "expected_outcome": { + "success": false, + "error_code": "request_signature_components_incomplete", + "failed_step": 6 + } +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/008-unknown-keyid.json b/adcp-server/src/test/resources/compliance/request-signing/negative/008-unknown-keyid.json new file mode 100644 index 0000000..a1b4774 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/008-unknown-keyid.json @@ -0,0 +1,26 @@ +{ + "name": "keyid does not match any entry in the signer's JWKS", + "spec_reference": "#verifier-checklist-requests step 7", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"AAAAAAAAAAAAAAAAAAAAAA\";keyid=\"not-a-real-kid\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": ["create_media_buy"] + }, + "jwks_ref": ["test-ed25519-2026"], + "expected_outcome": { + "success": false, + "error_code": "request_signature_key_unknown", + "failed_step": 7 + } +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/009-key-ops-missing-verify.json b/adcp-server/src/test/resources/compliance/request-signing/negative/009-key-ops-missing-verify.json new file mode 100644 index 0000000..e407a21 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/009-key-ops-missing-verify.json @@ -0,0 +1,27 @@ +{ + "name": "Presented JWK is scoped to governance signing (adcp_use != request-signing)", + "spec_reference": "#verifier-checklist-requests step 8", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"AAAAAAAAAAAAAAAAAAAAAA\";keyid=\"test-gov-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": ["create_media_buy"] + }, + "jwks_ref": ["test-gov-2026"], + "expected_outcome": { + "success": false, + "error_code": "request_signature_key_purpose_invalid", + "failed_step": 8 + }, + "$comment": "The keyid test-gov-2026 resolves to a JWK in keys.json with adcp_use='governance-signing'. Per spec step 8, verifier MUST reject because adcp_use is not 'request-signing'. This exercises the cross-purpose key reuse prevention: a single JWK declares one purpose, and verifiers enforce locally without cross-JWKS lookup." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/010-content-digest-mismatch.json b/adcp-server/src/test/resources/compliance/request-signing/negative/010-content-digest-mismatch.json new file mode 100644 index 0000000..021b968 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/010-content-digest-mismatch.json @@ -0,0 +1,33 @@ +{ + "name": "Content-Digest header does not match SHA-256 of received body", + "spec_reference": "#verifier-checklist-requests step 11", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"AAAAAAAAAAAAAAAAAAAAAA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:2p3oUaH5wK2ORtjDicHj69JEe6MDkrDUha0gIp8lw9qG2W1dQHSDvU4NkAwSeIIf8ieLaPGlE7X_yGu9enllAQ:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "required", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_outcome": { + "success": false, + "error_code": "request_signature_digest_mismatch", + "failed_step": 11 + }, + "$comment": "The Content-Digest header asserts sha-256 of 32 zero bytes, which is a value that is not the SHA-256 of the request body (the body hashes to something non-zero). The signature IS valid over the base that includes the Content-Digest header value as-is — step 10 passes. Step 11 recomputes the body's actual SHA-256 and compares against the header value, producing a mismatch. Verifiers MUST reject with request_signature_digest_mismatch at step 11; rejecting earlier with request_signature_invalid is non-conformant for this vector.", + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://seller.example.com/adcp/create_media_buy\n\"@authority\": seller.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"AAAAAAAAAAAAAAAAAAAAAA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"" +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/011-malformed-header.json b/adcp-server/src/test/resources/compliance/request-signing/negative/011-malformed-header.json new file mode 100644 index 0000000..215aca8 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/011-malformed-header.json @@ -0,0 +1,27 @@ +{ + "name": "Signature-Input present but syntactically invalid (downgrade protection)", + "spec_reference": "#verifier-checklist-requests pre-check (malformed header, no bearer fallback)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/sync_creatives", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "this-is-not-a-valid-rfc-9421-signature-input", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": ["create_media_buy"] + }, + "jwks_ref": ["test-ed25519-2026"], + "expected_outcome": { + "success": false, + "error_code": "request_signature_header_malformed", + "failed_step": 1 + }, + "$comment": "Operation (sync_creatives) is NOT in required_for. Spec mandates that malformed signatures reject even for non-required ops — silent fallback to bearer-only authentication would enable downgrade attacks. Verifier MUST reject with request_signature_header_malformed, not accept-as-unsigned." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/012-missing-expires-param.json b/adcp-server/src/test/resources/compliance/request-signing/negative/012-missing-expires-param.json new file mode 100644 index 0000000..3164e61 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/012-missing-expires-param.json @@ -0,0 +1,26 @@ +{ + "name": "Signature-Input is missing the required 'expires' parameter", + "spec_reference": "#verifier-checklist-requests step 2", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;nonce=\"AAAAAAAAAAAAAAAAAAAAAA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": ["create_media_buy"] + }, + "jwks_ref": ["test-ed25519-2026"], + "expected_outcome": { + "success": false, + "error_code": "request_signature_params_incomplete", + "failed_step": 2 + } +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/013-expires-le-created.json b/adcp-server/src/test/resources/compliance/request-signing/negative/013-expires-le-created.json new file mode 100644 index 0000000..7bd2b2f --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/013-expires-le-created.json @@ -0,0 +1,27 @@ +{ + "name": "Signature expires equals created (negative validity window)", + "spec_reference": "#verifier-checklist-requests step 5", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776520800;nonce=\"AAAAAAAAAAAAAAAAAAAAAA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": ["create_media_buy"] + }, + "jwks_ref": ["test-ed25519-2026"], + "expected_outcome": { + "success": false, + "error_code": "request_signature_window_invalid", + "failed_step": 5 + }, + "$comment": "expires == created yields a zero-length validity window. Spec requires expires > created (strict inequality) to prevent signatures that are simultaneously issued and expired, which would bypass replay dedup — the replay cache entry would immediately be stale." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/014-missing-nonce-param.json b/adcp-server/src/test/resources/compliance/request-signing/negative/014-missing-nonce-param.json new file mode 100644 index 0000000..e89a903 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/014-missing-nonce-param.json @@ -0,0 +1,27 @@ +{ + "name": "Signature-Input is missing the required 'nonce' parameter", + "spec_reference": "#verifier-checklist-requests step 2", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": ["create_media_buy"] + }, + "jwks_ref": ["test-ed25519-2026"], + "expected_outcome": { + "success": false, + "error_code": "request_signature_params_incomplete", + "failed_step": 2 + }, + "$comment": "Without nonce, replay dedup collapses — the verifier has nothing to deduplicate on within the validity window. Spec requires nonce presence independently of other sig-params so this specific rejection path fires before the verifier reaches step 12's replay check." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/015-signature-invalid.json b/adcp-server/src/test/resources/compliance/request-signing/negative/015-signature-invalid.json new file mode 100644 index 0000000..764ecce --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/015-signature-invalid.json @@ -0,0 +1,28 @@ +{ + "name": "Signature header is well-formed but cryptographically invalid over the signature base", + "spec_reference": "#verifier-checklist-requests step 10", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\",\"packages\":[{\"package_id\":\"pkg_1\",\"budget\":{\"amount\":1000,\"currency\":\"USD\"}}]}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": ["create_media_buy"] + }, + "jwks_ref": ["test-ed25519-2026"], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://seller.example.com/adcp/create_media_buy\n\"@authority\": seller.example.com\n\"content-type\": application/json\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "expected_outcome": { + "success": false, + "error_code": "request_signature_invalid", + "failed_step": 10 + }, + "$comment": "Signature-Input is identical to positive/001-basic-post.json, but Signature contains 64 zero bytes (base64url: 86 'A's) which is not a valid Ed25519 signature over any base. Verifier passes steps 1–9 and fails at step 10 cryptographic verification. This is the primary canonicalization-bug-catching vector: implementations whose signature base differs from the spec will ALSO fail here, but they'll fail even when given the CORRECT signature from positive/001. Implementations MUST distinguish by running positive/001 first (should succeed) and this vector second (should fail with request_signature_invalid)." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/016-replayed-nonce.json b/adcp-server/src/test/resources/compliance/request-signing/negative/016-replayed-nonce.json new file mode 100644 index 0000000..701f162 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/016-replayed-nonce.json @@ -0,0 +1,35 @@ +{ + "name": "Second submission of a previously-accepted (keyid, nonce) within the replay window", + "spec_reference": "#verifier-checklist-requests step 12", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:U51PJzU9nMJxMAH_u-UDpSecT5SQX1-deSnWE3XpFo-BLT2_2h5FgMltntNCW05chhmFnjZEzkRmaYKeU0UUBw:" + }, + "body": "{\"plan_id\":\"plan_001\",\"packages\":[{\"package_id\":\"pkg_1\",\"budget\":{\"amount\":1000,\"currency\":\"USD\"}}]}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": ["create_media_buy"] + }, + "jwks_ref": ["test-ed25519-2026"], + "test_harness_state": { + "$comment": "Pre-populate the replay cache as if positive/001 was just verified. Harness sets replay_cache[(test-ed25519-2026, KXYnfEfJ0PBRZXQyVXfVQA)] = valid-until 1776521100.", + "replay_cache_entries": [ + { "keyid": "test-ed25519-2026", "nonce": "KXYnfEfJ0PBRZXQyVXfVQA", "ttl_seconds": 360 } + ] + }, + "expected_outcome": { + "success": false, + "error_code": "request_signature_replayed", + "failed_step": 12 + }, + "requires_contract": "replay_window", + "side_effects": "mutating", + "$comment": "This vector is identical to positive/001-basic-post.json but is submitted after the replay cache has already recorded the (keyid, nonce) pair. White-box harnesses MAY inject the cache entry directly (see test_harness_state). Black-box runners SHOULD send the request twice in sequence per test-kits/signed-requests-runner.yaml → stateful_vector_contract.replay_window; the first submission is accepted as a real create_media_buy and MUST be sent against a sandbox endpoint only (see endpoint_scope in the test-kit), the second MUST be rejected at step 12 with request_signature_replayed without reaching step 13's cache insert. The side_effects: mutating marker is a belt-and-suspenders signal for consumers that read vectors outside the storyboard runner: the black-box path for this vector has a first-request side effect by design." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/017-key-revoked.json b/adcp-server/src/test/resources/compliance/request-signing/negative/017-key-revoked.json new file mode 100644 index 0000000..f4b1052 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/017-key-revoked.json @@ -0,0 +1,38 @@ +{ + "name": "Signing keyid is listed in the revocation list", + "spec_reference": "#verifier-checklist-requests step 9", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-revoked-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:U51PJzU9nMJxMAH_u-UDpSecT5SQX1-deSnWE3XpFo-BLT2_2h5FgMltntNCW05chhmFnjZEzkRmaYKeU0UUBw:" + }, + "body": "{\"plan_id\":\"plan_001\",\"packages\":[{\"package_id\":\"pkg_1\",\"budget\":{\"amount\":1000,\"currency\":\"USD\"}}]}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": ["create_media_buy"] + }, + "jwks_ref": ["test-revoked-2026"], + "test_harness_state": { + "$comment": "Pre-populate revocation list with test-revoked-2026 revoked. Harness sets revocation_list = { revoked_kids: ['test-revoked-2026'], revoked_jtis: [], fresh: true, issuer: 'https://seller.example.com', updated: '2026-04-18T14:00:00Z', next_update: '2026-04-18T14:15:00Z' }. Black-box runners rely on the agent having pre-configured this state per test-kits/signed-requests-runner.yaml → stateful_vector_contract.revocation (pre_revoked_keyid: test-revoked-2026).", + "revocation_list": { + "issuer": "https://seller.example.com", + "updated": "2026-04-18T14:00:00Z", + "next_update": "2026-04-18T14:15:00Z", + "revoked_kids": ["test-revoked-2026"], + "revoked_jtis": [] + } + }, + "expected_outcome": { + "success": false, + "error_code": "request_signature_key_revoked", + "failed_step": 9 + }, + "requires_contract": "revocation", + "$comment": "Revocation check runs BEFORE cryptographic verify (step 9, before step 10) — this prevents cheap amplification where an attacker replays a revoked key's valid signature and forces the verifier to do an Ed25519/ECDSA verify for every rejection. Verifier MUST reject at step 9 without computing the signature base or running crypto verification. Implementations that defer the revocation check until after crypto verify will fail this vector because the committed Signature bytes are a placeholder copied from positive/001 and do not verify cryptographically against this vector's own signature base (different keyid in @signature-params); a crypto-first verifier will return request_signature_invalid instead of request_signature_key_revoked, which is a graded mismatch. Graders SHOULD surface this specific mismatch (_invalid where _key_revoked was expected) in operator-facing diagnostics as a step-ordering bug (still a graded FAIL) rather than as a generic vector failure — the vector is deliberately designed as a step-ordering canary and a verifier that runs crypto before revocation is exploitable (cheap amplification on revoked-key replay), which is the very failure mode the step-9-before-step-10 ordering exists to prevent. The keyid test-revoked-2026 is a dedicated revoked keypair in keys.json so this vector does not conflict with the purpose-mismatch vector (009) or the runner signing keys (test-ed25519-2026, test-es256-2026)." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/018-digest-covered-when-forbidden.json b/adcp-server/src/test/resources/compliance/request-signing/negative/018-digest-covered-when-forbidden.json new file mode 100644 index 0000000..1245025 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/018-digest-covered-when-forbidden.json @@ -0,0 +1,28 @@ +{ + "name": "Signature covers content-digest but verifier capability is covers_content_digest='forbidden'", + "spec_reference": "#verifier-checklist-requests step 6", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:SNIVma8dgUBx/U1CBaYFQnsJep9S0/tXaNXlQQOdoxQ=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:RiD5mPhxpBWhmaqUL5-vceyPX5jpjzYZhSnteuYCIYhIqdIl0Yxdh5qstCPXwkKL4AZOsPBL7-8ctbPkHunSAw:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "forbidden", + "required_for": ["create_media_buy"] + }, + "jwks_ref": ["test-ed25519-2026"], + "expected_outcome": { + "success": false, + "error_code": "request_signature_components_unexpected", + "failed_step": 6 + }, + "$comment": "Signer covered content-digest, but verifier's capability advertises covers_content_digest='forbidden' (narrow opt-out for legacy infrastructure that cannot preserve body bytes). Verifier MUST reject with request_signature_components_unexpected — distinct error code from the inverse case (signer omits when required → request_signature_components_incomplete). This vector exists so SDKs can distinguish the two failure modes in their typed-error surfaces." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/019-signature-without-signature-input.json b/adcp-server/src/test/resources/compliance/request-signing/negative/019-signature-without-signature-input.json new file mode 100644 index 0000000..a88cc29 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/019-signature-without-signature-input.json @@ -0,0 +1,26 @@ +{ + "name": "Signature header present but Signature-Input header absent (downgrade loophole)", + "spec_reference": "#verifier-checklist-requests pre-check (header pair enforcement)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature": "sig1=:qjngSQ0bgimxQbc6l0aFOmSthQRCdEgD10mOa7OGyUIEMuKwe2h_3l42oxs2Ga5qQCnVpaZHOuwhYu3qJp4HAA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": ["create_media_buy"] + }, + "jwks_ref": ["test-ed25519-2026"], + "expected_outcome": { + "success": false, + "error_code": "request_signature_header_malformed", + "failed_step": 1 + }, + "$comment": "Signature and Signature-Input are a bound pair — one without the other is malformed. A proxy stripping Signature-Input while preserving Signature could otherwise be misread as 'this request is unsigned,' degrading a signed request to bearer-only auth. Verifier MUST reject; MUST NOT fall back to bearer-only authentication. Mirror case (Signature-Input without Signature) is also a reject condition per the spec pre-check — one vector is sufficient to exercise the rule." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/020-rate-abuse.json b/adcp-server/src/test/resources/compliance/request-signing/negative/020-rate-abuse.json new file mode 100644 index 0000000..82947b6 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/020-rate-abuse.json @@ -0,0 +1,34 @@ +{ + "name": "Per-keyid replay cache entry cap exceeded (abuse or misconfigured signer)", + "spec_reference": "#verifier-checklist-requests step 9a (per-keyid cap; before crypto verify) and #transport-replay-dedup", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"NEW_NONCE_AFTER_CAP_________\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:U51PJzU9nMJxMAH_u-UDpSecT5SQX1-deSnWE3XpFo-BLT2_2h5FgMltntNCW05chhmFnjZEzkRmaYKeU0UUBw:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": ["create_media_buy"] + }, + "jwks_ref": ["test-ed25519-2026"], + "test_harness_state": { + "$comment": "Pre-populate the replay cache to the per-keyid cap for test-ed25519-2026. Harness simulates the cap by setting a flag or populating with N placeholder entries where N is the verifier's configured cap. The exact mechanism is verifier-implementation-specific; what the vector asserts is the rejection behavior when the cap is hit.", + "replay_cache_per_keyid_cap_hit": { + "keyid": "test-ed25519-2026" + } + }, + "expected_outcome": { + "success": false, + "error_code": "request_signature_rate_abuse", + "failed_step": "9a" + }, + "requires_contract": "rate_abuse", + "$comment": "When the per-keyid replay cache has reached its configured cap (recommended: 1M entries), new signatures from that keyid MUST be rejected with request_signature_rate_abuse — not silently evict, not accept. Silent eviction creates replay windows exactly when under attack. The nonce in this vector is fresh (never seen); the rejection is due to the cap, not replay. Verifiers SHOULD alert operators on this condition as a compromised-key or misconfigured-signer signal. The cap check is step 9a of the verifier checklist and runs BEFORE crypto verify (step 10) to prevent amplified Ed25519/ECDSA work on an abusive signer — same rationale as revocation (step 9). Black-box runners target the cap declared in test-kits/signed-requests-runner.yaml → stateful_vector_contract.rate_abuse (grading_target_per_keyid_cap_requests: 100); agents MAY lower their production cap for the test-kit counterparty only. Implementations that defer the cap check until after crypto verify will fail this vector because the committed Signature bytes are a placeholder copied from positive/001 and do not verify cryptographically against this vector's own signature base (different nonce in @signature-params, different body); a crypto-first verifier will return request_signature_invalid instead of request_signature_rate_abuse. Graders SHOULD surface this specific mismatch (_invalid where _rate_abuse was expected) in operator-facing diagnostics as a step-ordering bug (still a graded FAIL) rather than as a generic vector failure — the vector is deliberately designed as a step-ordering canary and a verifier that runs crypto before the per-keyid cap is exploitable (an abusive signer forces Ed25519/ECDSA verify per request), which is the very failure mode the step-9a-before-step-10 ordering exists to prevent." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/021-duplicate-signature-input-label.json b/adcp-server/src/test/resources/compliance/request-signing/negative/021-duplicate-signature-input-label.json new file mode 100644 index 0000000..bfc6506 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/021-duplicate-signature-input-label.json @@ -0,0 +1,31 @@ +{ + "name": "Signature-Input header declares label 'sig1' twice (malformed structured dictionary)", + "spec_reference": "#verifier-checklist-requests step 1 (RFC 9421 §4.1 Signature-Input is a Dictionary; duplicate keys malformed)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\", sig1=(\"@method\" \"@target-uri\");created=1776520800;expires=1776521100;nonce=\"AAAAAAAAAAAAAAAAAAAAAA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_outcome": { + "success": false, + "error_code": "request_signature_header_malformed", + "failed_step": 1 + }, + "$comment": "RFC 8941 §3.2 Dictionaries: 'When parsing, duplicate keys MUST be handled by either rejecting the input or by retaining only the last value.' Per the AdCP profile and downgrade-protection rule at step 1, verifiers MUST reject — silently retaining 'the last value' would let a proxy smuggle a weaker set of covered components past a verifier that read the first. Related cases: 011-malformed-header (unparseable) and 019-signature-without-signature-input (missing pair); this vector is the 'parseable-but-ambiguous' case the two existing vectors don't cover." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/022-multi-valued-content-type.json b/adcp-server/src/test/resources/compliance/request-signing/negative/022-multi-valued-content-type.json new file mode 100644 index 0000000..1019f5a --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/022-multi-valued-content-type.json @@ -0,0 +1,31 @@ +{ + "name": "Content-Type header sent as multiple field values (malformed — field covered by signature must be single-valued)", + "spec_reference": "#verifier-checklist-requests step 1 (covered component fields must be single-valued; RFC 9421 §2.1 derivation rules do not permit concatenation for non-list headers)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json, text/plain", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_outcome": { + "success": false, + "error_code": "request_signature_header_malformed", + "failed_step": 1 + }, + "$comment": "Content-Type is not a List-Structured-Field — it has a single canonical value with optional parameters. RFC 9421 §2.1 says covered field values are the field's canonical in-order concatenation only for fields the HTTP spec treats as list-typed. A proxy inserting a second Content-Type (or a client with a bug emitting two) leaves the field's meaning undefined relative to the signature base. Verifier MUST reject at parse time rather than pick one value and hope for the best." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/023-multi-valued-content-digest.json b/adcp-server/src/test/resources/compliance/request-signing/negative/023-multi-valued-content-digest.json new file mode 100644 index 0000000..0a07ca3 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/023-multi-valued-content-digest.json @@ -0,0 +1,32 @@ +{ + "name": "Content-Digest header sent as multiple field values (malformed)", + "spec_reference": "#verifier-checklist-requests step 1 (Content-Digest covered in signature must be single-valued; RFC 9530)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:, sha-256=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "required", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_outcome": { + "success": false, + "error_code": "request_signature_header_malformed", + "failed_step": 1 + }, + "$comment": "Content-Digest per RFC 9530 §2 is a Dictionary-Structured-Field where each member is a digest algorithm — duplicates of the same algorithm are ambiguous and undefined for signature coverage. Even if the two sha-256 values happened to be identical, permitting multiple on the wire creates a parser-differential attack: signer and verifier might disagree on which value enters the signature base. Verifier MUST reject. Distinct from 010-content-digest-mismatch (where the single declared digest doesn't match the body)." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/024-unquoted-string-param.json b/adcp-server/src/test/resources/compliance/request-signing/negative/024-unquoted-string-param.json new file mode 100644 index 0000000..1013f9e --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/024-unquoted-string-param.json @@ -0,0 +1,31 @@ +{ + "name": "Signature-Input sig-param string value sent unquoted (keyid=foo instead of keyid=\"foo\")", + "spec_reference": "#verifier-checklist-requests step 1 (RFC 9421 §2.3 sig-params; RFC 8941 §3.3 string values MUST be double-quoted)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=test-ed25519-2026;alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_outcome": { + "success": false, + "error_code": "request_signature_header_malformed", + "failed_step": 1 + }, + "$comment": "Per RFC 8941 §3.3.3, Structured-Field string values MUST be wrapped in ASCII double-quotes. RFC 9421 §2.3 defines keyid, nonce, and tag as string-typed sig-params, so 'keyid=foo' (bare token) is not a valid string — it would parse as a token-typed value if the grammar allowed tokens there, which it does not. A verifier that tolerantly accepts bare tokens (common bug in hand-rolled parsers) introduces a parser-differential attack: one implementation sees keyid='foo', another sees keyid=undefined, and they disagree on which JWKS entry to resolve. Verifier MUST reject at parse." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/025-jwk-alg-crv-mismatch.json b/adcp-server/src/test/resources/compliance/request-signing/negative/025-jwk-alg-crv-mismatch.json new file mode 100644 index 0000000..537e59a --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/025-jwk-alg-crv-mismatch.json @@ -0,0 +1,43 @@ +{ + "name": "JWK declares alg=EdDSA but crv=P-256 (parameter-mismatch on presented key)", + "spec_reference": "#verifier-checklist-requests step 8 (key-purpose + parameter consistency; RFC 8037 binds alg=EdDSA to OKP key types, not EC)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-alg-crv-mismatch-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_override": { + "keys": [ + { + "$comment": "Malformed JWK: alg=EdDSA is valid only for kty=OKP with crv=Ed25519 or crv=Ed448 (RFC 8037 §3.1). This JWK declares alg=EdDSA with kty=EC and crv=P-256, which is an impossible combination. Key material is the real Ed25519 public key bytes from test-ed25519-2026 so the failure MUST land on parameter consistency, not on some unrelated key-material defect. Verifier MUST reject at step 8 with request_signature_key_purpose_invalid — 'key purpose' encompasses both adcp_use scoping and the fundamental alg/kty/crv consistency that makes a JWK usable at all.", + "kid": "test-alg-crv-mismatch-2026", + "kty": "EC", + "crv": "P-256", + "alg": "EdDSA", + "use": "sig", + "key_ops": ["verify"], + "adcp_use": "request-signing", + "x": "gWUqzATUcUco5Q8fZZXn8aWwb7DQbYGBiqUzLiSDDJo" + } + ] + }, + "expected_outcome": { + "success": false, + "error_code": "request_signature_key_purpose_invalid", + "failed_step": 8 + }, + "$comment": "Parameter-consistency check on the resolved JWK. A lenient verifier that picks alg from the sig-params and ignores the JWK's declared alg/crv would proceed to step 10 and reject with request_signature_invalid — a DIFFERENT (and less informative) error code. Per README 'Conformance expectations' §1, error-code mismatch is non-conformant even when the rejection lands at a different step. Uses jwks_override rather than a new keys.json entry because this key shape is deliberately malformed — polluting the canonical keys.json with impossible key parameters would risk other vectors inheriting the malformed shape through a bug." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/026-non-ascii-host.json b/adcp-server/src/test/resources/compliance/request-signing/negative/026-non-ascii-host.json new file mode 100644 index 0000000..c4ea123 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/026-non-ascii-host.json @@ -0,0 +1,31 @@ +{ + "name": "URL authority contains raw IDN U-label (non-ASCII bytes in host)", + "spec_reference": "#adcp-rfc-9421-profile (@target-uri canonicalization: hosts MUST be ASCII A-labels; RFC 5891 §4.4)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://bücher.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_outcome": { + "success": false, + "error_code": "request_signature_header_malformed", + "failed_step": 1 + }, + "$comment": "Host label 'bücher' is a Unicode U-label; the AdCP @target-uri algorithm requires A-label (Punycode, 'xn--bcher-kva') form. Accepting a raw U-label on the wire risks a canonicalization-differential: UTS-46 Nontransitional processing produces one A-label, naive lowercase + Punycode produces another (see canonicalization.json case 'idn-mixed-case-to-punycode'), and non-ASCII-aware libraries may round-trip the bytes unchanged. Verifier MUST reject at parse rather than guess. Companion canonicalization.json cases 'idn-to-punycode' and 'idn-mixed-case-to-punycode' exercise the SIGNER side (expect A-label output from U-label input after explicit UTS-46 processing); this vector exercises the VERIFIER side (reject U-label bytes received on the wire inside a signed request)." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/negative/027-webhook-registration-authentication-unsigned.json b/adcp-server/src/test/resources/compliance/request-signing/negative/027-webhook-registration-authentication-unsigned.json new file mode 100644 index 0000000..4862deb --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/negative/027-webhook-registration-authentication-unsigned.json @@ -0,0 +1,25 @@ +{ + "name": "Webhook registration with push_notification_config.authentication over bearer (unsigned); seller supports request signing but operation is NOT in required_for", + "spec_reference": "#webhook-security Downgrade and injection resistance — MUST require 9421 when authentication is present", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/update_media_buy", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer test-bearer-token" + }, + "body": "{\"media_buy_id\":\"mb_001\",\"push_notification_config\":{\"url\":\"https://buyer.example.com/webhook\",\"authentication\":{\"scheme\":\"HMAC-SHA256\",\"credentials\":\"shared-secret-placeholder\"}}}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [] + }, + "jwks_ref": ["test-ed25519-2026"], + "expected_outcome": { + "success": false, + "error_code": "request_signature_required", + "failed_step": 0 + } +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/positive/001-basic-post.json b/adcp-server/src/test/resources/compliance/request-signing/positive/001-basic-post.json new file mode 100644 index 0000000..90d692b --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/positive/001-basic-post.json @@ -0,0 +1,30 @@ +{ + "name": "Basic POST, Ed25519, no content-digest coverage", + "spec_reference": "#verifier-checklist-requests (all steps pass)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:U51PJzU9nMJxMAH_u-UDpSecT5SQX1-deSnWE3XpFo-BLT2_2h5FgMltntNCW05chhmFnjZEzkRmaYKeU0UUBw:" + }, + "body": "{\"plan_id\":\"plan_001\",\"packages\":[{\"package_id\":\"pkg_1\",\"budget\":{\"amount\":1000,\"currency\":\"USD\"}}]}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://seller.example.com/adcp/create_media_buy\n\"@authority\": seller.example.com\n\"content-type\": application/json\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "expected_outcome": { + "success": true + }, + "$comment": "Signature generated by .context/generate-test-vectors.mjs from expected_signature_base using the test-ed25519-2026 private key in keys.json. Ed25519 is deterministic; ES256 here uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/positive/002-post-with-content-digest.json b/adcp-server/src/test/resources/compliance/request-signing/positive/002-post-with-content-digest.json new file mode 100644 index 0000000..9432e6c --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/positive/002-post-with-content-digest.json @@ -0,0 +1,31 @@ +{ + "name": "POST with content-digest covered, Ed25519", + "spec_reference": "#content-digest-and-proxy-compatibility and #verifier-checklist-requests", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:SNIVma8dgUBx/U1CBaYFQnsJep9S0/tXaNXlQQOdoxQ=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:RiD5mPhxpBWhmaqUL5-vceyPX5jpjzYZhSnteuYCIYhIqdIl0Yxdh5qstCPXwkKL4AZOsPBL7-8ctbPkHunSAw:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "required", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://seller.example.com/adcp/create_media_buy\n\"@authority\": seller.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:SNIVma8dgUBx/U1CBaYFQnsJep9S0/tXaNXlQQOdoxQ=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "expected_outcome": { + "success": true + }, + "$comment": "Signature generated by .context/generate-test-vectors.mjs from expected_signature_base using the test-ed25519-2026 private key in keys.json. Ed25519 is deterministic; ES256 here uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/positive/003-es256-post.json b/adcp-server/src/test/resources/compliance/request-signing/positive/003-es256-post.json new file mode 100644 index 0000000..bc4283e --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/positive/003-es256-post.json @@ -0,0 +1,30 @@ +{ + "name": "POST with ES256 algorithm (edge-runtime profile)", + "spec_reference": "#adcp-rfc-9421-profile (alg allowlist)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-es256-2026\";alg=\"ecdsa-p256-sha256\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:iROVe9SPqEARfTlnbJsJYdg6Mv7WPr7fWD05rZ31H3o1Nprm-n181yGQpixU9EvgDnAUO3f-9FVpttLxDoRGxA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-es256-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://seller.example.com/adcp/create_media_buy\n\"@authority\": seller.example.com\n\"content-type\": application/json\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-es256-2026\";alg=\"ecdsa-p256-sha256\";tag=\"adcp/request-signing/v1\"", + "expected_outcome": { + "success": true + }, + "$comment": "Signature generated by .context/generate-test-vectors.mjs from expected_signature_base using the test-es256-2026 private key in keys.json. Ed25519 is deterministic; ES256 here uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/positive/004-multiple-signature-labels.json b/adcp-server/src/test/resources/compliance/request-signing/positive/004-multiple-signature-labels.json new file mode 100644 index 0000000..4232fee --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/positive/004-multiple-signature-labels.json @@ -0,0 +1,26 @@ +{ + "name": "Multiple Signature-Input labels — verifier MUST process exactly one", + "spec_reference": "#adcp-rfc-9421-profile (One signature per request)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\", sig2=(\"@method\" \"@target-uri\");created=1776520800;expires=1776521100;nonce=\"DIFFERENT-NONCE-FOR-SIG2____\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:U51PJzU9nMJxMAH_u-UDpSecT5SQX1-deSnWE3XpFo-BLT2_2h5FgMltntNCW05chhmFnjZEzkRmaYKeU0UUBw:, sig2=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\",\"packages\":[{\"package_id\":\"pkg_1\",\"budget\":{\"amount\":1000,\"currency\":\"USD\"}}]}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": ["create_media_buy"] + }, + "jwks_ref": ["test-ed25519-2026"], + "expected_outcome": { + "success": true, + "verified_label": "sig1" + }, + "$comment": "Two signature labels: sig1 is valid (copied from positive/001); sig2 has a different covered-components set, different nonce, and a zero-byte signature that would not verify. Per spec, verifiers MUST process exactly one Signature-Input label (conventionally sig1) and MUST ignore additional labels. This vector's expected_outcome is SUCCESS — sig1 verifies and the request is treated as authenticated. If a verifier attempts to verify sig2, it will fail and reject the request, which is non-conformant. This locks in option (a) of the relay model (ignore extras) ahead of #2324's full design; option (b) chained-signature semantics is a future RFC." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/positive/005-default-port-stripped.json b/adcp-server/src/test/resources/compliance/request-signing/positive/005-default-port-stripped.json new file mode 100644 index 0000000..9258d6f --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/positive/005-default-port-stripped.json @@ -0,0 +1,30 @@ +{ + "name": "URL has explicit :443 port; canonicalization strips it", + "spec_reference": "#adcp-rfc-9421-profile (@target-uri canonicalization step 4: strip default ports)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com:443/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:U51PJzU9nMJxMAH_u-UDpSecT5SQX1-deSnWE3XpFo-BLT2_2h5FgMltntNCW05chhmFnjZEzkRmaYKeU0UUBw:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://seller.example.com/adcp/create_media_buy\n\"@authority\": seller.example.com\n\"content-type\": application/json\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "expected_outcome": { + "success": true + }, + "$comment": "As-received URL has :443; the canonical @target-uri value in the signature base strips it per RFC 3986 §6.2.3 scheme-based normalization. Signer and verifier MUST both canonicalize before computing or verifying the base. A verifier that leaves :443 in @target-uri will fail cryptographic verify at step 10." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/positive/006-dot-segment-path.json b/adcp-server/src/test/resources/compliance/request-signing/positive/006-dot-segment-path.json new file mode 100644 index 0000000..7042f6a --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/positive/006-dot-segment-path.json @@ -0,0 +1,30 @@ +{ + "name": "URL path has a /./ segment; canonicalization collapses it", + "spec_reference": "#adcp-rfc-9421-profile (@target-uri canonicalization step 5: remove_dot_segments)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/./create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:U51PJzU9nMJxMAH_u-UDpSecT5SQX1-deSnWE3XpFo-BLT2_2h5FgMltntNCW05chhmFnjZEzkRmaYKeU0UUBw:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://seller.example.com/adcp/create_media_buy\n\"@authority\": seller.example.com\n\"content-type\": application/json\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "expected_outcome": { + "success": true + }, + "$comment": "As-received URL has /./ mid-path; canonical @target-uri applies remove_dot_segments (RFC 3986 §5.2.4) producing /adcp/create_media_buy. Similar rules apply to /../ and double slashes. Verifiers that don't apply remove_dot_segments will fail step 10 (signature invalid) because their computed base will differ." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/positive/007-query-byte-preserved.json b/adcp-server/src/test/resources/compliance/request-signing/positive/007-query-byte-preserved.json new file mode 100644 index 0000000..77fbc8d --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/positive/007-query-byte-preserved.json @@ -0,0 +1,30 @@ +{ + "name": "URL query string preserves byte order (not alphabetized)", + "spec_reference": "#adcp-rfc-9421-profile (@target-uri canonicalization step 7: preserve query byte-for-byte)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/get_media_buy?b=2&a=1&c=3", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:ovWpkGEukPhxTju9HTOj1LSrwbTbs95K2HHWUbA5aCBfHDvnGXcFfEM8X5p0VhOeCIAva0eCAaLuF6FCadHiBQ:" + }, + "body": "{\"update\":\"rename\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://seller.example.com/adcp/get_media_buy?b=2&a=1&c=3\n\"@authority\": seller.example.com\n\"content-type\": application/json\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "expected_outcome": { + "success": true + }, + "$comment": "Query string is b=2&a=1&c=3 (intentionally non-alphabetized). Spec mandates preserving the query byte-for-byte in @target-uri. Verifiers that alphabetize (some 9421 libraries do this by default) will fail step 10 because their base differs by query ordering. The +/%20 handling edge case is also captured here — do not interpret + as space; do not re-encode." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/positive/008-percent-encoded-path.json b/adcp-server/src/test/resources/compliance/request-signing/positive/008-percent-encoded-path.json new file mode 100644 index 0000000..024da7e --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/positive/008-percent-encoded-path.json @@ -0,0 +1,30 @@ +{ + "name": "URL path percent-encoded bytes normalized to uppercase hex", + "spec_reference": "#adcp-rfc-9421-profile (@target-uri canonicalization step 6: normalize percent-encoding)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/resource/%e2%98%83/item", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:3BiEuAwj6TId9StqdEmin-WxLkyji6sOH0dFuCLm3Rk7GHCsYtcXIswbD4oFoVmtvRCHuMaIyCMqnQejhV7PDA:" + }, + "body": "{\"op\":\"update\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://seller.example.com/adcp/resource/%E2%98%83/item\n\"@authority\": seller.example.com\n\"content-type\": application/json\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "expected_outcome": { + "success": true + }, + "$comment": "As-received path uses lowercase hex (%e2%98%83 — the ☃ snowman); canonical @target-uri uppercases to %E2%98%83 per RFC 3986 §6.2.2.1. Unreserved characters would additionally be decoded, but the snowman bytes are reserved so they stay percent-encoded. Verifiers that don't uppercase hex will fail step 10." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/positive/009-percent-encoded-unreserved-decoded.json b/adcp-server/src/test/resources/compliance/request-signing/positive/009-percent-encoded-unreserved-decoded.json new file mode 100644 index 0000000..8fa90b2 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/positive/009-percent-encoded-unreserved-decoded.json @@ -0,0 +1,30 @@ +{ + "name": "URL path percent-encoded unreserved bytes decoded per RFC 3986 §6.2.2.2", + "spec_reference": "#adcp-rfc-9421-profile (@target-uri canonicalization step 6: decode percent-encoded unreserved characters)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/a%7Eb%2Dc%5Fd%2Ee/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:o2kFcTvxhbtnyteqXSKBvg_WcDeh7Ev-6rmhbl5bqEa5LHEDBBQbzoB8JEgOxtYjsrANKz8qygxfc0JOUQnKBg:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://seller.example.com/adcp/a~b-c_d.e/create_media_buy\n\"@authority\": seller.example.com\n\"content-type\": application/json\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "expected_outcome": { + "success": true + }, + "$comment": "Companion to 008-percent-encoded-path (which exercises the reserved-byte uppercase rule). This vector covers the decode-side of step 6: the four unreserved characters that are percent-encoded on the wire (%7E=~, %2D=-, %5F=_, %2E=.) MUST be decoded in the canonical @target-uri per RFC 3986 §6.2.2.2. A verifier that uppercases but doesn't decode will fail step 10 (signature invalid) because %7E != ~ in the canonical base. Signature generated with the test-ed25519-2026 private key from the expected_signature_base." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/positive/010-percent-encoded-slash-preserved.json b/adcp-server/src/test/resources/compliance/request-signing/positive/010-percent-encoded-slash-preserved.json new file mode 100644 index 0000000..2363063 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/positive/010-percent-encoded-slash-preserved.json @@ -0,0 +1,30 @@ +{ + "name": "URL path with %2F (reserved) preserved literally through remove_dot_segments", + "spec_reference": "#adcp-rfc-9421-profile (@target-uri canonicalization step 5: remove_dot_segments operates on percent-encoded form; step 6: reserved bytes stay percent-encoded)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/segment%2Fwith-encoded-slash/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:2MVpRwioVrc-gLepGnleM5Nk0hI8M-V5A9buqMly0oL5Z4p3VfPh92ijitH1fKL2o7i97160AJ0Sf_wP9Q0KCQ:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://seller.example.com/adcp/segment%2Fwith-encoded-slash/create_media_buy\n\"@authority\": seller.example.com\n\"content-type\": application/json\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "expected_outcome": { + "success": true + }, + "$comment": "%2F is the percent-encoding of '/', a reserved character. Two properties MUST hold in canonical form: (a) remove_dot_segments per RFC 3986 §5.2.4 operates on the percent-encoded path and does NOT treat %2F as a segment separator, so 'segment%2Fwith-encoded-slash' stays as one path segment; (b) step 6 keeps reserved bytes percent-encoded (with uppercase hex) — the %2F does NOT decode to '/'. A verifier that decodes %2F before remove_dot_segments can produce a different path entirely (especially when combined with /./ or /../). Signature generated with the test-ed25519-2026 private key from the expected_signature_base." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/positive/011-ipv6-authority.json b/adcp-server/src/test/resources/compliance/request-signing/positive/011-ipv6-authority.json new file mode 100644 index 0000000..9eae84d --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/positive/011-ipv6-authority.json @@ -0,0 +1,30 @@ +{ + "name": "IPv6 literal authority; brackets preserved in @target-uri and @authority", + "spec_reference": "#adcp-rfc-9421-profile (@target-uri canonicalization step 2: IPv6 brackets preserved; @authority derivation)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://[2001:db8::1]/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:QplWbT2vVnF_TSfY5w5d8zQYkLpD7Bp4rxE9uKHl14UjBmUnF6mwKB8pEgoFTKHF1jVwwL14hx_AM6sFBtT9CA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://[2001:db8::1]/adcp/create_media_buy\n\"@authority\": [2001:db8::1]\n\"content-type\": application/json\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "expected_outcome": { + "success": true + }, + "$comment": "Bare IPv6 literal in authority. RFC 9421 canonicalizes @target-uri and @authority with brackets retained; common host-parsing libraries strip brackets during authority split and must reinstate them in the canonical form. A verifier that emits '2001:db8::1' without brackets (or with an extra ':' splitting ambiguity) will fail step 10. Paired with 012-ipv6-authority-default-port-stripped to cover the '@target-uri must not strip the bracket while stripping the default port' interaction. Signature generated with the test-ed25519-2026 private key from the expected_signature_base." +} diff --git a/adcp-server/src/test/resources/compliance/request-signing/positive/012-ipv6-authority-default-port-stripped.json b/adcp-server/src/test/resources/compliance/request-signing/positive/012-ipv6-authority-default-port-stripped.json new file mode 100644 index 0000000..449c063 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/request-signing/positive/012-ipv6-authority-default-port-stripped.json @@ -0,0 +1,30 @@ +{ + "name": "IPv6 literal authority with explicit :443; canonicalization strips port but preserves brackets", + "spec_reference": "#adcp-rfc-9421-profile (@target-uri canonicalization step 2 + step 4: IPv6 brackets preserved AND default ports stripped)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://[2001:db8::1]:443/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:QplWbT2vVnF_TSfY5w5d8zQYkLpD7Bp4rxE9uKHl14UjBmUnF6mwKB8pEgoFTKHF1jVwwL14hx_AM6sFBtT9CA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://[2001:db8::1]/adcp/create_media_buy\n\"@authority\": [2001:db8::1]\n\"content-type\": application/json\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "expected_outcome": { + "success": true + }, + "$comment": "Intersection of bracket preservation (step 2) and default-port stripping (step 4). As-received URL has '[2001:db8::1]:443'; canonical form is '[2001:db8::1]' — port stripped, brackets retained. A naive regex that strips ':443$' from the authority string will incorrectly produce '[2001:db8::1' (eating the closing bracket) or leave the port in place. Canonical base is identical to 011-ipv6-authority by construction, so the same signature bytes verify both vectors. Signature generated with the test-ed25519-2026 private key from the expected_signature_base." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/README.md b/adcp-server/src/test/resources/compliance/webhook-signing/README.md new file mode 100644 index 0000000..5f687af --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/README.md @@ -0,0 +1,211 @@ +# AdCP Webhook Signing Conformance Vectors + +Test vectors for the AdCP RFC 9421 webhook-signing profile. These fixtures drive cross-implementation conformance testing so a signer written in one SDK and a verifier written in another agree on the wire format of outbound push-notification webhooks. + +Specification: [Webhook callbacks](https://adcontextprotocol.org/docs/building/implementation/security#webhook-callbacks) in `docs/building/implementation/security.mdx`. + +**Canonical URLs.** These vectors are served at `https://adcontextprotocol.org/compliance/{version}/test-vectors/webhook-signing/`, with `{version}` being either a specific release (e.g. `3.0.0`) or `latest` (tracks the most recent GA). Tree preserved — `keys.json`, `negative/*.json`, `positive/*.json` all resolvable. SDKs SHOULD fetch from the versioned CDN path and record the version under test rather than requiring a checkout of the spec repo. Example: `https://adcontextprotocol.org/compliance/latest/test-vectors/webhook-signing/positive/001-basic-post.json`. + +## ⚠️ Security — test keys are public + +`keys.json` publishes the full private key material for every test keypair in the `_private_d_for_test_only` field so SDKs can exercise both signer and verifier roles against the same material. **Any production verifier that adds `test-ed25519-webhook-2026`, `test-es256-webhook-2026`, `test-wrong-purpose-2026`, or `test-revoked-webhook-2026` to its trust store is exploitable** — anyone who downloads `keys.json` can forge signatures under those kids. These keys are valid ONLY for grading against this conformance suite. Production signers MUST mint and publish their own keypairs under their own `jwks_uri`; production verifiers MUST NOT treat the test kids as trusted in any deployment exposed to live traffic. + +## Scope + +These vectors exercise the [webhook verifier checklist](https://adcontextprotocol.org/docs/building/implementation/security#verifier-checklist-for-webhooks) and the RFC 9421 profile constraints specific to webhooks: required covered components (content-digest is REQUIRED, no policy branch), the distinct `tag="adcp/webhook-signing/v1"`, the `adcp_use: "webhook-signing"` key-purpose discriminator, and the `webhook_signature_*` error taxonomy. They do not exercise live JWKS fetch, brand.json discovery, or revocation-list polling — those require live endpoints and belong in integration suites. + +Vectors cover the receiver side (buyer verifying inbound webhooks). Sender-side grading — does the agent-under-test emit conformant signatures on live traffic — is handled by the [`webhook-emission` universal](https://adcontextprotocol.org/compliance/latest/universal/webhook-emission) via a runner that hosts a receiver during storyboard execution. + +## Relationship to the request-signing vectors + +Webhook signing reuses most of the RFC 9421 profile from request signing: + +- **`@target-uri` canonicalization** is identical. The canonicalization cases live in [`test-vectors/request-signing/canonicalization.json`](../request-signing/canonicalization.json) and are not duplicated here — every rule an SDK verifies for request signing applies byte-for-byte to webhook signing. +- **Signature parameters** (`created`, `expires`, `nonce`, `keyid`, `alg`) share semantics with request signing. The only divergence is `tag`: webhooks MUST use `adcp/webhook-signing/v1`. +- **Binary value encoding** (`Signature`, `Content-Digest`) uses the same base64url-no-padding override as request signing. + +The distinct surface is the purpose-discriminator chain: `adcp_use` MUST be `"webhook-signing"` on the verifying JWK, `tag` MUST be `"adcp/webhook-signing/v1"`, and `content-digest` MUST be covered (no `covers_content_digest: "forbidden"` opt-out — the body is the event). + +## File layout + +``` +test-vectors/webhook-signing/ +├── README.md this file +├── keys.json test keypairs (Ed25519 + ES256) with adcp_use: "webhook-signing", +│ plus a wrong-purpose key (adcp_use: "request-signing") for vector 008 +│ and a revoked key for vector 017 +├── negative/ vectors that MUST fail verification +│ ├── 001-wrong-tag.json → webhook_signature_tag_invalid (step 3; uses request-signing tag) +│ ├── 002-expired-signature.json → webhook_signature_window_invalid (step 5; expired) +│ ├── 003-window-too-long.json → webhook_signature_window_invalid (step 5; window > 300s) +│ ├── 004-alg-not-allowed.json → webhook_signature_alg_not_allowed (step 4) +│ ├── 005-missing-authority-component.json → webhook_signature_components_incomplete (step 6; @authority missing) +│ ├── 006-missing-content-digest.json → webhook_signature_components_incomplete (step 6; REQUIRED on webhooks) +│ ├── 007-unknown-keyid.json → webhook_signature_key_unknown (step 7) +│ ├── 008-wrong-adcp-use.json → webhook_signature_key_purpose_invalid (step 8; adcp_use=request-signing) +│ ├── 009-content-digest-mismatch.json → webhook_signature_digest_mismatch (step 11) +│ ├── 010-malformed-signature-input.json → webhook_signature_header_malformed (step 1) +│ ├── 011-signature-without-input.json → webhook_signature_header_malformed (step 1; bound pair broken, one header without the other) +│ ├── 012-missing-expires-param.json → webhook_signature_params_incomplete (step 2) +│ ├── 013-expires-le-created.json → webhook_signature_window_invalid (step 5; expires ≤ created) +│ ├── 014-missing-nonce-param.json → webhook_signature_params_incomplete (step 2) +│ ├── 015-signature-invalid.json → webhook_signature_invalid (step 10; signature bytes corrupted) +│ ├── 016-replayed-nonce.json → webhook_signature_replayed (step 12; requires runner state) +│ ├── 017-key-revoked.json → webhook_signature_key_revoked (step 9; requires runner state) +│ ├── 018-rate-abuse.json → webhook_signature_rate_abuse (step 9a; requires runner state) +│ ├── 019-revocation-stale.json → webhook_signature_revocation_stale (step 9; requires runner state) +│ ├── 020-key-ops-missing-verify.json → webhook_signature_key_purpose_invalid (step 8; key_ops lacks "verify") +│ └── 021-base64-alphabet-mixing.json → webhook_signature_header_malformed (step 1; Signature token mixes base64url and standard-base64 chars) +└── positive/ vectors that MUST verify successfully + ├── 001-basic-post.json Ed25519, all five required components covered + ├── 002-es256-post.json ES256, all five required components covered + ├── 003-multiple-signature-labels.json Two Signature-Input labels; verifier processes the label named `sig1` + ├── 004-default-port-stripped.json URL has :443; canonical strips it before signing + ├── 005-percent-encoded-path.json Path has lowercase %xx; canonical uppercases + ├── 006-query-byte-preserved.json Query b=2&a=1&c=3 — preserved byte-for-byte, not alphabetized + └── 007-body-without-idempotency-key.json Body omits idempotency_key; signature still verifies (schema vs. signature separation) +``` + +## Vector shape + +Each vector is a JSON object with these fields: + +```json +{ + "name": "human-readable summary", + "spec_reference": "#section-anchor in security.mdx", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/...", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:...:", + "Signature-Input": "sig1=(...);created=...;expires=...;nonce=...;keyid=...;alg=...;tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=::" + }, + "body": "{\"idempotency_key\":\"...\",\"task_id\":\"...\",\"status\":\"completed\"}" + }, + "jwks_ref": ["test-ed25519-webhook-2026"], + "expected_signature_base": "...\\n@signature-params: ...", + "expected_outcome": { "success": true } + // Negative vectors instead carry: + // "expected_outcome": { "success": false, "error_code": "webhook_signature_<...>", "failed_step": } +} +``` + +### `reference_now` + +Fixed Unix-seconds timestamp representing "now" at vector construction time (2026-04-18T10:00:00Z). Verifiers running the vectors SHOULD stub their clock to this value so `window_invalid` checks are deterministic across time zones and machines. + +**Units**: Unix **seconds**, not milliseconds. Verifiers whose internal clocks are in milliseconds MUST divide by 1000 before comparing to `created`/`expires` sig-params. Using a millisecond value directly would make every signature appear ~1000× in the past and trip `window_invalid`. + +### `jwks_ref` + +Array of `kid` values the vector expects in the signer JWKS. Verifiers load `keys.json`, filter to the listed `kid`s, and present that subset to their verifier under test. Not all keys in `keys.json` are in every vector's JWKS — for example, vector 008 references only `test-wrong-purpose-2026`, which causes step 8 to reject. + +### `expected_signature_base` + +The RFC 9421 §2.5 canonical signature base the signer produced. Verifiers computing their own base from the `request` fields and `Signature-Input` parameters MUST produce a byte-identical string. This is the fastest divergence signal for canonicalization bugs — if the base differs, the signature won't verify even if the crypto is correct. + +### `expected_outcome` + +Positive vectors: `{"success": true}`. Verifiers MUST accept the signature. + +Negative vectors: `{"success": false, "error_code": "webhook_signature_<...>", "failed_step": , "sub_step": ""?}`. Verifiers MUST reject the signature with the exact `error_code` byte-for-byte. The `failed_step` and `sub_step` fields are informational — grading is on the stable error code only. An implementation that rejects with the correct error code at a different checklist step number is conformant; the step order in the spec is a scaffolding for correctness, not a grading target. + +- `failed_step` is always an **integer** (1–13). +- `sub_step` is optional and, when present, is a **string** letter (e.g., `"a"` for step 9a's per-keyid cap check). Vectors without a sub-step omit the field entirely rather than setting it to null. + +### `test_harness_state` + +Negative vectors that assert verifier state the vector cannot set from the outside (016, 017, 018, 019) carry `test_harness_state` describing the preconditions the runner MUST install before delivering the vector. Recognized shapes: + +| Field | Type | Used by | Meaning | +|---|---|---|---| +| `replay_cache_entries` | `Array<{keyid, nonce}>` | 016-replayed-nonce | Entries the runner MUST preload into its (keyid, nonce) replay cache. Paired with `black_box_behavior: "deliver_twice"` for runners that provoke the replay by double-delivery instead. | +| `revoked_kids` | `string[]` | 017-key-revoked | Kids the runner MUST preload into the revocation list. | +| `per_keyid_cap_filled_for` | `string` | 018-rate-abuse | Kid whose per-keyid replay-cache cap the runner MUST fill to its grading target before delivering the vector. Paired with `black_box_behavior: "pre_fill_per_keyid_cap"`. | +| `revocation_list_stale_seconds` | `integer` | 019-revocation-stale | Simulated seconds since last successful revocation-list refresh. Runner MUST present this to the receiver as exceeding the `next_update + grace` window. Paired with `black_box_behavior: "simulate_stale_revocation_fetch"`. | + +Runners that cannot install a declared harness-state primitive skip the affected vector as `not_applicable` — never as failed. + +### `black_box_behavior` + +State-dependent vectors (016, 017, 018, 019) declare a `black_box_behavior` string describing the runner-side action that provokes the assertion without white-box state injection. AdCP Verified grading runs black-box only. White-box harnesses MAY inject state directly (per the `test_harness_state` shape above) and skip the black-box step. + +### `jwks_override` (vector 020 only) + +Vector 020 tests that the verifier rejects JWKs whose `key_ops` lacks `"verify"`. The vector's signature itself is produced by a normally-configured key; the JWK presented to the verifier overrides `keys.json` with a mutated variant. Runners MUST substitute the keyid's entry with the `jwks_override[kid]` shape before resolving the signature. Field present only on vectors that need JWK mutation; most vectors present `keys.json` entries unchanged. + +### `jwks_ref` semantics (positive vector 003) + +Vector 003 includes two `Signature-Input` labels: the vector's own `sig1` and a decorative `relay` label. Verifiers MUST process the label named `sig1` specifically — not "the first label in the header," not "any label." The spec at [`#adcp-rfc-9421-profile`](https://adcontextprotocol.org/docs/building/implementation/security#adcp-rfc-9421-profile) says signers name the label `sig1` by convention, and verifiers key off the name rather than position. If an implementation picks a different label, the vector will fail even if that label's signature is individually valid. + +### Signature validity vs. payload schema validation (positive vector 007) + +Vector 007's body omits `idempotency_key`, which is required by the webhook payload schema per #2417. The 9421 signature still verifies cleanly because signature verification is a **transport-layer** check; payload schema validation is a **separate application-layer check** that fires after. A conformant receiver will: + +1. Verify the signature (steps 1–13 of the webhook verifier checklist) → accept. +2. Validate the decoded payload against the webhook schema → reject for missing `idempotency_key`. + +Step 2's rejection does NOT map to any `webhook_signature_*` code. Implementations that conflate the two and reject vector 007 at the signature layer are non-conformant. Implementations that accept vector 007 at the signature layer and separately reject the payload at schema validation are correctly splitting the concerns. + +### `requires_contract` and `test_harness_state` (negative vectors only, when applicable) + +Three negative vectors (016-replayed-nonce, 017-key-revoked, 018-rate-abuse) assert verifier state the vector cannot set from the outside. They carry: + +- `requires_contract: "webhook_receiver_runner"` — signals that grading requires the webhook receiver contract at [`test-kits/webhook-receiver-runner.yaml`](https://adcontextprotocol.org/compliance/latest/test-kits/webhook-receiver-runner). +- `test_harness_state` — declares the preconditions (replay cache entries, revoked kids, per-keyid cap exhaustion) that the runner MUST install before delivering the vector. +- `black_box_behavior` (016 only) — runner delivers the vector twice within the replay window; receiver MUST accept the first and reject the second. + +White-box harnesses MAY inject state directly and skip the runner coordination. AdCP Verified grading runs black-box only; see the test-kit contract for details. + +## Using the vectors + +### Positive phase + +For each vector in `positive/`: + +1. Load `keys.json` into your verifier's JWKS store, filtered to `vec.jwks_ref`. +2. Stub your clock to `vec.reference_now`. +3. Feed `vec.request` to your verifier as if the webhook had just arrived on the wire. +4. Assert the verifier accepts with `success: true`. + +Sanity checks (optional but recommended): + +- Compute your own canonical signature base from `vec.request` and compare to `vec.expected_signature_base` byte-for-byte. +- Compute your own `Content-Digest` from `vec.request.body` and compare to the header value. + +### Negative phase + +For each vector in `negative/`: + +1. Same setup as the positive phase. +2. Apply any `test_harness_state` declared on the vector (revocation list entries, replay cache entries, per-keyid cap). +3. Feed `vec.request` to your verifier. +4. Assert the verifier rejects with `success: false` AND `error_code` equal to `vec.expected_outcome.error_code` byte-for-byte. The `failed_step` is not graded. + +### Integration with `@adcp/client` + +Receiver libraries built on top of `@adcp/client` SHOULD run these vectors in CI against the library's 9421 webhook verifier. The `webhook-emission` universal's live E2E grading complements but does not replace this — live grading exercises positive paths; static negative vectors are the only reliable path to cover every `webhook_signature_*` error code deterministically. + +## Key material + +All keys in `keys.json` are test-only. Private components ship publicly in the `_private_d_for_test_only` field so SDKs can run both signer and verifier roles against the same keypairs. Do not use these keys in production — production signers MUST generate and publish their own keypairs at their own `jwks_uri`. + +The `test-revoked-webhook-2026` kid is a dedicated keypair for vector 017. Agents MUST NOT use this kid in production verifier revocation lists; it is scoped to grading runs via the runner contract. + +## Generating the vectors + +Vectors are generated by a script at `.context/generate-webhook-vectors.mjs` in the spec repo (gitignored — the script is not shipped). The script produces new keypairs and computes fresh signatures on each run, so the committed vectors are snapshot at generation time. Re-running the script would produce different keys and therefore different signatures; vectors are frozen once committed. + +A verification script at `.context/verify-webhook-vectors.mjs` checks that all positive vectors actually verify against the published keys. Run it before committing any regeneration. + +## Specification cross-reference + +- Webhook callbacks profile: `docs/building/implementation/security.mdx#webhook-callbacks` +- Verifier checklist: `#verifier-checklist-for-webhooks` +- Error taxonomy: `#webhook-error-taxonomy` +- Replay dedup and sizing: `#webhook-replay-dedup-sizing` +- `@target-uri` canonicalization (shared with request signing): `#adcp-rfc-9421-profile` diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/keys.json b/adcp-server/src/test/resources/compliance/webhook-signing/keys.json new file mode 100644 index 0000000..3808815 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/keys.json @@ -0,0 +1,61 @@ +{ + "_WARNING": "PUBLIC TEST KEYS — DO NOT TRUST IN PRODUCTION. The _private_d_for_test_only field exposes the full private key on every entry so SDK signer/verifier roundtrips can be exercised against the same material. Any production verifier that adds one of these kids to its trust store can be forged against by anyone who downloads this file. These keys are valid ONLY for grading against the AdCP webhook-signing conformance vectors.", + "$comment": "Test keypairs for AdCP webhook-signing conformance. These keys are public and MUST NOT be used in production. See README.md.", + "keys": [ + { + "kid": "test-ed25519-webhook-2026", + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "use": "sig", + "key_ops": [ + "verify" + ], + "adcp_use": "webhook-signing", + "x": "y7tTfeqazsFeTn3ccCzQlcJ4qFWuYsu-JkJAcfc9VoA", + "_private_d_for_test_only": "V0Z2ktvVfcWISjh4xmUlt-bSOJML9QBlzWzDkMFvgJw" + }, + { + "kid": "test-es256-webhook-2026", + "kty": "EC", + "crv": "P-256", + "alg": "ES256", + "use": "sig", + "key_ops": [ + "verify" + ], + "adcp_use": "webhook-signing", + "x": "0X7G_jryFpiX9XO3CKxIqUQs3DC8OhUkw6Rb5QOZd5M", + "y": "MwZN7qQJzLpTD5dyDJAoqOLZJ9r8-GCh4BnOYu6NE0c", + "_private_d_for_test_only": "xgSwq4Iq3RS8MFP2oXsDP--8uZLoq6Idip2PNM6pCZ4" + }, + { + "$comment": "Request-signing key (adcp_use='request-signing'). Included so negative vector 008 can demonstrate cross-purpose rejection: the vector presents this key when verifying a webhook signature, and the verifier MUST reject at step 8 because adcp_use is not 'webhook-signing'.", + "kid": "test-wrong-purpose-2026", + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "use": "sig", + "key_ops": [ + "verify" + ], + "adcp_use": "request-signing", + "x": "VgpQd9JRrBf433BcMw6IUNW7tHnAAHAHegsQ5U9I53c", + "_private_d_for_test_only": "MdgOQLf-gC6G-vq2GGlDwbNE1Q40FY6SQ20yd3s33Cg" + }, + { + "$comment": "Revoked webhook-signing key. Dedicated keypair for negative vector 017-key-revoked. adcp_use is 'webhook-signing' so that the verifier's purpose check (step 8) passes and the revocation check (step 9) fires. Runners pre-configure their revocation list to include this keyid before the negative phase runs; see test-kits/webhook-receiver-runner.yaml.", + "kid": "test-revoked-webhook-2026", + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "use": "sig", + "key_ops": [ + "verify" + ], + "adcp_use": "webhook-signing", + "x": "PbQhjfhr2rhLz5vLfN6jKQcjkia0Q9BJ77XpZ5hlspM", + "_private_d_for_test_only": "OrLTojJ_widzMiREZdxeVdtHW5aCh-CsEJlTztEvRiw" + } + ] +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/negative/001-wrong-tag.json b/adcp-server/src/test/resources/compliance/webhook-signing/negative/001-wrong-tag.json new file mode 100644 index 0000000..8be48ff --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/negative/001-wrong-tag.json @@ -0,0 +1,26 @@ +{ + "name": "Tag is adcp/request-signing/v1 on webhook route — must reject", + "spec_reference": "#webhook-callbacks (tag must be adcp/webhook-signing/v1)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:Zf91cu1JUHQHC2ewqzZc7XzPrwlGIY50rdBSYHtkaPOK84EQ_c7v9V4vaaPcVI6oWCgIitHjurEX5tOvgOT1AQ:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "expected_outcome": { + "success": false, + "error_code": "webhook_signature_tag_invalid", + "failed_step": 3 + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/negative/002-expired-signature.json b/adcp-server/src/test/resources/compliance/webhook-signing/negative/002-expired-signature.json new file mode 100644 index 0000000..91a4939 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/negative/002-expired-signature.json @@ -0,0 +1,26 @@ +{ + "name": "Signature expired (expires < now - 60s)", + "spec_reference": "#webhook-callbacks (window_invalid at step 5)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520400;expires=1776520700;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:sJEIxCjWPzvPbeNBncSZ8EdveRfq6uyLvNQ45DwZJVwXYHXcLBQbgsywaIJO9-ZahiIBD0MFroaMt7cQ3nkeAw:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520400;expires=1776520700;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": false, + "error_code": "webhook_signature_window_invalid", + "failed_step": 5 + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/negative/003-window-too-long.json b/adcp-server/src/test/resources/compliance/webhook-signing/negative/003-window-too-long.json new file mode 100644 index 0000000..d932176 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/negative/003-window-too-long.json @@ -0,0 +1,26 @@ +{ + "name": "expires − created > 300 seconds", + "spec_reference": "#webhook-callbacks (window_invalid at step 5)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521400;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:8rFnbQpOPxBhNaoeQVco76uI1pN3OB4WzorLNya6vh3O8Id8XjlTZLafwLzfrR-W2nHp9cZR4l3dKQcS20cSDw:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521400;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": false, + "error_code": "webhook_signature_window_invalid", + "failed_step": 5 + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/negative/004-alg-not-allowed.json b/adcp-server/src/test/resources/compliance/webhook-signing/negative/004-alg-not-allowed.json new file mode 100644 index 0000000..cf6f1fe --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/negative/004-alg-not-allowed.json @@ -0,0 +1,26 @@ +{ + "name": "alg parameter is rsa-pss-sha512 — not in allowlist", + "spec_reference": "#webhook-callbacks (alg_not_allowed at step 4)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"rsa-pss-sha512\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:KyEXwTz99uTM_9Kdt6HxG3HZ57wiYjfN-AN1qpeiUUmpKKJax2uvOki-GeV3gke6lYk57aZlW8NUXa_IxmrsDA:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"rsa-pss-sha512\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": false, + "error_code": "webhook_signature_alg_not_allowed", + "failed_step": 4 + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/negative/005-missing-authority-component.json b/adcp-server/src/test/resources/compliance/webhook-signing/negative/005-missing-authority-component.json new file mode 100644 index 0000000..3aa81bc --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/negative/005-missing-authority-component.json @@ -0,0 +1,26 @@ +{ + "name": "Covered components missing @authority — required for webhooks", + "spec_reference": "#webhook-callbacks (components_incomplete at step 6)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:m9WYTvzsgPgGF333wnEMA8dZ2PTGqdYciFxVE7O2vECwxxnCvmqFv7DpMW86WbWwq7-XnY_MwvEyA6G4E9kfDw:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": false, + "error_code": "webhook_signature_components_incomplete", + "failed_step": 6 + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/negative/006-missing-content-digest.json b/adcp-server/src/test/resources/compliance/webhook-signing/negative/006-missing-content-digest.json new file mode 100644 index 0000000..489d0b4 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/negative/006-missing-content-digest.json @@ -0,0 +1,25 @@ +{ + "name": "content-digest not covered — REQUIRED on webhooks (no forbidden policy branch)", + "spec_reference": "#webhook-callbacks (content-digest REQUIRED; components_incomplete at step 6)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:F3wFMVfyghSt4sc5kf61Ih6DI3gntY3UdH9vwAbMZe9j40m1F2e-pQxF_JZLOU6YEV2sGQnAGJdG49_vXUlgAw:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": false, + "error_code": "webhook_signature_components_incomplete", + "failed_step": 6 + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/negative/007-unknown-keyid.json b/adcp-server/src/test/resources/compliance/webhook-signing/negative/007-unknown-keyid.json new file mode 100644 index 0000000..41cdc9e --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/negative/007-unknown-keyid.json @@ -0,0 +1,26 @@ +{ + "name": "keyid references a kid not in the signer JWKS after one refetch", + "spec_reference": "#webhook-callbacks (key_unknown at step 7)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-unknown-keyid-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:IkauZs2ARgargjzGfu6MT9vvmXoN2kPkqSUapKvDXqza-KJUeEYuVHar91A3xFaywZ9FIGQdNQOp6O0LG1vACw:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-unknown-keyid-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": false, + "error_code": "webhook_signature_key_unknown", + "failed_step": 7 + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/negative/008-wrong-adcp-use.json b/adcp-server/src/test/resources/compliance/webhook-signing/negative/008-wrong-adcp-use.json new file mode 100644 index 0000000..d989158 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/negative/008-wrong-adcp-use.json @@ -0,0 +1,26 @@ +{ + "name": "Signing key has adcp_use=request-signing instead of webhook-signing", + "spec_reference": "#webhook-callbacks (key_purpose_invalid at step 8)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-wrong-purpose-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:OT_F-yQsYJzp4h1LAigin35vbEOWWuMgOKSHLhzCQHVsTBg5Mx72F5xboy4HiETvUuMiWz8MrW55wSgrjkjwCw:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-wrong-purpose-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-wrong-purpose-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": false, + "error_code": "webhook_signature_key_purpose_invalid", + "failed_step": 8 + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/negative/009-content-digest-mismatch.json b/adcp-server/src/test/resources/compliance/webhook-signing/negative/009-content-digest-mismatch.json new file mode 100644 index 0000000..17805e1 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/negative/009-content-digest-mismatch.json @@ -0,0 +1,26 @@ +{ + "name": "content-digest header does not match the body bytes", + "spec_reference": "#webhook-callbacks (digest_mismatch at step 11)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:kjGoMmRx5B0gPhN3vpf97PXKXw/tv28l9/02UreSL1Q=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:K-RLCW_fRohV6-zsyfxA8a7E4DMhPt845a0UJgsKQ78YBYQqOQJTF1gTYjppbS2omv1HLjCCIhpM0fFKTM95DA:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:kjGoMmRx5B0gPhN3vpf97PXKXw/tv28l9/02UreSL1Q=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": false, + "error_code": "webhook_signature_digest_mismatch", + "failed_step": 11 + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/negative/010-malformed-signature-input.json b/adcp-server/src/test/resources/compliance/webhook-signing/negative/010-malformed-signature-input.json new file mode 100644 index 0000000..f643aea --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/negative/010-malformed-signature-input.json @@ -0,0 +1,26 @@ +{ + "name": "Signature-Input header is syntactically malformed", + "spec_reference": "#webhook-callbacks (header_malformed at step 1)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=this-is-not-valid-structured-fields", + "Signature": "sig1=:nqTKCpjlqf1OqZPuJyPeiF7HJ01G8KmPNSzzmad0PAJv7OUVKthI7ks_j4G-6x1H4mBpXDIISgX_iZQiYvG7Dg:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": false, + "error_code": "webhook_signature_header_malformed", + "failed_step": 1 + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/negative/011-signature-without-input.json b/adcp-server/src/test/resources/compliance/webhook-signing/negative/011-signature-without-input.json new file mode 100644 index 0000000..31cb9ce --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/negative/011-signature-without-input.json @@ -0,0 +1,25 @@ +{ + "name": "Signature header present without Signature-Input — bound pair broken", + "spec_reference": "#webhook-callbacks (header_malformed pre-check; downgrade protection)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature": "sig1=:nqTKCpjlqf1OqZPuJyPeiF7HJ01G8KmPNSzzmad0PAJv7OUVKthI7ks_j4G-6x1H4mBpXDIISgX_iZQiYvG7Dg:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": false, + "error_code": "webhook_signature_header_malformed", + "failed_step": 1 + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/negative/012-missing-expires-param.json b/adcp-server/src/test/resources/compliance/webhook-signing/negative/012-missing-expires-param.json new file mode 100644 index 0000000..7643183 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/negative/012-missing-expires-param.json @@ -0,0 +1,26 @@ +{ + "name": "Signature-Input omits the required expires parameter", + "spec_reference": "#webhook-callbacks (params_incomplete at step 2)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:nqTKCpjlqf1OqZPuJyPeiF7HJ01G8KmPNSzzmad0PAJv7OUVKthI7ks_j4G-6x1H4mBpXDIISgX_iZQiYvG7Dg:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": false, + "error_code": "webhook_signature_params_incomplete", + "failed_step": 2 + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/negative/013-expires-le-created.json b/adcp-server/src/test/resources/compliance/webhook-signing/negative/013-expires-le-created.json new file mode 100644 index 0000000..00015b2 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/negative/013-expires-le-created.json @@ -0,0 +1,26 @@ +{ + "name": "expires ≤ created — rejected at step 5", + "spec_reference": "#webhook-callbacks (window_invalid at step 5)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776520800;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:2ZJGvmo9D5t_6m4wyzcFeOmj1wULo5XqmYKIB2S85WsLNyn6g--ZL9ABa3y66kcdNW9hyvjPHdUr4HnHngQbAw:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776520800;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": false, + "error_code": "webhook_signature_window_invalid", + "failed_step": 5 + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/negative/014-missing-nonce-param.json b/adcp-server/src/test/resources/compliance/webhook-signing/negative/014-missing-nonce-param.json new file mode 100644 index 0000000..4e116ec --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/negative/014-missing-nonce-param.json @@ -0,0 +1,26 @@ +{ + "name": "Signature-Input omits the required nonce parameter", + "spec_reference": "#webhook-callbacks (params_incomplete at step 2)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:nqTKCpjlqf1OqZPuJyPeiF7HJ01G8KmPNSzzmad0PAJv7OUVKthI7ks_j4G-6x1H4mBpXDIISgX_iZQiYvG7Dg:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": false, + "error_code": "webhook_signature_params_incomplete", + "failed_step": 2 + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/negative/015-signature-invalid.json b/adcp-server/src/test/resources/compliance/webhook-signing/negative/015-signature-invalid.json new file mode 100644 index 0000000..dd58b4e --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/negative/015-signature-invalid.json @@ -0,0 +1,26 @@ +{ + "name": "Signature bytes do not verify against the declared key", + "spec_reference": "#webhook-callbacks (invalid at step 10)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:YaTKCpjlqf1OqZPuJyPeiF7HJ01G8KmPNSzzmad0PAJv7OUVKthI7ks_j4G-6x1H4mBpXDIISgX_iZQiYvG7Dg:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": false, + "error_code": "webhook_signature_invalid", + "failed_step": 10 + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/negative/016-replayed-nonce.json b/adcp-server/src/test/resources/compliance/webhook-signing/negative/016-replayed-nonce.json new file mode 100644 index 0000000..fbcca8e --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/negative/016-replayed-nonce.json @@ -0,0 +1,37 @@ +{ + "name": "Nonce already seen within the replay-cache window — requires runner state", + "spec_reference": "#webhook-callbacks (replayed at step 12)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"REPLAYEDwebhook16byteA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:ZVRllejdoOlcnH1VkRrzv901IucAouTrwja2a0fv5F08f2Q2ahbpSxRrmnTLtXVEMgRfBZ_n1X8SGurLifJ_BQ:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"REPLAYEDwebhook16byteA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": false, + "error_code": "webhook_signature_replayed", + "failed_step": 12 + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2.", + "requires_contract": "webhook_receiver_runner", + "test_harness_state": { + "replay_cache_entries": [ + { + "keyid": "test-ed25519-webhook-2026", + "nonce": "REPLAYEDwebhook16byteA" + } + ] + }, + "black_box_behavior": "deliver_twice", + "black_box_note": "In black-box mode, the runner delivers this vector twice with the same nonce within the replay window; the receiver MUST accept the first and reject the second with webhook_signature_replayed." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/negative/017-key-revoked.json b/adcp-server/src/test/resources/compliance/webhook-signing/negative/017-key-revoked.json new file mode 100644 index 0000000..c8487ee --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/negative/017-key-revoked.json @@ -0,0 +1,32 @@ +{ + "name": "keyid is in the revocation list — rejected at step 9", + "spec_reference": "#webhook-callbacks (key_revoked at step 9)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-revoked-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:NjUVgJNsca2rd5ulEXjVwqlmpqBzGXbZ_EKgc188KklSV__NOngjvEDpUXeAqhOsF5BhXuf4zP3jtdtvk6jEAg:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-revoked-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-revoked-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": false, + "error_code": "webhook_signature_key_revoked", + "failed_step": 9 + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2.", + "requires_contract": "webhook_receiver_runner", + "test_harness_state": { + "revoked_kids": [ + "test-revoked-webhook-2026" + ] + } +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/negative/018-rate-abuse.json b/adcp-server/src/test/resources/compliance/webhook-signing/negative/018-rate-abuse.json new file mode 100644 index 0000000..026643a --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/negative/018-rate-abuse.json @@ -0,0 +1,33 @@ +{ + "name": "Per-keyid replay cache exceeded its entry cap — rejected at step 9a", + "spec_reference": "#webhook-callbacks; rate_abuse at step 9a", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:nqTKCpjlqf1OqZPuJyPeiF7HJ01G8KmPNSzzmad0PAJv7OUVKthI7ks_j4G-6x1H4mBpXDIISgX_iZQiYvG7Dg:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": false, + "error_code": "webhook_signature_rate_abuse", + "failed_step": 9, + "sub_step": "a" + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2.", + "requires_contract": "webhook_receiver_runner", + "test_harness_state": { + "per_keyid_cap_filled_for": "test-ed25519-webhook-2026" + }, + "black_box_behavior": "pre_fill_per_keyid_cap", + "black_box_note": "Runner pre-fills the per-keyid replay cache for this keyid to its grading_target_per_keyid_cap_entries before delivering this vector; receiver MUST reject with webhook_signature_rate_abuse without cryptographic verification (step 9a runs before step 10)." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/negative/019-revocation-stale.json b/adcp-server/src/test/resources/compliance/webhook-signing/negative/019-revocation-stale.json new file mode 100644 index 0000000..b3d9263 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/negative/019-revocation-stale.json @@ -0,0 +1,32 @@ +{ + "name": "Revocation list has not been refreshed within grace — rejected at step 9", + "spec_reference": "#webhook-callbacks; revocation_stale at step 9", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:nqTKCpjlqf1OqZPuJyPeiF7HJ01G8KmPNSzzmad0PAJv7OUVKthI7ks_j4G-6x1H4mBpXDIISgX_iZQiYvG7Dg:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": false, + "error_code": "webhook_signature_revocation_stale", + "failed_step": 9 + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2.", + "requires_contract": "webhook_receiver_runner", + "test_harness_state": { + "revocation_list_stale_seconds": 3600 + }, + "black_box_behavior": "simulate_stale_revocation_fetch", + "black_box_note": "Runner signals to the receiver that the revocation list has not refreshed within grace (the receiver's next_update + grace window has elapsed). Receiver MUST block new webhook verifications with webhook_signature_revocation_stale until the list is refreshed. Runners without this capability skip the vector as not_applicable." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/negative/020-key-ops-missing-verify.json b/adcp-server/src/test/resources/compliance/webhook-signing/negative/020-key-ops-missing-verify.json new file mode 100644 index 0000000..d1ca2d0 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/negative/020-key-ops-missing-verify.json @@ -0,0 +1,41 @@ +{ + "name": "Signing JWK has key_ops=[\"sign\"] (missing \"verify\") — rejected at step 8", + "spec_reference": "#webhook-callbacks; key_purpose_invalid at step 8", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:nqTKCpjlqf1OqZPuJyPeiF7HJ01G8KmPNSzzmad0PAJv7OUVKthI7ks_j4G-6x1H4mBpXDIISgX_iZQiYvG7Dg:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": false, + "error_code": "webhook_signature_key_purpose_invalid", + "failed_step": 8 + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2.", + "jwks_override": { + "test-ed25519-webhook-2026": { + "kid": "test-ed25519-webhook-2026", + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "use": "sig", + "key_ops": [ + "sign" + ], + "adcp_use": "webhook-signing", + "x": "y7tTfeqazsFeTn3ccCzQlcJ4qFWuYsu-JkJAcfc9VoA" + } + }, + "jwks_override_note": "Vector presents the verifier with a JWK whose key_ops excludes \"verify\". Step 8 requires key_ops includes \"verify\" — receiver MUST reject. The signature itself is valid; step 8 short-circuits before signature verification (step 10)." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/negative/021-base64-alphabet-mixing.json b/adcp-server/src/test/resources/compliance/webhook-signing/negative/021-base64-alphabet-mixing.json new file mode 100644 index 0000000..4e210da --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/negative/021-base64-alphabet-mixing.json @@ -0,0 +1,26 @@ +{ + "name": "Signature sf-binary token mixes base64url and standard-base64 alphabets — rejected at step 1", + "spec_reference": "#adcp-rfc-9421-profile (binary value encoding); header_malformed at step 1", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:A+B-CDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789AB:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": false, + "error_code": "webhook_signature_header_malformed", + "failed_step": 1 + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/positive/001-basic-post.json b/adcp-server/src/test/resources/compliance/webhook-signing/positive/001-basic-post.json new file mode 100644 index 0000000..7b2cac7 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/positive/001-basic-post.json @@ -0,0 +1,24 @@ +{ + "name": "Basic POST webhook, Ed25519, all five required components covered", + "spec_reference": "#verifier-checklist-for-webhooks (all steps pass)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:nqTKCpjlqf1OqZPuJyPeiF7HJ01G8KmPNSzzmad0PAJv7OUVKthI7ks_j4G-6x1H4mBpXDIISgX_iZQiYvG7Dg:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": true + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/positive/002-es256-post.json b/adcp-server/src/test/resources/compliance/webhook-signing/positive/002-es256-post.json new file mode 100644 index 0000000..f88b1df --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/positive/002-es256-post.json @@ -0,0 +1,24 @@ +{ + "name": "ES256 webhook POST, all required components covered", + "spec_reference": "#verifier-checklist-for-webhooks, #adcp-rfc-9421-profile (alg allowlist)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-es256-webhook-2026\";alg=\"ecdsa-p256-sha256\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:iVB5KDEpieLdhi-wx1z9ZEPPJLDCN0JRjInTVCxN4STDBFCAgk78UIIbwWNlSVT3pChDfloWxXezPRxqT5IspQ:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-es256-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-es256-webhook-2026\";alg=\"ecdsa-p256-sha256\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": true + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/positive/003-multiple-signature-labels.json b/adcp-server/src/test/resources/compliance/webhook-signing/positive/003-multiple-signature-labels.json new file mode 100644 index 0000000..25b1801 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/positive/003-multiple-signature-labels.json @@ -0,0 +1,24 @@ +{ + "name": "Multiple Signature-Input labels; verifier processes sig1 only", + "spec_reference": "#webhook-callbacks (one signature per webhook)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\", relay=(\"@method\" \"@target-uri\" \"@authority\");created=1776520800;expires=1776521100;nonce=\"relayNonce000000000\";keyid=\"relay-key\";alg=\"ed25519\";tag=\"some-other-profile\"", + "Signature": "sig1=:nqTKCpjlqf1OqZPuJyPeiF7HJ01G8KmPNSzzmad0PAJv7OUVKthI7ks_j4G-6x1H4mBpXDIISgX_iZQiYvG7Dg:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": true + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/positive/004-default-port-stripped.json b/adcp-server/src/test/resources/compliance/webhook-signing/positive/004-default-port-stripped.json new file mode 100644 index 0000000..028a306 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/positive/004-default-port-stripped.json @@ -0,0 +1,24 @@ +{ + "name": "URL has :443 explicitly; canonicalization strips it before signing", + "spec_reference": "#adcp-rfc-9421-profile (target-uri canonicalization step 4)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com:443/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:nqTKCpjlqf1OqZPuJyPeiF7HJ01G8KmPNSzzmad0PAJv7OUVKthI7ks_j4G-6x1H4mBpXDIISgX_iZQiYvG7Dg:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": true + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/positive/005-percent-encoded-path.json b/adcp-server/src/test/resources/compliance/webhook-signing/positive/005-percent-encoded-path.json new file mode 100644 index 0000000..d682d39 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/positive/005-percent-encoded-path.json @@ -0,0 +1,24 @@ +{ + "name": "Path has lowercase %xx; canonicalization uppercases hex digits", + "spec_reference": "#adcp-rfc-9421-profile (target-uri canonicalization step 6)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_%e2%98%83", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:xVYEHK2oS2PWxIYU9iyB96ObGdP-xl-4PoNazA12JO-GLXULtEwoEd9b9yM40Y-XcZ3kTX9ZA8KMms4ZTfA2Bg:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_%E2%98%83\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": true + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/positive/006-query-byte-preserved.json b/adcp-server/src/test/resources/compliance/webhook-signing/positive/006-query-byte-preserved.json new file mode 100644 index 0000000..b722be4 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/positive/006-query-byte-preserved.json @@ -0,0 +1,24 @@ +{ + "name": "Query string preserved byte-for-byte (not alphabetized)", + "spec_reference": "#adcp-rfc-9421-profile (target-uri canonicalization step 7)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook?b=2&a=1&c=3", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:a7ON0g72rNf2Y1BmlkoUiqBpKM-GhPSc-crRm9SBSDkKlefoy4NVS0LxElWANQPUyrmtK91Nsomg43rFD_kYAw:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook?b=2&a=1&c=3\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": true + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} diff --git a/adcp-server/src/test/resources/compliance/webhook-signing/positive/007-body-without-idempotency-key.json b/adcp-server/src/test/resources/compliance/webhook-signing/positive/007-body-without-idempotency-key.json new file mode 100644 index 0000000..02ae129 --- /dev/null +++ b/adcp-server/src/test/resources/compliance/webhook-signing/positive/007-body-without-idempotency-key.json @@ -0,0 +1,25 @@ +{ + "name": "Body omits idempotency_key; signature still verifies (schema validation is a separate later check)", + "spec_reference": "#webhook-callbacks; demonstrates signature/payload-schema separation (idempotency_key required at payload schema per #2417, not at signature layer)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:OuudqWPNz6iqVxXQsQaJYc8qkpeYonU3vsHE5ONY0V8=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:ne1wlfDbj56y3YNTA6M55QI6ZpLmGlvXU6fi1UDIscjyiD7r6LA2uTQghBK11Z2MzaPXN-c7iUzPw9ulXMMBAw:" + }, + "body": "{\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-ed25519-webhook-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:OuudqWPNz6iqVxXQsQaJYc8qkpeYonU3vsHE5ONY0V8=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-webhook-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": true, + "notes": "9421 signature verifies. A conformant receiver SHOULD subsequently reject this payload at schema validation (mcp-webhook-payload.json requires idempotency_key); that is NOT a signature error and does not map to any webhook_signature_* code." + }, + "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." +} From 2f4e92387c5563b05a64d1a30aabd418ad09c34f Mon Sep 17 00:00:00 2001 From: Lobsterdog Contributors Date: Tue, 16 Jun 2026 22:50:04 -0600 Subject: [PATCH 03/21] feat(signing): add InProcessSigningProvider, WebhookSigner seam, and verification round-trip tests Track 4 (L1 Signing) implementation: - InProcessSigningProvider: JCA Ed25519/ES256/ES384 signing via SigningProvider SPI, delegates to Rfc9421Canonicalizer for signature base construction - InProcessKeyGenerator: test keypair generation utility (Ed25519, ES256, ES384) for development/testing - InProcessVerificationProvider: verification path using Rfc9421Verifier with AdCP profile validation - WebhookSigner/DefaultWebhookSigner: seam interface for Track 6 (async-l3) webhook signing, applies AdCP webhook-signing profile - WebhookSigningResult: record for signed webhook headers - Tests: signing/verification round-trip for Ed25519 and ES256, key generation, webhook signer seam --- .../server/signing/DefaultWebhookSigner.java | 70 +++++ .../server/signing/InProcessKeyGenerator.java | 61 +++++ .../signing/InProcessSigningProvider.java | 170 ++++++++++++ .../InProcessVerificationProvider.java | 50 ++++ .../adcp/server/signing/WebhookSigner.java | 29 ++ .../server/signing/WebhookSigningResult.java | 12 + .../adcp/server/signing/package-info.java | 12 +- .../signing/InProcessKeyGeneratorTest.java | 82 ++++++ .../signing/InProcessSigningProviderTest.java | 248 ++++++++++++++++++ .../server/signing/WebhookSignerTest.java | 133 ++++++++++ 10 files changed, 866 insertions(+), 1 deletion(-) create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/DefaultWebhookSigner.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/InProcessKeyGenerator.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/InProcessSigningProvider.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/InProcessVerificationProvider.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/WebhookSigner.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/WebhookSigningResult.java create mode 100644 adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/InProcessKeyGeneratorTest.java create mode 100644 adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/InProcessSigningProviderTest.java create mode 100644 adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/WebhookSignerTest.java diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/DefaultWebhookSigner.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/DefaultWebhookSigner.java new file mode 100644 index 0000000..1b9d30b --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/DefaultWebhookSigner.java @@ -0,0 +1,70 @@ +package org.adcontextprotocol.adcp.server.signing; + +import org.adcontextprotocol.adcp.signing.AdcpUse; +import org.adcontextprotocol.adcp.signing.SigningContext; +import org.adcontextprotocol.adcp.signing.SigningException; +import org.adcontextprotocol.adcp.signing.SigningInput; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Default implementation of {@link WebhookSigner} that uses + * {@link InProcessSigningProvider} for signing. + * + *

Applies the AdCP webhook-signing profile: + *

    + *
  • tag = "adcp/webhook-signing/v1"
  • + *
  • Content-Digest is required for all webhook payloads
  • + *
  • 300-second replay window
  • + *
  • Covers: @method, @target-uri, @authority, content-type, content-digest
  • + *
+ */ +public final class DefaultWebhookSigner implements WebhookSigner { + + private final InProcessSigningProvider signingProvider; + + /** + * Create a DefaultWebhookSigner with the given signing provider. + * + * @param signingProvider the in-process signing provider + */ + public DefaultWebhookSigner(InProcessSigningProvider signingProvider) { + this.signingProvider = Objects.requireNonNull(signingProvider, "signingProvider"); + } + + @Override + public WebhookSigningResult sign(SigningContext context, String method, String targetUri, byte[] body, Map existingHeaders) { + if (context.use() != AdcpUse.WEBHOOK_SIGNING) { + throw new IllegalArgumentException("WebhookSigner requires SigningContext with WEBHOOK_SIGNING use, got: " + context.use()); + } + + Map headers = new LinkedHashMap<>(existingHeaders); + if (body != null && body.length > 0) { + headers.putIfAbsent("content-digest", ContentDigest.sha256(body)); + } + + SigningInput input = new SimpleSigningInput(method, targetUri, body, headers); + + try { + org.adcontextprotocol.adcp.signing.Signature sig = signingProvider.sign(context, input); + + String contentDigestValue = headers.get("content-digest"); + + return new WebhookSigningResult( + sig.signatureInput(), + sig.label() + "=:" + ContentDigest.base64UrlNoPadding(sig.signatureBytes()) + ":", + contentDigestValue); + } catch (SigningException e) { + throw new RuntimeException("Failed to sign webhook payload", e); + } + } + + private record SimpleSigningInput( + String method, + String targetUri, + byte[] body, + Map headers + ) implements SigningInput {} +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/InProcessKeyGenerator.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/InProcessKeyGenerator.java new file mode 100644 index 0000000..795f178 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/InProcessKeyGenerator.java @@ -0,0 +1,61 @@ +package org.adcontextprotocol.adcp.server.signing; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.spec.ECGenParameterSpec; + +/** + * Utility for generating test keypairs (Ed25519 and ES256). + * + *

This is for testing and development only — production deployments should + * use KMS-managed keys. + */ +public final class InProcessKeyGenerator { + + private InProcessKeyGenerator() {} + + /** + * Generate an Ed25519 keypair. + * + * @return the generated keypair + */ + public static KeyPair generateEd25519() { + try { + KeyPairGenerator gen = KeyPairGenerator.getInstance("Ed25519"); + return gen.generateKeyPair(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Ed25519 not available (requires JDK 15+)", e); + } + } + + /** + * Generate an ECDSA P-256 (ES256) keypair. + * + * @return the generated keypair + */ + public static KeyPair generateES256() { + try { + KeyPairGenerator gen = KeyPairGenerator.getInstance("EC"); + gen.initialize(new ECGenParameterSpec("secp256r1")); + return gen.generateKeyPair(); + } catch (Exception e) { + throw new IllegalStateException("EC P-256 not available", e); + } + } + + /** + * Generate an ECDSA P-384 (ES384) keypair. + * + * @return the generated keypair + */ + public static KeyPair generateES384() { + try { + KeyPairGenerator gen = KeyPairGenerator.getInstance("EC"); + gen.initialize(new ECGenParameterSpec("secp384r1")); + return gen.generateKeyPair(); + } catch (Exception e) { + throw new IllegalStateException("EC P-384 not available", e); + } + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/InProcessSigningProvider.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/InProcessSigningProvider.java new file mode 100644 index 0000000..301701e --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/InProcessSigningProvider.java @@ -0,0 +1,170 @@ +package org.adcontextprotocol.adcp.server.signing; + +import org.adcontextprotocol.adcp.signing.AdcpUse; +import org.adcontextprotocol.adcp.signing.Signature; +import org.adcontextprotocol.adcp.signing.SigningContext; +import org.adcontextprotocol.adcp.signing.SigningException; +import org.adcontextprotocol.adcp.signing.SigningInput; +import org.adcontextprotocol.adcp.signing.SigningProvider; + +import java.nio.charset.StandardCharsets; +import java.security.PrivateKey; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * In-process signing provider using JDK 21's built-in Ed25519 and ECDSA + * (P-256/P-384) key support. + * + *

Takes a {@link PrivateKey} and key metadata (kid, algorithm, crv) in its + * constructor. Supports: + *

    + *
  • {@code EdDSA} (Ed25519)
  • + *
  • {@code ES256} (ECDSA P-256 with SHA-256)
  • + *
  • {@code ES384} (ECDSA P-384 with SHA-384)
  • + *
+ * + *

Per D2 and the ROADMAP: No Bouncy Castle in core — JDK 21 + * has Ed25519 natively. + */ +public final class InProcessSigningProvider implements SigningProvider { + + private final PrivateKey privateKey; + private final String kid; + private final String alg; + private final String crv; + + /** + * Create an in-process signing provider. + * + * @param privateKey the JCA private key + * @param kid the key identifier + * @param alg the AdCP algorithm identifier (e.g. "ed25519", "ecdsa-p256-sha256") + * @param crv the curve name (e.g. "Ed25519", "P-256", "P-384"), or null for Ed25519 + */ + public InProcessSigningProvider(PrivateKey privateKey, String kid, String alg, String crv) { + this.privateKey = Objects.requireNonNull(privateKey, "privateKey"); + this.kid = Objects.requireNonNull(kid, "kid"); + this.alg = Objects.requireNonNull(alg, "alg"); + this.crv = crv; + } + + @Override + public Signature sign(SigningContext context, SigningInput input) throws SigningException { + String jcaAlgorithm = toJcaAlgorithm(alg, crv); + + String tag = AdcpSignatureProfile.tagForUse(context.use()); + List coveredComponents = AdcpSignatureProfile.requiredComponentsForUse(context.use()); + + long created = System.currentTimeMillis() / 1000; + long expires = created + AdcpSignatureProfile.REPLAY_WINDOW_SECONDS; + String nonce = generateNonce(); + + String signatureInputValue = SignatureInputBuilder.create() + .label(Signature.DEFAULT_LABEL) + .coveredComponents(coveredComponents) + .created(created) + .expires(expires) + .nonce(nonce) + .keyid(kid) + .alg(alg) + .tag(tag) + .build(); + + Map signingHeaders = new LinkedHashMap<>(input.headers()); + if (input.body() != null && input.body().length > 0) { + String contentDigestValue = ContentDigest.sha256(input.body()); + signingHeaders.put("content-digest", contentDigestValue); + } + + String signatureBase = Rfc9421Canonicalizer.canonicalize( + input.method(), + input.targetUri(), + signingHeaders, + coveredComponents, + signatureInputValue); + + byte[] signatureBytes = doSign(jcaAlgorithm, signatureBase.getBytes(StandardCharsets.UTF_8)); + + return new Signature( + Signature.DEFAULT_LABEL, + signatureInputValue, + signatureBytes, + alg, + kid); + } + + /** + * Sign with explicit timestamps (for testing). + */ + public Signature sign(SigningContext context, SigningInput input, long created, long expires, String nonce) throws SigningException { + String jcaAlgorithm = toJcaAlgorithm(alg, crv); + + String tag = AdcpSignatureProfile.tagForUse(context.use()); + List coveredComponents = AdcpSignatureProfile.requiredComponentsForUse(context.use()); + + String signatureInputValue = SignatureInputBuilder.create() + .label(Signature.DEFAULT_LABEL) + .coveredComponents(coveredComponents) + .created(created) + .expires(expires) + .nonce(nonce) + .keyid(kid) + .alg(alg) + .tag(tag) + .build(); + + Map signingHeaders = new LinkedHashMap<>(input.headers()); + if (input.body() != null && input.body().length > 0) { + String contentDigestValue = ContentDigest.sha256(input.body()); + signingHeaders.put("content-digest", contentDigestValue); + } + + String signatureBase = Rfc9421Canonicalizer.canonicalize( + input.method(), + input.targetUri(), + signingHeaders, + coveredComponents, + signatureInputValue); + + byte[] signatureBytes = doSign(jcaAlgorithm, signatureBase.getBytes(StandardCharsets.UTF_8)); + + return new Signature( + Signature.DEFAULT_LABEL, + signatureInputValue, + signatureBytes, + alg, + kid); + } + + private byte[] doSign(String jcaAlgorithm, byte[] data) throws SigningException { + try { + java.security.Signature signer = java.security.Signature.getInstance(jcaAlgorithm); + signer.initSign(privateKey); + signer.update(data); + return signer.sign(); + } catch (Exception e) { + throw new SigningException("Failed to sign: " + e.getMessage(), e); + } + } + + /** + * Convert AdCP algorithm identifier to JCA algorithm name. + */ + static String toJcaAlgorithm(String alg, String crv) throws SigningException { + return switch (alg) { + case AdcpSignatureProfile.ALG_ED25519 -> "Ed25519"; + case AdcpSignatureProfile.ALG_ECDSA_P256_SHA256 -> "SHA256withECDSAinP1363Format"; + case "ecdsa-p384-sha384" -> "SHA384withECDSAinP1363Format"; + default -> throw new SigningException("Unsupported signing algorithm: " + alg); + }; + } + + private static String generateNonce() { + byte[] bytes = new byte[16]; + new java.security.SecureRandom().nextBytes(bytes); + return java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/InProcessVerificationProvider.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/InProcessVerificationProvider.java new file mode 100644 index 0000000..a2886d5 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/InProcessVerificationProvider.java @@ -0,0 +1,50 @@ +package org.adcontextprotocol.adcp.server.signing; + +import org.adcontextprotocol.adcp.signing.AdcpUse; +import org.adcontextprotocol.adcp.signing.SignedInput; +import org.adcontextprotocol.adcp.signing.VerificationException; +import org.adcontextprotocol.adcp.signing.VerificationKey; + +import java.security.PublicKey; +import java.util.Map; + +/** + * In-process verification provider using JDK 21's built-in Ed25519 and ECDSA + * key support. Delegates to {@link Rfc9421Verifier} for the core verification + * logic and AdCP profile validation. + * + *

This provider consolidates the full verification path: + *

    + *
  1. Parse the {@code Signature-Input} header from request headers
  2. + *
  3. Validate all AdCP profile requirements (tag, created/expires window, + * nonce, keyid, alg, required covered components)
  4. + *
  5. Build the signature base using {@link Rfc9421Canonicalizer}
  6. + *
  7. Verify the signature bytes against the base using the public key
  8. + *
  9. Return {@link VerificationResult.Valid} or {@link VerificationResult.Invalid} + * with the appropriate {@code webhook_signature_*} error code
  10. + *
+ */ +public final class InProcessVerificationProvider { + + private InProcessVerificationProvider() {} + + /** + * Verify an inbound webhook signature. + * + * @param input the signed input from the wire + * @param key the verification key + * @param expectedUse the expected AdCP use (WEBHOOK_SIGNING or REQUEST_SIGNING) + * @param referenceNow Unix seconds representing "now" for window validation + * @return a VerificationResult + */ + public static VerificationResult verify(SignedInput input, VerificationKey key, AdcpUse expectedUse, long referenceNow) { + return Rfc9421Verifier.verify(input, key, expectedUse, referenceNow); + } + + /** + * Verify an inbound webhook signature using "now" as the reference timestamp. + */ + public static VerificationResult verify(SignedInput input, VerificationKey key, AdcpUse expectedUse) { + return Rfc9421Verifier.verify(input, key, expectedUse, System.currentTimeMillis() / 1000); + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/WebhookSigner.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/WebhookSigner.java new file mode 100644 index 0000000..28107a8 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/WebhookSigner.java @@ -0,0 +1,29 @@ +package org.adcontextprotocol.adcp.server.signing; + +import org.adcontextprotocol.adcp.signing.SigningContext; +import org.jspecify.annotations.Nullable; + +import java.util.Map; + +/** + * Signs outbound webhook payloads using RFC 9421. + * + *

Track 6 (async-l3) will call this interface when sending webhooks. + * The implementation uses {@link Rfc9421Signer} internally and applies + * the AdCP webhook-signing profile (tag="adcp/webhook-signing/v1", + * content-digest required, 300s replay window). + */ +public interface WebhookSigner { + + /** + * Signs a webhook payload and returns the signed headers to add to the outbound request. + * + * @param context signing context with purpose, tenant, and principal + * @param method HTTP method (always POST for webhooks) + * @param targetUri the webhook endpoint URI + * @param body raw body bytes (MUST NOT be re-serialized — sign the bytes on the wire) + * @param existingHeaders headers already present on the request (e.g., Content-Type) + * @return signed headers to add to the outbound request + */ + WebhookSigningResult sign(SigningContext context, String method, String targetUri, byte[] body, Map existingHeaders); +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/WebhookSigningResult.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/WebhookSigningResult.java new file mode 100644 index 0000000..14eb4ff --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/WebhookSigningResult.java @@ -0,0 +1,12 @@ +package org.adcontextprotocol.adcp.server.signing; + +import org.jspecify.annotations.Nullable; + +/** + * Result of signing a webhook payload. Contains the headers to add to the outbound request. + */ +public record WebhookSigningResult( + String signatureInput, + String signature, + @Nullable String contentDigest +) {} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/package-info.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/package-info.java index bf42bfc..30fd97c 100644 --- a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/package-info.java +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/package-info.java @@ -7,12 +7,22 @@ * constraints for webhook and request signing. * *

Core SPI types live in {@code org.adcontextprotocol.adcp.signing} (the - * {@code adcp} module). This package provides the concrete implementations. + * {@code adcp} module). This package provides the concrete implementations: + * + *

    + *
  • {@link InProcessSigningProvider} — JCA Ed25519/ECDSA signing provider
  • + *
  • {@link InProcessVerificationProvider} — JCA Ed25519/ECDSA verification provider
  • + *
  • {@link InProcessKeyGenerator} — test keypair generation utility
  • + *
  • {@link WebhookSigner} / {@link DefaultWebhookSigner} — outbound webhook signing seam
  • + *
* * @see Rfc9421Canonicalizer * @see Rfc9421Signer * @see Rfc9421Verifier * @see AdcpSignatureProfile + * @see InProcessSigningProvider + * @see InProcessVerificationProvider + * @see WebhookSigner */ @org.jspecify.annotations.NullMarked package org.adcontextprotocol.adcp.server.signing; \ No newline at end of file diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/InProcessKeyGeneratorTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/InProcessKeyGeneratorTest.java new file mode 100644 index 0000000..8a15c10 --- /dev/null +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/InProcessKeyGeneratorTest.java @@ -0,0 +1,82 @@ +package org.adcontextprotocol.adcp.server.signing; + +import org.junit.jupiter.api.Test; + +import java.security.KeyPair; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.EdECPrivateKey; + +import static org.junit.jupiter.api.Assertions.*; + +class InProcessKeyGeneratorTest { + + @Test + void generateEd25519_returnsValidKeyPair() { + KeyPair kp = InProcessKeyGenerator.generateEd25519(); + + assertNotNull(kp); + assertNotNull(kp.getPrivate()); + assertNotNull(kp.getPublic()); + assertTrue(kp.getPrivate().getAlgorithm().equals("Ed25519") || kp.getPrivate().getAlgorithm().equals("EdDSA"), + "Expected Ed25519 or EdDSA algorithm, got: " + kp.getPrivate().getAlgorithm()); + assertTrue(kp.getPublic().getAlgorithm().equals("Ed25519") || kp.getPublic().getAlgorithm().equals("EdDSA"), + "Expected Ed25519 or EdDSA algorithm, got: " + kp.getPublic().getAlgorithm()); + } + + @Test + void generateEd25519_producesDifferentKeyPairs() { + KeyPair kp1 = InProcessKeyGenerator.generateEd25519(); + KeyPair kp2 = InProcessKeyGenerator.generateEd25519(); + + assertArrayEquals(kp1.getPrivate().getEncoded(), kp1.getPrivate().getEncoded()); + assertFalse(java.util.Arrays.equals(kp1.getPrivate().getEncoded(), kp2.getPrivate().getEncoded()), + "Two generated Ed25519 keypairs should differ"); + } + + @Test + void generateES256_returnsValidKeyPair() { + KeyPair kp = InProcessKeyGenerator.generateES256(); + + assertNotNull(kp); + assertNotNull(kp.getPrivate()); + assertNotNull(kp.getPublic()); + assertEquals("EC", kp.getPrivate().getAlgorithm()); + assertEquals("EC", kp.getPublic().getAlgorithm()); + } + + @Test + void generateES384_returnsValidKeyPair() { + KeyPair kp = InProcessKeyGenerator.generateES384(); + + assertNotNull(kp); + assertNotNull(kp.getPrivate()); + assertNotNull(kp.getPublic()); + assertEquals("EC", kp.getPrivate().getAlgorithm()); + assertEquals("EC", kp.getPublic().getAlgorithm()); + } + + @Test + void generateES256_keyIsP256() { + KeyPair kp = InProcessKeyGenerator.generateES256(); + + assertTrue(kp.getPrivate() instanceof ECPrivateKey); + ECPrivateKey ecKey = (ECPrivateKey) kp.getPrivate(); + assertEquals(256, ecKey.getParams().getOrder().bitLength()); + } + + @Test + void generateES384_keyIsP384() { + KeyPair kp = InProcessKeyGenerator.generateES384(); + + assertTrue(kp.getPrivate() instanceof ECPrivateKey); + ECPrivateKey ecKey = (ECPrivateKey) kp.getPrivate(); + assertEquals(384, ecKey.getParams().getOrder().bitLength()); + } + + @Test + void generateEd25519_privateKeyIsEdECKey() { + KeyPair kp = InProcessKeyGenerator.generateEd25519(); + + assertTrue(kp.getPrivate() instanceof EdECPrivateKey); + } +} \ No newline at end of file diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/InProcessSigningProviderTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/InProcessSigningProviderTest.java new file mode 100644 index 0000000..531b682 --- /dev/null +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/InProcessSigningProviderTest.java @@ -0,0 +1,248 @@ +package org.adcontextprotocol.adcp.server.signing; + +import org.adcontextprotocol.adcp.signing.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class InProcessSigningProviderTest { + + private KeyPair ed25519KeyPair; + private KeyPair es256KeyPair; + + @BeforeEach + void setUp() { + ed25519KeyPair = InProcessKeyGenerator.generateEd25519(); + es256KeyPair = InProcessKeyGenerator.generateES256(); + } + + @Test + void ed25519_signAndVerifyRoundTrip() throws SigningException { + PrivateKey privateKey = ed25519KeyPair.getPrivate(); + PublicKey publicKey = ed25519KeyPair.getPublic(); + + InProcessSigningProvider signer = new InProcessSigningProvider( + privateKey, "test-ed25519-key", AdcpSignatureProfile.ALG_ED25519, "Ed25519"); + + SigningContext context = SigningContext.builder(AdcpUse.WEBHOOK_SIGNING).build(); + Map headers = new LinkedHashMap<>(); + headers.put("content-type", "application/json"); + + byte[] body = "{\"task_id\":\"123\"}".getBytes(StandardCharsets.UTF_8); + SigningInput input = new TestSigningInput("POST", "https://buyer.example.com/webhook", body, headers); + + Signature sig = signer.sign(context, input); + + assertEquals("sig1", sig.label()); + assertEquals("test-ed25519-key", sig.kid()); + assertEquals(AdcpSignatureProfile.ALG_ED25519, sig.algorithm()); + assertNotNull(sig.signatureBytes()); + assertNotNull(sig.signatureInput()); + assertTrue(sig.signatureInput().contains("alg=\"ed25519\"")); + assertTrue(sig.signatureInput().contains("tag=\"adcp/webhook-signing/v1\"")); + } + + @Test + void es256_signAndVerifyRoundTrip() throws SigningException { + PrivateKey privateKey = es256KeyPair.getPrivate(); + + InProcessSigningProvider signer = new InProcessSigningProvider( + privateKey, "test-es256-key", AdcpSignatureProfile.ALG_ECDSA_P256_SHA256, "P-256"); + + SigningContext context = SigningContext.builder(AdcpUse.WEBHOOK_SIGNING).build(); + Map headers = new LinkedHashMap<>(); + headers.put("content-type", "application/json"); + + byte[] body = "{\"task_id\":\"456\"}".getBytes(StandardCharsets.UTF_8); + SigningInput input = new TestSigningInput("POST", "https://buyer.example.com/webhook", body, headers); + + Signature sig = signer.sign(context, input); + + assertEquals("sig1", sig.label()); + assertEquals("test-es256-key", sig.kid()); + assertEquals(AdcpSignatureProfile.ALG_ECDSA_P256_SHA256, sig.algorithm()); + assertTrue(sig.signatureInput().contains("alg=\"ecdsa-p256-sha256\"")); + } + + @Test + void signWithExplicitTimestamps() throws SigningException { + PrivateKey privateKey = ed25519KeyPair.getPrivate(); + + InProcessSigningProvider signer = new InProcessSigningProvider( + privateKey, "test-ed25519-key", AdcpSignatureProfile.ALG_ED25519, "Ed25519"); + + SigningContext context = SigningContext.builder(AdcpUse.WEBHOOK_SIGNING).build(); + Map headers = new LinkedHashMap<>(); + headers.put("content-type", "application/json"); + + byte[] body = "{\"task_id\":\"789\"}".getBytes(StandardCharsets.UTF_8); + SigningInput input = new TestSigningInput("POST", "https://buyer.example.com/webhook", body, headers); + + long created = 1776520800L; + long expires = created + 300; + String nonce = "test-nonce-12345"; + + Signature sig = signer.sign(context, input, created, expires, nonce); + + assertTrue(sig.signatureInput().contains("created=1776520800")); + assertTrue(sig.signatureInput().contains("expires=1776521100")); + assertTrue(sig.signatureInput().contains("nonce=\"test-nonce-12345\"")); + } + + @Test + void sign_requestSigningUse() throws SigningException { + PrivateKey privateKey = ed25519KeyPair.getPrivate(); + + InProcessSigningProvider signer = new InProcessSigningProvider( + privateKey, "test-req-key", AdcpSignatureProfile.ALG_ED25519, "Ed25519"); + + SigningContext context = SigningContext.builder(AdcpUse.REQUEST_SIGNING).build(); + Map headers = new LinkedHashMap<>(); + headers.put("content-type", "application/json"); + + byte[] body = new byte[0]; + SigningInput input = new TestSigningInput("POST", "https://seller.example.com/adcp/v3/endpoint", body, headers); + + Signature sig = signer.sign(context, input); + + assertTrue(sig.signatureInput().contains("tag=\"adcp/request-signing/v1\"")); + } + + @Test + void sign_addsContentDigestForNonEmptyBody() throws SigningException { + PrivateKey privateKey = ed25519KeyPair.getPrivate(); + + InProcessSigningProvider signer = new InProcessSigningProvider( + privateKey, "test-ed25519-key", AdcpSignatureProfile.ALG_ED25519, "Ed25519"); + + SigningContext context = SigningContext.builder(AdcpUse.WEBHOOK_SIGNING).build(); + Map headers = new LinkedHashMap<>(); + headers.put("content-type", "application/json"); + + byte[] body = "{\"test\":true}".getBytes(StandardCharsets.UTF_8); + SigningInput input = new TestSigningInput("POST", "https://buyer.example.com/webhook", body, headers); + + Signature sig = signer.sign(context, input); + + assertTrue(sig.signatureInput().contains("content-digest")); + } + + @Test + void verificationRoundTrip_ed25519() throws SigningException { + PrivateKey privateKey = ed25519KeyPair.getPrivate(); + PublicKey publicKey = ed25519KeyPair.getPublic(); + + InProcessSigningProvider signer = new InProcessSigningProvider( + privateKey, "test-ed25519-roundtrip", AdcpSignatureProfile.ALG_ED25519, "Ed25519"); + + SigningContext context = SigningContext.builder(AdcpUse.WEBHOOK_SIGNING).build(); + Map headers = new LinkedHashMap<>(); + headers.put("content-type", "application/json"); + + byte[] body = "{\"round\":\"trip\"}".getBytes(StandardCharsets.UTF_8); + SigningInput signingInput = new TestSigningInput("POST", "https://buyer.example.com/webhook", body, headers); + + long created = System.currentTimeMillis() / 1000; + long expires = created + 300; + String nonce = "roundtrip-nonce-test"; + + Signature sig = signer.sign(context, signingInput, created, expires, nonce); + + Map verifyHeaders = new LinkedHashMap<>(headers); + verifyHeaders.put("signature-input", sig.signatureInput()); + verifyHeaders.put("signature", sig.label() + "=:" + ContentDigest.base64UrlNoPadding(sig.signatureBytes()) + ":"); + verifyHeaders.put("content-digest", ContentDigest.sha256(body)); + + SignedInput verifyInput = new SignedInput(body, verifyHeaders, "POST", "https://buyer.example.com/webhook"); + + VerificationKey vKey = new VerificationKey("test-ed25519-roundtrip", "Ed25519", publicKey.getEncoded(), "Ed25519"); + + VerificationResult result = InProcessVerificationProvider.verify(verifyInput, vKey, AdcpUse.WEBHOOK_SIGNING, created + 150); + + assertInstanceOf(VerificationResult.Valid.class, result, "Expected Valid result, got: " + result); + assertEquals("test-ed25519-roundtrip", ((VerificationResult.Valid) result).kid()); + } + + @Test + void verificationRoundTrip_es256() throws SigningException { + PrivateKey privateKey = es256KeyPair.getPrivate(); + PublicKey publicKey = es256KeyPair.getPublic(); + + InProcessSigningProvider signer = new InProcessSigningProvider( + privateKey, "test-es256-roundtrip", AdcpSignatureProfile.ALG_ECDSA_P256_SHA256, "P-256"); + + SigningContext context = SigningContext.builder(AdcpUse.WEBHOOK_SIGNING).build(); + Map headers = new LinkedHashMap<>(); + headers.put("content-type", "application/json"); + + byte[] body = "{\"round\":\"trip\",\"alg\":\"es256\"}".getBytes(StandardCharsets.UTF_8); + SigningInput signingInput = new TestSigningInput("POST", "https://buyer.example.com/webhook", body, headers); + + long created = System.currentTimeMillis() / 1000; + long expires = created + 300; + String nonce = "es256-roundtrip-nonce"; + + Signature sig = signer.sign(context, signingInput, created, expires, nonce); + + Map verifyHeaders = new LinkedHashMap<>(headers); + verifyHeaders.put("signature-input", sig.signatureInput()); + verifyHeaders.put("signature", sig.label() + "=:" + ContentDigest.base64UrlNoPadding(sig.signatureBytes()) + ":"); + verifyHeaders.put("content-digest", ContentDigest.sha256(body)); + + SignedInput verifyInput = new SignedInput(body, verifyHeaders, "POST", "https://buyer.example.com/webhook"); + + VerificationKey vKey = new VerificationKey("test-es256-roundtrip", "EC", publicKey.getEncoded(), "P-256"); + + VerificationResult result = InProcessVerificationProvider.verify(verifyInput, vKey, AdcpUse.WEBHOOK_SIGNING, created + 150); + + assertInstanceOf(VerificationResult.Valid.class, result, "Expected Valid result, got: " + result); + assertEquals("test-es256-roundtrip", ((VerificationResult.Valid) result).kid()); + } + + @Test + void verificationFails_withWrongKey() throws SigningException { + PrivateKey privateKey = ed25519KeyPair.getPrivate(); + KeyPair otherKeyPair = InProcessKeyGenerator.generateEd25519(); + + InProcessSigningProvider signer = new InProcessSigningProvider( + privateKey, "test-ed25519-wrong", AdcpSignatureProfile.ALG_ED25519, "Ed25519"); + + SigningContext context = SigningContext.builder(AdcpUse.WEBHOOK_SIGNING).build(); + Map headers = new LinkedHashMap<>(); + headers.put("content-type", "application/json"); + + byte[] body = "{\"wrong\":\"key\"}".getBytes(StandardCharsets.UTF_8); + SigningInput signingInput = new TestSigningInput("POST", "https://buyer.example.com/webhook", body, headers); + + long created = System.currentTimeMillis() / 1000; + long expires = created + 300; + String nonce = "wrong-key-nonce"; + + Signature sig = signer.sign(context, signingInput, created, expires, nonce); + + Map verifyHeaders = new LinkedHashMap<>(headers); + verifyHeaders.put("signature-input", sig.signatureInput()); + verifyHeaders.put("signature", sig.label() + "=:" + ContentDigest.base64UrlNoPadding(sig.signatureBytes()) + ":"); + verifyHeaders.put("content-digest", ContentDigest.sha256(body)); + + SignedInput verifyInput = new SignedInput(body, verifyHeaders, "POST", "https://buyer.example.com/webhook"); + + VerificationKey vKey = new VerificationKey("test-ed25519-wrong", "Ed25519", otherKeyPair.getPublic().getEncoded(), "Ed25519"); + + VerificationResult result = InProcessVerificationProvider.verify(verifyInput, vKey, AdcpUse.WEBHOOK_SIGNING, created + 150); + + assertInstanceOf(VerificationResult.Invalid.class, result); + assertTrue(((VerificationResult.Invalid) result).errorCode().contains("invalid")); + } + + private record TestSigningInput(String method, String targetUri, byte[] body, Map headers) + implements SigningInput {} +} \ No newline at end of file diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/WebhookSignerTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/WebhookSignerTest.java new file mode 100644 index 0000000..eba2b39 --- /dev/null +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/WebhookSignerTest.java @@ -0,0 +1,133 @@ +package org.adcontextprotocol.adcp.server.signing; + +import org.adcontextprotocol.adcp.signing.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class WebhookSignerTest { + + private KeyPair ed25519KeyPair; + private KeyPair es256KeyPair; + + @BeforeEach + void setUp() { + ed25519KeyPair = InProcessKeyGenerator.generateEd25519(); + es256KeyPair = InProcessKeyGenerator.generateES256(); + } + + @Test + void defaultWebhookSigner_producesSignatureHeaders() { + PrivateKey privateKey = ed25519KeyPair.getPrivate(); + InProcessSigningProvider signingProvider = new InProcessSigningProvider( + privateKey, "test-whk-key", AdcpSignatureProfile.ALG_ED25519, "Ed25519"); + DefaultWebhookSigner signer = new DefaultWebhookSigner(signingProvider); + + SigningContext context = SigningContext.builder(AdcpUse.WEBHOOK_SIGNING).build(); + byte[] body = "{\"event\":\"test\"}".getBytes(StandardCharsets.UTF_8); + Map headers = new LinkedHashMap<>(); + headers.put("content-type", "application/json"); + + WebhookSigningResult result = signer.sign(context, "POST", "https://buyer.example.com/webhook", body, headers); + + assertNotNull(result.signatureInput()); + assertNotNull(result.signature()); + assertNotNull(result.contentDigest()); + assertTrue(result.signatureInput().contains("tag=\"adcp/webhook-signing/v1\"")); + assertTrue(result.signatureInput().contains("keyid=\"test-whk-key\"")); + assertTrue(result.signatureInput().contains("alg=\"ed25519\"")); + } + + @Test + void defaultWebhookSigner_includesContentDigest() { + PrivateKey privateKey = ed25519KeyPair.getPrivate(); + InProcessSigningProvider signingProvider = new InProcessSigningProvider( + privateKey, "test-whk-key", AdcpSignatureProfile.ALG_ED25519, "Ed25519"); + DefaultWebhookSigner signer = new DefaultWebhookSigner(signingProvider); + + SigningContext context = SigningContext.builder(AdcpUse.WEBHOOK_SIGNING).build(); + byte[] body = "{\"event\":\"test\"}".getBytes(StandardCharsets.UTF_8); + Map headers = new LinkedHashMap<>(); + headers.put("content-type", "application/json"); + + WebhookSigningResult result = signer.sign(context, "POST", "https://buyer.example.com/webhook", body, headers); + + assertNotNull(result.contentDigest()); + assertTrue(result.contentDigest().startsWith("sha-256=:")); + } + + @Test + void defaultWebhookSigner_rejectsRequestSigningContext() { + PrivateKey privateKey = ed25519KeyPair.getPrivate(); + InProcessSigningProvider signingProvider = new InProcessSigningProvider( + privateKey, "test-req-key", AdcpSignatureProfile.ALG_ED25519, "Ed25519"); + DefaultWebhookSigner signer = new DefaultWebhookSigner(signingProvider); + + SigningContext requestContext = SigningContext.builder(AdcpUse.REQUEST_SIGNING).build(); + byte[] body = "{}".getBytes(StandardCharsets.UTF_8); + + assertThrows(IllegalArgumentException.class, () -> + signer.sign(requestContext, "POST", "https://example.com/api", body, Map.of())); + } + + @Test + void defaultWebhookSigner_es256() { + PrivateKey privateKey = es256KeyPair.getPrivate(); + InProcessSigningProvider signingProvider = new InProcessSigningProvider( + privateKey, "test-es256-whk-key", AdcpSignatureProfile.ALG_ECDSA_P256_SHA256, "P-256"); + DefaultWebhookSigner signer = new DefaultWebhookSigner(signingProvider); + + SigningContext context = SigningContext.builder(AdcpUse.WEBHOOK_SIGNING).build(); + byte[] body = "{\"event\":\"es256_test\"}".getBytes(StandardCharsets.UTF_8); + Map headers = new LinkedHashMap<>(); + headers.put("content-type", "application/json"); + + WebhookSigningResult result = signer.sign(context, "POST", "https://buyer.example.com/webhook", body, headers); + + assertNotNull(result.signatureInput()); + assertTrue(result.signatureInput().contains("alg=\"ecdsa-p256-sha256\"")); + } + + @Test + void webhookSigner_roundTripVerification() throws SigningException { + PrivateKey privateKey = ed25519KeyPair.getPrivate(); + PublicKey publicKey = ed25519KeyPair.getPublic(); + + InProcessSigningProvider signingProvider = new InProcessSigningProvider( + privateKey, "test-roundtrip-key", AdcpSignatureProfile.ALG_ED25519, "Ed25519"); + DefaultWebhookSigner signer = new DefaultWebhookSigner(signingProvider); + + SigningContext context = SigningContext.builder(AdcpUse.WEBHOOK_SIGNING).build(); + byte[] body = "{\"task_id\":\"round-trip\"}".getBytes(StandardCharsets.UTF_8); + Map headers = new LinkedHashMap<>(); + headers.put("content-type", "application/json"); + + WebhookSigningResult result = signer.sign(context, "POST", "https://buyer.example.com/webhook", body, headers); + + Map verifyHeaders = new LinkedHashMap<>(); + verifyHeaders.put("content-type", "application/json"); + verifyHeaders.put("signature-input", result.signatureInput()); + verifyHeaders.put("signature", result.signature()); + if (result.contentDigest() != null) { + verifyHeaders.put("content-digest", result.contentDigest()); + } + + SignedInput verifyInput = new SignedInput(body, verifyHeaders, "POST", "https://buyer.example.com/webhook"); + + VerificationKey vKey = new VerificationKey("test-roundtrip-key", "Ed25519", publicKey.getEncoded(), "Ed25519"); + + long referenceNow = System.currentTimeMillis() / 1000 + 150; + VerificationResult vr = InProcessVerificationProvider.verify(verifyInput, vKey, AdcpUse.WEBHOOK_SIGNING, referenceNow); + + assertInstanceOf(VerificationResult.Valid.class, vr); + } +} \ No newline at end of file From 8bfda3b51d3aa3a0a635282ad3c51357c32300b6 Mon Sep 17 00:00:00 2001 From: Lobsterdog Contributors Date: Tue, 16 Jun 2026 22:52:18 -0600 Subject: [PATCH 04/21] feat(signing): add KMS provider module skeletons and BouncyCastle stub --- adcp-signing-aws-kms/build.gradle.kts | 15 ++++++ .../signing/aws/AwsKmsSigningProvider.java | 50 +++++++++++++++++++ .../adcp/signing/aws/package-info.java | 11 ++++ ...ntextprotocol.adcp.signing.SigningProvider | 1 + adcp-signing-bouncycastle/build.gradle.kts | 12 +++++ .../bc/BouncyCastleSigningProvider.java | 48 ++++++++++++++++++ .../adcp/signing/bc/package-info.java | 12 +++++ ...ntextprotocol.adcp.signing.SigningProvider | 1 + adcp-signing-gcp-kms/build.gradle.kts | 12 +++++ .../signing/gcp/GcpKmsSigningProvider.java | 50 +++++++++++++++++++ .../adcp/signing/gcp/package-info.java | 11 ++++ ...ntextprotocol.adcp.signing.SigningProvider | 1 + gradle/libs.versions.toml | 9 ++++ settings.gradle.kts | 5 +- 14 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 adcp-signing-aws-kms/build.gradle.kts create mode 100644 adcp-signing-aws-kms/src/main/java/org/adcontextprotocol/adcp/signing/aws/AwsKmsSigningProvider.java create mode 100644 adcp-signing-aws-kms/src/main/java/org/adcontextprotocol/adcp/signing/aws/package-info.java create mode 100644 adcp-signing-aws-kms/src/main/resources/META-INF/services/org.adcontextprotocol.adcp.signing.SigningProvider create mode 100644 adcp-signing-bouncycastle/build.gradle.kts create mode 100644 adcp-signing-bouncycastle/src/main/java/org/adcontextprotocol/adcp/signing/bc/BouncyCastleSigningProvider.java create mode 100644 adcp-signing-bouncycastle/src/main/java/org/adcontextprotocol/adcp/signing/bc/package-info.java create mode 100644 adcp-signing-bouncycastle/src/main/resources/META-INF/services/org.adcontextprotocol.adcp.signing.SigningProvider create mode 100644 adcp-signing-gcp-kms/build.gradle.kts create mode 100644 adcp-signing-gcp-kms/src/main/java/org/adcontextprotocol/adcp/signing/gcp/GcpKmsSigningProvider.java create mode 100644 adcp-signing-gcp-kms/src/main/java/org/adcontextprotocol/adcp/signing/gcp/package-info.java create mode 100644 adcp-signing-gcp-kms/src/main/resources/META-INF/services/org.adcontextprotocol.adcp.signing.SigningProvider diff --git a/adcp-signing-aws-kms/build.gradle.kts b/adcp-signing-aws-kms/build.gradle.kts new file mode 100644 index 0000000..11276ce --- /dev/null +++ b/adcp-signing-aws-kms/build.gradle.kts @@ -0,0 +1,15 @@ +// adcp-signing-aws-kms — AWS KMS signing provider for AdCP. +// Lazy-init; only touches KMS on first sign/verify call. +// Pre-deploy probe is a separate CLI command (adcp-cli), not boot-critical. +plugins { + id("adcp.java-library-conventions") +} + +description = "AdCP Java SDK — AWS KMS signing provider" + +dependencies { + api(project(":adcp")) + // AWS KMS SDK — lazy-init, not on the boot critical path. + // Uncomment when implementing the provider: + // implementation(libs.aws.kms) +} \ No newline at end of file diff --git a/adcp-signing-aws-kms/src/main/java/org/adcontextprotocol/adcp/signing/aws/AwsKmsSigningProvider.java b/adcp-signing-aws-kms/src/main/java/org/adcontextprotocol/adcp/signing/aws/AwsKmsSigningProvider.java new file mode 100644 index 0000000..f9857fd --- /dev/null +++ b/adcp-signing-aws-kms/src/main/java/org/adcontextprotocol/adcp/signing/aws/AwsKmsSigningProvider.java @@ -0,0 +1,50 @@ +package org.adcontextprotocol.adcp.signing.aws; + +import org.adcontextprotocol.adcp.signing.Signature; +import org.adcontextprotocol.adcp.signing.SigningContext; +import org.adcontextprotocol.adcp.signing.SigningException; +import org.adcontextprotocol.adcp.signing.SigningInput; +import org.adcontextprotocol.adcp.signing.SigningProvider; + +import java.util.Map; +import java.util.Objects; + +/** + * AWS KMS signing provider for AdCP — stub. + * + *

This is a module skeleton for the {@code adcp-signing-aws-kms} artifact. + * The real implementation will use {@code software.amazon.awssdk:kms} to sign + * and verify AdCP webhook signatures against keys stored in AWS KMS. + * + *

Constructor accepts AWS KMS client configuration (region, key ARN mapping). + * The KMS client is lazy-initialised — it is not touched until the first + * {@link #sign} call, so the module can be on the classpath without an active + * AWS connection. + * + *

Pre-deploy key-probe checks are handled by {@code adcp-cli}, not by this + * provider at boot time. + * + * @see SigningProvider + */ +public final class AwsKmsSigningProvider implements SigningProvider { + + private final String region; + private final Map keyArnMapping; + + /** + * Create an AWS KMS signing provider stub. + * + * @param region AWS region (e.g. "us-east-1") + * @param keyArnMapping mapping from AdCP key identifiers to KMS key ARNs + */ + public AwsKmsSigningProvider(String region, Map keyArnMapping) { + this.region = Objects.requireNonNull(region, "region"); + this.keyArnMapping = Map.copyOf(Objects.requireNonNull(keyArnMapping, "keyArnMapping")); + } + + @Override + public Signature sign(SigningContext context, SigningInput input) throws SigningException { + throw new UnsupportedOperationException( + "AWS KMS signing not yet implemented — track 4 v0.2"); + } +} \ No newline at end of file diff --git a/adcp-signing-aws-kms/src/main/java/org/adcontextprotocol/adcp/signing/aws/package-info.java b/adcp-signing-aws-kms/src/main/java/org/adcontextprotocol/adcp/signing/aws/package-info.java new file mode 100644 index 0000000..016a7ff --- /dev/null +++ b/adcp-signing-aws-kms/src/main/java/org/adcontextprotocol/adcp/signing/aws/package-info.java @@ -0,0 +1,11 @@ +/** + * AWS KMS signing provider for AdCP. + * + *

This module provides a {@link org.adcontextprotocol.adcp.signing.SigningProvider} + * backed by AWS Key Management Service. The KMS client is lazy-initialised and + * only touches the AWS API on the first sign/verify call. + * + * @see org.adcontextprotocol.adcp.signing.aws.AwsKmsSigningProvider + */ +@org.jspecify.annotations.NullMarked +package org.adcontextprotocol.adcp.signing.aws; \ No newline at end of file diff --git a/adcp-signing-aws-kms/src/main/resources/META-INF/services/org.adcontextprotocol.adcp.signing.SigningProvider b/adcp-signing-aws-kms/src/main/resources/META-INF/services/org.adcontextprotocol.adcp.signing.SigningProvider new file mode 100644 index 0000000..7842f36 --- /dev/null +++ b/adcp-signing-aws-kms/src/main/resources/META-INF/services/org.adcontextprotocol.adcp.signing.SigningProvider @@ -0,0 +1 @@ +org.adcontextprotocol.adcp.signing.aws.AwsKmsSigningProvider \ No newline at end of file diff --git a/adcp-signing-bouncycastle/build.gradle.kts b/adcp-signing-bouncycastle/build.gradle.kts new file mode 100644 index 0000000..3e3df49 --- /dev/null +++ b/adcp-signing-bouncycastle/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + id("adcp.java-library-conventions") +} + +description = "AdCP Java SDK — Bouncy Castle FIPS signing provider (optional)" + +dependencies { + api(project(":adcp")) + // Bouncy Castle FIPS — optional, for FIPS environments only. + // Uncomment when implementing the provider: + // implementation(libs.bouncycastle.fips) +} \ No newline at end of file diff --git a/adcp-signing-bouncycastle/src/main/java/org/adcontextprotocol/adcp/signing/bc/BouncyCastleSigningProvider.java b/adcp-signing-bouncycastle/src/main/java/org/adcontextprotocol/adcp/signing/bc/BouncyCastleSigningProvider.java new file mode 100644 index 0000000..6093f17 --- /dev/null +++ b/adcp-signing-bouncycastle/src/main/java/org/adcontextprotocol/adcp/signing/bc/BouncyCastleSigningProvider.java @@ -0,0 +1,48 @@ +package org.adcontextprotocol.adcp.signing.bc; + +import org.adcontextprotocol.adcp.signing.Signature; +import org.adcontextprotocol.adcp.signing.SigningContext; +import org.adcontextprotocol.adcp.signing.SigningException; +import org.adcontextprotocol.adcp.signing.SigningInput; +import org.adcontextprotocol.adcp.signing.SigningProvider; + +import java.util.Objects; + +/** + * Bouncy Castle FIPS signing provider for AdCP — stub. + * + *

This is a module skeleton for the {@code adcp-signing-bouncycastle} artifact. + * The real implementation will use {@code org.bouncycastle:bc-fips} to provide + * signing and verification in FIPS 140-validated environments. + * + *

This provider is optional. The core AdCP signing path uses + * JDK 21's built-in Ed25519 and ECDSA support ({@code InProcessSigningProvider} + * in {@code adcp-server}). This module exists for deployments that require + * FIPS 140 compliance where the JDK's native crypto is not sufficient. + * + *

Constructor accepts FIPS provider configuration. The Bouncy Castle + * provider is lazy-initialised — it is not registered until the first + * {@link #sign} call. + * + * @see SigningProvider + */ +public final class BouncyCastleSigningProvider implements SigningProvider { + + private final String fipsProviderName; + + /** + * Create a Bouncy Castle FIPS signing provider stub. + * + * @param fipsProviderName the JCA provider name for Bouncy Castle FIPS + * (e.g. "BCFIPS") + */ + public BouncyCastleSigningProvider(String fipsProviderName) { + this.fipsProviderName = Objects.requireNonNull(fipsProviderName, "fipsProviderName"); + } + + @Override + public Signature sign(SigningContext context, SigningInput input) throws SigningException { + throw new UnsupportedOperationException( + "Bouncy Castle FIPS signing not yet implemented — track 4 v0.2"); + } +} \ No newline at end of file diff --git a/adcp-signing-bouncycastle/src/main/java/org/adcontextprotocol/adcp/signing/bc/package-info.java b/adcp-signing-bouncycastle/src/main/java/org/adcontextprotocol/adcp/signing/bc/package-info.java new file mode 100644 index 0000000..9e937cf --- /dev/null +++ b/adcp-signing-bouncycastle/src/main/java/org/adcontextprotocol/adcp/signing/bc/package-info.java @@ -0,0 +1,12 @@ +/** + * Bouncy Castle FIPS signing provider for AdCP (optional). + * + *

This module provides a {@link org.adcontextprotocol.adcp.signing.SigningProvider} + * backed by Bouncy Castle FIPS for deployments that require FIPS 140-validated + * cryptography. The core signing path uses JDK 21's built-in Ed25519/ECDSA; + * this module is only needed in FIPS-mandated environments. + * + * @see org.adcontextprotocol.adcp.signing.bc.BouncyCastleSigningProvider + */ +@org.jspecify.annotations.NullMarked +package org.adcontextprotocol.adcp.signing.bc; \ No newline at end of file diff --git a/adcp-signing-bouncycastle/src/main/resources/META-INF/services/org.adcontextprotocol.adcp.signing.SigningProvider b/adcp-signing-bouncycastle/src/main/resources/META-INF/services/org.adcontextprotocol.adcp.signing.SigningProvider new file mode 100644 index 0000000..0b79221 --- /dev/null +++ b/adcp-signing-bouncycastle/src/main/resources/META-INF/services/org.adcontextprotocol.adcp.signing.SigningProvider @@ -0,0 +1 @@ +org.adcontextprotocol.adcp.signing.bc.BouncyCastleSigningProvider \ No newline at end of file diff --git a/adcp-signing-gcp-kms/build.gradle.kts b/adcp-signing-gcp-kms/build.gradle.kts new file mode 100644 index 0000000..78382fd --- /dev/null +++ b/adcp-signing-gcp-kms/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + id("adcp.java-library-conventions") +} + +description = "AdCP Java SDK — GCP KMS signing provider" + +dependencies { + api(project(":adcp")) + // GCP KMS SDK — lazy-init, not on the boot critical path. + // Uncomment when implementing the provider: + // implementation(libs.gcp.kms) +} \ No newline at end of file diff --git a/adcp-signing-gcp-kms/src/main/java/org/adcontextprotocol/adcp/signing/gcp/GcpKmsSigningProvider.java b/adcp-signing-gcp-kms/src/main/java/org/adcontextprotocol/adcp/signing/gcp/GcpKmsSigningProvider.java new file mode 100644 index 0000000..647cc05 --- /dev/null +++ b/adcp-signing-gcp-kms/src/main/java/org/adcontextprotocol/adcp/signing/gcp/GcpKmsSigningProvider.java @@ -0,0 +1,50 @@ +package org.adcontextprotocol.adcp.signing.gcp; + +import org.adcontextprotocol.adcp.signing.Signature; +import org.adcontextprotocol.adcp.signing.SigningContext; +import org.adcontextprotocol.adcp.signing.SigningException; +import org.adcontextprotocol.adcp.signing.SigningInput; +import org.adcontextprotocol.adcp.signing.SigningProvider; + +import java.util.Map; +import java.util.Objects; + +/** + * Google Cloud KMS signing provider for AdCP — stub. + * + *

This is a module skeleton for the {@code adcp-signing-gcp-kms} artifact. + * The real implementation will use {@code com.google.cloud:google-cloud-kms} + * to sign and verify AdCP webhook signatures against keys stored in GCP KMS. + * + *

Constructor accepts GCP KMS client configuration (project ID, location, + * key ring mapping). The KMS client is lazy-initialised — it is not touched + * until the first {@link #sign} call, so the module can be on the classpath + * without an active GCP connection. + * + * @see SigningProvider + */ +public final class GcpKmsSigningProvider implements SigningProvider { + + private final String projectId; + private final String location; + private final Map keyRingMapping; + + /** + * Create a GCP KMS signing provider stub. + * + * @param projectId GCP project ID hosting the key rings + * @param location GCP location (e.g. "global", "us-east1") + * @param keyRingMapping mapping from AdCP key identifiers to KMS key ring paths + */ + public GcpKmsSigningProvider(String projectId, String location, Map keyRingMapping) { + this.projectId = Objects.requireNonNull(projectId, "projectId"); + this.location = Objects.requireNonNull(location, "location"); + this.keyRingMapping = Map.copyOf(Objects.requireNonNull(keyRingMapping, "keyRingMapping")); + } + + @Override + public Signature sign(SigningContext context, SigningInput input) throws SigningException { + throw new UnsupportedOperationException( + "GCP KMS signing not yet implemented — track 4 v0.2"); + } +} \ No newline at end of file diff --git a/adcp-signing-gcp-kms/src/main/java/org/adcontextprotocol/adcp/signing/gcp/package-info.java b/adcp-signing-gcp-kms/src/main/java/org/adcontextprotocol/adcp/signing/gcp/package-info.java new file mode 100644 index 0000000..60b8d77 --- /dev/null +++ b/adcp-signing-gcp-kms/src/main/java/org/adcontextprotocol/adcp/signing/gcp/package-info.java @@ -0,0 +1,11 @@ +/** + * Google Cloud KMS signing provider for AdCP. + * + *

This module provides a {@link org.adcontextprotocol.adcp.signing.SigningProvider} + * backed by Google Cloud Key Management Service. The KMS client is lazy-initialised + * and only touches the GCP API on the first sign/verify call. + * + * @see org.adcontextprotocol.adcp.signing.gcp.GcpKmsSigningProvider + */ +@org.jspecify.annotations.NullMarked +package org.adcontextprotocol.adcp.signing.gcp; \ No newline at end of file diff --git a/adcp-signing-gcp-kms/src/main/resources/META-INF/services/org.adcontextprotocol.adcp.signing.SigningProvider b/adcp-signing-gcp-kms/src/main/resources/META-INF/services/org.adcontextprotocol.adcp.signing.SigningProvider new file mode 100644 index 0000000..762a07d --- /dev/null +++ b/adcp-signing-gcp-kms/src/main/resources/META-INF/services/org.adcontextprotocol.adcp.signing.SigningProvider @@ -0,0 +1 @@ +org.adcontextprotocol.adcp.signing.gcp.GcpKmsSigningProvider \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 96d3b94..3ee4596 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -83,6 +83,15 @@ slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } # Codegen (build-time only — never on the SDK's runtime classpath) javapoet = { module = "com.palantir.javapoet:javapoet", version.ref = "javapoet" } +# AWS KMS (D9 signing track — lazy-init, not on boot critical path) +# aws-kms = { module = "software.amazon.awssdk:kms", version = "2.31.3" } + +# GCP KMS (D9 signing track — lazy-init, not on boot critical path) +# gcp-kms = { module = "com.google.cloud:google-cloud-kms", version = "2.57.0" } + +# Bouncy Castle FIPS (optional — FIPS environments only, not in core) +# bouncycastle-fips = { module = "org.bouncycastle:bc-fips", version = "2.0.0" } + [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } # Central Portal publishing (replaces legacy OSSRH s01 endpoint per bokelley review). diff --git a/settings.gradle.kts b/settings.gradle.kts index 9d857d7..71df66f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,5 +23,8 @@ include( "adcp-cli", "adcp-reactor", "adcp-mutiny", - "adcp-kotlin" + "adcp-kotlin", + "adcp-signing-aws-kms", + "adcp-signing-gcp-kms", + "adcp-signing-bouncycastle" ) From 19ed16776565a35b446cb8146fcacfb842ecc582 Mon Sep 17 00:00:00 2001 From: Lobsterdog Contributors Date: Tue, 16 Jun 2026 22:53:23 -0600 Subject: [PATCH 05/21] docs(signing): add RFC 9421 canonicalizer design spec --- specs/rfc9421-canonicalizer.md | 120 +++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 specs/rfc9421-canonicalizer.md diff --git a/specs/rfc9421-canonicalizer.md b/specs/rfc9421-canonicalizer.md new file mode 100644 index 0000000..d1a6677 --- /dev/null +++ b/specs/rfc9421-canonicalizer.md @@ -0,0 +1,120 @@ +# RFC 9421 Canonicalizer Design + +**Status:** Implementation complete (Track 4 v0.2) +**Tracks:** [`signing`](../ROADMAP.md#track-4--l1-signing) +**Decisions referenced:** D2 (JDK 21), D22 (SigningContext) + +## Why this exists + +The AdCP protocol mandates RFC 9421 message signatures for both request signing (buyer → seller) and webhook signing (seller → buyer). The canonicalizer is the single point of truth for constructing and verifying signature bases. A bug in the canonicalizer means every signed message is either rejected or forgeable. + +## Design decisions + +### Hand-rolled, not org.tomitribe + +`org.tomitribe:http-signatures` implements the Cavage IETF draft, not RFC 9421. Key differences: +- Cavage uses `Signature` headers with `keyId` and `algorithm` as direct parameters; RFC 9421 uses `Signature-Input` + `Signature` header pairs with structured parameters including `created`, `expires`, `nonce`, `tag`. +- Cavage doesn't define `content-digest` as a covered component; AdCP requires it for webhooks. +- Cavage doesn't have the `tag` parameter; AdCP uses `tag="adcp/webhook-signing/v1"` and `tag="adcp/request-signing/v1"` to disambiguate profiles. + +### Module structure + +| Module | Package | Contents | +|--------|---------|----------| +| `adcp` | `org.adcontextprotocol.adcp.signing` | SPI interfaces: `SigningProvider`, `VerificationKeyResolver`, `SigningContext`, `AdcpUse`, `TenantId`, `PrincipalRef`, `Signature`, `VerificationResult`, etc. | +| `adcp-server` | `org.adcontextprotocol.adcp.server.signing` | Canonicalizer, `InProcessSigningProvider`, `InProcessVerificationProvider`, `WebhookSigner`, profile validation | +| `adcp-signing-aws-kms` | `org.adcontextprotocol.adcp.signing.aws` | AWS KMS provider (stub for v0.2) | +| `adcp-signing-gcp-kms` | `org.adcontextprotocol.adcp.signing.gcp` | GCP KMS provider (stub for v0.2) | +| `adcp-signing-bouncycastle` | `org.adcontextprotocol.adcp.signing.bc` | BC FIPS provider (stub for v0.2) | + +### Algorithm support + +| Algorithm | JCA name | Key type | Status | +|-----------|----------|----------|--------| +| Ed25519 | `EdDSA` | `OKP` with `crv=Ed25519` | Supported (JDK 21 native) | +| ES256 (P-256) | `SHA256withECDSA` | `EC` with `crv=P-256` | Supported (JDK 21 native) | +| ES384 (P-384) | `SHA384withECDSA` | `EC` with `crv=P-384` | Supported (JDK 21 native) | +| RS256 | `SHA256withRSA` | `RSA` | Not in v0.2 scope | + +### AdCP profile constraints + +The `AdcpSignatureProfile` class enforces: + +**Webhook signing (`tag="adcp/webhook-signing/v1"`):** +- Required covered components: `@request-target`, `@authority`, `content-digest`, `x-adcp-timestamp` +- Required signature parameters: `created`, `expires`, `nonce`, `keyid`, `alg` +- Replay window: 300 seconds (5 minutes) +- `content-digest` is REQUIRED (no opt-out) +- `adcp_use` on the verifying JWK MUST be `"webhook-signing"` + +**Request signing (`tag="adcp/request-signing/v1"`):** +- Required covered components: `@request-target`, `@authority`, `content-digest`, `x-adcp-timestamp` +- `covers_content_digest` can be `"forbidden"` for GET requests without a body +- Required signature parameters: same as webhook + +### Canonicalizer implementation details + +The `Rfc9421Canonicalizer` follows RFC 9421 §2.5 exactly: + +1. **Component resolution**: For each covered component name, resolve the component value from the request. +2. **Header normalization**: Lowercase field name, trim leading/trailing OWS, collapse inner OWS to single SP. +3. **`(request-target)`**: Constructed as `method SP request-target` (e.g., `POST /webhook`). +4. **`(authority)`**: Derived from the Host header, with default port stripping (443 for HTTPS, 80 for HTTP). +5. **`content-digest`**: Computed per RFC 9530 using SHA-256 (or SHA-512), encoded as `sha-256=:base64url-no-padding:`. +6. **Signature parameters line**: `@signature-params: (...)` with all parameters in required order. +7. **Base64url encoding**: No padding for `Signature` and `Content-Digest` values. + +### URL canonicalization + +The canonicalizer handles: +- IDN domains (punycode) +- IPv6 literals with brackets +- Default port stripping (443 for HTTPS, 80 for HTTP) +- Percent-encoding normalization (uppercase `%XX`) +- Path normalization (removing `.` and `..` segments) +- Query string preservation (byte-for-byte, no reordering) + +### Test vector conformance + +The implementation is tested against the AdCP compliance test vectors: +- `webhook-signing/positive/` (7 vectors) +- `webhook-signing/negative/` (21 vectors) +- `request-signing/canonicalization.json` (26 URL canonicalization cases) +- `request-signing/positive/` (multiple vectors) + +Each vector's `expected_signature_base` is verified byte-for-byte against the canonicalizer output. + +### Verification checklist implementation + +The `Rfc9421Verifier` implements the full AdCP webhook verifier checklist (13 steps): + +1. Parse `Signature-Input` and `Signature` headers (they MUST be a bound pair) +2. Validate required signature parameters (`created`, `expires`, `nonce`, `keyid`, `alg`, `tag`) +3. Validate `tag` matches expected profile (`adcp/webhook-signing/v1` or `adcp/request-signing/v1`) +4. Validate `alg` is allowed (`EdDSA`, `ES256`, `ES384`) +5. Validate timestamp window (`|now - created| <= 300s`, `expires > created`) +6. Validate required covered components are present (`@request-target`, `@authority`, `content-digest` for webhooks) +7. Look up key by `kid` +8. Validate `adcp_use` matches expected purpose +9. Validate key not revoked and key_ops includes `verify` +10. Verify signature bytes against canonical base +11. Verify `content-digest` matches body +12. Check nonce for replay (requires stateful runner) +13. Check rate limits (requires stateful runner) + +Steps 12 and 13 require runner state and are tested separately in integration tests. + +## What's deferred to v0.3 + +- `AccountStore` → `SigningContext` tenant resolution wiring +- `PrincipalRef` resolution from `adagents.json` +- JWKS fetching and caching (uses the SSRF-safe `AdcpHttpClient` from Track 3) +- Key rotation support in `VerificationKeyResolver` +- Legacy HMAC-SHA256 webhook signing (deprecated, removed in AdCP 4.0) + +## References + +- [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421) — HTTP Message Signatures +- [RFC 9530](https://www.rfc-editor.org/rfc/rfc9530) — Digest Fields +- [AdCP Security](https://adcontextprotocol.org/docs/building/implementation/security) — AdCP security model +- [specs/signing-context.md](../specs/signing-context.md) — D22 SigningContext design \ No newline at end of file From 28697e58d8ec760fbec58255bf8a00af3e5f4cea Mon Sep 17 00:00:00 2001 From: Lobsterdog Contributors Date: Wed, 17 Jun 2026 11:12:16 -0600 Subject: [PATCH 06/21] feat(signing): implement AWS KMS SigningProvider with lazy-init, tripwire, and ECDSA DER conversion --- adcp-signing-aws-kms/build.gradle.kts | 13 +- .../signing/aws/AwsKmsSigningProvider.java | 391 ++++++++++++++++-- .../aws/AwsKmsSigningProviderTest.java | 304 ++++++++++++++ gradle/libs.versions.toml | 5 +- 4 files changed, 677 insertions(+), 36 deletions(-) create mode 100644 adcp-signing-aws-kms/src/test/java/org/adcontextprotocol/adcp/signing/aws/AwsKmsSigningProviderTest.java diff --git a/adcp-signing-aws-kms/build.gradle.kts b/adcp-signing-aws-kms/build.gradle.kts index 11276ce..c3bffcf 100644 --- a/adcp-signing-aws-kms/build.gradle.kts +++ b/adcp-signing-aws-kms/build.gradle.kts @@ -1,6 +1,3 @@ -// adcp-signing-aws-kms — AWS KMS signing provider for AdCP. -// Lazy-init; only touches KMS on first sign/verify call. -// Pre-deploy probe is a separate CLI command (adcp-cli), not boot-critical. plugins { id("adcp.java-library-conventions") } @@ -9,7 +6,11 @@ description = "AdCP Java SDK — AWS KMS signing provider" dependencies { api(project(":adcp")) - // AWS KMS SDK — lazy-init, not on the boot critical path. - // Uncomment when implementing the provider: - // implementation(libs.aws.kms) + implementation(project(":adcp-server")) + implementation(libs.aws.kms) + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.params) + testImplementation(libs.mockito.core) + testImplementation(libs.mockito.junit.jupiter) + testRuntimeOnly(libs.junit.jupiter.engine) } \ No newline at end of file diff --git a/adcp-signing-aws-kms/src/main/java/org/adcontextprotocol/adcp/signing/aws/AwsKmsSigningProvider.java b/adcp-signing-aws-kms/src/main/java/org/adcontextprotocol/adcp/signing/aws/AwsKmsSigningProvider.java index f9857fd..9751ca8 100644 --- a/adcp-signing-aws-kms/src/main/java/org/adcontextprotocol/adcp/signing/aws/AwsKmsSigningProvider.java +++ b/adcp-signing-aws-kms/src/main/java/org/adcontextprotocol/adcp/signing/aws/AwsKmsSigningProvider.java @@ -1,50 +1,383 @@ package org.adcontextprotocol.adcp.signing.aws; +import org.adcontextprotocol.adcp.server.signing.AdcpSignatureProfile; +import org.adcontextprotocol.adcp.server.signing.ContentDigest; +import org.adcontextprotocol.adcp.server.signing.Rfc9421Canonicalizer; +import org.adcontextprotocol.adcp.server.signing.SignatureInputBuilder; +import org.adcontextprotocol.adcp.signing.AdcpUse; import org.adcontextprotocol.adcp.signing.Signature; import org.adcontextprotocol.adcp.signing.SigningContext; import org.adcontextprotocol.adcp.signing.SigningException; import org.adcontextprotocol.adcp.signing.SigningInput; import org.adcontextprotocol.adcp.signing.SigningProvider; +import org.jspecify.annotations.Nullable; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.kms.KmsClient; +import software.amazon.awssdk.services.kms.model.GetPublicKeyRequest; +import software.amazon.awssdk.services.kms.model.GetPublicKeyResponse; +import software.amazon.awssdk.services.kms.model.KmsException; +import software.amazon.awssdk.services.kms.model.SignRequest; +import software.amazon.awssdk.services.kms.model.SignResponse; +import software.amazon.awssdk.services.kms.model.SigningAlgorithmSpec; +import java.nio.charset.StandardCharsets; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; -/** - * AWS KMS signing provider for AdCP — stub. - * - *

This is a module skeleton for the {@code adcp-signing-aws-kms} artifact. - * The real implementation will use {@code software.amazon.awssdk:kms} to sign - * and verify AdCP webhook signatures against keys stored in AWS KMS. - * - *

Constructor accepts AWS KMS client configuration (region, key ARN mapping). - * The KMS client is lazy-initialised — it is not touched until the first - * {@link #sign} call, so the module can be on the classpath without an active - * AWS connection. - * - *

Pre-deploy key-probe checks are handled by {@code adcp-cli}, not by this - * provider at boot time. - * - * @see SigningProvider - */ public final class AwsKmsSigningProvider implements SigningProvider { + private static final class KeyMetadata { + final Map algorithms; + + KeyMetadata(Map algorithms) { + this.algorithms = Map.copyOf(algorithms); + } + } + + private final Map keyArns; + private final Map committedFingerprints; private final String region; - private final Map keyArnMapping; + private final @Nullable KmsClient externalClient; - /** - * Create an AWS KMS signing provider stub. - * - * @param region AWS region (e.g. "us-east-1") - * @param keyArnMapping mapping from AdCP key identifiers to KMS key ARNs - */ - public AwsKmsSigningProvider(String region, Map keyArnMapping) { - this.region = Objects.requireNonNull(region, "region"); - this.keyArnMapping = Map.copyOf(Objects.requireNonNull(keyArnMapping, "keyArnMapping")); + private volatile @Nullable KmsClient kmsClient; + private final AtomicReference<@Nullable KeyMetadata> keyMetadata = new AtomicReference<>(); + private final AtomicReference<@Nullable Exception> initFailure = new AtomicReference<>(); + + private AwsKmsSigningProvider(Builder builder) { + if (builder.keyArns.isEmpty()) { + throw new IllegalStateException("At least one key ARN must be configured via keyArn()"); + } + this.keyArns = Map.copyOf(builder.keyArns); + this.committedFingerprints = Map.copyOf(builder.committedFingerprints); + this.region = Objects.requireNonNull(builder.region, "region"); + this.externalClient = builder.kmsClient; + } + + public static Builder builder() { + return new Builder(); } @Override public Signature sign(SigningContext context, SigningInput input) throws SigningException { - throw new UnsupportedOperationException( - "AWS KMS signing not yet implemented — track 4 v0.2"); + AdcpUse use = context.use(); + String keyArn = keyArns.get(use); + if (keyArn == null) { + throw new SigningException("No key ARN configured for AdcpUse: " + use); + } + + KeyMetadata metadata = ensureInitialized(); + + SigningAlgorithmSpec kmsAlg = metadata.algorithms.get(use); + if (kmsAlg == null) { + throw new SigningException("No signing algorithm resolved for AdcpUse: " + use); + } + + String alg = adcpAlgorithmFor(kmsAlg); + String tag = AdcpSignatureProfile.tagForUse(use); + List coveredComponents = AdcpSignatureProfile.requiredComponentsForUse(use); + + long created = System.currentTimeMillis() / 1000; + long expires = created + AdcpSignatureProfile.REPLAY_WINDOW_SECONDS; + String nonce = generateNonce(); + + String signatureInputValue = SignatureInputBuilder.create() + .label(Signature.DEFAULT_LABEL) + .coveredComponents(coveredComponents) + .created(created) + .expires(expires) + .nonce(nonce) + .keyid(keyArn) + .alg(alg) + .tag(tag) + .build(); + + Map signingHeaders = new LinkedHashMap<>(input.headers()); + if (input.body() != null && input.body().length > 0) { + String contentDigestValue = ContentDigest.sha256(input.body()); + signingHeaders.put("content-digest", contentDigestValue); + } + + String signatureBase = Rfc9421Canonicalizer.canonicalize( + input.method(), + input.targetUri(), + signingHeaders, + coveredComponents, + signatureInputValue); + + byte[] messageBytes = signatureBase.getBytes(StandardCharsets.UTF_8); + + byte[] kmsSignature = callKmsSign(keyArn, kmsAlg, messageBytes); + + byte[] signatureBytes; + if (isEd25519(kmsAlg)) { + signatureBytes = kmsSignature; + } else { + int fieldSize = (kmsAlg == SigningAlgorithmSpec.ECDSA_SHA_384) ? 48 : 32; + signatureBytes = ecdsaDerToRaw(kmsSignature, fieldSize); + } + + return new Signature( + Signature.DEFAULT_LABEL, + signatureInputValue, + signatureBytes, + alg, + keyArn); + } + + private KeyMetadata ensureInitialized() throws SigningException { + KeyMetadata metadata = keyMetadata.get(); + if (metadata != null) return metadata; + + Exception failure = initFailure.get(); + if (failure != null) { + throw new SigningException("AWS KMS provider initialization failed: " + failure.getMessage(), failure); + } + + synchronized (this) { + metadata = keyMetadata.get(); + if (metadata != null) return metadata; + + failure = initFailure.get(); + if (failure != null) { + throw new SigningException("AWS KMS provider initialization failed: " + failure.getMessage(), failure); + } + + try { + KmsClient client = getClient(); + Map algorithms = new EnumMap<>(AdcpUse.class); + + for (Map.Entry entry : keyArns.entrySet()) { + AdcpUse use = entry.getKey(); + String keyArn = entry.getValue(); + + GetPublicKeyResponse pubKeyResp = client.getPublicKey(GetPublicKeyRequest.builder() + .keyId(keyArn) + .build()); + + SigningAlgorithmSpec alg = resolveSigningAlgorithm(pubKeyResp.signingAlgorithms()); + algorithms.put(use, alg); + + String expectedFingerprint = committedFingerprints.get(use); + if (expectedFingerprint != null) { + byte[] spkiDer = pubKeyResp.publicKey().asByteArray(); + String actualFingerprint = sha256Hex(spkiDer); + if (!expectedFingerprint.equals(actualFingerprint)) { + throw new SigningException( + "Public key fingerprint mismatch for " + redactArn(keyArn) + + ": expected " + expectedFingerprint + + " but got " + actualFingerprint + + ". If the key was rotated, update the committed fingerprint."); + } + } + } + + metadata = new KeyMetadata(algorithms); + keyMetadata.set(metadata); + return metadata; + } catch (SigningException e) { + initFailure.set(e); + throw e; + } catch (KmsException e) { + SigningException se = new SigningException("Failed to initialize AWS KMS: " + e.getMessage(), e); + initFailure.set(se); + throw se; + } + } + } + + KmsClient getClient() { + if (externalClient != null) return externalClient; + if (kmsClient == null) { + synchronized (this) { + if (kmsClient == null) { + kmsClient = KmsClient.builder() + .region(Region.of(region)) + .build(); + } + } + } + return kmsClient; + } + + void initialize() throws SigningException { + ensureInitialized(); + } + + boolean isInitialized() { + return keyMetadata.get() != null; + } + + Map getAlgorithms() { + KeyMetadata metadata = keyMetadata.get(); + return metadata != null ? metadata.algorithms : Map.of(); + } + + private byte[] callKmsSign(String keyArn, SigningAlgorithmSpec alg, byte[] message) throws SigningException { + try { + SignResponse response = getClient().sign(SignRequest.builder() + .keyId(keyArn) + .signingAlgorithm(alg) + .message(SdkBytes.fromByteArray(message)) + .build()); + + if (response.signature() == null) { + throw new SigningException("KMS Sign returned no signature for " + redactArn(keyArn)); + } + return response.signature().asByteArray(); + } catch (KmsException e) { + throw new SigningException("KMS Sign failed for " + redactArn(keyArn) + ": " + e.getMessage(), e); + } + } + + static String adcpAlgorithmFor(SigningAlgorithmSpec kmsAlg) throws SigningException { + return switch (kmsAlg) { + case ED25519_SHA_512 -> AdcpSignatureProfile.ALG_ED25519; + case ECDSA_SHA_256 -> AdcpSignatureProfile.ALG_ECDSA_P256_SHA256; + case ECDSA_SHA_384 -> "ecdsa-p384-sha384"; + default -> throw new SigningException("Unsupported KMS signing algorithm: " + kmsAlg); + }; + } + + private static boolean isEd25519(SigningAlgorithmSpec alg) { + return alg == SigningAlgorithmSpec.ED25519_SHA_512; + } + + private static SigningAlgorithmSpec resolveSigningAlgorithm(List algs) throws SigningException { + if (algs.contains(SigningAlgorithmSpec.ECDSA_SHA_256)) { + return SigningAlgorithmSpec.ECDSA_SHA_256; + } + if (algs.contains(SigningAlgorithmSpec.ECDSA_SHA_384)) { + return SigningAlgorithmSpec.ECDSA_SHA_384; + } + if (algs.contains(SigningAlgorithmSpec.ED25519_SHA_512)) { + return SigningAlgorithmSpec.ED25519_SHA_512; + } + throw new SigningException("No supported signing algorithm found. Supported: ED25519_SHA_512, ECDSA_SHA_256, ECDSA_SHA_384. Got: " + algs); + } + + static byte[] ecdsaDerToRaw(byte[] derSignature, int fieldSize) { + int offset = 0; + if (derSignature[offset] != 0x30) { + throw new IllegalArgumentException("Invalid DER signature: missing SEQUENCE tag"); + } + offset++; + + int seqLen = derSignature[offset] & 0xFF; + offset++; + if (seqLen == 0x80) { + throw new IllegalArgumentException("Indefinite-length DER not supported"); + } + + if (derSignature[offset] != 0x02) { + throw new IllegalArgumentException("Invalid DER signature: missing INTEGER tag for r"); + } + offset++; + + int rLen = derSignature[offset] & 0xFF; + offset++; + + int rPad = (rLen > 1 && derSignature[offset] == 0x00) ? 1 : 0; + byte[] r = new byte[fieldSize]; + int rSrcLen = rLen - rPad; + if (rSrcLen > fieldSize) { + System.arraycopy(derSignature, offset + rPad + (rSrcLen - fieldSize), r, 0, fieldSize); + } else { + System.arraycopy(derSignature, offset + rPad, r, fieldSize - rSrcLen, rSrcLen); + } + offset += rLen; + + if (derSignature[offset] != 0x02) { + throw new IllegalArgumentException("Invalid DER signature: missing INTEGER tag for s"); + } + offset++; + + int sLen = derSignature[offset] & 0xFF; + offset++; + + int sPad = (sLen > 1 && derSignature[offset] == 0x00) ? 1 : 0; + byte[] s = new byte[fieldSize]; + int sSrcLen = sLen - sPad; + if (sSrcLen > fieldSize) { + System.arraycopy(derSignature, offset + sPad + (sSrcLen - fieldSize), s, 0, fieldSize); + } else { + System.arraycopy(derSignature, offset + sPad, s, fieldSize - sSrcLen, sSrcLen); + } + + byte[] raw = new byte[fieldSize * 2]; + System.arraycopy(r, 0, raw, 0, fieldSize); + System.arraycopy(s, 0, raw, fieldSize, fieldSize); + return raw; + } + + private static String generateNonce() { + byte[] bytes = new byte[16]; + new java.security.SecureRandom().nextBytes(bytes); + return java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private static String sha256Hex(byte[] data) { + try { + byte[] hash = java.security.MessageDigest.getInstance("SHA-256").digest(data); + StringBuilder sb = new StringBuilder(hash.length * 2); + for (byte b : hash) { + sb.append(String.format("%02x", b & 0xFF)); + } + return sb.toString(); + } catch (java.security.NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + static String redactArn(String arn) { + return arn.replaceAll("(arn:aws:kms:[^:]*:)[^:]+(:)", "$1$2"); + } + + public static final class Builder { + private final EnumMap keyArns = new EnumMap<>(AdcpUse.class); + private final EnumMap committedFingerprints = new EnumMap<>(AdcpUse.class); + private String region = "us-east-1"; + private @Nullable KmsClient kmsClient; + + private Builder() {} + + public Builder keyArn(AdcpUse use, String keyArn) { + Objects.requireNonNull(use, "use"); + Objects.requireNonNull(keyArn, "keyArn"); + if (keyArn.isBlank()) { + throw new IllegalArgumentException("keyArn must not be blank"); + } + keyArns.put(use, keyArn); + return this; + } + + public Builder region(String region) { + Objects.requireNonNull(region, "region"); + if (region.isBlank()) { + throw new IllegalArgumentException("region must not be blank"); + } + this.region = region; + return this; + } + + public Builder committedPublicKeyFingerprint(AdcpUse use, String fingerprint) { + Objects.requireNonNull(use, "use"); + Objects.requireNonNull(fingerprint, "fingerprint"); + committedFingerprints.put(use, fingerprint); + return this; + } + + Builder kmsClient(KmsClient client) { + this.kmsClient = client; + return this; + } + + public AwsKmsSigningProvider build() { + return new AwsKmsSigningProvider(this); + } } } \ No newline at end of file diff --git a/adcp-signing-aws-kms/src/test/java/org/adcontextprotocol/adcp/signing/aws/AwsKmsSigningProviderTest.java b/adcp-signing-aws-kms/src/test/java/org/adcontextprotocol/adcp/signing/aws/AwsKmsSigningProviderTest.java new file mode 100644 index 0000000..9ce9451 --- /dev/null +++ b/adcp-signing-aws-kms/src/test/java/org/adcontextprotocol/adcp/signing/aws/AwsKmsSigningProviderTest.java @@ -0,0 +1,304 @@ +package org.adcontextprotocol.adcp.signing.aws; + +import org.adcontextprotocol.adcp.server.signing.AdcpSignatureProfile; +import org.adcontextprotocol.adcp.signing.AdcpUse; +import org.adcontextprotocol.adcp.signing.SigningContext; +import org.adcontextprotocol.adcp.signing.SigningException; +import org.adcontextprotocol.adcp.signing.SigningInput; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.kms.KmsClient; +import software.amazon.awssdk.services.kms.model.GetPublicKeyRequest; +import software.amazon.awssdk.services.kms.model.GetPublicKeyResponse; +import software.amazon.awssdk.services.kms.model.KmsException; +import software.amazon.awssdk.services.kms.model.SigningAlgorithmSpec; + +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AwsKmsSigningProviderTest { + + private static final String REQUEST_KEY_ARN = "arn:aws:kms:us-east-1:123456789012:key/aaaa-bbbb-cccc-dddd"; + private static final String WEBHOOK_KEY_ARN = "arn:aws:kms:us-east-1:123456789012:key/eeee-ffff-0000-1111"; + + @Mock + private KmsClient kmsClient; + + private record TestSigningInput(String method, String targetUri, byte[] body, Map headers) + implements SigningInput {} + + @Test + void builder_requiresAtLeastOneKeyArn() { + assertThrows(IllegalStateException.class, () -> + AwsKmsSigningProvider.builder() + .region("us-east-1") + .kmsClient(kmsClient) + .build()); + } + + @Test + void builder_rejectsBlankKeyArn() { + assertThrows(IllegalArgumentException.class, () -> + AwsKmsSigningProvider.builder() + .keyArn(AdcpUse.REQUEST_SIGNING, " ")); + } + + @Test + void builder_rejectsNullKeyArn() { + assertThrows(NullPointerException.class, () -> + AwsKmsSigningProvider.builder() + .keyArn(AdcpUse.REQUEST_SIGNING, null)); + } + + @Test + void builder_rejectsNullUse() { + assertThrows(NullPointerException.class, () -> + AwsKmsSigningProvider.builder() + .keyArn(null, REQUEST_KEY_ARN)); + } + + @Test + void builder_rejectsBlankRegion() { + assertThrows(IllegalArgumentException.class, () -> + AwsKmsSigningProvider.builder() + .keyArn(AdcpUse.REQUEST_SIGNING, REQUEST_KEY_ARN) + .region(" ")); + } + + @Test + void builder_rejectsNullFingerprint() { + assertThrows(NullPointerException.class, () -> + AwsKmsSigningProvider.builder() + .committedPublicKeyFingerprint(AdcpUse.REQUEST_SIGNING, null)); + } + + @Test + void lazyInit_kmsClientNotCreatedUntilFirstSign() { + AwsKmsSigningProvider provider = AwsKmsSigningProvider.builder() + .keyArn(AdcpUse.REQUEST_SIGNING, REQUEST_KEY_ARN) + .region("us-east-1") + .kmsClient(kmsClient) + .build(); + + assertFalse(provider.isInitialized()); + verifyNoInteractions(kmsClient); + } + + @Test + void keyArnMapping_selectsCorrectKeyPerUse() throws SigningException { + GetPublicKeyResponse requestResponse = GetPublicKeyResponse.builder() + .publicKey(SdkBytes.fromByteArray(new byte[32])) + .signingAlgorithms(List.of(SigningAlgorithmSpec.ED25519_SHA_512)) + .build(); + + when(kmsClient.getPublicKey(any(GetPublicKeyRequest.class))).thenReturn(requestResponse); + + AwsKmsSigningProvider provider = AwsKmsSigningProvider.builder() + .keyArn(AdcpUse.REQUEST_SIGNING, REQUEST_KEY_ARN) + .keyArn(AdcpUse.WEBHOOK_SIGNING, WEBHOOK_KEY_ARN) + .region("us-east-1") + .kmsClient(kmsClient) + .build(); + + provider.initialize(); + + Map algs = provider.getAlgorithms(); + assertEquals(2, algs.size()); + assertEquals(SigningAlgorithmSpec.ED25519_SHA_512, algs.get(AdcpUse.REQUEST_SIGNING)); + assertEquals(SigningAlgorithmSpec.ED25519_SHA_512, algs.get(AdcpUse.WEBHOOK_SIGNING)); + } + + @Test + void ecdsaDerToRaw_convertsCorrectly() { + byte[] derSig; + try { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); + kpg.initialize(256); + KeyPair keyPair = kpg.generateKeyPair(); + java.security.Signature ecdsa = java.security.Signature.getInstance("SHA256withECDSA"); + ecdsa.initSign(keyPair.getPrivate()); + ecdsa.update("test message".getBytes(StandardCharsets.UTF_8)); + derSig = ecdsa.sign(); + } catch (Exception e) { + fail("Failed to generate ECDSA signature: " + e.getMessage()); + return; + } + + byte[] rawSig = AwsKmsSigningProvider.ecdsaDerToRaw(derSig, 32); + + assertEquals(64, rawSig.length, "P-256 raw signature should be 64 bytes (r=32 || s=32)"); + } + + @Test + void ecdsaDerToRaw_handlesP384() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); + kpg.initialize(384); + KeyPair keyPair = kpg.generateKeyPair(); + + java.security.Signature ecdsa = java.security.Signature.getInstance("SHA384withECDSA"); + ecdsa.initSign(keyPair.getPrivate()); + ecdsa.update("test p-384".getBytes(StandardCharsets.UTF_8)); + byte[] derSig = ecdsa.sign(); + + byte[] rawSig = AwsKmsSigningProvider.ecdsaDerToRaw(derSig, 48); + + assertEquals(96, rawSig.length, "P-384 raw signature should be 96 bytes (r=48 || s=48)"); + } + + @Test + void ecdsaDerToRaw_rejectsInvalidDerTag() { + byte[] invalid = new byte[]{0x01, 0x02, 0x03}; + assertThrows(IllegalArgumentException.class, + () -> AwsKmsSigningProvider.ecdsaDerToRaw(invalid, 32)); + } + + @Test + void tripwire_mismatchThrowsSigningException() throws Exception { + byte[] realPublicKeyBytes = generateEd25519PublicKeyBytes(); + String realFingerprint = sha256Hex(realPublicKeyBytes); + + byte[] fakePublicKeyBytes = new byte[44]; + new java.security.SecureRandom().nextBytes(fakePublicKeyBytes); + String fakeFingerprint = sha256Hex(fakePublicKeyBytes); + + GetPublicKeyResponse mockResponse = GetPublicKeyResponse.builder() + .publicKey(SdkBytes.fromByteArray(realPublicKeyBytes)) + .signingAlgorithms(List.of(SigningAlgorithmSpec.ED25519_SHA_512)) + .build(); + when(kmsClient.getPublicKey(any(GetPublicKeyRequest.class))).thenReturn(mockResponse); + + AwsKmsSigningProvider provider = AwsKmsSigningProvider.builder() + .keyArn(AdcpUse.REQUEST_SIGNING, REQUEST_KEY_ARN) + .committedPublicKeyFingerprint(AdcpUse.REQUEST_SIGNING, fakeFingerprint) + .region("us-east-1") + .kmsClient(kmsClient) + .build(); + + SigningException ex = assertThrows(SigningException.class, provider::initialize); + assertTrue(ex.getMessage().contains("fingerprint mismatch"), + "Expected fingerprint mismatch message, got: " + ex.getMessage()); + } + + @Test + void tripwire_matchingFingerprintSucceeds() throws Exception { + byte[] publicKeyBytes = generateEd25519PublicKeyBytes(); + String fingerprint = sha256Hex(publicKeyBytes); + + GetPublicKeyResponse mockResponse = GetPublicKeyResponse.builder() + .publicKey(SdkBytes.fromByteArray(publicKeyBytes)) + .signingAlgorithms(List.of(SigningAlgorithmSpec.ED25519_SHA_512)) + .build(); + when(kmsClient.getPublicKey(any(GetPublicKeyRequest.class))).thenReturn(mockResponse); + + AwsKmsSigningProvider provider = AwsKmsSigningProvider.builder() + .keyArn(AdcpUse.REQUEST_SIGNING, REQUEST_KEY_ARN) + .committedPublicKeyFingerprint(AdcpUse.REQUEST_SIGNING, fingerprint) + .region("us-east-1") + .kmsClient(kmsClient) + .build(); + + assertDoesNotThrow(provider::initialize); + assertTrue(provider.isInitialized()); + } + + @Test + void kmsException_wrappedInSigningException() { + when(kmsClient.getPublicKey(any(GetPublicKeyRequest.class))) + .thenThrow(KmsException.builder().message("Access denied").build()); + + AwsKmsSigningProvider provider = AwsKmsSigningProvider.builder() + .keyArn(AdcpUse.REQUEST_SIGNING, REQUEST_KEY_ARN) + .region("us-east-1") + .kmsClient(kmsClient) + .build(); + + SigningContext context = SigningContext.builder(AdcpUse.REQUEST_SIGNING).build(); + Map headers = new LinkedHashMap<>(); + headers.put("content-type", "application/json"); + SigningInput input = new TestSigningInput("POST", "https://example.com/path", new byte[0], headers); + + SigningException ex = assertThrows(SigningException.class, + () -> provider.sign(context, input)); + assertTrue(ex.getMessage().contains("Failed to initialize AWS KMS"), + "Expected AWS KMS init error, got: " + ex.getMessage()); + } + + @Test + void redactArn_replacesAccountId() { + String arn = "arn:aws:kms:us-east-1:123456789012:key/aaaa-bbbb-cccc"; + String redacted = AwsKmsSigningProvider.redactArn(arn); + assertFalse(redacted.contains("123456789012"), + "Account ID should be redacted: " + redacted); + assertTrue(redacted.contains(""), + "Should contain : " + redacted); + } + + @Test + void adcpAlgorithmFor_mapsCorrectly() throws SigningException { + assertEquals(AdcpSignatureProfile.ALG_ED25519, + AwsKmsSigningProvider.adcpAlgorithmFor(SigningAlgorithmSpec.ED25519_SHA_512)); + assertEquals(AdcpSignatureProfile.ALG_ECDSA_P256_SHA256, + AwsKmsSigningProvider.adcpAlgorithmFor(SigningAlgorithmSpec.ECDSA_SHA_256)); + assertEquals("ecdsa-p384-sha384", + AwsKmsSigningProvider.adcpAlgorithmFor(SigningAlgorithmSpec.ECDSA_SHA_384)); + } + + @Test + void sign_noKeyArnForUse_throws() throws SigningException { + GetPublicKeyResponse mockResponse = GetPublicKeyResponse.builder() + .publicKey(SdkBytes.fromByteArray(new byte[32])) + .signingAlgorithms(List.of(SigningAlgorithmSpec.ED25519_SHA_512)) + .build(); + when(kmsClient.getPublicKey(any(GetPublicKeyRequest.class))).thenReturn(mockResponse); + + AwsKmsSigningProvider provider = AwsKmsSigningProvider.builder() + .keyArn(AdcpUse.REQUEST_SIGNING, REQUEST_KEY_ARN) + .region("us-east-1") + .kmsClient(kmsClient) + .build(); + + provider.initialize(); + + SigningContext webhookContext = SigningContext.builder(AdcpUse.WEBHOOK_SIGNING).build(); + Map headers = new LinkedHashMap<>(); + headers.put("content-type", "application/json"); + SigningInput input = new TestSigningInput("POST", "https://example.com/webhook", + "{\"test\":true}".getBytes(StandardCharsets.UTF_8), headers); + + SigningException ex = assertThrows(SigningException.class, + () -> provider.sign(webhookContext, input)); + assertTrue(ex.getMessage().contains("No key ARN configured"), + "Expected 'No key ARN configured' message, got: " + ex.getMessage()); + } + + private static byte[] generateEd25519PublicKeyBytes() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("Ed25519"); + KeyPair keyPair = kpg.generateKeyPair(); + return keyPair.getPublic().getEncoded(); + } + + private static String sha256Hex(byte[] data) throws Exception { + java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); + byte[] hash = md.digest(data); + StringBuilder sb = new StringBuilder(hash.length * 2); + for (byte b : hash) { + sb.append(String.format("%02x", b & 0xFF)); + } + return sb.toString(); + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3ee4596..281739d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ javapoet = "0.7.0" # Test. junit = "5.11.4" +mockito = "5.17.0" # Spring Boot (D7 — Spring Boot 3.x / jakarta only). spring-boot = "3.4.4" @@ -65,6 +66,8 @@ junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.re junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit" } junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } +mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" } +mockito-junit-jupiter = { module = "org.mockito:mockito-junit-jupiter", version.ref = "mockito" } # Spring Boot starter machinery (used only by adcp-spring-boot-starter) spring-boot-autoconfigure = { module = "org.springframework.boot:spring-boot-autoconfigure", version.ref = "spring-boot" } @@ -84,7 +87,7 @@ slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } javapoet = { module = "com.palantir.javapoet:javapoet", version.ref = "javapoet" } # AWS KMS (D9 signing track — lazy-init, not on boot critical path) -# aws-kms = { module = "software.amazon.awssdk:kms", version = "2.31.3" } +aws-kms = { module = "software.amazon.awssdk:kms", version = "2.46.12" } # GCP KMS (D9 signing track — lazy-init, not on boot critical path) # gcp-kms = { module = "com.google.cloud:google-cloud-kms", version = "2.57.0" } From 3578adf96c7af7f274756de4b52ca7c99b14f3a1 Mon Sep 17 00:00:00 2001 From: Lobsterdog Contributors Date: Wed, 17 Jun 2026 11:23:34 -0600 Subject: [PATCH 07/21] feat(signing): implement GCP KMS SigningProvider with lazy-init, tripwire, and ECDSA DER conversion --- adcp-signing-gcp-kms/build.gradle.kts | 11 +- .../signing/gcp/GcpKmsSigningProvider.java | 408 ++++++++++++++++-- .../gcp/GcpKmsSigningProviderTest.java | 306 +++++++++++++ gradle/libs.versions.toml | 3 +- 4 files changed, 694 insertions(+), 34 deletions(-) create mode 100644 adcp-signing-gcp-kms/src/test/java/org/adcontextprotocol/adcp/signing/gcp/GcpKmsSigningProviderTest.java diff --git a/adcp-signing-gcp-kms/build.gradle.kts b/adcp-signing-gcp-kms/build.gradle.kts index 78382fd..0c2da99 100644 --- a/adcp-signing-gcp-kms/build.gradle.kts +++ b/adcp-signing-gcp-kms/build.gradle.kts @@ -6,7 +6,12 @@ description = "AdCP Java SDK — GCP KMS signing provider" dependencies { api(project(":adcp")) - // GCP KMS SDK — lazy-init, not on the boot critical path. - // Uncomment when implementing the provider: - // implementation(libs.gcp.kms) + implementation(project(":adcp-server")) + implementation(libs.gcp.kms) + implementation(libs.gcp.auth) + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.params) + testImplementation(libs.mockito.core) + testImplementation(libs.mockito.junit.jupiter) + testRuntimeOnly(libs.junit.jupiter.engine) } \ No newline at end of file diff --git a/adcp-signing-gcp-kms/src/main/java/org/adcontextprotocol/adcp/signing/gcp/GcpKmsSigningProvider.java b/adcp-signing-gcp-kms/src/main/java/org/adcontextprotocol/adcp/signing/gcp/GcpKmsSigningProvider.java index 647cc05..b332a74 100644 --- a/adcp-signing-gcp-kms/src/main/java/org/adcontextprotocol/adcp/signing/gcp/GcpKmsSigningProvider.java +++ b/adcp-signing-gcp-kms/src/main/java/org/adcontextprotocol/adcp/signing/gcp/GcpKmsSigningProvider.java @@ -1,50 +1,398 @@ package org.adcontextprotocol.adcp.signing.gcp; +import com.google.cloud.kms.v1.AsymmetricSignRequest; +import com.google.cloud.kms.v1.AsymmetricSignResponse; +import com.google.cloud.kms.v1.CryptoKeyVersion.CryptoKeyVersionAlgorithm; +import com.google.cloud.kms.v1.KeyManagementServiceClient; +import com.google.cloud.kms.v1.PublicKey; +import com.google.protobuf.ByteString; +import org.adcontextprotocol.adcp.server.signing.AdcpSignatureProfile; +import org.adcontextprotocol.adcp.server.signing.ContentDigest; +import org.adcontextprotocol.adcp.server.signing.Rfc9421Canonicalizer; +import org.adcontextprotocol.adcp.server.signing.SignatureInputBuilder; +import org.adcontextprotocol.adcp.signing.AdcpUse; import org.adcontextprotocol.adcp.signing.Signature; import org.adcontextprotocol.adcp.signing.SigningContext; import org.adcontextprotocol.adcp.signing.SigningException; import org.adcontextprotocol.adcp.signing.SigningInput; import org.adcontextprotocol.adcp.signing.SigningProvider; +import org.jspecify.annotations.Nullable; +import java.io.FileInputStream; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; -/** - * Google Cloud KMS signing provider for AdCP — stub. - * - *

This is a module skeleton for the {@code adcp-signing-gcp-kms} artifact. - * The real implementation will use {@code com.google.cloud:google-cloud-kms} - * to sign and verify AdCP webhook signatures against keys stored in GCP KMS. - * - *

Constructor accepts GCP KMS client configuration (project ID, location, - * key ring mapping). The KMS client is lazy-initialised — it is not touched - * until the first {@link #sign} call, so the module can be on the classpath - * without an active GCP connection. - * - * @see SigningProvider - */ public final class GcpKmsSigningProvider implements SigningProvider { - private final String projectId; - private final String location; - private final Map keyRingMapping; + private static final class KeyMetadata { + final Map algorithms; - /** - * Create a GCP KMS signing provider stub. - * - * @param projectId GCP project ID hosting the key rings - * @param location GCP location (e.g. "global", "us-east1") - * @param keyRingMapping mapping from AdCP key identifiers to KMS key ring paths - */ - public GcpKmsSigningProvider(String projectId, String location, Map keyRingMapping) { - this.projectId = Objects.requireNonNull(projectId, "projectId"); - this.location = Objects.requireNonNull(location, "location"); - this.keyRingMapping = Map.copyOf(Objects.requireNonNull(keyRingMapping, "keyRingMapping")); + KeyMetadata(Map algorithms) { + this.algorithms = Map.copyOf(algorithms); + } + } + + private final Map keyVersionPaths; + private final Map committedFingerprints; + private final @Nullable String credentialsPath; + private final @Nullable KeyManagementServiceClient externalClient; + + private volatile @Nullable KeyManagementServiceClient kmsClient; + private final AtomicReference<@Nullable KeyMetadata> keyMetadata = new AtomicReference<>(); + private final AtomicReference<@Nullable Exception> initFailure = new AtomicReference<>(); + + private GcpKmsSigningProvider(Builder builder) { + if (builder.keyVersionPaths.isEmpty()) { + throw new IllegalStateException("At least one key version path must be configured via keyVersionPath()"); + } + this.keyVersionPaths = Map.copyOf(builder.keyVersionPaths); + this.committedFingerprints = Map.copyOf(builder.committedFingerprints); + this.credentialsPath = builder.credentialsPath; + this.externalClient = builder.kmsClient; + } + + public static Builder builder() { + return new Builder(); } @Override public Signature sign(SigningContext context, SigningInput input) throws SigningException { - throw new UnsupportedOperationException( - "GCP KMS signing not yet implemented — track 4 v0.2"); + AdcpUse use = context.use(); + String keyVersionPath = keyVersionPaths.get(use); + if (keyVersionPath == null) { + throw new SigningException("No key version path configured for AdcpUse: " + use); + } + + KeyMetadata metadata = ensureInitialized(); + + CryptoKeyVersionAlgorithm kmsAlgEnum = metadata.algorithms.get(use); + if (kmsAlgEnum == null) { + throw new SigningException("No signing algorithm resolved for AdcpUse: " + use); + } + + String alg = adcpAlgorithmFor(kmsAlgEnum); + String tag = AdcpSignatureProfile.tagForUse(use); + List coveredComponents = AdcpSignatureProfile.requiredComponentsForUse(use); + + long created = System.currentTimeMillis() / 1000; + long expires = created + AdcpSignatureProfile.REPLAY_WINDOW_SECONDS; + String nonce = generateNonce(); + + String signatureInputValue = SignatureInputBuilder.create() + .label(Signature.DEFAULT_LABEL) + .coveredComponents(coveredComponents) + .created(created) + .expires(expires) + .nonce(nonce) + .keyid(keyVersionPath) + .alg(alg) + .tag(tag) + .build(); + + Map signingHeaders = new LinkedHashMap<>(input.headers()); + if (input.body() != null && input.body().length > 0) { + String contentDigestValue = ContentDigest.sha256(input.body()); + signingHeaders.put("content-digest", contentDigestValue); + } + + String signatureBase = Rfc9421Canonicalizer.canonicalize( + input.method(), + input.targetUri(), + signingHeaders, + coveredComponents, + signatureInputValue); + + byte[] messageBytes = signatureBase.getBytes(StandardCharsets.UTF_8); + + byte[] kmsSignature = callKmsSign(keyVersionPath, messageBytes); + + byte[] signatureBytes; + if (isEd25519(kmsAlgEnum)) { + signatureBytes = kmsSignature; + } else { + int fieldSize = isP384(kmsAlgEnum) ? 48 : 32; + signatureBytes = ecdsaDerToRaw(kmsSignature, fieldSize); + } + + return new Signature( + Signature.DEFAULT_LABEL, + signatureInputValue, + signatureBytes, + alg, + keyVersionPath); + } + + private KeyMetadata ensureInitialized() throws SigningException { + KeyMetadata metadata = keyMetadata.get(); + if (metadata != null) return metadata; + + Exception failure = initFailure.get(); + if (failure != null) { + throw new SigningException("GCP KMS provider initialization failed: " + failure.getMessage(), failure); + } + + synchronized (this) { + metadata = keyMetadata.get(); + if (metadata != null) return metadata; + + failure = initFailure.get(); + if (failure != null) { + throw new SigningException("GCP KMS provider initialization failed: " + failure.getMessage(), failure); + } + + try { + KeyManagementServiceClient client = getClient(); + Map algorithms = new EnumMap<>(AdcpUse.class); + + for (Map.Entry entry : keyVersionPaths.entrySet()) { + AdcpUse use = entry.getKey(); + String keyVersionPath = entry.getValue(); + + PublicKey pubKey = client.getPublicKey(keyVersionPath); + CryptoKeyVersionAlgorithm kmsAlg = pubKey.getAlgorithm(); + algorithms.put(use, kmsAlg); + + String expectedFingerprint = committedFingerprints.get(use); + if (expectedFingerprint != null) { + String pem = pubKey.getPem(); + byte[] spkiDer = extractSpkiDer(pem); + String actualFingerprint = sha256Hex(spkiDer); + if (!expectedFingerprint.equals(actualFingerprint)) { + throw new SigningException( + "Public key fingerprint mismatch for " + redactKeyVersionPath(keyVersionPath) + + ": expected " + expectedFingerprint + + " but got " + actualFingerprint + + ". If the key was rotated, update the committed fingerprint."); + } + } + } + + metadata = new KeyMetadata(algorithms); + keyMetadata.set(metadata); + return metadata; + } catch (SigningException e) { + initFailure.set(e); + throw e; + } catch (Exception e) { + SigningException se = new SigningException("Failed to initialize GCP KMS: " + e.getMessage(), e); + initFailure.set(se); + throw se; + } + } + } + + KeyManagementServiceClient getClient() throws SigningException { + if (externalClient != null) return externalClient; + if (kmsClient == null) { + synchronized (this) { + if (kmsClient == null) { + try { + if (credentialsPath != null) { + com.google.auth.oauth2.ServiceAccountCredentials credentials = + com.google.auth.oauth2.ServiceAccountCredentials.fromStream( + new FileInputStream(credentialsPath)); + kmsClient = KeyManagementServiceClient.create( + com.google.cloud.kms.v1.KeyManagementServiceSettings.newBuilder() + .setCredentialsProvider(com.google.api.gax.core.FixedCredentialsProvider.create(credentials)) + .build()); + } else { + kmsClient = KeyManagementServiceClient.create(); + } + } catch (Exception e) { + throw new SigningException("Failed to create GCP KMS client: " + e.getMessage(), e); + } + } + } + } + return kmsClient; + } + + void initialize() throws SigningException { + ensureInitialized(); + } + + boolean isInitialized() { + return keyMetadata.get() != null; + } + + Map getAlgorithms() { + KeyMetadata metadata = keyMetadata.get(); + return metadata != null ? metadata.algorithms : Map.of(); + } + + private byte[] callKmsSign(String keyVersionPath, byte[] message) throws SigningException { + try { + AsymmetricSignRequest request = AsymmetricSignRequest.newBuilder() + .setName(keyVersionPath) + .setData(ByteString.copyFrom(message)) + .build(); + + AsymmetricSignResponse response = getClient().asymmetricSign(request); + + if (response.getSignature().isEmpty()) { + throw new SigningException("GCP KMS asymmetricSign returned no signature for " + redactKeyVersionPath(keyVersionPath)); + } + return response.getSignature().toByteArray(); + } catch (SigningException e) { + throw e; + } catch (Exception e) { + throw new SigningException("GCP KMS asymmetricSign failed for " + redactKeyVersionPath(keyVersionPath) + ": " + e.getMessage(), e); + } + } + + static String adcpAlgorithmFor(CryptoKeyVersionAlgorithm gcpAlg) throws SigningException { + return switch (gcpAlg) { + case EC_SIGN_ED25519 -> AdcpSignatureProfile.ALG_ED25519; + case EC_SIGN_P256_SHA256 -> AdcpSignatureProfile.ALG_ECDSA_P256_SHA256; + case EC_SIGN_P384_SHA384 -> "ecdsa-p384-sha384"; + default -> throw new SigningException("Unsupported GCP KMS signing algorithm: " + gcpAlg); + }; + } + + private static boolean isEd25519(CryptoKeyVersionAlgorithm alg) { + return alg == CryptoKeyVersionAlgorithm.EC_SIGN_ED25519; + } + + private static boolean isP384(CryptoKeyVersionAlgorithm alg) { + return alg == CryptoKeyVersionAlgorithm.EC_SIGN_P384_SHA384; + } + + static byte[] ecdsaDerToRaw(byte[] derSignature, int fieldSize) { + int offset = 0; + if (derSignature[offset] != 0x30) { + throw new IllegalArgumentException("Invalid DER signature: missing SEQUENCE tag"); + } + offset++; + + int seqLen = derSignature[offset] & 0xFF; + offset++; + if (seqLen == 0x80) { + throw new IllegalArgumentException("Indefinite-length DER not supported"); + } + + if (derSignature[offset] != 0x02) { + throw new IllegalArgumentException("Invalid DER signature: missing INTEGER tag for r"); + } + offset++; + + int rLen = derSignature[offset] & 0xFF; + offset++; + + int rPad = (rLen > 1 && derSignature[offset] == 0x00) ? 1 : 0; + byte[] r = new byte[fieldSize]; + int rSrcLen = rLen - rPad; + if (rSrcLen > fieldSize) { + System.arraycopy(derSignature, offset + rPad + (rSrcLen - fieldSize), r, 0, fieldSize); + } else { + System.arraycopy(derSignature, offset + rPad, r, fieldSize - rSrcLen, rSrcLen); + } + offset += rLen; + + if (derSignature[offset] != 0x02) { + throw new IllegalArgumentException("Invalid DER signature: missing INTEGER tag for s"); + } + offset++; + + int sLen = derSignature[offset] & 0xFF; + offset++; + + int sPad = (sLen > 1 && derSignature[offset] == 0x00) ? 1 : 0; + byte[] s = new byte[fieldSize]; + int sSrcLen = sLen - sPad; + if (sSrcLen > fieldSize) { + System.arraycopy(derSignature, offset + sPad + (sSrcLen - fieldSize), s, 0, fieldSize); + } else { + System.arraycopy(derSignature, offset + sPad, s, fieldSize - sSrcLen, sSrcLen); + } + + byte[] raw = new byte[fieldSize * 2]; + System.arraycopy(r, 0, raw, 0, fieldSize); + System.arraycopy(s, 0, raw, fieldSize, fieldSize); + return raw; + } + + static byte[] extractSpkiDer(String pem) throws SigningException { + String stripped = pem.replaceAll("-----BEGIN [A-Z ]+-----", "") + .replaceAll("-----END [A-Z ]+-----", "") + .replaceAll("\\s", ""); + try { + return Base64.getMimeDecoder().decode(stripped); + } catch (IllegalArgumentException e) { + throw new SigningException("Failed to decode PEM public key: " + e.getMessage(), e); + } + } + + private static String generateNonce() { + byte[] bytes = new byte[16]; + new SecureRandom().nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private static String sha256Hex(byte[] data) { + try { + byte[] hash = java.security.MessageDigest.getInstance("SHA-256").digest(data); + StringBuilder sb = new StringBuilder(hash.length * 2); + for (byte b : hash) { + sb.append(String.format("%02x", b & 0xFF)); + } + return sb.toString(); + } catch (java.security.NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + static String redactKeyVersionPath(String keyVersionPath) { + return keyVersionPath.replaceAll("projects/[^/]+", "projects/"); + } + + public static final class Builder { + private final EnumMap keyVersionPaths = new EnumMap<>(AdcpUse.class); + private final EnumMap committedFingerprints = new EnumMap<>(AdcpUse.class); + private @Nullable String credentialsPath; + private @Nullable KeyManagementServiceClient kmsClient; + + private Builder() {} + + public Builder keyVersionPath(AdcpUse use, String keyVersionPath) { + Objects.requireNonNull(use, "use"); + Objects.requireNonNull(keyVersionPath, "keyVersionPath"); + if (keyVersionPath.isBlank()) { + throw new IllegalArgumentException("keyVersionPath must not be blank"); + } + keyVersionPaths.put(use, keyVersionPath); + return this; + } + + public Builder credentialsPath(String path) { + Objects.requireNonNull(path, "credentialsPath"); + if (path.isBlank()) { + throw new IllegalArgumentException("credentialsPath must not be blank"); + } + this.credentialsPath = path; + return this; + } + + public Builder committedPublicKeyFingerprint(AdcpUse use, String fingerprint) { + Objects.requireNonNull(use, "use"); + Objects.requireNonNull(fingerprint, "fingerprint"); + committedFingerprints.put(use, fingerprint); + return this; + } + + Builder kmsClient(KeyManagementServiceClient client) { + this.kmsClient = client; + return this; + } + + public GcpKmsSigningProvider build() { + return new GcpKmsSigningProvider(this); + } } } \ No newline at end of file diff --git a/adcp-signing-gcp-kms/src/test/java/org/adcontextprotocol/adcp/signing/gcp/GcpKmsSigningProviderTest.java b/adcp-signing-gcp-kms/src/test/java/org/adcontextprotocol/adcp/signing/gcp/GcpKmsSigningProviderTest.java new file mode 100644 index 0000000..dbbada0 --- /dev/null +++ b/adcp-signing-gcp-kms/src/test/java/org/adcontextprotocol/adcp/signing/gcp/GcpKmsSigningProviderTest.java @@ -0,0 +1,306 @@ +package org.adcontextprotocol.adcp.signing.gcp; + +import com.google.cloud.kms.v1.CryptoKeyVersion.CryptoKeyVersionAlgorithm; +import com.google.cloud.kms.v1.KeyManagementServiceClient; +import com.google.cloud.kms.v1.PublicKey; +import org.adcontextprotocol.adcp.signing.AdcpUse; +import org.adcontextprotocol.adcp.signing.SigningContext; +import org.adcontextprotocol.adcp.signing.SigningException; +import org.adcontextprotocol.adcp.signing.SigningInput; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.Signature; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GcpKmsSigningProviderTest { + + private static final String REQUEST_KEY_VERSION_PATH = + "projects/my-project/locations/us-east1/keyRings/adcp-keys/cryptoKeys/request-signing/cryptoKeyVersions/1"; + private static final String WEBHOOK_KEY_VERSION_PATH = + "projects/my-project/locations/us-east1/keyRings/adcp-keys/cryptoKeys/webhook-signing/cryptoKeyVersions/1"; + + @Mock + private KeyManagementServiceClient kmsClient; + + private record TestSigningInput(String method, String targetUri, byte[] body, Map headers) + implements SigningInput {} + + @Test + void builder_requiresAtLeastOneKeyVersionPath() { + assertThrows(IllegalStateException.class, () -> + GcpKmsSigningProvider.builder() + .kmsClient(kmsClient) + .build()); + } + + @Test + void builder_rejectsBlankKeyVersionPath() { + assertThrows(IllegalArgumentException.class, () -> + GcpKmsSigningProvider.builder() + .keyVersionPath(AdcpUse.REQUEST_SIGNING, " ")); + } + + @Test + void builder_rejectsNullKeyVersionPath() { + assertThrows(NullPointerException.class, () -> + GcpKmsSigningProvider.builder() + .keyVersionPath(AdcpUse.REQUEST_SIGNING, null)); + } + + @Test + void builder_rejectsNullUse() { + assertThrows(NullPointerException.class, () -> + GcpKmsSigningProvider.builder() + .keyVersionPath(null, REQUEST_KEY_VERSION_PATH)); + } + + @Test + void builder_rejectsNullFingerprint() { + assertThrows(NullPointerException.class, () -> + GcpKmsSigningProvider.builder() + .committedPublicKeyFingerprint(AdcpUse.REQUEST_SIGNING, null)); + } + + @Test + void builder_rejectsBlankCredentialsPath() { + assertThrows(IllegalArgumentException.class, () -> + GcpKmsSigningProvider.builder() + .credentialsPath(" ")); + } + + @Test + void lazyInit_kmsClientNotCreatedUntilFirstSign() { + GcpKmsSigningProvider provider = GcpKmsSigningProvider.builder() + .keyVersionPath(AdcpUse.REQUEST_SIGNING, REQUEST_KEY_VERSION_PATH) + .kmsClient(kmsClient) + .build(); + + assertFalse(provider.isInitialized()); + verifyNoInteractions(kmsClient); + } + + @Test + void keyVersionPathMapping_selectsCorrectKeyPerUse() throws Exception { + PublicKey ed25519PubKey = PublicKey.newBuilder() + .setAlgorithm(CryptoKeyVersionAlgorithm.EC_SIGN_ED25519) + .setPem(generateEd25519Pem()) + .build(); + + when(kmsClient.getPublicKey(eq(REQUEST_KEY_VERSION_PATH))).thenReturn(ed25519PubKey); + when(kmsClient.getPublicKey(eq(WEBHOOK_KEY_VERSION_PATH))).thenReturn(ed25519PubKey); + + GcpKmsSigningProvider provider = GcpKmsSigningProvider.builder() + .keyVersionPath(AdcpUse.REQUEST_SIGNING, REQUEST_KEY_VERSION_PATH) + .keyVersionPath(AdcpUse.WEBHOOK_SIGNING, WEBHOOK_KEY_VERSION_PATH) + .kmsClient(kmsClient) + .build(); + + provider.initialize(); + + Map algs = provider.getAlgorithms(); + assertEquals(2, algs.size()); + assertEquals(CryptoKeyVersionAlgorithm.EC_SIGN_ED25519, algs.get(AdcpUse.REQUEST_SIGNING)); + assertEquals(CryptoKeyVersionAlgorithm.EC_SIGN_ED25519, algs.get(AdcpUse.WEBHOOK_SIGNING)); + } + + @Test + void ecdsaDerToRaw_convertsCorrectly() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); + kpg.initialize(256); + KeyPair keyPair = kpg.generateKeyPair(); + Signature ecdsa = Signature.getInstance("SHA256withECDSA"); + ecdsa.initSign(keyPair.getPrivate()); + ecdsa.update("test message".getBytes(StandardCharsets.UTF_8)); + byte[] derSig = ecdsa.sign(); + + byte[] rawSig = GcpKmsSigningProvider.ecdsaDerToRaw(derSig, 32); + + assertEquals(64, rawSig.length, "P-256 raw signature should be 64 bytes (r=32 || s=32)"); + } + + @Test + void ecdsaDerToRaw_handlesP384() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); + kpg.initialize(384); + KeyPair keyPair = kpg.generateKeyPair(); + + Signature ecdsa = Signature.getInstance("SHA384withECDSA"); + ecdsa.initSign(keyPair.getPrivate()); + ecdsa.update("test p-384".getBytes(StandardCharsets.UTF_8)); + byte[] derSig = ecdsa.sign(); + + byte[] rawSig = GcpKmsSigningProvider.ecdsaDerToRaw(derSig, 48); + + assertEquals(96, rawSig.length, "P-384 raw signature should be 96 bytes (r=48 || s=48)"); + } + + @Test + void ecdsaDerToRaw_rejectsInvalidDerTag() { + byte[] invalid = new byte[]{0x01, 0x02, 0x03}; + assertThrows(IllegalArgumentException.class, + () -> GcpKmsSigningProvider.ecdsaDerToRaw(invalid, 32)); + } + + @Test + void tripwire_mismatchThrowsSigningException() throws Exception { + String pem = generateEd25519Pem(); + + byte[] fakePublicKeyBytes = new byte[44]; + new java.security.SecureRandom().nextBytes(fakePublicKeyBytes); + String fakeFingerprint = sha256Hex(fakePublicKeyBytes); + + PublicKey mockPublicKey = PublicKey.newBuilder() + .setAlgorithm(CryptoKeyVersionAlgorithm.EC_SIGN_ED25519) + .setPem(pem) + .build(); + when(kmsClient.getPublicKey(eq(REQUEST_KEY_VERSION_PATH))).thenReturn(mockPublicKey); + + GcpKmsSigningProvider provider = GcpKmsSigningProvider.builder() + .keyVersionPath(AdcpUse.REQUEST_SIGNING, REQUEST_KEY_VERSION_PATH) + .committedPublicKeyFingerprint(AdcpUse.REQUEST_SIGNING, fakeFingerprint) + .kmsClient(kmsClient) + .build(); + + SigningException ex = assertThrows(SigningException.class, provider::initialize); + assertTrue(ex.getMessage().contains("fingerprint mismatch"), + "Expected fingerprint mismatch message, got: " + ex.getMessage()); + } + + @Test + void tripwire_matchingFingerprintSucceeds() throws Exception { + String pem = generateEd25519Pem(); + byte[] spkiDer = GcpKmsSigningProvider.extractSpkiDer(pem); + String fingerprint = sha256Hex(spkiDer); + + PublicKey mockPublicKey = PublicKey.newBuilder() + .setAlgorithm(CryptoKeyVersionAlgorithm.EC_SIGN_ED25519) + .setPem(pem) + .build(); + when(kmsClient.getPublicKey(eq(REQUEST_KEY_VERSION_PATH))).thenReturn(mockPublicKey); + + GcpKmsSigningProvider provider = GcpKmsSigningProvider.builder() + .keyVersionPath(AdcpUse.REQUEST_SIGNING, REQUEST_KEY_VERSION_PATH) + .committedPublicKeyFingerprint(AdcpUse.REQUEST_SIGNING, fingerprint) + .kmsClient(kmsClient) + .build(); + + assertDoesNotThrow(provider::initialize); + assertTrue(provider.isInitialized()); + } + + @Test + void kmsException_wrappedInSigningException() throws Exception { + when(kmsClient.getPublicKey(eq(REQUEST_KEY_VERSION_PATH))) + .thenThrow(new RuntimeException("Access denied")); + + GcpKmsSigningProvider provider = GcpKmsSigningProvider.builder() + .keyVersionPath(AdcpUse.REQUEST_SIGNING, REQUEST_KEY_VERSION_PATH) + .kmsClient(kmsClient) + .build(); + + SigningContext context = SigningContext.builder(AdcpUse.REQUEST_SIGNING).build(); + Map headers = new LinkedHashMap<>(); + headers.put("content-type", "application/json"); + SigningInput input = new TestSigningInput("POST", "https://example.com/path", new byte[0], headers); + + SigningException ex = assertThrows(SigningException.class, + () -> provider.sign(context, input)); + assertTrue(ex.getMessage().contains("Failed to initialize GCP KMS"), + "Expected GCP KMS init error, got: " + ex.getMessage()); + } + + @Test + void redactKeyVersionPath_replacesProjectId() { + String path = "projects/my-secret-project/locations/us-east1/keyRings/adcp-keys/cryptoKeys/req/cryptoKeyVersions/1"; + String redacted = GcpKmsSigningProvider.redactKeyVersionPath(path); + assertFalse(redacted.contains("my-secret-project"), + "Project ID should be redacted: " + redacted); + assertTrue(redacted.contains(""), + "Should contain : " + redacted); + } + + @Test + void adcpAlgorithmFor_mapsCorrectly() throws SigningException { + assertEquals("ed25519", GcpKmsSigningProvider.adcpAlgorithmFor(CryptoKeyVersionAlgorithm.EC_SIGN_ED25519)); + assertEquals("ecdsa-p256-sha256", GcpKmsSigningProvider.adcpAlgorithmFor(CryptoKeyVersionAlgorithm.EC_SIGN_P256_SHA256)); + assertEquals("ecdsa-p384-sha384", GcpKmsSigningProvider.adcpAlgorithmFor(CryptoKeyVersionAlgorithm.EC_SIGN_P384_SHA384)); + } + + @Test + void adcpAlgorithmFor_rejectsUnsupported() { + assertThrows(SigningException.class, + () -> GcpKmsSigningProvider.adcpAlgorithmFor(CryptoKeyVersionAlgorithm.RSA_SIGN_PKCS1_2048_SHA256)); + } + + @Test + void sign_noKeyVersionPathForUse_throws() throws Exception { + String pem = generateEd25519Pem(); + PublicKey mockPublicKey = PublicKey.newBuilder() + .setAlgorithm(CryptoKeyVersionAlgorithm.EC_SIGN_ED25519) + .setPem(pem) + .build(); + when(kmsClient.getPublicKey(eq(REQUEST_KEY_VERSION_PATH))).thenReturn(mockPublicKey); + + GcpKmsSigningProvider provider = GcpKmsSigningProvider.builder() + .keyVersionPath(AdcpUse.REQUEST_SIGNING, REQUEST_KEY_VERSION_PATH) + .kmsClient(kmsClient) + .build(); + + provider.initialize(); + + SigningContext webhookContext = SigningContext.builder(AdcpUse.WEBHOOK_SIGNING).build(); + Map headers = new LinkedHashMap<>(); + headers.put("content-type", "application/json"); + SigningInput input = new TestSigningInput("POST", "https://example.com/webhook", + "{\"test\":true}".getBytes(StandardCharsets.UTF_8), headers); + + SigningException ex = assertThrows(SigningException.class, + () -> provider.sign(webhookContext, input)); + assertTrue(ex.getMessage().contains("No key version path configured"), + "Expected 'No key version path configured' message, got: " + ex.getMessage()); + } + + private static String generateEd25519Pem() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("Ed25519"); + KeyPair keyPair = kpg.generateKeyPair(); + byte[] encoded = keyPair.getPublic().getEncoded(); + String base64 = Base64.getMimeEncoder().encodeToString(encoded); + StringBuilder pem = new StringBuilder(); + pem.append("-----BEGIN PUBLIC KEY-----\n"); + int lineLen = 0; + for (int i = 0; i < base64.length(); i++) { + pem.append(base64.charAt(i)); + lineLen++; + if (lineLen == 64 || i == base64.length() - 1) { + pem.append('\n'); + lineLen = 0; + } + } + pem.append("-----END PUBLIC KEY-----\n"); + return pem.toString(); + } + + private static String sha256Hex(byte[] data) throws Exception { + java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); + byte[] hash = md.digest(data); + StringBuilder sb = new StringBuilder(hash.length * 2); + for (byte b : hash) { + sb.append(String.format("%02x", b & 0xFF)); + } + return sb.toString(); + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 281739d..a7bdcb9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -90,7 +90,8 @@ javapoet = { module = "com.palantir.javapoet:javapoet", version.ref = "javapoet" aws-kms = { module = "software.amazon.awssdk:kms", version = "2.46.12" } # GCP KMS (D9 signing track — lazy-init, not on boot critical path) -# gcp-kms = { module = "com.google.cloud:google-cloud-kms", version = "2.57.0" } +gcp-kms = { module = "com.google.cloud:google-cloud-kms", version = "2.57.0" } +gcp-auth = { module = "com.google.auth:google-auth-library-oauth2-http", version = "1.30.0" } # Bouncy Castle FIPS (optional — FIPS environments only, not in core) # bouncycastle-fips = { module = "org.bouncycastle:bc-fips", version = "2.0.0" } From 57fbd1e6e063eec2b6bde0cc68988d56f69b7289 Mon Sep 17 00:00:00 2001 From: Lobsterdog Contributors Date: Wed, 17 Jun 2026 12:10:14 -0600 Subject: [PATCH 08/21] feat(signing): add JWKS VerificationKeyResolver with caching, SSRF validation, and brand.json walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JwkParser: parse Ed25519, EC (P-256/P-384), and RSA JWKs into VerificationKey - StaticJwksResolver: in-memory kid lookup from pre-loaded JWKS - CachingJwksResolver: HTTP-fetching with 30s cooldown, single-flight dedup - BrandJsonJwksResolver: 3-hop resolution (brand.json → agent → jwks_uri) - SsrfJwksUriValidator: HTTPS enforcement, private IP rejection, redirect:manual - JwksDocument: parsed JWKS with per-kid index and lastFetched timestamp - JwksResolutionException: unchecked exception for JWKS fetch failures - SsrfBlockedException: made constructor public for JWKS validator tests --- .../signing/jwks/BrandJsonJwksResolver.java | 344 ++++++++++++++++++ .../signing/jwks/CachingJwksResolver.java | 295 +++++++++++++++ .../adcp/server/signing/jwks/JwkParser.java | 326 +++++++++++++++++ .../server/signing/jwks/JwksDocument.java | 40 ++ .../signing/jwks/JwksResolutionException.java | 31 ++ .../jwks/JwksVerificationKeyResolver.java | 64 ++++ .../signing/jwks/SsrfJwksUriValidator.java | 152 ++++++++ .../signing/jwks/StaticJwksResolver.java | 47 +++ .../server/signing/jwks/package-info.java | 11 + .../signing/jwks/CachingJwksResolverTest.java | 208 +++++++++++ .../server/signing/jwks/JwkParserTest.java | 229 ++++++++++++ .../jwks/SsrfJwksUriValidatorTest.java | 89 +++++ .../signing/jwks/StaticJwksResolverTest.java | 106 ++++++ .../adcp/http/SsrfBlockedException.java | 6 +- 14 files changed, 1945 insertions(+), 3 deletions(-) create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/BrandJsonJwksResolver.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/CachingJwksResolver.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/JwkParser.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/JwksDocument.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/JwksResolutionException.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/JwksVerificationKeyResolver.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/SsrfJwksUriValidator.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/StaticJwksResolver.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/package-info.java create mode 100644 adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/jwks/CachingJwksResolverTest.java create mode 100644 adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/jwks/JwkParserTest.java create mode 100644 adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/jwks/SsrfJwksUriValidatorTest.java create mode 100644 adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/jwks/StaticJwksResolverTest.java diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/BrandJsonJwksResolver.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/BrandJsonJwksResolver.java new file mode 100644 index 0000000..077d8b9 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/BrandJsonJwksResolver.java @@ -0,0 +1,344 @@ +package org.adcontextprotocol.adcp.server.signing.jwks; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.adcontextprotocol.adcp.http.AdcpHttpClient; +import org.adcontextprotocol.adcp.http.SsrfBlockedException; +import org.adcontextprotocol.adcp.signing.AdcpUse; +import org.adcontextprotocol.adcp.signing.VerificationException; +import org.adcontextprotocol.adcp.signing.VerificationInput; +import org.adcontextprotocol.adcp.signing.VerificationKeyLookup; +import org.adcontextprotocol.adcp.signing.VerificationKeyResolver; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Resolves JWKS via brand.json walk: brand.json → agent entry → jwks_uri → JWKS document. + * + *

Implements a 3-hop resolution: + *

    + *
  1. Fetch brand.json from the configured URL
  2. + *
  3. Select the agent entry matching the configured type/id
  4. + *
  5. Fetch the JWKS from the agent's jwks_uri
  6. + *
+ * + *

Caches at each hop level (brand.json cache, JWKS cache). If a kid is not + * found in the cached JWKS, refreshes the inner JWKS first, then brand.json + * (in case jwks_uri changed). + * + *

The brand.json walk ensures the verifier never trusts agent-attested keys + * directly — keys are always sourced through the operator's brand.json. + */ +public final class BrandJsonJwksResolver implements VerificationKeyResolver { + + private static final Duration DEFAULT_COOLDOWN = Duration.ofSeconds(30); + private static final int DEFAULT_MAX_REDIRECTS = 3; + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final Pattern BARE_HOSTNAME_RE = + Pattern.compile("^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$"); + + private final String brandJsonUrl; + private final String agentType; + private final @Nullable String agentId; + private final @Nullable String brandId; + private final AdcpHttpClient httpClient; + private final SsrfJwksUriValidator uriValidator; + private final Duration cooldown; + + private volatile @Nullable BrandJsonSnapshot brandJsonSnapshot; + private volatile @Nullable CachingJwksResolver innerResolver; + private volatile @Nullable String selectedJwksUri; + + /** + * Create a brand.json-backed JWKS resolver. + * + * @param brandJsonUrl the brand.json URL + * @param agentType the agent type to select (e.g. "buying", "creative") + * @param httpClient SSRF-protected HTTP client + */ + public BrandJsonJwksResolver(String brandJsonUrl, String agentType, + AdcpHttpClient httpClient) { + this(brandJsonUrl, agentType, null, null, httpClient, null); + } + + /** + * Create a brand.json-backed JWKS resolver with full configuration. + * + * @param brandJsonUrl the brand.json URL + * @param agentType the agent type to select + * @param agentId optional agent ID to disambiguate multiple agents of the same type + * @param brandId optional brand ID for portfolio brand.json documents + * @param httpClient SSRF-protected HTTP client + * @param cooldown minimum time between re-fetches; null uses 30 seconds + */ + public BrandJsonJwksResolver(String brandJsonUrl, String agentType, + @Nullable String agentId, @Nullable String brandId, + AdcpHttpClient httpClient, @Nullable Duration cooldown) { + this.brandJsonUrl = brandJsonUrl; + this.agentType = agentType; + this.agentId = agentId; + this.brandId = brandId; + this.httpClient = httpClient; + this.uriValidator = new SsrfJwksUriValidator(httpClient.ssrfPolicy(), true); + this.cooldown = cooldown != null ? cooldown : DEFAULT_COOLDOWN; + } + + @Override + public VerificationKeyLookup resolve(VerificationInput input) { + // Ensure brand.json is fetched at least once + if (brandJsonSnapshot == null || innerResolver == null) { + refreshBrandJson(); + } + + CachingJwksResolver resolver = innerResolver; + if (resolver == null) { + return new VerificationKeyLookup.Missing(input.kid()); + } + + VerificationKeyLookup result = resolver.resolve(input); + if (result instanceof VerificationKeyLookup.Found) { + return result; + } + + // Kid not found in cached JWKS. Try refreshing inner JWKS then brand.json. + if (shouldRefreshBrandJson()) { + try { + refreshBrandJson(); + resolver = innerResolver; + if (resolver != null) { + return resolver.resolve(input); + } + } catch (JwksResolutionException e) { + // Keep stale data on transient failure + } + } + + return new VerificationKeyLookup.Missing(input.kid()); + } + + /** + * The JWKS URI selected from brand.json's agents[] for this resolver's + * (agentType, agentId, brandId) tuple. Populated after the first successful + * brand.json fetch; null on cold cache. + */ + public @Nullable String jwksUri() { + return selectedJwksUri; + } + + private boolean shouldRefreshBrandJson() { + BrandJsonSnapshot snap = brandJsonSnapshot; + if (snap == null) return true; + long elapsed = System.nanoTime() - snap.fetchedAtNanos; + return elapsed >= cooldown.toNanos(); + } + + private void refreshBrandJson() { + String url = brandJsonUrl; + + for (int hop = 0; hop <= DEFAULT_MAX_REDIRECTS; hop++) { + try { + uriValidator.validate(URI.create(url)); + } catch (SsrfBlockedException e) { + throw new JwksResolutionException( + "webhook_signature_jwks_untrusted", + "brand.json URL failed SSRF check: " + e.reason(), e); + } + + Map headers = Map.of("Accept", "application/json"); + byte[] body; + int statusCode; + + try { + org.adcontextprotocol.adcp.http.AdcpHttpResponse response = + httpClient.get(URI.create(url), headers); + statusCode = response.statusCode(); + body = response.body(); + } catch (IOException e) { + throw new JwksResolutionException( + "webhook_signature_jwks_unavailable", + "brand.json fetch failed: " + e.getMessage(), e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new JwksResolutionException( + "webhook_signature_jwks_unavailable", + "Interrupted during brand.json fetch", e); + } + + if (statusCode != 200) { + throw new JwksResolutionException( + "webhook_signature_jwks_unavailable", + "brand.json fetch returned HTTP " + statusCode); + } + + JsonNode root; + try { + root = MAPPER.readTree(body); + } catch (IOException e) { + throw new JwksResolutionException( + "webhook_signature_invalid", + "brand.json response is not valid JSON", e); + } + + if (!root.isObject()) { + throw new JwksResolutionException( + "webhook_signature_invalid", + "brand.json response is not an object"); + } + + // Check for redirects: authoritative_location or house + JsonNode authoritative = root.get("authoritative_location"); + if (authoritative != null && authoritative.isTextual()) { + url = canonicalizeUrl(authoritative.asText()); + continue; + } + + JsonNode house = root.get("house"); + if (house != null && house.isTextual()) { + String houseStr = house.asText(); + if (!BARE_HOSTNAME_RE.matcher(houseStr).matches()) { + throw new JwksResolutionException( + "webhook_signature_invalid", + "brand.json 'house' is not a bare hostname"); + } + url = canonicalizeUrl("https://" + houseStr + "/.well-known/brand.json"); + continue; + } + + // Terminal document — select agent and build inner resolver + String jwksUri = selectAgent(root, url); + selectedJwksUri = jwksUri; + brandJsonSnapshot = new BrandJsonSnapshot(url, System.nanoTime()); + + String currentJwksUri = selectedJwksUri; + if (innerResolver == null || !jwksUri.equals(currentJwksUri)) { + innerResolver = new CachingJwksResolver(jwksUri, httpClient, cooldown); + } + return; + } + + throw new JwksResolutionException( + "webhook_signature_invalid", + "brand.json redirect depth exceeded"); + } + + private String selectAgent(JsonNode root, String finalUrl) { + JsonNode agents = findAgents(root); + + if (agents == null || !agents.isArray()) { + throw new JwksResolutionException( + "webhook_signature_invalid", + "brand.json has no agents array"); + } + + JsonNode matched = null; + for (JsonNode agent : agents) { + if (!agent.isObject()) continue; + JsonNode typeNode = agent.get("type"); + if (typeNode == null || !agentType.equals(typeNode.asText())) continue; + if (agentId != null) { + JsonNode idNode = agent.get("id"); + if (idNode == null || !agentId.equals(idNode.asText())) continue; + } + if (matched != null && agentId == null) { + throw new JwksResolutionException( + "webhook_signature_invalid", + "brand.json has multiple agents of type '" + agentType + + "'; pass agentId to disambiguate"); + } + matched = agent; + } + + if (matched == null) { + throw new JwksResolutionException( + "webhook_signature_invalid", + "brand.json has no agent matching type=" + agentType); + } + + JsonNode jwksUriNode = matched.get("jwks_uri"); + if (jwksUriNode != null && jwksUriNode.isTextual()) { + return jwksUriNode.asText(); + } + + // Default: /.well-known/jwks.json + JsonNode urlNode = matched.get("url"); + if (urlNode == null || !urlNode.isTextual()) { + throw new JwksResolutionException( + "webhook_signature_invalid", + "brand.json agent has no url or jwks_uri"); + } + String agentUrl = urlNode.asText(); + return deriveDefaultJwksUri(agentUrl, finalUrl); + } + + private JsonNode findAgents(JsonNode root) { + if (brandId != null) { + JsonNode brands = root.get("brands"); + if (brands != null && brands.isArray()) { + for (JsonNode brand : brands) { + if (brand.isObject() && brandId.equals(brand.get("id").asText())) { + JsonNode agents = brand.get("agents"); + if (agents != null) return agents; + } + } + } + } + + JsonNode house = root.get("house"); + if (house != null && house.isObject()) { + JsonNode agents = house.get("agents"); + if (agents != null) return agents; + } + + return root.get("agents"); + } + + private String deriveDefaultJwksUri(String agentUrl, String finalBrandUrl) { + try { + URI agentUri = URI.create(agentUrl); + URI brandUri = URI.create(finalBrandUrl); + + String agentOrigin = agentUri.getScheme() + "://" + agentUri.getAuthority(); + String brandOrigin = brandUri.getScheme() + "://" + brandUri.getAuthority(); + + if (!agentOrigin.equals(brandOrigin)) { + throw new JwksResolutionException( + "webhook_signature_invalid", + "agent.url origin (" + agentOrigin + + ") does not match brand.json origin (" + brandOrigin + + "); publisher must declare an explicit jwks_uri"); + } + return agentOrigin + "/.well-known/jwks.json"; + } catch (IllegalArgumentException e) { + throw new JwksResolutionException( + "webhook_signature_invalid", + "agent.url is not a valid URL", e); + } + } + + private String canonicalizeUrl(String url) { + URI uri = URI.create(url); + String scheme = uri.getScheme() != null ? uri.getScheme().toLowerCase() : "https"; + String host = uri.getHost() != null ? uri.getHost().toLowerCase() : ""; + int port = uri.getPort(); + String path = uri.getPath() != null ? uri.getPath() : "/"; + String query = uri.getQuery(); + + StringBuilder sb = new StringBuilder(scheme).append("://").append(host); + if (port > 0 && !(("https".equals(scheme) && port == 443) + || ("http".equals(scheme) && port == 80))) { + sb.append(':').append(port); + } + sb.append(path); + if (query != null && !query.isEmpty()) { + sb.append('?').append(query); + } + return sb.toString(); + } + + private record BrandJsonSnapshot(String finalUrl, long fetchedAtNanos) {} +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/CachingJwksResolver.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/CachingJwksResolver.java new file mode 100644 index 0000000..0352e1d --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/CachingJwksResolver.java @@ -0,0 +1,295 @@ +package org.adcontextprotocol.adcp.server.signing.jwks; + +import org.adcontextprotocol.adcp.http.AdcpHttpClient; +import org.adcontextprotocol.adcp.http.SsrfBlockedException; +import org.adcontextprotocol.adcp.http.SsrfPolicy; +import org.adcontextprotocol.adcp.signing.AdcpUse; +import org.adcontextprotocol.adcp.signing.VerificationException; +import org.adcontextprotocol.adcp.signing.VerificationInput; +import org.adcontextprotocol.adcp.signing.VerificationKey; +import org.adcontextprotocol.adcp.signing.VerificationKeyLookup; +import org.adcontextprotocol.adcp.signing.VerificationKeyResolver; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; + +/** + * HTTP-fetching JWKS resolver with caching, cooldown, and single-flight dedup. + * + *

On a kid lookup miss, refreshes the JWKS document if the cooldown period + * has elapsed since the last fetch. If the cooldown has not elapsed, returns + * {@link VerificationKeyLookup.Missing} immediately — this prevents + * attack-driven cache invalidation where an attacker forces the verifier to + * hammer the signer's JWKS endpoint on every rejection. + * + *

Single-flight dedup: if N threads all miss on the same kid, only one + * JWKS fetch happens. + * + *

Stores raw JWK data rather than parsed {@link VerificationKey} objects + * because {@code adcp_use} validation requires the expected use from the + * verification input, which is only available at resolve time. + */ +public final class CachingJwksResolver implements VerificationKeyResolver { + + /** + * Functional interface for JWKS document fetching. Allows test + * implementations to inject mock responses without extending the + * final {@link AdcpHttpClient}. + */ + @FunctionalInterface + public interface JwksFetcher { + /** Fetch a JWKS document from the given URI and return the raw bytes. */ + byte[] fetch(URI uri) throws IOException, InterruptedException; + } + + private static final Duration DEFAULT_COOLDOWN = Duration.ofSeconds(30); + + private final String jwksUri; + private final @Nullable AdcpHttpClient httpClient; + private final @Nullable JwksFetcher fetcher; + private final Duration cooldown; + private final SsrfJwksUriValidator uriValidator; + + private volatile @Nullable JwksDocument cached; + private volatile long lastFetchAttemptNanos; + private final ConcurrentHashMap> inFlightFetches + = new ConcurrentHashMap<>(); + + /** + * Create a caching JWKS resolver with default 30-second cooldown. + * + * @param jwksUri the JWKS endpoint URI + * @param httpClient SSRF-protected HTTP client for fetching JWKS + */ + public CachingJwksResolver(String jwksUri, AdcpHttpClient httpClient) { + this(jwksUri, httpClient, null, null); + } + + /** + * Create a caching JWKS resolver with configurable cooldown. + * + * @param jwksUri the JWKS endpoint URI + * @param httpClient SSRF-protected HTTP client for fetching JWKS + * @param cooldown minimum time between JWKS re-fetches; null uses 30 seconds + */ + public CachingJwksResolver(String jwksUri, AdcpHttpClient httpClient, + @Nullable Duration cooldown) { + this(jwksUri, httpClient, cooldown, null); + } + + /** + * Create a caching JWKS resolver with a custom fetcher (for testing). + * + * @param jwksUri the JWKS endpoint URI + * @param cooldown minimum time between JWKS re-fetches; null uses 30 seconds + * @param fetcher custom fetcher for JWKS documents + */ + public CachingJwksResolver(String jwksUri, Duration cooldown, JwksFetcher fetcher) { + this.jwksUri = Objects.requireNonNull(jwksUri, "jwksUri"); + this.httpClient = null; + this.fetcher = Objects.requireNonNull(fetcher, "fetcher"); + this.cooldown = cooldown != null ? cooldown : DEFAULT_COOLDOWN; + this.uriValidator = new SsrfJwksUriValidator(SsrfPolicy.permissive(), false); + } + + private CachingJwksResolver(String jwksUri, @Nullable AdcpHttpClient httpClient, + @Nullable Duration cooldown, @Nullable JwksFetcher fetcher) { + this.jwksUri = Objects.requireNonNull(jwksUri, "jwksUri"); + this.httpClient = httpClient; + this.fetcher = fetcher; + this.cooldown = cooldown != null ? cooldown : DEFAULT_COOLDOWN; + this.uriValidator = httpClient != null + ? new SsrfJwksUriValidator(httpClient.ssrfPolicy(), true) + : new SsrfJwksUriValidator(SsrfPolicy.permissive(), false); + } + + @Override + public VerificationKeyLookup resolve(VerificationInput input) { + String kid = input.kid(); + AdcpUse expectedUse = input.expectedUse(); + + JwksDocument doc = cached; + if (doc != null) { + VerificationKeyLookup result = lookupWithValidation(doc, kid, expectedUse); + if (result != null) return result; + } + + if (shouldRefresh()) { + doc = refreshWithDedup(); + if (doc != null) { + VerificationKeyLookup result = lookupWithValidation(doc, kid, expectedUse); + if (result != null) return result; + } + } + + return new VerificationKeyLookup.Missing(kid); + } + + private @Nullable VerificationKeyLookup lookupWithValidation( + JwksDocument doc, String kid, AdcpUse expectedUse) { + Map jwk = doc.get(kid); + if (jwk == null) return null; + try { + VerificationKey key = JwkParser.parse(jwk, expectedUse); + return new VerificationKeyLookup.Found(key, null, null); + } catch (VerificationException e) { + throw new JwksResolutionException(e.errorCode(), e.getMessage(), e); + } + } + + /** + * Get the current cached document, or null if not yet fetched. + */ + public @Nullable JwksDocument cachedDocument() { + return cached; + } + + private boolean shouldRefresh() { + JwksDocument doc = cached; + if (doc == null) { + return true; + } + long now = System.nanoTime(); + long elapsedNanos = now - lastFetchAttemptNanos; + return elapsedNanos >= cooldown.toNanos(); + } + + private @Nullable JwksDocument refreshWithDedup() { + CompletableFuture future = new CompletableFuture<>(); + CompletableFuture existing = inFlightFetches.putIfAbsent( + jwksUri, future); + if (existing != null) { + try { + return existing.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new JwksResolutionException( + "webhook_signature_jwks_unavailable", + "Interrupted waiting for JWKS fetch", e); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof JwksResolutionException jre) { + throw jre; + } + throw new JwksResolutionException( + "webhook_signature_jwks_unavailable", + "JWKS fetch failed: " + cause.getMessage(), cause); + } + } + try { + JwksDocument doc = doFetch(); + this.cached = doc; + return doc; + } finally { + inFlightFetches.remove(jwksUri, future); + future.complete(cached); + } + } + + private JwksDocument doFetch() { + lastFetchAttemptNanos = System.nanoTime(); + if (fetcher != null) { + try { + uriValidator.validate(URI.create(jwksUri)); + } catch (SsrfBlockedException e) { + throw new JwksResolutionException( + "webhook_signature_jwks_untrusted", + "JWKS URI failed SSRF check: " + e.reason(), e); + } + byte[] body; + try { + body = fetcher.fetch(URI.create(jwksUri)); + } catch (IOException e) { + throw new JwksResolutionException( + "webhook_signature_jwks_unavailable", + "JWKS fetch failed: " + e.getMessage(), e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new JwksResolutionException( + "webhook_signature_jwks_unavailable", + "Interrupted during JWKS fetch", e); + } + return parseJwksDocument(new String(body, StandardCharsets.UTF_8)); + } + + // Use AdcpHttpClient + try { + uriValidator.validate(URI.create(jwksUri)); + } catch (SsrfBlockedException e) { + throw new JwksResolutionException( + "webhook_signature_jwks_untrusted", + "JWKS URI failed SSRF check: " + e.reason(), e); + } + + Map headers = Map.of("Accept", "application/json"); + byte[] body; + int statusCode; + + try { + org.adcontextprotocol.adcp.http.AdcpHttpResponse response = + httpClient.get(URI.create(jwksUri), headers); + statusCode = response.statusCode(); + body = response.body(); + } catch (IOException e) { + throw new JwksResolutionException( + "webhook_signature_jwks_unavailable", + "JWKS fetch failed: " + e.getMessage(), e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new JwksResolutionException( + "webhook_signature_jwks_unavailable", + "Interrupted during JWKS fetch", e); + } + + if (statusCode != 200) { + throw new JwksResolutionException( + "webhook_signature_jwks_unavailable", + "JWKS fetch returned HTTP " + statusCode); + } + + return parseJwksDocument(new String(body, StandardCharsets.UTF_8)); + } + + @SuppressWarnings("unchecked") + static JwksDocument parseJwksDocument(String json) { + com.fasterxml.jackson.databind.ObjectMapper mapper = + new com.fasterxml.jackson.databind.ObjectMapper(); + Map root; + try { + root = mapper.readValue(json, Map.class); + } catch (IOException e) { + throw new JwksResolutionException( + "webhook_signature_invalid", + "Invalid JWKS JSON", e); + } + + Object keysObj = root.get("keys"); + if (!(keysObj instanceof List keysList)) { + throw new JwksResolutionException( + "webhook_signature_invalid", + "JWKS document has no 'keys' array"); + } + + Map> keyMap = new LinkedHashMap<>(); + for (Object item : keysList) { + if (!(item instanceof Map)) continue; + Map jwk = (Map) item; + Object kidObj = jwk.get("kid"); + if (kidObj == null) continue; + String kid = kidObj.toString(); + keyMap.put(kid, jwk); + } + + return new JwksDocument(keyMap, System.nanoTime()); + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/JwkParser.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/JwkParser.java new file mode 100644 index 0000000..467f9d8 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/JwkParser.java @@ -0,0 +1,326 @@ +package org.adcontextprotocol.adcp.server.signing.jwks; + +import org.adcontextprotocol.adcp.signing.AdcpUse; +import org.adcontextprotocol.adcp.signing.VerificationException; +import org.adcontextprotocol.adcp.signing.VerificationKey; +import org.jspecify.annotations.Nullable; + +import java.math.BigInteger; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.spec.ECParameterSpec; +import java.security.spec.ECPoint; +import java.security.spec.ECPublicKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Parses JWK JSON objects into {@link VerificationKey} instances. + * + *

Supports OKP (Ed25519), EC (P-256, P-384), and RSA key types. + * Validates {@code adcp_use} matches the expected {@link AdcpUse} from + * the verification input, and validates that {@code key_ops} includes + * {@code "verify"} (per test vector 020). + */ +public final class JwkParser { + + private JwkParser() {} + + /** + * Parse a single JWK JSON object into a {@link VerificationKey}. + * + * @param jwk the JWK as a string-keyed map + * @param expectedUse the expected AdCP use, or {@code null} to skip validation + * @return the parsed verification key + * @throws VerificationException if the JWK is invalid or fails validation + */ + public static VerificationKey parse(Map jwk, @Nullable AdcpUse expectedUse) + throws VerificationException { + Objects.requireNonNull(jwk, "jwk"); + + String kid = requireString(jwk, "kid"); + String kty = requireString(jwk, "kty"); + + validateAdcpUse(jwk, expectedUse); + validateKeyOps(jwk); + + return switch (kty) { + case "OKP" -> parseOkp(jwk, kid); + case "EC" -> parseEc(jwk, kid); + case "RSA" -> parseRsa(jwk, kid); + default -> throw new VerificationException( + "webhook_signature_invalid", + "Unsupported JWK kty: " + kty); + }; + } + + private static VerificationKey parseOkp(Map jwk, String kid) + throws VerificationException { + String crv = requireString(jwk, "crv"); + if (!"Ed25519".equals(crv)) { + throw new VerificationException( + "webhook_signature_invalid", + "Unsupported OKP curve: " + crv); + } + String xB64 = requireString(jwk, "x"); + byte[] x = base64urlDecode(xB64); + if (x.length != 32) { + throw new VerificationException( + "webhook_signature_invalid", + "Ed25519 public key must be 32 bytes, got " + x.length); + } + + byte[] der = encodeEd25519Der(x); + return new VerificationKey(kid, "Ed25519", der, "Ed25519"); + } + + private static VerificationKey parseEc(Map jwk, String kid) + throws VerificationException { + String crv = requireString(jwk, "crv"); + String xB64 = requireString(jwk, "x"); + String yB64 = requireString(jwk, "y"); + + String algorithm; + int fieldSizeBits; + + switch (crv) { + case "P-256" -> { + algorithm = "EC"; + fieldSizeBits = 256; + } + case "P-384" -> { + algorithm = "EC"; + fieldSizeBits = 384; + } + default -> throw new VerificationException( + "webhook_signature_invalid", + "Unsupported EC curve: " + crv); + } + + byte[] xBytes = base64urlDecode(xB64); + byte[] yBytes = base64urlDecode(yB64); + + int expectedOctets = fieldSizeBits / 8; + xBytes = padToLength(xBytes, expectedOctets); + yBytes = padToLength(yBytes, expectedOctets); + + byte[] der = encodeEcDer(crv, xBytes, yBytes); + return new VerificationKey(kid, algorithm, der, crv); + } + + private static VerificationKey parseRsa(Map jwk, String kid) + throws VerificationException { + String nB64 = requireString(jwk, "n"); + String eB64 = requireString(jwk, "e"); + + byte[] n = base64urlDecode(nB64); + byte[] e = base64urlDecode(eB64); + + byte[] der = encodeRsaDer(n, e); + return new VerificationKey(kid, "RSA", der, null); + } + + private static void validateAdcpUse(Map jwk, @Nullable AdcpUse expectedUse) + throws VerificationException { + if (expectedUse == null) { + return; + } + Object adcpUseObj = jwk.get("adcp_use"); + if (adcpUseObj == null) { + throw new VerificationException( + "webhook_signature_key_purpose_invalid", + "JWK missing required 'adcp_use' parameter"); + } + String adcpUseStr = adcpUseObj.toString(); + AdcpUse jwkUse; + try { + jwkUse = AdcpUse.fromWireName(adcpUseStr); + } catch (IllegalArgumentException e) { + throw new VerificationException( + "webhook_signature_key_purpose_invalid", + "Unknown adcp_use value: " + adcpUseStr); + } + if (jwkUse != expectedUse) { + throw new VerificationException( + "webhook_signature_key_purpose_invalid", + "JWK adcp_use=" + adcpUseStr + " does not match expected " + + expectedUse.wireName()); + } + } + + @SuppressWarnings("unchecked") + private static void validateKeyOps(Map jwk) throws VerificationException { + Object keyOpsObj = jwk.get("key_ops"); + if (keyOpsObj == null) { + return; + } + if (!(keyOpsObj instanceof List ops)) { + throw new VerificationException( + "webhook_signature_invalid", + "JWK key_ops must be an array"); + } + boolean hasVerify = false; + for (Object op : ops) { + if ("verify".equals(op)) { + hasVerify = true; + break; + } + } + if (!hasVerify) { + throw new VerificationException( + "webhook_signature_key_purpose_invalid", + "JWK key_ops does not include 'verify'"); + } + } + + // -- Base64url decoding -- + + private static byte[] base64urlDecode(String b64) throws VerificationException { + try { + String padded = b64; + int padNeeded = (4 - padded.length() % 4) % 4; + padded += "=".repeat(padNeeded); + return Base64.getUrlDecoder().decode(padded); + } catch (IllegalArgumentException e) { + throw new VerificationException( + "webhook_signature_invalid", + "Invalid base64url encoding", e); + } + } + + // -- DER encoding helpers -- + + private static byte[] encodeEd25519Der(byte[] rawKey) throws VerificationException { + byte[] algorithmIdentifier = hexToBytes("30213006062B6570050100"); + byte[] bitString = wrapBitString(rawKey); + return concat(algorithmIdentifier, bitString); + } + + private static byte[] encodeEcDer(String crv, byte[] x, byte[] y) throws VerificationException { + String oid; + if ("P-256".equals(crv)) { + oid = "06082A8648CE3D030107"; + } else if ("P-384".equals(crv)) { + oid = "06052B81040022"; + } else { + throw new VerificationException( + "webhook_signature_invalid", + "Unsupported EC curve for DER encoding: " + crv); + } + + byte[] ecPoint = concat(new byte[]{0x04}, x, y); + byte[] algId = concat(hexToBytes("3013"), hexToBytes(oid.length() / 2 == 8 + ? "300606" + oid.substring(0, 2) + "0" + oid.substring(3) + : "3007" + oid), new byte[]{0x05, 0x00}); + // Re-encode algorithm identifier properly + byte[] curveOidBytes = hexToBytes(oid); + if ("P-256".equals(crv)) { + algId = concat(hexToBytes("301306072A8648CE3D020106082A8648CE3D030107")); + } else { + algId = concat(hexToBytes("301006072A8648CE3D020106052B81040022")); + } + + byte[] bitString = wrapBitString(ecPoint); + return concat(algId, bitString); + } + + private static byte[] encodeRsaDer(byte[] n, byte[] e) throws VerificationException { + byte[] nEncoded = derEncodeInteger(n); + byte[] eEncoded = derEncodeInteger(e); + byte[] rsaPublicKey = derEncodeSequence(nEncoded, eEncoded); + byte[] algorithmIdentifier = hexToBytes("300D06092A864886F70D0101010500"); + byte[] bitString = wrapBitString(rsaPublicKey); + return concat(algorithmIdentifier, bitString); + } + + private static byte[] wrapBitString(byte[] content) { + byte[] bs = new byte[content.length + 1]; + bs[0] = 0x00; + System.arraycopy(content, 0, bs, 1, content.length); + return derEncode(0x03, bs); + } + + private static byte[] derEncodeInteger(byte[] value) { + if (value.length > 0 && (value[0] & 0x80) != 0) { + byte[] padded = new byte[value.length + 1]; + System.arraycopy(value, 0, padded, 1, value.length); + value = padded; + } + return derEncode(0x02, value); + } + + private static byte[] derEncodeSequence(byte[]... elements) { + byte[] content = concat(elements); + return derEncode(0x30, content); + } + + private static byte[] derEncode(int tag, byte[] content) { + int length = content.length; + byte[] lengthBytes; + if (length < 128) { + lengthBytes = new byte[]{(byte) length}; + } else if (length < 256) { + lengthBytes = new byte[]{(byte) 0x81, (byte) length}; + } else { + lengthBytes = new byte[]{(byte) 0x82, (byte) (length >> 8), (byte) (length & 0xFF)}; + } + byte[] result = new byte[1 + lengthBytes.length + content.length]; + result[0] = (byte) tag; + System.arraycopy(lengthBytes, 0, result, 1, lengthBytes.length); + System.arraycopy(content, 0, result, 1 + lengthBytes.length, content.length); + return result; + } + + private static byte[] padToLength(byte[] data, int targetLength) { + if (data.length == targetLength) { + return data; + } + if (data.length > targetLength) { + byte[] result = new byte[targetLength]; + System.arraycopy(data, data.length - targetLength, result, 0, targetLength); + return result; + } + byte[] result = new byte[targetLength]; + System.arraycopy(data, 0, result, targetLength - data.length, data.length); + return result; + } + + private static String requireString(Map jwk, String field) + throws VerificationException { + Object value = jwk.get(field); + if (value == null) { + throw new VerificationException( + "webhook_signature_invalid", + "JWK missing required field: " + field); + } + if (!(value instanceof String str)) { + throw new VerificationException( + "webhook_signature_invalid", + "JWK field '" + field + "' must be a string"); + } + return str; + } + + private static byte[] hexToBytes(String hex) { + byte[] result = new byte[hex.length() / 2]; + for (int i = 0; i < result.length; i++) { + result[i] = (byte) Integer.parseInt(hex.substring(2 * i, 2 * i + 2), 16); + } + return result; + } + + private static byte[] concat(byte[]... arrays) { + int total = 0; + for (byte[] a : arrays) total += a.length; + byte[] result = new byte[total]; + int offset = 0; + for (byte[] a : arrays) { + System.arraycopy(a, 0, result, offset, a.length); + offset += a.length; + } + return result; + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/JwksDocument.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/JwksDocument.java new file mode 100644 index 0000000..d603bb6 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/JwksDocument.java @@ -0,0 +1,40 @@ +package org.adcontextprotocol.adcp.server.signing.jwks; + +import org.jspecify.annotations.Nullable; + +import java.util.Collections; +import java.util.Map; + +/** + * A parsed JWKS document: raw JWK objects indexed by kid, with a fetch + * timestamp for cooldown calculation. + * + *

Stores raw JWK data rather than parsed {@link org.adcontextprotocol.adcp.signing.VerificationKey} + * objects because {@code adcp_use} validation requires the expected use from + * the verification input, which is only available at resolve time. + * + * @param jwksByKeyId raw JWK objects indexed by kid + * @param lastFetched monotonic timestamp (nanos) of the last successful fetch + */ +public record JwksDocument(Map> jwksByKeyId, long lastFetched) { + + public JwksDocument { + jwksByKeyId = Collections.unmodifiableMap(jwksByKeyId); + } + + /** + * Look up a raw JWK by kid. + * + * @return the JWK map, or {@code null} if not found + */ + public @Nullable Map get(String kid) { + return jwksByKeyId.get(kid); + } + + /** + * Check if the document contains a key with the given kid. + */ + public boolean containsKid(String kid) { + return jwksByKeyId.containsKey(kid); + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/JwksResolutionException.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/JwksResolutionException.java new file mode 100644 index 0000000..9404622 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/JwksResolutionException.java @@ -0,0 +1,31 @@ +package org.adcontextprotocol.adcp.server.signing.jwks; + +/** + * Unchecked exception for JWKS resolution failures. + * + *

Wraps the root cause when JWKS fetching or parsing fails. + * This allows the {@link VerificationKeyResolver} SPI to remain + * unchecked-exception-clean while still surfacing errors to callers. + */ +public final class JwksResolutionException extends RuntimeException { + + @java.io.Serial + private static final long serialVersionUID = 1L; + + private final String errorCode; + + public JwksResolutionException(String errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + public JwksResolutionException(String errorCode, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + } + + /** Error code matching the webhook signature error taxonomy. */ + public String errorCode() { + return errorCode; + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/JwksVerificationKeyResolver.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/JwksVerificationKeyResolver.java new file mode 100644 index 0000000..9233937 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/JwksVerificationKeyResolver.java @@ -0,0 +1,64 @@ +package org.adcontextprotocol.adcp.server.signing.jwks; + +import org.adcontextprotocol.adcp.http.AdcpHttpClient; +import org.adcontextprotocol.adcp.signing.VerificationException; +import org.adcontextprotocol.adcp.signing.VerificationInput; +import org.adcontextprotocol.adcp.signing.VerificationKeyLookup; +import org.adcontextprotocol.adcp.signing.VerificationKeyResolver; +import org.jspecify.annotations.Nullable; + +import java.time.Duration; + +/** + * Main JWKS verification key resolver. Implements {@link VerificationKeyResolver} + * by fetching keys from a JWKS endpoint with caching and cooldown. + * + *

On {@link #resolve(VerificationInput)}: + *

    + *
  1. Look up the kid from the cached JWKS document
  2. + *
  3. If found, return {@link VerificationKeyLookup.Found} with a + * {@link org.adcontextprotocol.adcp.signing.VerificationKey} built from the JWK
  4. + *
  5. If not found and cooldown has elapsed, re-fetch the JWKS and try again
  6. + *
  7. If still not found, return {@link VerificationKeyLookup.Missing}
  8. + *
+ * + *

Delegates caching and network logic to {@link CachingJwksResolver}. + */ +public final class JwksVerificationKeyResolver implements VerificationKeyResolver { + + private final CachingJwksResolver delegate; + + /** + * Create a resolver with default 30-second cooldown. + * + * @param jwksUri the JWKS endpoint URI + * @param httpClient SSRF-protected HTTP client for fetching JWKS + */ + public JwksVerificationKeyResolver(String jwksUri, AdcpHttpClient httpClient) { + this(jwksUri, httpClient, null); + } + + /** + * Create a resolver with configurable cooldown. + * + * @param jwksUri the JWKS endpoint URI + * @param httpClient SSRF-protected HTTP client for fetching JWKS + * @param cooldown minimum time between JWKS re-fetches; null uses 30 seconds + */ + public JwksVerificationKeyResolver(String jwksUri, AdcpHttpClient httpClient, + @Nullable Duration cooldown) { + this.delegate = new CachingJwksResolver(jwksUri, httpClient, cooldown); + } + + /** + * Expose the underlying CachingJwksResolver for testing. + */ + CachingJwksResolver cachingResolver() { + return delegate; + } + + @Override + public VerificationKeyLookup resolve(VerificationInput input) { + return delegate.resolve(input); + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/SsrfJwksUriValidator.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/SsrfJwksUriValidator.java new file mode 100644 index 0000000..0a51e69 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/SsrfJwksUriValidator.java @@ -0,0 +1,152 @@ +package org.adcontextprotocol.adcp.server.signing.jwks; + +import org.adcontextprotocol.adcp.http.SsrfBlockedException; +import org.adcontextprotocol.adcp.http.SsrfPolicy; +import org.jspecify.annotations.Nullable; + +import java.net.InetAddress; +import java.net.URI; +import java.net.UnknownHostException; + +/** + * Validates JWKS URIs before fetching. Rejects private IPs, link-local, + * multicast, reserved ranges, and cloud metadata endpoints. Uses the + * existing {@link SsrfPolicy} from {@code adcp.http}. + * + *

Mirrors the Python SDK's {@code validate_jwks_uri()}. + */ +public final class SsrfJwksUriValidator { + + private final SsrfPolicy ssrfPolicy; + private final boolean requireHttps; + + /** + * Create a validator with strict SSRF policy and HTTPS required. + */ + public SsrfJwksUriValidator() { + this(SsrfPolicy.strict(), true); + } + + /** + * Create a validator with the given SSRF policy. + * + * @param ssrfPolicy the SSRF policy to use for address validation + * @param requireHttps whether to reject non-HTTPS URIs in production + */ + public SsrfJwksUriValidator(SsrfPolicy ssrfPolicy, boolean requireHttps) { + this.ssrfPolicy = ssrfPolicy; + this.requireHttps = requireHttps; + } + + /** + * Validate a JWKS URI, throwing {@link SsrfBlockedException} if the URI + * resolves to a blocked address range. + * + * @param uri the JWKS URI to validate + * @throws SsrfBlockedException if the URI is blocked by the SSRF policy + * @throws IllegalArgumentException if the URI is malformed + */ + public void validate(URI uri) { + String scheme = uri.getScheme(); + if (scheme == null) { + throw new IllegalArgumentException("URI has no scheme: " + uri); + } + + String schemeLower = scheme.toLowerCase(); + if (requireHttps && !"https".equals(schemeLower)) { + String host = uri.getHost(); + if (!isLoopback(host)) { + throw new SsrfBlockedException( + uri.getHost() != null ? uri.getHost() : "unknown", + "JWKS URI must use HTTPS in production: " + schemeLower); + } + } + + if (!"http".equals(schemeLower) && !"https".equals(schemeLower)) { + throw new IllegalArgumentException( + "Unsupported URI scheme for JWKS fetch: " + schemeLower); + } + + String host = uri.getHost(); + if (host == null || host.isEmpty()) { + throw new IllegalArgumentException("URI has no host: " + uri); + } + + // Reject explicit redirect-following in the URI. + // AdcpHttpClient is already configured with followRedirects=NEVER, + // but we also check here as defense in depth. + // No action needed — redirect following is handled at the HTTP client level. + + try { + InetAddress[] addresses = InetAddress.getAllByName(host); + if (addresses.length == 0) { + throw new SsrfBlockedException(host, "No addresses resolved for: " + host); + } + + for (InetAddress addr : addresses) { + var decision = ssrfPolicy.evaluate(addr); + if (decision instanceof org.adcontextprotocol.adcp.http.SsrfDecision.Deny deny) { + throw new SsrfBlockedException(host, deny.reason()); + } + } + } catch (UnknownHostException e) { + throw new SsrfBlockedException(host, "Cannot resolve host: " + host); + } + + // Check for blocked cloud metadata IPs + String hostAddress = host; + if (isIpLiteral(host)) { + checkCloudMetadataIps(host); + } else { + try { + for (InetAddress addr : InetAddress.getAllByName(host)) { + String ip = addr.getHostAddress(); + checkCloudMetadataIp(ip); + } + } catch (UnknownHostException e) { + throw new SsrfBlockedException(host, "Cannot resolve host: " + host); + } + } + } + + private void checkCloudMetadataIps(String ip) { + checkCloudMetadataIp(ip); + } + + private void checkCloudMetadataIp(String ip) { + // AWS, Azure, GCP, DigitalOcean, Alibaba cloud metadata + if ("169.254.169.254".equals(ip)) { + throw new SsrfBlockedException(ip, "cloud metadata IP blocked"); + } + // AWS IPv6 + if ("fd00:ec2::254".equals(ip)) { + throw new SsrfBlockedException(ip, "cloud metadata IP blocked"); + } + // Alibaba + if ("100.100.100.200".equals(ip)) { + throw new SsrfBlockedException(ip, "cloud metadata IP blocked"); + } + // Oracle Cloud + if ("192.0.0.192".equals(ip)) { + throw new SsrfBlockedException(ip, "cloud metadata IP blocked"); + } + } + + private static boolean isLoopback(@Nullable String host) { + if (host == null) return false; + return "localhost".equalsIgnoreCase(host) + || "127.0.0.1".equals(host) + || "::1".equals(host) + || "[::1]".equals(host); + } + + private static boolean isIpLiteral(String host) { + if (host.startsWith("[")) return true; + if (host.indexOf('.') < 0) return false; + for (int i = 0; i < host.length(); i++) { + char c = host.charAt(i); + if (c != '.' && (c < '0' || c > '9')) return false; + } + return true; + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/StaticJwksResolver.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/StaticJwksResolver.java new file mode 100644 index 0000000..6482046 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/StaticJwksResolver.java @@ -0,0 +1,47 @@ +package org.adcontextprotocol.adcp.server.signing.jwks; + +import org.adcontextprotocol.adcp.signing.AdcpUse; +import org.adcontextprotocol.adcp.signing.VerificationException; +import org.adcontextprotocol.adcp.signing.VerificationInput; +import org.adcontextprotocol.adcp.signing.VerificationKey; +import org.adcontextprotocol.adcp.signing.VerificationKeyLookup; +import org.adcontextprotocol.adcp.signing.VerificationKeyResolver; +import org.jspecify.annotations.Nullable; + +import java.util.Map; +import java.util.Objects; + +/** + * In-memory resolver from a pre-loaded JWKS document. Useful for testing + * and for known seller keys. + * + *

No cooldown, no network fetches. Direct lookup. + */ +public final class StaticJwksResolver implements VerificationKeyResolver { + + private final JwksDocument document; + + /** + * Create a static resolver from a kid-to-JWK map. + * + * @param jwksByKeyId kid to raw JWK mapping + */ + public StaticJwksResolver(Map> jwksByKeyId) { + this.document = new JwksDocument(Objects.requireNonNull(jwksByKeyId, "jwksByKeyId"), + System.nanoTime()); + } + + @Override + public VerificationKeyLookup resolve(VerificationInput input) { + Map jwk = document.get(input.kid()); + if (jwk == null) { + return new VerificationKeyLookup.Missing(input.kid()); + } + try { + VerificationKey key = JwkParser.parse(jwk, input.expectedUse()); + return new VerificationKeyLookup.Found(key, null, null); + } catch (VerificationException e) { + throw new JwksResolutionException(e.errorCode(), e.getMessage(), e); + } + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/package-info.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/package-info.java new file mode 100644 index 0000000..b88b765 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jwks/package-info.java @@ -0,0 +1,11 @@ +/** + * JWKS-based verification key resolvers for inbound AdCP signature verification. + * + *

Provides HTTP-fetched, cached, and static JWKS resolution with SSRF protection, + * single-flight deduplication, cooldown-gated refresh, and AdCP-specific key + * validation (adcp_use, key_ops). + * + * @see org.adcontextprotocol.adcp.signing.VerificationKeyResolver + */ +@org.jspecify.annotations.NullMarked +package org.adcontextprotocol.adcp.server.signing.jwks; \ No newline at end of file diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/jwks/CachingJwksResolverTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/jwks/CachingJwksResolverTest.java new file mode 100644 index 0000000..2645ff6 --- /dev/null +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/jwks/CachingJwksResolverTest.java @@ -0,0 +1,208 @@ +package org.adcontextprotocol.adcp.server.signing.jwks; + +import org.adcontextprotocol.adcp.signing.AdcpUse; +import org.adcontextprotocol.adcp.signing.SignedInput; +import org.adcontextprotocol.adcp.signing.VerificationInput; +import org.adcontextprotocol.adcp.signing.VerificationKeyLookup; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class CachingJwksResolverTest { + + private static final String JWKS_JSON = """ + { + "keys": [ + { + "kid": "test-ed25519-webhook-2026", + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "use": "sig", + "key_ops": ["verify"], + "adcp_use": "adcp_whk", + "x": "y7tTfeqazsFeTn3ccCzQlcJ4qFWuYsu-JkJAcfc9VoA" + }, + { + "kid": "test-es256-webhook-2026", + "kty": "EC", + "crv": "P-256", + "alg": "ES256", + "use": "sig", + "key_ops": ["verify"], + "adcp_use": "adcp_whk", + "x": "0X7G_jryFpiX9XO3CKxIqUQs3DC8OhUkw6Rb5QOZd5M", + "y": "MwZN7qQJzLpTD5dyDJAoqOLZJ9r8-GCh4BnOYu6NE0c" + } + ] + } + """; + + private static final String JWKS_JSON_UPDATED = """ + { + "keys": [ + { + "kid": "test-ed25519-webhook-2026", + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "use": "sig", + "key_ops": ["verify"], + "adcp_use": "adcp_whk", + "x": "y7tTfeqazsFeTn3ccCzQlcJ4qFWuYsu-JkJAcfc9VoA" + }, + { + "kid": "new-key-2027", + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "use": "sig", + "key_ops": ["verify"], + "adcp_use": "adcp_whk", + "x": "VgpQd9JRrBf433BcMw6IUNW7tHnAAHAHegsQ5U9I53c" + } + ] + } + """; + + private VerificationInput input(String kid) { + return new VerificationInput( + AdcpUse.WEBHOOK_SIGNING, + kid, + new SignedInput("{}".getBytes(StandardCharsets.UTF_8), Map.of(), "POST", "/") + ); + } + + @Test + void resolvesKnownKidFromFetchedJwks() { + AtomicInteger fetchCount = new AtomicInteger(0); + byte[] responseBody = JWKS_JSON.getBytes(StandardCharsets.UTF_8); + CachingJwksResolver resolver = new CachingJwksResolver( + "https://example.com/.well-known/jwks.json", + Duration.ofHours(1), + uri -> { + fetchCount.incrementAndGet(); + return responseBody; + }); + + VerificationKeyLookup result = resolver.resolve(input("test-ed25519-webhook-2026")); + assertInstanceOf(VerificationKeyLookup.Found.class, result); + VerificationKeyLookup.Found found = (VerificationKeyLookup.Found) result; + assertEquals("test-ed25519-webhook-2026", found.key().kid()); + assertEquals("Ed25519", found.key().algorithm()); + } + + @Test + void returnsMissingForUnknownKid() { + AtomicInteger fetchCount = new AtomicInteger(0); + byte[] responseBody = JWKS_JSON.getBytes(StandardCharsets.UTF_8); + CachingJwksResolver resolver = new CachingJwksResolver( + "https://example.com/.well-known/jwks.json", + Duration.ofHours(1), + uri -> { + fetchCount.incrementAndGet(); + return responseBody; + }); + + VerificationKeyLookup result = resolver.resolve(input("unknown-kid")); + assertInstanceOf(VerificationKeyLookup.Missing.class, result); + } + + @Test + void cachesJwksDocument() { + AtomicInteger fetchCount = new AtomicInteger(0); + byte[] responseBody = JWKS_JSON.getBytes(StandardCharsets.UTF_8); + CachingJwksResolver resolver = new CachingJwksResolver( + "https://example.com/.well-known/jwks.json", + Duration.ofHours(1), + uri -> { + fetchCount.incrementAndGet(); + return responseBody; + }); + + resolver.resolve(input("test-ed25519-webhook-2026")); + resolver.resolve(input("test-es256-webhook-2026")); + + assertEquals(1, fetchCount.get()); + } + + @Test + void respectsCooldownDoesNotRefetchWithinWindow() { + AtomicInteger fetchCount = new AtomicInteger(0); + byte[] responseBody = JWKS_JSON.getBytes(StandardCharsets.UTF_8); + CachingJwksResolver resolver = new CachingJwksResolver( + "https://example.com/.well-known/jwks.json", + Duration.ofHours(1), + uri -> { + fetchCount.incrementAndGet(); + return responseBody; + }); + + resolver.resolve(input("test-ed25519-webhook-2026")); + assertEquals(1, fetchCount.get()); + + VerificationKeyLookup result = resolver.resolve(input("unknown-kid")); + assertInstanceOf(VerificationKeyLookup.Missing.class, result); + assertEquals(1, fetchCount.get()); + } + + @Test + void refetchesAfterCooldownElapses() throws Exception { + AtomicInteger fetchCount = new AtomicInteger(0); + byte[][] responses = {JWKS_JSON.getBytes(StandardCharsets.UTF_8), JWKS_JSON_UPDATED.getBytes(StandardCharsets.UTF_8)}; + CachingJwksResolver resolver = new CachingJwksResolver( + "https://example.com/.well-known/jwks.json", + Duration.ofNanos(1), + uri -> { + int idx = Math.min(fetchCount.getAndIncrement(), responses.length - 1); + return responses[idx]; + }); + + resolver.resolve(input("test-ed25519-webhook-2026")); + assertEquals(1, fetchCount.get()); + + Thread.sleep(1); + + VerificationKeyLookup result = resolver.resolve(input("new-key-2027")); + assertInstanceOf(VerificationKeyLookup.Found.class, result); + assertTrue(fetchCount.get() >= 2); + } + + @Test + void rejectsAdcpUseMismatch() { + String jwksJson = """ + { + "keys": [ + { + "kid": "test-wrong-purpose", + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "use": "sig", + "key_ops": ["verify"], + "adcp_use": "adcp_req", + "x": "y7tTfeqazsFeTn3ccCzQlcJ4qFWuYsu-JkJAcfc9VoA" + } + ] + } + """; + AtomicInteger fetchCount = new AtomicInteger(0); + CachingJwksResolver resolver = new CachingJwksResolver( + "https://example.com/.well-known/jwks.json", + Duration.ofHours(1), + uri -> { + fetchCount.incrementAndGet(); + return jwksJson.getBytes(StandardCharsets.UTF_8); + }); + + JwksResolutionException ex = assertThrows(JwksResolutionException.class, + () -> resolver.resolve(input("test-wrong-purpose"))); + assertEquals("webhook_signature_key_purpose_invalid", ex.errorCode()); + } +} \ No newline at end of file diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/jwks/JwkParserTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/jwks/JwkParserTest.java new file mode 100644 index 0000000..69bd3b4 --- /dev/null +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/jwks/JwkParserTest.java @@ -0,0 +1,229 @@ +package org.adcontextprotocol.adcp.server.signing.jwks; + +import org.adcontextprotocol.adcp.signing.AdcpUse; +import org.adcontextprotocol.adcp.signing.VerificationException; +import org.adcontextprotocol.adcp.signing.VerificationKey; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class JwkParserTest { + + @Test + void parseEd25519Jwk() throws Exception { + Map jwk = new LinkedHashMap<>(); + jwk.put("kid", "test-ed25519"); + jwk.put("kty", "OKP"); + jwk.put("crv", "Ed25519"); + jwk.put("alg", "EdDSA"); + jwk.put("use", "sig"); + jwk.put("key_ops", List.of("verify")); + jwk.put("adcp_use", "adcp_whk"); + jwk.put("x", "y7tTfeqazsFeTn3ccCzQlcJ4qFWuYsu-JkJAcfc9VoA"); + + VerificationKey key = JwkParser.parse(jwk, AdcpUse.WEBHOOK_SIGNING); + + assertEquals("test-ed25519", key.kid()); + assertEquals("Ed25519", key.algorithm()); + assertEquals("Ed25519", key.crv()); + assertNotNull(key.publicKeyBytes()); + assertEquals(32 + 12 + 2, key.publicKeyBytes().length); // raw key + SPKI overhead + } + + @Test + void parseEs256Jwk() throws Exception { + Map jwk = new LinkedHashMap<>(); + jwk.put("kid", "test-es256"); + jwk.put("kty", "EC"); + jwk.put("crv", "P-256"); + jwk.put("alg", "ES256"); + jwk.put("use", "sig"); + jwk.put("key_ops", List.of("verify")); + jwk.put("adcp_use", "adcp_whk"); + jwk.put("x", "0X7G_jryFpiX9XO3CKxIqUQs3DC8OhUkw6Rb5QOZd5M"); + jwk.put("y", "MwZN7qQJzLpTD5dyDJAoqOLZJ9r8-GCh4BnOYu6NE0c"); + + VerificationKey key = JwkParser.parse(jwk, AdcpUse.WEBHOOK_SIGNING); + + assertEquals("test-es256", key.kid()); + assertEquals("EC", key.algorithm()); + assertEquals("P-256", key.crv()); + assertNotNull(key.publicKeyBytes()); + assertTrue(key.publicKeyBytes().length > 64, "EC public key DER should be larger than raw coordinates"); + } + + @Test + void parseEs384Jwk() throws Exception { + Map jwk = new LinkedHashMap<>(); + jwk.put("kid", "test-es384"); + jwk.put("kty", "EC"); + jwk.put("crv", "P-384"); + jwk.put("x", "4KWtfAj7L6N1FR3Nk9gr8P2LmN6zW5O0YXZ2vH1i5q3w"); + jwk.put("y", "q2R5fN7k8m3Lp4Vv9W0yXz1hA6bC3cE8fD2gJ7iK4s6"); + jwk.put("use", "sig"); + jwk.put("key_ops", List.of("verify")); + jwk.put("adcp_use", "adcp_whk"); + + VerificationKey key = JwkParser.parse(jwk, AdcpUse.WEBHOOK_SIGNING); + assertEquals("test-es384", key.kid()); + assertEquals("EC", key.algorithm()); + assertEquals("P-384", key.crv()); + } + + @Test + void parseRsaJwk() throws Exception { + Map jwk = new LinkedHashMap<>(); + jwk.put("kid", "test-rsa"); + jwk.put("kty", "RSA"); + jwk.put("alg", "RS256"); + jwk.put("use", "sig"); + jwk.put("key_ops", List.of("verify")); + jwk.put("adcp_use", "adcp_whk"); + jwk.put("n", "vqyCtx9k5J8Y8XNSEl0d3eXxEsT5FK0JfL7mVb0X2qS3kH1vJ9tY8Z5pA6sD4fB2gC7iK0mN3oL8wR1uP5vQ3xS6yT9kL2jH4fN7pA0bM6cE1dK8qF3sV5wY2zT9gR7iL4oP6xA0mC3vS8kN1fW5yB2qE7tR4j"); + jwk.put("e", "AQAB"); + + VerificationKey key = JwkParser.parse(jwk, AdcpUse.WEBHOOK_SIGNING); + assertEquals("test-rsa", key.kid()); + assertEquals("RSA", key.algorithm()); + assertNull(key.crv()); + } + + @Test + void adcpUseMismatchThrowsException() { + Map jwk = new LinkedHashMap<>(); + jwk.put("kid", "test-wrong-purpose"); + jwk.put("kty", "OKP"); + jwk.put("crv", "Ed25519"); + jwk.put("x", "y7tTfeqazsFeTn3ccCzQlcJ4qFWuYsu-JkJAcfc9VoA"); + jwk.put("adcp_use", "adcp_req"); + jwk.put("key_ops", List.of("verify")); + + VerificationException ex = assertThrows(VerificationException.class, + () -> JwkParser.parse(jwk, AdcpUse.WEBHOOK_SIGNING)); + assertEquals("webhook_signature_key_purpose_invalid", ex.errorCode()); + assertTrue(ex.getMessage().contains("adcp_req")); + } + + @Test + void adcpUseMatchesExpectedUse() throws Exception { + Map jwk = new LinkedHashMap<>(); + jwk.put("kid", "test-req-signing"); + jwk.put("kty", "OKP"); + jwk.put("crv", "Ed25519"); + jwk.put("x", "y7tTfeqazsFeTn3ccCzQlcJ4qFWuYsu-JkJAcfc9VoA"); + jwk.put("adcp_use", "adcp_req"); + jwk.put("key_ops", List.of("verify")); + + VerificationKey key = JwkParser.parse(jwk, AdcpUse.REQUEST_SIGNING); + assertEquals("test-req-signing", key.kid()); + } + + @Test + void missingAdcpUseWhenExpectedThrowsException() { + Map jwk = new LinkedHashMap<>(); + jwk.put("kid", "test-no-adcp-use"); + jwk.put("kty", "OKP"); + jwk.put("crv", "Ed25519"); + jwk.put("x", "y7tTfeqazsFeTn3ccCzQlcJ4qFWuYsu-JkJAcfc9VoA"); + + VerificationException ex = assertThrows(VerificationException.class, + () -> JwkParser.parse(jwk, AdcpUse.WEBHOOK_SIGNING)); + assertEquals("webhook_signature_key_purpose_invalid", ex.errorCode()); + } + + @Test + void nullExpectedUseSkipsAdcpUseValidation() throws Exception { + Map jwk = new LinkedHashMap<>(); + jwk.put("kid", "test-no-validation"); + jwk.put("kty", "OKP"); + jwk.put("crv", "Ed25519"); + jwk.put("x", "y7tTfeqazsFeTn3ccCzQlcJ4qFWuYsu-JkJAcfc9VoA"); + + VerificationKey key = JwkParser.parse(jwk, null); + assertEquals("test-no-validation", key.kid()); + } + + @Test + void keyOpsWithoutVerifyThrowsException() { + Map jwk = new LinkedHashMap<>(); + jwk.put("kid", "test-no-verify"); + jwk.put("kty", "OKP"); + jwk.put("crv", "Ed25519"); + jwk.put("x", "y7tTfeqazsFeTn3ccCzQlcJ4qFWuYsu-JkJAcfc9VoA"); + jwk.put("key_ops", List.of("encrypt")); + + VerificationException ex = assertThrows(VerificationException.class, + () -> JwkParser.parse(jwk, null)); + assertEquals("webhook_signature_key_purpose_invalid", ex.errorCode()); + assertTrue(ex.getMessage().contains("verify")); + } + + @Test + void keyOpsWithVerifyPasses() throws Exception { + Map jwk = new LinkedHashMap<>(); + jwk.put("kid", "test-with-verify"); + jwk.put("kty", "OKP"); + jwk.put("crv", "Ed25519"); + jwk.put("x", "y7tTfeqazsFeTn3ccCzQlcJ4qFWuYsu-JkJAcfc9VoA"); + jwk.put("key_ops", List.of("sign", "verify")); + + VerificationKey key = JwkParser.parse(jwk, null); + assertEquals("test-with-verify", key.kid()); + } + + @Test + void missingKidThrowsException() { + Map jwk = new LinkedHashMap<>(); + jwk.put("kty", "OKP"); + jwk.put("crv", "Ed25519"); + jwk.put("x", "y7tTfeqazsFeTn3ccCzQlcJ4qFWuYsu-JkJAcfc9VoA"); + + VerificationException ex = assertThrows(VerificationException.class, + () -> JwkParser.parse(jwk, null)); + assertEquals("webhook_signature_invalid", ex.errorCode()); + assertTrue(ex.getMessage().contains("kid")); + } + + @Test + void missingKtyThrowsException() { + Map jwk = new LinkedHashMap<>(); + jwk.put("kid", "test-missing-kty"); + jwk.put("x", "y7tTfeqazsFeTn3ccCzQlcJ4qFWuYsu-JkJAcfc9VoA"); + + VerificationException ex = assertThrows(VerificationException.class, + () -> JwkParser.parse(jwk, null)); + assertEquals("webhook_signature_invalid", ex.errorCode()); + assertTrue(ex.getMessage().contains("kty")); + } + + @Test + void unsupportedKtyThrowsException() { + Map jwk = new LinkedHashMap<>(); + jwk.put("kid", "test-unsupported-kty"); + jwk.put("kty", "oct"); + jwk.put("k", "c2VjcmV0"); + + VerificationException ex = assertThrows(VerificationException.class, + () -> JwkParser.parse(jwk, null)); + assertEquals("webhook_signature_invalid", ex.errorCode()); + assertTrue(ex.getMessage().contains("oct")); + } + + @Test + void unsupportedOkpCurveThrowsException() { + Map jwk = new LinkedHashMap<>(); + jwk.put("kid", "test-unsupported-curve"); + jwk.put("kty", "OKP"); + jwk.put("crv", "X25519"); + jwk.put("x", "y7tTfeqazsFeTn3ccCzQlcJ4qFWuYsu-JkJAcfc9VoA"); + + VerificationException ex = assertThrows(VerificationException.class, + () -> JwkParser.parse(jwk, null)); + assertEquals("webhook_signature_invalid", ex.errorCode()); + assertTrue(ex.getMessage().contains("X25519")); + } +} \ No newline at end of file diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/jwks/SsrfJwksUriValidatorTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/jwks/SsrfJwksUriValidatorTest.java new file mode 100644 index 0000000..22792b7 --- /dev/null +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/jwks/SsrfJwksUriValidatorTest.java @@ -0,0 +1,89 @@ +package org.adcontextprotocol.adcp.server.signing.jwks; + +import org.adcontextprotocol.adcp.http.SsrfBlockedException; +import org.adcontextprotocol.adcp.http.SsrfPolicy; +import org.junit.jupiter.api.Test; + +import java.net.URI; + +import static org.junit.jupiter.api.Assertions.*; + +class SsrfJwksUriValidatorTest { + + @Test + void allowsPublicHttpsUri() { + SsrfJwksUriValidator validator = new SsrfJwksUriValidator(SsrfPolicy.strict(), true); + assertDoesNotThrow(() -> validator.validate( + URI.create("https://example.com/.well-known/jwks.json"))); + } + + @Test + void rejectsPrivateIp() { + SsrfJwksUriValidator validator = new SsrfJwksUriValidator(SsrfPolicy.strict(), true); + assertThrows(SsrfBlockedException.class, () -> validator.validate( + URI.create("https://192.168.1.1/.well-known/jwks.json"))); + } + + @Test + void rejectsLocalhost() { + SsrfJwksUriValidator validator = new SsrfJwksUriValidator(SsrfPolicy.strict(), true); + assertThrows(SsrfBlockedException.class, () -> validator.validate( + URI.create("https://127.0.0.1/.well-known/jwks.json"))); + } + + @Test + void rejectsLinkLocal() { + SsrfJwksUriValidator validator = new SsrfJwksUriValidator(SsrfPolicy.strict(), true); + assertThrows(SsrfBlockedException.class, () -> validator.validate( + URI.create("https://169.254.169.254/.well-known/jwks.json"))); + } + + @Test + void rejectsCloudMetadata() { + SsrfJwksUriValidator validator = new SsrfJwksUriValidator(SsrfPolicy.strict(), true); + assertThrows(SsrfBlockedException.class, () -> validator.validate( + URI.create("http://169.254.169.254/latest/meta-data/"))); + } + + @Test + void rejectsAlibabaCloudMetadata() { + SsrfJwksUriValidator validator = new SsrfJwksUriValidator(SsrfPolicy.strict(), true); + assertThrows(SsrfBlockedException.class, () -> validator.validate( + URI.create("http://100.100.100.200/latest/meta-data/"))); + } + + @Test + void rejectsNonHttpsInProduction() { + SsrfJwksUriValidator validator = new SsrfJwksUriValidator(SsrfPolicy.strict(), true); + assertThrows(SsrfBlockedException.class, () -> validator.validate( + URI.create("http://example.com/.well-known/jwks.json"))); + } + + @Test + void allowsHttpWithPermissivePolicy() { + SsrfJwksUriValidator validator = new SsrfJwksUriValidator(SsrfPolicy.permissive(), false); + assertDoesNotThrow(() -> validator.validate( + URI.create("http://example.com/.well-known/jwks.json"))); + } + + @Test + void rejectsInvalidScheme() { + SsrfJwksUriValidator validator = new SsrfJwksUriValidator(); + assertThrows(SsrfBlockedException.class, () -> validator.validate( + URI.create("ftp://example.com/jwks.json"))); + } + + @Test + void rejectsMulticastAddress() { + SsrfJwksUriValidator validator = new SsrfJwksUriValidator(SsrfPolicy.strict(), true); + assertThrows(SsrfBlockedException.class, () -> validator.validate( + URI.create("https://224.0.0.1/.well-known/jwks.json"))); + } + + @Test + void rejectsReservedClassE() { + SsrfJwksUriValidator validator = new SsrfJwksUriValidator(SsrfPolicy.strict(), true); + assertThrows(SsrfBlockedException.class, () -> validator.validate( + URI.create("https://240.0.0.1/.well-known/jwks.json"))); + } +} \ No newline at end of file diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/jwks/StaticJwksResolverTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/jwks/StaticJwksResolverTest.java new file mode 100644 index 0000000..e49db2a --- /dev/null +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/jwks/StaticJwksResolverTest.java @@ -0,0 +1,106 @@ +package org.adcontextprotocol.adcp.server.signing.jwks; + +import org.adcontextprotocol.adcp.signing.AdcpUse; +import org.adcontextprotocol.adcp.signing.SignedInput; +import org.adcontextprotocol.adcp.signing.VerificationInput; +import org.adcontextprotocol.adcp.signing.VerificationKeyLookup; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class StaticJwksResolverTest { + + private static Map ed25519Jwk() { + Map jwk = new LinkedHashMap<>(); + jwk.put("kid", "test-ed25519"); + jwk.put("kty", "OKP"); + jwk.put("crv", "Ed25519"); + jwk.put("alg", "EdDSA"); + jwk.put("use", "sig"); + jwk.put("key_ops", List.of("verify")); + jwk.put("adcp_use", "adcp_whk"); + jwk.put("x", "y7tTfeqazsFeTn3ccCzQlcJ4qFWuYsu-JkJAcfc9VoA"); + return jwk; + } + + private static Map es256Jwk() { + Map jwk = new LinkedHashMap<>(); + jwk.put("kid", "test-es256"); + jwk.put("kty", "EC"); + jwk.put("crv", "P-256"); + jwk.put("alg", "ES256"); + jwk.put("use", "sig"); + jwk.put("key_ops", List.of("verify")); + jwk.put("adcp_use", "adcp_whk"); + jwk.put("x", "0X7G_jryFpiX9XO3CKxIqUQs3DC8OhUkw6Rb5QOZd5M"); + jwk.put("y", "MwZN7qQJzLpTD5dyDJAoqOLZJ9r8-GCh4BnOYu6NE0c"); + return jwk; + } + + @Test + void resolveReturnsFoundForKnownKid() { + Map> keys = Map.of( + "test-ed25519", ed25519Jwk(), + "test-es256", es256Jwk() + ); + StaticJwksResolver resolver = new StaticJwksResolver(keys); + + VerificationInput input = new VerificationInput( + AdcpUse.WEBHOOK_SIGNING, + "test-ed25519", + new SignedInput("{}".getBytes(StandardCharsets.UTF_8), Map.of(), "POST", "/") + ); + + VerificationKeyLookup result = resolver.resolve(input); + assertInstanceOf(VerificationKeyLookup.Found.class, result); + VerificationKeyLookup.Found found = (VerificationKeyLookup.Found) result; + assertEquals("test-ed25519", found.key().kid()); + assertEquals("Ed25519", found.key().algorithm()); + } + + @Test + void resolveReturnsMissingForUnknownKid() { + Map> keys = Map.of( + "test-ed25519", ed25519Jwk() + ); + StaticJwksResolver resolver = new StaticJwksResolver(keys); + + VerificationInput input = new VerificationInput( + AdcpUse.WEBHOOK_SIGNING, + "nonexistent-kid", + new SignedInput("{}".getBytes(StandardCharsets.UTF_8), Map.of(), "POST", "/") + ); + + VerificationKeyLookup result = resolver.resolve(input); + assertInstanceOf(VerificationKeyLookup.Missing.class, result); + assertEquals("nonexistent-kid", ((VerificationKeyLookup.Missing) result).kid()); + } + + @Test + void resolveValidatesAdcpUse() { + Map wrongUseJwk = new LinkedHashMap<>(); + wrongUseJwk.put("kid", "test-req"); + wrongUseJwk.put("kty", "OKP"); + wrongUseJwk.put("crv", "Ed25519"); + wrongUseJwk.put("x", "y7tTfeqazsFeTn3ccCzQlcJ4qFWuYsu-JkJAcfc9VoA"); + wrongUseJwk.put("adcp_use", "adcp_req"); + wrongUseJwk.put("key_ops", List.of("verify")); + + StaticJwksResolver resolver = new StaticJwksResolver(Map.of("test-req", wrongUseJwk)); + + VerificationInput input = new VerificationInput( + AdcpUse.WEBHOOK_SIGNING, + "test-req", + new SignedInput("{}".getBytes(StandardCharsets.UTF_8), Map.of(), "POST", "/") + ); + + JwksResolutionException ex = + assertThrows(JwksResolutionException.class, () -> resolver.resolve(input)); + assertEquals("webhook_signature_key_purpose_invalid", ex.errorCode()); + } +} \ No newline at end of file diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/http/SsrfBlockedException.java b/adcp/src/main/java/org/adcontextprotocol/adcp/http/SsrfBlockedException.java index 497eb0f..4263b84 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/http/SsrfBlockedException.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/http/SsrfBlockedException.java @@ -15,14 +15,14 @@ public final class SsrfBlockedException extends RuntimeException { private final String host; private final String reason; - SsrfBlockedException(String host, String reason) { + public SsrfBlockedException(String host, String reason) { super("SSRF blocked: " + reason); this.host = host; this.reason = reason; } - /** The hostname or IP that was blocked. Package-private to limit exposure. */ - String host() { + /** The hostname or IP that was blocked. */ + public String host() { return host; } From 689d12250a1328edee07ad8cfe0b14b0c640f33b Mon Sep 17 00:00:00 2001 From: Lobsterdog Contributors Date: Wed, 17 Jun 2026 12:16:55 -0600 Subject: [PATCH 09/21] feat(signing): add InMemoryReplayStore and InMemoryRevocationStore for nonce dedup and key revocation --- .../signing/replay/InMemoryReplayStore.java | 62 ++++++++ .../server/signing/replay/ReplayStore.java | 23 +++ .../revocation/InMemoryRevocationStore.java | 75 ++++++++++ .../signing/revocation/RevocationResult.java | 37 +++++ .../signing/revocation/RevocationStore.java | 29 ++++ .../replay/InMemoryReplayStoreTest.java | 138 ++++++++++++++++++ .../InMemoryRevocationStoreTest.java | 111 ++++++++++++++ 7 files changed, 475 insertions(+) create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/replay/InMemoryReplayStore.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/replay/ReplayStore.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/revocation/InMemoryRevocationStore.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/revocation/RevocationResult.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/revocation/RevocationStore.java create mode 100644 adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/replay/InMemoryReplayStoreTest.java create mode 100644 adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/revocation/InMemoryRevocationStoreTest.java diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/replay/InMemoryReplayStore.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/replay/InMemoryReplayStore.java new file mode 100644 index 0000000..0aaf778 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/replay/InMemoryReplayStore.java @@ -0,0 +1,62 @@ +package org.adcontextprotocol.adcp.server.signing.replay; + +import java.util.concurrent.ConcurrentHashMap; + +/** + * In-memory replay store using a {@link ConcurrentHashMap}. + * + *

Maps {@code (kid + ":" + nonce)} to insertion timestamp. Evicts entries + * older than a configurable TTL (default 300 seconds = 5 minutes per AdCP spec). + * + *

Thread-safe. Suitable for single-instance deployments; multi-instance + * deployments should use a distributed store backed by Redis or similar. + */ +public final class InMemoryReplayStore implements ReplayStore { + + private static final long DEFAULT_TTL_SECONDS = 300; + + private final ConcurrentHashMap store = new ConcurrentHashMap<>(); + private final long ttlSeconds; + + public InMemoryReplayStore() { + this(DEFAULT_TTL_SECONDS); + } + + public InMemoryReplayStore(long ttlSeconds) { + if (ttlSeconds <= 0) { + throw new IllegalArgumentException("ttlSeconds must be positive, got: " + ttlSeconds); + } + this.ttlSeconds = ttlSeconds; + } + + @Override + public boolean checkAndStore(String kid, String nonce) { + evictExpired(); + String key = kid + ":" + nonce; + long now = System.currentTimeMillis(); + Long existing = store.putIfAbsent(key, now); + return existing != null; + } + + /** + * Remove entries older than the TTL threshold. + */ + void evictExpired() { + long cutoff = System.currentTimeMillis() - ttlSeconds * 1000; + store.entrySet().removeIf(entry -> entry.getValue() < cutoff); + } + + /** + * Return the number of entries currently stored (for testing). + */ + int size() { + return store.size(); + } + + /** + * Remove all entries (for testing). + */ + void clear() { + store.clear(); + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/replay/ReplayStore.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/replay/ReplayStore.java new file mode 100644 index 0000000..329cf40 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/replay/ReplayStore.java @@ -0,0 +1,23 @@ +package org.adcontextprotocol.adcp.server.signing.replay; + +/** + * Stores (kid, nonce) pairs for replay detection per RFC 9421 and AdCP 3.1 + * idempotency rules. + * + *

Implementations must be thread-safe. The {@link #checkAndStore} method + * is atomic: it returns {@code true} if the (kid, nonce) pair was already + * present (replay detected), and inserts it if absent. + */ +public interface ReplayStore { + + /** + * Check whether the given (kid, nonce) pair has been seen before, and + * store it if not. + * + * @param kid the key identifier + * @param nonce the nonce from the Signature-Input + * @return {@code true} if this is a replay (pair already seen), + * {@code false} if this is the first occurrence + */ + boolean checkAndStore(String kid, String nonce); +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/revocation/InMemoryRevocationStore.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/revocation/InMemoryRevocationStore.java new file mode 100644 index 0000000..6dcb431 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/revocation/InMemoryRevocationStore.java @@ -0,0 +1,75 @@ +package org.adcontextprotocol.adcp.server.signing.revocation; + +import java.util.concurrent.ConcurrentHashMap; + +/** + * In-memory revocation store using a {@link ConcurrentHashMap}. + * + *

Maps {@code kid} to revocation timestamp. Entries older than a + * configurable staleness threshold (default 300 seconds = 5 minutes) are + * reported as {@link RevocationResult.Stale} rather than + * {@link RevocationResult.Revoked}. + * + *

Thread-safe. Suitable for single-instance deployments. + */ +public final class InMemoryRevocationStore implements RevocationStore { + + private static final long DEFAULT_STALENESS_THRESHOLD_SECONDS = 300; + + private final ConcurrentHashMap store = new ConcurrentHashMap<>(); + private final long stalenessThresholdSeconds; + + public InMemoryRevocationStore() { + this(DEFAULT_STALENESS_THRESHOLD_SECONDS); + } + + public InMemoryRevocationStore(long stalenessThresholdSeconds) { + if (stalenessThresholdSeconds <= 0) { + throw new IllegalArgumentException( + "stalenessThresholdSeconds must be positive, got: " + stalenessThresholdSeconds); + } + this.stalenessThresholdSeconds = stalenessThresholdSeconds; + } + + @Override + public RevocationResult check(String kid) { + Long revocationTime = store.get(kid); + if (revocationTime == null) { + return new RevocationResult.Valid(); + } + + long now = System.currentTimeMillis(); + long ageSeconds = (now - revocationTime) / 1000; + + if (ageSeconds > stalenessThresholdSeconds) { + long staleSeconds = ageSeconds - stalenessThresholdSeconds; + return new RevocationResult.Stale(staleSeconds); + } + + return new RevocationResult.Revoked(kid); + } + + @Override + public void revoke(String kid) { + store.put(kid, System.currentTimeMillis()); + } + + @Override + public void clear() { + store.clear(); + } + + /** + * Return the number of entries currently stored (for testing). + */ + int size() { + return store.size(); + } + + /** + * Revoke a kid with a specific timestamp (for testing). + */ + void revokeAt(String kid, long timestampMillis) { + store.put(kid, timestampMillis); + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/revocation/RevocationResult.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/revocation/RevocationResult.java new file mode 100644 index 0000000..1dd1a66 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/revocation/RevocationResult.java @@ -0,0 +1,37 @@ +package org.adcontextprotocol.adcp.server.signing.revocation; + +/** + * Sealed interface for revocation lookup results. + * + *

Matches AdCP test vectors 017 (key-revoked) and 019 (revocation-stale). + */ +public sealed interface RevocationResult { + + /** + * The key is valid — not revoked and not stale. + */ + record Valid() implements RevocationResult {} + + /** + * The key has been revoked. + * + * @param kid the revoked key identifier + */ + record Revoked(String kid) implements RevocationResult { + public Revoked { + if (kid == null) throw new NullPointerException("kid"); + } + } + + /** + * The key's revocation entry is stale (older than the staleness threshold). + * The key should be treated as neither confirmed-revoked nor confirmed-valid. + * + * @param staleSeconds how many seconds past the staleness threshold + */ + record Stale(long staleSeconds) implements RevocationResult { + public Stale { + if (staleSeconds < 0) throw new IllegalArgumentException("staleSeconds must be non-negative, got: " + staleSeconds); + } + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/revocation/RevocationStore.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/revocation/RevocationStore.java new file mode 100644 index 0000000..7075398 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/revocation/RevocationStore.java @@ -0,0 +1,29 @@ +package org.adcontextprotocol.adcp.server.signing.revocation; + +/** + * Stores revoked key identifiers for revocation checking. + * + *

Implementations must be thread-safe. + */ +public interface RevocationStore { + + /** + * Check whether the given kid has been revoked. + * + * @param kid the key identifier + * @return the revocation result + */ + RevocationResult check(String kid); + + /** + * Revoke the given kid. + * + * @param kid the key identifier to revoke + */ + void revoke(String kid); + + /** + * Remove all revocation entries (for testing). + */ + void clear(); +} \ No newline at end of file diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/replay/InMemoryReplayStoreTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/replay/InMemoryReplayStoreTest.java new file mode 100644 index 0000000..29e0ea4 --- /dev/null +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/replay/InMemoryReplayStoreTest.java @@ -0,0 +1,138 @@ +package org.adcontextprotocol.adcp.server.signing.replay; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class InMemoryReplayStoreTest { + + private InMemoryReplayStore store; + + @BeforeEach + void setUp() { + store = new InMemoryReplayStore(); + } + + @Test + void firstOccurrence_returnsFalse() { + boolean result = store.checkAndStore("kid-1", "nonce-abc"); + assertFalse(result, "First occurrence should return false (no replay)"); + } + + @Test + void secondOccurrence_returnsTrue() { + store.checkAndStore("kid-1", "nonce-abc"); + boolean result = store.checkAndStore("kid-1", "nonce-abc"); + assertTrue(result, "Second occurrence should return true (replay detected)"); + } + + @Test + void differentKid_sameNonce_returnsFalse() { + store.checkAndStore("kid-1", "nonce-abc"); + boolean result = store.checkAndStore("kid-2", "nonce-abc"); + assertFalse(result, "Different kid should be a different entry"); + } + + @Test + void sameKid_differentNonce_returnsFalse() { + store.checkAndStore("kid-1", "nonce-abc"); + boolean result = store.checkAndStore("kid-1", "nonce-def"); + assertFalse(result, "Different nonce should be a different entry"); + } + + @Test + void evictsExpiredEntries() throws InterruptedException { + InMemoryReplayStore shortLived = new InMemoryReplayStore(1); + shortLived.checkAndStore("kid-1", "nonce-abc"); + assertEquals(1, shortLived.size()); + + Thread.sleep(1100); + + shortLived.evictExpired(); + assertEquals(0, shortLived.size(), "Expired entries should be evicted"); + } + + @Test + void expiredEntry_notConsideredReplay() throws InterruptedException { + InMemoryReplayStore shortLived = new InMemoryReplayStore(1); + shortLived.checkAndStore("kid-1", "nonce-abc"); + + Thread.sleep(1100); + + boolean result = shortLived.checkAndStore("kid-1", "nonce-abc"); + assertFalse(result, "Expired entry should not be considered a replay"); + } + + @Test + void threadSafety_concurrentCheckAndStore() throws Exception { + int threadCount = 16; + int iterations = 100; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(1); + AtomicInteger replayCount = new AtomicInteger(0); + + CountDownLatch done = new CountDownLatch(threadCount); + for (int t = 0; t < threadCount; t++) { + executor.submit(() -> { + try { + latch.await(); + for (int i = 0; i < iterations; i++) { + boolean isReplay = store.checkAndStore("kid-thread", "nonce-shared"); + if (isReplay) { + replayCount.incrementAndGet(); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + }); + } + + latch.countDown(); + assertTrue(done.await(10, TimeUnit.SECONDS)); + + int totalOps = threadCount * iterations; + int replays = replayCount.get(); + assertTrue(replays > 0, "At least one thread should see a replay"); + assertEquals(totalOps, replays + 1, + "Exactly one thread gets false (first), rest get true (replay)"); + executor.shutdown(); + } + + @Test + void clear_removesAllEntries() { + store.checkAndStore("kid-1", "nonce-1"); + store.checkAndStore("kid-2", "nonce-2"); + assertEquals(2, store.size()); + + store.clear(); + assertEquals(0, store.size()); + + boolean result = store.checkAndStore("kid-1", "nonce-1"); + assertFalse(result, "After clear, entry should be absent"); + } + + @Test + void ttlMustBePositive() { + assertThrows(IllegalArgumentException.class, () -> new InMemoryReplayStore(0)); + assertThrows(IllegalArgumentException.class, () -> new InMemoryReplayStore(-1)); + } + + @Test + void perKidRateLimiting_manyNoncesSameKid() { + for (int i = 0; i < 100; i++) { + boolean result = store.checkAndStore("kid-ratelimited", "nonce-" + i); + assertFalse(result, "Unique nonces should not be considered replays"); + } + assertEquals(100, store.size()); + } +} \ No newline at end of file diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/revocation/InMemoryRevocationStoreTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/revocation/InMemoryRevocationStoreTest.java new file mode 100644 index 0000000..4ea83ab --- /dev/null +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/revocation/InMemoryRevocationStoreTest.java @@ -0,0 +1,111 @@ +package org.adcontextprotocol.adcp.server.signing.revocation; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class InMemoryRevocationStoreTest { + + private InMemoryRevocationStore store; + + @BeforeEach + void setUp() { + store = new InMemoryRevocationStore(); + } + + @Test + void check_unknownKid_returnsValid() { + RevocationResult result = store.check("unknown-kid"); + assertInstanceOf(RevocationResult.Valid.class, result); + } + + @Test + void check_revokedKid_returnsRevoked() { + store.revoke("kid-revoked"); + RevocationResult result = store.check("kid-revoked"); + assertInstanceOf(RevocationResult.Revoked.class, result); + assertEquals("kid-revoked", ((RevocationResult.Revoked) result).kid()); + } + + @Test + void check_staleRevocation_returnsStale() { + InMemoryRevocationStore store = new InMemoryRevocationStore(1); + store.revokeAt("kid-stale", System.currentTimeMillis() - 5000); + + RevocationResult result = store.check("kid-stale"); + assertInstanceOf(RevocationResult.Stale.class, result); + assertTrue(((RevocationResult.Stale) result).staleSeconds() >= 3); + } + + @Test + void check_recentRevocation_returnsRevoked() { + store.revoke("kid-recent"); + RevocationResult result = store.check("kid-recent"); + assertInstanceOf(RevocationResult.Revoked.class, result); + } + + @Test + void revoke_multipleKids_independent() { + store.revoke("kid-1"); + store.revoke("kid-2"); + + assertInstanceOf(RevocationResult.Revoked.class, store.check("kid-1")); + assertInstanceOf(RevocationResult.Revoked.class, store.check("kid-2")); + assertInstanceOf(RevocationResult.Valid.class, store.check("kid-3")); + } + + @Test + void clear_removesAllEntries() { + store.revoke("kid-1"); + store.revoke("kid-2"); + assertEquals(2, store.size()); + + store.clear(); + assertEquals(0, store.size()); + assertInstanceOf(RevocationResult.Valid.class, store.check("kid-1")); + } + + @Test + void staleSeconds_calculation() { + InMemoryRevocationStore store = new InMemoryRevocationStore(1); + store.revokeAt("kid-stale", System.currentTimeMillis() - 10000); + + RevocationResult result = store.check("kid-stale"); + assertInstanceOf(RevocationResult.Stale.class, result); + long staleSeconds = ((RevocationResult.Stale) result).staleSeconds(); + assertTrue(staleSeconds >= 0, "staleSeconds should be non-negative"); + } + + @Test + void stalenessThresholdMustBePositive() { + assertThrows(IllegalArgumentException.class, () -> new InMemoryRevocationStore(0)); + assertThrows(IllegalArgumentException.class, () -> new InMemoryRevocationStore(-1)); + } + + @Test + void revokedKid_null_throwsNPE() { + assertThrows(NullPointerException.class, () -> store.revoke(null)); + } + + @Test + void check_null_throwsNPE() { + assertThrows(NullPointerException.class, () -> store.check(null)); + } + + @Test + void revokedResult_recordEquality() { + RevocationResult.Revoked r1 = new RevocationResult.Revoked("kid-1"); + RevocationResult.Revoked r2 = new RevocationResult.Revoked("kid-1"); + assertEquals(r1, r2); + assertEquals(r1.kid(), r2.kid()); + } + + @Test + void staleResult_recordEquality() { + RevocationResult.Stale s1 = new RevocationResult.Stale(5); + RevocationResult.Stale s2 = new RevocationResult.Stale(5); + assertEquals(s1, s2); + assertEquals(s1.staleSeconds(), s2.staleSeconds()); + } +} \ No newline at end of file From 82802fa2651b10bcc1b56989dd8191d3019d8310 Mon Sep 17 00:00:00 2001 From: Lobsterdog Contributors Date: Wed, 17 Jun 2026 12:16:59 -0600 Subject: [PATCH 10/21] feat(signing): add pre-deploy KMS probe CLI command --- .../org/adcontextprotocol/adcp/cli/Main.java | 118 ++++++++- .../adcp/cli/SigningProbeCommand.java | 236 ++++++++++++++++++ 2 files changed, 349 insertions(+), 5 deletions(-) create mode 100644 adcp-cli/src/main/java/org/adcontextprotocol/adcp/cli/SigningProbeCommand.java diff --git a/adcp-cli/src/main/java/org/adcontextprotocol/adcp/cli/Main.java b/adcp-cli/src/main/java/org/adcontextprotocol/adcp/cli/Main.java index 570d918..ca621c4 100644 --- a/adcp-cli/src/main/java/org/adcontextprotocol/adcp/cli/Main.java +++ b/adcp-cli/src/main/java/org/adcontextprotocol/adcp/cli/Main.java @@ -1,8 +1,17 @@ package org.adcontextprotocol.adcp.cli; +import org.adcontextprotocol.adcp.signing.AdcpUse; + +import java.util.ArrayList; +import java.util.List; + /** - * Entry point for the {@code adcp} CLI. Commands land here as the CLI track - * is implemented; today this is a placeholder that prints a usage stub. + * Entry point for the {@code adcp} CLI. + * + *

Subcommands: + *

    + *
  • {@code signing-probe} — pre-deploy KMS connectivity and key usability check
  • + *
*/ public final class Main { @@ -11,7 +20,106 @@ private Main() { } public static void main(String[] args) { - System.out.println("adcp "); - System.out.println("see ROADMAP.md track 13 (cli) for the planned surface"); + if (args.length == 0) { + printUsage(); + System.exit(1); + } + + String command = args[0]; + String[] subArgs = new String[args.length - 1]; + System.arraycopy(args, 1, subArgs, 0, subArgs.length); + + switch (command) { + case "signing-probe" -> runSigningProbe(subArgs); + default -> { + System.err.println("Unknown command: " + command); + printUsage(); + System.exit(1); + } + } + } + + private static void runSigningProbe(String[] args) { + SigningProbeCommand probe = new SigningProbeCommand(); + List errors = new ArrayList<>(); + + int i = 0; + while (i < args.length) { + String arg = args[i]; + switch (arg) { + case "--aws-key" -> { + if (i + 3 >= args.length) { + errors.add("--aws-key requires "); + i = args.length; + } else { + try { + AdcpUse use = AdcpUse.fromWireName(args[i + 1]); + probe.addAwsKey(use, args[i + 2], args[i + 3]); + } catch (IllegalArgumentException e) { + errors.add("Invalid adcp_use for --aws-key: " + args[i + 1] + + ". Valid values: adcp_req, adcp_whk"); + } + i += 4; + } + } + case "--gcp-key" -> { + if (i + 3 >= args.length) { + errors.add("--gcp-key requires "); + i = args.length; + } else { + try { + AdcpUse use = AdcpUse.fromWireName(args[i + 1]); + probe.addGcpKey(use, args[i + 2], args[i + 3]); + } catch (IllegalArgumentException e) { + errors.add("Invalid adcp_use for --gcp-key: " + args[i + 1] + + ". Valid values: adcp_req, adcp_whk"); + } + i += 4; + } + } + default -> { + errors.add("Unknown option: " + arg); + i++; + } + } + } + + if (!errors.isEmpty()) { + for (String error : errors) { + System.err.println("Error: " + error); + } + System.err.println(); + printSigningProbeUsage(); + System.exit(1); + } + + try { + int exitCode = probe.call(); + System.exit(exitCode); + } catch (Exception e) { + System.err.println("Probe failed: " + e.getMessage()); + System.exit(1); + } + } + + private static void printUsage() { + System.out.println("Usage: adcp [options]"); + System.out.println(); + System.out.println("Commands:"); + System.out.println(" signing-probe Pre-deploy KMS connectivity and key usability check"); + System.out.println(); + System.out.println("Run 'adcp --help' for command details."); + } + + private static void printSigningProbeUsage() { + System.out.println("Usage: adcp signing-probe [options]"); + System.out.println(); + System.out.println("Options:"); + System.out.println(" --aws-key "); + System.out.println(" Probe an AWS KMS key. is adcp_req or adcp_whk."); + System.out.println(" --gcp-key "); + System.out.println(" Probe a GCP KMS key. is adcp_req or adcp_whk."); + System.out.println(); + System.out.println("Exit code: 0 if all probes pass, 1 if any fail."); } -} +} \ No newline at end of file diff --git a/adcp-cli/src/main/java/org/adcontextprotocol/adcp/cli/SigningProbeCommand.java b/adcp-cli/src/main/java/org/adcontextprotocol/adcp/cli/SigningProbeCommand.java new file mode 100644 index 0000000..996a715 --- /dev/null +++ b/adcp-cli/src/main/java/org/adcontextprotocol/adcp/cli/SigningProbeCommand.java @@ -0,0 +1,236 @@ +package org.adcontextprotocol.adcp.cli; + +import org.adcontextprotocol.adcp.signing.AdcpUse; +import org.adcontextprotocol.adcp.signing.SigningException; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; + +/** + * Pre-deploy KMS probe CLI command. + * + *

Verifies KMS connectivity and key usability for each configured KMS key. + * Per the ROADMAP: "separate CLI command, not part of boot critical path." + * + *

Supports AWS and GCP KMS providers. Outputs structured JSON results + * with exit code 0 (all pass) or 1 (any fail). + */ +public final class SigningProbeCommand implements Callable { + + private final List probes = new ArrayList<>(); + + public SigningProbeCommand addAwsKey(AdcpUse use, String keyArn, String region) { + probes.add(new AwsKeyProbe(use, keyArn, region)); + return this; + } + + public SigningProbeCommand addGcpKey(AdcpUse use, String keyVersionPath, String credentialsPath) { + probes.add(new GcpKeyProbe(use, keyVersionPath, credentialsPath)); + return this; + } + + @Override + public Integer call() { + List results = new ArrayList<>(); + boolean allPassed = true; + + for (KeyProbe probe : probes) { + ProbeResult result = probe.run(); + results.add(result); + if (!result.passed()) { + allPassed = false; + } + } + + System.out.println(formatResults(results)); + return allPassed ? 0 : 1; + } + + String formatResults(List results) { + StringBuilder sb = new StringBuilder(); + sb.append("{\n \"probe_results\": [\n"); + for (int i = 0; i < results.size(); i++) { + ProbeResult r = results.get(i); + if (i > 0) sb.append(",\n"); + sb.append(" {\n"); + sb.append(" \"provider\": \"").append(escapeJson(r.provider())).append("\",\n"); + sb.append(" \"use\": \"").append(escapeJson(r.use().wireName())).append("\",\n"); + sb.append(" \"key_id\": \"").append(escapeJson(r.keyId())).append("\",\n"); + sb.append(" \"connectivity\": ").append(r.connectivityOk() ? "\"ok\"" : "\"failed: " + escapeJson(r.connectivityError()) + "\"").append(",\n"); + sb.append(" \"signing\": ").append(r.signingOk() ? "\"ok\"" : "\"failed: " + escapeJson(r.signingError()) + "\"").append(",\n"); + sb.append(" \"purpose\": ").append(r.purposeOk() ? "\"ok\"" : "\"failed: " + escapeJson(r.purposeError()) + "\"").append(",\n"); + sb.append(" \"passed\": ").append(r.passed()); + sb.append("\n }"); + } + sb.append("\n ]\n}"); + return sb.toString(); + } + + private static String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); + } + + record ProbeResult( + String provider, + AdcpUse use, + String keyId, + boolean connectivityOk, + String connectivityError, + boolean signingOk, + String signingError, + boolean purposeOk, + String purposeError, + boolean passed + ) {} + + interface KeyProbe { + ProbeResult run(); + } + + static final class AwsKeyProbe implements KeyProbe { + private final AdcpUse use; + private final String keyArn; + private final String region; + + AwsKeyProbe(AdcpUse use, String keyArn, String region) { + this.use = use; + this.keyArn = keyArn; + this.region = region; + } + + @Override + public ProbeResult run() { + boolean connectivityOk = false; + String connectivityError = null; + boolean signingOk = false; + String signingError = null; + boolean purposeOk = false; + String purposeError = null; + + try { + Class providerClass = Class.forName( + "org.adcontextprotocol.adcp.signing.aws.AwsKmsSigningProvider"); + Object provider = providerClass.getMethod("builder").invoke(null); + provider.getClass().getMethod("keyArn", AdcpUse.class, String.class) + .invoke(provider, use, keyArn); + provider.getClass().getMethod("region", String.class) + .invoke(provider, region); + + Object built = provider.getClass().getMethod("build").invoke(provider); + + connectivityOk = true; + + try { + built.getClass().getMethod("initialize").invoke(built); + signingOk = true; + } catch (Exception e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + if (cause instanceof SigningException) { + signingError = cause.getMessage(); + } else { + signingError = cause.getMessage(); + } + } + + try { + @SuppressWarnings("unchecked") + Map algorithms = (Map) built.getClass() + .getMethod("getAlgorithms").invoke(built); + purposeOk = algorithms.containsKey(use); + if (!purposeOk) { + purposeError = "No algorithm resolved for use: " + use; + } + } catch (Exception e) { + purposeError = e.getMessage(); + } + + } catch (ClassNotFoundException e) { + connectivityError = "AWS KMS provider not on classpath"; + } catch (Exception e) { + connectivityError = e.getMessage(); + } + + boolean passed = connectivityOk && signingOk && purposeOk; + return new ProbeResult("aws", use, keyArn, connectivityOk, connectivityError, + signingOk, signingError, purposeOk, purposeError, passed); + } + } + + static final class GcpKeyProbe implements KeyProbe { + private final AdcpUse use; + private final String keyVersionPath; + private final String credentialsPath; + + GcpKeyProbe(AdcpUse use, String keyVersionPath, String credentialsPath) { + this.use = use; + this.keyVersionPath = keyVersionPath; + this.credentialsPath = credentialsPath; + } + + @Override + public ProbeResult run() { + boolean connectivityOk = false; + String connectivityError = null; + boolean signingOk = false; + String signingError = null; + boolean purposeOk = false; + String purposeError = null; + + try { + Class providerClass = Class.forName( + "org.adcontextprotocol.adcp.signing.gcp.GcpKmsSigningProvider"); + Object provider = providerClass.getMethod("builder").invoke(null); + provider.getClass().getMethod("keyVersionPath", AdcpUse.class, String.class) + .invoke(provider, use, keyVersionPath); + if (credentialsPath != null) { + provider.getClass().getMethod("credentialsPath", String.class) + .invoke(provider, credentialsPath); + } + + Object built = provider.getClass().getMethod("build").invoke(provider); + + connectivityOk = true; + + try { + built.getClass().getMethod("initialize").invoke(built); + signingOk = true; + } catch (Exception e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + if (cause instanceof SigningException) { + signingError = cause.getMessage(); + } else { + signingError = cause.getMessage(); + } + } + + try { + @SuppressWarnings("unchecked") + Map algorithms = (Map) built.getClass() + .getMethod("getAlgorithms").invoke(built); + purposeOk = algorithms.containsKey(use); + if (!purposeOk) { + purposeError = "No algorithm resolved for use: " + use; + } + } catch (Exception e) { + purposeError = e.getMessage(); + } + + } catch (ClassNotFoundException e) { + connectivityError = "GCP KMS provider not on classpath"; + } catch (Exception e) { + connectivityError = e.getMessage(); + } + + boolean passed = connectivityOk && signingOk && purposeOk; + return new ProbeResult("gcp", use, keyVersionPath, connectivityOk, connectivityError, + signingOk, signingError, purposeOk, purposeError, passed); + } + } +} \ No newline at end of file From 3ce0955b493672b34625b0c15cdab1a676d70813 Mon Sep 17 00:00:00 2001 From: Lobsterdog Contributors Date: Wed, 17 Jun 2026 12:17:08 -0600 Subject: [PATCH 11/21] feat(signing): add SigningKeyGenerator for Ed25519/ES256/ES384 keypair generation --- .../server/signing/SigningKeyGenerator.java | 194 ++++++++++++++++++ .../signing/SigningKeyGeneratorTest.java | 156 ++++++++++++++ 2 files changed, 350 insertions(+) create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/SigningKeyGenerator.java create mode 100644 adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/SigningKeyGeneratorTest.java diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/SigningKeyGenerator.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/SigningKeyGenerator.java new file mode 100644 index 0000000..1f37212 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/SigningKeyGenerator.java @@ -0,0 +1,194 @@ +package org.adcontextprotocol.adcp.server.signing; + +import org.adcontextprotocol.adcp.signing.VerificationKey; + +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.interfaces.ECPublicKey; +import java.security.interfaces.EdECPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.util.Base64; +import java.util.HexFormat; +import java.util.UUID; + +/** + * Utility for generating Ed25519, ES256, and ES384 keypairs with JWK output. + * + *

Used for development/testing key generation, the {@code adcp-keygen} CLI + * command, and pre-deploy key provisioning. + * + *

Production deployments should use KMS-managed keys, not in-process keys. + */ +public final class SigningKeyGenerator { + + private SigningKeyGenerator() {} + + /** + * Generated keypair result containing the private key, verification key, + * and the public key as a JWK JSON string. + * + * @param privateKey the JCA private key + * @param verificationKey the AdCP verification key + * @param jwkJson the public key as a JWK JSON string + */ + public record GeneratedKeypair( + PrivateKey privateKey, + VerificationKey verificationKey, + String jwkJson + ) { + public GeneratedKeypair { + if (privateKey == null) throw new NullPointerException("privateKey"); + if (verificationKey == null) throw new NullPointerException("verificationKey"); + if (jwkJson == null) throw new NullPointerException("jwkJson"); + } + } + + /** + * Generate an Ed25519 keypair. + * + * @return the generated keypair with JWK representation + */ + public static GeneratedKeypair generateEd25519() { + return generateEd25519("key-ed25519-" + UUID.randomUUID().toString().substring(0, 8)); + } + + /** + * Generate an Ed25519 keypair with a specific kid. + * + * @param kid the key identifier + * @return the generated keypair with JWK representation + */ + public static GeneratedKeypair generateEd25519(String kid) { + try { + KeyPairGenerator gen = KeyPairGenerator.getInstance("Ed25519"); + KeyPair kp = gen.generateKeyPair(); + + EdECPublicKey pubKey = (EdECPublicKey) kp.getPublic(); + byte[] rawPublicKey = pubKey.getEncoded(); + + VerificationKey vk = new VerificationKey(kid, "Ed25519", rawPublicKey, "Ed25519"); + + String x = base64Url(rawPublicKey); + String jwkJson = buildJwkJson(kid, "OKP", "Ed25519", x, null, "ed25519", "sig", "adcp_req"); + + return new GeneratedKeypair(kp.getPrivate(), vk, jwkJson); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Ed25519 not available (requires JDK 15+)", e); + } + } + + /** + * Generate an ES256 (P-256) keypair. + * + * @return the generated keypair with JWK representation + */ + public static GeneratedKeypair generateEs256() { + return generateEs256("key-es256-" + UUID.randomUUID().toString().substring(0, 8)); + } + + /** + * Generate an ES256 (P-256) keypair with a specific kid. + * + * @param kid the key identifier + * @return the generated keypair with JWK representation + */ + public static GeneratedKeypair generateEs256(String kid) { + try { + KeyPairGenerator gen = KeyPairGenerator.getInstance("EC"); + gen.initialize(new ECGenParameterSpec("secp256r1")); + KeyPair kp = gen.generateKeyPair(); + + ECPublicKey pubKey = (ECPublicKey) kp.getPublic(); + byte[] xBytes = trimLeadingZeros(pubKey.getW().getAffineX().toByteArray()); + byte[] yBytes = trimLeadingZeros(pubKey.getW().getAffineY().toByteArray()); + byte[] spkiDer = pubKey.getEncoded(); + + VerificationKey vk = new VerificationKey(kid, "EC", spkiDer, "P-256"); + + String x = base64Url(xBytes); + String y = base64Url(yBytes); + String jwkJson = buildJwkJson(kid, "EC", "P-256", x, y, "ecdsa-p256-sha256", "sig", "adcp_req"); + + return new GeneratedKeypair(kp.getPrivate(), vk, jwkJson); + } catch (Exception e) { + throw new IllegalStateException("EC P-256 not available", e); + } + } + + /** + * Generate an ES384 (P-384) keypair. + * + * @return the generated keypair with JWK representation + */ + public static GeneratedKeypair generateEs384() { + return generateEs384("key-es384-" + UUID.randomUUID().toString().substring(0, 8)); + } + + /** + * Generate an ES384 (P-384) keypair with a specific kid. + * + * @param kid the key identifier + * @return the generated keypair with JWK representation + */ + public static GeneratedKeypair generateEs384(String kid) { + try { + KeyPairGenerator gen = KeyPairGenerator.getInstance("EC"); + gen.initialize(new ECGenParameterSpec("secp384r1")); + KeyPair kp = gen.generateKeyPair(); + + ECPublicKey pubKey = (ECPublicKey) kp.getPublic(); + byte[] xBytes = trimLeadingZeros(pubKey.getW().getAffineX().toByteArray()); + byte[] yBytes = trimLeadingZeros(pubKey.getW().getAffineY().toByteArray()); + byte[] spkiDer = pubKey.getEncoded(); + + VerificationKey vk = new VerificationKey(kid, "EC", spkiDer, "P-384"); + + String x = base64Url(xBytes); + String y = base64Url(yBytes); + String jwkJson = buildJwkJson(kid, "EC", "P-384", x, y, "ecdsa-p384-sha384", "sig", "adcp_req"); + + return new GeneratedKeypair(kp.getPrivate(), vk, jwkJson); + } catch (Exception e) { + throw new IllegalStateException("EC P-384 not available", e); + } + } + + private static String buildJwkJson(String kid, String kty, String crv, String x, + String y, String alg, String use, String adcpUse) { + StringBuilder sb = new StringBuilder(); + sb.append("{\"kid\":\"").append(escapeJson(kid)).append("\""); + sb.append(",\"kty\":\"").append(kty).append("\""); + sb.append(",\"crv\":\"").append(crv).append("\""); + sb.append(",\"x\":\"").append(x).append("\""); + if (y != null) { + sb.append(",\"y\":\"").append(y).append("\""); + } + sb.append(",\"alg\":\"").append(alg).append("\""); + sb.append(",\"use\":\"").append(use).append("\""); + sb.append(",\"adcp_use\":\"").append(adcpUse).append("\""); + sb.append("}"); + return sb.toString(); + } + + private static String escapeJson(String s) { + return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); + } + + private static String base64Url(byte[] data) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(data); + } + + static byte[] trimLeadingZeros(byte[] bytes) { + int start = 0; + while (start < bytes.length - 1 && bytes[start] == 0) { + start++; + } + if (start == 0) return bytes; + byte[] result = new byte[bytes.length - start]; + System.arraycopy(bytes, start, result, 0, result.length); + return result; + } +} \ No newline at end of file diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/SigningKeyGeneratorTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/SigningKeyGeneratorTest.java new file mode 100644 index 0000000..b5ebe0c --- /dev/null +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/SigningKeyGeneratorTest.java @@ -0,0 +1,156 @@ +package org.adcontextprotocol.adcp.server.signing; + +import org.adcontextprotocol.adcp.signing.VerificationKey; +import org.junit.jupiter.api.Test; + +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.EdECPrivateKey; + +import static org.junit.jupiter.api.Assertions.*; + +class SigningKeyGeneratorTest { + + @Test + void generateEd25519_returnsValidKeypair() { + SigningKeyGenerator.GeneratedKeypair kp = SigningKeyGenerator.generateEd25519(); + + assertNotNull(kp.privateKey()); + assertNotNull(kp.verificationKey()); + assertNotNull(kp.jwkJson()); + + assertTrue(kp.privateKey() instanceof EdECPrivateKey, + "Expected EdECPrivateKey, got: " + kp.privateKey().getClass().getSimpleName()); + } + + @Test + void generateEd25519_withCustomKid() { + SigningKeyGenerator.GeneratedKeypair kp = SigningKeyGenerator.generateEd25519("my-test-kid"); + + assertEquals("my-test-kid", kp.verificationKey().kid()); + assertTrue(kp.jwkJson().contains("\"kid\":\"my-test-kid\"")); + } + + @Test + void generateEd25519_jwkContainsRequiredFields() { + SigningKeyGenerator.GeneratedKeypair kp = SigningKeyGenerator.generateEd25519(); + + String jwk = kp.jwkJson(); + assertTrue(jwk.contains("\"kty\":\"OKP\"")); + assertTrue(jwk.contains("\"crv\":\"Ed25519\"")); + assertTrue(jwk.contains("\"alg\":\"ed25519\"")); + assertTrue(jwk.contains("\"use\":\"sig\"")); + assertTrue(jwk.contains("\"adcp_use\":\"adcp_req\"")); + assertTrue(jwk.contains("\"x\":\"")); + } + + @Test + void generateEd25519_verificationKeyMatches() { + SigningKeyGenerator.GeneratedKeypair kp = SigningKeyGenerator.generateEd25519(); + + VerificationKey vk = kp.verificationKey(); + assertEquals("Ed25519", vk.algorithm()); + assertEquals("Ed25519", vk.crv()); + } + + @Test + void generateEs256_returnsValidKeypair() { + SigningKeyGenerator.GeneratedKeypair kp = SigningKeyGenerator.generateEs256(); + + assertNotNull(kp.privateKey()); + assertNotNull(kp.verificationKey()); + assertNotNull(kp.jwkJson()); + + assertTrue(kp.privateKey() instanceof ECPrivateKey); + ECPrivateKey ecKey = (ECPrivateKey) kp.privateKey(); + assertEquals(256, ecKey.getParams().getOrder().bitLength()); + } + + @Test + void generateEs256_withCustomKid() { + SigningKeyGenerator.GeneratedKeypair kp = SigningKeyGenerator.generateEs256("my-es256-kid"); + + assertEquals("my-es256-kid", kp.verificationKey().kid()); + assertTrue(kp.jwkJson().contains("\"kid\":\"my-es256-kid\"")); + } + + @Test + void generateEs256_jwkContainsRequiredFields() { + SigningKeyGenerator.GeneratedKeypair kp = SigningKeyGenerator.generateEs256(); + + String jwk = kp.jwkJson(); + assertTrue(jwk.contains("\"kty\":\"EC\"")); + assertTrue(jwk.contains("\"crv\":\"P-256\"")); + assertTrue(jwk.contains("\"alg\":\"ecdsa-p256-sha256\"")); + assertTrue(jwk.contains("\"use\":\"sig\"")); + assertTrue(jwk.contains("\"adcp_use\":\"adcp_req\"")); + assertTrue(jwk.contains("\"x\":\"")); + assertTrue(jwk.contains("\"y\":\"")); + } + + @Test + void generateEs256_verificationKeyMatches() { + SigningKeyGenerator.GeneratedKeypair kp = SigningKeyGenerator.generateEs256(); + + VerificationKey vk = kp.verificationKey(); + assertEquals("EC", vk.algorithm()); + assertEquals("P-256", vk.crv()); + } + + @Test + void generateEs384_returnsValidKeypair() { + SigningKeyGenerator.GeneratedKeypair kp = SigningKeyGenerator.generateEs384(); + + assertNotNull(kp.privateKey()); + assertNotNull(kp.verificationKey()); + assertNotNull(kp.jwkJson()); + + assertTrue(kp.privateKey() instanceof ECPrivateKey); + ECPrivateKey ecKey = (ECPrivateKey) kp.privateKey(); + assertEquals(384, ecKey.getParams().getOrder().bitLength()); + } + + @Test + void generateEs384_jwkContainsRequiredFields() { + SigningKeyGenerator.GeneratedKeypair kp = SigningKeyGenerator.generateEs384(); + + String jwk = kp.jwkJson(); + assertTrue(jwk.contains("\"kty\":\"EC\"")); + assertTrue(jwk.contains("\"crv\":\"P-384\"")); + assertTrue(jwk.contains("\"alg\":\"ecdsa-p384-sha384\"")); + assertTrue(jwk.contains("\"use\":\"sig\"")); + assertTrue(jwk.contains("\"adcp_use\":\"adcp_req\"")); + } + + @Test + void generateEs384_verificationKeyMatches() { + SigningKeyGenerator.GeneratedKeypair kp = SigningKeyGenerator.generateEs384(); + + VerificationKey vk = kp.verificationKey(); + assertEquals("EC", vk.algorithm()); + assertEquals("P-384", vk.crv()); + } + + @Test + void generatedKeypairsAreUnique() { + SigningKeyGenerator.GeneratedKeypair kp1 = SigningKeyGenerator.generateEd25519(); + SigningKeyGenerator.GeneratedKeypair kp2 = SigningKeyGenerator.generateEd25519(); + + assertFalse(java.util.Arrays.equals(kp1.verificationKey().publicKeyBytes(), + kp2.verificationKey().publicKeyBytes()), + "Two generated keypairs should differ"); + } + + @Test + void generatedKeypair_nullChecks() { + assertThrows(NullPointerException.class, () -> + new SigningKeyGenerator.GeneratedKeypair(null, null, null)); + } + + @Test + void trimLeadingZeros_basic() { + assertArrayEquals(new byte[]{1}, SigningKeyGenerator.trimLeadingZeros(new byte[]{0, 1})); + assertArrayEquals(new byte[]{1, 2}, SigningKeyGenerator.trimLeadingZeros(new byte[]{0, 0, 1, 2})); + assertArrayEquals(new byte[]{1}, SigningKeyGenerator.trimLeadingZeros(new byte[]{1})); + assertArrayEquals(new byte[]{-1, 0}, SigningKeyGenerator.trimLeadingZeros(new byte[]{-1, 0})); + } +} \ No newline at end of file From d98cf3e059d8ef9e6a7bb17dc3875c7bdb42fd5c Mon Sep 17 00:00:00 2001 From: Lobsterdog Contributors Date: Wed, 17 Jun 2026 12:17:11 -0600 Subject: [PATCH 12/21] feat(signing): add IDNA hostname canonicalization to Rfc9421Canonicalizer --- .../server/signing/Rfc9421Canonicalizer.java | 4 +- .../signing/Rfc9421CanonicalizerTest.java | 54 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/Rfc9421Canonicalizer.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/Rfc9421Canonicalizer.java index a87364f..88d1038 100644 --- a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/Rfc9421Canonicalizer.java +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/Rfc9421Canonicalizer.java @@ -263,7 +263,7 @@ static String canonicalizeHost(String host) { // Try IDN ToASCII for international domain names String ascii; try { - ascii = IDN.toASCII(host, IDN.USE_STD3_ASCII_RULES); + ascii = IDN.toASCII(host, IDN.ALLOW_UNASSIGNED | IDN.USE_STD3_ASCII_RULES); } catch (IllegalArgumentException e) { // If IDN conversion fails, just lowercase ascii = host.toLowerCase(java.util.Locale.ROOT); @@ -326,7 +326,7 @@ static String preprocessIdnUri(String uri) throws SigningException { String asciiHost; try { - asciiHost = IDN.toASCII(hostPart, IDN.USE_STD3_ASCII_RULES).toLowerCase(java.util.Locale.ROOT); + asciiHost = IDN.toASCII(hostPart, IDN.ALLOW_UNASSIGNED | IDN.USE_STD3_ASCII_RULES).toLowerCase(java.util.Locale.ROOT); } catch (IllegalArgumentException e) { throw new SigningException("Invalid host in URI: " + hostPart, e); } diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/Rfc9421CanonicalizerTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/Rfc9421CanonicalizerTest.java index 8b8a28e..9539bc4 100644 --- a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/Rfc9421CanonicalizerTest.java +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/Rfc9421CanonicalizerTest.java @@ -226,6 +226,60 @@ void extractAuthority_ipv6WithPort() throws SigningException { assertEquals("[::1]:8443", result); } + @Test + void canonicalizeHost_unicodeHostname() { + String result = Rfc9421Canonicalizer.canonicalizeHost("münchen.example.com"); + assertEquals("xn--mnchen-3ya.example.com", result); + } + + @Test + void canonicalizeHost_ipv6Literal() { + String result = Rfc9421Canonicalizer.canonicalizeHost("[::1]"); + assertEquals("[::1]", result); + } + + @Test + void canonicalizeHost_ipv6Uppercase() { + String result = Rfc9421Canonicalizer.canonicalizeHost("[2001:DB8::1]"); + assertEquals("[2001:db8::1]", result); + } + + @Test + void canonicalizeHost_asciiHostname() { + String result = Rfc9421Canonicalizer.canonicalizeHost("Example.COM"); + assertEquals("example.com", result); + } + + @Test + void canonicalizeTargetUri_idnHostname() throws SigningException { + String result = Rfc9421Canonicalizer.canonicalizeTargetUri("https://münchen.example.com/path"); + assertEquals("https://xn--mnchen-3ya.example.com/path", result); + } + + @Test + void extractAuthority_idnHostname() throws SigningException { + String result = Rfc9421Canonicalizer.extractAuthority("https://münchen.example.com/path"); + assertEquals("xn--mnchen-3ya.example.com", result); + } + + @Test + void canonicalizeTargetUri_ipv6Literal() throws SigningException { + String result = Rfc9421Canonicalizer.canonicalizeTargetUri("https://[::1]/path"); + assertEquals("https://[::1]/path", result); + } + + @Test + void canonicalizeTargetUri_defaultPort443Stripped() throws SigningException { + String result = Rfc9421Canonicalizer.canonicalizeTargetUri("https://example.com:443/path"); + assertEquals("https://example.com/path", result); + } + + @Test + void canonicalizeTargetUri_defaultPort80Stripped() throws SigningException { + String result = Rfc9421Canonicalizer.canonicalizeTargetUri("http://example.com:80/path"); + assertEquals("http://example.com/path", result); + } + private JsonNode loadResource(String path) throws IOException { try (InputStream is = getClass().getResourceAsStream(path)) { if (is == null) { From 15ebe32a20c4781cae43a91adbd29973337928b8 Mon Sep 17 00:00:00 2001 From: Lobsterdog Contributors Date: Wed, 17 Jun 2026 13:31:18 -0600 Subject: [PATCH 13/21] feat(signing): add key origin consistency check and eTLD+1 utility --- .../adcp/server/signing/ETldPlusOne.java | 145 ++++++++++++++++++ .../server/signing/KeyOriginCheckResult.java | 32 ++++ .../signing/KeyOriginConsistencyCheck.java | 130 ++++++++++++++++ .../adcp/server/signing/ETldPlusOneTest.java | 65 ++++++++ .../KeyOriginConsistencyCheckTest.java | 105 +++++++++++++ 5 files changed, 477 insertions(+) create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/ETldPlusOne.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/KeyOriginCheckResult.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/KeyOriginConsistencyCheck.java create mode 100644 adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/ETldPlusOneTest.java create mode 100644 adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/KeyOriginConsistencyCheckTest.java diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/ETldPlusOne.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/ETldPlusOne.java new file mode 100644 index 0000000..f91eee4 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/ETldPlusOne.java @@ -0,0 +1,145 @@ +package org.adcontextprotocol.adcp.server.signing; + +import org.jspecify.annotations.Nullable; + +import java.net.IDN; +import java.util.Set; + +/** + * Effective TLD+1 (registrable domain) extraction for the key-origin + * consistency check (AdCP #3690). + * + *

Computes the eTLD+1 (also called "registrable domain") of a hostname. + * Two hosts sharing an eTLD+1 are considered same-origin for the purpose of + * the key-origin binding. Hosts where the eTLD+1 cannot be derived (raw IPs, + * single-label hosts, hosts that are themselves public suffixes) yield + * {@code null} — callers must treat {@code null} as a binding failure, not + * a soft skip. + * + *

Uses a hardcoded set of public suffixes covering the most common TLDs. + * Java's {@link IDN} handles IDN (internationalized domain name) + * canonicalization. + */ +public final class ETldPlusOne { + + private ETldPlusOne() {} + + private static final Set PUBLIC_SUFFIXES = Set.of( + "com", "net", "org", "edu", "gov", "mil", "io", "co", "co.uk", + "co.jp", "co.kr", "co.nz", "co.za", "co.in", + "com.au", "com.br", "com.cn", "com.de", "com.es", "com.fr", + "com.in", "com.it", "com.mx", "com.nl", "com.sg", "com.tr", + "com.tw", "com.uk", "com.ua", + "ac.uk", "org.uk", "me.uk", "gov.uk", + "org.au", "net.au", "edu.au", + "ne.jp", "or.jp", "ac.jp", + "dev", "app", "page", "cloud", "site", "live", + "info", "biz", "name", "mobi", "pro", "tv", "cc", + "de", "fr", "it", "es", "nl", "pl", "ru", "se", "no", "fi", + "dk", "at", "ch", "be", "cz", "pt", "ro", "hu", "gr", "ie", + "nz", "za", "sg", "hk", "tw", "jp", "kr", "in", "br", "mx", + "ar", "cl", "pe", "ve", "ua", + "vercel.app", "netlify.app", "github.io", "gitlab.io", + "herokuapp.com", "azurewebsites.net", "pages.dev", + "cloudfront.net", "s3.amazonaws.com", "s3-website.amazonaws.com", + "blob.core.windows.net", "weebly.com", "wordpress.com", + "blogspot.com", "shopify.com", "square.site", + "amazonaws.com", "googleapis.com", + "onrender.com", "fly.dev", "railway.app", + "glitch.me", "repl.co", "deno.dev" + ); + + /** + * Extract the eTLD+1 (registrable domain) from a hostname. + * + *

Returns {@code null} when: + *

    + *
  • The host is an IP literal (v4 or v6)
  • + *
  • The host is a single label (e.g. {@code localhost})
  • + *
  • The host is itself a public suffix (e.g. {@code co.uk})
  • + *
+ * + * @param host the hostname to extract from + * @return the eTLD+1, or {@code null} if it cannot be derived + */ + public static @Nullable String extract(String host) { + if (host == null || host.isEmpty()) { + return null; + } + + if (isIpLiteral(host)) { + return null; + } + + String normalized = IDN.toASCII(host.toLowerCase()); + if (normalized.isEmpty()) { + return null; + } + if (normalized.endsWith(".")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + + String[] labels = normalized.split("\\."); + if (labels.length < 2) { + return null; + } + + int suffixStart = findPublicSuffixStart(labels); + if (suffixStart < 0) { + if (labels.length >= 2) { + return normalized; + } + return null; + } + + if (suffixStart == 0) { + return null; + } + + String result = joinLabels(labels, suffixStart - 1, labels.length - 1); + + if (PUBLIC_SUFFIXES.contains(result)) { + return null; + } + + return result; + } + + private static int findPublicSuffixStart(String[] labels) { + for (int start = 0; start < labels.length; start++) { + String candidate = joinLabels(labels, start, labels.length - 1); + if (PUBLIC_SUFFIXES.contains(candidate)) { + return start; + } + } + return -1; + } + + private static boolean isIpLiteral(String host) { + if (host.startsWith("[") && host.endsWith("]")) { + return true; + } + try { + String[] parts = host.split("\\."); + if (parts.length == 4) { + for (String part : parts) { + int val = Integer.parseInt(part); + if (val < 0 || val > 255) return false; + } + return true; + } + } catch (NumberFormatException e) { + return false; + } + return false; + } + + private static String joinLabels(String[] labels, int from, int to) { + StringBuilder sb = new StringBuilder(); + for (int i = from; i <= to; i++) { + if (i > from) sb.append('.'); + sb.append(labels[i]); + } + return sb.toString(); + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/KeyOriginCheckResult.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/KeyOriginCheckResult.java new file mode 100644 index 0000000..b60a9bb --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/KeyOriginCheckResult.java @@ -0,0 +1,32 @@ +package org.adcontextprotocol.adcp.server.signing; + +import org.jspecify.annotations.Nullable; + +/** + * Sealed interface for key origin consistency check results. + * + * @see KeyOriginConsistencyCheck + */ +public sealed interface KeyOriginCheckResult { + + /** + * The JWKS URI host matches the declared origin for the purpose. + */ + record Consistent() implements KeyOriginCheckResult {} + + /** + * The JWKS URI host does not match the declared origin, or the purpose + * is missing from the key_origins map. + * + * @param errorCode the spec error code (e.g. + * {@code request_signature_key_origin_mismatch} or + * {@code request_signature_key_origin_missing}) + * @param reason human-readable description of the mismatch + */ + record Inconsistent(String errorCode, String reason) implements KeyOriginCheckResult { + public Inconsistent { + if (errorCode == null) throw new NullPointerException("errorCode"); + if (reason == null) throw new NullPointerException("reason"); + } + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/KeyOriginConsistencyCheck.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/KeyOriginConsistencyCheck.java new file mode 100644 index 0000000..6fe6a36 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/KeyOriginConsistencyCheck.java @@ -0,0 +1,130 @@ +package org.adcontextprotocol.adcp.server.signing; + +import org.jspecify.annotations.Nullable; + +import java.net.URI; +import java.util.Map; + +/** + * Key origin consistency check per AdCP #3690. + * + *

When verifying a signature, the verifier checks that the JWKS source + * matches the expected origin (e.g., the seller's {@code jwks_uri} from + * {@code adagents.json} matches the domain the request came from). This + * defends against the shared-tenancy spoof where an attacker stands up a + * brand.json that lists a counterparty's legitimate {@code jwks_uri} while + * the counterparty's own capabilities advertise a different origin. + * + *

Callers MUST skip this check for publisher-pinned JWKS sources. The + * check is mandatory only when the JWKS source was the operator brand.json. + * + * @see KeyOriginCheckResult + */ +public final class KeyOriginConsistencyCheck { + + private KeyOriginConsistencyCheck() {} + + /** + * Check that the resolved JWKS URI host matches the declared origin for + * the given purpose. + * + * @param jwksUri the JWKS URI the verifier resolved via the brand.json chain + * @param keyOrigins the {@code identity.key_origins} map from the agent's + * capabilities response; {@code null} is treated as empty + * @param purpose the purpose under check (e.g. "request_signing", + * "webhook_signing") + * @param codeFamily the error code family: "request" or "webhook" + * @return a {@link KeyOriginCheckResult} + */ + public static KeyOriginCheckResult check( + String jwksUri, + @Nullable Map keyOrigins, + String purpose, + String codeFamily) { + + Map origins = keyOrigins != null ? keyOrigins : Map.of(); + String declared = origins.get(purpose); + + if (declared == null) { + String errorCode = "request".equals(codeFamily) + ? "request_signature_key_origin_missing" + : "webhook_signature_key_origin_missing"; + return new KeyOriginCheckResult.Inconsistent( + errorCode, + "identity.key_origins." + purpose + " declaration missing"); + } + + String actualHost = extractHost(jwksUri); + String declaredHost = extractHost(declared); + + if (actualHost == null || declaredHost == null || !actualHost.equals(declaredHost)) { + String errorCode = "request".equals(codeFamily) + ? "request_signature_key_origin_mismatch" + : "webhook_signature_key_origin_mismatch"; + String expectedOrigin = declaredHost != null ? declaredHost : declared; + String actualOrigin = actualHost != null ? actualHost : jwksUri; + return new KeyOriginCheckResult.Inconsistent( + errorCode, + "identity.key_origins." + purpose + " declares '" + + expectedOrigin + "' but resolved jwks_uri host is '" + + actualOrigin + "'"); + } + + return new KeyOriginCheckResult.Consistent(); + } + + /** + * Overload with default code family "request". + */ + public static KeyOriginCheckResult check( + String jwksUri, + @Nullable Map keyOrigins, + String purpose) { + return check(jwksUri, keyOrigins, purpose, "request"); + } + + static @Nullable String extractHost(String value) { + if (value == null || value.isBlank()) { + return null; + } + + String stripped = value.strip(); + + if (stripped.contains("://")) { + try { + URI uri = URI.create(stripped); + String host = uri.getHost(); + if (host != null && !host.isEmpty()) { + return canonicalizeHost(host); + } + } catch (Exception e) { + return null; + } + } + + String withScheme = "https://" + stripped; + try { + URI uri = URI.create(withScheme); + String host = uri.getHost(); + if (host != null && !host.isEmpty()) { + return canonicalizeHost(host); + } + } catch (Exception e) { + return null; + } + + return null; + } + + private static String canonicalizeHost(String host) { + String h = host.toLowerCase(); + if (h.endsWith(".")) { + h = h.substring(0, h.length() - 1); + } + try { + return java.net.IDN.toASCII(h); + } catch (Exception e) { + return h; + } + } +} \ No newline at end of file diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/ETldPlusOneTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/ETldPlusOneTest.java new file mode 100644 index 0000000..a130343 --- /dev/null +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/ETldPlusOneTest.java @@ -0,0 +1,65 @@ +package org.adcontextprotocol.adcp.server.signing; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ETldPlusOneTest { + + @Test + void extract_standardDomain_returnsRegistrableDomain() { + assertEquals("example.com", ETldPlusOne.extract("www.example.com")); + assertEquals("example.com", ETldPlusOne.extract("example.com")); + assertEquals("example.co.uk", ETldPlusOne.extract("www.example.co.uk")); + assertEquals("example.co.uk", ETldPlusOne.extract("sub.example.co.uk")); + } + + @Test + void extract_multiLevelSubdomain_returnsRegistrableDomain() { + assertEquals("example.com", ETldPlusOne.extract("a.b.c.example.com")); + } + + @Test + void extract_ipLiteral_returnsNull() { + assertNull(ETldPlusOne.extract("192.168.1.1")); + assertNull(ETldPlusOne.extract("[2001:db8::1]")); + } + + @Test + void extract_singleLabel_returnsNull() { + assertNull(ETldPlusOne.extract("localhost")); + assertNull(ETldPlusOne.extract("intranet")); + } + + @Test + void extract_publicSuffix_itself_returnsNull() { + assertNull(ETldPlusOne.extract("co.uk")); + assertNull(ETldPlusOne.extract("com")); + assertNull(ETldPlusOne.extract("github.io")); + } + + @Test + void extract_nullOrEmpty_returnsNull() { + assertNull(ETldPlusOne.extract(null)); + assertNull(ETldPlusOne.extract("")); + } + + @Test + void extract_platformSubdomain_returnsRegistrableDomain() { + assertEquals("example.vercel.app", ETldPlusOne.extract("example.vercel.app")); + assertEquals("example.github.io", ETldPlusOne.extract("example.github.io")); + assertEquals("example.pages.dev", ETldPlusOne.extract("sub.example.pages.dev")); + } + + @Test + void extract_caseInsensitive() { + assertEquals("example.com", ETldPlusOne.extract("WWW.EXAMPLE.COM")); + assertEquals("example.com", ETldPlusOne.extract("Example.Com")); + } + + @Test + void extract_trailingDot_handled() { + String result = ETldPlusOne.extract("www.example.com."); + assertEquals("example.com", result); + } +} \ No newline at end of file diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/KeyOriginConsistencyCheckTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/KeyOriginConsistencyCheckTest.java new file mode 100644 index 0000000..a63522c --- /dev/null +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/KeyOriginConsistencyCheckTest.java @@ -0,0 +1,105 @@ +package org.adcontextprotocol.adcp.server.signing; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class KeyOriginConsistencyCheckTest { + + @Test + void check_matchingOrigins_returnsConsistent() { + KeyOriginCheckResult result = KeyOriginConsistencyCheck.check( + "https://keys.brand.com/.well-known/jwks.json", + Map.of("request_signing", "https://keys.brand.com"), + "request_signing"); + assertInstanceOf(KeyOriginCheckResult.Consistent.class, result); + } + + @Test + void check_mismatchedOrigins_returnsInconsistent() { + KeyOriginCheckResult result = KeyOriginConsistencyCheck.check( + "https://keys.brand.com/.well-known/jwks.json", + Map.of("request_signing", "https://keys.attacker.com"), + "request_signing"); + assertInstanceOf(KeyOriginCheckResult.Inconsistent.class, result); + KeyOriginCheckResult.Inconsistent inc = (KeyOriginCheckResult.Inconsistent) result; + assertEquals("request_signature_key_origin_mismatch", inc.errorCode()); + assertTrue(inc.reason().contains("attacker.com")); + } + + @Test + void check_missingPurpose_returnsInconsistentWithMissingCode() { + KeyOriginCheckResult result = KeyOriginConsistencyCheck.check( + "https://keys.brand.com/.well-known/jwks.json", + Map.of("webhook_signing", "https://keys.brand.com"), + "request_signing"); + assertInstanceOf(KeyOriginCheckResult.Inconsistent.class, result); + KeyOriginCheckResult.Inconsistent inc = (KeyOriginCheckResult.Inconsistent) result; + assertEquals("request_signature_key_origin_missing", inc.errorCode()); + } + + @Test + void check_nullKeyOrigins_treatedAsMissing() { + KeyOriginCheckResult result = KeyOriginConsistencyCheck.check( + "https://keys.brand.com/.well-known/jwks.json", + null, + "request_signing"); + assertInstanceOf(KeyOriginCheckResult.Inconsistent.class, result); + assertEquals("request_signature_key_origin_missing", + ((KeyOriginCheckResult.Inconsistent) result).errorCode()); + } + + @Test + void check_webhookCodeFamily_usesWebhookErrorCodes() { + KeyOriginCheckResult result = KeyOriginConsistencyCheck.check( + "https://keys.brand.com/.well-known/jwks.json", + Map.of("webhook_signing", "https://keys.attacker.com"), + "webhook_signing", + "webhook"); + assertInstanceOf(KeyOriginCheckResult.Inconsistent.class, result); + assertEquals("webhook_signature_key_origin_mismatch", + ((KeyOriginCheckResult.Inconsistent) result).errorCode()); + } + + @Test + void check_webhookMissingPurpose_usesWebhookMissingCode() { + KeyOriginCheckResult result = KeyOriginConsistencyCheck.check( + "https://keys.brand.com/.well-known/jwks.json", + null, + "webhook_signing", + "webhook"); + assertInstanceOf(KeyOriginCheckResult.Inconsistent.class, result); + assertEquals("webhook_signature_key_origin_missing", + ((KeyOriginCheckResult.Inconsistent) result).errorCode()); + } + + @Test + void check_bareHostInKeyOrigins_matchesUrlHost() { + KeyOriginCheckResult result = KeyOriginConsistencyCheck.check( + "https://keys.brand.com/.well-known/jwks.json", + Map.of("request_signing", "keys.brand.com"), + "request_signing"); + assertInstanceOf(KeyOriginCheckResult.Consistent.class, result); + } + + @Test + void check_defaultCodeFamily_isRequest() { + KeyOriginCheckResult result = KeyOriginConsistencyCheck.check( + "https://keys.brand.com/.well-known/jwks.json", + null, + "request_signing"); + assertInstanceOf(KeyOriginCheckResult.Inconsistent.class, result); + assertTrue(((KeyOriginCheckResult.Inconsistent) result).errorCode().startsWith("request_")); + } + + @Test + void check_invalidJwksUri_returnsInconsistentMismatch() { + KeyOriginCheckResult result = KeyOriginConsistencyCheck.check( + "not-a-valid-url", + Map.of("request_signing", "https://keys.brand.com"), + "request_signing"); + assertInstanceOf(KeyOriginCheckResult.Inconsistent.class, result); + } +} \ No newline at end of file From 7147c6102d6e8875ccc8ba7f08db983e76485364 Mon Sep 17 00:00:00 2001 From: Lobsterdog Contributors Date: Wed, 17 Jun 2026 13:34:11 -0600 Subject: [PATCH 14/21] feat(signing): add JWS verification for revocation lists --- .../adcp/server/signing/jws/JwsDocument.java | 17 ++ .../signing/jws/JwsVerificationResult.java | 41 +++ .../adcp/server/signing/jws/JwsVerifier.java | 233 ++++++++++++++++++ .../adcp/server/signing/jws/package-info.java | 11 + .../server/signing/jws/JwsVerifierTest.java | 216 ++++++++++++++++ 5 files changed, 518 insertions(+) create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jws/JwsDocument.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jws/JwsVerificationResult.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jws/JwsVerifier.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jws/package-info.java create mode 100644 adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/jws/JwsVerifierTest.java diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jws/JwsDocument.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jws/JwsDocument.java new file mode 100644 index 0000000..35c8fe6 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jws/JwsDocument.java @@ -0,0 +1,17 @@ +package org.adcontextprotocol.adcp.server.signing.jws; + +/** + * Parsed JWS document with header, payload, and signature bytes. + * + * @param b64ProtectedHeader the base64url-encoded protected header (original wire form) + * @param b64Payload the base64url-encoded payload (original wire form) + * @param signature the decoded signature bytes + */ +public record JwsDocument(String b64ProtectedHeader, String b64Payload, byte[] signature) { + + public JwsDocument { + if (b64ProtectedHeader == null) throw new NullPointerException("b64ProtectedHeader"); + if (b64Payload == null) throw new NullPointerException("b64Payload"); + if (signature == null) throw new NullPointerException("signature"); + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jws/JwsVerificationResult.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jws/JwsVerificationResult.java new file mode 100644 index 0000000..d78f82d --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jws/JwsVerificationResult.java @@ -0,0 +1,41 @@ +package org.adcontextprotocol.adcp.server.signing.jws; + +import org.jspecify.annotations.Nullable; + +import java.security.PublicKey; +import java.security.Signature; +import java.util.Base64; +import java.util.Map; +import java.util.Set; + +/** + * Sealed interface for JWS verification results. + * + * @see JwsVerifier + */ +public sealed interface JwsVerificationResult { + + /** + * JWS signature verified successfully and payload decoded. + * + * @param payload the decoded payload as a string + * @param kid the key identifier from the JWS header + */ + record Valid(String payload, String kid) implements JwsVerificationResult { + public Valid { + if (payload == null) throw new NullPointerException("payload"); + if (kid == null) throw new NullPointerException("kid"); + } + } + + /** + * JWS verification failed. + * + * @param reason human-readable description of the failure + */ + record Invalid(String reason) implements JwsVerificationResult { + public Invalid { + if (reason == null) throw new NullPointerException("reason"); + } + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jws/JwsVerifier.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jws/JwsVerifier.java new file mode 100644 index 0000000..adeb181 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jws/JwsVerifier.java @@ -0,0 +1,233 @@ +package org.adcontextprotocol.adcp.server.signing.jws; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.adcontextprotocol.adcp.signing.VerificationKey; +import org.jspecify.annotations.Nullable; + +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.Signature; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import java.util.Map; +import java.util.Set; + +/** + * JWS (JSON Web Signature) verification for AdCP revocation lists. + * + *

Supports both compact serialization ({@code header.payload.signature}) + * and JSON general serialization. AdCP revocation lists use a narrow + * allowed-alg set ({@code EdDSA} and {@code ES256}). + * + *

The verification process: + *

    + *
  1. Parse the JWS (compact or JSON general)
  2. + *
  3. Decode and validate the protected header
  4. + *
  5. Reject if {@code alg} is absent, is {@code "none"}, or not in the allowed set
  6. + *
  7. Optionally validate {@code typ} matches an expected value
  8. + *
  9. Resolve the key via the provided key resolver
  10. + *
  11. Verify the signature
  12. + *
  13. Decode the payload
  14. + *
+ * + * @see JwsVerificationResult + * @see JwsDocument + */ +public final class JwsVerifier { + + private JwsVerifier() {} + + private static final ObjectMapper OM = new ObjectMapper(); + + private static final Set ALLOWED_ALGS = Set.of("EdDSA", "ES256"); + + private static final Map JWS_ALG_TO_JCA = Map.of( + "EdDSA", "Ed25519", + "ES256", "SHA256withECDSAinP1363Format" + ); + + /** + * Verify a compact or JSON-general JWS document using the provided key. + * + *

The document can be a compact string ({@code header.payload.signature}) + * or a JSON object with {@code payload} and {@code signatures} fields. + * + * @param jwsDocument the JWS document as a string + * @param key the verification key + * @return a {@link JwsVerificationResult} + */ + public static JwsVerificationResult verify(String jwsDocument, VerificationKey key) { + return verify(jwsDocument, key, null); + } + + /** + * Verify a compact or JSON-general JWS document with an optional expected + * {@code typ} check. + * + * @param jwsDocument the JWS document as a string + * @param key the verification key + * @param expectedTyp expected {@code typ} header value, or {@code null} to skip + * @return a {@link JwsVerificationResult} + */ + public static JwsVerificationResult verify(String jwsDocument, VerificationKey key, + @Nullable String expectedTyp) { + JwsDocument doc; + try { + doc = parse(jwsDocument); + } catch (IllegalArgumentException e) { + return new JwsVerificationResult.Invalid("Failed to parse JWS: " + e.getMessage()); + } + return verifyDocument(doc, key, expectedTyp); + } + + /** + * Parse a JWS document (compact or JSON general) into its components. + * + * @param jwsDocument the JWS string + * @return the parsed document + * @throws IllegalArgumentException if parsing fails + */ + public static JwsDocument parse(String jwsDocument) { + String trimmed = jwsDocument.strip(); + if (trimmed.startsWith("{")) { + return parseJsonGeneral(trimmed); + } + return parseCompact(trimmed); + } + + /** + * Verify a parsed JWS document. + */ + public static JwsVerificationResult verifyDocument(JwsDocument doc, VerificationKey key, + @Nullable String expectedTyp) { + + JsonNode header; + try { + byte[] headerBytes = Base64.getUrlDecoder().decode(doc.b64ProtectedHeader()); + header = OM.readTree(headerBytes); + } catch (Exception e) { + return new JwsVerificationResult.Invalid("Failed to decode JWS header: " + e.getMessage()); + } + + if (!header.isObject()) { + return new JwsVerificationResult.Invalid("JWS header is not a JSON object"); + } + + JsonNode algNode = header.get("alg"); + if (algNode == null || !algNode.isTextual()) { + return new JwsVerificationResult.Invalid("JWS header missing or invalid 'alg'"); + } + String alg = algNode.asText(); + if ("none".equals(alg) || !ALLOWED_ALGS.contains(alg)) { + return new JwsVerificationResult.Invalid("JWS alg '" + alg + "' not allowed"); + } + + if (expectedTyp != null) { + JsonNode typNode = header.get("typ"); + if (typNode == null || !expectedTyp.equals(typNode.asText())) { + String actual = typNode != null ? typNode.asText() : "(missing)"; + return new JwsVerificationResult.Invalid( + "JWS typ '" + actual + "' does not match expected '" + expectedTyp + "'"); + } + } + + JsonNode critNode = header.get("crit"); + if (critNode != null && critNode.isArray() && !critNode.isEmpty()) { + return new JwsVerificationResult.Invalid("JWS 'crit' header is not supported"); + } + + JsonNode kidNode = header.get("kid"); + if (kidNode == null || !kidNode.isTextual() || kidNode.asText().isEmpty()) { + return new JwsVerificationResult.Invalid("JWS header must include a non-empty 'kid'"); + } + String kid = kidNode.asText(); + + String jcaAlg = JWS_ALG_TO_JCA.get(alg); + if (jcaAlg == null) { + return new JwsVerificationResult.Invalid("No JCA mapping for alg '" + alg + "'"); + } + + String signingInput = doc.b64ProtectedHeader() + "." + doc.b64Payload(); + try { + PublicKey publicKey = key.asJcaKey(); + if (publicKey == null) { + return new JwsVerificationResult.Invalid("Key resolution returned null for kid: " + kid); + } + Signature verifier = Signature.getInstance(jcaAlg); + verifier.initVerify(publicKey); + verifier.update(signingInput.getBytes(StandardCharsets.UTF_8)); + if (!verifier.verify(doc.signature())) { + return new JwsVerificationResult.Invalid("Signature verification failed for kid: " + kid); + } + } catch (Exception e) { + return new JwsVerificationResult.Invalid("Signature verification error: " + e.getMessage()); + } + + String payload; + try { + byte[] payloadBytes = Base64.getUrlDecoder().decode(doc.b64Payload()); + payload = new String(payloadBytes, StandardCharsets.UTF_8); + } catch (Exception e) { + return new JwsVerificationResult.Invalid("Failed to decode JWS payload: " + e.getMessage()); + } + + return new JwsVerificationResult.Valid(payload, kid); + } + + private static JwsDocument parseCompact(String token) { + String[] parts = token.split("\\."); + if (parts.length != 3) { + throw new IllegalArgumentException( + "Compact JWS must have exactly 3 dot-separated segments, got " + parts.length); + } + if (parts[0].isEmpty() || parts[1].isEmpty() || parts[2].isEmpty()) { + throw new IllegalArgumentException("Compact JWS has an empty segment"); + } + byte[] signature; + try { + signature = Base64.getUrlDecoder().decode(parts[2]); + } catch (Exception e) { + throw new IllegalArgumentException("Compact JWS signature is not valid base64url: " + e.getMessage()); + } + return new JwsDocument(parts[0], parts[1], signature); + } + + private static JwsDocument parseJsonGeneral(String json) { + JsonNode doc; + try { + doc = OM.readTree(json); + } catch (Exception e) { + throw new IllegalArgumentException("JWS JSON document is not valid JSON: " + e.getMessage()); + } + if (!doc.isObject()) { + throw new IllegalArgumentException("JWS general JSON document must be an object"); + } + JsonNode payloadNode = doc.get("payload"); + if (payloadNode == null || !payloadNode.isTextual()) { + throw new IllegalArgumentException("JWS general JSON document must have 'payload' string"); + } + JsonNode sigsNode = doc.get("signatures"); + if (sigsNode == null || !sigsNode.isArray() || sigsNode.isEmpty()) { + throw new IllegalArgumentException("JWS general JSON 'signatures' must be a non-empty array"); + } + if (sigsNode.size() > 1) { + throw new IllegalArgumentException( + "JWS general JSON 'signatures' with multiple entries is not supported"); + } + JsonNode entry = sigsNode.get(0); + JsonNode protectedNode = entry.get("protected"); + JsonNode sigNode = entry.get("signature"); + if (protectedNode == null || !protectedNode.isTextual() || sigNode == null || !sigNode.isTextual()) { + throw new IllegalArgumentException("JWS signature entry missing 'protected' or 'signature'"); + } + byte[] signature; + try { + signature = Base64.getUrlDecoder().decode(sigNode.asText()); + } catch (Exception e) { + throw new IllegalArgumentException("JWS signature is not valid base64url: " + e.getMessage()); + } + return new JwsDocument(protectedNode.asText(), payloadNode.asText(), signature); + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jws/package-info.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jws/package-info.java new file mode 100644 index 0000000..f24c20c --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/jws/package-info.java @@ -0,0 +1,11 @@ +/** + * JWS verification for AdCP revocation lists. + * + *

Supports both compact serialization ({@code header.payload.signature}) + * and JSON general serialization, with EdDSA and ES256 algorithms. + * + * @see JwsVerifier + * @see JwsDocument + */ +@org.jspecify.annotations.NullMarked +package org.adcontextprotocol.adcp.server.signing.jws; \ No newline at end of file diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/jws/JwsVerifierTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/jws/JwsVerifierTest.java new file mode 100644 index 0000000..c60eab4 --- /dev/null +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/jws/JwsVerifierTest.java @@ -0,0 +1,216 @@ +package org.adcontextprotocol.adcp.server.signing.jws; + +import org.adcontextprotocol.adcp.server.signing.InProcessKeyGenerator; +import org.adcontextprotocol.adcp.signing.VerificationKey; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.Signature; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.*; + +class JwsVerifierTest { + + private KeyPair ed25519KeyPair; + private VerificationKey ed25519VerificationKey; + + @BeforeEach + void setUp() throws Exception { + ed25519KeyPair = InProcessKeyGenerator.generateEd25519(); + ed25519VerificationKey = new VerificationKey( + "test-kid-ed25519", + "Ed25519", + ed25519KeyPair.getPublic().getEncoded(), + null); + } + + @Test + void parse_compactJws_threeParts() { + String header = Base64.getUrlEncoder().withoutPadding() + .encodeToString("{\"alg\":\"EdDSA\",\"kid\":\"test\"}".getBytes(StandardCharsets.UTF_8)); + String payload = Base64.getUrlEncoder().withoutPadding() + .encodeToString("{\"iss\":\"https://example.com\"}".getBytes(StandardCharsets.UTF_8)); + String sig = Base64.getUrlEncoder().withoutPadding() + .encodeToString(new byte[64]); + + JwsDocument doc = JwsVerifier.parse(header + "." + payload + "." + sig); + assertEquals(header, doc.b64ProtectedHeader()); + assertEquals(payload, doc.b64Payload()); + assertArrayEquals(new byte[64], doc.signature()); + } + + @Test + void parse_compactJws_wrongPartCount_throws() { + assertThrows(IllegalArgumentException.class, + () -> JwsVerifier.parse("header.payload")); + assertThrows(IllegalArgumentException.class, + () -> JwsVerifier.parse("a.b.c.d")); + } + + @Test + void parse_compactJws_emptySegment_throws() { + assertThrows(IllegalArgumentException.class, + () -> JwsVerifier.parse(".payload.c2ln")); + } + + @Test + void parse_jsonGeneralJws_valid() { + String header = Base64.getUrlEncoder().withoutPadding() + .encodeToString("{\"alg\":\"EdDSA\",\"kid\":\"test\"}".getBytes(StandardCharsets.UTF_8)); + String payload = Base64.getUrlEncoder().withoutPadding() + .encodeToString("{\"iss\":\"https://example.com\"}".getBytes(StandardCharsets.UTF_8)); + String sig = Base64.getUrlEncoder().withoutPadding() + .encodeToString(new byte[64]); + + String json = "{\"payload\":\"" + payload + "\",\"signatures\":[{\"protected\":\"" + + header + "\",\"signature\":\"" + sig + "\"}]}"; + JwsDocument doc = JwsVerifier.parse(json); + assertEquals(header, doc.b64ProtectedHeader()); + assertEquals(payload, doc.b64Payload()); + } + + @Test + void parse_jsonGeneralJws_multipleSignatures_throws() { + String json = "{\"payload\":\"cGF5bG9hZA\",\"signatures\":" + + "[{\"protected\":\"a\",\"signature\":\"b\"},{\"protected\":\"c\",\"signature\":\"d\"}]}"; + assertThrows(IllegalArgumentException.class, () -> JwsVerifier.parse(json)); + } + + @Test + void verify_validCompactJws_ed25519() throws Exception { + String headerJson = "{\"alg\":\"EdDSA\",\"kid\":\"test-kid-ed25519\",\"typ\":\"adcp-gov-revocation+jws\"}"; + String b64Header = Base64.getUrlEncoder().withoutPadding() + .encodeToString(headerJson.getBytes(StandardCharsets.UTF_8)); + String payloadJson = "{\"iss\":\"https://example.com\",\"revoked_kids\":[]}"; + String b64Payload = Base64.getUrlEncoder().withoutPadding() + .encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8)); + + String signingInput = b64Header + "." + b64Payload; + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(ed25519KeyPair.getPrivate()); + signer.update(signingInput.getBytes(StandardCharsets.UTF_8)); + byte[] signature = signer.sign(); + String b64Signature = Base64.getUrlEncoder().withoutPadding().encodeToString(signature); + + String compactJws = b64Header + "." + b64Payload + "." + b64Signature; + + JwsVerificationResult result = JwsVerifier.verify(compactJws, ed25519VerificationKey, + "adcp-gov-revocation+jws"); + assertInstanceOf(JwsVerificationResult.Valid.class, result); + JwsVerificationResult.Valid valid = (JwsVerificationResult.Valid) result; + assertTrue(valid.payload().contains("example.com")); + assertEquals("test-kid-ed25519", valid.kid()); + } + + @Test + void verify_wrongKey_returnsInvalid() throws Exception { + String headerJson = "{\"alg\":\"EdDSA\",\"kid\":\"test-kid-ed25519\"}"; + String b64Header = Base64.getUrlEncoder().withoutPadding() + .encodeToString(headerJson.getBytes(StandardCharsets.UTF_8)); + String b64Payload = Base64.getUrlEncoder().withoutPadding() + .encodeToString("{\"iss\":\"https://example.com\"}".getBytes(StandardCharsets.UTF_8)); + + String signingInput = b64Header + "." + b64Payload; + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(ed25519KeyPair.getPrivate()); + signer.update(signingInput.getBytes(StandardCharsets.UTF_8)); + byte[] signature = signer.sign(); + String b64Signature = Base64.getUrlEncoder().withoutPadding().encodeToString(signature); + + String compactJws = b64Header + "." + b64Payload + "." + b64Signature; + + KeyPair wrongKeyPair = InProcessKeyGenerator.generateEd25519(); + VerificationKey wrongKey = new VerificationKey( + "test-kid-ed25519", "Ed25519", wrongKeyPair.getPublic().getEncoded(), null); + + JwsVerificationResult result = JwsVerifier.verify(compactJws, wrongKey); + assertInstanceOf(JwsVerificationResult.Invalid.class, result); + assertTrue(((JwsVerificationResult.Invalid) result).reason().contains("Signature verification failed")); + } + + @Test + void verify_algNone_returnsInvalid() { + String headerJson = "{\"alg\":\"none\",\"kid\":\"test\"}"; + String b64Header = Base64.getUrlEncoder().withoutPadding() + .encodeToString(headerJson.getBytes(StandardCharsets.UTF_8)); + String b64Payload = Base64.getUrlEncoder().withoutPadding() + .encodeToString("{}".getBytes(StandardCharsets.UTF_8)); + String b64Signature = Base64.getUrlEncoder().withoutPadding().encodeToString(new byte[64]); + + String compactJws = b64Header + "." + b64Payload + "." + b64Signature; + + JwsVerificationResult result = JwsVerifier.verify(compactJws, ed25519VerificationKey); + assertInstanceOf(JwsVerificationResult.Invalid.class, result); + assertTrue(((JwsVerificationResult.Invalid) result).reason().contains("not allowed")); + } + + @Test + void verify_typMismatch_returnsInvalid() throws Exception { + String headerJson = "{\"alg\":\"EdDSA\",\"kid\":\"test-kid-ed25519\",\"typ\":\"wrong-typ\"}"; + String b64Header = Base64.getUrlEncoder().withoutPadding() + .encodeToString(headerJson.getBytes(StandardCharsets.UTF_8)); + String b64Payload = Base64.getUrlEncoder().withoutPadding() + .encodeToString("{}".getBytes(StandardCharsets.UTF_8)); + + String signingInput = b64Header + "." + b64Payload; + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(ed25519KeyPair.getPrivate()); + signer.update(signingInput.getBytes(StandardCharsets.UTF_8)); + byte[] signature = signer.sign(); + String b64Signature = Base64.getUrlEncoder().withoutPadding().encodeToString(signature); + + String compactJws = b64Header + "." + b64Payload + "." + b64Signature; + + JwsVerificationResult result = JwsVerifier.verify(compactJws, ed25519VerificationKey, + "adcp-gov-revocation+jws"); + assertInstanceOf(JwsVerificationResult.Invalid.class, result); + assertTrue(((JwsVerificationResult.Invalid) result).reason().contains("typ")); + } + + @Test + void verify_missingKid_returnsInvalid() throws Exception { + String headerJson = "{\"alg\":\"EdDSA\"}"; + String b64Header = Base64.getUrlEncoder().withoutPadding() + .encodeToString(headerJson.getBytes(StandardCharsets.UTF_8)); + String b64Payload = Base64.getUrlEncoder().withoutPadding() + .encodeToString("{}".getBytes(StandardCharsets.UTF_8)); + + String signingInput = b64Header + "." + b64Payload; + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(ed25519KeyPair.getPrivate()); + signer.update(signingInput.getBytes(StandardCharsets.UTF_8)); + byte[] signature = signer.sign(); + String b64Signature = Base64.getUrlEncoder().withoutPadding().encodeToString(signature); + + String compactJws = b64Header + "." + b64Payload + "." + b64Signature; + + JwsVerificationResult result = JwsVerifier.verify(compactJws, ed25519VerificationKey); + assertInstanceOf(JwsVerificationResult.Invalid.class, result); + assertTrue(((JwsVerificationResult.Invalid) result).reason().contains("kid")); + } + + @Test + void verify_tamperedSignature_returnsInvalid() throws Exception { + String headerJson = "{\"alg\":\"EdDSA\",\"kid\":\"test-kid-ed25519\"}"; + String b64Header = Base64.getUrlEncoder().withoutPadding() + .encodeToString(headerJson.getBytes(StandardCharsets.UTF_8)); + String b64Payload = Base64.getUrlEncoder().withoutPadding() + .encodeToString("{}".getBytes(StandardCharsets.UTF_8)); + + String signingInput = b64Header + "." + b64Payload; + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(ed25519KeyPair.getPrivate()); + signer.update(signingInput.getBytes(StandardCharsets.UTF_8)); + byte[] signature = signer.sign(); + signature[0] = (byte) (signature[0] ^ 0xFF); + String b64Signature = Base64.getUrlEncoder().withoutPadding().encodeToString(signature); + + String compactJws = b64Header + "." + b64Payload + "." + b64Signature; + + JwsVerificationResult result = JwsVerifier.verify(compactJws, ed25519VerificationKey); + assertInstanceOf(JwsVerificationResult.Invalid.class, result); + } +} \ No newline at end of file From fada93f16090f85038898537122fae84f4d59ba3 Mon Sep 17 00:00:00 2001 From: Lobsterdog Contributors Date: Wed, 17 Jun 2026 13:36:20 -0600 Subject: [PATCH 15/21] feat(signing): add CachingRevocationChecker with JWS verification --- .../revocation/CachingRevocationChecker.java | 351 ++++++++++++++++++ .../signing/revocation/RevocationResult.java | 11 + .../CachingRevocationCheckerTest.java | 88 +++++ 3 files changed, 450 insertions(+) create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/revocation/CachingRevocationChecker.java create mode 100644 adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/revocation/CachingRevocationCheckerTest.java diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/revocation/CachingRevocationChecker.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/revocation/CachingRevocationChecker.java new file mode 100644 index 0000000..d3ec83d --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/revocation/CachingRevocationChecker.java @@ -0,0 +1,351 @@ +package org.adcontextprotocol.adcp.server.signing.revocation; + +import org.adcontextprotocol.adcp.signing.VerificationKey; +import org.adcontextprotocol.adcp.server.signing.jws.JwsVerificationResult; +import org.adcontextprotocol.adcp.server.signing.jws.JwsVerifier; +import org.jspecify.annotations.Nullable; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.format.DateTimeParseException; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Caching revocation list fetcher and checker per the AdCP governance profile. + * + *

Fetches a revocation list from a URL (e.g., + * {@code https://seller.example.com/.well-known/adcp/revocation.jws}), + * verifies the JWS signature before trusting the content, and caches the + * list with a grace window. If the list is stale (past + * {@code next_update + grace_window}), returns {@link RevocationResult.Stale}. + * + *

Uses single-flight dedup for concurrent fetches to avoid thundering-herd + * issues on cache expiry. + * + *

The grace window defaults to 60 seconds per the AdCP spec. + */ +public final class CachingRevocationChecker { + + private static final long DEFAULT_GRACE_WINDOW_SECONDS = 60; + private static final String REVOCATION_LIST_TYP = "adcp-gov-revocation+jws"; + private static final Set ALLOWED_JWS_ALGS = Set.of("EdDSA", "ES256"); + + private final String revocationUri; + private final VerificationKey verificationKey; + private final HttpClient httpClient; + private final long graceWindowSeconds; + + private volatile @Nullable CachedList cachedList; + private volatile @Nullable Instant lastRefreshAttempt; + private final ReentrantLock fetchLock = new ReentrantLock(); + + /** + * Create a new caching revocation checker. + * + * @param revocationUri the URL to fetch the revocation list from + * @param verificationKey the key to verify the JWS signature + * @param httpClient the HTTP client for fetching (SSRF-safe recommended) + * @param graceWindowSeconds seconds of grace past next_update before stale + */ + public CachingRevocationChecker(String revocationUri, VerificationKey verificationKey, + HttpClient httpClient, long graceWindowSeconds) { + this.revocationUri = revocationUri; + this.verificationKey = verificationKey; + this.httpClient = httpClient; + this.graceWindowSeconds = graceWindowSeconds; + } + + /** + * Create a checker with default 60-second grace window. + */ + public CachingRevocationChecker(String revocationUri, VerificationKey verificationKey, + HttpClient httpClient) { + this(revocationUri, verificationKey, httpClient, DEFAULT_GRACE_WINDOW_SECONDS); + } + + /** + * Check whether a key identifier has been revoked. + * + *

If no cached list exists, fetches one. If the cached list is past + * its {@code next_update}, attempts a refresh. If the list is stale + * beyond the grace window, returns {@link RevocationResult.Stale}. + * + * @param kid the key identifier to check + * @return the revocation result + */ + public RevocationResult check(String kid) { + ensureFresh(); + CachedList current = cachedList; + if (current == null) { + return new RevocationResult.FetchFailed("revocation list not available after fetch attempt"); + } + if (current.revokedKids().contains(kid)) { + return new RevocationResult.Revoked(kid); + } + return new RevocationResult.Valid(); + } + + /** + * Check whether a JTI (JSON Token Identifier) has been revoked. + */ + public boolean isJtiRevoked(String jti) { + ensureFresh(); + CachedList current = cachedList; + if (current == null) { + throw new RevocationListUnavailableException("revocation list not available"); + } + return current.revokedJtis().contains(jti); + } + + /** + * Prime the cache by fetching the revocation list immediately. + */ + public void prime() { + fetchAndVerify(); + } + + private void ensureFresh() { + CachedList current = cachedList; + if (current == null) { + fetchAndVerify(); + return; + } + + Instant nextUpdate = current.nextUpdate(); + Instant now = Instant.now(); + + if (now.isBefore(nextUpdate)) { + return; + } + + Instant lastAttempt = lastRefreshAttempt; + if (lastAttempt != null && now.isBefore(lastAttempt.plusSeconds(60))) { + if (now.isAfter(nextUpdate.plusSeconds(graceWindowSeconds))) { + long staleSeconds = java.time.Duration.between(nextUpdate.plusSeconds(graceWindowSeconds), now).getSeconds(); + throw new RevocationListStaleException("revocation list past next_update + grace", staleSeconds); + } + return; + } + + fetchAndVerify(); + } + + private void fetchAndVerify() { + fetchLock.lock(); + try { + lastRefreshAttempt = Instant.now(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(revocationUri)) + .timeout(Duration.ofSeconds(10)) + .header("Accept", "application/jose+json, application/json, application/jose") + .GET() + .build(); + + HttpResponse response; + try { + response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } catch (Exception e) { + CachedList current = cachedList; + if (current != null && Instant.now().isBefore(current.nextUpdate().plusSeconds(graceWindowSeconds))) { + return; + } + throw new RevocationFetchException("Failed to fetch revocation list: " + e.getMessage(), e); + } + + if (response.statusCode() == 304) { + CachedList current = cachedList; + if (current != null) { + Instant now = Instant.now(); + Duration pollingInterval = Duration.between(current.updated(), current.nextUpdate()); + cachedList = new CachedList( + current.issuer(), + current.updated(), + current.nextUpdate().plus(pollingInterval), + current.revokedKids(), + current.revokedJtis() + ); + } + return; + } + + if (response.statusCode() != 200) { + CachedList current = cachedList; + if (current != null && Instant.now().isBefore(current.nextUpdate().plusSeconds(graceWindowSeconds))) { + return; + } + throw new RevocationFetchException( + "Revocation list returned HTTP " + response.statusCode()); + } + + String body = response.body(); + if (body == null || body.isBlank()) { + throw new RevocationFetchException("Revocation list returned empty body"); + } + + JwsVerificationResult jwsResult = JwsVerifier.verify(body, verificationKey, REVOCATION_LIST_TYP); + if (jwsResult instanceof JwsVerificationResult.Invalid invalid) { + throw new RevocationFetchException("JWS verification failed: " + invalid.reason()); + } + + JwsVerificationResult.Valid valid = (JwsVerificationResult.Valid) jwsResult; + String payload = valid.payload(); + + RevocationListParsed parsed; + try { + parsed = parsePayload(payload); + } catch (IllegalArgumentException e) { + throw new RevocationFetchException("Invalid revocation list payload: " + e.getMessage()); + } + + Instant now = Instant.now(); + if (parsed.updated.isAfter(now.plusSeconds(60))) { + throw new RevocationFetchException("revocation list updated is in the future"); + } + if (!parsed.nextUpdate.isAfter(parsed.updated)) { + throw new RevocationFetchException("revocation list next_update is not after updated"); + } + + CachedList current = cachedList; + if (current != null && parsed.updated.isBefore(current.updated())) { + throw new RevocationFetchException("revocation list updated is older than cached list"); + } + + cachedList = new CachedList( + parsed.issuer, + parsed.updated, + parsed.nextUpdate, + parsed.revokedKids, + parsed.revokedJtis + ); + } finally { + fetchLock.unlock(); + } + } + + private RevocationListParsed parsePayload(String payload) { + com.fasterxml.jackson.databind.JsonNode root; + try { + root = new com.fasterxml.jackson.databind.ObjectMapper().readTree(payload); + } catch (Exception e) { + throw new IllegalArgumentException("Payload is not valid JSON: " + e.getMessage()); + } + + if (!root.isObject()) { + throw new IllegalArgumentException("Payload is not a JSON object"); + } + + String issuer = root.path("issuer").asText(null); + if (issuer == null || issuer.isEmpty()) { + throw new IllegalArgumentException("Missing 'issuer' field"); + } + + String updatedStr = root.path("updated").asText(null); + String nextUpdateStr = root.path("next_update").asText(null); + if (updatedStr == null || nextUpdateStr == null) { + throw new IllegalArgumentException("Missing 'updated' or 'next_update' field"); + } + + Instant updated = parseInstant(updatedStr); + Instant nextUpdate = parseInstant(nextUpdateStr); + + Set revokedKids = java.util.Set.of(); + if (root.has("revoked_kids") && root.get("revoked_kids").isArray()) { + java.util.Set kids = new java.util.HashSet<>(); + for (com.fasterxml.jackson.databind.JsonNode kid : root.get("revoked_kids")) { + kids.add(kid.asText()); + } + revokedKids = kids; + } + + Set revokedJtis = java.util.Set.of(); + if (root.has("revoked_jtis") && root.get("revoked_jtis").isArray()) { + java.util.Set jtis = new java.util.HashSet<>(); + for (com.fasterxml.jackson.databind.JsonNode jti : root.get("revoked_jtis")) { + jtis.add(jti.asText()); + } + revokedJtis = jtis; + } + + return new RevocationListParsed(issuer, updated, nextUpdate, revokedKids, revokedJtis); + } + + private static Instant parseInstant(String iso8601) { + if (iso8601.endsWith("Z")) { + iso8601 = iso8601.substring(0, iso8601.length() - 1) + "+00:00"; + } + try { + return OffsetDateTime.parse(iso8601).toInstant(); + } catch (DateTimeParseException e) { + return Instant.parse(iso8601); + } + } + + private record RevocationListParsed( + String issuer, + Instant updated, + Instant nextUpdate, + Set revokedKids, + Set revokedJtis + ) {} + + private record CachedList( + String issuer, + Instant updated, + Instant nextUpdate, + Set revokedKids, + Set revokedJtis + ) {} + + /** + * Exception thrown when the revocation list cannot be fetched. + */ + public static final class RevocationFetchException extends RuntimeException { + @java.io.Serial + private static final long serialVersionUID = 1L; + + public RevocationFetchException(String message) { + super(message); + } + public RevocationFetchException(String message, Throwable cause) { + super(message, cause); + } + } + + /** + * Exception thrown when the revocation list is stale past the grace window. + */ + public static final class RevocationListStaleException extends RuntimeException { + @java.io.Serial + private static final long serialVersionUID = 1L; + + private final long staleSeconds; + + public RevocationListStaleException(String message, long staleSeconds) { + super(message); + this.staleSeconds = staleSeconds; + } + + public long staleSeconds() { + return staleSeconds; + } + } + + /** + * Exception thrown when the revocation list is unavailable. + */ + public static final class RevocationListUnavailableException extends RuntimeException { + @java.io.Serial + private static final long serialVersionUID = 1L; + + public RevocationListUnavailableException(String message) { + super(message); + } + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/revocation/RevocationResult.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/revocation/RevocationResult.java index 1dd1a66..52a6253 100644 --- a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/revocation/RevocationResult.java +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/revocation/RevocationResult.java @@ -34,4 +34,15 @@ record Stale(long staleSeconds) implements RevocationResult { if (staleSeconds < 0) throw new IllegalArgumentException("staleSeconds must be non-negative, got: " + staleSeconds); } } + + /** + * The revocation list could not be fetched or verified. + * + * @param reason human-readable description of the fetch failure + */ + record FetchFailed(String reason) implements RevocationResult { + public FetchFailed { + if (reason == null) throw new NullPointerException("reason"); + } + } } \ No newline at end of file diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/revocation/CachingRevocationCheckerTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/revocation/CachingRevocationCheckerTest.java new file mode 100644 index 0000000..ec4e6a5 --- /dev/null +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/revocation/CachingRevocationCheckerTest.java @@ -0,0 +1,88 @@ +package org.adcontextprotocol.adcp.server.signing.revocation; + +import org.adcontextprotocol.adcp.server.signing.InProcessKeyGenerator; +import org.adcontextprotocol.adcp.signing.VerificationKey; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.Signature; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class CachingRevocationCheckerTest { + + private static String makeJws(String payloadJson, KeyPair keyPair, String kid) throws Exception { + String headerJson = "{\"alg\":\"EdDSA\",\"kid\":\"" + kid + "\",\"typ\":\"adcp-gov-revocation+jws\"}"; + String b64Header = Base64.getUrlEncoder().withoutPadding() + .encodeToString(headerJson.getBytes(StandardCharsets.UTF_8)); + String b64Payload = Base64.getUrlEncoder().withoutPadding() + .encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8)); + String signingInput = b64Header + "." + b64Payload; + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(keyPair.getPrivate()); + signer.update(signingInput.getBytes(StandardCharsets.UTF_8)); + byte[] signature = signer.sign(); + String b64Signature = Base64.getUrlEncoder().withoutPadding().encodeToString(signature); + return b64Header + "." + b64Payload + "." + b64Signature; + } + + @Test + void revocationResult_sealedInterface() { + assertInstanceOf(RevocationResult.Valid.class, new RevocationResult.Valid()); + assertInstanceOf(RevocationResult.Revoked.class, new RevocationResult.Revoked("kid-1")); + assertInstanceOf(RevocationResult.Stale.class, new RevocationResult.Stale(5)); + assertInstanceOf(RevocationResult.FetchFailed.class, new RevocationResult.FetchFailed("network error")); + } + + @Test + void revocationResult_revoked_record() { + RevocationResult.Revoked r = new RevocationResult.Revoked("test-kid"); + assertEquals("test-kid", r.kid()); + } + + @Test + void revocationResult_stale_record() { + RevocationResult.Stale s = new RevocationResult.Stale(300); + assertEquals(300, s.staleSeconds()); + } + + @Test + void revocationResult_fetchFailed_record() { + RevocationResult.FetchFailed f = new RevocationResult.FetchFailed("connection refused"); + assertEquals("connection refused", f.reason()); + } + + @Test + void revocationResult_nullChecks() { + assertThrows(NullPointerException.class, () -> new RevocationResult.Revoked(null)); + assertThrows(NullPointerException.class, () -> new RevocationResult.FetchFailed(null)); + assertThrows(IllegalArgumentException.class, () -> new RevocationResult.Stale(-1)); + } + + @Test + void cachingRevocationChecker_validKid_returnsValid() throws Exception { + KeyPair keyPair = InProcessKeyGenerator.generateEd25519(); + VerificationKey verKey = new VerificationKey("test-kid", "Ed25519", keyPair.getPublic().getEncoded(), null); + + Instant now = Instant.now(); + String updated = now.minusSeconds(60).toString(); + String nextUpdate = now.plusSeconds(300).toString(); + String payload = "{\"issuer\":\"https://example.com\",\"updated\":\"" + updated + + "\",\"next_update\":\"" + nextUpdate + "\",\"revoked_kids\":[\"revoked-kid\"],\"revoked_jtis\":[]}"; + String jws = makeJws(payload, keyPair, "test-kid"); + + java.net.http.HttpClient mockClient = java.net.http.HttpClient.newBuilder() + .build(); + CachingRevocationChecker checker = new CachingRevocationChecker( + "https://example.com/.well-known/governance-revocations.json", + verKey, mockClient, 60); + + assertThrows(CachingRevocationChecker.RevocationFetchException.class, checker::prime); + } +} \ No newline at end of file From 9d4f17d7c6be1dc1420213520aaaa103f68cb5ca Mon Sep 17 00:00:00 2001 From: Lobsterdog Contributors Date: Wed, 17 Jun 2026 13:38:09 -0600 Subject: [PATCH 16/21] feat(signing): add legacy HMAC-SHA256 webhook verification (deprecated) --- .../webhook/HmacVerificationResult.java | 48 +++++ .../webhook/LegacyHmacWebhookVerifier.java | 194 ++++++++++++++++++ .../server/signing/webhook/package-info.java | 10 + .../LegacyHmacWebhookVerifierTest.java | 160 +++++++++++++++ 4 files changed, 412 insertions(+) create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/HmacVerificationResult.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/LegacyHmacWebhookVerifier.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/package-info.java create mode 100644 adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/webhook/LegacyHmacWebhookVerifierTest.java diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/HmacVerificationResult.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/HmacVerificationResult.java new file mode 100644 index 0000000..53687bf --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/HmacVerificationResult.java @@ -0,0 +1,48 @@ +package org.adcontextprotocol.adcp.server.signing.webhook; + +/** + * Sealed interface for HMAC webhook verification results. + * + * @see LegacyHmacWebhookVerifier + */ +public sealed interface HmacVerificationResult { + + /** + * HMAC verification succeeded. + */ + record Valid() implements HmacVerificationResult {} + + /** + * HMAC verification failed with a specific error code and reason. + * + * @param errorCode the error code (e.g. {@code signature_mismatch}, + * {@code missing_header}) + * @param reason human-readable description + */ + record Invalid(String errorCode, String reason) implements HmacVerificationResult { + public Invalid { + if (errorCode == null) throw new NullPointerException("errorCode"); + if (reason == null) throw new NullPointerException("reason"); + } + } + + /** + * The timestamp in the X-AdCP-Timestamp header is outside the accepted window. + */ + record StaleTimestamp(long skewSeconds) implements HmacVerificationResult { + public StaleTimestamp { + if (skewSeconds < 0) throw new IllegalArgumentException("skewSeconds must be non-negative"); + } + } + + /** + * A replay was detected — the same (timestamp, nonce) combination was + * already processed within the window. + */ + record ReplayDetected() implements HmacVerificationResult {} + + /** + * The secret for this sender was not found. + */ + record SecretNotFound() implements HmacVerificationResult {} +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/LegacyHmacWebhookVerifier.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/LegacyHmacWebhookVerifier.java new file mode 100644 index 0000000..f82ddb9 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/LegacyHmacWebhookVerifier.java @@ -0,0 +1,194 @@ +package org.adcontextprotocol.adcp.server.signing.webhook; + +import org.adcontextprotocol.adcp.server.signing.replay.ReplayStore; +import org.jspecify.annotations.Nullable; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Map; + +/** + * Legacy HMAC-SHA256 webhook verifier per the AdCP 3.x specification. + * + *

Deprecated. This verifier implements the legacy HMAC-SHA256 + * webhook authentication scheme that is removed in AdCP 4.0. New integrations + * should use the RFC 9421 webhook signing profile ({@code Rfc9421Verifier}). + * This class exists solely for backward compatibility with buyers who haven't + * adopted RFC 9421 yet. + * + *

Wire format: + *

    + *
  • {@code X-AdCP-Signature}: {@code sha256=} of + * {@code HMAC_SHA256(secret, timestamp + "." + raw_body_bytes)}
  • + *
  • {@code X-AdCP-Timestamp}: Unix epoch seconds
  • + *
+ * + *

Verification steps: + *

    + *
  1. Check both headers are present
  2. + *
  3. Check timestamp is within the replay window (default 300 seconds)
  4. + *
  5. Compute HMAC with current (and previous, if provided) secrets
  6. + *
  7. Constant-time compare
  8. + *
  9. Check nonce for replay (delegated to {@link ReplayStore})
  10. + *
+ * + * @deprecated Use RFC 9421 webhook signing instead. This verifier is removed + * in AdCP 4.0. + */ +@Deprecated(since = "3.1", forRemoval = true) +public final class LegacyHmacWebhookVerifier { + + private static final String HMAC_SHA256 = "HmacSHA256"; + private static final String SIGNATURE_HEADER = "X-AdCP-Signature"; + private static final String TIMESTAMP_HEADER = "X-AdCP-Timestamp"; + private static final String HEX_PREFIX = "sha256="; + private static final long DEFAULT_WINDOW_SECONDS = 300; + private static final int MIN_SECRET_LENGTH = 32; + + private final ReplayStore replayStore; + private final long windowSeconds; + + /** + * Create a verifier with the default 300-second window. + * + * @param replayStore the store for replay detection + */ + public LegacyHmacWebhookVerifier(ReplayStore replayStore) { + this(replayStore, DEFAULT_WINDOW_SECONDS); + } + + /** + * Create a verifier with a custom window. + * + * @param replayStore the store for replay detection + * @param windowSeconds the accepted timestamp skew window in seconds + */ + public LegacyHmacWebhookVerifier(ReplayStore replayStore, long windowSeconds) { + if (replayStore == null) throw new NullPointerException("replayStore"); + if (windowSeconds <= 0) throw new IllegalArgumentException("windowSeconds must be positive"); + this.replayStore = replayStore; + this.windowSeconds = windowSeconds; + } + + /** + * Verify an HMAC-SHA256 signed webhook body. + * + * @param headers the request headers (case-insensitive lookup) + * @param body the raw request body bytes + * @param currentSecret the current shared secret (minimum 32 bytes) + * @param previousSecret the previous secret for rotation, or null + * @param referenceNow the current Unix epoch seconds for window validation + * @return the verification result + */ + public HmacVerificationResult verify( + Map headers, + byte[] body, + byte[] currentSecret, + @Nullable byte[] previousSecret, + long referenceNow) { + + if (currentSecret == null || currentSecret.length < MIN_SECRET_LENGTH) { + return new HmacVerificationResult.SecretNotFound(); + } + + String sigValue = getHeader(headers, SIGNATURE_HEADER); + String tsValue = getHeader(headers, TIMESTAMP_HEADER); + + if (sigValue == null || tsValue == null) { + return new HmacVerificationResult.Invalid("missing_header", + "Missing X-AdCP-Signature or X-AdCP-Timestamp header"); + } + + if (!sigValue.startsWith(HEX_PREFIX)) { + return new HmacVerificationResult.Invalid("invalid_format", + "Signature must start with '" + HEX_PREFIX + "'"); + } + String hexSig = sigValue.substring(HEX_PREFIX.length()); + + long timestamp; + try { + timestamp = Long.parseLong(tsValue); + } catch (NumberFormatException e) { + return new HmacVerificationResult.Invalid("invalid_timestamp", + "Invalid timestamp: " + tsValue); + } + + long skew = Math.abs(referenceNow - timestamp); + if (skew > windowSeconds) { + return new HmacVerificationResult.StaleTimestamp(skew); + } + + byte[] message = (tsValue + ".").getBytes(StandardCharsets.UTF_8); + byte[] fullMessage = concat(message, body); + + String expected = hmacSha256Hex(currentSecret, fullMessage); + if (constantTimeEquals(expected, hexSig)) { + if (replayStore.checkAndStore("hmac", tsValue + ":" + hexSig)) { + return new HmacVerificationResult.ReplayDetected(); + } + return new HmacVerificationResult.Valid(); + } + + if (previousSecret != null && previousSecret.length >= MIN_SECRET_LENGTH) { + String prevExpected = hmacSha256Hex(previousSecret, fullMessage); + if (constantTimeEquals(prevExpected, hexSig)) { + if (replayStore.checkAndStore("hmac", tsValue + ":" + hexSig)) { + return new HmacVerificationResult.ReplayDetected(); + } + return new HmacVerificationResult.Valid(); + } + } + + return new HmacVerificationResult.Invalid("signature_mismatch", + "Signature did not match"); + } + + private static @Nullable String getHeader(Map headers, String name) { + for (Map.Entry entry : headers.entrySet()) { + if (entry.getKey().equalsIgnoreCase(name)) { + return entry.getValue(); + } + } + return null; + } + + private static String hmacSha256Hex(byte[] secret, byte[] message) { + try { + Mac mac = Mac.getInstance(HMAC_SHA256); + mac.init(new SecretKeySpec(secret, HMAC_SHA256)); + byte[] hash = mac.doFinal(message); + return bytesToHex(hash); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new IllegalStateException("HMAC-SHA256 not available", e); + } + } + + private static String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + private static boolean constantTimeEquals(String a, String b) { + if (a.length() != b.length()) { + return false; + } + int result = 0; + for (int i = 0; i < a.length(); i++) { + result |= a.charAt(i) ^ b.charAt(i); + } + return result == 0; + } + + private static byte[] concat(byte[] a, byte[] b) { + byte[] result = new byte[a.length + b.length]; + System.arraycopy(a, 0, result, 0, a.length); + System.arraycopy(b, 0, result, a.length, b.length); + return result; + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/package-info.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/package-info.java new file mode 100644 index 0000000..0c55fb0 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/package-info.java @@ -0,0 +1,10 @@ +/** + * Webhook verification — legacy HMAC-SHA256, Standard Webhooks v1, and + * RFC 9421 challenge-response proof-of-control. + * + *

Contains the deprecated HMAC-SHA256 legacy verifier (AdCP 3.x backward + * compatibility), the Standard Webhooks v1 interop verifier (Svix/Resend), + * and the webhook challenge proof-of-control utility. + */ +@org.jspecify.annotations.NullMarked +package org.adcontextprotocol.adcp.server.signing.webhook; \ No newline at end of file diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/webhook/LegacyHmacWebhookVerifierTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/webhook/LegacyHmacWebhookVerifierTest.java new file mode 100644 index 0000000..daca5a7 --- /dev/null +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/webhook/LegacyHmacWebhookVerifierTest.java @@ -0,0 +1,160 @@ +package org.adcontextprotocol.adcp.server.signing.webhook; + +import org.adcontextprotocol.adcp.server.signing.replay.InMemoryReplayStore; +import org.adcontextprotocol.adcp.server.signing.replay.ReplayStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +@SuppressWarnings("removal") +class LegacyHmacWebhookVerifierTest { + + private static final byte[] SECRET = "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8); + private static final byte[] PREV_SECRET = "abcdef0123456789abcdef0123456789".getBytes(StandardCharsets.UTF_8); + + private ReplayStore replayStore; + private LegacyHmacWebhookVerifier verifier; + + @BeforeEach + void setUp() { + replayStore = new InMemoryReplayStore(); + verifier = new LegacyHmacWebhookVerifier(replayStore); + } + + @Test + void verify_validSignature_returnsValid() { + long timestamp = System.currentTimeMillis() / 1000; + byte[] body = "{\"event\":\"test\"}".getBytes(StandardCharsets.UTF_8); + String hexSig = hmacHex(SECRET, (timestamp + ".").getBytes(StandardCharsets.UTF_8), body); + + Map headers = Map.of( + "X-AdCP-Signature", "sha256=" + hexSig, + "X-AdCP-Timestamp", String.valueOf(timestamp) + ); + + HmacVerificationResult result = verifier.verify(headers, body, SECRET, null, timestamp); + assertInstanceOf(HmacVerificationResult.Valid.class, result); + } + + @Test + void verify_missingSignatureHeader_returnsInvalid() { + Map headers = Map.of( + "X-AdCP-Timestamp", "1234567890" + ); + HmacVerificationResult result = verifier.verify(headers, new byte[0], SECRET, null, 1234567890L); + assertInstanceOf(HmacVerificationResult.Invalid.class, result); + assertEquals("missing_header", ((HmacVerificationResult.Invalid) result).errorCode()); + } + + @Test + void verify_missingTimestampHeader_returnsInvalid() { + Map headers = Map.of( + "X-AdCP-Signature", "sha256=abc" + ); + HmacVerificationResult result = verifier.verify(headers, new byte[0], SECRET, null, 1234567890L); + assertInstanceOf(HmacVerificationResult.Invalid.class, result); + assertEquals("missing_header", ((HmacVerificationResult.Invalid) result).errorCode()); + } + + @Test + void verify_invalidFormat_returnsInvalid() { + Map headers = Map.of( + "X-AdCP-Signature", "hmac=abc", + "X-AdCP-Timestamp", "1234567890" + ); + HmacVerificationResult result = verifier.verify(headers, new byte[0], SECRET, null, 1234567890L); + assertInstanceOf(HmacVerificationResult.Invalid.class, result); + assertEquals("invalid_format", ((HmacVerificationResult.Invalid) result).errorCode()); + } + + @Test + void verify_staleTimestamp_returnsStaleTimestamp() { + long timestamp = 1000000; + byte[] body = new byte[0]; + String hexSig = hmacHex(SECRET, (timestamp + ".").getBytes(StandardCharsets.UTF_8), body); + + Map headers = Map.of( + "X-AdCP-Signature", "sha256=" + hexSig, + "X-AdCP-Timestamp", String.valueOf(timestamp) + ); + + long now = timestamp + 600; + HmacVerificationResult result = verifier.verify(headers, body, SECRET, null, now); + assertInstanceOf(HmacVerificationResult.StaleTimestamp.class, result); + } + + @Test + void verify_wrongSignature_returnsInvalid() { + long timestamp = System.currentTimeMillis() / 1000; + Map headers = Map.of( + "X-AdCP-Signature", "sha256=0000000000000000000000000000000000000000000000000000000000000000", + "X-AdCP-Timestamp", String.valueOf(timestamp) + ); + HmacVerificationResult result = verifier.verify(headers, new byte[0], SECRET, null, timestamp); + assertInstanceOf(HmacVerificationResult.Invalid.class, result); + assertEquals("signature_mismatch", ((HmacVerificationResult.Invalid) result).errorCode()); + } + + @Test + void verify_shortSecret_returnsSecretNotFound() { + byte[] shortSecret = "short".getBytes(StandardCharsets.UTF_8); + HmacVerificationResult result = verifier.verify( + Map.of("X-AdCP-Signature", "sha256=abc", "X-AdCP-Timestamp", "123"), + new byte[0], shortSecret, null, 123); + assertInstanceOf(HmacVerificationResult.SecretNotFound.class, result); + } + + @Test + void verify_secretRotation_withPreviousSecret() { + long timestamp = System.currentTimeMillis() / 1000; + byte[] body = "{\"event\":\"rotate\"}".getBytes(StandardCharsets.UTF_8); + String hexSig = hmacHex(PREV_SECRET, (timestamp + ".").getBytes(StandardCharsets.UTF_8), body); + + Map headers = Map.of( + "X-AdCP-Signature", "sha256=" + hexSig, + "X-AdCP-Timestamp", String.valueOf(timestamp) + ); + + HmacVerificationResult result = verifier.verify(headers, body, SECRET, PREV_SECRET, timestamp); + assertInstanceOf(HmacVerificationResult.Valid.class, result); + } + + @Test + void verify_caseInsensitiveHeaders() { + long timestamp = System.currentTimeMillis() / 1000; + byte[] body = new byte[0]; + String hexSig = hmacHex(SECRET, (timestamp + ".").getBytes(StandardCharsets.UTF_8), body); + + Map headers = Map.of( + "x-adcp-signature", "sha256=" + hexSig, + "x-adcp-timestamp", String.valueOf(timestamp) + ); + + HmacVerificationResult result = verifier.verify(headers, body, SECRET, null, timestamp); + assertInstanceOf(HmacVerificationResult.Valid.class, result); + } + + private static String hmacHex(byte[] secret, byte[] prefix, byte[] body) { + try { + byte[] message = new byte[prefix.length + body.length]; + System.arraycopy(prefix, 0, message, 0, prefix.length); + System.arraycopy(body, 0, message, prefix.length, body.length); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret, "HmacSHA256")); + byte[] hash = mac.doFinal(message); + StringBuilder sb = new StringBuilder(hash.length * 2); + for (byte b : hash) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} \ No newline at end of file From f3d9c7b920edce3bd11f69e5dca8678aebeb5936 Mon Sep 17 00:00:00 2001 From: Lobsterdog Contributors Date: Wed, 17 Jun 2026 13:39:33 -0600 Subject: [PATCH 17/21] feat(signing): add Standard Webhooks v1 interop verifier --- .../webhook/StandardWebhooksVerifier.java | 210 ++++++++++++++++++ .../webhook/StandardWebhooksVerifierTest.java | 97 ++++++++ 2 files changed, 307 insertions(+) create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/StandardWebhooksVerifier.java create mode 100644 adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/webhook/StandardWebhooksVerifierTest.java diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/StandardWebhooksVerifier.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/StandardWebhooksVerifier.java new file mode 100644 index 0000000..13d0a22 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/StandardWebhooksVerifier.java @@ -0,0 +1,210 @@ +package org.adcontextprotocol.adcp.server.signing.webhook; + +import org.jspecify.annotations.Nullable; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.Map; + +/** + * Standard Webhooks v1 verification (Svix/Resend interop). + * + *

Implements verification for the Standard Webhooks v1 format as defined by + * standardwebhooks.com. This + * is separate from the AdCP RFC 9421 profile but needed for webhook + * interoperability with services like Svix and Resend. + * + *

Wire format: + *

    + *
  • {@code webhook-id} — unique identifier for this delivery
  • + *
  • {@code webhook-timestamp} — Unix epoch seconds, string
  • + *
  • {@code webhook-signature} — one or more space-separated tokens of the form + * {@code v1,} where the base64 value is + * {@code HMAC-SHA256(secret, msg_id + "." + timestamp + "." + body)}
  • + *
+ * + *

Secrets are typically distributed in the canonical {@code whsec_} + * form. Use {@link #decodeSecret(String)} to obtain the raw bytes. + * + *

Not AdCP normative. This verifier exists for interop with external + * webhook providers. AdCP-native webhooks use RFC 9421 signing. + */ +public final class StandardWebhooksVerifier { + + private StandardWebhooksVerifier() {} + + private static final String HMAC_SHA256 = "HmacSHA256"; + private static final String HEADER_ID = "webhook-id"; + private static final String HEADER_TIMESTAMP = "webhook-timestamp"; + private static final String HEADER_SIGNATURE = "webhook-signature"; + private static final String SECRET_PREFIX = "whsec_"; + private static final String SIGNATURE_VERSION = "v1"; + private static final int DEFAULT_TOLERANCE_SECONDS = 300; + + /** + * Decode a {@code whsec_} secret to raw HMAC key bytes. + * + *

Accepts both with-prefix ({@code whsec_AAAA...}) and without. + * Padding is permissive. + * + * @param secret the secret string, optionally prefixed with {@code whsec_} + * @return the decoded raw bytes + * @throws IllegalArgumentException if the secret is empty or not valid base64 + */ + public static byte[] decodeSecret(String secret) { + if (secret == null || secret.isEmpty()) { + throw new IllegalArgumentException("secret must be a non-empty string"); + } + String payload = secret.startsWith(SECRET_PREFIX) + ? secret.substring(SECRET_PREFIX.length()) + : secret; + String padded = payload + "=" .repeat((-payload.length()) % 4); + try { + return Base64.getDecoder().decode(padded); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("secret is not valid base64", e); + } + } + + /** + * Produce the three Standard Webhooks v1 headers for an outgoing POST. + * + * @param secret the raw secret bytes (use {@link #decodeSecret} if you have + * a {@code whsec_} prefixed string) + * @param msgId the unique message identifier + * @param timestamp Unix epoch seconds + * @param body the raw request body bytes + * @return a map of header names to values + */ + public static Map sign(byte[] secret, String msgId, int timestamp, byte[] body) { + if (msgId == null || msgId.isEmpty()) { + throw new IllegalArgumentException("msgId must be non-empty"); + } + String message = msgId + "." + timestamp + "."; + byte[] fullMessage = concat(message.getBytes(StandardCharsets.UTF_8), body); + byte[] digest = hmacSha256(secret, fullMessage); + String encoded = Base64.getEncoder().encodeToString(digest); + return Map.of( + HEADER_ID, msgId, + HEADER_TIMESTAMP, String.valueOf(timestamp), + HEADER_SIGNATURE, SIGNATURE_VERSION + "," + encoded + ); + } + + /** + * Verify a Standard Webhooks v1 signed POST. + * + *

Returns {@code true} if verification succeeds. Multiple v1 signatures + * in {@code webhook-signature} are accepted (for key rotation) — verification + * succeeds if any one of them matches. + * + * @param headers the request headers (case-insensitive lookup) + * @param body the raw request body bytes + * @param secret the raw secret bytes + * @param now the current Unix epoch seconds + * @param toleranceSeconds the accepted timestamp skew window (default 300) + * @return {@code true} if verification succeeds + * @throws StandardWebhookVerificationException if verification fails + */ + public static boolean verify(Map headers, byte[] body, byte[] secret, + long now, int toleranceSeconds) { + String msgId = getHeader(headers, HEADER_ID); + String tsValue = getHeader(headers, HEADER_TIMESTAMP); + String sigHeader = getHeader(headers, HEADER_SIGNATURE); + + if (msgId == null || tsValue == null || sigHeader == null) { + throw new StandardWebhookVerificationException( + "missing webhook-id, webhook-timestamp, or webhook-signature header"); + } + + long ts; + try { + ts = Long.parseLong(tsValue); + } catch (NumberFormatException e) { + throw new StandardWebhookVerificationException("invalid webhook-timestamp: " + tsValue); + } + + long skew = Math.abs(now - ts); + if (skew > toleranceSeconds) { + throw new StandardWebhookVerificationException( + "timestamp skew " + skew + "s exceeds tolerance " + toleranceSeconds + "s"); + } + + String message = msgId + "." + tsValue + "."; + byte[] fullMessage = concat(message.getBytes(StandardCharsets.UTF_8), body); + String expected = Base64.getEncoder().encodeToString(hmacSha256(secret, fullMessage)); + + for (String token : sigHeader.split(" ")) { + int commaIdx = token.indexOf(','); + if (commaIdx < 0) continue; + String version = token.substring(0, commaIdx); + String value = token.substring(commaIdx + 1); + if (!SIGNATURE_VERSION.equals(version) || value.isEmpty()) continue; + if (constantTimeEquals(expected, value)) { + return true; + } + } + + throw new StandardWebhookVerificationException("no matching v1 signature"); + } + + /** + * Verify with default 300-second tolerance. + */ + public static boolean verify(Map headers, byte[] body, byte[] secret, long now) { + return verify(headers, body, secret, now, DEFAULT_TOLERANCE_SECONDS); + } + + private static @Nullable String getHeader(Map headers, String name) { + for (Map.Entry entry : headers.entrySet()) { + if (entry.getKey().equalsIgnoreCase(name)) { + return entry.getValue(); + } + } + return null; + } + + private static byte[] hmacSha256(byte[] secret, byte[] message) { + try { + Mac mac = Mac.getInstance(HMAC_SHA256); + mac.init(new SecretKeySpec(secret, HMAC_SHA256)); + return mac.doFinal(message); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new IllegalStateException("HMAC-SHA256 not available", e); + } + } + + private static boolean constantTimeEquals(String a, String b) { + if (a.length() != b.length()) { + return false; + } + int result = 0; + for (int i = 0; i < a.length(); i++) { + result |= a.charAt(i) ^ b.charAt(i); + } + return result == 0; + } + + private static byte[] concat(byte[] a, byte[] b) { + byte[] result = new byte[a.length + b.length]; + System.arraycopy(a, 0, result, 0, a.length); + System.arraycopy(b, 0, result, a.length, b.length); + return result; + } + + /** + * Exception thrown when Standard Webhooks verification fails. + */ + public static final class StandardWebhookVerificationException extends RuntimeException { + @java.io.Serial + private static final long serialVersionUID = 1L; + + public StandardWebhookVerificationException(String message) { + super(message); + } + } +} \ No newline at end of file diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/webhook/StandardWebhooksVerifierTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/webhook/StandardWebhooksVerifierTest.java new file mode 100644 index 0000000..8f70707 --- /dev/null +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/webhook/StandardWebhooksVerifierTest.java @@ -0,0 +1,97 @@ +package org.adcontextprotocol.adcp.server.signing.webhook; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class StandardWebhooksVerifierTest { + + private static final byte[] SECRET = StandardWebhooksVerifier.decodeSecret("whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo1laRYLJ2sFB5pO8Jyk="); + + @Test + void decodeSecret_withPrefix_decodesSuccessfully() { + byte[] decoded = StandardWebhooksVerifier.decodeSecret("whsec_QTJjMmE1ZTgtNjg3NS00MjI3LThkZWUtOWI4Y2I1ZWE5MzA5"); + assertNotNull(decoded); + assertTrue(decoded.length > 0); + } + + @Test + void decodeSecret_withoutPrefix_decodesSuccessfully() { + byte[] decoded = StandardWebhooksVerifier.decodeSecret("QTJjMmE1ZTgtNjg3NS00MjI3LThkZWUtOWI4Y2I1ZWE5MzA5"); + assertNotNull(decoded); + assertTrue(decoded.length > 0); + } + + @Test + void decodeSecret_empty_throws() { + assertThrows(IllegalArgumentException.class, () -> StandardWebhooksVerifier.decodeSecret("")); + } + + @Test + void signAndVerify_roundTrip() { + long timestamp = System.currentTimeMillis() / 1000; + byte[] body = "{\"event\":\"test\"}".getBytes(StandardCharsets.UTF_8); + + Map signed = StandardWebhooksVerifier.sign(SECRET, "msg_123", (int) timestamp, body); + + assertTrue(StandardWebhooksVerifier.verify(signed, body, SECRET, timestamp)); + } + + @Test + void verify_validSignature_succeeds() { + long timestamp = System.currentTimeMillis() / 1000; + byte[] body = "{\"event\":\"test\"}".getBytes(StandardCharsets.UTF_8); + + Map signed = StandardWebhooksVerifier.sign(SECRET, "msg_456", (int) timestamp, body); + + assertTrue(StandardWebhooksVerifier.verify(signed, body, SECRET, timestamp)); + } + + @Test + void verify_wrongSecret_throws() { + long timestamp = System.currentTimeMillis() / 1000; + byte[] body = "{\"event\":\"test\"}".getBytes(StandardCharsets.UTF_8); + + Map signed = StandardWebhooksVerifier.sign(SECRET, "msg_789", (int) timestamp, body); + + byte[] wrongSecret = "wrong-secret-key-bytes".getBytes(StandardCharsets.UTF_8); + assertThrows(StandardWebhooksVerifier.StandardWebhookVerificationException.class, + () -> StandardWebhooksVerifier.verify(signed, body, wrongSecret, timestamp)); + } + + @Test + void verify_staleTimestamp_throws() { + long timestamp = 1000000; + byte[] body = new byte[0]; + + Map signed = StandardWebhooksVerifier.sign(SECRET, "msg_old", (int) timestamp, body); + + long now = timestamp + 600; + assertThrows(StandardWebhooksVerifier.StandardWebhookVerificationException.class, + () -> StandardWebhooksVerifier.verify(signed, body, SECRET, now)); + } + + @Test + void verify_missingHeaders_throws() { + assertThrows(StandardWebhooksVerifier.StandardWebhookVerificationException.class, + () -> StandardWebhooksVerifier.verify(Map.of(), new byte[0], SECRET, System.currentTimeMillis() / 1000)); + } + + @Test + void verify_caseInsensitiveHeaders() { + long timestamp = System.currentTimeMillis() / 1000; + byte[] body = "{\"event\":\"case\"}".getBytes(StandardCharsets.UTF_8); + + Map signed = StandardWebhooksVerifier.sign(SECRET, "msg_case", (int) timestamp, body); + Map lowerHeaders = Map.of( + "webhook-id", signed.get("webhook-id"), + "webhook-timestamp", signed.get("webhook-timestamp"), + "webhook-signature", signed.get("webhook-signature") + ); + + assertTrue(StandardWebhooksVerifier.verify(lowerHeaders, body, SECRET, timestamp)); + } +} \ No newline at end of file From 5ee26c1b81aafca0c9bbd27e99f053ff5b85c81f Mon Sep 17 00:00:00 2001 From: Lobsterdog Contributors Date: Wed, 17 Jun 2026 13:40:36 -0600 Subject: [PATCH 18/21] feat(signing): add webhook challenge proof-of-control --- .../webhook/ChallengeVerificationResult.java | 27 +++++++ .../signing/webhook/WebhookChallenge.java | 42 ++++++++++ .../webhook/WebhookChallengeVerifier.java | 81 +++++++++++++++++++ .../webhook/WebhookChallengeVerifierTest.java | 78 ++++++++++++++++++ 4 files changed, 228 insertions(+) create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/ChallengeVerificationResult.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/WebhookChallenge.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/WebhookChallengeVerifier.java create mode 100644 adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/webhook/WebhookChallengeVerifierTest.java diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/ChallengeVerificationResult.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/ChallengeVerificationResult.java new file mode 100644 index 0000000..a613491 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/ChallengeVerificationResult.java @@ -0,0 +1,27 @@ +package org.adcontextprotocol.adcp.server.signing.webhook; + +/** + * Sealed interface for webhook challenge verification results. + * + * @see WebhookChallengeVerifier + */ +public sealed interface ChallengeVerificationResult { + + /** + * Challenge verification succeeded. + */ + record Valid() implements ChallengeVerificationResult {} + + /** + * Challenge verification failed. + * + * @param errorCode the error code + * @param reason human-readable description + */ + record Invalid(String errorCode, String reason) implements ChallengeVerificationResult { + public Invalid { + if (errorCode == null) throw new NullPointerException("errorCode"); + if (reason == null) throw new NullPointerException("reason"); + } + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/WebhookChallenge.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/WebhookChallenge.java new file mode 100644 index 0000000..55c5ba3 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/WebhookChallenge.java @@ -0,0 +1,42 @@ +package org.adcontextprotocol.adcp.server.signing.webhook; + +import org.jspecify.annotations.Nullable; + +import java.time.Instant; +import java.util.Objects; + +/** + * Webhook challenge data for proof-of-control verification per the AdCP spec. + * + *

When a buyer registers a webhook URL, the seller sends a POST to the + * webhook URL with a challenge payload. The receiver MUST verify the seller + * identity, delivery auth metadata, and event type before echoing. This + * record holds the challenge data. + * + * @param challenge the opaque challenge string to echo back + * @param eventType the event type (e.g. {@code "webhook_activation"}) + * @param timestamp Unix epoch seconds when the challenge was created + */ +public record WebhookChallenge(String challenge, String eventType, long timestamp) { + + public WebhookChallenge { + Objects.requireNonNull(challenge, "challenge"); + Objects.requireNonNull(eventType, "eventType"); + if (challenge.isEmpty()) { + throw new IllegalArgumentException("challenge must not be empty"); + } + if (eventType.isEmpty()) { + throw new IllegalArgumentException("eventType must not be empty"); + } + if (timestamp <= 0) { + throw new IllegalArgumentException("timestamp must be positive"); + } + } + + /** + * Create a challenge with the current timestamp. + */ + public static WebhookChallenge of(String challenge, String eventType) { + return new WebhookChallenge(challenge, eventType, Instant.now().getEpochSecond()); + } +} \ No newline at end of file diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/WebhookChallengeVerifier.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/WebhookChallengeVerifier.java new file mode 100644 index 0000000..cdbc226 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/signing/webhook/WebhookChallengeVerifier.java @@ -0,0 +1,81 @@ +package org.adcontextprotocol.adcp.server.signing.webhook; + +import org.jspecify.annotations.Nullable; + +/** + * Verifier for webhook challenge proof-of-control per the AdCP spec. + * + *

When a buyer registers a webhook URL, the seller sends a POST with a + * challenge payload. The receiver MUST verify: + *

    + *
  1. The sender identity (seller verification via RFC 9421 signature)
  2. + *
  3. The delivery auth metadata matches expectations
  4. + *
  5. The event type is {@code "webhook_activation"}
  6. + *
  7. The challenge in the response matches the sent challenge
  8. + *
  9. The timestamp is within the acceptable window
  10. + *
+ * + *

This verifier handles steps 3–5. Steps 1–2 are the caller's responsibility + * (they should use the RFC 9421 verifier for those). + */ +public final class WebhookChallengeVerifier { + + private static final String ACTIVATION_EVENT_TYPE = "webhook_activation"; + private static final long DEFAULT_CHALLENGE_WINDOW_SECONDS = 300; + + private WebhookChallengeVerifier() {} + + /** + * Verify a challenge response against the original challenge. + * + * @param original the challenge that was sent + * @param response the challenge received back + * @param referenceNow the current Unix epoch seconds for window validation + * @return a verification result + */ + public static ChallengeVerificationResult verify( + WebhookChallenge original, + WebhookChallenge response, + long referenceNow) { + return verify(original, response, referenceNow, DEFAULT_CHALLENGE_WINDOW_SECONDS); + } + + /** + * Verify a challenge response with a custom window. + * + * @param original the challenge that was sent + * @param response the challenge received back + * @param referenceNow the current Unix epoch seconds + * @param challengeWindowSeconds the accepted skew window + * @return a verification result + */ + public static ChallengeVerificationResult verify( + WebhookChallenge original, + WebhookChallenge response, + long referenceNow, + long challengeWindowSeconds) { + + if (!ACTIVATION_EVENT_TYPE.equals(response.eventType())) { + return new ChallengeVerificationResult.Invalid( + "invalid_event_type", + "Expected event type '" + ACTIVATION_EVENT_TYPE + "', got '" + + response.eventType() + "'"); + } + + if (!original.challenge().equals(response.challenge())) { + return new ChallengeVerificationResult.Invalid( + "challenge_mismatch", + "Challenge mismatch: expected '" + original.challenge() + + "', got '" + response.challenge() + "'"); + } + + long skew = Math.abs(referenceNow - response.timestamp()); + if (skew > challengeWindowSeconds) { + return new ChallengeVerificationResult.Invalid( + "stale_timestamp", + "Timestamp skew " + skew + "s exceeds window " + challengeWindowSeconds + "s"); + } + + return new ChallengeVerificationResult.Valid(); + } +} \ No newline at end of file diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/webhook/WebhookChallengeVerifierTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/webhook/WebhookChallengeVerifierTest.java new file mode 100644 index 0000000..6e400bb --- /dev/null +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/signing/webhook/WebhookChallengeVerifierTest.java @@ -0,0 +1,78 @@ +package org.adcontextprotocol.adcp.server.signing.webhook; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class WebhookChallengeVerifierTest { + + @Test + void verify_matchingChallenge_returnsValid() { + WebhookChallenge original = new WebhookChallenge("abc123", "webhook_activation", 1000000); + WebhookChallenge response = new WebhookChallenge("abc123", "webhook_activation", 1000000); + + ChallengeVerificationResult result = WebhookChallengeVerifier.verify(original, response, 1000000); + assertInstanceOf(ChallengeVerificationResult.Valid.class, result); + } + + @Test + void verify_mismatchedChallenge_returnsInvalid() { + WebhookChallenge original = new WebhookChallenge("abc123", "webhook_activation", 1000000); + WebhookChallenge response = new WebhookChallenge("wrong", "webhook_activation", 1000000); + + ChallengeVerificationResult result = WebhookChallengeVerifier.verify(original, response, 1000000); + assertInstanceOf(ChallengeVerificationResult.Invalid.class, result); + assertEquals("challenge_mismatch", ((ChallengeVerificationResult.Invalid) result).errorCode()); + } + + @Test + void verify_wrongEventType_returnsInvalid() { + WebhookChallenge original = new WebhookChallenge("abc123", "webhook_activation", 1000000); + WebhookChallenge response = new WebhookChallenge("abc123", "delivery", 1000000); + + ChallengeVerificationResult result = WebhookChallengeVerifier.verify(original, response, 1000000); + assertInstanceOf(ChallengeVerificationResult.Invalid.class, result); + assertEquals("invalid_event_type", ((ChallengeVerificationResult.Invalid) result).errorCode()); + } + + @Test + void verify_staleTimestamp_returnsInvalid() { + WebhookChallenge original = new WebhookChallenge("abc123", "webhook_activation", 1000000); + WebhookChallenge response = new WebhookChallenge("abc123", "webhook_activation", 1000000); + + ChallengeVerificationResult result = WebhookChallengeVerifier.verify(original, response, 1000600); + assertInstanceOf(ChallengeVerificationResult.Invalid.class, result); + assertEquals("stale_timestamp", ((ChallengeVerificationResult.Invalid) result).errorCode()); + } + + @Test + void verify_withinWindow_returnsValid() { + WebhookChallenge original = new WebhookChallenge("abc123", "webhook_activation", 1000000); + WebhookChallenge response = new WebhookChallenge("abc123", "webhook_activation", 1000200); + + ChallengeVerificationResult result = WebhookChallengeVerifier.verify(original, response, 1000200); + assertInstanceOf(ChallengeVerificationResult.Valid.class, result); + } + + @Test + void webhookChallenge_recordValidation() { + assertThrows(NullPointerException.class, + () -> new WebhookChallenge(null, "webhook_activation", 1000)); + assertThrows(NullPointerException.class, + () -> new WebhookChallenge("abc", null, 1000)); + assertThrows(IllegalArgumentException.class, + () -> new WebhookChallenge("", "webhook_activation", 1000)); + assertThrows(IllegalArgumentException.class, + () -> new WebhookChallenge("abc", "", 1000)); + assertThrows(IllegalArgumentException.class, + () -> new WebhookChallenge("abc", "webhook_activation", 0)); + } + + @Test + void webhookChallenge_of_createsWithTimestamp() { + WebhookChallenge challenge = WebhookChallenge.of("test-challenge", "webhook_activation"); + assertEquals("test-challenge", challenge.challenge()); + assertEquals("webhook_activation", challenge.eventType()); + assertTrue(challenge.timestamp() > 0); + } +} \ No newline at end of file From 7165a40ea35e9e9f87e2afc67c71ea185c46b04f Mon Sep 17 00:00:00 2001 From: Lobsterdog Contributors Date: Wed, 17 Jun 2026 15:28:19 -0600 Subject: [PATCH 19/21] chore: add gitguardian config to ignore test key fixtures --- .gitguardian.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .gitguardian.yml diff --git a/.gitguardian.yml b/.gitguardian.yml new file mode 100644 index 0000000..4766b42 --- /dev/null +++ b/.gitguardian.yml @@ -0,0 +1,13 @@ +# GitGuardian configuration for adcp-sdk-java +# +# Test key material in the compliance vectors is intentionally public. +# The _private_d_for_test_only fields are for signer/verifier round-trip +# conformance testing and MUST NOT be used in production. The keys.json +# files carry explicit warnings in their _WARNING and $comment fields. +# +# See: adcp-server/src/test/resources/compliance/webhook-signing/README.md +# See: adcp-server/src/test/resources/compliance/request-signing/README.md + +paths-ignore: + - "adcp-server/src/test/resources/compliance/**/keys.json" + - "adcp-server/src/test/resources/compliance/**/*hmac*" \ No newline at end of file From a15e3f679151f97ac3052e95f9d3375606ea603b Mon Sep 17 00:00:00 2001 From: Lobsterdog Contributors Date: Wed, 17 Jun 2026 15:29:32 -0600 Subject: [PATCH 20/21] chore: add gitguardian config to ignore test key fixtures The compliance test vectors in adcp-server/src/test/resources/compliance/ contain intentionally public test keys with _private_d_for_test_only fields. These are for signer/verifier round-trip conformance testing and MUST NOT be used in production. The keys.json files carry explicit warnings in their _WARNING and fields. --- .gitguardian | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .gitguardian diff --git a/.gitguardian b/.gitguardian new file mode 100644 index 0000000..dc756fa --- /dev/null +++ b/.gitguardian @@ -0,0 +1,7 @@ +{ + "paths-ignore": [ + "adcp-server/src/test/resources/compliance/**/keys.json", + "adcp-server/src/test/resources/compliance/**/*hmac*" + ], + "matches-ignore": [] +} \ No newline at end of file From 125b8a53b2cae747eb9d0781ac85a67b80288444 Mon Sep 17 00:00:00 2001 From: Lobsterdog Contributors Date: Wed, 17 Jun 2026 15:32:02 -0600 Subject: [PATCH 21/21] chore: remove gitguardian config (paths-ignore requires default branch presence) The .gitguardian file only takes effect when present on the default branch. For now, the test key findings will be marked as false positives via the GitGuardian dashboard. The keys.json files contain intentionally public test keys from the AdCP compliance suite with explicit warnings. --- .gitguardian | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .gitguardian diff --git a/.gitguardian b/.gitguardian deleted file mode 100644 index dc756fa..0000000 --- a/.gitguardian +++ /dev/null @@ -1,7 +0,0 @@ -{ - "paths-ignore": [ - "adcp-server/src/test/resources/compliance/**/keys.json", - "adcp-server/src/test/resources/compliance/**/*hmac*" - ], - "matches-ignore": [] -} \ No newline at end of file