12 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. The reason is Membership.SuspensionReason, written with the status change and its receipt, cleared on unsuspend, projected only to admins on the members page; the receipt (AuditEvent.Detail) holds no reason text. Receipts written before the column existed still hold their reasons as history; the member dashboard keeps filtering suspension receipts out. An unsuspend still requires a reason, but nothing stores it. |
| 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/challengeandPOST /auth/bifrost/registershare 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.
The sign-up form and the no-JS fallback
GET /auth/bifrost/register renders the form: identity, optional display name, the notice in full beside an unchecked
consent box (the box is the consent act), and a challenge. With JavaScript the page solves the proof of work in the
background and reports progress in a polite live region. Without JavaScript the form still submits and succeeds, by a
time-lock:
- The challenge carries its issue time. A submission that says it waited is accepted only once
FallbackWaitSeconds(default 30) have passed since issue; earlier, the page comes back saying how many seconds remain and the challenge is not spent (a fresh challenge would restart the clock). The same holds for a refused identity (taken, reserved or malformed): those rules are checked before any permit or challenge is spent, and the still-valid challenge is kept. Every other failure re-renders with a fresh challenge, the consent box unchecked again (tick it again) and the identity kept.FallbackWaitSecondsis validated at 5 to under 300, since a challenge lives 300 seconds;Captcha:Difficultyat 1 to 24. - Fallback submissions have their own, stricter per-address budget (
Limits:FallbackPermits, default 3 perFallbackWindow, default one hour; over it is 429 with Retry-After). - A submission names a nonce or says it waited, never both.
- The page has a second submit button, always enabled, that abandons a solve in progress and submits on the time-lock, so a slow device is never stuck behind the proof of work.
POST /auth/bifrost/register/formrefuses a request that did not come from the site itself (RequestOriginGuard:Originequal to the site origin, elseSec-Fetch-Site: same-origin), 403, before it reads the body or spends anything, so a third-party page cannot register an account and replace a visitor's session. A request with neither header and no session cookie is allowed, because text browsers send neither and are the people the fallback is for. The body is capped at 16 KB (413) and the per-address budget is spent before it is read.- Whether a name is taken is answerable without a proof, bounded by the per-address budget. Names are public (they are chat localparts), so that leaks nothing.
Tradeoff, stated plainly. The time-lock is accessible to everyone who can use a browser: nothing to see, hear, read or
solve, no script, no third party. It is also weaker than the proof of work. It costs an attacker wall-clock time, not CPU,
and an attacker can start many challenges at once and wait once, so what bounds the fallback is the per-address budget
and nothing else. An attacker with many addresses passes it at the price of the wait. We accept that because a
registration buys little: the account is Authenticated, in Users, with no vote and no member surface, and the other
gates (consent, verification once built, reserved names, suspension) still apply. If fallback registrations are abused,
raise the wait or lower the budget; do not add a visual or audio puzzle, which excludes the people the fallback is for.
Consent receipt
Lives in Bifrost: the account, the receipt and the registration are one transaction, and chat admission is Bifrost's
decision (admission-is-bifrost-membership).
- Consent is optional at registration. Registration succeeds with or without it; a receipt exists only if the person
agreed. It is not optional for chat: the OIDC chat sign-in needs a live receipt for the version in force
(
oidc-issuer.md, Who may sign in). Seetraining-notice.mdfor why and for what the notice says. - Notice:
TrainingNotice.Version(positive integer, no leading zeros, per the version rule in Heimdall'sanswer-collector-consent.md) andTrainingNotice.Text, served atGET /auth/bifrost/notice. Version 1 is in force, and its text is frozen (a test pins its hash). Changing the text is a version bump, never an edit in place. The text states that nothing from Discord is used. - Receipt:
ConsentReceipt(account, notice version,AcceptedAtUtc,WithdrawnAtUtc). One live (not withdrawn) receipt per account and version; withdrawn rows stay as history. At registration anacceptedNoticeVersionequal to the version in force writes the receipt in the same save as the account; a different version is refused (400) and creates nothing; absent means no receipt. - Consent page:
GET /auth/bifrost/consent, for any signed-in account (native, provider, existing). It shows the notice, the account's state (no agreement, agreed to the current version, agreed to an older one, withdrawn) and the buttons that apply (with areturnUrlquery a refused chat sign-in sends, a local path only, Agree returns the member there): Withdraw whenever any receipt is live, Agree whenever none is live for the version in force (so an account on an older version sees both).POST /auth/bifrost/consent/acceptandPOST /auth/bifrost/consent/withdraware form posts refused with 403 unless they came from the site itself (the strictRequestOriginGuard), before anything is read or written. Agree is idempotent; withdraw stampsWithdrawnAtUtconce and deletes nothing; agreeing again after a withdrawal writes a new live receipt. Withdrawal stops future use and cannot recall past use; the notice says so. - A version bump: receipts name the version agreed to, so after a bump no receipt matches and everyone is out of the set until they agree to the new version on the consent page, which tells them their agreement is to an older version. No one is prompted anywhere else; silence means no.
- Consumer: a training export must include an account's data only while a live receipt for the notice version in force exists. No export exists in Bifrost; this page is the contract it will be built against, and the notice is not operative for real people until the export enforces it with a test that a withdrawn or never-agreed account's rows are absent.
Limits: accounts without a receipt (provider-created, existing, registered without agreeing) are excluded, which is the rule, and they opt in on the consent page. A native account that loses its session cannot reach the page (no credential until sign-in recovery exists), so it cannot withdraw.
Email verification
Not built. Decided (steward, 2026-11-11): Bifrost keeps a verified address, as sign-in recovery.
Why: a native account has no credential and no sign-in route (concept-mill-votes-target.md, claim 12). A person who
loses the session cookie loses the account, and the chat localpart minted from it is permanent. An address that was
verified is the one way back, and the usual way (MAS, Matrix homeservers and most accounts hold one). Hashing it instead
cannot send mail, so it recovers nothing.
Privacy handling, which the build must honour:
- Stored only for native accounts, one verified address per account, unique across accounts (a keyed hash of the normalized address is the lookup; the address itself is encrypted with Data Protection, since sending needs it).
- Never in a token or claim (the issuer doc's claim table is unchanged), never in a log line, a dashboard, a Verse document, a training export, or Heimdall. The account page shows it only to its owner.
- Used for two things: the verification mail and a single-use sign-in link (15 minutes, spent on use, per-address and per-account send budgets). No notices, no newsletters, no sharing.
- The owner can remove it (then there is no recovery) or change it (a new verification first). Deleting the account deletes it.
- Existing accounts and provider accounts are not asked.
The build cannot start until ops provides a transport: there is no mail sender anywhere in Bifrost, Heimdall or the chat
stack. Bifrost's side is an IEmailSender port with the relay's settings as secret files; the relay, its sending domain
and SPF/DKIM are ops infrastructure.
Shape: 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, the receipt and the stored address together. An unverified account then cannot exist, so it cannot chat.
Failure it prevents: accounts for addresses nobody holds, and unrecoverable accounts.