Skip to content

Commit a5e15bb

Browse files
wolpertclaude
andcommitted
fix: resolve security and correctness findings from review (M1–M9)
Address the actionable High/Medium/Low findings from the multi-agent review. All fixes land together; `./gradlew check` is green (spotless + tests incl. Testcontainers + JaCoCo). High - magic-link(jwt): stop magic links inheriting the 1h access-token TTL. PkAuthJwtIssuer gains an issue(claims, ttlOverride) overload; MagicLink now issues with its own tokenTtl (default 15m) and enforces consumedJtiTtl >= tokenTtl at construction, closing the replay window where a redeemed link stayed valid after its single-use JTI was evicted. - magic-link: startLogin now delivers ONLY to the address bound to the resolved user (UserLookup#emailFor), never the caller-supplied one — previously an attacker could request a login token for any account by username and have it sent to an address they control. Skips the send (enumeration-resistant Sent) when no bound email exists. - build: reconcile the Spotless version/pin/docs divergence — the catalog comment + dependabot ignore claimed a 7.x pin while the build ran 8.7.0 (the ignore targeted the plugin-marker artifact, not the implementation artifact, so it never matched). Docs now match reality; dead ignore removed. Medium - core: enforce per-request userVerification at finish. The resolved UV is persisted on ChallengeRecord at start and the finish step now requires UV if EITHER the global config OR the per-request value is REQUIRED (max of the two), so a step-up REQUIRED can no longer be silently downgraded. Adds challenges.user_verification (Flyway V11, schema -> "11") and carries it through the JDBI and DynamoDB challenge stores. - dynamodb(refresh): make create() a single atomic TransactWriteItems for the primary + user/family index items, eliminating the orphaned-primary window that revokeFamily/revokeAllForUser would silently skip; duplicate refreshId now surfaces via PkAuthPersistenceException, not a raw escape. - jdbi(credentials): updateSignCount guard relaxed from `< :sc` to `<= :sc` so last_used_at is recorded for sync passkeys that always report signCount=0 (regression is still rejected), per the SPI contract. - spring: pin pkAuthSecurityFilterChain order so a host catch-all chain can't swallow /auth/**, and disable global servlet auto-registration of the JWT filter so it runs only inside the security chain. Low - core: null-guard AAGUID in persistRegistration so a null AAGUID returns a sealed RegistrationResult instead of NPE-ing across the boundary. - jwt: JwtKeyset.es256 stores only public JWKs in verificationSource() so a host that publishes it as JWKS cannot leak the private signing key. - otp: TwilioSmsSender no longer echoes accountSid/fromNumber/authToken into its exception message. Deferred (noted for follow-up): magic-link dedicated-audience split (needs per-adapter validator rewiring) and in-store OTP expiry filtering (needs a ClockProvider threaded through the OtpRepository SPI). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e2a3a77 commit a5e15bb

21 files changed

Lines changed: 447 additions & 83 deletions

File tree

.github/dependabot.yml

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,6 @@ updates:
1717
patterns:
1818
- "*"
1919
ignore:
20-
# Spotless 8.x's google-java-format integration intermittently fails to load Guava / GJF
21-
# classes (NoClassDefFoundError on Newlines / ImmutableList$*) on Gradle 9 / JDK 21. Pin
22-
# to 7.x until upstream fixes. The Gradle plugin marker artifact uses the plugin id, not
23-
# the implementation artifact id, so this is the right Dependabot dependency-name.
24-
- dependency-name: "com.diffplug.spotless:com.diffplug.spotless.gradle.plugin"
25-
versions: [">=8"]
2620
# Micronaut 5.x requires JVM 25; the project targets Java 21 (brief §3). Stay on the 4.x
2721
# line until the JDK baseline moves.
2822
- dependency-name: "io.micronaut:*"

gradle/libs.versions.toml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010
java = "21"
1111

1212
# Build / quality
13-
# Spotless pinned to the 7.x line: Spotless 8's google-java-format integration intermittently
14-
# fails class loading on Gradle 9 / JDK 21. Dependabot ignore in .github/dependabot.yml keeps
15-
# this from getting bumped back. Revisit when Spotless 8 ships a fix.
13+
# Spotless tracks the 8.x line (currently 8.7.0) and is kept current by Dependabot's
14+
# dev-dependencies group. Historical note: Spotless 8's google-java-format integration could
15+
# intermittently fail class loading on Gradle 9 / JDK 21 under a *cached* build, which is why the
16+
# Gradle build cache stays disabled (see gradle.properties) — `./gradlew check` itself runs clean
17+
# on 8.x. The earlier 7.x pin (and its dependabot ignore) has been retired.
1618
spotless = "8.7.0"
1719
errorprone-plugin = "5.1.0"
1820
errorprone-core = "2.50.0"

pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationService.java

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -229,12 +229,20 @@ public StartRegistrationResult startRegistration(
229229
byte[] challenge = challengeGenerator.generate();
230230
ChallengeId challengeId = ChallengeId.random();
231231

232+
// Resolve the user-verification requirement (per-request override, else ceremony default) and
233+
// persist it on the challenge record so finishRegistration can enforce it server-side. Without
234+
// this, a per-request REQUIRED (e.g. step-up) is advisory only — finish would fall back to the
235+
// global config and accept flagUV=false.
236+
UserVerificationRequirement uv =
237+
req.userVerification() == null ? ceremonyConfig.userVerification() : req.userVerification();
238+
232239
challengeStore.put(
233240
challengeId,
234241
new ChallengeRecord(
235242
challenge,
236243
ChallengeRecord.Purpose.REGISTRATION,
237244
userHandle,
245+
uv,
238246
clockProvider.now().plus(ceremonyConfig.challengeTtl())),
239247
ceremonyConfig.challengeTtl());
240248

@@ -244,9 +252,6 @@ public StartRegistrationResult startRegistration(
244252
List<PublicKeyCredentialDescriptor> excludeCredentials =
245253
credentialRepository.findByUserHandle(userHandle).stream().map(this::toDescriptor).toList();
246254

247-
UserVerificationRequirement uv =
248-
req.userVerification() == null ? ceremonyConfig.userVerification() : req.userVerification();
249-
250255
AuthenticatorSelectionCriteria selection =
251256
new AuthenticatorSelectionCriteria(null, ceremonyConfig.residentKey(), null, uv);
252257

@@ -351,13 +356,31 @@ private RegistrationData verifyRegistrationWithW4j(
351356
new RegistrationParameters(
352357
serverProperty,
353358
WebAuthn4JConverters.pubKeyCredParams(ceremonyConfig.acceptedAlgorithms()),
354-
WebAuthn4JConverters.userVerificationRequired(ceremonyConfig.userVerification()),
359+
effectiveUserVerificationRequired(challengeRecord),
355360
/* userPresenceRequired */ true);
356361
// Attestation signature verification is intentionally non-strict in the default manager;
357362
// BadSignatureException is not thrown here (see finding #41 / #3 for strict-mode opt-in).
358363
return webAuthnManager.verify(w4jRequest, w4jParams);
359364
}
360365

366+
/**
367+
* Effective user-verification requirement for a finish step: {@code true} (UV required) if EITHER
368+
* the global {@link CeremonyConfig#userVerification()} OR the per-request requirement resolved at
369+
* start (persisted on {@link ChallengeRecord#userVerification()}) is {@code REQUIRED} — i.e. the
370+
* stricter of the two. This enforces a per-request step-up {@code REQUIRED} server-side even when
371+
* the global default is relaxed to {@code PREFERRED}/{@code DISCOURAGED}, and never weakens the
372+
* global config. A {@code null} recorded requirement (legacy record) contributes nothing, so the
373+
* global config still applies.
374+
*/
375+
private boolean effectiveUserVerificationRequired(ChallengeRecord challengeRecord) {
376+
boolean configRequired =
377+
WebAuthn4JConverters.userVerificationRequired(ceremonyConfig.userVerification());
378+
boolean perRequestRequired =
379+
challengeRecord.userVerification() != null
380+
&& WebAuthn4JConverters.userVerificationRequired(challengeRecord.userVerification());
381+
return configRequired || perRequestRequired;
382+
}
383+
361384
/**
362385
* Validates the attested credential data against the configured {@link AttestationTrustPolicy}
363386
* and rejects duplicates already stored. Returns the failure result to emit, or {@code null} when
@@ -399,7 +422,13 @@ private RegistrationResult persistRegistration(
399422

400423
var authData = data.getAttestationObject().getAuthenticatorData();
401424
byte[] coseBytes = WebAuthn4JConverters.serializeCoseKey(acd.getCOSEKey(), objectConverter);
402-
UUID aaguidUuid = AAGUID.ZERO.equals(acd.getAaguid()) ? null : acd.getAaguid().getValue();
425+
// Null-guard the AAGUID to match evaluateAttestation: AAGUID.ZERO.equals(null) is false, so
426+
// without the explicit null check a null AAGUID would NPE on getValue() and escape as a 500
427+
// instead of a sealed RegistrationResult.
428+
UUID aaguidUuid =
429+
acd.getAaguid() == null || AAGUID.ZERO.equals(acd.getAaguid())
430+
? null
431+
: acd.getAaguid().getValue();
403432

404433
Set<Transport> transports = EnumSet.noneOf(Transport.class);
405434
if (data.getTransports() != null) {
@@ -480,18 +509,21 @@ public StartAuthenticationResult startAuthentication(
480509
byte[] challenge = challengeGenerator.generate();
481510
ChallengeId challengeId = ChallengeId.random();
482511

512+
// Resolve UV (per-request override, else ceremony default) and persist it on the record so
513+
// finishAuthentication enforces it server-side (see startRegistration for rationale).
514+
UserVerificationRequirement uv =
515+
req.userVerification() == null ? ceremonyConfig.userVerification() : req.userVerification();
516+
483517
challengeStore.put(
484518
challengeId,
485519
new ChallengeRecord(
486520
challenge,
487521
ChallengeRecord.Purpose.AUTHENTICATION,
488522
resolvedHandle,
523+
uv,
489524
clockProvider.now().plus(ceremonyConfig.challengeTtl())),
490525
ceremonyConfig.challengeTtl());
491526

492-
UserVerificationRequirement uv =
493-
req.userVerification() == null ? ceremonyConfig.userVerification() : req.userVerification();
494-
495527
PublicKeyCredentialRequestOptionsJson options =
496528
new PublicKeyCredentialRequestOptionsJson(
497529
challenge, DEFAULT_TIMEOUT_MS, rpConfig.id(), allowCredentials, uv, null);
@@ -648,7 +680,7 @@ private AuthenticationData verifyAssertionWithW4j(
648680
serverProperty,
649681
w4jCred,
650682
/* allowCredentials */ null,
651-
WebAuthn4JConverters.userVerificationRequired(ceremonyConfig.userVerification()),
683+
effectiveUserVerificationRequired(challengeRecord),
652684
/* userPresenceRequired */ true);
653685
return webAuthnManager.verify(w4jRequest, w4jParams);
654686
}

pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/ChallengeRecord.java

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
package com.codeheadsystems.pkauth.spi;
33

44
import com.codeheadsystems.pkauth.api.UserHandle;
5+
import com.codeheadsystems.pkauth.api.UserVerificationRequirement;
56
import java.time.Instant;
67
import java.util.Arrays;
78
import java.util.Objects;
@@ -14,11 +15,21 @@
1415
* @param purpose whether this challenge belongs to a registration or authentication ceremony
1516
* @param userHandle the user this challenge is bound to; nullable for usernameless flows where the
1617
* user is only known at finish
18+
* @param userVerification the user-verification requirement resolved at start (the per-request
19+
* override if the caller supplied one, otherwise the ceremony default); persisted so the finish
20+
* step can enforce it server-side rather than letting a per-request {@code REQUIRED} be
21+
* silently downgraded. {@code null} means "no resolved requirement recorded" (legacy records /
22+
* direct constructions), in which case the finish step enforces only the global ceremony
23+
* config.
1724
* @param expiresAt absolute expiration; consumers should treat past-due records as missing
1825
* @since 0.9.0
1926
*/
2027
public record ChallengeRecord(
21-
byte[] challenge, Purpose purpose, @Nullable UserHandle userHandle, Instant expiresAt) {
28+
byte[] challenge,
29+
Purpose purpose,
30+
@Nullable UserHandle userHandle,
31+
@Nullable UserVerificationRequirement userVerification,
32+
Instant expiresAt) {
2233

2334
public ChallengeRecord {
2435
Objects.requireNonNull(challenge, "challenge");
@@ -30,6 +41,17 @@ public record ChallengeRecord(
3041
challenge = challenge.clone();
3142
}
3243

44+
/**
45+
* Back-compatible constructor for callers that don't carry a resolved user-verification
46+
* requirement; equivalent to passing {@code null} for {@code userVerification}.
47+
*
48+
* @since 2.1.0
49+
*/
50+
public ChallengeRecord(
51+
byte[] challenge, Purpose purpose, @Nullable UserHandle userHandle, Instant expiresAt) {
52+
this(challenge, purpose, userHandle, null, expiresAt);
53+
}
54+
3355
@Override
3456
public byte[] challenge() {
3557
return challenge.clone();
@@ -41,12 +63,14 @@ public boolean equals(Object o) {
4163
&& Arrays.equals(this.challenge, other.challenge)
4264
&& this.purpose == other.purpose
4365
&& Objects.equals(this.userHandle, other.userHandle)
66+
&& this.userVerification == other.userVerification
4467
&& this.expiresAt.equals(other.expiresAt);
4568
}
4669

4770
@Override
4871
public int hashCode() {
49-
return Objects.hash(Arrays.hashCode(challenge), purpose, userHandle, expiresAt);
72+
return Objects.hash(
73+
Arrays.hashCode(challenge), purpose, userHandle, userVerification, expiresAt);
5074
}
5175

5276
/**

pk-auth-core/src/test/java/com/codeheadsystems/pkauth/spi/ChallengeRecordTest.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import static org.assertj.core.api.Assertions.assertThatThrownBy;
66

77
import com.codeheadsystems.pkauth.api.UserHandle;
8+
import com.codeheadsystems.pkauth.api.UserVerificationRequirement;
89
import java.time.Instant;
910
import org.junit.jupiter.api.Test;
1011

@@ -24,6 +25,29 @@ void buildsAndCopiesChallenge() {
2425
assertThat(rec.expiresAt()).isEqualTo(exp);
2526
}
2627

28+
@Test
29+
void carriesUserVerificationAndDefaultsToNullViaCompatConstructor() {
30+
Instant exp = Instant.parse("2024-01-01T00:05:00Z");
31+
UserHandle uh = UserHandle.random();
32+
33+
ChallengeRecord withUv =
34+
new ChallengeRecord(
35+
new byte[] {1},
36+
ChallengeRecord.Purpose.AUTHENTICATION,
37+
uh,
38+
UserVerificationRequirement.REQUIRED,
39+
exp);
40+
assertThat(withUv.userVerification()).isEqualTo(UserVerificationRequirement.REQUIRED);
41+
42+
// The back-compat 4-arg constructor records no resolved requirement.
43+
ChallengeRecord legacy =
44+
new ChallengeRecord(new byte[] {1}, ChallengeRecord.Purpose.AUTHENTICATION, uh, exp);
45+
assertThat(legacy.userVerification()).isNull();
46+
47+
// userVerification participates in equality.
48+
assertThat(withUv).isNotEqualTo(legacy);
49+
}
50+
2751
@Test
2852
void equalsAndHashCode() {
2953
Instant exp = Instant.parse("2024-01-01T00:05:00Z");

pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtKeyset.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,13 @@ public static JwtKeyset hs256(byte[] secret) {
6464
public static JwtKeyset es256(ECKey current, ECKey... retired) {
6565
Objects.requireNonNull(current, "current");
6666
List<JWK> all = new ArrayList<>();
67-
all.add(current);
67+
// Store ONLY public halves in the verification set. verificationSource() is public and a host
68+
// may publish it as a JWKS endpoint; including the private `d` parameter here would leak the
69+
// signing key. The full `current` (with the private key) is retained separately for signer().
70+
all.add(current.toPublicJWK());
6871
for (ECKey r : retired) {
6972
Objects.requireNonNull(r, "retired key");
70-
all.add(r);
73+
all.add(r.toPublicJWK());
7174
}
7275
return new JwtKeyset(current, JWSAlgorithm.ES256, all);
7376
}

pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtIssuer.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@
77
import com.nimbusds.jose.JWSHeader;
88
import com.nimbusds.jwt.JWTClaimsSet;
99
import com.nimbusds.jwt.SignedJWT;
10+
import java.time.Duration;
1011
import java.time.Instant;
1112
import java.util.Date;
1213
import java.util.List;
1314
import java.util.Map;
1415
import java.util.Objects;
1516
import java.util.Optional;
1617
import java.util.UUID;
18+
import org.jspecify.annotations.Nullable;
1719

1820
/**
1921
* Issues pk-auth JWTs. Maps {@link JwtClaims} onto the standard {@code iss/sub/aud/iat/nbf/exp/jti}
@@ -78,11 +80,30 @@ public PkAuthJwtIssuer(
7880
* to the caller — the host must retry or surface the failure upstream.
7981
*/
8082
public String issue(JwtClaims claims) {
83+
return issue(claims, null);
84+
}
85+
86+
/**
87+
* Issues a signed JWT whose lifetime is {@code ttlOverride} instead of the per-audience
88+
* access-token TTL resolved from {@link JwtConfig#ttlPolicy()}. Use this for short-lived,
89+
* single-purpose tokens (e.g. magic links) that must NOT inherit the full access-token validity
90+
* window — a magic link minted at the 1-hour access TTL stays redeemable as a bearer token (and
91+
* replayable past its single-use JTI retention) far longer than intended. A {@code null} {@code
92+
* ttlOverride} falls back to the audience's access TTL, making this exactly equivalent to {@link
93+
* #issue(JwtClaims)}.
94+
*
95+
* @param claims the claims to sign
96+
* @param ttlOverride the token lifetime, or {@code null} to use the audience access TTL
97+
* @return the serialized, signed JWT
98+
* @since 2.1.0
99+
*/
100+
public String issue(JwtClaims claims, @Nullable Duration ttlOverride) {
81101
Objects.requireNonNull(claims, "claims");
82102
Instant now = clockProvider.now();
83103
Instant nbf = now.minus(config.notBeforeSkew());
84104
String audience = claims.audience() != null ? claims.audience() : config.defaultAudience();
85-
Instant exp = now.plus(config.ttlPolicy().accessTtl(audience));
105+
Instant exp =
106+
now.plus(ttlOverride != null ? ttlOverride : config.ttlPolicy().accessTtl(audience));
86107
String jti = UUID.randomUUID().toString();
87108

88109
JWTClaimsSet.Builder body =

0 commit comments

Comments
 (0)