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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ finding from that review.
clean **400** — covered by an adapter-level test asserting the refusal reaches
the client as a 400 and not a 500. `StartAuthenticationRequest` still accepts a
`null` username (the usernameless / discoverable-credential flow).
- **The per-username ceremony rate limit can no longer be bypassed by varying
case.** `UserLookup` implementations resolve usernames case-insensitively
(`DynamoDbUserLookup` lower-cases the identity key), but the ceremony service
passed the raw request string to `CeremonyRateLimiter.tryAcquireForUsername` —
so `alice`, `Alice`, and `ALICE` drew on three independent budgets against one
account, and an 8-character username yielded 256 of them. The per-IP bucket did
not compensate: the per-username bucket exists for the distributed case, which
is exactly the case the split defeated. The service now case-folds
(`toLowerCase(Locale.ROOT)`) before consulting the limiter, so **every**
`CeremonyRateLimiter` — including a host's shared Redis implementation —
inherits the fix. The SPI documents that the argument arrives folded and MUST
be used as given.

## [2.2.0] — 2026-06-27

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
import java.util.EnumSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
Expand Down Expand Up @@ -903,7 +904,14 @@ private AssertionResult outcomeAssertion(AssertionResult result, long start) {
LOG.info("{} rate-limited ip-bucket clientIp={}", phase, clientIp);
return "ip";
}
if (username != null && !rateLimiter.tryAcquireForUsername(username)) {
// Case-fold before bucketing. UserLookup implementations resolve usernames case-insensitively
// (DynamoDbUserLookup lower-cases the identity key), so keying the bucket on the raw request
// string gave "alice", "Alice", and "ALICE" three independent budgets against one account — an
// 8-character username yields 256 of them. The per-IP bucket does not compensate: the
// per-username bucket exists precisely for the distributed case, which is the case the split
// defeated. Folding here rather than inside the limiter means every CeremonyRateLimiter
// implementation — including a host's shared Redis one — inherits the fix.
if (username != null && !rateLimiter.tryAcquireForUsername(username.toLowerCase(Locale.ROOT))) {
LOG.info("{} rate-limited username-bucket username={}", phase, username);
return "username";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,14 @@ public interface CeremonyRateLimiter {
* budget. Only called by the ceremony service for {@code start*} requests that carry a username;
* {@code finish*} requests have no username on the wire and consult only the per-IP bucket.
*
* @param username the username supplied on the start ceremony request; never {@code null}
* <p><strong>The value arrives already case-folded</strong> ({@code toLowerCase(Locale.ROOT)}),
* because {@link UserLookup} implementations resolve usernames case-insensitively. Since 2.3.0
* the ceremony service folds it before calling, so implementations MUST use the argument as given
* and MUST NOT re-derive a bucket key from a differently-cased source — case variants of one
* username would otherwise get independent budgets and multiply the effective limit against a
* single account.
*
* @param username the case-folded username from the start ceremony request; never {@code null}
* @return {@code true} when allowed; {@code false} when the per-username budget for the current
* window is exhausted
* @since 0.9.1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,20 @@ void allowedCallsConsultLimiterAndProceed() {
verify(challengeStore).put(any(), any(), any());
}

@Test
void usernameBucketKeyIsCaseFoldedSoVariantsShareOneBudget() {
// Regression guard: UserLookup resolves usernames case-insensitively, so case variants must
// not each get their own per-username budget. Keying on the raw request string let a
// distributed attacker multiply the 10/min allowance against one account by 2^len.
service.startRegistration(
new StartRegistrationRequest("Alice", "Alice", null, null), "1.1.1.1");
service.startRegistration(
new StartRegistrationRequest("ALICE", "Alice", null, null), "1.1.1.1");
service.startAuthentication(new StartAuthenticationRequest("aLiCe", null), "1.1.1.1");

assertThat(limiter.usernameCalls).containsExactly("alice", "alice", "alice");
}

private static FinishRegistrationRequest stubFinishRegistration() {
return new FinishRegistrationRequest(
CHALLENGE_ID,
Expand Down
Loading