8000 feat: RFC 9421 HTTP Message Signatures + shared crypto layer + JWT processor by kawasima · Pull Request #166 · enkan/enkan · GitHub
[go: up one dir, main page]

Skip to content

feat: RFC 9421 HTTP Message Signatures + shared crypto layer + JWT processor - #166

Merged
kawasima merged 18 commits into
developfrom
feat/rfc9421-http-message-signatures
Apr 5, 2026
Merged

kawasima merged 18 commits into
developfrom
feat/rfc9421-http-message-signatures

Conversation

@kawasima
@kawasima kawasima commented Apr 5, 2026
Copy link
Copy Markdown
Collaborator

Summary

  • enkan.security.crypto (enkan-core): Shared cryptographic signing/verification layer — CryptoAlgorithm enum, Signer/Verifier interfaces, JcaSigner/JcaVerifier JCA implementations, EcdsaUtils DER↔P1363 conversion. Covers HMAC, Ed25519, ECDSA (P-256/P-384/P-521), RSA-PSS, RSA PKCS#1 v1.5. No external dependencies.
  • enkan.web.jwt (enkan-web): Stateless JWT signing/verification — JwsAlgorithm mapping to CryptoAlgorithm, JwtHeader record, JwtProcessor with no JSON library dependency. Validates exp/nbf, rejects alg:none.
  • enkan.web.signature (enkan-web): RFC 9421 HTTP Message Signatures — SignatureAlgorithm, SignatureComponent, SignatureBaseBuilder (§2.5), SignatureKeyResolver, VerifyResult (with covered component values for downstream use), HttpMessageSignatures sign/verify utility.
  • SignatureVerificationMiddleware: Verifies Signature/Signature-Input headers with requiredLabels/requiredComponents policy enforcement and Accept-Signature negotiation on 401 responses.

Closes #135

