feat(registry): TOTP second factor and recovery codes (#227) #272

Merged
argus merged 1 commit from loop/227-registry-totp into main 2026-10-09 15:51:27 +00:00
Collaborator

Closes #227. One commit; slice 5 of #16. Written without a local torb, so CI is the first run.

Per criterion

  • TOTP (RFC 6238/4226): src/totp.trb, HMAC-SHA-1, 6 digits, 30 s steps, ±1 step. All window steps are compared in constant time. The last used step is stored and claimed by UPDATE ... WHERE last_step < step with changes(), so a replay is refused. The clock is the database's, and a function argument in the pure part.
  • Enrolment: POST /account/totp/new|add|remove|recovery. A 160-bit seed is shown once as an otpauth:// URI and as Base32, with no script, no image and the CSP unchanged. A pending seed is kept 10 minutes. Enrolment needs a recent session and completes only on a valid code. A second enrolment is a 409. Removal needs a code or a recovery code and ends the other sessions.
  • Sealed seed: ChaCha20-Poly1305, a fresh 12-byte nonce, associated data totp:<account>, nonce and ciphertext in one column. REGISTRY_SECRET_KEY is read in Config. Unset or malformed, the registry logs that enrolment is off and the routes answer 503. A changed key makes open fail, never accepts a code.
  • Recovery codes: 10 codes of 10 Crockford Base32 characters (50 bits), shown once in groups, each consumed by a DELETE whose changes() must be 1, all compared in constant time. recovery regenerates them after a valid code. Deviation: they are stored as HMAC-SHA-256 under a key derived from REGISTRY_SECRET_KEY, not as plain SHA-256. 50 bits of plain SHA-256 can be brute-forced from a database copy (found in architect review).
  • Rate limit: 5 wrong codes per account in 15 min, in code_attempts. A try is reserved in one atomic statement before the code is checked, so concurrent attempts cannot slip under the limit. A right code gives back only its own try, so a success does not reset an exceeded limit. Past the limit the answer is 429 with Retry-After, without checking the code.
  • Tests: tests/totp.test.trb has the RFC 4226 appendix D and RFC 6238 appendix B SHA-1 vectors, the window, sealing and recovery codes. tests/totp-account.test.trb runs the HTTP flow, including wrong, replayed and used-recovery codes, the limit, no key, a second enrolment and a stale session, and checks that no code or seed is stored in the clear or in events.
  • Docs: README (call table, REGISTRY_SECRET_KEY, design bullet, tests), RELEASE.md "Not built yet", pdisc restamped.

Notes

  • TOTP is not a way to sign in, so waysToSignIn and the "last way to sign in" rule are unchanged. The second sign-in step is #228. Registry.acceptedCode(account, code) is what #228 will call.
  • The event log records add-totp, remove-totp, new-recovery-codes and use-recovery-code with an empty detail.
  • Architect review done. Its findings (test fixes, keyed recovery digests, non-fatal try release) are fixed here. Left for #228: escalating backoff, and a key version prefix for rotation.
Closes #227. One commit; slice 5 of #16. Written without a local `torb`, so CI is the first run. ## Per criterion - **TOTP (RFC 6238/4226)**: `src/totp.trb`, HMAC-SHA-1, 6 digits, 30 s steps, ±1 step. All window steps are compared in constant time. The last used step is stored and claimed by `UPDATE ... WHERE last_step < step` with `changes()`, so a replay is refused. The clock is the database's, and a function argument in the pure part. - **Enrolment**: `POST /account/totp/new|add|remove|recovery`. A 160-bit seed is shown once as an `otpauth://` URI and as Base32, with no script, no image and the CSP unchanged. A pending seed is kept 10 minutes. Enrolment needs a recent session and completes only on a valid code. A second enrolment is a 409. Removal needs a code or a recovery code and ends the other sessions. - **Sealed seed**: ChaCha20-Poly1305, a fresh 12-byte nonce, associated data `totp:<account>`, nonce and ciphertext in one column. `REGISTRY_SECRET_KEY` is read in `Config`. Unset or malformed, the registry logs that enrolment is off and the routes answer 503. A changed key makes `open` fail, never accepts a code. - **Recovery codes**: 10 codes of 10 Crockford Base32 characters (50 bits), shown once in groups, each consumed by a `DELETE` whose `changes()` must be 1, all compared in constant time. `recovery` regenerates them after a valid code. **Deviation**: they are stored as HMAC-SHA-256 under a key derived from `REGISTRY_SECRET_KEY`, not as plain SHA-256. 50 bits of plain SHA-256 can be brute-forced from a database copy (found in architect review). - **Rate limit**: 5 wrong codes per account in 15 min, in `code_attempts`. A try is reserved in one atomic statement before the code is checked, so concurrent attempts cannot slip under the limit. A right code gives back only its own try, so a success does not reset an exceeded limit. Past the limit the answer is 429 with `Retry-After`, without checking the code. - **Tests**: `tests/totp.test.trb` has the RFC 4226 appendix D and RFC 6238 appendix B SHA-1 vectors, the window, sealing and recovery codes. `tests/totp-account.test.trb` runs the HTTP flow, including wrong, replayed and used-recovery codes, the limit, no key, a second enrolment and a stale session, and checks that no code or seed is stored in the clear or in events. - **Docs**: README (call table, `REGISTRY_SECRET_KEY`, design bullet, tests), RELEASE.md "Not built yet", pdisc restamped. ## Notes - TOTP is not a way to sign in, so `waysToSignIn` and the "last way to sign in" rule are unchanged. The second sign-in step is #228. `Registry.acceptedCode(account, code)` is what #228 will call. - The event log records `add-totp`, `remove-totp`, `new-recovery-codes` and `use-recovery-code` with an empty detail. - Architect review done. Its findings (test fixes, keyed recovery digests, non-fatal try release) are fixed here. Left for #228: escalating backoff, and a key version prefix for rotation.
feat(registry): TOTP second factor and recovery codes (#227)
Some checks failed
ci / commit messages (pull_request) Successful in 6s
ci / what the change touches (pull_request) Successful in 6s
ci / which runners exist (pull_request) Successful in 0s
ci / bootstrap and conformance (windows-x64) (pull_request) Has been skipped
ci / bootstrap and conformance (linux-arm64) (pull_request) Has been skipped
ci / bootstrap and conformance (macos-arm64) (pull_request) Has been skipped
pdisc / pdisc (pull_request) Successful in 12s
ci / bootstrap (linux-x64) (pull_request) Successful in 13s
ci / cross-compiled on linux-x64 (windows-x64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (linux-arm64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (macos-arm64) (pull_request) Has been skipped
ci / the VS Code extension (editors/vscode) (pull_request) Successful in 14s
ci / tier A (linux-x64) (pull_request) Failing after 4m7s
ci / tools/release-sync and tools/registry (linux-x64) (pull_request) Failing after 27s
ci / every host emits the same C (pull_request) Successful in 4m30s
ci / tier B (linux-x64) (pull_request) Successful in 9m37s
ci / pack the seed (pull_request) Has been skipped
ci / archives (pull_request) Has been skipped
ci / the playground (torb as WebAssembly) (pull_request) Successful in 1m56s
ci / gates (pull_request) Failing after 0s
36da6c7d9a
orion requested review from argus 2026-10-09 15:21:50 +00:00
orion force-pushed loop/227-registry-totp from 36da6c7d9a
Some checks failed
ci / commit messages (pull_request) Successful in 6s
ci / what the change touches (pull_request) Successful in 6s
ci / which runners exist (pull_request) Successful in 0s
ci / bootstrap and conformance (windows-x64) (pull_request) Has been skipped
ci / bootstrap and conformance (linux-arm64) (pull_request) Has been skipped
ci / bootstrap and conformance (macos-arm64) (pull_request) Has been skipped
pdisc / pdisc (pull_request) Successful in 12s
ci / bootstrap (linux-x64) (pull_request) Successful in 13s
ci / cross-compiled on linux-x64 (windows-x64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (linux-arm64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (macos-arm64) (pull_request) Has been skipped
ci / the VS Code extension (editors/vscode) (pull_request) Successful in 14s
ci / tier A (linux-x64) (pull_request) Failing after 4m7s
ci / tools/release-sync and tools/registry (linux-x64) (pull_request) Failing after 27s
ci / every host emits the same C (pull_request) Successful in 4m30s
ci / tier B (linux-x64) (pull_request) Successful in 9m37s
ci / pack the seed (pull_request) Has been skipped
ci / archives (pull_request) Has been skipped
ci / the playground (torb as WebAssembly) (pull_request) Successful in 1m56s
ci / gates (pull_request) Failing after 0s
to 173c9c66e6
All checks were successful
pdisc / pdisc (push) Successful in 10s
ci / the VS Code extension (editors/vscode) (push) Successful in 13s
ci / tier A (linux-x64) (push) Successful in 4m0s
ci / tools/release-sync and tools/registry (linux-x64) (push) Successful in 1m10s
images / cr.torb.dev/torbscript/site:edge (main) (push) Successful in 9m39s
ci / tier B (linux-x64) (push) Successful in 9m30s
ci / pack the seed (push) Has been skipped
ci / every host emits the same C (push) Successful in 4m25s
ci / the playground (torb as WebAssembly) (push) Successful in 1m57s
ci / gates (push) Successful in 0s
ci / archives (push) Has been skipped
ci / tier B (linux-x64) (pull_request) Successful in 9m30s
ci / pack the seed (pull_request) Has been skipped
ci / every host emits the same C (pull_request) Successful in 4m27s
ci / archives (pull_request) Has been skipped
ci / the playground (torb as WebAssembly) (pull_request) Successful in 1m55s
ci / gates (pull_request) Successful in 0s
ci / what the change touches (push) Successful in 6s
ci / bootstrap (linux-x64) (push) Successful in 13s
ci / cross-compiled on linux-x64 (windows-x64) (push) Has been skipped
ci / cross-compiled on linux-x64 (linux-arm64) (push) Has been skipped
ci / cross-compiled on linux-x64 (macos-arm64) (push) Has been skipped
images / cr.torb.dev/torbscript/registry (push) Has been skipped
images / cr.torb.dev/torbscript/release-sync (push) Has been skipped
images / cr.torb.dev/torbscript/site (push) Has been skipped
ci / commit messages (push) Successful in 6s
ci / which runners exist (push) Successful in 1s
ci / bootstrap and conformance (macos-arm64) (push) Has been skipped
ci / bootstrap and conformance (windows-x64) (push) Has been skipped
ci / bootstrap and conformance (linux-arm64) (push) Has been skipped
2026-10-09 15:35:29 +00:00
Compare
argus approved these changes 2026-10-09 15:51:26 +00:00
argus left a comment

Approved at 173c9c66 (CI success on this head, pdisc green, no tests skipped that matter).

Checklist

  • Tests: ok. RFC 4226 appendix D and RFC 6238 appendix B SHA-1 vectors, window/drift, sealing (wrong key, wrong account, tampered/short row), recovery codes, and an HTTP flow covering wrong, replayed, used-recovery, limit, no key, second enrolment, stale session, plus checks that no seed or code is stored in the clear or in events.
  • Correctness: ok. Replay is claimed by UPDATE ... WHERE last_step < step plus changes(); recovery codes by DELETE with changes() = 1; the attempt slot is reserved in one INSERT ... SELECT ... WHERE count < limit before the code is looked at, so concurrent tries cannot pass the limit; a right code returns only its own try. The window loop in matchedStep compares every step in constant time. Recovery character draw is unbiased (byte & 31).
  • Scope = #227: ok. All acceptance criteria are covered. Two deliberate deviations are stated and both are stricter: removal needs a code, and recovery codes are keyed HMAC-SHA-256 instead of plain SHA-256 (50 bits would be searchable from a database copy).
  • Security/secrets: ok. Seed sealed with ChaCha20-Poly1305, fresh nonce, totp:<account> as AAD; unset/malformed key means 503 and no unsealed storage; secrets are shown only on no-store pages with the CSP unchanged (no script/image); events carry empty detail; the recovery key is derived, not the sealing key itself.
  • Data: ok. Additive CREATE TABLE IF NOT EXISTS only.
  • Docs: ok. README (call table, REGISTRY_SECRET_KEY, design bullet, tests), RELEASE.md, sidecar hash restamped.
  • Architecture/placement: ok. No new boundary; one new env variable, documented.

Non-blocking

  1. removeTotp and regenerateRecoveryCodes consume the proof code (TOTP step or recovery code) before the follow-up transaction; if that transaction fails, the code is spent. Rare, and the user can retry with the next code.
  2. As noted in the PR body, escalating backoff and a key-version prefix for rotation are left for #228; worth an issue if none exists yet.

Merging by rebase.

Approved at 173c9c66 (CI success on this head, pdisc green, no tests skipped that matter). Checklist - Tests: ok. RFC 4226 appendix D and RFC 6238 appendix B SHA-1 vectors, window/drift, sealing (wrong key, wrong account, tampered/short row), recovery codes, and an HTTP flow covering wrong, replayed, used-recovery, limit, no key, second enrolment, stale session, plus checks that no seed or code is stored in the clear or in events. - Correctness: ok. Replay is claimed by `UPDATE ... WHERE last_step < step` plus `changes()`; recovery codes by `DELETE` with `changes()` = 1; the attempt slot is reserved in one `INSERT ... SELECT ... WHERE count < limit` before the code is looked at, so concurrent tries cannot pass the limit; a right code returns only its own try. The window loop in `matchedStep` compares every step in constant time. Recovery character draw is unbiased (byte & 31). - Scope = #227: ok. All acceptance criteria are covered. Two deliberate deviations are stated and both are stricter: removal needs a code, and recovery codes are keyed HMAC-SHA-256 instead of plain SHA-256 (50 bits would be searchable from a database copy). - Security/secrets: ok. Seed sealed with ChaCha20-Poly1305, fresh nonce, `totp:<account>` as AAD; unset/malformed key means 503 and no unsealed storage; secrets are shown only on `no-store` pages with the CSP unchanged (no script/image); events carry empty detail; the recovery key is derived, not the sealing key itself. - Data: ok. Additive `CREATE TABLE IF NOT EXISTS` only. - Docs: ok. README (call table, `REGISTRY_SECRET_KEY`, design bullet, tests), RELEASE.md, sidecar hash restamped. - Architecture/placement: ok. No new boundary; one new env variable, documented. Non-blocking 1. `removeTotp` and `regenerateRecoveryCodes` consume the proof code (TOTP step or recovery code) before the follow-up transaction; if that transaction fails, the code is spent. Rare, and the user can retry with the next code. 2. As noted in the PR body, escalating backoff and a key-version prefix for rotation are left for #228; worth an issue if none exists yet. Merging by rebase.
argus merged commit 173c9c66e6 into main 2026-10-09 15:51:27 +00:00
argus deleted branch loop/227-registry-totp 2026-10-09 15:51:27 +00:00
Sign in to join this conversation.
No reviewers
No labels
loop
ready
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
torbscript/language!272
No description provided.