Skip to content

Recovery credentials for preserving password reset - #904

Open
w3irdrobot wants to merge 4 commits into
MaplePrivacyLabs:masterfrom
w3irdrobot:docs/recovery-credential-architecture
Open

w3irdrobot wants to merge 4 commits into
MaplePrivacyLabs:masterfrom
w3irdrobot:docs/recovery-credential-architecture

Conversation

@w3irdrobot

@w3irdrobot w3irdrobot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

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_v2 is 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 status
  • POST /protected/recovery-code/enroll, .../rotate and DELETE /protected/recovery-code: email-password users only, with a current-password step-up
  • POST /password-reset/v2/options: reports whether recovery is enrolled, but only after the email code and client secret verify, and without consuming the reset request
  • POST /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.
  • The existing POST /password-reset/confirm is unchanged, except that any recovery_code field (a value, an empty string or null) 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_db pool, recovery secret types are covered by the redacting-Debug check, 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 -- --check and CI clippy (-D warnings): clean
  • cargo test --locked --all-features: 1126 passed / 0 failed / 92 ignored
  • Disposable-DB helper --redo-latest: down/up of 2026-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 secrets
  • Root nix flake check --no-update-lock-file: passed on the pre-squash merge of the same tree; the only tree difference is two unrelated master commits

Not verified: SDK or app integration, Nitro attestation, email delivery, and deployed behavior (see "Evidence limits" in docs/dev-shell.md).

🤖 Generated with Claude Code

@w3irdrobot
w3irdrobot force-pushed the docs/recovery-credential-architecture branch 2 times, most recently from 1d37a35 to c9c11c7 Compare September 15, 2026 02:46
@w3irdrobot
w3irdrobot marked this pull request as ready for review September 15, 2026 02:46
@w3irdrobot
w3irdrobot force-pushed the docs/recovery-credential-architecture branch from c9c11c7 to 6d6ad71 Compare October 29, 2026 11:59
@w3irdrobot w3irdrobot changed the title Recovery credential architecture (transplanted from opensecret#286) Recovery credentials for preserving password reset Oct 29, 2026
@w3irdrobot

Copy link
Copy Markdown
Contributor Author

After rebasing, potential issues found by DeepSeek abliterated.

  1. Silent credential install — no notification on enroll/rotate/disable, and destructive reset deletes the recovery wrap without a mail. (enroll_recovery/rotate_recovery/disable_recovery never touch email.) A stolen session plus the current password can add an attacker's own recovery code that survives later password changes; destruction is invisible to the user. Needs a mail hook.
  2. wrapping_version dead end. UserSeedWrapping::get_recovery_for_user (.limit(2)) errors on a second slot, so the first v2 recovery wrap makes every recovery read — status, options, preserve — fail closed as 500 instead of selecting the highest version. Zero impact today, certain to bite on the next version bump.
  3. Unrecoverable-code contract gap. The code is returned only after commit: a retried enroll answers 409, a retried rotate has already burned the old code, and recovery_status has no "code not yet seen" signal. Nothing tells the client to rotate; a lost response degrades to "needs the password again".
  4. Legacy /password-reset/confirm reports a lost commit race as 500. complete_destructive_password_reset returns PasswordResetRequestNotFound/StaleCredentialState, and the handler's map_err sends both to InternalServerError while v2 maps them to 400. Old clients can't classify it as retryable.
  5. A concurrent password change silently loses to a preserving reset. Defined and pinned by ..._races_safely_with_password_change, but the change may already have returned success and its fresh tokens are then dead, with no error and no client-facing note.
  6. Reset-request lookup uses .first() with no ORDER BY and no expiry filter (PasswordResetRequest::get_by_user_id_and_code). Two unreset rows with the same code let the options call and the completion read different rows, and an expired row can win, producing a spurious PasswordResetExpired/InvalidPasswordResetSecret at a 2⁻⁴¹-ish rate. order_by(id.desc()) fixes it.
  7. Disposable helper weakened one assertion — recovery_count -gt 0 where the aead/oauth/callback/compaction sections pin exact counts.

@w3irdrobot

w3irdrobot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Review follow-up (f4299bc1)

A separate review raised seven findings. Here's how each was resolved:

# 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.

w3irdrobot and others added 3 commits October 30, 2026 11:16
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>
@w3irdrobot
w3irdrobot force-pushed the docs/recovery-credential-architecture branch from f4299bc to 435aa05 Compare October 30, 2026 15:20
…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).
@AnthonyRonning

Copy link
Copy Markdown
Contributor

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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants