From e67257c7504f9bafbccab15eeff71aef4a9e2dd4 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 09:34:44 +0000 Subject: [PATCH] Budget registration per address; embed difficulty; expiry queue; 404 on empty withdraw Co-Authored-By: Claude Sonnet 5.5 --- docs/registration-gates.md | 9 +- .../Configuration/RegistrationOptions.cs | 12 + src/Bifrost.Web/Features/Oidc/OidcGuard.cs | 25 +-- .../RegistrationChallengeService.cs | 22 +- .../Registration/RegistrationRateLimit.cs | 43 ++++ .../Features/Shared/ClientAddress.cs | 32 +++ .../Shared/StartupConfigurationValidator.cs | 6 + src/Bifrost.Web/Program.cs | 19 +- .../RegistrationGateTests.cs | 24 ++ .../RegistrationLimitTests.cs | 211 ++++++++++++++++++ .../Support/TestWebApplicationFactory.cs | 1 + 11 files changed, 368 insertions(+), 36 deletions(-) create mode 100644 src/Bifrost.Web/Features/Registration/RegistrationRateLimit.cs create mode 100644 src/Bifrost.Web/Features/Shared/ClientAddress.cs create mode 100644 tests/Bifrost.Web.Tests/RegistrationLimitTests.cs diff --git a/docs/registration-gates.md b/docs/registration-gates.md index 791a1e4..1b9278a 100644 --- a/docs/registration-gates.md +++ b/docs/registration-gates.md @@ -34,9 +34,12 @@ server verifies the protection, the expiry and the hash, and spends the id once. script all pass the same way, at the cost of CPU time on the client. - Cost: difficulty 18 is about 260,000 hashes on average, well under a second natively and a few seconds in browser JavaScript. It prices a bulk registration run in CPU; it does not stop a determined attacker with CPUs, and it is not a - proof of humanity. -- Replay: the spent-id set is in memory and bounded; a restart forgets it, so a challenge can be replayed for at most its - five-minute life. When the set is full the endpoint refuses rather than grows. + proof of humanity. The difficulty is embedded in the signed challenge, so a challenge verifies at the difficulty it was issued with. +- Replay: the spent-id set is in memory and bounded (10,000, entries dropped at expiry); a restart forgets it, so a challenge + can be replayed for at most its five-minute life. When the set is full the endpoint refuses rather than grows. One address + cannot fill it alone: `GET /auth/bifrost/challenge` and `POST /auth/bifrost/register` share one per-address budget + (`Bifrost:Registration:Limits`, 10 per minute by default, IPv6 by /64, over budget is 429 with Retry-After). An attacker + with many addresses can still fill the set; that is the accepted bound, the same as oidc-issuer.md. Not built: the sign-up form. No registration page exists today; the endpoint is the contract and the only client is a script. The form (solving in a worker, a `