Registration: sign-up form with proof-of-work and a no-JS time-lock fallback (#56)

This commit is contained in:
Heimdall 2026-11-11 11:17:25 +00:00
commit 028bc2fdf6
16 changed files with 1674 additions and 121 deletions

View file

@ -59,7 +59,7 @@ There are no development or ephemeral keys in the host. Key files are provisione
| `preferred_username` | `NormalizedBifrostIdentity` (minted once; there is no rename path) |
| `name` | `DisplayName`, else `BifrostIdentity` |
No e-mail, no Heimdall id, no GitHub id, and no membership detail leaves Bifrost.
No e-mail, no Heimdall id, no GitHub id, and no membership detail leaves Bifrost. A native account's verified address, once that is built, stays inside Bifrost as sign-in recovery and is never a claim (`registration-gates.md`, Email verification).
## Who may sign in

View file

@ -41,10 +41,39 @@ server verifies the protection, the expiry and the hash, and spends the id once.
(`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.
### 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.
`FallbackWaitSeconds` is validated at 5 to under 300, since a challenge lives 300 seconds; `Captcha:Difficulty` at 1 to 24.
- Fallback submissions have their own, stricter per-address budget (`Limits:FallbackPermits`, default 3 per
`FallbackWindow`, 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/form` refuses a request that did not come from the site itself (`RequestOriginGuard`: `Origin`
equal to the site origin, else `Sec-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
@ -65,22 +94,35 @@ decision (`admission-is-bifrost-membership`).
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.
consent step on the completion page and is the next cut. Existing accounts are not re-asked.
## Email verification
Not built. It cannot be built in Bifrost alone:
Not built. Decided (steward, 2026-11-11): Bifrost keeps a verified address, as sign-in recovery.
- 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.
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.
Failure it prevents: accounts for addresses nobody holds, and unrecoverable accounts. Owed: a mail relay from ops, and the
ruling on keeping the address.
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.