From 33be966b7dff7fc6bb7443d47ef8f2e2186a83ec Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 10:15:46 +0000 Subject: [PATCH] Registration gates: proof-of-work CAPTCHA and training-notice consent receipt (#54) --- README.md | 2 +- docs/bridge.md | 2 +- docs/registration-gates.md | 86 ++++ .../Configuration/RegistrationOptions.cs | 31 ++ src/Bifrost.Web/Data/BifrostDbContext.cs | 10 + .../20261111090000_AddConsentReceipts.cs | 40 ++ .../BifrostDbContextModelSnapshot.cs | 37 ++ src/Bifrost.Web/Domain/BifrostModels.cs | 19 + .../Membership/BifrostIdentityService.cs | 8 + src/Bifrost.Web/Features/Oidc/OidcGuard.cs | 25 +- .../RegistrationChallengeService.cs | 137 +++++++ .../Registration/RegistrationRateLimit.cs | 43 ++ .../Features/Registration/TrainingNotice.cs | 17 + .../Features/Shared/ClientAddress.cs | 32 ++ .../Shared/StartupConfigurationValidator.cs | 15 +- src/Bifrost.Web/Program.cs | 80 +++- tests/Bifrost.Web.Tests/DeskOptionsTests.cs | 3 +- tests/Bifrost.Web.Tests/HostTrustTests.cs | 3 +- .../MigrationDiscoveryTests.cs | 1 + tests/Bifrost.Web.Tests/OidcIssuerTests.cs | 2 +- .../PlatformEndpointsTests.cs | 23 +- .../RegistrationGateTests.cs | 367 ++++++++++++++++++ .../RegistrationLimitTests.cs | 239 ++++++++++++ .../ReservedIdentityTests.cs | 10 +- .../Support/OidcLogoutKit.cs | 2 +- .../Support/RegistrationKit.cs | 63 +++ .../Support/TestAuthHandler.cs | 10 + .../Support/TestWebApplicationFactory.cs | 2 + 28 files changed, 1253 insertions(+), 56 deletions(-) create mode 100644 docs/registration-gates.md create mode 100644 src/Bifrost.Web/Configuration/RegistrationOptions.cs create mode 100644 src/Bifrost.Web/Data/Migrations/20261111090000_AddConsentReceipts.cs create mode 100644 src/Bifrost.Web/Features/Registration/RegistrationChallengeService.cs create mode 100644 src/Bifrost.Web/Features/Registration/RegistrationRateLimit.cs create mode 100644 src/Bifrost.Web/Features/Registration/TrainingNotice.cs create mode 100644 src/Bifrost.Web/Features/Shared/ClientAddress.cs create mode 100644 tests/Bifrost.Web.Tests/RegistrationGateTests.cs create mode 100644 tests/Bifrost.Web.Tests/RegistrationLimitTests.cs create mode 100644 tests/Bifrost.Web.Tests/Support/RegistrationKit.cs diff --git a/README.md b/README.md index 0e4ea9d..33302e1 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ For the full staged roadmap, including contribution points, revenue share, patro ## Local Dev Notes - set `GitHubOAuth:ClientId` and `GitHubOAuth:ClientSecret` before using sign-in -- POST `/auth/bifrost/register` with `identity`, optional `displayName`, and optional `returnUrl` to create a Bifrost-native account without OAuth; this signs in as `Authenticated` but does not grant active member access +- POST `/auth/bifrost/register` with `identity`, optional `displayName`, optional `returnUrl`, a solved proof-of-work `challenge` and `nonce` (from `GET /auth/bifrost/challenge`), and `acceptedNoticeVersion` (the `version` from `GET /auth/bifrost/notice`) to create a Bifrost-native account without OAuth; this signs in as `Authenticated` but does not grant active member access - set `Bootstrap:AdminGitHubLogins` with at least one GitHub login for the first active admin path - HTTP bridge ledger configuration (`BIFROST_BRIDGE_BASE_URL` / `BIFROST_BRIDGE_TOKEN`) is removed from live CLI transport. Request-lane, governance, dispatch, and bridge receipts converge on Bifrost-owned `bifrost.crossing_receipt.v1` documents in `.bifrost/bridge-receipts.cc`. - external bridge actions must carry typed CultMesh command provenance such as `BIFROST_CULTMESH_COMMAND_ID`; unreceipted and HTTP-ledger hatches fail closed. diff --git a/docs/bridge.md b/docs/bridge.md index b9b35a6..da23705 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -23,7 +23,7 @@ That means Bifrost owns the operational meaning of the crossing: It does not mean Bifrost should own OAuth sludge. Bifrost is both an identity provider and identity consumer: a GameCult actor should be able to register with Bifrost without OAuth, and that Bifrost identity should be enough to authenticate with most GameCult services. Heimdall owns outside-provider OAuth, linked-account token custody, grants, and signed account claims. Bifrost associates those Heimdall-backed accounts with the Bifrost identity, consumes the permission facts, and decides whether a bridge action is allowed in the Bifrost domain. -The native registration surface is `POST /auth/bifrost/register`. It creates a Bifrost-owned `UserAccount` with `BifrostIdentity` and `NormalizedBifrostIdentity`, signs the caller in with the Bifrost cookie, and leaves membership at `Authenticated`. It does not grant active member access, mint outside-provider tokens, or replace Heimdall's account-link and capability custody. +The native registration surface is `POST /auth/bifrost/register`, which takes `identity`, optional `displayName` and `returnUrl`, a solved proof-of-work `challenge` and `nonce` (from `GET /auth/bifrost/challenge`), and `acceptedNoticeVersion` (from `GET /auth/bifrost/notice`). It creates a Bifrost-owned `UserAccount` with `BifrostIdentity` and `NormalizedBifrostIdentity`, signs the caller in with the Bifrost cookie, and leaves membership at `Authenticated`. It does not grant active member access, mint outside-provider tokens, or replace Heimdall's account-link and capability custody. ## Ownership diff --git a/docs/registration-gates.md b/docs/registration-gates.md new file mode 100644 index 0000000..1b9278a --- /dev/null +++ b/docs/registration-gates.md @@ -0,0 +1,86 @@ +# Public registration gates + +Meta ruled yes to public registration on 2026-11-10. The gates she required: self-hosted CAPTCHA, email verification, +a training-notice consent receipt, rate limits and a brake, reserved names, and new accounts in Users with no vote. +This page scopes the three that did not exist, says where each lives, and records what is built. + +## What exists + +| Gate | Where | State | +| --- | --- | --- | +| Rate limits and brake | `OidcGuardMiddleware` (`docs/oidc-issuer.md`) | Merged. Covers `/connect/*` only, not the register endpoint. | +| Reserved names | `BifrostIdentityService.IsReserved`, 28 names | Merged, at creation and at authorize. | +| Chat tier, no vote | `Authenticated` / `PendingApproval` accounts chat and open no member or governance surface | Merged. | +| Suspension | `MembershipSuspensionService` | Merged. | +| CAPTCHA | `Features/Registration/RegistrationChallengeService` | Built here. | +| Consent receipt | `ConsentReceipt`, `TrainingNotice` | Built here. | +| Email verification | nowhere | Scoped below, not built. | + +The hole these gates close: `POST /auth/bifrost/register` is anonymous, and since the chat tier merged, an +`Authenticated` account it creates can sign in to chat. Before this cut it took an identity name and nothing else. + +## CAPTCHA + +Lives in Bifrost, because the register endpoint is Bifrost's. Heimdall authenticates people through providers and has no +registration form. + +Design: a hashcash proof of work. `GET /auth/bifrost/challenge` returns a Data Protection-protected token (random id and +expiry, five minutes) and a difficulty in bits. The client finds a decimal `nonce` such that +`SHA-256(challenge + ":" + nonce)` has at least that many leading zero bits, and posts both with the registration. The +server verifies the protection, the expiry and the hash, and spends the id once. + +- Self-hosted: no third-party script, request or cookie; nothing to track. +- Accessible: there is nothing to see or hear and nothing to solve by hand. A screen-reader user, a keyboard user and a + 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. 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 `