Auth: a single 24-word recovery key instead of recovery codes #68

Closed
opened 2026-09-24 15:58:33 +00:00 by kayg · 12 comments
Owner

Owner decision (2026-09-24, A2): replace the 10 one-time recovery codes with one recovery key of 24 checksum words, the calternal.js way (read-only reference: /home/kayg/Developer/calternal.js CONTEXT.md "Recovery Package" and the recovery code in packages/core + docs/contracts.md). A1: keep what is built for passkeys (at least one required, more can be added).

  • Key: 256-bit random seed encoded as 24 words from the BIP39 English list with checksum; shown once at signup (setup, invite, OIDC-linked local credential) with copy and "I saved it" confirmation; the server stores only a slow hash (argon2id) of the normalized phrase.
  • Recovery flow: enter the 24 words (typo-tolerant input: checksum validation, word autocomplete from the list, case/whitespace insensitive) → enrol a new passkey → the key is rotated (new key shown once) and every other session is revoked.
  • Regenerate key from Settings → Account (requires a fresh passkey assertion); the old key stops working atomically; security event logged.
  • Migration: existing accounts with recovery codes keep them working until they generate a key; then the codes are deleted.
  • Rate limiting and uniform responses as for the current recovery path (#3 hardening decisions); no user enumeration.
  • Update the API contract and the auth screens' copy (the UI job consumes it).
    Tests: checksum rejects single-word typos, recovery end to end with a virtual authenticator, rotation invalidates the old key, concurrent recovery attempts, migration from codes.
    Read CLAUDE.md, CONTEXT.md, docs/DESIGN.md §7, §21. Comment on this issue as you work; never close it.
Owner decision (2026-09-24, A2): replace the 10 one-time recovery codes with **one recovery key of 24 checksum words**, the calternal.js way (read-only reference: /home/kayg/Developer/calternal.js CONTEXT.md "Recovery Package" and the recovery code in packages/core + docs/contracts.md). A1: keep what is built for passkeys (at least one required, more can be added). - Key: 256-bit random seed encoded as 24 words from the BIP39 English list with checksum; shown once at signup (setup, invite, OIDC-linked local credential) with copy and "I saved it" confirmation; the server stores only a slow hash (argon2id) of the normalized phrase. - Recovery flow: enter the 24 words (typo-tolerant input: checksum validation, word autocomplete from the list, case/whitespace insensitive) → enrol a new passkey → the key is rotated (new key shown once) and every other session is revoked. - Regenerate key from Settings → Account (requires a fresh passkey assertion); the old key stops working atomically; security event logged. - Migration: existing accounts with recovery codes keep them working until they generate a key; then the codes are deleted. - Rate limiting and uniform responses as for the current recovery path (#3 hardening decisions); no user enumeration. - Update the API contract and the auth screens' copy (the UI job consumes it). Tests: checksum rejects single-word typos, recovery end to end with a virtual authenticator, rotation invalidates the old key, concurrent recovery attempts, migration from codes. Read CLAUDE.md, CONTEXT.md, docs/DESIGN.md §7, §21. Comment on this issue as you work; never close it.
Author
Owner

Started issue #68 on branch job/recovery-key at base f14e2b5be3. I read CLAUDE.md, CONTEXT.md, docs/DESIGN.md, and the issue spec. I am tracing the current recovery API, storage, and contract before implementation.

Started issue #68 on branch job/recovery-key at base f14e2b5be35ff40fc43b56952048bf945803c37f. I read CLAUDE.md, CONTEXT.md, docs/DESIGN.md, and the issue spec. I am tracing the current recovery API, storage, and contract before implementation.
Author
Owner

Finding: the current API issued ten codes during initial passkey registration, and the existing recovery transaction consumed one code while adding a passkey. The new key path uses BIP39 English checksum words and stores one Argon2id hash per user. The recovery transaction rotates that hash, adds the passkey, revokes sessions, and logs the security event together. Existing codes remain valid only for accounts without a key, then are deleted on first key issuance.

Finding: the current API issued ten codes during initial passkey registration, and the existing recovery transaction consumed one code while adding a passkey. The new key path uses BIP39 English checksum words and stores one Argon2id hash per user. The recovery transaction rotates that hash, adds the passkey, revokes sessions, and logs the security event together. Existing codes remain valid only for accounts without a key, then are deleted on first key issuance.
Author
Owner

Auth implementation committed at b9bfa14e27. The auth crate passed 34 tests (including virtual authenticator recovery, rotation, concurrency, and code migration) and cargo clippy -p calternal-auth --all-targets -- -D warnings. Adversarial recovery probes committed at b6de0045a3. Contract generation and full gates are running.

Auth implementation committed at b9bfa14e275eb6e6091430a785f4f1849deccf2e. The auth crate passed 34 tests (including virtual authenticator recovery, rotation, concurrency, and code migration) and `cargo clippy -p calternal-auth --all-targets -- -D warnings`. Adversarial recovery probes committed at b6de0045a330958ca2f852a84edff38d15238aa7. Contract generation and full gates are running.
Author
Owner

Contract finding: Utoipa inferred text/plain for a String response even though Axum's Json<String> sends JSON. I added an explicit application/json response annotation, regenerated contracts/openapi.json and packages/api-client/src/generated.ts, and committed them at 7d16d26b46.

Contract finding: Utoipa inferred `text/plain` for a `String` response even though Axum's `Json<String>` sends JSON. I added an explicit `application/json` response annotation, regenerated `contracts/openapi.json` and `packages/api-client/src/generated.ts`, and committed them at 7d16d26b4624ae2d90dd73cbedb4ba33ca6eed9b.
Author
Owner

Full workspace tests exposed a server wiring omission: wire::tests::full_app_setup_session_config_and_backup returned 500 on registration because the server's manual migration list omitted 0003_recovery_key.sql, while the auth crate's own migration runner included it. I added migration 3 to the server list and am rerunning the integration test and all gates.

Full workspace tests exposed a server wiring omission: `wire::tests::full_app_setup_session_config_and_backup` returned 500 on registration because the server's manual migration list omitted `0003_recovery_key.sql`, while the auth crate's own migration runner included it. I added migration 3 to the server list and am rerunning the integration test and all gates.
Author
Owner

Server migration wiring fixed and committed at eb7ec4a0aa. The targeted wire::tests::full_app_setup_session_config_and_backup passed; it now also asserts that setup returns 24 words. Full workspace gates are rerunning.

Server migration wiring fixed and committed at eb7ec4a0aadc2b89f2dcbe4e3e1cc35725f8ba27. The targeted `wire::tests::full_app_setup_session_config_and_backup` passed; it now also asserts that setup returns 24 words. Full workspace gates are rerunning.
Author
Owner

Adversarial round found one probe error, not an API defect: setup.mjs had just logged in with a passkey, so the bearer session was fresh and recovery-key rotation correctly returned 200. I changed the probe to use that fresh session for rotation, verify that the old key fails, and separately check an unauthenticated request. The temporary log that printed the disposable fixture key was removed; the test data directory was deleted by the probe trap.

Adversarial round found one probe error, not an API defect: setup.mjs had just logged in with a passkey, so the bearer session was fresh and recovery-key rotation correctly returned 200. I changed the probe to use that fresh session for rotation, verify that the old key fails, and separately check an unauthenticated request. The temporary log that printed the disposable fixture key was removed; the test data directory was deleted by the probe trap.
Author
Owner

Issue #68 implementation is finished on branch job/recovery-key, head 64c988a2d1bfe8c1853d6288a42895f17019766e (base f14e2b5be35ff40fc43b56952048bf945803c37f).

Built: BIP39 English 24-word recovery key generation and checksum validation; Argon2id hash-only security state; atomic key rotation, passkey enrollment, session revocation, and legacy-code deletion; fresh-assertion key regeneration; API/OpenAPI/TypeScript updates; server migration wiring; virtual-authenticator and adversarial coverage. Commits: b9bfa14, b6de004, 7d16d26, eb7ec4a, 64c988a.

Files: crates/calternal-auth/Cargo.toml, migrations/0003_recovery_key.sql, src/{api,lib,passkey,recovery,store}.rs; crates/calternal-server/src/wire.rs; contracts/openapi.json; packages/api-client/src/generated.ts; tests/adversarial/attack.py; Cargo.lock.

Gate output (verbatim excerpts from successful runs):

cargo fmt --check: no output, exit 0.

cargo clippy --all-targets -- -D warnings:

   Compiling calternal-server v0.0.1 (/home/kayg/Developer/calternal-wt/recovery-key/crates/calternal-server)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 13.69s

cargo test:

test result: ok. 34 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 62.86s
test wire::tests::full_app_setup_session_config_and_backup ... ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.12s

All workspace test binaries and doc tests passed; the full log is /tmp/calternal-recovery-test-final.log.

bash packages/api-client/check-generated.sh:

   Compiling calternal-server v0.0.1 (/home/kayg/Developer/calternal-wt/recovery-key/crates/calternal-server)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 32.04s
     Running `target/debug/calternal-server openapi`
$ bunx --package openapi-typescript@7.13.0 openapi-typescript ../../contracts/openapi.json -o src/generated.ts
✨ openapi-typescript 7.13.0
🚀 ../../contracts/openapi.json → src/generated.ts [479.9ms]

bash tests/adversarial/run.sh:

top-level entries: ['.cas', '.system', 'users']
server alive at end: True

==== FINDINGS 0

cargo clean:

     Removed 15811 files, 7.1GiB total

Known gap: the web auth screens' phrase entry, word autocomplete, copy action, and "I saved it" confirmation belong to the separate UI job; this job did not edit apps/web. The backend validates checksum and case/whitespace normalization. No backend gate failures remain.

API decisions for owner review: POST /api/v1/auth/recovery/key returns a JSON string after a fresh assertion; registration returns nullable recovery_key; recovery start accepts key with a legacy code alias so existing code holders can migrate. The issue did not prescribe these wire shapes. I did not push, merge, deploy, or close the issue.

Issue #68 implementation is finished on branch `job/recovery-key`, head `64c988a2d1bfe8c1853d6288a42895f17019766e` (base `f14e2b5be35ff40fc43b56952048bf945803c37f`). Built: BIP39 English 24-word recovery key generation and checksum validation; Argon2id hash-only security state; atomic key rotation, passkey enrollment, session revocation, and legacy-code deletion; fresh-assertion key regeneration; API/OpenAPI/TypeScript updates; server migration wiring; virtual-authenticator and adversarial coverage. Commits: `b9bfa14`, `b6de004`, `7d16d26`, `eb7ec4a`, `64c988a`. Files: `crates/calternal-auth/Cargo.toml`, `migrations/0003_recovery_key.sql`, `src/{api,lib,passkey,recovery,store}.rs`; `crates/calternal-server/src/wire.rs`; `contracts/openapi.json`; `packages/api-client/src/generated.ts`; `tests/adversarial/attack.py`; `Cargo.lock`. Gate output (verbatim excerpts from successful runs): `cargo fmt --check`: no output, exit 0. `cargo clippy --all-targets -- -D warnings`: ``` Compiling calternal-server v0.0.1 (/home/kayg/Developer/calternal-wt/recovery-key/crates/calternal-server) Finished `dev` profile [unoptimized + debuginfo] target(s) in 13.69s ``` `cargo test`: ``` test result: ok. 34 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 62.86s test wire::tests::full_app_setup_session_config_and_backup ... ok test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.12s ``` All workspace test binaries and doc tests passed; the full log is `/tmp/calternal-recovery-test-final.log`. `bash packages/api-client/check-generated.sh`: ``` Compiling calternal-server v0.0.1 (/home/kayg/Developer/calternal-wt/recovery-key/crates/calternal-server) Finished `dev` profile [unoptimized + debuginfo] target(s) in 32.04s Running `target/debug/calternal-server openapi` $ bunx --package openapi-typescript@7.13.0 openapi-typescript ../../contracts/openapi.json -o src/generated.ts ✨ openapi-typescript 7.13.0 🚀 ../../contracts/openapi.json → src/generated.ts [479.9ms] ``` `bash tests/adversarial/run.sh`: ``` top-level entries: ['.cas', '.system', 'users'] server alive at end: True ==== FINDINGS 0 ``` `cargo clean`: ``` Removed 15811 files, 7.1GiB total ``` Known gap: the web auth screens' phrase entry, word autocomplete, copy action, and "I saved it" confirmation belong to the separate UI job; this job did not edit `apps/web`. The backend validates checksum and case/whitespace normalization. No backend gate failures remain. API decisions for owner review: `POST /api/v1/auth/recovery/key` returns a JSON string after a fresh assertion; registration returns nullable `recovery_key`; recovery start accepts `key` with a legacy `code` alias so existing code holders can migrate. The issue did not prescribe these wire shapes. I did not push, merge, deploy, or close the issue.
Author
Owner

Backend merged to main (job/recovery-key @ 64c988a). Remaining: the web UI for the recovery key (show once with copy + "I saved it", 24-word entry with checksum validation and word autocomplete, regenerate in Settings → Account). The UI wave picks this up.

Backend merged to main (job/recovery-key @ 64c988a). Remaining: the web UI for the recovery key (show once with copy + "I saved it", 24-word entry with checksum validation and word autocomplete, regenerate in Settings → Account). The UI wave picks this up.
Author
Owner

UI job started for the recovery key screens (worktree branch worktree-agent-a33eab04d814ffa19, base afe8c7a). Scope: show-once key screen at signup with copy and an "I saved it" confirmation, 24-word recovery entry with checksum validation and word autocomplete, regenerate in Settings → Account, usernameless passkey sign-in, and the auth/settings polish. The login page ghost in the top-left corner is the skip link's shadow bleeding into view; that fix is part of this job. A few small read endpoints (sign-in options, users, invites, account security, passkey rename, config error details, SSO browser callback) are being added so the screens show real data.

UI job started for the recovery key screens (worktree branch worktree-agent-a33eab04d814ffa19, base afe8c7a). Scope: show-once key screen at signup with copy and an "I saved it" confirmation, 24-word recovery entry with checksum validation and word autocomplete, regenerate in Settings → Account, usernameless passkey sign-in, and the auth/settings polish. The login page ghost in the top-left corner is the skip link's shadow bleeding into view; that fix is part of this job. A few small read endpoints (sign-in options, users, invites, account security, passkey rename, config error details, SSO browser callback) are being added so the screens show real data.
Author
Owner

The UI for the recovery key is finished on branch worktree-agent-a33eab04d814ffa19, head bb188e2. It is not merged or pushed.

What is in it:

  • Account creation (setup link, invitation, open signup, admin passkey link): first "Create your passkey", then the 24 words shown once. The screen has Copy and Download buttons. The "I saved my recovery key somewhere safe" box must be ticked before Continue works. After that comes a short summary and the sign-in.
  • Recovery (/recover): the username and 24 numbered fields. You can paste the whole key into any field; numbers, commas, line breaks and capital letters are ignored. Each field suggests words from the BIP39 English list. The page checks the checksum and names the wrong word, with a "Did you mean" hint. Then it creates a new passkey and shows the new key once.
  • Settings → Account → Recovery key: "Create new key" opens a sheet that shows the words once. The sheet does not close until the words are marked saved. The interim word list is gone.
  • The server additions the screens need (sign-in options, users, invitations, account security, passkey rename, config error details, SSO browser callback, sign-out, dead-cookie handling) are in the same branch, with tests and adversarial probes (0 findings).

Real-server e2e (apps/web/e2e/auth.mjs) passes: setup with the show-once key, usernameless sign-in, regenerating the key in Settings, and recovery with the 24 words on a device that has no passkey. The rotated key differs from the old one each time.

The UI for the recovery key is finished on branch `worktree-agent-a33eab04d814ffa19`, head `bb188e2`. It is not merged or pushed. What is in it: - Account creation (setup link, invitation, open signup, admin passkey link): first "Create your passkey", then the 24 words shown once. The screen has Copy and Download buttons. The "I saved my recovery key somewhere safe" box must be ticked before Continue works. After that comes a short summary and the sign-in. - Recovery (`/recover`): the username and 24 numbered fields. You can paste the whole key into any field; numbers, commas, line breaks and capital letters are ignored. Each field suggests words from the BIP39 English list. The page checks the checksum and names the wrong word, with a "Did you mean" hint. Then it creates a new passkey and shows the new key once. - Settings → Account → Recovery key: "Create new key" opens a sheet that shows the words once. The sheet does not close until the words are marked saved. The interim word list is gone. - The server additions the screens need (sign-in options, users, invitations, account security, passkey rename, config error details, SSO browser callback, sign-out, dead-cookie handling) are in the same branch, with tests and adversarial probes (0 findings). Real-server e2e (`apps/web/e2e/auth.mjs`) passes: setup with the show-once key, usernameless sign-in, regenerating the key in Settings, and recovery with the 24 words on a device that has no passkey. The rotated key differs from the old one each time.
Author
Owner

Completed on dev in bb1acbaa7a (Merge auth and settings polish: show-once recovery key, usernameless passkey login, settings deep links (#68)).

Completed on dev in bb1acbaa7aff07a44850db03f01923a7c695c3d3 (Merge auth and settings polish: show-once recovery key, usernameless passkey login, settings deep links (#68)).
kayg closed this issue 2026-10-01 05:08:57 +00:00
Sign in to join this conversation.
No labels
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.

Dependencies

No dependencies set

Reference
kayg/calternal#68
No description provided.