registry: TOTP and recovery codes (slice 5 of #16) #227
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Blocks
Depends on
#228 registry: a second factor before publishing tokens (slice 6 of #16)
torbscript/language
#223 registry: sessions, linked identities and the account page (slice 1 of #16)
torbscript/language
#264 cipher:
std/cipher with ChaCha20-Poly1305 (RFC 8439)
torbscript/language
Reference
torbscript/language#227
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 (
Registrycalls 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)
Pinned records
std/digesthasHmacSha1andSha256,std/encodinghasBase32Alphabet.Standard,std/oshasEntropy.std/cipher(#264), decided in refine (see the comment below).Acceptance criteria
Database.now-style seconds) and a function argument in the pure part, so tests fix it.POST /account/totp/new {csrf},POST /account/totp/add {csrf, code},POST /account/totp/remove {csrf}): a 160-bit seed fromEntropy.bytes 20, shown once asotpauth://totp/...URI and Base32 text (the page's CSP staysdefault-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 a409); 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).std/cipher'sChaCha20Poly1305(#264): a fresh 12-byte nonce fromEntropyper encryption, the account id and the purpose (totp) as associated data, nonce and sealed seed in one column. The key isREGISTRY_SECRET_KEY, 64 hexadecimal digits (32 bytes), read inConfig; unset or malformed, the registry starts, logs that two-factor enrolment is off and refusesPOST /account/totp/*with503; existing rows are never decrypted without it. A changed key makesopenfail, which is an error to the operator and never a code accepted.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.429without checking the code; a success does not reset an exceeded limit inside the window.torb test tools/registry/testsgreen natively and in the VM.tools/registry/README.md(call table, configuration table withREGISTRY_SECRET_KEY, the paragraph on the account page) and RELEASE.md "Not built yet" updated;skills/regenerated if docs underdocs/change.Seed impact
None, re-checked in refine: TorbScript in
tools/registryonstd/digest,std/encoding,std/osand the newstd/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 adocs(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.[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] slice 1 #223 has landed (PR #249). next: waiting (refine when the ready buffer has room) until 2026-10-10T04:00:00Z
[loop:torbscript] claim refine by session:loop-torbscript-20261009 until 2026-10-09T13:04Z
std/cipherwith ChaCha20-Poly1305 (RFC 8439) #264std/cipherwith ChaCha20-Poly1305 (RFC 8439)[loop:torbscript] decided by the loop: the cipher for the stored TOTP seed is ChaCha20-Poly1305 (RFC 8439) as its own
stdslice,std/cipher, filed as #264, and #227 depends on it.stdlater (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).Intarithmetic is both simple and free of data-dependent table access; 32-bit word operations thatstd/digestalready 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).REGISTRY_SECRET_KEY(64 hexadecimal digits), a fresh 12-byte nonce fromEntropyper encryption, account id and purpose as associated data (a copied row does not decrypt for another account), no key means enrolment off with a503and a log line at start-up.std/*is already a workspace member glob). No seed refresh, so the two-commit rule does not apply.[loop:torbscript] refine: decided the cipher (
std/cipher, ChaCha20-Poly1305, new issue #264), tightened the acceptance criteria and added the dependency #227 on #264. Notready. Worker usage: sonnet, roughly 60k tokens. next: waiting (dependency #264)std/cipherwith ChaCha20-Poly1305 (RFC 8439) #264std/cipherwith ChaCha20-Poly1305 (RFC 8439) #264std/cipherwith ChaCha20-Poly1305 (RFC 8439) #264[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] 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] close: PR #272 approved and merged by argus as
173c9c66; main CI green on that commit. Claim released.