Design decisions

  • Shared crypto layer in enkan-core enables future JWT migration from enkan-bouncr and eliminates BouncyCastle dependency for standard algorithms
  • Signer/Verifier interfaces (not raw Key) returned from SignatureKeyResolver to support HSM and remote KMS
  • VerifyResult.coveredValues() (pyauth pattern) provides cryptographically verified component values to downstream handlers
  • Accept-Signature negotiation integrated into the verification middleware (same concern as DigestFilter's Want-* negotiation)

Test plan

  • JcaSignerVerifierTest: sign→verify roundtrips for all algorithm families, wrong-key rejection, tamper detection (105 core tests)
  • JwtProcessorTest: HMAC/Ed25519/ECDSA/RSA roundtrips, exp/nbf validation, alg:none rejection, malformed tokens (14 tests)
  • SignatureBaseBuilderTest: all derived components, header normalization, full signature base format (18 tests)
  • HttpMessageSignaturesTest: HMAC/Ed25519 sign→verify, tamper detection, expired signatures, unknown keyid (6 tests)
  • SignatureVerificationMiddlewareTest: valid/invalid signatures, required labels/components, Accept-Signature header (7 tests)
  • Full build: mvn test -pl enkan-core,enkan-web -am — 583 tests pass

🤖 Generated with Claude Code

kawasima and others added 14 commits April 5, 2026 09:06
…g/verification layer

Add a protocol-agnostic crypto layer to enkan-core for use by RFC 9421
HTTP Message Signatures, JWT/JWS, and future signing needs:

- CryptoAlgorithm: enum mapping JCA names for HMAC, Ed25519, ECDSA
  (P-256/P-384/P-521), RSA-PSS, and RSA PKCS#1 v1.5
- Signer / Verifier: minimal byte[]-in/byte[]-out interfaces
  supporting JCA, HSM, and remote KMS implementations
- JcaSigner / JcaVerifier: JCA-based implementations with
  constant-time HMAC comparison (MessageDigest.isEqual) and
  RSA-PSS PSSParameterSpec configuration
- EcdsaUtils: DER ↔ IEEE P1363 signature format conversion
  (ported from enkan-bouncr, no BouncyCastle dependency)

Tests cover sign→verify roundtrips for all algorithm families,
wrong-key rejection, tampered-data detection, and ECDSA P1363
output size validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add JWT (RFC 7519) / JWS (RFC 7515) support to enkan-web:

- JwsAlgorithm: maps JWS alg names (HS256, RS256, ES256, EdDSA, etc.)
  to CryptoAlgorithm constants from the shared crypto layer
- JwtHeader: record for JOSE header (typ, alg, kid)
- JwtProcessor: stateless sign/verify utility with no JSON library
  dependency — header is parsed/serialized with a minimal built-in
  parser; payload JSON (de)serialization is caller-provided via
  Function<byte[], T>. Validates exp/nbf time claims.

Rejects alg:none per security best practice. HMAC verification uses
constant-time comparison via the shared JcaVerifier.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ureBaseBuilder

RFC 9421 HTTP Message Signatures core types and signature base
construction:

- SignatureAlgorithm: maps SF algorithm names (hmac-sha256, ed25519,
  etc.) to CryptoAlgorithm constants
- SignatureComponent: record for covered component identifiers with
  SF parameter access (;sf, ;key, ;name, ;req, ;bs)
- SignatureBaseBuilder: constructs the canonical signature base string
  per §2.5. Supports all derived components (@method, @path, @query,
  @scheme, @authority, @target-uri, @request-target, @query-param,
  @status). Header components support bare values, ;sf canonicalization,
  and ;key dictionary member extraction. Multi-value headers are joined
  with ", " per §2.1.

@signature-params serialization reuses StructuredFields.serializeList
for inner-list output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Complete the RFC 9421 sign/verify pipeline:

- SignatureKeyResolver: interface resolving keyid to Verifier/Signer,
  supporting JCA, HSM, and remote KMS implementations
- VerifyResult: record carrying signature label, keyid, algorithm, and
  the covered component values used during verification — downstream
  consumers use these cryptographically verified values
- HttpMessageSignatures: static utility with verifyAll(), verify(), and
  sign() methods. Parses Signature/Signature-Input SF Dictionaries,
  validates created/expires time params, builds signature base, and
  delegates to Verifier/Signer from the shared crypto layer

Tests cover HMAC and Ed25519 sign→verify roundtrips, tamper detection,
missing headers, unknown keyid, and expired signature rejection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ure negotiation

Complete the RFC 9421 middleware layer:

- SignatureVerificationMiddleware: verifies Signature/Signature-Input
  headers on incoming requests. Stores VerifyResult list as a request
  extension for downstream access to verified component values.
  Supports requiredLabels and requiredComponents policy enforcement.
- Accept-Signature negotiation: when configured, 401 responses include
  an Accept-Signature header telling clients what signature parameters
  are expected (SF Dictionary format).
- HttpMessageSignatures.verifyAll: let SfParseException propagate so
  the middleware can distinguish malformed headers (400) from failed
  verification (401).
- module-info.java: export enkan.web.jwt and enkan.web.signature
  packages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Security fixes:
- JwtProcessor: prevent algorithm confusion attack (CVE-2016-5431) by
  validating key type against algorithm family (SecretKey for HMAC,
  PublicKey for asymmetric). Add verify(token, expectedAlgorithm, key)
  overload for explicit algorithm pinning.
- JwtProcessor: escape typ/alg fields in serializeHeader, not just kid
- EcdsaUtils: validate SEQUENCE (0x30) and INTEGER (0x02) DER tags
  instead of silently skipping them

Correctness fixes:
- JwtProcessor: add 60-second clock skew tolerance for exp/nbf validation
  to handle distributed system clock drift
- HttpMessageSignatures: add 60-second clock skew tolerance for
  created/expires params; reject signatures older than 300 seconds
  (created freshness check) to limit replay window
- JwtProcessor.verify(token, Key): catch IllegalArgumentException from
  unknown algorithm names instead of propagating (matches null-return
  contract)
- CryptoAlgorithm: add isEcdsa() method, removing duplicate private
  methods in JcaSigner and JcaVerifier
- JwtProcessor: document extractJson parser limitations (naive key
  matching, integer-only NumericDate); fix whitespace skipping to use
  Character.isWhitespace()

Test coverage additions:
- EcdsaUtilsTest: direct DER↔P1363 tests — null/empty/malformed input,
  wrong tags, round-trip identity for P-256 and P-521, high-bit padding
- JwtProcessorTest: algorithm confusion attack, verify with expected
  algorithm, unknown algorithm, alg:none end-to-end
- SignatureBaseBuilderTest: ;sf canonicalization, ;key dictionary member
  extraction, @query-param edge cases (empty value, missing param),
  response header resolution
- HttpMessageSignaturesTest: future created rejection, stale created
  rejection (max age)
- Extract SignatureTestSupport with shared buildRequest/emptyResolver/
  testResolver helpers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EcdsaUtils.derToP1363: INTEGER length fields now handle DER long-form
encoding (0x81/0x82 prefix) consistently with the existing SEQUENCE
length handling, closing a gap where malformed long-form INTEGER lengths
would be mis-parsed.

JwtProcessor.extractJsonNumber: the digit-scan loop previously allowed
'-' at any position in the number string, so "123-456" would reach
Long.parseLong and silently return null, bypassing time-claim validation.
Now '-' is only permitted as the leading character.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- SignatureAlgorithm: add 8 missing RFC 9421 §3.3 algorithms
  (hmac-sha384/512, ecdsa-p384/p521, rsa-pss-sha256/384, rsa-v1_5-sha384/512)
- EcdsaUtils.p1363ToDer: support full DER length encoding (0x81 and 0x82
  prefixes) for SEQUENCE lengths up to 65535, replacing silent byte truncation
- HttpMessageSignatures.verify: let UnsupportedOperationException propagate
  so callers can return 501 instead of silently skipping the signature
- SignatureVerificationMiddleware: catch UnsupportedOperationException and
  return 501 (not 400) for unsupported features like ;bs
- Tests: unknown alg value is skipped (not thrown), multiple simultaneous
  signatures are all verified, ;bs component returns 501

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…for dev misconfiguration

SignatureBaseBuilder throws MisconfigurationException (not IllegalArgumentException)
for two cases that are clearly caused by incorrect developer setup:
- @status component used without a response context
- @query-param component defined without a required ;name parameter

Added EN/JA message entries and updated the corresponding test assertions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
JcaSigner / JcaVerifier
- Validate key type at construction time and throw MisconfigurationException
  (crypto.INCOMPATIBLE_KEY_TYPE) instead of ClassCastException at signing time
- Remove SecretKeySpec double-wrap in signHmac/verifyHmac: pass SecretKey
  directly to Mac.init(), eliminating a redundant encode+re-wrap allocation

SignatureVerificationMiddleware.requiredComponents
- Fix policy logic: require at least ONE verified signature to cover ALL
  required components, not all signatures to cover each component.
  Previous code rejected multi-signature requests where sig2 covered
  component X but sig1 did not.

JwtProcessor.extractJsonString / extractJsonNumber
- Fix substring match: a key like "alg" could match inside a JSON string
  value that happens to contain the text "alg":... Scanners now verify
  the match is preceded by '{' or ',' and followed immediately by ':',
  skipping non-key occurrences.
- Extract buildSigningInput() helper to compose header.payload bytes
  directly without allocating an intermediate String.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…pe sequences, edge-case hardening

- SignatureBaseBuilder: collect all @query-param occurrences and join with ", " per RFC 9421 §2.2.8
- SignatureVerificationMiddleware: fix requiredComponents policy — anyMatch instead of allMatch
- JwtProcessor: add full RFC 8259 escape sequence support (\b, \f, \uXXXX) in unescapeJson
- JwtProcessor: validate alg is non-null before signing (RFC 7515 §4.1.1 mandatory field)
- HttpMessageSignatures: document time validation parameters in verifyAll Javadoc
- Tests: queryParamMultipleValuesJoined, headerWithUnicodeEscapeInKidIsDecoded, signWithNullAlgThrows

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ed ordering

- SignatureComponent.fromSfItem: normalize non-derived component names to lowercase
  per RFC 9421 §2.1 (HTTP field identifiers MUST be lowercase)
- HttpMessageSignatures.validateTimeParams: reject signatures where expires precedes
  created (RFC 9421 §4.3 — structurally invalid time parameters)
- Tests: fromSfItemNormalizesHeaderNameToLowerCase, verifyAllRejectsExpiresBeforeCreated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…cent-decode without + expansion

- @query-param with multiple occurrences now emits each value as a separate
  signature base line (e.g. three 'tag' params → three lines), per RFC 9421 §2.2.8
- Replace URLDecoder.decode() with a custom percentDecode() that treats '+' as a
  literal '+', not a space — RFC 9421 uses query-component semantics, not form-encoding
- Tests: queryParamMultipleValuesEachOnSeparateLine, queryParamPlusSignIsNotDecoded

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…authority

- JcaSigner: validate that ECDSA algorithms receive an ECKey (not just any PrivateKey),
  preventing ClassCastException with an opaque message at signing time
- SignatureBaseBuilder.resolveAuthority: prefer Host header per RFC 9421 §2.2.5,
  falling back to serverName/serverPort for reverse-proxy environments

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a shared cryptography abstraction in enkan-core and builds on it in enkan-web to add (1) stateless JWT JWS signing/verification and (2) RFC 9421 HTTP Message Signatures signing/verification plus a verification middleware.

Changes:

  • Add enkan.security.crypto shared layer (alg enum + Signer/Verifier + JCA implementations + ECDSA DER↔P1363 conversion).
  • Add enkan.web.jwt JWT processor with minimal JOSE header parsing and exp/nbf validation.
  • Add enkan.web.signature RFC 9421 signature base builder, algorithms/components, sign/verify utility, and verification middleware (+ tests and misconfiguration messages).

Reviewed changes

Copilot reviewed 29 out of 29 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
enkan-web/src/test/java/enkan/web/signature/SignatureTestSupport.java Shared request builder and key resolver stubs for signature tests
enkan-web/src/test/java/enkan/web/signature/SignatureBaseBuilderTest.java Tests for derived/header components and signature-base canonicalization
enkan-web/src/test/java/enkan/web/signature/HttpMessageSignaturesTest.java End-to-end sign/verify tests including time validation and multiple signatures
enkan-web/src/test/java/enkan/web/middleware/SignatureVerificationMiddlewareTest.java Middleware behavior tests (401/400/501 + Accept-Signature)
enkan-web/src/test/java/enkan/web/jwt/JwtProcessorTest.java JWT sign/verify tests (alg rejection, confusion, time claims, malformed tokens)
enkan-web/src/main/resources/META-INF/misconfiguration.properties Add web signature-related misconfiguration messages
enkan-web/src/main/resources/META-INF/misconfiguration_ja.properties Japanese translations for new web misconfiguration messages
enkan-web/src/main/java/module-info.java Export new enkan.web.jwt and enkan.web.signature packages
enkan-web/src/main/java/enkan/web/signature/VerifyResult.java Public verification result record including covered component values
enkan-web/src/main/java/enkan/web/signature/SignatureKeyResolver.java Interface for resolving Signer/Verifier by keyid + algorithm
enkan-web/src/main/java/enkan/web/signature/SignatureComponent.java Component identifier representation + SF item conversion
enkan-web/src/main/java/enkan/web/signature/SignatureBaseBuilder.java RFC 9421 signature base construction + component resolution
enkan-web/src/main/java/enkan/web/signature/SignatureAlgorithm.java Map RFC 9421 alg names → shared CryptoAlgorithm
enkan-web/src/main/java/enkan/web/signature/HttpMessageSignatures.java RFC 9421 sign/verify utility + time validation
enkan-web/src/main/java/enkan/web/middleware/SignatureVerificationMiddleware.java Middleware enforcing signature verification and policies
enkan-web/src/main/java/enkan/web/jwt/JwtProcessor.java Stateless JWT sign/verify + minimal header parsing + exp/nbf validation
enkan-web/src/main/java/enkan/web/jwt/JwtHeader.java JOSE header record
enkan-web/src/main/java/enkan/web/jwt/JwsAlgorithm.java JWS alg names → shared CryptoAlgorithm
enkan-core/src/test/java/enkan/security/crypto/JcaSignerVerifierTest.java Round-trip tests across algorithm families
enkan-core/src/test/java/enkan/security/crypto/EcdsaUtilsTest.java Tests for DER↔P1363 conversion edge cases
enkan-core/src/main/resources/META-INF/misconfiguration.properties Add crypto key-type mismatch misconfiguration messages
enkan-core/src/main/resources/META-INF/misconfiguration_ja.properties Japanese translations for crypto misconfiguration messages
enkan-core/src/main/java/module-info.java Export new enkan.security.crypto package
enkan-core/src/main/java/enkan/security/crypto/Verifier.java Verifier functional interface
enkan-core/src/main/java/enkan/security/crypto/Signer.java Signer functional interface
enkan-core/src/main/java/enkan/security/crypto/JcaVerifier.java JCA verifier implementation (HMAC constant-time compare, ECDSA conversion)
enkan-core/src/main/java/enkan/security/crypto/JcaSigner.java JCA signer implementation (ECDSA conversion to P1363)
enkan-core/src/main/java/enkan/security/crypto/EcdsaUtils.java DER↔P1363 conversion utilities
enkan-core/src/main/java/enkan/security/crypto/CryptoAlgorithm.java Central algorithm enum + RSA-PSS parameter specs

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

public record SignatureComponent(String name, SfParameters parameters) {

/** Creates a component with no parameters. */
public static SignatureComponent of(String name) {
Copilot AI Apr 5, 2026
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SignatureComponent.of(String) does not normalize HTTP field component identifiers to lowercase. RFC 9421 requires field component identifiers to be lowercase, and SignatureBaseBuilder/serializeSignatureParams will emit whatever case is provided, producing non-canonical signature bases (and potentially verification mismatches). Consider lowercasing name when it does not start with '@' (or validating and throwing if it contains uppercase).

Suggested change
public static SignatureComponent of(String name) {
public static SignatureComponent of(String name) {
if (!name.startsWith("@")) {
name = name.toLowerCase(Locale.ROOT);
}

Copilot uses AI. Check for mistakes.
Comment on lines +286 to +292
// Serialize the member as a single-entry dictionary, then strip the key prefix
Map<String, SfMember> single = new LinkedHashMap<>();
single.put(key, member);
String serialized = StructuredFields.serializeDictionary(new SfDictionary(single));
// Result is "key=value" or "key=(inner-list)"; strip "key="
int eq = serialized.indexOf('=');
return eq >= 0 ? serialized.substring(eq + 1) : serialized;
Copilot AI Apr 5, 2026
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extractDictionaryMember serializes a single-entry dictionary then strips key=. This is incorrect for SF Dictionary members whose value serializes without = (notably boolean true, which serializes as a bare key and may include parameters), causing the returned component value to include the key name instead of the member value (e.g. should be ?1 / ?1;...). Consider serializing the SfMember itself (e.g. via list serialization of a single member) rather than round-tripping through a dictionary and string slicing.

Suggested change
// Serialize the member as a single-entry dictionary, then strip the key prefix
Map<String, SfMember> single = new LinkedHashMap<>();
single.put(key, member);
String serialized = StructuredFields.serializeDictionary(new SfDictionary(single));
// Result is "key=value" or "key=(inner-list)"; strip "key="
int eq = serialized.indexOf('=');
return eq >= 0 ? serialized.substring(eq + 1) : serialized;
List<SfMember> single = new ArrayList<>(1);
single.add(member);
return StructuredFields.serializeList(new SfList(single));

Copilot uses AI. Check for mistakes.
Comment on lines +45 to +53
String signatureHeader = request.getHeaders().get("Signature");
String signatureInputHeader = request.getHeaders().get("Signature-Input");
if (signatureHeader == null || signatureInputHeader == null) {
return List.of();
}

SfDictionary signatureDict = StructuredFields.parseDictionary(signatureHeader);
SfDictionary signatureInputDict = StructuredFields.parseDictionary(signatureInputHeader);

Copilot AI Apr 5, 2026
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

verifyAll reads Signature / Signature-Input via Headers.get(...), which coerces multi-valued headers to List.toString() (e.g. [v1, v2]). If these headers are received multiple times and stored as a list, Structured Fields parsing will fail (likely returning 400). Consider retrieving the raw value (getRawType) and joining lists with ", " (similar to SignatureBaseBuilder.normalizeHeaderValue) before parsing.

Copilot uses AI. Check for mistakes.
Comment on lines +110 to +124
/**
* Verifies a single signature on a response.
*/
public static VerifyResult verify(HttpRequest request, HttpResponse response,
String label, SfInnerList signatureInput,
byte[] signatureBytes, Verifier verifier) {
List<SignatureComponent> components = signatureInput.items().stream()
.map(SignatureComponent::fromSfItem)
.toList();

// Build covered values map
Map<String, String> coveredValues = new LinkedHashMap<>();
for (SignatureComponent comp : components) {
try {
coveredValues.put(comp.name(),
Copilot AI Apr 5, 2026
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

coveredValues is populated using coveredValues.put(comp.name(), ...), which (1) drops component parameters (e.g. "@query-param";name="tag" and "@query-param";name="id" collide), and (2) cannot represent multi-line components like @query-param (RFC 9421 requires one signature-base line per occurrence). As a result, VerifyResult.coveredValues() can diverge from what was actually verified and requiredComponents checks cannot be parameter-precise. Consider keying by the serialized component identifier (including parameters) and changing the value type to support multiple occurrences (e.g. Map<String, List<String>>).

Suggested change
/**
* Verifies a single signature on a response.
*/
public static VerifyResult verify(HttpRequest request, HttpResponse response,
String label, SfInnerList signatureInput,
byte[] signatureBytes, Verifier verifier) {
List<SignatureComponent> components = signatureInput.items().stream()
.map(SignatureComponent::fromSfItem)
.toList();
// Build covered values map
Map<String, String> coveredValues = new LinkedHashMap<>();
for (SignatureComponent comp : components) {
try {
coveredValues.put(comp.name(),
private static void addCoveredValue(Map<String, String> coveredValues, String componentId, String value) {
coveredValues.merge(componentId, value, (existing, next) -> existing + "\n" + next);
}
private static String serializeCoveredComponentIdentifier(SfItem item, SignatureComponent component) {
String serialized = String.valueOf(item);
return serialized != null && !serialized.isEmpty() ? serialized : component.name();
}
/**
* Verifies a single signature on a response.
*/
public static VerifyResult verify(HttpRequest request, HttpResponse response,
String label, SfInnerList signatureInput,
byte[] signatureBytes, Verifier verifier) {
List<SfItem> signatureItems = signatureInput.items();
List<SignatureComponent> components = signatureItems.stream()
.map(SignatureComponent::fromSfItem)
.toList();
// Build covered values map using the serialized component identifier,
// preserving repeated occurrences in signature-base order.
Map<String, String> coveredValues = new LinkedHashMap<>();
for (int i = 0; i < components.size(); i++) {
SignatureComponent comp = components.get(i);
SfItem item = signatureItems.get(i);
try {
addCoveredValue(
coveredValues,
serializeCoveredComponentIdentifier(item, comp),

Copilot uses AI. Check for mistakes.
Comment on lines +140 to +145
/**
* Sets the component identifiers that must be covered by every valid signature.
*/
public void setRequiredComponents(Set<String> requiredComponents) {
this.requiredComponents = requiredComponents;
}
Copilot AI Apr 5, 2026
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Javadoc for setRequiredComponents says components must be covered by "every valid signature", but the enforcement logic earlier requires that at least one verified signature covers all required components (anyMatch). Please align the documentation with the implemented policy (or adjust the policy if the intent really is "every signature").

Copilot uses AI. Check for mistakes.
web.SIGNATURE_STATUS_REQUIRES_RESPONSE.problem=The @status derived component requires a response context.
web.SIGNATURE_STATUS_REQUIRES_RESPONSE.solution=Use @status only when signing or verifying a response. Pass a non-null HttpResponse to the signing/verification method.
web.SIGNATURE_QUERY_PARAM_NAME_REQUIRED.problem=The @query-param component requires a ;name parameter.
web.SIGNATURE_QUERY_PARAM_NAME_REQUIRED.solution=Specify the query parameter name with ;name=<param-name>, e.g. SignatureComponent.of("@query-param;name=id").
Copilot AI Apr 5, 2026
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The suggested usage SignatureComponent.of("@query-param;name=id") is not supported by the current API: SignatureComponent.of(String) does not parse ;name parameters. This error solution text may mislead users. Consider updating the example to show constructing a SignatureComponent with SfParameters (or add a parsing helper if that string form is intended to be supported).

Suggested change
web.SIGNATURE_QUERY_PARAM_NAME_REQUIRED.solution=Specify the query parameter name with ;name=<param-name>, e.g. SignatureComponent.of("@query-param;name=id").
web.SIGNATURE_QUERY_PARAM_NAME_REQUIRED.solution=Specify the query parameter name with ;name=<param-name> by constructing the @query-param SignatureComponent with SfParameters containing name=<param-name>.

Copilot uses AI. Check for mistakes.
- SignatureComponent.of: normalize to lowercase per RFC 9421 §2.1 (mirrors fromSfItem)
- SignatureBaseBuilder.extractDictionaryMember: serialize via SfList to correctly
  handle SF boolean true members (which have no '=' in dictionary form)
- HttpMessageSignatures.verifyAll: use entrySet() to access raw header values so
  multi-value List headers are joined with ", " before SF parsing (not "[v1, v2]")
- SignatureVerificationMiddleware.setRequiredComponents: fix Javadoc to say
  "at least one valid signature" instead of "every valid signature"
- misconfiguration.properties: fix misleading SignatureComponent.of("@query-param;name=id")
  example — that form is not supported; use SfParameters constructor instead

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated 7 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +23 to +29
public static SignatureComponent of(String name) {
// RFC 9421 §2.1: HTTP field component identifiers MUST be lowercase
if (!name.startsWith("@")) {
name = name.toLowerCase(Locale.ROOT);
}
return new SignatureComponent(name, SfParameters.EMPTY);
}
Copilot AI Apr 5, 2026
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SignatureComponent.of() does not normalize derived component identifiers (those starting with '@') to lowercase. This makes inputs like "@method"/"@path" fail later (e.g., SignatureBaseBuilder's switch only recognizes lowercase names). Consider lowercasing all component names (derived and field) per RFC 9421 and validating/normalizing in one place.

Copilot uses AI. Check for mistakes.
Comment on lines +232 to +238

if (component.isSf()) {
return canonicalizeSf(raw);
}
String keyParam = component.keyParam();
if (keyParam != null) {
return extractDictionaryMember(raw, keyParam);
Copilot AI Apr 5, 2026
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveHeader() returns early when the ;sf flag is present, which means a component that includes both ;sf and ;key will never apply the dictionary-member extraction. If both parameters can appear together, this produces incorrect covered values/signature bases. Consider applying ;key extraction even when ;sf is present (or explicitly rejecting the unsupported combination with a clear exception).

Suggested change
if (component.isSf()) {
return canonicalizeSf(raw);
}
String keyParam = component.keyParam();
if (keyParam != null) {
return extractDictionaryMember(raw, keyParam);
String keyParam = component.keyParam();
String resolved = raw;
if (keyParam != null) {
resolved = extractDictionaryMember(raw, keyParam);
}
if (component.isSf()) {
return canonicalizeSf(resolved);
}
if (keyParam != null) {
return resolved;

Copilot uses AI. Check for mistakes.
Comment on lines +117 to +132
List<SignatureComponent> components = signatureInput.items().stream()
.map(SignatureComponent::fromSfItem)
.toList();

// Build covered values map
Map<String, String> coveredValues = new LinkedHashMap<>();
for (SignatureComponent comp : components) {
try {
coveredValues.put(comp.name(),
SignatureBaseBuilder.resolveComponentValue(request, response, comp));
} catch (UnsupportedOperationException e) {
throw e; // propagate so callers can return 501
} catch (Exception e) {
return null;
}
}
Copilot AI Apr 5, 2026
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

verify() builds coveredValues with coveredValues.put(comp.name(), ...). This (1) loses information when the same component appears more than once (e.g., multiple @query-param occurrences or multiple header components with different parameters) and (2) does not reflect what is actually signed for @query-param, since buildSignatureBase emits one line per value but coveredValues stores a single comma-joined string. Consider keying by the serialized component identifier (including parameters) and allowing multiple values (e.g., Map<String,List> or Map<String,String> with newline-joined values matching the signature base).

Copilot uses AI. Check for mistakes.
SfParameters params = signatureInput.parameters();
String keyId = params.get("keyid") instanceof SfValue.SfString s ? s.value() : null;
String algName = params.get("alg") instanceof SfValue.SfString s ? s.value() : null;
SignatureAlgorithm algorithm = algName != null ? SignatureAlgorithm.fromSfName(algName) : null;
Copilot AI Apr 5, 2026
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HttpMessageSignatures.verify(...) resolves the algorithm at the end via SignatureAlgorithm.fromSfName(algName) without catching IllegalArgumentException. If a caller invokes verify(...) directly with an unknown/typo'd alg in Signature-Input, this will throw instead of returning null like verifyAll() does. Consider catching IllegalArgumentException here and treating it as verification failure (return null) for consistency.

Suggested change
SignatureAlgorithm algorithm = algName != null ? SignatureAlgorithm.fromSfName(algName) : null;
SignatureAlgorithm algorithm;
try {
algorithm = algName != null ? SignatureAlgorithm.fromSfName(algName) : null;
} catch (IllegalArgumentException e) {
return null;
}

Copilot uses AI. Check for mistakes.
try {
results = HttpMessageSignatures.verifyAll(request, keyResolver);
} catch (SfParseException e) {
return errorResponse(400, "Invalid Signature-Input header");
Copilot AI Apr 5, 2026
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SfParseException catch-all returns "Invalid Signature-Input header", but HttpMessageSignatures.verifyAll() parses both Signature and Signature-Input; malformed Signature will also land here. Consider updating the message to reflect both headers (or include which header failed) so clients/debugging aren't misled.

Suggested change
return errorResponse(400, "Invalid Signature-Input header");
return errorResponse(400, "Invalid Signature or Signature-Input header");

Copilot uses AI. Check for mistakes.
Comment on lines +140 to +145
/**
* Sets the component identifiers that must be covered by at least one valid signature.
* A request is rejected if no single verified signature covers all of the specified components.
*/
public void setRequiredComponents(Set<String> requiredComponents) {
this.requiredComponents = requiredComponents;
Copilot AI Apr 5, 2026
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setRequiredComponents() accepts arbitrary strings, but component identifiers are effectively case-sensitive in enforcement (coveredValues keys are lowercase for header fields, and derived components are expected lowercase like "@method"). This can lead to false 401s if callers configure e.g. "Content-Type". Consider normalizing required component names on set (lowercase when not starting with '@', and lowercase derived identifiers too) to match SignatureComponent normalization.

Suggested change
/**
* Sets the component identifiers that must be covered by at least one valid signature.
* A request is rejected if no single verified signature covers all of the specified components.
*/
public void setRequiredComponents(Set<String> requiredComponents) {
this.requiredComponents = requiredComponents;
private String normalizeRequiredComponent(String component) {
return component == null ? null : component.toLowerCase(Locale.ROOT);
}
/**
* Sets the component identifiers that must be covered by at least one valid signature.
* A request is rejected if no single verified signature covers all of the specified components.
*/
public void setRequiredComponents(Set<String> requiredComponents) {
this.requiredComponents = requiredComponents == null ? null : requiredComponents.stream()
.map(this::normalizeRequiredComponent)
.collect(LinkedHashSet::new, Set::add, Set::addAll);

Copilot uses AI. Check for mistakes.
* @param key the signing key (SecretKey for HMAC, PrivateKey for asymmetric)
* @return the signed JWT string
*/
public static String sign(JwtHeader header, byte[] claimsJsonBytes, Key key) {
Copilot AI Apr 5, 2026
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sign(JwtHeader, byte[], Key) calls JwsAlgorithm.fromJwsName(header.alg()) before serializeHeader(header) validates that alg is present. With a null/invalid alg this produces a less specific error than the serializer's RFC-referencing message. Consider validating header.alg() (and possibly rejecting "none") before mapping to JwsAlgorithm so error reporting is consistent across overloads.

Suggested change
public static String sign(JwtHeader header, byte[] claimsJsonBytes, Key key) {
public static String sign(JwtHeader header, byte[] claimsJsonBytes, Key key) {
serializeHeader(header);

Copilot uses AI. Check for mistakes.
kawasima and others added 3 commits April 5, 2026 10:46
- SignatureComponent: normalize all component names (including derived @-names)
  to lowercase so inputs like "@method" or "@path" don't break the switch
- SignatureBaseBuilder.resolveHeader: apply ;key extraction before ;sf
  canonicalization so components with both parameters are handled correctly
- HttpMessageSignatures.verify: catch IllegalArgumentException from
  SignatureAlgorithm.fromSfName to return null instead of throwing
- SignatureVerificationMiddleware: normalize requiredComponents to lowercase
  on set to match SignatureComponent normalization and avoid false 401s
- SignatureVerificationMiddleware: clarify 400 error message to mention
  both Signature and Signature-Input headers
- JwtProcessor.sign(JwtHeader, byte[], Key): validate header (alg present)
  before JwsAlgorithm lookup for consistent error reporting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- CryptoAlgorithm: replace isEcdsa() string-contains check with an explicit
  boolean enum field — more robust if JCA naming conventions ever change
- EcdsaUtils: add DER structure diagram to class Javadoc for maintainability
- JwtProcessor.verify(token, Key): document algorithm confusion risk and
  recommend the three-argument overload in the Javadoc
- SignatureBaseBuilder.resolveHeader: add comment explaining why lowercase
  component names always match Headers keys (stored in ASCII lowercase)
- Test: add malformed base64 payload test for JwtProcessor.verify
- Test: add clock-skew boundary tests (59s inside / 61s outside tolerance)
  for HttpMessageSignatures.verifyAll

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- queryParamPercentDecodedValue: verify %20/%2B/%3D are decoded per RFC 3986
  (not form-encoding) in @query-param component resolution
- nonJsonPayloadDoesNotFailVerification: document that a non-JSON payload
  passes JWT verification because there are no time claims to reject

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@kawasima
kawasima merged commit 717460d into develop Apr 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

0