registry: TOTP and recovery codes (slice 5 of #16) #227

Closed
opened 2026-10-08 21:26:56 +00:00 by orion · 8 comments
Collaborator

Goal

TOTP as a second factor for registry accounts, with single-use recovery codes, enrolled and managed on the account page. This slice builds the factor and the checks (Registry calls that answer whether a code or a recovery code is valid for an account); requiring it before publishing tokens is #228.

Owner decision (recorded on #16)

  • A provider sign-in needs TOTP or a passkey on top to count as two factors.
  • TOTP seeds are stored encrypted with a key from the environment; 10 single-use recovery codes.

Pinned records

  • RELEASE.md 7.6 Security
  • RFC 6238 (TOTP), RFC 4226 (HOTP); std/digest has HmacSha1 and Sha256, std/encoding has Base32Alphabet.Standard, std/os has Entropy.
  • The cipher is std/cipher (#264), decided in refine (see the comment below).

Acceptance criteria

  • TOTP per RFC 6238 with HMAC-SHA-1, 6 digits, 30-second steps, one step of drift either way; a code is accepted once (the last used step is stored, a replay in the same or an earlier step is refused). The clock is the database's (Database.now-style seconds) and a function argument in the pure part, so tests fix it.
  • Enrolment on the account page (POST /account/totp/new {csrf}, POST /account/totp/add {csrf, code}, POST /account/totp/remove {csrf}): a 160-bit seed from Entropy.bytes 20, shown once as otpauth://totp/... URI and Base32 text (the page's CSP stays default-src 'none': text only, no QR image, no script); a pending seed is kept 10 minutes and enrolment completes only after a valid code; one factor per account (a second enrolment is a 409); removing it needs a valid code or recovery code and, like removing a passkey, never the account's last way to sign in; the same session rules as the passkey forms (recent session for enrolment).
  • The seed is stored encrypted with std/cipher's ChaCha20Poly1305 (#264): a fresh 12-byte nonce from Entropy per encryption, the account id and the purpose (totp) as associated data, nonce and sealed seed in one column. The key is REGISTRY_SECRET_KEY, 64 hexadecimal digits (32 bytes), read in Config; unset or malformed, the registry starts, logs that two-factor enrolment is off and refuses POST /account/totp/* with 503; existing rows are never decrypted without it. A changed key makes open fail, which is an error to the operator and never a code accepted.
  • 10 recovery codes of at least 50 bits each (10 characters of Crockford Base32 from Entropy, shown once as groups), stored as their SHA-256, each usable once (consumed in the same transaction that checks it); regenerating (POST /account/totp/recovery {csrf}, needs a valid code) replaces all of them; a code is compared in constant time.
  • Rate limit on code attempts per account: at most 5 failed TOTP or recovery-code attempts per account in 15 minutes, counted in a database table; further attempts are refused with 429 without checking the code; a success does not reset an exceeded limit inside the window.
  • Tests with the RFC 6238 appendix B vectors (the SHA-1 rows) and a fixed clock; every call once as it works and once as it is refused (wrong code, replayed code, drift beyond one step, used recovery code, rate limit hit, missing key, second enrolment, enrolment without a valid code); torb test tools/registry/tests green natively and in the VM.
  • tools/registry/README.md (call table, configuration table with REGISTRY_SECRET_KEY, the paragraph on the account page) and RELEASE.md "Not built yet" updated; skills/ regenerated if docs under docs/ change.

Seed impact

None, re-checked in refine: TorbScript in tools/registry on std/digest, std/encoding, std/os and the new std/cipher (#264); no syntax change, no native, no driver flag, no std name looked up by the compiler. No seed refresh, so no two-commit rule.

Size and dependencies

One PR of one or two Conventional Commits (feat(registry): ..., and a docs(registry): ... commit if separate). Depends on #223 (landed, PR #249), #226 (landed) and on #264 (std/cipher, which must land first). Parent: #16; #228 uses this slice. Security-relevant: architect review of the diff.

## Goal TOTP as a second factor for registry accounts, with single-use recovery codes, enrolled and managed on the account page. This slice builds the factor and the checks (`Registry` calls that answer whether a code or a recovery code is valid for an account); requiring it before publishing tokens is #228. ## Owner decision (recorded on #16) - A provider sign-in needs TOTP or a passkey on top to count as two factors. - TOTP seeds are stored encrypted with a key from the environment; 10 single-use recovery codes. ## Pinned records - [RELEASE.md 7.6 Security](https://git.torb.dev/torbscript/language/src/commit/45be87a8bdba3d0a00212c7a82fa8e451bef398c/docs/design/RELEASE.md#L1029) - RFC 6238 (TOTP), RFC 4226 (HOTP); `std/digest` has `HmacSha1` and `Sha256`, `std/encoding` has `Base32Alphabet.Standard`, `std/os` has `Entropy`. - The cipher is `std/cipher` (#264), decided in refine (see the comment below). ## Acceptance criteria - [ ] TOTP per RFC 6238 with HMAC-SHA-1, 6 digits, 30-second steps, one step of drift either way; a code is accepted once (the last used step is stored, a replay in the same or an earlier step is refused). The clock is the database's (`Database.now`-style seconds) and a function argument in the pure part, so tests fix it. - [ ] Enrolment on the account page (`POST /account/totp/new {csrf}`, `POST /account/totp/add {csrf, code}`, `POST /account/totp/remove {csrf}`): a 160-bit seed from `Entropy.bytes 20`, shown once as `otpauth://totp/...` URI and Base32 text (the page's CSP stays `default-src 'none'`: text only, no QR image, no script); a pending seed is kept 10 minutes and enrolment completes only after a valid code; one factor per account (a second enrolment is a `409`); removing it needs a valid code or recovery code and, like removing a passkey, never the account's last way to sign in; the same session rules as the passkey forms (recent session for enrolment). - [ ] The seed is stored encrypted with `std/cipher`'s `ChaCha20Poly1305` (#264): a fresh 12-byte nonce from `Entropy` per encryption, the account id and the purpose (`totp`) as associated data, nonce and sealed seed in one column. The key is `REGISTRY_SECRET_KEY`, 64 hexadecimal digits (32 bytes), read in `Config`; unset or malformed, the registry starts, logs that two-factor enrolment is off and refuses `POST /account/totp/*` with `503`; existing rows are never decrypted without it. A changed key makes `open` fail, which is an error to the operator and never a code accepted. - [ ] 10 recovery codes of at least 50 bits each (10 characters of Crockford Base32 from `Entropy`, shown once as groups), stored as their SHA-256, each usable once (consumed in the same transaction that checks it); regenerating (`POST /account/totp/recovery {csrf}`, needs a valid code) replaces all of them; a code is compared in constant time. - [ ] Rate limit on code attempts per account: at most 5 failed TOTP or recovery-code attempts per account in 15 minutes, counted in a database table; further attempts are refused with `429` without checking the code; a success does not reset an exceeded limit inside the window. - [ ] Tests with the RFC 6238 appendix B vectors (the SHA-1 rows) and a fixed clock; every call once as it works and once as it is refused (wrong code, replayed code, drift beyond one step, used recovery code, rate limit hit, missing key, second enrolment, enrolment without a valid code); `torb test tools/registry/tests` green natively and in the VM. - [ ] `tools/registry/README.md` (call table, configuration table with `REGISTRY_SECRET_KEY`, the paragraph on the account page) and RELEASE.md "Not built yet" updated; `skills/` regenerated if docs under `docs/` change. ## Seed impact None, re-checked in refine: TorbScript in `tools/registry` on `std/digest`, `std/encoding`, `std/os` and the new `std/cipher` (#264); no syntax change, no native, no driver flag, no std name looked up by the compiler. No seed refresh, so no two-commit rule. ## Size and dependencies One PR of one or two Conventional Commits (`feat(registry): ...`, and a `docs(registry): ...` commit if separate). Depends on #223 (landed, PR #249), #226 (landed) and on #264 (`std/cipher`, which must land first). Parent: #16; #228 uses this slice. Security-relevant: architect review of the diff.
Author
Collaborator

[loop:torbscript] intake: in scope (author orion, label loop), slice 5 of #16 filed by the split, not a duplicate, no collision with a design record, no seed impact. next: waiting (slice 1 #223 has not landed; PR #249 is open) until 2026-10-10T04:00:00Z

[loop:torbscript] intake: in scope (author orion, label loop), slice 5 of #16 filed by the split, not a duplicate, no collision with a design record, no seed impact. next: waiting (slice 1 #223 has not landed; PR #249 is open) until 2026-10-10T04:00:00Z
Author
Collaborator

[loop:torbscript] slice 1 #223 has landed (PR #249). next: waiting (refine when the ready buffer has room) until 2026-10-10T04:00:00Z

[loop:torbscript] slice 1 #223 has landed (PR #249). next: waiting (refine when the ready buffer has room) until 2026-10-10T04:00:00Z
Author
Collaborator

[loop:torbscript] claim refine by session:loop-torbscript-20261009 until 2026-10-09T13:04Z

[loop:torbscript] claim refine by session:loop-torbscript-20261009 until 2026-10-09T13:04Z
Author
Collaborator

[loop:torbscript] decided by the loop: the cipher for the stored TOTP seed is ChaCha20-Poly1305 (RFC 8439) as its own std slice, std/cipher, filed as #264, and #227 depends on it.

  • Why not private to the registry: a cipher has published vectors (RFC 8439 sections 2.1 to 2.8, appendix A) and is tested on its own; privately written primitives have had to be moved into std later (rsa.trb, FIRST-PARTY.md section 8 row 6). It also takes about 300 lines of primitive and its vectors out of this PR, which is already TOTP, recovery codes, rate limit, account page, schema and tests (the passkey slice was of that size).
  • Why ChaCha20-Poly1305 and not AES-GCM: no table lookups or carry-less multiplication, so plain Int arithmetic is both simple and free of data-dependent table access; 32-bit word operations that std/digest already uses. The alternatives were an HMAC-SHA-256 stream plus tag (a homemade construction, no vectors) and a native over mbedTLS (a runtime/seed change for a 20-byte secret).
  • Sizing: a seed is 20 bytes, so speed is irrelevant here; #264 still records the measured speed (32 bytes and 1 MiB) in its docs.
  • Registry design recorded in the new body of #227: key REGISTRY_SECRET_KEY (64 hexadecimal digits), a fresh 12-byte nonce from Entropy per encryption, account id and purpose as associated data (a copied row does not decrypt for another account), no key means enrolment off with a 503 and a log line at start-up.
  • Seed impact: none for both issues (no syntax change, no native, no driver flag, no std name looked up by the compiler; std/* is already a workspace member glob). No seed refresh, so the two-commit rule does not apply.
  • Size: with the cipher out, #227 is one PR of one or two Conventional Commits; criteria tightened (calls and paths, rate limit 5 failures per 15 minutes per account, recovery codes 10 characters of Base32 = 50 bits, text-only enrolment page under the existing CSP).
[loop:torbscript] decided by the loop: the cipher for the stored TOTP seed is ChaCha20-Poly1305 (RFC 8439) as its own `std` slice, `std/cipher`, filed as https://git.torb.dev/torbscript/language/issues/264, and #227 depends on it. - Why not private to the registry: a cipher has published vectors (RFC 8439 sections 2.1 to 2.8, appendix A) and is tested on its own; privately written primitives have had to be moved into `std` later (`rsa.trb`, FIRST-PARTY.md section 8 row 6). It also takes about 300 lines of primitive and its vectors out of this PR, which is already TOTP, recovery codes, rate limit, account page, schema and tests (the passkey slice was of that size). - Why ChaCha20-Poly1305 and not AES-GCM: no table lookups or carry-less multiplication, so plain `Int` arithmetic is both simple and free of data-dependent table access; 32-bit word operations that `std/digest` already uses. The alternatives were an HMAC-SHA-256 stream plus tag (a homemade construction, no vectors) and a native over mbedTLS (a runtime/seed change for a 20-byte secret). - Sizing: a seed is 20 bytes, so speed is irrelevant here; #264 still records the measured speed (32 bytes and 1 MiB) in its docs. - Registry design recorded in the new body of #227: key `REGISTRY_SECRET_KEY` (64 hexadecimal digits), a fresh 12-byte nonce from `Entropy` per encryption, account id and purpose as associated data (a copied row does not decrypt for another account), no key means enrolment off with a `503` and a log line at start-up. - Seed impact: none for both issues (no syntax change, no native, no driver flag, no std name looked up by the compiler; `std/*` is already a workspace member glob). No seed refresh, so the two-commit rule does not apply. - Size: with the cipher out, #227 is one PR of one or two Conventional Commits; criteria tightened (calls and paths, rate limit 5 failures per 15 minutes per account, recovery codes 10 characters of Base32 = 50 bits, text-only enrolment page under the existing CSP).
Author
Collaborator

[loop:torbscript] refine: decided the cipher (std/cipher, ChaCha20-Poly1305, new issue #264), tightened the acceptance criteria and added the dependency #227 on #264. Not ready. Worker usage: sonnet, roughly 60k tokens. next: waiting (dependency #264)

[loop:torbscript] refine: decided the cipher (`std/cipher`, ChaCha20-Poly1305, new issue https://git.torb.dev/torbscript/language/issues/264), tightened the acceptance criteria and added the dependency #227 on #264. Not `ready`. Worker usage: sonnet, roughly 60k tokens. next: waiting (dependency #264)
Author
Collaborator

[loop:torbscript] decided by the loop: dependency #264 landed (PR #266, main green on 0a129c2e), criteria complete, so set ready. claim implement by session:loop-torbscript-20261009 until 2026-10-09T21:04Z

[loop:torbscript] decided by the loop: dependency #264 landed (PR #266, main green on 0a129c2e), criteria complete, so set ready. claim implement by session:loop-torbscript-20261009 until 2026-10-09T21:04Z
Author
Collaborator

[loop:torbscript] implement: PR #272 opened, CI green, registry TOTP (RFC 6238, sealed seed, replay protection) with keyed single-use recovery codes and a per-account rate limit. next: review (waiting on the merge gate argus). Claim released.

[loop:torbscript] implement: PR https://git.torb.dev/torbscript/language/pulls/272 opened, CI green, registry TOTP (RFC 6238, sealed seed, replay protection) with keyed single-use recovery codes and a per-account rate limit. next: review (waiting on the merge gate argus). Claim released.
argus closed this issue 2026-10-09 15:51:27 +00:00
Author
Collaborator

[loop:torbscript] close: PR #272 approved and merged by argus as 173c9c66; main CI green on that commit. Claim released.

[loop:torbscript] close: PR https://git.torb.dev/torbscript/language/pulls/272 approved and merged by argus as 173c9c66; main CI green on that commit. Claim released.
Sign in to join this conversation.
No labels
loop
ready
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
torbscript/language#227
No description provided.