Repository navigation
Recovery credentials for preserving password reset - #904
w3irdrobot wants to merge 4 commits into
Conversation
1d37a35 to
c9c11c7
Compare
c9c11c7 to
6d6ad71
Compare
|
After rebasing, potential issues found by DeepSeek abliterated.
|
Review follow-up (
|
| # | Finding | Outcome |
|---|---|---|
| 1 | No email on recovery enroll/rotate/disable | Open, needs a decision (below) |
| 2 | A second wrapping_version row would make every recovery read fail with a 500 |
Fixed. A partial unique index on user_id for recovery rows means a version bump must replace the slot, as rotation does. Reads still fail closed on a duplicate, since host storage is untrusted. |
| 3 | A lost enroll/rotate response leaves the code unrecoverable | By design: the code is shown once. The client contract: if the response wasn't displayed, treat the code as unknown and offer Rotate with password step-up. |
| 4 | /password-reset/confirm returns 500 when it loses a commit race |
Fixed (this also affected master). It now returns the generic 400, matching V2. A new test forces the lost commit deterministically, and it fails without the fix. |
| 5 | A concurrent password change loses to a preserving reset | Intended. The reset is the newer change, backed by the email proof and the recovery code, the same way a destructive reset already beats a password change. Now recorded under Concurrency Rules. |
| 6 | Reset-request lookup has no ORDER BY |
Fixed, also existing master code: newest open request wins, with a test. The platform reset and account-deletion lookups have the same pattern and are left for a separate change. |
| 7 | Disposable helper's recovery assertion is weak | Fixed. Suites are now selected by exact module prefix with pinned counts (12 DB, 24 route, 1 gateway), like the callback subsets. |
Re-validated locally: fmt and clippy clean; 1126 unit tests passed / 92 ignored; disposable-DB helper with --redo-latest passed, including all 37 recovery tests with 0 skipped; test-recovery-flow.sh passed all six phases.
Notification question
@AnthonyRonning: enroll, rotate and disable currently send no email. Both reset paths already send the password-reset confirmation, though it doesn't say recovery was removed. The risk is limited: using a planted recovery code still requires the emailed reset proof, and a stolen session plus password can already read /protected/private_key. Master doesn't email on password change either. Still, a planted recovery code is invisible to the user today.
Should this PR add a security notification for recovery enroll/rotate/disable, and possibly mention recovery removal in the destructive-reset confirmation? If so, would you rather I add a Maple template on the existing layout, reusing the closest current hero image, with copy reviewed here, or wait for design and new assets? The other option is to make it a required item before any client ships the feature.
Storage and crypto: - Add the recovery credential kind and its database constraint. - RecoveryCode: 256 random bits shown once as grouped Crockford Base32 with a 40-bit checksum; not Debug/Display-formattable. - Seal and verify recovery seed wraps with a recovery AuthBinding and domain-separated lookup hash; bound stored hash/envelope sizes in the model and reject duplicate recovery slots as storage-integrity errors. - Recovery wrap CRUD with compare-and-swap rotation/disablement and a preserving-reset transaction that takes the user-row lock, rechecks the selected reset request at commit time, and replaces only the password credential. Routes (all behind an outermost require_transport_v2 gate, re-checked in each handler): - GET/DELETE /protected/recovery-code and POST .../enroll and .../rotate for email-password users, with current-password step-up; sealed wraps are verified before any write. - POST /password-reset/v2/options reports recovery enrollment only after the email code and client secret verify, without consuming the request. - POST /password-reset/v2/complete preserves the seed with the recovery code or destructively resets with acknowledge_data_loss. A malformed code leaves the request active; a well-formed wrong code consumes only the selected request. - The existing destructive /password-reset/confirm rejects any recovery_code field, value or null, before any lookup. Tests and invariants: recovery DB lifecycle and route suites against a disposable database, security invariants pinning the router layer order and guard placement, recovery secret types pinned against derived Debug, and recovery identifiers added to the sensitive-log scanner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Drive the production application composition through the real V1 encryption middleware and V2 gateway on a disposable database: every recovery route rejects a V1 session generically without side effects, and V2 status, enrollment, reset options and preserving completion succeed with the seed preserved. - Add scripts/test-recovery-flow.sh and its encrypted HTTP client, which build this checkout, migrate a private PostgreSQL cluster, run a loopback backend, exercise the full recovery lifecycle including invalid stored sizes and legacy reset compatibility, and scan the owned backend logs for generated secrets. - Run the recovery suites serially in the disposable-DB validation helper with the same count and skip-marker checks as the other subsets, and document the smoke runner and helper coverage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Enforce one recovery slot per user across wrapping versions with a partial unique index, so a future version bump must replace the slot. Reads still fail closed on a second row if host storage drops the index. - The existing /password-reset/confirm now returns the generic 400, not 500, when a concurrent reset consumes its request or changes the credential first, matching the V2 completion. - Reset-request lookup by code selects the newest open request, so the V2 options and completion calls read the same row when codes repeat. - The disposable-DB helper selects recovery suites by exact module prefix and pins their counts, like the callback subsets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
f4299bc to
435aa05
Compare
…king
Review fixes on the recovery credential surface:
- The down migration re-adds a `('password','oauth')` CHECK, which validates
existing rows, so any enrolled recovery wrap aborted the rollback. Delete
recovery rows first and document the asymmetry.
- Zeroize the one-time code in the enroll/rotate response and give it a manual
redacting `Debug`, so logging a response cannot leak it.
- Decode Crockford case-insensitively inside the decoder instead of relying on
the caller having normalized input, and drop the now-dead lowercase arms.
- Add `recovery_last_used_at`/`recovery_use_count` and expose them on the
status route, so silent use of a long-lived credential is visible. They are
metadata only and are bumped after the CAS.
- Comment the invariant that any flow replacing a stored seed must clear or
re-seal the recovery wrap.
- Add independent route probes, and let the disposable-DB helper redo more than
one migration via `--redo-latest COUNT` (pinned recovery count updated).
|
Yeah I think routine level emails on security events like this make sense. I think some of the existing templates for password operations could mostly be copied. |
Summary
Adds opt-in recovery credentials to OpenSecret, so a password reset can keep a user's seed and encrypted data instead of wiping them.
A recovery code (256 random bits, grouped Crockford Base32 with a 40-bit checksum, shown once) wraps the existing seed as a third credential kind beside password and OAuth. This is backend-only and additive: nothing changes for existing clients, and there's no feature flag. SDK bindings and the Security settings UI come later as the client rollout.
Surface
All recovery endpoints require a V2 transport session.
require_transport_v2is the outermost layer, so V1 callers are rejected before decryption, JWT validation or any DB access, and every handler checks again.GET /protected/recovery-code: enrollment statusPOST /protected/recovery-code/enroll,.../rotateandDELETE /protected/recovery-code: email-password users only, with a current-password step-upPOST /password-reset/v2/options: reports whether recovery is enrolled, but only after the email code and client secret verify, and without consuming the reset requestPOST /password-reset/v2/complete: either a preserving reset (recovery code) or a destructive one (acknowledge_data_loss). A malformed code leaves the request active; a well-formed wrong code consumes only the selected request.POST /password-reset/confirmis unchanged, except that anyrecovery_codefield (a value, an empty string ornull) is rejected with a generic 400 before any lookup.The preserving reset locks the user row, rechecks the selected reset request when it commits, and replaces only the password credential. The recovery wrap stays byte-for-byte unchanged.
Ported from OpenSecretCloud/opensecret#286 onto current master, and adapted to master's newer patterns: the migration is renamed so it sorts after master's migrations, the DB suites use the
setup_test_dbpool, recovery secret types are covered by the redacting-Debugcheck, and the smoke uses user instructions in place of the retired KV API.Verification
Local Nix shells with side-effect hooks disabled; disposable loopback Postgres:
cargo fmt --all -- --checkand CI clippy (-D warnings): cleancargo test --locked --all-features: 1126 passed / 0 failed / 92 ignored--redo-latest: down/up of2026-10-28-000000_recovery_credential_kind; 90 passed / 0 skipped (37 recovery: 12 DB, 24 route, 1 real gateway, counts pinned)bash services/opensecret/scripts/test-recovery-flow.sh: all six phases, including the backend log scan for generated secretsnix flake check --no-update-lock-file: passed on the pre-squash merge of the same tree; the only tree difference is two unrelated master commitsNot verified: SDK or app integration, Nitro attestation, email delivery, and deployed behavior (see "Evidence limits" in
docs/dev-shell.md).🤖 Generated with Claude Code