Bifrost/docs/registration-gates.md

6.4 KiB

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 <noscript> message) is the cut after this one. Without JavaScript a person cannot solve the challenge, so the accessible fallback for them is owed with the form: a documented solver is not one. Do not call the CAPTCHA gate complete for people until that fallback exists.

Lives in Bifrost: the account, the receipt and the registration are one transaction, and chat admission is Bifrost's decision (admission-is-bifrost-membership).

  • Notice: TrainingNotice.Version (positive integer, no leading zeros, per the version rule in Heimdall's answer-collector-consent.md) and TrainingNotice.Text. Served at GET /auth/bifrost/notice. The text in the repo is draft wording. Eureka owns the final wording in gamecult-ops; changing the text is a version bump, never an edit in place. The text states that no Discord material is used.
  • Receipt: ConsentReceipt (account, notice version, AcceptedAtUtc, WithdrawnAtUtc), unique per account and version, written in the same save as the account. Registration without acceptedNoticeVersion equal to the current version creates nothing. A new notice version needs a new acceptance; there is no path yet that asks for one.
  • Withdrawal: POST /auth/bifrost/consent/withdraw, signed in, sets WithdrawnAtUtc once on the account's receipts. Withdrawal stops future use and cannot recall past use; the notice says so. The row is one-way and never deleted.
  • Consumer: a training export must include an account's data only while a receipt for the notice version in force exists with WithdrawnAtUtc null. No export exists in Bifrost; this page is the contract it will be built against.

Scope limits: accounts that arrive through Heimdall providers (Discord, Patreon, Twitch, GitHub) are created in HeimdallAuthService.UpsertUserAccountAsync and get no receipt. They reach chat the same way. Closing that needs a consent step on the completion page and is the next cut after the form. Existing accounts are not re-asked.

Email verification

Not built. It cannot be built in Bifrost alone:

  • There is no mail transport anywhere in Bifrost, Heimdall or the chat stack. A sender (SMTP relay or provider, SPF/DKIM for the sending domain) is ops infrastructure, and Bifrost's side is an IEmailSender port with that relay's settings as secret files.
  • A native account has no credential and no sign-in route (concept-mill-votes-target.md, claim 12). Verifying an address proves nothing unless the address is also how the person returns. That makes email the credential: a single-use sign-in link, and the choice of whether Bifrost keeps the address (needed to send it) or only a hash (cannot send). The issuer doc promises no email leaves Bifrost; keeping one inside it is a privacy decision for the operator.
  • Shape once ruled: registration becomes two steps. Step one takes identity, address, consent and the CAPTCHA, and mails a single-use token; no account exists yet. Step two redeems the token and creates the account and receipt together. An unverified account then cannot exist, so it cannot chat.

Failure it prevents: accounts for addresses nobody holds, and unrecoverable accounts. Owed: a mail relay from ops, and the ruling on keeping the address.