Skip to content

Recovery credential architecture (transplanted from opensecret#286) - #904

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

Recovery credential architecture (transplanted from opensecret#286)#904
w3irdrobot wants to merge 22 commits into
MaplePrivacyLabs:masterfrom
w3irdrobot:docs/recovery-credential-architecture

Conversation

@w3irdrobot

Copy link
Copy Markdown

Summary

Ports the recovery credential architecture work from
OpenSecretCloud/opensecret#286
into the monorepo (services/opensecret/). The feature is not complete — this
is a draft PR to continue development in the right codebase.

What is here

  • Plan document: services/opensecret/docs/recovery-credential-plan.md, including the pending v1 decisions recorded with binding milestones
  • Recovery credential kind added to CredentialKind with a database constraint (migration)
  • RecoveryCode crypto primitives and wrapping helpers (src/recovery_code.rs, src/seed_wrapping.rs)
  • Recovery wrap CRUD helpers and database tests (src/db.rs, src/recovery_db_tests.rs)
  • Protected recovery management routes and tests (V2 transport required) (src/web/protected_routes.rs, src/recovery_route_tests.rs)
  • Security invariant updates (src/security_invariants.rs, src/crypto_property_tests.rs)

Provenance

  • Branch history is a faithful replay of
    OpenSecretCloud/opensecret@9b5cdc8..docs/recovery-credential-architecture
    (each commit preserved with original authorship: Phase 1 → Phase 4).
  • Tree verified byte-for-byte identical after path remap to
    services/opensecret/, minus the isolated toolchain-component commit which
    was intentionally dropped to keep the Nix-pinned toolchain untouched.
    services/opensecret/rust-toolchain.toml matches upstream.

Notes

  • Planning scratch files (docs/recovery-existing-patterns.md, docs/recovery-implementation-tasks.md) exist only in the working tree and are deliberately not committed.
  • Toolchain components (rust-analyzer, rust-src) were dropped from this PR and can land separately if wanted.

Verification

  • Patch replay verified: migrated tree diff identical to original branch diff
  • Backend gates via Nix-pinned toolchain ($validate-opensecret / component just recipes from repo root)
  • cargo test for recovery modules (recovery_code, recovery_db_tests, recovery_route_tests, crypto_property_tests, security invariants)
  • Migration up/down check for the new database constraint

…ers to seed_wrapping.rs

The Phase 2 commit (7af07a2) accidentally reverted the Recovery variant
from the CredentialKind enum after Phase 1 added it. It also never
included the recovery wrapping helpers (compute_recovery_auth_binding,
recovery_credential_lookup_hash, recovery_wrap_key, recovery_wrap_aad,
new_recovery_seed_wrapping, verify_recovery_seed_wrapping) that are
required for subsequent phases.

This commit restores Recovery to the enum and adds the missing helpers
plus 22 unit tests covering round-trip, wrong code/key/user/project,
tamper-proof rejection, domain separation, and the new_seed_wrapping /
verify_recovery_seed_wrapping boundary.
- Add reusable require_transport_v2 middleware (encryption_middleware)
- Mount dedicated recovery sub-router: status/enroll/rotate/disable
- Gate routes: v2 transport (outermost), user JWT, email-password user,
  password step-up, and seed opening through the signed AuthContext
- Enroll/rotate seal+verify wraps before any DB write; CAS from Phase 3
  adjudicates concurrency; disablement is idempotent
- Keep recovery credentials out of logs; sanitized generic error bodies
- Add 7 DB-backed route integration tests and security-invariant
  structure tests; extend sensitive log scanner identifiers
- Mark Phase 5/6-consumed recovery crypto helpers for CI clippy parity
…tion

Match the existing web mod pattern: re-export the sub-router function from
src/web/mod.rs and import it bare in application_routes, instead of the
namespace-qualified protected_routes::recovery_router path.
Remove recovery_wrap_key, recovery_wrap_aad, their RECOVERY_WRAP_* domain
constants, RecoveryCodeError, and RecoveryCode::parse with its crockford
decoder. None had a production consumer (parse arrives with Phase 6 reset
completion) and none will be reintroduced without one.

Recovery wraps keep opening through decrypt_seed_v1 with a recovery
AuthBinding; the recovery_code display shape unit test now covers the
canonical grouped format on its own.
This reverts commit 626fa3bad1dd4d0b10cf420e6d5e2db77b35d164.
Track the four open items opened by the Phase 4 work in the committed plan
document instead of the disposable task scratchpad: management status-code
contract, OAuth-only status eligibility, the plan-vs-implementation
recovery-wrap-envelope reconciliation, and validation-gate evidence scope.
Each carries the phase or event where the decision can no longer be
deferred.
@w3irdrobot
w3irdrobot force-pushed the docs/recovery-credential-architecture branch from 168f4e3 to f160804 Compare September 10, 2026 00:41
- POST /password-reset/v2/complete behind the outermost v2-transport gate
  with Preserve/Destructive modes, shared proof reverification, and
  transport-aware token issuance on success.
- Preserving completion opens the enrolled recovery wrap through
  decrypt_seed_v1 with a recovery auth binding, validates the opened seed,
  and replaces only the password credential under a user-row lock with a
  guarded reset consume and an unchanged-recovery-wrap CAS; a well-formed
  wrong code consumes exactly the selected request.
- Destructive completion requires an explicit data-loss acknowledgment and
  reuses the legacy destructive reset path unchanged, creating no recovery.
- Resolve the pending Phase 6 decision: delete the unused
  recovery_wrap_key/recovery_wrap_aad helpers, un-annotate the now-consumed
  RecoveryCode::parse, and reconcile the plan doc section.
- 10 DB-backed route tests covering the P6.T matrix plus a completion-route
  security invariant test.
…yloads

- PasswordResetConfirmPayload carries a presence-aware recovery_code
  Option<Option<String>> with a custom deserializer, so any present form
  of the field — a value, an empty string, or an explicit null — stays
  observable where a plain Option<String> would treat null as absent.
- password_reset_confirm rejects any recovery_code presence with a
  generic 400 as its first statement, before project, user,
  reset-request, or recovery lookup; the unchanged one-shot destructive
  flow follows for old clients that omit the field.
- 2 DB-backed route tests: the old-client destructive contract is
  retained exactly (response, request consumption, credentials, seed and
  data deletion, no recovery created), and every present recovery_code
  value is rejected before mutation with the reset request left active.
- Security invariant pin for the presence-aware field shape,
  guard-before-lookup order, and absence of recovery material in the
  legacy handler.
- Reconcile the plan doc's legacy-guard sketch with the implemented
  stricter shape.
The helper exported only DATABASE_URL/AEAD_TAMPER_TEST_DATABASE_URL and
filtered to the AEAD-tamper and OAuth subsets, so the 31 DB-backed recovery
tests silently never ran while the summary reported no skips. Export
RECOVERY_TEST_DATABASE_URL and run the recovery subset serially with the
same count and skip-marker assertions as the existing subsets.
…cisions

Phases 0-8 are complete; fold the Phase 8 validation evidence, the
implemented-surface file map, and the silent-rollout strategy into the
plan doc. Confirm the client-facing management status codes and keep
recovery_status reachable for OAuth-only users; close the deferred
encrypted-carrier smoke and runtime log-capture rows, moving both to the
client-rollout gate. The untracked tasks scratchpad and patterns
reference are deleted now that the durable content lives here.
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.

1 participant