From 5aa4041e09e3bb5e88cfdbfdb95d82a23816bb1d Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 8 Sep 2026 20:46:51 +0800 Subject: [PATCH 01/14] feat: password change lifecyles and state --- .../0001-seedless-password-change-recovery.md | 380 +++++++++++++++++ ...ess-password-change-implementation-plan.md | 396 ++++++++++++++++++ ...0003-seedless-password-change-contracts.md | 189 +++++++++ .../src/SeedlessOnboardingController.test.ts | 11 +- .../src/SeedlessOnboardingController.ts | 7 + .../src/constants.ts | 41 ++ .../src/index.ts | 3 + .../src/types.ts | 33 +- .../src/utils.test.ts | 308 +++++++++++++- .../src/utils.ts | 201 ++++++++- 10 files changed, 1563 insertions(+), 6 deletions(-) create mode 100644 packages/seedless-onboarding-controller/docs/0001-seedless-password-change-recovery.md create mode 100644 packages/seedless-onboarding-controller/docs/0002-seedless-password-change-implementation-plan.md create mode 100644 packages/seedless-onboarding-controller/docs/0003-seedless-password-change-contracts.md diff --git a/packages/seedless-onboarding-controller/docs/0001-seedless-password-change-recovery.md b/packages/seedless-onboarding-controller/docs/0001-seedless-password-change-recovery.md new file mode 100644 index 00000000000..4a581128ad7 --- /dev/null +++ b/packages/seedless-onboarding-controller/docs/0001-seedless-password-change-recovery.md @@ -0,0 +1,380 @@ +# ADR 0001: Recovering server-first Seedless password changes + +- Status: Proposed +- Date: 2026-09-07 +- Scope: MetaMask extension password changes backed by Seedless + +## Context + +Changing a Seedless password updates multiple independently persisted states: + +1. The remote Seedless/TOPRF password, backup data, and key shares. +2. The local Seedless controller state. +3. The local KeyringController vault password. +4. The Keyring encryption key stored in Seedless. +5. The password-change lifecycle state. + +These changes cannot be committed atomically. A process, browser, app, device, network request, or persistence operation can fail between any two steps. + +The proposed ordering commits the remote Seedless change first. After remote commitment, recovery must support both possible local states: + +- The local Keyring is still protected by the old password. Recovery uses the new Seedless password to recover the stored Keyring encryption key, calls `submitEncryptionKey`, and re-encrypts the local Keyring with the new password. +- The local Keyring is already protected by the new password, but key synchronization failed. Recovery unlocks with the new password, exports the current Keyring encryption key, and synchronizes it to Seedless. + +The lifecycle state helps select the recovery checks, but it cannot establish the actual state. The process may terminate before the lifecycle update is persisted. Cryptographic verification and authoritative server-state checks must therefore determine the recovery branch. + +## Decision + +Use a durable, idempotent lifecycle state machine around the server-first operation. + +- Lock the wallet whenever the operation cannot establish a consistent state. +- On the next unlock, inspect the lifecycle state before normal unlock error handling. +- Require an authoritative Seedless server-state check for every unfinished state. +- Use cryptographic verification to determine whether the local Keyring is old or new. +- Re-run already-completed operations safely instead of attempting an in-process rollback. +- Lock the wallet from the client whenever any password-change or recovery step fails, before exposing an error or intermediary screen. +- Mark `COMPLETE` only after the current Keyring encryption key is synchronized to Seedless and all required local state is durably persisted. +- Keep any state that cannot be distinguished safely as `unknown`. + +The lifecycle names below are descriptive. They can be mapped to the final implementation enum without changing the recovery semantics. + +## Implementation scope + +The implementation is split between shared controller capabilities and client-specific orchestration/UI. The KeyringController and SeedlessOnboardingController should provide safe primitives; neither controller alone can own the complete transaction because the operation spans both controllers and multiple persistence systems. + +### Controller and shared-contract scope + +#### SeedlessOnboardingController + +The controller already provides most of the required recovery primitives: + +- `changePassword` performs the Seedless password/vault change and handles the controller-level token-refresh path. +- `loadKeyringEncryptionKey` can recover the stored Keyring encryption key after the new Seedless password is submitted. +- `storeKeyringEncryptionKey` encrypts and stores the current Keyring encryption key in controller state. +- `submitGlobalPassword` and `syncLatestGlobalPassword` provide the password-sync operations needed to rehydrate and update local Seedless state. +- `checkIsPasswordOutdated({ skipCache: true })` provides a cache-bypassed password-state check. +- Controller locking already serializes controller-level operations. + +The new controller work is: + +- Add a persisted password-change lifecycle state/phase to `SeedlessOnboardingControllerState`, with persistence metadata. The lifecycle must not store passwords, SRPs, raw Keyring encryption keys, or decrypted backup material. +- Modify `changePassword` to update the lifecycle after each relevant operation: before the remote change, after remote commitment, after the local Seedless vault/state update, and when the operation fails or becomes ambiguous. +- Modify `storeKeyringEncryptionKey` to update the lifecycle after the encrypted Keyring encryption key has been stored in controller state. The encrypted-key update and lifecycle update should be adjacent so observers do not see an inconsistent intermediate controller state. +- Do not let `storeKeyringEncryptionKey` mark `COMPLETE` by itself. Completion also requires client confirmation of the local Keyring state, remote synchronization, and durable persistence. +- Ensure a thrown error after a partial mutation does not reset the lifecycle to the pre-operation state. The last known phase must remain available for recovery. +- Facilitate the existing password-sync operations for both post-remote-commit recovery branches: + - Old local Keyring: submit the new Seedless password, load the stored Keyring encryption key, and allow the client to call `submitEncryptionKey` before re-encrypting locally. + - New local Keyring: submit the new password, verify/unlock the local Keyring, export its current Keyring encryption key, and store/synchronize it. +- Preserve the existing token-refresh and controller-lock behavior while making lifecycle transitions observable to clients. + +The controller must not infer completion from a successful in-memory update or from a rejected Promise. The client remains responsible for coordinating the KeyringController and for the final durable `COMPLETE` transition. + +#### KeyringController + +No new KeyringController recovery API is required by this ADR. The client reuses the existing primitives: + +- `verifyPassword` cryptographically checks whether the local Keyring uses the old or new password. +- `submitEncryptionKey` unlocks an old local Keyring after its encryption key is recovered from Seedless. +- `changePassword` re-encrypts the local Keyring with the new password. +- `exportEncryptionKey` provides the current key for synchronization when the local Keyring is already new. + +The KeyringController remains responsible only for local vault operations. Remote Seedless status, cross-controller orchestration, and final lifecycle completion remain outside it. + +Wallet locking for password-change errors is also a client responsibility. The controllers should return the operation result/error and expose the locking primitives, while the client decides when to lock and which recovery/intermediary screen to present. + +#### Password-change coordinator and lifecycle persistence + +- The lifecycle state is persisted by `SeedlessOnboardingController`, while the client orchestration layer coordinates KeyringController operations against those phases. +- Persist only non-sensitive transaction data, such as lifecycle phase, transaction identifier, timestamps, retry metadata, and non-sensitive error classification. +- Write `SEEDLESS_CHANGE_PENDING` before the first remote mutation. +- Write `SEEDLESS_COMMITTED` only after remote commitment is confirmed by the server or an authoritative status check. +- Advance the lifecycle after each `changePassword` and `storeKeyringEncryptionKey` operation so a later unlock can identify the last known boundary, while treating the phase as advisory when persistence may have been interrupted. +- Use an awaitable durable persistence operation for lifecycle transitions and `COMPLETE`. The generic debounced state-change path must not be the only durability boundary. +- Serialize password-change and recovery operations. A second request must be rejected or queued until the first transaction reaches `COMPLETE` or an explicitly recoverable terminal state. +- Make recovery verify the actual cryptographic state before mutating either controller. +- Keep the recovery transaction active until Keyring encryption-key synchronization and local persistence are confirmed. Do not clear the lifecycle marker early. + +#### External server/API dependency + +The controller changes reuse the existing Seedless/TOPRF operations. The following server/API behavior must be confirmed or added outside the controller lifecycle-state changes: + +- Idempotent password-change and Keyring-key synchronization requests. +- A transaction identifier that can be queried after an ambiguous response. +- An authoritative distinction between old, new, and partial remote state. +- Verification that the current Keyring encryption key is the one stored by the completed backup. + +If these capabilities are unavailable, the client must preserve `unknown` rather than infer that an error means “nothing changed.” + +### Client scope + +The extension and mobile client must implement the same recovery contract. The platform-specific location of each responsibility may differ, but the ordering, state transitions, cryptographic checks, and completion criteria must not. + +#### Client orchestration + +Each client must provide an orchestration layer that: + +- Replaces the local-first/rollback flow with the server-first lifecycle flow. +- Persists the lifecycle phase before and after each irreversible boundary. +- Uses one transaction identifier for the password change and all retries. +- Locks the wallet if any password-change or recovery step returns an error, including remote Seedless operations, local controller operations, key synchronization, verification, and persistence. +- Performs the lock before surfacing the error, navigating to an intermediary screen, or returning control to a normal wallet screen. +- Keeps the wallet locked when recovery cannot establish a consistent state. If the lock operation itself fails, the client must keep the wallet in a recovery-blocked UI and must not expose wallet access. +- Prevents a second password-change transaction from running concurrently. + +After remote commitment, the orchestrator must cryptographically classify the local Keyring: + +- **Old Keyring:** recover the stored Keyring encryption key with the new Seedless password, call `submitEncryptionKey`, re-encrypt locally, export the current key, and synchronize it. +- **New Keyring:** verify/unlock with the new password, export the current key, and synchronize it. + +The orchestrator must not infer the local state from the lifecycle marker, a rejected Promise, or a cached password-outdated result. + +#### Client unlock and recovery + +Each client must route an unfinished lifecycle through recovery before normal unlock failure handling: + +- Read the last durably persisted lifecycle state before classifying a password as invalid. +- Perform an authoritative Seedless server-state check for every unfinished state. +- Bypass or invalidate cached password-outdated results during recovery. +- Verify cryptographically whether the supplied new password unlocks the local Keyring. +- Select the old-Keyring or new-Keyring recovery branch based on verification. +- Retry already-completed operations safely after process termination or a lost response. +- Leave the state as `unknown` when the server result or local cryptographic state cannot be established. + +#### Client persistence + +Each client must provide a durable persistence boundary for lifecycle state: + +- Lifecycle transitions must have an explicit, awaitable durable-write path. +- The client must be able to read the last lifecycle state before normal unlock routing begins. +- `COMPLETE` must be written only after the synchronized Keyring encryption key and all required local controller state are durably persisted. +- A generic debounce may remain acceptable for unrelated state, but it cannot prove that password-change state is durable. +- Lifecycle state must contain only non-sensitive metadata and must never contain passwords, SRPs, raw Keyring encryption keys, or decrypted backup material. + +#### Client UI and user behavior + +Each client must provide UI behavior for `SEEDLESS_CHANGE_PENDING`, `SEEDLESS_COMMITTED`, `LOCAL_KEYRING_PENDING`, `KEY_SYNC_PENDING`, `COMPLETE`, and `UNKNOWN`: + +- Show a recovery-blocked state for every unfinished lifecycle state. +- Treat any password-change error as a locked-wallet state before showing an error modal, retry screen, or other intermediary UI. +- Ask for the new password after remote commitment is established. +- Do not ask for the old Keyring password when Seedless can provide the Keyring encryption key through `submitEncryptionKey`. +- Explain whether the remote change is unresolved, local Keyring recovery is in progress, or Keyring-key synchronization is pending. +- Preserve retryable recovery actions across app/browser restarts and backgrounding. +- Prevent a second password change while recovery is pending. +- Do not display raw server/controller errors or sensitive recovery data. +- Do not expose the wallet as fully recovered until key synchronization is verified and `COMPLETE` is durable. +- Keep reset wallet as an explicit last resort. It must not be triggered automatically for a recoverable partial state or used to hide an unresolved remote result. + +The client owns this lock/error boundary because it controls navigation and intermediary screens. This allows UX changes without changing the controller’s cryptographic responsibilities, while ensuring that no client-specific screen accidentally leaves a partially changed wallet unlocked. + +#### Client tests + +Each client must add unit, integration, and end-to-end coverage for: + +- Every lifecycle transition and durable-write boundary. +- Termination before and after remote commitment. +- Remote commitment with an old local Keyring. +- Remote commitment with an already-new local Keyring and failed key synchronization. +- Lost responses and idempotent retries. +- Stale/missing lifecycle state and stale password-outdated cache. +- Recovery UI, retry behavior, wallet locking, and explicit reset-wallet fallback. + +Each platform may map these tests to its own orchestration, persistence, authentication, navigation, and UI modules. The fault-injection scenarios and expected final states must remain equivalent. + +### Cross-client contract + +All clients must agree on: + +- Lifecycle state names and meanings. +- Which states require a server check before unlock. +- The two cryptographic recovery branches. +- Idempotency and transaction-identifier semantics. +- The definition of durable synchronization and the `COMPLETE` boundary. +- The meaning of `unknown` and the conditions under which reset wallet may be offered. +- The requirement that any password-change or recovery error locks the wallet before an error or intermediary screen is shown. + +Platform-specific UI can differ, but it must not change the recovery decision or silently treat an ambiguous state as a failed or completed password change. + +## Lifecycle state matrix + +This table defines what the user and UI should experience when the lifecycle state is encountered during unlock. The current server and local states are defined separately below so that recovery behavior is not confused with state observation. + +| Lifecycle state | Sync Server state check required before unlock? | User behaviors | UI requirements | +| --- | --- | --- | --- | +| `IDLE` | No. A server check may run as part of normal Seedless behavior, but it is not a recovery prerequisite. | Enter the current wallet password and continue normally. The user may start a new password change. | Show the normal locked or unlocked wallet UI. | +| `SEEDLESS_CHANGE_PENDING` | Yes. The remote request may not have started, may have failed before mutation, or may have committed with a lost response. | Do not assume which password is valid. If the server proves that the change did not commit, enter the old password. If it proves commitment, enter the new password. If the result remains ambiguous, `unknown`. Do not start another password change. | Show a password-change recovery screen. Do not report an entered password as an ordinary unlock failure while recovery is pending. Explain that the previous password change must be resolved first. | +| `SEEDLESS_COMMITTED` | Yes. Confirm the remote Seedless password and required backup/key-share changes. | Enter the new Seedless password. The user should not need the old Keyring password when the stored Keyring encryption key is recoverable from Seedless. | Keep wallet access behind a recovery screen. Explain that the remote change succeeded but local recovery still needs to finish. | +| `LOCAL_KEYRING_PENDING` | Yes. Confirm the remote new-password state before recovering the Keyring encryption key or synchronizing a local key. | Enter the new password. Allow recovery to determine cryptographically whether the local Keyring is old or new; do not ask the user to guess which state occurred. | Keep wallet access blocked until the local Keyring is reconciled and its current encryption key is synchronized. Show progress and retryable errors without clearing the recovery state. | +| `KEY_SYNC_PENDING` | Yes. The remote password is expected to be new, but the remote copy of the Keyring encryption key may be old, new, missing, or unknown. | Enter the new password, unlock the local Keyring, and allow the current Keyring encryption key to be exported and synchronized. Do not start another password change. | Show that the wallet password has changed but backup synchronization is incomplete. Do not expose the wallet as fully recovered until synchronization is verified. | +| `COMPLETE` | No additional recovery check is required. A defensive check may still run. | Enter the new password and unlock normally. | Show the normal wallet UI. | +| `UNKNOWN` | Yes, whenever a server status check or cryptographic verification may resolve the state. If it cannot, remain `unknown`. | unknown | Keep the wallet locked and show a recovery-blocked state. Do not silently retry a non-idempotent operation or claim that either password is authoritative. | + +## Server and local state matrix + +| Lifecycle state | Server State | Local State | Recovered server state | Recovered local state | +| --- | --- | --- | --- | --- | +| `IDLE` | Stable and synchronized. The password is the current password; no change is pending. | Local Keyring, local Seedless state, and the persisted Keyring encryption key are stable and synchronized. | No change. The server remains in its current stable state. | No change. The local state remains in its current stable state. | +| `SEEDLESS_CHANGE_PENDING` | `unknown` until authoritative server status resolves whether the remote password change and backup updates are old, new, or partial. | Normally old/old, but local state may already have changed if lifecycle persistence was delayed or lost. Verify the local Keyring and local Seedless state independently. | Definitively old: return to `IDLE` after durable cleanup. Definitively new: transition to `SEEDLESS_COMMITTED` or `LOCAL_KEYRING_PENDING`. Ambiguous: `unknown`. | Do not mutate until the server result is resolved. After remote commitment, cryptographically classify the local Keyring as old or new and follow the matching branch. | +| `SEEDLESS_COMMITTED` | New Seedless password and new remote backup/key-share state, confirmed through server verification. If this cannot be established, `unknown`. | Local Keyring may be old or new. Local Seedless state and the stored Keyring encryption key may be old, new, or not durably persisted. | Remains new and committed. | Transition to `LOCAL_KEYRING_PENDING`; cryptographically determine whether to recover the old local Keyring or synchronize the already-new local Keyring. | +| `LOCAL_KEYRING_PENDING` | Remote Seedless is new and committed. | Local Keyring state is unresolved: old with a recoverable stored encryption key, new with a locally exportable current key, or `unknown`. | Remains new and committed. | **Old Keyring:** recover the stored key with the new Seedless password, call `submitEncryptionKey`, re-encrypt locally, export the current key, and synchronize it. **New Keyring:** unlock with the new password, export the current key, and synchronize it. In both cases, verify and durably persist before `COMPLETE`. | +| `KEY_SYNC_PENDING` | New Seedless password and remote backup/key-share state. The synchronized Keyring encryption key is not confirmed. | Local Keyring uses the new password. The Seedless copy of its Keyring encryption key is stale, missing, or not durably confirmed. | New password with the current Keyring encryption key synchronized and verified. | Local Keyring, local Seedless state, synchronized key, and lifecycle marker are durably persisted. Only then transition to `COMPLETE`. | +| `COMPLETE` | New Seedless password, new remote backup/key-share state, and the current Keyring encryption key synchronized. | Local Keyring and local Seedless state use the new password. The current Keyring encryption key is durably persisted locally and in Seedless. | No change. The server remains new and synchronized. | No change. The local state remains new and synchronized. | +| `UNKNOWN` | `unknown`. The server may have accepted some, all, or none of the remote password-change or key-synchronization operations. | `unknown`. Local Keyring, local Seedless state, or durable lifecycle state may reflect different points in the operation. | unknown | unknown | + +## Failure and recovery rules + +### Definitive remote failure before mutation + +**Trigger** + +Seedless returns a definitive error and server status confirms that the remote password change did not commit. + +**Wallet state** + +Remote Seedless, local Seedless, and local Keyring remain old and synchronized. + +**Recovery plan** + +Lock the wallet if required, ask the user to unlock with the old password, and durably clear the pending lifecycle state. A later retry starts from `IDLE`. + +### Remote error after a possible mutation + +**Trigger** + +Timeout, connection reset, lost response, client crash, or server error while remote Seedless operations were in flight. + +**Wallet state** + +unknown + +**Recovery plan** + +unknown + +The implementation must not classify this as “server failed” solely from the rejected Promise. + +### Local Keyring failure after remote commitment + +**Trigger** + +`KeyringController:changePassword` throws, or the process terminates while local Keyring persistence is pending. + +**Wallet state** + +Remote Seedless is new. The local Keyring may be old, new in memory, new and durably persisted, or unknown. + +**Recovery plan** + +Cryptographically verify the local Keyring: + +- Old local Keyring: recover the stored Keyring encryption key with the new Seedless password, call `submitEncryptionKey`, re-encrypt locally, then synchronize the current key. +- New local Keyring: export the current Keyring encryption key and synchronize it to Seedless. +- Indeterminate result: unknown. + +### Key synchronization failure or lost response + +**Trigger** + +The local Keyring is new, but the synchronization request fails or returns an ambiguous result. + +**Wallet state** + +Remote Seedless is new. The remote synchronized Keyring encryption key is old, new, missing, or unknown. + +**Recovery plan** + +Keep `KEY_SYNC_PENDING`. Unlock with the new password, export the current Keyring encryption key, retry using the same transaction identity, and verify the remote result. Do not mark `COMPLETE` until synchronization and local persistence are durable. If the remote result cannot be verified, unknown. + +### Lifecycle persistence failure + +**Trigger** + +The logical state transition succeeds, but the state-change notification is queued, debounced, lost, or fails before reaching durable storage. + +**Wallet state** + +The durable lifecycle marker may lag behind the actual cryptographic state. A marker such as `SEEDLESS_CHANGE_PENDING` does not prove that remote or local later steps did not happen. + +**Recovery plan** + +Use the marker only to trigger broader verification. Check remote state and cryptographically verify the local Keyring. Repeat the appropriate recovery branch idempotently. Do not treat an in-memory update or queued persistence operation as durable. + +### Process, browser, app, or device termination + +**Trigger** + +Termination occurs before or after any remote request, local controller update, synchronization request, or lifecycle write. + +**Wallet state** + +The state is determined by the last durable evidence, but may differ from the last in-memory state. + +**Recovery plan** + +At next startup/unlock, inspect the lifecycle marker and then verify actual state. Use the old-Keyring recovery branch if the new password does not unlock the local Keyring; use the new-Keyring synchronization branch if it does. If remote commitment cannot be established, unknown. + +## Idempotency requirements + +The recovery operations must be safe across retries and lost responses. + +- Assign one transaction identifier to the entire password change and reuse it during recovery. +- Make the remote password change and key synchronization idempotent or queryable by transaction identifier. +- Verify the local Keyring before changing its password again. +- Treat storing the already-current Keyring encryption key as a deterministic no-op or safe overwrite. +- Do not perform a compensating rollback based solely on an error response. +- Serialize password-change and recovery requests so two transactions cannot update the same vault concurrently. +- Persist lifecycle transitions with an awaitable durability boundary. + +## Consequences + +### Positive + +- A committed remote Seedless change has a deterministic recovery direction: bring the local Keyring forward to the new password. +- The old-Keyring and new-Keyring post-commit states are both recoverable through the new password when the stored Keyring encryption key is available. +- In-process rollback is not required for normal partial-failure handling. +- Recovery remains possible even if the lifecycle update was lost, because cryptographic verification is authoritative for the local Keyring state. + +### Negative and residual risks + +- This is still a distributed transaction. Server-first ordering does not make the operation atomic. +- Ambiguous remote outcomes remain `unknown` unless the server supports authoritative status lookup and idempotency. +- Recovery adds a special unlock path and additional user-facing states. +- The remote server must support safe retries; otherwise a lost response can still create an unrecoverable ambiguity. +- If the stored Keyring encryption key cannot be recovered, the old-Keyring branch cannot proceed and the state remains `unknown`. +- Locking the wallet protects the vault but does not itself restore consistency; the recovery path must be available before normal unlock failure handling. +- Mobile and extension must implement compatible lifecycle and recovery semantics to avoid platform-dependent outcomes. + +## Acceptance criteria + +Fault-injection tests must terminate or fail the operation at every boundary: + +- Before the remote request. +- During every remote Seedless round trip. +- After remote commitment but before the response. +- Before and after local Seedless persistence. +- Before, during, and after local Keyring password change. +- Before, during, and after Keyring encryption-key synchronization. +- Before and after each lifecycle persistence write. +- Immediately before writing `COMPLETE`. + +After restart, each test must verify that: + +- The correct password is requested for the recovered state. +- The local Keyring can be unlocked with the new password after recovery. +- Seedless recovers the current Keyring encryption key. +- Retrying recovery produces the same final state. +- A lost response does not cause a second non-idempotent password change. +- `COMPLETE` is never durable before key synchronization and local persistence. +- An unresolved server result remains `unknown`. + +## Open questions + +The following are intentionally left as `unknown` until addressed separately: + +- How TOPRF/Seedless exposes authoritative status after an ambiguous request. +- Whether password changes and key synchronization support idempotency keys. +- Recovery when the remote result is `unknown`. +- Recovery when the stored Keyring encryption key is unavailable or corrupt. +- The exact durable-persistence guarantee available on each platform. +- The final lifecycle enum names and migration strategy. +- Retry limits, rate-limit behavior, and user-facing copy for recovery-blocked states. diff --git a/packages/seedless-onboarding-controller/docs/0002-seedless-password-change-implementation-plan.md b/packages/seedless-onboarding-controller/docs/0002-seedless-password-change-implementation-plan.md new file mode 100644 index 00000000000..48be559e9ff --- /dev/null +++ b/packages/seedless-onboarding-controller/docs/0002-seedless-password-change-implementation-plan.md @@ -0,0 +1,396 @@ +# Implementation plan: Seedless password-change recovery + +- Related ADR: [ADR 0001: Recovering server-first Seedless password changes](./0001-seedless-password-change-recovery.md) +- Related contract: [Phase 0 contract 0003](./0003-seedless-password-change-contracts.md) +- Status: Planned +- Scope: `SeedlessOnboardingController`, its messenger contract, package tests, and the client persistence/orchestration contract required by the controller + +## Progress tracker + +Update the checkboxes as work is completed. Keep the phase status aligned with its checklist: + +- `Not started` — no task in the phase is complete. +- `In progress` — at least one task is complete, but the phase checklist is not complete. +- `Blocked` — progress cannot continue until an open decision or external dependency is resolved. +- `Complete` — all tasks and verification items in the phase are complete. + +| Phase | Status | Remaining work | +| ---------------------------------- | ----------- | -------------------------------------------------------------- | +| 0. External prerequisites | Complete | — | +| 1. Lifecycle model | Complete | — | +| 2. Controller lifecycle operations | Not started | Add serialized lifecycle transitions and durable writes | +| 3. `changePassword` flow | Not started | Add server-first lifecycle boundaries | +| 4. Keyring-key storage | Not started | Couple encrypted-key storage to lifecycle persistence | +| 5. Recovery primitives | Not started | Preserve existing recovery methods and add recovery safeguards | +| 6. Messenger/package contracts | Not started | Update exports, action types, fixtures, and consumers | +| 7. Client integration | Not started | Add coordinator, locking, unlock routing, UI, and E2E coverage | + +At the end of each phase, update its status and remove completed items from the remaining-work description. Keep unresolved items in [Open decisions before implementation](#open-decisions-before-implementation). + +## Goal + +Make Seedless password changes recoverable after a crash, lost response, or partial local update. + +The controller should persist enough non-sensitive lifecycle information to tell the client that recovery is required. The client should then verify the actual remote and local state and finish the operation. The implementation must remain server-first. The password change is never retried; recovery reconciles local state using the existing password-sync flow. + +## Design summary + +Use one persisted lifecycle record: + +```ts +type SeedlessPasswordChangeLifecycle = { + phase: SeedlessPasswordChangePhase; + lastErrorCode?: string; +}; +``` + +The record must never contain a password, SRP, raw encryption key, decrypted vault data, or an error message that may contain sensitive data. + +Use the lifecycle as a recovery signal only. It is not proof that a remote or local operation completed. Recovery must always: + +1. Check authoritative remote state. +2. Verify the local Keyring cryptographically. +3. Reconcile local state using the existing password-sync flow (`submitGlobalPassword` + `syncLatestGlobalPassword`); never retry `changePassword` or `changeEncKey`. +4. Keep the wallet locked if the state cannot be established. + +**No retries, no concurrency.** A password-change operation must never be retried as a fresh `changePassword`/`changeEncKey` call while the previous outcome is unresolved. Race conditions here are dangerous and could block users from their wallets. The existing controller mutex (`#withControllerLock` / `#controllerOperationMutex`) already serializes all mutable controller operations; the client coordinator must add a single lock that also covers the KeyringController operation. The lifecycle exists to signal that recovery is needed — not to enable retries. + +The lifecycle phases are the names from the ADR: + +`IDLE`, `SEEDLESS_CHANGE_PENDING`, `SEEDLESS_COMMITTED`, `LOCAL_KEYRING_PENDING`, `KEY_SYNC_PENDING`, `COMPLETE`, and `UNKNOWN`. + +## Current implementation and gaps + +The plan should reuse these existing capabilities: + +- `changePassword` already verifies the old Seedless vault password, calls `#assertPasswordInSync({ skipCache: true })`, calls `#changeEncryptionKey`, rewrites the local Seedless vault with `#createNewVaultWithAuthData`, and stores the existing Keyring encryption key with `storeKeyringEncryptionKey`. +- `#executeWithTokenRefresh` already handles the controller’s token-refresh retry path. +- `#withControllerLock` already serializes mutable controller operations. +- `loadKeyringEncryptionKey` and `storeKeyringEncryptionKey` already decrypt and encrypt the Keyring encryption key using the current Seedless password encryption key. +- `submitGlobalPassword` and `syncLatestGlobalPassword` already recover and persist the latest global password locally. This is the **existing password-sync flow** that handles “remote changed, local is outdated” (e.g. another device changed the password). It is reused as the recovery mechanism for a partially committed password change. +- `checkIsPasswordOutdated({ skipCache: true })` already provides a cache-bypassed auth-public-key comparison via `toprfClient.fetchAuthPubKey`. This is the authoritative old-vs-new check. +- `verifyVaultPassword`, `#unlockVaultAndGetVaultData`, and `#updateVault` already provide local Seedless vault verification and rewriting. +- `serializeVaultData`, `deserializeVaultData`, and the existing AES helpers should continue to be used for vault/key handling. + +The gaps that the implementation must address are: + +- `changePassword` has no lifecycle transitions. +- `storeKeyringEncryptionKey` currently updates controller state but does not prove that the state reached durable storage. +- `BaseController.update` is synchronous. It publishes `stateChanged`, but the controller cannot currently await a client’s storage write. +- The current controller mutex protects controller operations, but it does not serialize a client’s KeyringController operation with the controller operation. + +## Proposed public contract + +Keep the existing methods where possible. Add only the lifecycle information needed to make recovery safe. + +### State and constants + +1. Add `SeedlessPasswordChangePhase` to `src/constants.ts`. + + - Use string enum values matching the ADR exactly. + - Add no sensitive values to the enum. + +2. Add `SeedlessPasswordChangeLifecycle` to `src/types.ts`. + + - Make the lifecycle state optional so old persisted state without the field is treated as `IDLE`. + +3. Add `passwordChangeLifecycle?: SeedlessPasswordChangeLifecycle` to `SeedlessOnboardingControllerState`. + +4. Add metadata for `passwordChangeLifecycle` in `seedlessOnboardingMetadata`. + + - Set `persist: true`. + - Keep state logs and debug snapshots limited to safe fields, or exclude the field if the platform does not need it there. + - Do not expose raw error objects through state. + +5. Export the phase and lifecycle types through `src/index.ts`. + +### Lifecycle helpers + +Add small, pure helpers rather than spreading phase mutations through the controller: + +1. Define a helper for creating a new lifecycle record. +2. Define a helper for applying a phase transition. +3. Define a helper for classifying errors into a non-sensitive error code. +4. Define a helper for treating missing lifecycle state as `IDLE`. +5. Validate legal transitions in tests. Do not make the transition validator the source of truth for recovery; a persisted phase may be stale. + +These helpers can live in `src/utils.ts` if they remain general and pure. Keep controller-specific transition behavior in private controller methods. + +### Transaction identifier + +Out of scope for this plan. The Seedless/TOPRF server does not accept an idempotency key or transaction ID today, and adding one is not a simple server-side change. Recovery here does not retry the password change — it uses the existing password-sync flow (`checkIsPasswordOutdated` + `submitGlobalPassword` + `syncLatestGlobalPassword`) to reconcile local state once remote state is established. A transaction ID remains a “good to have” for a future TOPRF release; until then, ambiguous remote results stay `UNKNOWN`. + +### Durable lifecycle persistence + +The existing `stateChanged` event remains the notification mechanism, but a normal state update is not a durability acknowledgement. + +**Phase 0 decision:** use a narrow awaitable persistence hook on the controller options (`persistPasswordChangeLifecycle`), used only for lifecycle boundaries. Clients implement the hook with a non-debounced durable write. See [0003](./0003-seedless-password-change-contracts.md). + +The hook must provide: + +- an awaitable write before the first remote mutation; +- an awaitable write after each irreversible boundary; +- an awaitable write for `COMPLETE`; +- a read of the last durable lifecycle before normal unlock error handling; +- surfaced write failures, so the client can lock the wallet and keep recovery active. + +Do not claim that `this.update(...)` alone satisfies this contract. Do not use the generic debounced persistence path as the only completion boundary. + +## Development phases + +### Phase 0: Confirm external prerequisites + +Complete these checks before changing controller behavior: + +- [x] Confirm how Seedless reports the result of a password-change request after a timeout or lost response. +- [x] Confirm whether the password-change request accepts an idempotency key or transaction ID. +- [x] Confirm whether Keyring encryption-key synchronization is a Seedless/TOPRF API, a client persistence operation, or both. +- [x] Define how the remote service reports old, new, partial, and unknown state. +- [x] Define the durable persistence hook for extension and mobile. +- [x] Define how the client reads the lifecycle before attempting normal unlock. + +Deliverable: [0003-seedless-password-change-contracts.md](./0003-seedless-password-change-contracts.md). Authoritative remote status is unavailable in `@metamask/toprf-secure-backup@1.1.0`; lost-response and partial backup paths remain `UNKNOWN` until TOPRF adds that API. + +### Phase 1: Add the lifecycle model + +- [x] Add the phase enum and lifecycle type. +- [x] Add the optional state field and metadata. +- [x] Treat missing state as `IDLE` for backward compatibility. +- [x] Add pure lifecycle helpers and legal-transition tests. +- [x] Add exports and messenger type visibility where required. +- [x] Update the test fixture helpers so lifecycle state can be supplied and inspected. + +Verify: + +- [x] Lifecycle values are persisted. +- [x] Sensitive fields are not included in the lifecycle. +- [x] Old state fixtures still construct successfully. +- [x] Default state behavior remains unchanged except for the new optional field. + +### Phase 2: Add controller lifecycle operations + +Add private methods with names that describe the boundary, for example: + +- `#startPasswordChangeLifecycle` +- `#advancePasswordChangeLifecycle` +- `#markPasswordChangeUnknown` +- `#completePasswordChangeLifecycle` +- `#clearPasswordChangeLifecycle` + +Implement them in this order: + +- [ ] Create the lifecycle before the first remote mutation with `SEEDLESS_CHANGE_PENDING`. +- [ ] Preserve the lifecycle when any later operation throws. +- [ ] Mark `UNKNOWN` only when the controller cannot safely classify the result; do not reset to `IDLE` on every error. +- [ ] Make clearing the lifecycle an explicit operation after definitive remote failure or durable `COMPLETE`. +- [ ] Keep all transitions serialized under `#withControllerLock`. +- [ ] Route durable lifecycle writes through the persistence contract selected in Phase 0. + +Do not add a second mutex unless the existing controller mutex cannot protect the lifecycle update. The client must use its own coordinator lock for the cross-controller transaction. + +### Phase 3: Refactor `changePassword` around explicit boundaries + +Refactor the current method without duplicating its cryptographic work: + +- [ ] Acquire the existing controller lock. +- [ ] Reject a second concurrent password change; recovery must finish before a new one starts. +- [ ] Create/persist the lifecycle as `SEEDLESS_CHANGE_PENDING`. +- [ ] Reuse `verifyVaultPassword(oldPassword, { skipLock: true })`. +- [ ] Reuse `#assertPasswordInSync({ skipCache: true, skipLock: true })`. +- [ ] Reuse `loadKeyringEncryptionKey()` before the remote mutation when an encrypted Keyring key exists. +- [ ] Call `#changeEncryptionKey` through the existing `#executeWithTokenRefresh` wrapper. +- [ ] After authoritative remote commitment, persist `SEEDLESS_COMMITTED`. +- [ ] Reuse `#createNewVaultWithAuthData` to write the new local Seedless vault. +- [ ] Persist `LOCAL_KEYRING_PENDING` after local Seedless state has been updated. +- [ ] Reuse `storeKeyringEncryptionKey` for the encrypted local copy of the current Keyring key. +- [ ] Leave final Keyring re-encryption, local Keyring-key storage, and `COMPLETE` to the client coordinator. +- [ ] Preserve the existing error wrapping with `SeedlessOnboardingError`, but retain the last lifecycle phase when wrapping the error. +- [ ] Reset the password-outdated cache only after the local Seedless password update succeeds, using the existing `#resetPasswordOutdatedCache`. + +Important: a rejected Promise from `#changeEncryptionKey` does not prove that the server did not mutate. Only a definitive server result may return the lifecycle to `IDLE`. + +### Phase 4: Make Keyring-key storage lifecycle-aware + +Update `storeKeyringEncryptionKey` and its private helper with minimal behavior changes: + +- [ ] Keep the current `#unlockVaultAndGetVaultData` call to obtain the Seedless password encryption key. +- [ ] Keep the current AES-GCM encryption and base64 encoding. +- [ ] Update `encryptedKeyringEncryptionKey` and the lifecycle boundary in the same controller update where possible, so observers do not see an unrelated intermediate lifecycle state. +- [ ] Await the selected durable persistence boundary after the encrypted key is stored. +- [ ] Allow the client to mark `KEY_SYNC_PENDING` before synchronization and `COMPLETE` only after synchronization verification and all local writes succeed. +- [ ] Never let `storeKeyringEncryptionKey` mark `COMPLETE` by itself. +- [ ] Keep `loadKeyringEncryptionKey` read-only with respect to lifecycle state; loading a key is not proof of recovery completion. + +### Phase 5: Add recovery-facing controller behavior + +Keep cross-controller orchestration in the client, but make the controller primitives safe and explicit: + +- [ ] `submitGlobalPassword({ globalPassword })` remains the entry point to recover the Seedless controller with the new password. +- [ ] `syncLatestGlobalPassword({ globalPassword })` remains the operation that rewrites the local Seedless vault after recovery. +- [ ] `loadKeyringEncryptionKey()` remains the old-Keyring recovery input. +- [ ] `storeKeyringEncryptionKey()` remains the local encrypted-key persistence operation. +- [ ] `checkIsPasswordOutdated({ skipCache: true })` must be used during recovery whenever the client needs a fresh auth-public-key comparison. +- [ ] Do not silently use a cached `passwordOutdatedCache` result on the recovery path. +- [ ] Preserve `#executeWithTokenRefresh` behavior for all existing password-sync operations. +- [ ] Ensure controller lock state is cleaned up correctly when recovery operations fail. + +The client coordinator then performs the two ADR branches: + +#### Old local Keyring + +- [ ] Confirm remote Seedless is new via `checkIsPasswordOutdated({ skipCache: true })`. +- [ ] Recover the Seedless controller with the new password via `submitGlobalPassword({ globalPassword })` (walks the server password-key history chain). +- [ ] Rewrite the local Seedless vault via `syncLatestGlobalPassword({ globalPassword })`. +- [ ] Load the stored Keyring encryption key via `loadKeyringEncryptionKey()`. +- [ ] Call `KeyringController:submitEncryptionKey`. +- [ ] Call `KeyringController:changePassword(newPassword)`. +- [ ] Export the current Keyring encryption key. +- [ ] Store it locally via `storeKeyringEncryptionKey`. +- [ ] Verify durable local state. +- [ ] Mark `COMPLETE`. + +#### New local Keyring + +- [ ] Confirm remote Seedless is new via `checkIsPasswordOutdated({ skipCache: true })`. +- [ ] Recover the Seedless controller with the new password via `submitGlobalPassword({ globalPassword })`. +- [ ] Rewrite the local Seedless vault via `syncLatestGlobalPassword({ globalPassword })`. +- [ ] Verify/unlock the local Keyring with the new password. +- [ ] Export the current Keyring encryption key. +- [ ] Store it locally via `storeKeyringEncryptionKey`. +- [ ] Verify durable local state. +- [ ] Mark `COMPLETE`. + +If local cryptographic verification or remote status cannot establish the branch, mark `UNKNOWN` and keep the wallet locked. + +### Phase 6: Update messenger and package contracts + +- [ ] Update `src/SeedlessOnboardingController-method-action-types.ts` documentation and types for the lifecycle-aware `changePassword` behavior. +- [ ] Export the new lifecycle types and enum from `src/index.ts`. +- [ ] Check all generated/action type references compile without manually editing generated output beyond the source-of-truth file. +- [ ] Update package consumers and mock messengers that call `changePassword`. +- [ ] Preserve the existing `changePassword` signature and behavior for callers that do not opt into lifecycle-aware recovery. + +### Phase 7: Implement client integration + +This work is outside the controller package but is required for the ADR to be complete: + +- [ ] Add a single coordinator lock covering Seedless and Keyring password changes. The controller mutex already serializes controller operations; this lock extends serialization to the cross-controller transaction. +- [ ] Persist `SEEDLESS_CHANGE_PENDING` before the first remote mutation. +- [ ] Lock the wallet before exposing any password-change or recovery error. +- [ ] On unlock, inspect the durable lifecycle before normal invalid-password handling. +- [ ] For every unfinished phase, bypass stale password-outdated cache and query remote state via `checkIsPasswordOutdated({ skipCache: true })`. +- [ ] Use `KeyringController:verifyPassword` to classify old versus new local Keyring state. +- [ ] Use the old-Keyring or new-Keyring branch above (existing `submitGlobalPassword` + `syncLatestGlobalPassword` flow). +- [ ] Never retry `changePassword` or `changeEncKey`; reconcile only via the existing password-sync flow. +- [ ] Keep the wallet locked and the phase `UNKNOWN` if the result is not distinguishable. +- [ ] Persist `COMPLETE` only after local persistence is verified. +- [ ] Offer reset wallet only as an explicit last resort for a confirmed unrecoverable state. + +## Test plan + +### Controller unit tests + +Extend `src/SeedlessOnboardingController.test.ts` and add focused tests for: + +- [ ] Default and legacy state handling. +- [ ] Lifecycle metadata persistence flags. +- [ ] Valid and invalid phase transitions. +- [ ] `changePassword` writing each expected phase. +- [ ] Lifecycle preservation when old-password verification fails. +- [ ] Lifecycle preservation when `#changeEncryptionKey` rejects. +- [ ] Lifecycle preservation when local vault rewriting rejects. +- [ ] Lifecycle preservation when `storeKeyringEncryptionKey` rejects. +- [ ] Definitive remote failure returning to `IDLE` only after authoritative confirmation. +- [ ] Ambiguous remote failure becoming `UNKNOWN`. +- [ ] Keyring-key storage and lifecycle update ordering. +- [ ] Durable persistence failures being surfaced to the caller. +- [ ] Repeated lifecycle transitions being safe to re-run. +- [ ] Existing token-refresh retry behavior remaining unchanged. +- [ ] Existing `loadKeyringEncryptionKey` and `storeKeyringEncryptionKey` behavior remaining compatible. + +Use the existing fixtures and mocks in `tests/__fixtures__` and `tests/mocks`. Add only the remote-status mocks that the new contract requires. + +### Coordinator/integration tests + +Add tests in each client for: + +- termination before the remote request; +- termination during the remote request; +- remote commitment with an old local Keyring; +- remote commitment with an already-new local Keyring; +- failure during local Seedless persistence; +- failure during local Keyring password change; +- failure during Keyring-key storage; +- lost responses; +- stale or missing lifecycle state; +- stale password-outdated cache; +- persistence failure before `COMPLETE`; +- wallet locking before error UI or recovery UI is shown; +- recovery remaining blocked when the lock operation fails. + +For every fault-injection test, verify both the durable lifecycle and the actual cryptographic/server state after restart. + +## Acceptance checklist + +The implementation is ready when: + +- [ ] A lifecycle marker is durable before the first remote mutation. +- [ ] A remote timeout is never classified as a definitive remote failure without an authoritative status result. +- [ ] The old-Keyring branch can recover through the stored Keyring encryption key without asking for the old Keyring password. +- [ ] The new-Keyring branch can export and store the current Keyring encryption key locally. +- [ ] `COMPLETE` cannot be written before local persistence is durable. +- [ ] Recovery bypasses stale password-outdated cache results. +- [ ] Any password-change or recovery error locks the wallet before error/intermediary UI is exposed. +- [ ] An unresolved server or cryptographic result remains `UNKNOWN`. +- [ ] A second password change cannot run concurrently. +- [ ] `changePassword` / `changeEncKey` is never retried; recovery uses the existing password-sync flow. +- [ ] Existing controller tests, lint, type checks, and changelog validation pass. + +## Suggested implementation order + +- [x] Confirm the remote status and durable persistence contracts. +- [ ] Add lifecycle types, constants, metadata, helpers, and unit tests. +- [ ] Add the persistence boundary and test its failure behavior. +- [ ] Add lifecycle transitions to `changePassword`. +- [ ] Make `storeKeyringEncryptionKey` lifecycle-aware. +- [ ] Update messenger exports and package consumers. +- [ ] Implement client recovery orchestration and locking. +- [ ] Add fault-injection integration tests. +- [ ] Run focused package tests, then lint/type checks and changelog validation. + +## Files expected to change + +### This package + +- `src/constants.ts` — lifecycle phase enum. +- `src/types.ts` — lifecycle record and state field. +- `src/utils.ts` — pure lifecycle helpers, if needed. +- `src/SeedlessOnboardingController.ts` — metadata, transition helpers, lifecycle-aware `changePassword`, and lifecycle-aware key storage. +- `src/SeedlessOnboardingController-method-action-types.ts` — public action documentation/signature. +- `src/index.ts` — public exports. +- `src/SeedlessOnboardingController.test.ts` — unit and fault-injection coverage. +- `docs/0003-seedless-password-change-contracts.md` — Phase 0 shared contract. +- `tests/__fixtures__/*` and `tests/mocks/*` — lifecycle, status, and persistence fixtures as needed. + +### Outside this package + +- Client password-change coordinator and unlock/recovery routing. +- KeyringController integration for `verifyPassword`, `submitEncryptionKey`, `changePassword`, and `exportEncryptionKey`. +- Durable storage adapter or persistence hook implementation. +- Seedless/TOPRF API support for authoritative status (future: idempotent retries keyed by transaction ID). +- Client UI and end-to-end tests. + +## Open decisions before implementation + +Resolved in [0003](./0003-seedless-password-change-contracts.md): + +- [x] Decide which layer owns the awaitable durable persistence hook. Controller option `persistPasswordChangeLifecycle`; clients supply the durable write. +- [x] Define what exact remote API confirms password-change and Keyring-key synchronization status. Today: `fetchAuthPubKey` plus cryptographic recover. No transaction-status API. Local Keyring-key proof is `storeKeyringEncryptionKey` durability only. +- [x] Confirm whether `transactionId` is accepted by the current Seedless/TOPRF API, or whether server work must land first. Not accepted, and out of scope for this plan. Recovery uses `fetchAuthPubKey` comparison and cryptographic verification instead. +- [x] Define what the remote service returns for a partial backup/key-share update. Nothing; classify as `UNKNOWN`. +- [x] Decide whether the lifecycle record is visible to UI state or only to the client coordinator through the messenger. Persisted controller state; coordinator reads `getState` before unlock; `usedInUi: true` for safe fields only. + +Still open: + +- [ ] Define rate-limit behavior for recovery. +- [ ] Define migration behavior for persisted state created before this field existed. Phase 1 treats a missing field as `IDLE`; confirm whether clients need an explicit migration version bump. diff --git a/packages/seedless-onboarding-controller/docs/0003-seedless-password-change-contracts.md b/packages/seedless-onboarding-controller/docs/0003-seedless-password-change-contracts.md new file mode 100644 index 00000000000..f56654dc47e --- /dev/null +++ b/packages/seedless-onboarding-controller/docs/0003-seedless-password-change-contracts.md @@ -0,0 +1,189 @@ +# Phase 0 contract: Seedless password-change recovery + +- Related ADR: [ADR 0001](./0001-seedless-password-change-recovery.md) +- Related plan: [Implementation plan 0002](./0002-seedless-password-change-implementation-plan.md) +- Status: Accepted for controller implementation, with documented `UNKNOWN` gaps +- Date: 2026-09-08 +- Scope: contracts shared by `SeedlessOnboardingController`, clients (extension and mobile), and the Seedless/TOPRF API layer + +This document is the Phase 0 deliverable. Later phases must follow these contracts. They must not treat `this.update(...)` or a rejected remote Promise as proof of remote or durable state. + +## Confirmed current APIs (`@metamask/toprf-secure-backup@1.1.0`) + +These are the APIs this package already calls. None of them accept a transaction identifier or idempotency key. + +| Operation | Current API | What a success proves | What a rejection proves | +| --- | --- | --- | --- | +| Remote password / key-share change | `toprfClient.changeEncKey` | The SDK reported that key shares and re-encrypted secret metadata were updated. | Nothing about whether nodes or metadata already mutated. Timeout, disconnect, and many server errors are ambiguous. | +| Current remote auth public key | `toprfClient.fetchAuthPubKey` | Returns `{ authPubKey, keyIndex }` for the current remote authentication public key. | Fetch failure. It is not a transaction-status API. | +| Local password vs remote | `checkIsPasswordOutdated({ skipCache: true })` | Local `authPubKey` equals or differs from the fetched remote `authPubKey`. | Fetch failure. Cached results must not be used on recovery. | +| Recover with a candidate password | `recoverEncKey` / `submitGlobalPassword` | The candidate password can derive the current remote encryption material. | The candidate is wrong, rate-limited, or the request failed. A failure is not proof that a concurrent change did not commit. | +| OPRF key-share persist | `toprfClient.persistLocalKey` | Used for first-time key setup and related persist paths, not as a password-change status query. | Ambiguous unless the error is a known pre-mutation client error. | +| Local Keyring encryption-key copy | `storeKeyringEncryptionKey` / `loadKeyringEncryptionKey` | Controller state holds an AES-GCM copy of the Keyring encryption key, encrypted under the current Seedless password encryption key. | Local encrypt/state-update failure only. This is not a remote write. | + +`changeEncKey` parameters today: `nodeAuthTokens`, `authConnectionId`, `groupedAuthConnectionId`, `userId`, `oldEncKey`, `oldPwEncKey`, `oldAuthKeyPair`, `newKeyShareIndex`, `newPassword` or `pregeneratedOprfKey`, and optional `transformDataItems`. There is no `transactionId`, `idempotencyKey`, or status-query field. + +`EncAccountDataType` today is `PrimarySrp`, `ImportedSrp`, and `ImportedPrivateKey`. There is no typed remote item for a Keyring encryption key. + +## Remote result after timeout or lost response + +**Contract:** a lost, timed-out, or otherwise incomplete `changeEncKey` response is **not** a definitive remote failure. + +Recovery must then: + +1. Call `fetchAuthPubKey` with no password-outdated cache (`skipCache: true`). +2. Compare the remote `authPubKey` with the last durable local `authPubKey`. +3. Optionally confirm a candidate password with `recoverEncKey` / `submitGlobalPassword` when the user supplies one. + +Classification after that check: + +| Observation | Remote classification | Lifecycle effect | +| --- | --- | --- | +| Fetch succeeds and remote `authPubKey` equals the pre-change local `authPubKey`. | **Old** | Safe to treat as uncommitted. Clear the lifecycle to `IDLE` only after this check. | +| Fetch succeeds and remote `authPubKey` equals the expected post-change public key, or the new password recovers remote material. | **New** | Treat as committed. Advance to `SEEDLESS_COMMITTED` or later recovery. | +| Fetch fails, comparison is impossible, or local `authPubKey` is missing/stale so old vs new cannot be distinguished. | **Unknown** | Persist `UNKNOWN`. Keep the wallet locked. Do not retry `changeEncKey` as if it were a fresh change. | +| Metadata/key shares may have updated without a matching auth-public-key change, or only some nodes/items updated. | **Partial / unknown** | There is **no** API that reports partial backup or key-share state. Classify as **Unknown**. | + +Do not infer “nothing changed” from `FailedToChangePassword` or from a rejected `#changeEncryptionKey` Promise. + +Until TOPRF exposes an authoritative transaction-status API, the `SEEDLESS_CHANGE_PENDING` lost-response path remains `UNKNOWN` whenever step 1 fails or step 2 cannot distinguish old from new. + +## No retries, no concurrency + +**Contract:** a password-change operation must never be retried as a fresh `changePassword` / `changeEncKey` call. Race conditions here are dangerous and could block users from their wallets. + +The existing controller mutex (`#withControllerLock` / `#controllerOperationMutex`) already serializes all mutable controller operations. The client coordinator adds a single lock that also covers the KeyringController operation. The lifecycle is a **recovery signal**, not a retry-enabler. + +Recovery rules: + +- Do **not** call `changeEncKey` again while remote classification is unknown. +- After remote classification is **old** (server did not commit), clear the lifecycle to `IDLE`. A later password change is a fresh operation, not a retry. +- After remote classification is **new** (server committed), do not call `changeEncKey` again. Reconcile local state using the existing password-sync flow: `submitGlobalPassword` (unlock via server password-key history chain) → `syncLatestGlobalPassword` (rewrite local vault) → `loadKeyringEncryptionKey` / `storeKeyringEncryptionKey`. +- `storeKeyringEncryptionKey` of the already-current key is a local overwrite and is safe to re-run. + +A transaction ID / idempotency key is out of scope — the TOPRF server does not accept one today, and adding it is not a simple server-side change. It remains a “good to have” for a future TOPRF release. + +## Keyring encryption-key storage and recovery + +The Keyring encryption key is stored **locally** in controller state (`encryptedKeyringEncryptionKey`), encrypted under the current Seedless password encryption key. There is no separate remote TOPRF API for it, and none is needed for this plan. + +- `storeKeyringEncryptionKey` encrypts the current Keyring encryption key under the current Seedless password encryption key and writes `encryptedKeyringEncryptionKey` on controller state. +- `loadKeyringEncryptionKey` is read-only with respect to lifecycle. Loading a key does not complete recovery. +- `storeKeyringEncryptionKey` must never mark `COMPLETE`. + +Recovery reuses the existing password-sync flow, which already handles “remote changed, local is outdated”: + +1. `checkIsPasswordOutdated({ skipCache: true })` — fetches remote `authPubKey`, compares with local. Classifies old vs new. +2. `submitGlobalPassword({ globalPassword })` — calls `toprfClient.recoverPwEncKey`, which walks the server-side password-key history chain (`maxPwChainLength`) to find the `pwEncKey` matching this device’s `authPubKey`, then unlocks the vault with the new password. +3. `syncLatestGlobalPassword({ globalPassword })` — rewrites the local Seedless vault with the new password’s keys. +4. `loadKeyringEncryptionKey()` (old-Keyring branch) or `storeKeyringEncryptionKey(currentKey)` (new-Keyring branch) — recover or persist the Keyring encryption key locally. + +`COMPLETE` in this contract means: remote Seedless password is new, local Seedless vault is new, local Keyring uses the new password, and the current Keyring encryption key is durably stored via `storeKeyringEncryptionKey`. + +## Durable persistence hook (extension and mobile) + +**Decision:** use the plan’s preferred approach — a narrow awaitable persistence hook on the controller, used only at lifecycle boundaries. Do not rely on generic debounced `stateChanged` persistence as the completion boundary. + +### Hook + +```ts +type PersistPasswordChangeLifecycle = (input: { + lifecycle: SeedlessPasswordChangeLifecycle | undefined; + state: SeedlessOnboardingControllerState; +}) => Promise; +``` + +Constructor option: `persistPasswordChangeLifecycle?: PersistPasswordChangeLifecycle`. + +Semantics: + +1. The controller updates in-memory state first (`this.update`), then **awaits** this hook before treating the boundary as durable. +2. The hook must return only after the lifecycle (and any adjacent fields written in the same update, such as `encryptedKeyringEncryptionKey`) is written to the platform’s durable store. +3. Hook rejection is a persistence failure. Surface it to the caller. The client must lock the wallet and keep recovery active. Do not classify the remote password change from this failure. +4. If the hook is omitted, later phases that persist lifecycle **fail closed** before the first remote mutation. Unit tests inject a resolving or rejecting mock. +5. `undefined` lifecycle means the durable record was cleared (`IDLE`). + +Required await points: + +- before the first remote mutation (`SEEDLESS_CHANGE_PENDING`); +- after authoritative remote commitment (`SEEDLESS_COMMITTED`); +- after local Seedless vault rewrite (`LOCAL_KEYRING_PENDING`); +- after local Keyring-key storage when that update is coupled to a lifecycle write; +- after `UNKNOWN`; +- after `COMPLETE`; +- after explicit clear to `IDLE`. + +### Platform adapters + +| Client | Durable write | Unlock-time read | +| --- | --- | --- | +| Extension | Await the persisted `SeedlessOnboardingController` slice in `chrome.storage` / the client persist pipeline. Bypass debounce for this write. | Read the persisted slice during background/start hydration, before password-unlock error handling. | +| Mobile | Await the filesystem / redux-persist (or equivalent) write for the same slice. Bypass debounce for this write. | Read the rehydrated slice at app start, before treating unlock as a normal invalid-password failure. | + +The generic ComposableController / redux persist debounce may remain for unrelated state. It must not be the only write that `COMPLETE` waits on. + +## Unlock-time lifecycle read + +**Decision:** the lifecycle is persisted controller state. Clients read it through `SeedlessOnboardingController:getState` (or the already-hydrated persisted snapshot) **before** normal Keyring invalid-password handling. Recovery UI may observe safe fields; the coordinator, not the UI, decides the recovery branch. + +Metadata for `passwordChangeLifecycle` (Phase 1): + +- `persist: true` +- `usedInUi: true` so recovery screens can show phase, without exposing secrets +- `includeInDebugSnapshot: false` +- `includeInStateLogs: true` only for non-sensitive fields (`phase`, `lastErrorCode`) + +Missing persisted field ⇒ `IDLE`. + +Unlock routing: + +1. Hydrate durable controller state. +2. Read `passwordChangeLifecycle`; treat missing as `IDLE`. +3. If phase is `IDLE` or `COMPLETE` (or `COMPLETE` already cleared to `IDLE`): continue normal unlock. +4. Otherwise: recovery-blocked path. Do not report the entered password as an ordinary Keyring unlock failure while recovery is pending. +5. For every unfinished phase, bypass `passwordOutdatedCache` and fetch remote `authPubKey`. +6. Classify the local Keyring with `KeyringController:verifyPassword` (new vs old). Do not infer that from the lifecycle phase. +7. If remote or local classification cannot be established, persist `UNKNOWN` and keep the wallet locked. + +`COMPLETE` is not a second source of cryptographic truth. After a durable `COMPLETE`, the controller should clear to `IDLE` so the next unlock is normal. + +## Error codes stored on the lifecycle + +`lastErrorCode` must be a closed, non-sensitive set. Do not persist error messages, passwords, or server bodies. + +Suggested codes for later phases: + +- `REMOTE_TIMEOUT` +- `REMOTE_AMBIGUOUS` +- `REMOTE_DEFINITIVE_FAILURE` +- `REMOTE_STATUS_UNAVAILABLE` +- `LOCAL_VAULT_FAILURE` +- `LOCAL_KEYRING_FAILURE` +- `KEY_STORE_FAILURE` +- `PERSISTENCE_FAILURE` + +## Implications for later phases + +- Phase 1–2 may add lifecycle types and the persistence hook without calling new TOPRF methods. +- Phase 3 must mark `UNKNOWN` on ambiguous `changeEncKey` failures and must not reset to `IDLE` without an **old** remote classification. It must never retry `changeEncKey`. +- Phase 4 couples local Keyring-key storage to the hook; no remote key-sync API is needed. +- Phase 5 reuses `submitGlobalPassword` and `syncLatestGlobalPassword` as the recovery mechanism. +- Phase 7 clients must implement the hook and the unlock-time read. They must not start a second password change while the lifecycle is unfinished, and must never retry `changePassword` / `changeEncKey`. + +## Existing TOPRF endpoints used by recovery + +The TOPRF server already exposes the endpoints this recovery model needs. No new server-side API is required for this plan: + +- `fetchAuthPubKey` — returns the current remote auth public key and key index. Used by `checkIsPasswordOutdated({ skipCache: true })` to classify old vs new after a lost response. +- `recoverPwEncKey` — walks the server-side password-key history chain (`maxPwChainLength`) to find the `pwEncKey` matching this device’s `authPubKey`. Used by `submitGlobalPassword` to unlock with the new password from a device still on the old auth key. +- `recoverEncKey` — derives encryption material from a candidate password. Used by `syncLatestGlobalPassword` to rewrite the local vault. + +The only residual risk is a network failure during the `fetchAuthPubKey` status check itself. In that case the result stays `UNKNOWN` and the wallet remains locked — this is not a missing API, just a network failure. + +## Future server/API work (optional, not required by this plan) + +These would improve recovery but are not required. The current plan works without them: + +1. Authoritative password-change status after a lost response (would let recovery distinguish committed vs uncommitted without relying on `authPubKey` comparison). +2. Idempotent `changeEncKey` keyed by a transaction ID (would make retries safe, but this plan does not retry). +3. Explicit partial-state reporting for backup and key-share updates (would reduce `UNKNOWN` outcomes). diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 3ff1b8651a8..b800acb9447 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -81,7 +81,10 @@ import type { SeedlessOnboardingControllerMessenger, SeedlessOnboardingControllerOptions, } from './SeedlessOnboardingController.js'; -import type { SeedlessOnboardingControllerState } from './types.js'; +import type { + SeedlessOnboardingControllerState, + SeedlessPasswordChangeLifecycle, +} from './types.js'; const authConnection = AuthConnection.Google; const socialLoginEmail = 'user-test@gmail.com'; @@ -643,6 +646,7 @@ async function decryptVault( * @param options.encryptedSeedlessEncryptionKey - The mock encrypted seedless encryption key. * @param options.pendingToBeRevokedTokens - The mock pending to be revoked tokens. * @param options.migrationVersion - The mock migration version. + * @param options.passwordChangeLifecycle - The mock password-change lifecycle. * @returns The initial controller state with the mock authenticated user. */ function getMockInitialControllerState(options?: { @@ -665,6 +669,7 @@ function getMockInitialControllerState(options?: { }[] | undefined; migrationVersion?: number; + passwordChangeLifecycle?: SeedlessPasswordChangeLifecycle; }): Partial { const state = getInitialSeedlessOnboardingControllerStateWithDefaults(); @@ -718,6 +723,10 @@ function getMockInitialControllerState(options?: { state.migrationVersion = options.migrationVersion; } + if (options?.passwordChangeLifecycle !== undefined) { + state.passwordChangeLifecycle = options.passwordChangeLifecycle; + } + return state; } diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index ba24723ff4e..df28adf1c2b 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -385,6 +385,13 @@ const seedlessOnboardingMetadata: StateMetadata & Partial & { @@ -190,6 +215,12 @@ export type SeedlessOnboardingControllerState = * Used to prevent re-running migrations. */ migrationVersion: number; + + /** + * The persisted lifecycle record for an in-progress or unresolved + * password-change operation. Missing or `undefined` means `IDLE`. + */ + passwordChangeLifecycle?: SeedlessPasswordChangeLifecycle; }; /** diff --git a/packages/seedless-onboarding-controller/src/utils.test.ts b/packages/seedless-onboarding-controller/src/utils.test.ts index d1a922d3b5d..6339fe82e8c 100644 --- a/packages/seedless-onboarding-controller/src/utils.test.ts +++ b/packages/seedless-onboarding-controller/src/utils.test.ts @@ -3,15 +3,24 @@ import { bytesToBase64 } from '@metamask/utils'; import { utf8ToBytes } from '@noble/ciphers/utils'; import { createMockJWTToken } from '../tests/mocks/utils.js'; -import { SecretType } from './constants.js'; +import { + SecretType, + SeedlessPasswordChangeErrorCode, + SeedlessPasswordChangePhase, +} from './constants.js'; import { SecretMetadata } from './SecretMetadata.js'; import type { DecodedNodeAuthToken } from './types.js'; import { - decodeNodeAuthToken, - decodeJWTToken, + classifyPasswordChangeError, compareAndGetLatestToken, + createPasswordChangeLifecycle, + decodeJWTToken, + decodeNodeAuthToken, getInvalidPrimarySecretDataTypeErrorData, + getPasswordChangePhase, getSecretTypeFromDataType, + isValidPasswordChangePhaseTransition, + transitionPasswordChangeLifecycle, } from './utils.js'; describe('utils', () => { @@ -269,4 +278,297 @@ describe('utils', () => { ]); }); }); + + describe('createPasswordChangeLifecycle', () => { + it('creates a record with the given phase and no error code', () => { + expect( + createPasswordChangeLifecycle(SeedlessPasswordChangePhase.Idle), + ).toStrictEqual({ phase: SeedlessPasswordChangePhase.Idle }); + }); + + it('creates a record with a pending phase', () => { + expect( + createPasswordChangeLifecycle( + SeedlessPasswordChangePhase.SeedlessChangePending, + ), + ).toStrictEqual({ + phase: SeedlessPasswordChangePhase.SeedlessChangePending, + }); + }); + }); + + describe('getPasswordChangePhase', () => { + it('returns IDLE when the lifecycle is undefined', () => { + expect(getPasswordChangePhase(undefined)).toBe( + SeedlessPasswordChangePhase.Idle, + ); + }); + + it('returns the stored phase when the lifecycle is defined', () => { + expect( + getPasswordChangePhase( + createPasswordChangeLifecycle( + SeedlessPasswordChangePhase.SeedlessCommitted, + ), + ), + ).toBe(SeedlessPasswordChangePhase.SeedlessCommitted); + }); + }); + + describe('transitionPasswordChangeLifecycle', () => { + it('transitions from undefined (IDLE) to SEEDLESS_CHANGE_PENDING', () => { + const next = transitionPasswordChangeLifecycle( + undefined, + SeedlessPasswordChangePhase.SeedlessChangePending, + ); + expect(next.phase).toBe(SeedlessPasswordChangePhase.SeedlessChangePending); + expect(next.lastErrorCode).toBeUndefined(); + }); + + it('preserves lastErrorCode when transitioning without a new code', () => { + const lifecycle = { + phase: SeedlessPasswordChangePhase.SeedlessChangePending, + lastErrorCode: SeedlessPasswordChangeErrorCode.RemoteTimeout, + }; + const next = transitionPasswordChangeLifecycle( + lifecycle, + SeedlessPasswordChangePhase.Unknown, + ); + expect(next.phase).toBe(SeedlessPasswordChangePhase.Unknown); + expect(next.lastErrorCode).toBe( + SeedlessPasswordChangeErrorCode.RemoteTimeout, + ); + }); + + it('sets a new lastErrorCode when provided', () => { + const next = transitionPasswordChangeLifecycle( + undefined, + SeedlessPasswordChangePhase.Unknown, + SeedlessPasswordChangeErrorCode.RemoteAmbiguous, + ); + expect(next.lastErrorCode).toBe( + SeedlessPasswordChangeErrorCode.RemoteAmbiguous, + ); + }); + + it('clears lastErrorCode when transitioning to IDLE', () => { + const lifecycle = { + phase: SeedlessPasswordChangePhase.Unknown, + lastErrorCode: SeedlessPasswordChangeErrorCode.RemoteTimeout, + }; + const next = transitionPasswordChangeLifecycle( + lifecycle, + SeedlessPasswordChangePhase.Idle, + ); + expect(next.phase).toBe(SeedlessPasswordChangePhase.Idle); + expect(next.lastErrorCode).toBeUndefined(); + }); + }); + + describe('isValidPasswordChangePhaseTransition', () => { + it('allows IDLE to SEEDLESS_CHANGE_PENDING', () => { + expect( + isValidPasswordChangePhaseTransition( + undefined, + SeedlessPasswordChangePhase.SeedlessChangePending, + ), + ).toBe(true); + }); + + it('allows SEEDLESS_CHANGE_PENDING to SEEDLESS_COMMITTED', () => { + expect( + isValidPasswordChangePhaseTransition( + createPasswordChangeLifecycle( + SeedlessPasswordChangePhase.SeedlessChangePending, + ), + SeedlessPasswordChangePhase.SeedlessCommitted, + ), + ).toBe(true); + }); + + it('allows SEEDLESS_CHANGE_PENDING to IDLE (definitive remote failure)', () => { + expect( + isValidPasswordChangePhaseTransition( + createPasswordChangeLifecycle( + SeedlessPasswordChangePhase.SeedlessChangePending, + ), + SeedlessPasswordChangePhase.Idle, + ), + ).toBe(true); + }); + + it('allows SEEDLESS_COMMITTED to LOCAL_KEYRING_PENDING', () => { + expect( + isValidPasswordChangePhaseTransition( + createPasswordChangeLifecycle( + SeedlessPasswordChangePhase.SeedlessCommitted, + ), + SeedlessPasswordChangePhase.LocalKeyringPending, + ), + ).toBe(true); + }); + + it('allows LOCAL_KEYRING_PENDING to KEY_SYNC_PENDING', () => { + expect( + isValidPasswordChangePhaseTransition( + createPasswordChangeLifecycle( + SeedlessPasswordChangePhase.LocalKeyringPending, + ), + SeedlessPasswordChangePhase.KeySyncPending, + ), + ).toBe(true); + }); + + it('allows KEY_SYNC_PENDING to COMPLETE', () => { + expect( + isValidPasswordChangePhaseTransition( + createPasswordChangeLifecycle( + SeedlessPasswordChangePhase.KeySyncPending, + ), + SeedlessPasswordChangePhase.Complete, + ), + ).toBe(true); + }); + + it('allows COMPLETE to IDLE (clear)', () => { + expect( + isValidPasswordChangePhaseTransition( + createPasswordChangeLifecycle(SeedlessPasswordChangePhase.Complete), + SeedlessPasswordChangePhase.Idle, + ), + ).toBe(true); + }); + + it('allows any phase to UNKNOWN', () => { + for (const phase of [ + SeedlessPasswordChangePhase.SeedlessChangePending, + SeedlessPasswordChangePhase.SeedlessCommitted, + SeedlessPasswordChangePhase.LocalKeyringPending, + SeedlessPasswordChangePhase.KeySyncPending, + ]) { + expect( + isValidPasswordChangePhaseTransition( + createPasswordChangeLifecycle(phase), + SeedlessPasswordChangePhase.Unknown, + ), + ).toBe(true); + } + }); + + it('allows UNKNOWN to any resolvable phase', () => { + for (const target of [ + SeedlessPasswordChangePhase.Idle, + SeedlessPasswordChangePhase.SeedlessCommitted, + SeedlessPasswordChangePhase.LocalKeyringPending, + SeedlessPasswordChangePhase.KeySyncPending, + SeedlessPasswordChangePhase.Complete, + ]) { + expect( + isValidPasswordChangePhaseTransition( + createPasswordChangeLifecycle(SeedlessPasswordChangePhase.Unknown), + target, + ), + ).toBe(true); + } + }); + + it('rejects IDLE to COMPLETE (skipping steps)', () => { + expect( + isValidPasswordChangePhaseTransition( + undefined, + SeedlessPasswordChangePhase.Complete, + ), + ).toBe(false); + }); + + it('rejects SEEDLESS_COMMITTED to IDLE (cannot skip back without definitive failure)', () => { + expect( + isValidPasswordChangePhaseTransition( + createPasswordChangeLifecycle( + SeedlessPasswordChangePhase.SeedlessCommitted, + ), + SeedlessPasswordChangePhase.Idle, + ), + ).toBe(false); + }); + + it('rejects COMPLETE to SEEDLESS_CHANGE_PENDING (cannot restart from complete)', () => { + expect( + isValidPasswordChangePhaseTransition( + createPasswordChangeLifecycle(SeedlessPasswordChangePhase.Complete), + SeedlessPasswordChangePhase.SeedlessChangePending, + ), + ).toBe(false); + }); + }); + + describe('classifyPasswordChangeError', () => { + it('classifies a fetch error as RemoteStatusUnavailable', () => { + expect( + classifyPasswordChangeError(new Error('Failed to fetch auth pub key')), + ).toBe(SeedlessPasswordChangeErrorCode.RemoteStatusUnavailable); + }); + + it('classifies a timeout error as RemoteTimeout', () => { + expect( + classifyPasswordChangeError(new Error('Request timeout')), + ).toBe(SeedlessPasswordChangeErrorCode.RemoteTimeout); + }); + + it('classifies a vault error as LocalVaultFailure', () => { + expect( + classifyPasswordChangeError(new Error('vault decryption failed')), + ).toBe(SeedlessPasswordChangeErrorCode.LocalVaultFailure); + }); + + it('classifies a keyring error as LocalKeyringFailure', () => { + expect( + classifyPasswordChangeError(new Error('Keyring operation failed')), + ).toBe(SeedlessPasswordChangeErrorCode.LocalKeyringFailure); + }); + + it('classifies a persistence error as PersistenceFailure', () => { + expect( + classifyPasswordChangeError(new Error('storage write failed')), + ).toBe(SeedlessPasswordChangeErrorCode.PersistenceFailure); + }); + + it('classifies a FailedToChangePassword error as RemoteAmbiguous', () => { + expect( + classifyPasswordChangeError( + new Error('SeedlessOnboardingController - Failed to change password'), + ), + ).toBe(SeedlessPasswordChangeErrorCode.RemoteAmbiguous); + }); + + it('classifies a changeEncKey error as RemoteAmbiguous', () => { + expect( + classifyPasswordChangeError(new Error('changeEncKey failed')), + ).toBe(SeedlessPasswordChangeErrorCode.RemoteAmbiguous); + }); + + it('classifies an unknown error as RemoteAmbiguous', () => { + expect(classifyPasswordChangeError(new Error('something went wrong'))).toBe( + SeedlessPasswordChangeErrorCode.RemoteAmbiguous, + ); + }); + + it('classifies a string error', () => { + expect( + classifyPasswordChangeError('timeout exceeded'), + ).toBe(SeedlessPasswordChangeErrorCode.RemoteTimeout); + }); + + it('classifies a non-Error non-string value as RemoteAmbiguous', () => { + expect(classifyPasswordChangeError(null)).toBe( + SeedlessPasswordChangeErrorCode.RemoteAmbiguous, + ); + expect(classifyPasswordChangeError(undefined)).toBe( + SeedlessPasswordChangeErrorCode.RemoteAmbiguous, + ); + expect(classifyPasswordChangeError({ foo: 'bar' })).toBe( + SeedlessPasswordChangeErrorCode.RemoteAmbiguous, + ); + }); + }); }); diff --git a/packages/seedless-onboarding-controller/src/utils.ts b/packages/seedless-onboarding-controller/src/utils.ts index 21a08b1f2da..61a063e9656 100644 --- a/packages/seedless-onboarding-controller/src/utils.ts +++ b/packages/seedless-onboarding-controller/src/utils.ts @@ -8,13 +8,18 @@ import { } from '@metamask/utils'; import { bytesToUtf8 } from '@noble/ciphers/utils'; -import { SecretType } from './constants.js'; +import { + SecretType, + SeedlessPasswordChangeErrorCode, + SeedlessPasswordChangePhase, +} from './constants.js'; import type { SecretMetadata } from './SecretMetadata.js'; import type { DecodedBaseJWTToken, DecodedNodeAuthToken, DeserializedVaultData, InvalidPrimarySecretDataTypeErrorData, + SeedlessPasswordChangeLifecycle, VaultData, } from './types.js'; @@ -187,3 +192,197 @@ export function getInvalidPrimarySecretDataTypeErrorData( ): InvalidPrimarySecretDataTypeErrorData { return secrets.map((secret) => secret.dataType ?? secret.type); } + +/** + * Legal forward transitions for the password-change lifecycle. + * + * This map is used by tests to validate that transitions are sensible. It is + * NOT the source of truth for recovery — a persisted phase may be stale, and + * recovery must always verify actual remote and local state before acting. + * + * `UNKNOWN` is intentionally permissive: recovery may resolve it to any phase + * or clear it to `IDLE`. Any phase may transition to `UNKNOWN` when a result + * is ambiguous. + */ +const LEGAL_PASSWORD_CHANGE_TRANSITIONS: Record< + SeedlessPasswordChangePhase, + SeedlessPasswordChangePhase[] +> = { + [SeedlessPasswordChangePhase.Idle]: [ + SeedlessPasswordChangePhase.SeedlessChangePending, + ], + [SeedlessPasswordChangePhase.SeedlessChangePending]: [ + SeedlessPasswordChangePhase.SeedlessCommitted, + SeedlessPasswordChangePhase.Idle, + SeedlessPasswordChangePhase.Unknown, + ], + [SeedlessPasswordChangePhase.SeedlessCommitted]: [ + SeedlessPasswordChangePhase.LocalKeyringPending, + SeedlessPasswordChangePhase.Unknown, + ], + [SeedlessPasswordChangePhase.LocalKeyringPending]: [ + SeedlessPasswordChangePhase.KeySyncPending, + SeedlessPasswordChangePhase.Unknown, + ], + [SeedlessPasswordChangePhase.KeySyncPending]: [ + SeedlessPasswordChangePhase.Complete, + SeedlessPasswordChangePhase.Unknown, + ], + [SeedlessPasswordChangePhase.Complete]: [ + SeedlessPasswordChangePhase.Idle, + ], + [SeedlessPasswordChangePhase.Unknown]: [ + SeedlessPasswordChangePhase.Idle, + SeedlessPasswordChangePhase.SeedlessCommitted, + SeedlessPasswordChangePhase.LocalKeyringPending, + SeedlessPasswordChangePhase.KeySyncPending, + SeedlessPasswordChangePhase.Complete, + ], +}; + +/** + * Create a new password-change lifecycle record at the given phase. + * + * @param phase - The initial phase. + * @returns A new lifecycle record. + */ +export function createPasswordChangeLifecycle( + phase: SeedlessPasswordChangePhase, +): SeedlessPasswordChangeLifecycle { + return { phase }; +} + +/** + * Apply a phase transition to a lifecycle record, returning a new record. + * + * Preserves `lastErrorCode` from the previous record unless a new code is + * provided or the target phase is `IDLE` (which clears it). + * + * @param lifecycle - The current lifecycle record (or `undefined` for `IDLE`). + * @param phase - The target phase. + * @param lastErrorCode - Optional error code to set on the new record. + * @returns A new lifecycle record with the transitioned phase. + */ +export function transitionPasswordChangeLifecycle( + lifecycle: SeedlessPasswordChangeLifecycle | undefined, + phase: SeedlessPasswordChangePhase, + lastErrorCode?: SeedlessPasswordChangeErrorCode, +): SeedlessPasswordChangeLifecycle { + const preservedErrorCode = resolvePasswordChangeErrorCode( + lifecycle, + phase, + lastErrorCode, + ); + const next: SeedlessPasswordChangeLifecycle = { + phase, + ...(preservedErrorCode === undefined + ? {} + : { lastErrorCode: preservedErrorCode }), + }; + return next; +} + +/** + * Resolve the `lastErrorCode` to attach to a transitioned lifecycle record. + * + * A new code wins. Otherwise the previous code is preserved unless the target + * phase is `IDLE` (which clears it). + * + * @param lifecycle - The current lifecycle record (or `undefined` for `IDLE`). + * @param phase - The target phase. + * @param lastErrorCode - Optional error code to set on the new record. + * @returns The error code to attach, or `undefined` to omit it. + */ +function resolvePasswordChangeErrorCode( + lifecycle: SeedlessPasswordChangeLifecycle | undefined, + phase: SeedlessPasswordChangePhase, + lastErrorCode?: SeedlessPasswordChangeErrorCode, +): SeedlessPasswordChangeErrorCode | undefined { + if (lastErrorCode !== undefined) { + return lastErrorCode; + } + if ( + lifecycle?.lastErrorCode !== undefined && + phase !== SeedlessPasswordChangePhase.Idle + ) { + return lifecycle.lastErrorCode; + } + return undefined; +} + +/** + * Return the phase of a lifecycle record, treating `undefined` as `IDLE`. + * + * @param lifecycle - The lifecycle record, or `undefined`. + * @returns The phase, or `IDLE` if the record is missing. + */ +export function getPasswordChangePhase( + lifecycle: SeedlessPasswordChangeLifecycle | undefined, +): SeedlessPasswordChangePhase { + return lifecycle?.phase ?? SeedlessPasswordChangePhase.Idle; +} + +/** + * Check whether a phase transition is legal according to the transition map. + * + * This is for test validation only. A persisted phase may be stale; recovery + * must verify actual state rather than relying on this validator. + * + * @param from - The source phase (or `undefined` for `IDLE`). + * @param to - The target phase. + * @returns `true` if the transition is legal. + */ +export function isValidPasswordChangePhaseTransition( + from: SeedlessPasswordChangeLifecycle | undefined, + to: SeedlessPasswordChangePhase, +): boolean { + const fromPhase = getPasswordChangePhase(from); + return LEGAL_PASSWORD_CHANGE_TRANSITIONS[fromPhase].includes(to); +} + +/** + * Classify an error into a non-sensitive password-change error code. + * + * This inspects the error's `message` and `name` for known patterns. It must + * never persist the raw error message — only the closed set of codes. + * + * @param error - The error to classify. + * @returns A non-sensitive error code. + */ +export function classifyPasswordChangeError( + error: unknown, +): SeedlessPasswordChangeErrorCode { + let message = ''; + if (error instanceof Error) { + message = error.message; + } else if (typeof error === 'string') { + message = error; + } + + if (message.includes('fetch')) { + return SeedlessPasswordChangeErrorCode.RemoteStatusUnavailable; + } + if ( + message.includes('timeout') || + message.includes('Timeout') || + message.includes('TIMEOUT') + ) { + return SeedlessPasswordChangeErrorCode.RemoteTimeout; + } + if ( + message.includes('FailedToChangePassword') || + message.includes('changeEncKey') + ) { + return SeedlessPasswordChangeErrorCode.RemoteAmbiguous; + } + if (message.includes('vault')) { + return SeedlessPasswordChangeErrorCode.LocalVaultFailure; + } + if (message.includes('keyring') || message.includes('Keyring')) { + return SeedlessPasswordChangeErrorCode.LocalKeyringFailure; + } + if (message.includes('persist') || message.includes('storage')) { + return SeedlessPasswordChangeErrorCode.PersistenceFailure; + } + return SeedlessPasswordChangeErrorCode.RemoteAmbiguous; +} From f38627476f4079297ed6bc3339eba75d9936c3d4 Mon Sep 17 00:00:00 2001 From: lwin Date: Wed, 9 Sep 2026 12:28:18 +0800 Subject: [PATCH 02/14] feat: apply lifecycle phase to the password change --- ...ess-password-change-implementation-plan.md | 75 ++++--- ...0003-seedless-password-change-contracts.md | 69 ++----- .../src/SeedlessOnboardingController.test.ts | 108 +++++++++- .../src/SeedlessOnboardingController.ts | 97 ++++++++- .../src/constants.ts | 17 -- .../src/index.ts | 2 - .../src/types.ts | 24 +-- .../src/utils.test.ts | 184 ++---------------- .../src/utils.ts | 131 +------------ 9 files changed, 272 insertions(+), 435 deletions(-) diff --git a/packages/seedless-onboarding-controller/docs/0002-seedless-password-change-implementation-plan.md b/packages/seedless-onboarding-controller/docs/0002-seedless-password-change-implementation-plan.md index 48be559e9ff..7aebce6521e 100644 --- a/packages/seedless-onboarding-controller/docs/0002-seedless-password-change-implementation-plan.md +++ b/packages/seedless-onboarding-controller/docs/0002-seedless-password-change-implementation-plan.md @@ -18,7 +18,7 @@ Update the checkboxes as work is completed. Keep the phase status aligned with i | ---------------------------------- | ----------- | -------------------------------------------------------------- | | 0. External prerequisites | Complete | — | | 1. Lifecycle model | Complete | — | -| 2. Controller lifecycle operations | Not started | Add serialized lifecycle transitions and durable writes | +| 2. Controller lifecycle operations | Complete | — | | 3. `changePassword` flow | Not started | Add server-first lifecycle boundaries | | 4. Keyring-key storage | Not started | Couple encrypted-key storage to lifecycle persistence | | 5. Recovery primitives | Not started | Preserve existing recovery methods and add recovery safeguards | @@ -35,16 +35,13 @@ The controller should persist enough non-sensitive lifecycle information to tell ## Design summary -Use one persisted lifecycle record: +Use one persisted field that holds the last-known phase: ```ts -type SeedlessPasswordChangeLifecycle = { - phase: SeedlessPasswordChangePhase; - lastErrorCode?: string; -}; +passwordChangePhase?: SeedlessPasswordChangePhase; ``` -The record must never contain a password, SRP, raw encryption key, decrypted vault data, or an error message that may contain sensitive data. +The field must never contain a password, SRP, raw encryption key, decrypted vault data, or an error message that may contain sensitive data. Missing or `undefined` means `IDLE`. Use the lifecycle as a recovery signal only. It is not proof that a remote or local operation completed. Recovery must always: @@ -90,29 +87,24 @@ Keep the existing methods where possible. Add only the lifecycle information nee - Use string enum values matching the ADR exactly. - Add no sensitive values to the enum. -2. Add `SeedlessPasswordChangeLifecycle` to `src/types.ts`. +2. Add `passwordChangePhase?: SeedlessPasswordChangePhase` to `SeedlessOnboardingControllerState` in `src/types.ts`. - - Make the lifecycle state optional so old persisted state without the field is treated as `IDLE`. + - Make the field optional so old persisted state without it is treated as `IDLE`. -3. Add `passwordChangeLifecycle?: SeedlessPasswordChangeLifecycle` to `SeedlessOnboardingControllerState`. - -4. Add metadata for `passwordChangeLifecycle` in `seedlessOnboardingMetadata`. +3. Add metadata for `passwordChangePhase` in `seedlessOnboardingMetadata`. - Set `persist: true`. - Keep state logs and debug snapshots limited to safe fields, or exclude the field if the platform does not need it there. - Do not expose raw error objects through state. -5. Export the phase and lifecycle types through `src/index.ts`. +4. Export the phase type through `src/index.ts`. ### Lifecycle helpers Add small, pure helpers rather than spreading phase mutations through the controller: -1. Define a helper for creating a new lifecycle record. -2. Define a helper for applying a phase transition. -3. Define a helper for classifying errors into a non-sensitive error code. -4. Define a helper for treating missing lifecycle state as `IDLE`. -5. Validate legal transitions in tests. Do not make the transition validator the source of truth for recovery; a persisted phase may be stale. +1. Define a helper for treating a missing phase as `IDLE`. +2. Validate legal transitions in tests. Do not make the transition validator the source of truth for recovery; a persisted phase may be stale. These helpers can live in `src/utils.ts` if they remain general and pure. Keep controller-specific transition behavior in private controller methods. @@ -120,21 +112,23 @@ These helpers can live in `src/utils.ts` if they remain general and pure. Keep c Out of scope for this plan. The Seedless/TOPRF server does not accept an idempotency key or transaction ID today, and adding one is not a simple server-side change. Recovery here does not retry the password change — it uses the existing password-sync flow (`checkIsPasswordOutdated` + `submitGlobalPassword` + `syncLatestGlobalPassword`) to reconcile local state once remote state is established. A transaction ID remains a “good to have” for a future TOPRF release; until then, ambiguous remote results stay `UNKNOWN`. -### Durable lifecycle persistence +### Lifecycle persistence + +The lifecycle is persisted as ordinary controller state. The `passwordChangePhase` field has `persist: true` metadata, so it is written through the controller's normal `stateChange` flow (the same debounced persistence path as every other persisted field). There is no separate awaitable durability hook on the controller. -The existing `stateChanged` event remains the notification mechanism, but a normal state update is not a durability acknowledgement. +The lifecycle is a recovery signal only — it is not proof that a remote or local operation completed, and it is not proof that the lifecycle itself reached durable storage before the next step ran. A crash can leave the durable marker behind the actual cryptographic state, so recovery must always re-verify actual remote and local state before acting on the phase. A missing or stale marker is recoverable: `checkIsPasswordOutdated({ skipCache: true })` detects a remote change with no marker at all, and cryptographic Keyring verification classifies the local state. -**Phase 0 decision:** use a narrow awaitable persistence hook on the controller options (`persistPasswordChangeLifecycle`), used only for lifecycle boundaries. Clients implement the hook with a non-debounced durable write. See [0003](./0003-seedless-password-change-contracts.md). +Required lifecycle write points (controller `this.update(...)` calls): -The hook must provide: +- before the first remote mutation (`SEEDLESS_CHANGE_PENDING`); +- after each irreversible boundary (`SEEDLESS_COMMITTED`, `LOCAL_KEYRING_PENDING`); +- after local Keyring-key storage when that update is coupled to a lifecycle write; +- after `COMPLETE`; +- after explicit clear to `IDLE`. -- an awaitable write before the first remote mutation; -- an awaitable write after each irreversible boundary; -- an awaitable write for `COMPLETE`; -- a read of the last durable lifecycle before normal unlock error handling; -- surfaced write failures, so the client can lock the wallet and keep recovery active. +On a failed step the controller **preserves the last known phase**; it does not overwrite it with `UNKNOWN`. The last written phase is the recovery signal: e.g. if `changeEncKey` rejected, the phase stays `SEEDLESS_CHANGE_PENDING` and the client performs an authoritative password-outdated check to choose the recovery branch. If the failure happened before the first lifecycle write, the lifecycle stays `IDLE` (nothing to recover). `UNKNOWN` is a recovery-time determination made by the client when an authoritative server check or local cryptographic verification cannot establish the state — not a phase written by `changePassword`'s catch block. -Do not claim that `this.update(...)` alone satisfies this contract. Do not use the generic debounced persistence path as the only completion boundary. +These publish `SeedlessOnboardingController:stateChange`; they are not awaited durability boundaries. See [0003](./0003-seedless-password-change-contracts.md). ## Development phases @@ -146,7 +140,7 @@ Complete these checks before changing controller behavior: - [x] Confirm whether the password-change request accepts an idempotency key or transaction ID. - [x] Confirm whether Keyring encryption-key synchronization is a Seedless/TOPRF API, a client persistence operation, or both. - [x] Define how the remote service reports old, new, partial, and unknown state. -- [x] Define the durable persistence hook for extension and mobile. +- [x] Define the lifecycle persistence approach for extension and mobile. - [x] Define how the client reads the lifecycle before attempting normal unlock. Deliverable: [0003-seedless-password-change-contracts.md](./0003-seedless-password-change-contracts.md). Authoritative remote status is unavailable in `@metamask/toprf-secure-backup@1.1.0`; lost-response and partial backup paths remain `UNKNOWN` until TOPRF adds that API. @@ -173,18 +167,17 @@ Add private methods with names that describe the boundary, for example: - `#startPasswordChangeLifecycle` - `#advancePasswordChangeLifecycle` -- `#markPasswordChangeUnknown` +- `#writePasswordChangePhase` - `#completePasswordChangeLifecycle` -- `#clearPasswordChangeLifecycle` +- `#clearPasswordChangePhase` Implement them in this order: -- [ ] Create the lifecycle before the first remote mutation with `SEEDLESS_CHANGE_PENDING`. -- [ ] Preserve the lifecycle when any later operation throws. -- [ ] Mark `UNKNOWN` only when the controller cannot safely classify the result; do not reset to `IDLE` on every error. -- [ ] Make clearing the lifecycle an explicit operation after definitive remote failure or durable `COMPLETE`. -- [ ] Keep all transitions serialized under `#withControllerLock`. -- [ ] Route durable lifecycle writes through the persistence contract selected in Phase 0. +- [x] Create the lifecycle before the first remote mutation with `SEEDLESS_CHANGE_PENDING`. +- [x] Preserve the last known phase when any later operation throws; do not overwrite it with `UNKNOWN` and do not reset to `IDLE` on every error. +- [x] Make clearing the lifecycle an explicit operation after definitive remote failure or durable `COMPLETE`. +- [x] Keep all transitions serialized under `#withControllerLock`. +- [x] Route durable lifecycle writes through the persistence contract selected in Phase 0. Do not add a second mutex unless the existing controller mutex cannot protect the lifecycle update. The client must use its own coordinator lock for the cross-controller transaction. @@ -363,7 +356,7 @@ The implementation is ready when: ### This package - `src/constants.ts` — lifecycle phase enum. -- `src/types.ts` — lifecycle record and state field. +- `src/types.ts` — password-change phase state field. - `src/utils.ts` — pure lifecycle helpers, if needed. - `src/SeedlessOnboardingController.ts` — metadata, transition helpers, lifecycle-aware `changePassword`, and lifecycle-aware key storage. - `src/SeedlessOnboardingController-method-action-types.ts` — public action documentation/signature. @@ -376,7 +369,7 @@ The implementation is ready when: - Client password-change coordinator and unlock/recovery routing. - KeyringController integration for `verifyPassword`, `submitEncryptionKey`, `changePassword`, and `exportEncryptionKey`. -- Durable storage adapter or persistence hook implementation. +- Client persistence of the `SeedlessOnboardingController` state slice (debounced, same as other persisted controller state). - Seedless/TOPRF API support for authoritative status (future: idempotent retries keyed by transaction ID). - Client UI and end-to-end tests. @@ -384,11 +377,11 @@ The implementation is ready when: Resolved in [0003](./0003-seedless-password-change-contracts.md): -- [x] Decide which layer owns the awaitable durable persistence hook. Controller option `persistPasswordChangeLifecycle`; clients supply the durable write. +- [x] Decide the lifecycle persistence approach. Persisted as ordinary controller state (`persist: true`) via the normal `stateChange` flow; no separate awaitable durability hook. Recovery re-verifies actual state, so a stale/missing marker is recoverable. - [x] Define what exact remote API confirms password-change and Keyring-key synchronization status. Today: `fetchAuthPubKey` plus cryptographic recover. No transaction-status API. Local Keyring-key proof is `storeKeyringEncryptionKey` durability only. - [x] Confirm whether `transactionId` is accepted by the current Seedless/TOPRF API, or whether server work must land first. Not accepted, and out of scope for this plan. Recovery uses `fetchAuthPubKey` comparison and cryptographic verification instead. - [x] Define what the remote service returns for a partial backup/key-share update. Nothing; classify as `UNKNOWN`. -- [x] Decide whether the lifecycle record is visible to UI state or only to the client coordinator through the messenger. Persisted controller state; coordinator reads `getState` before unlock; `usedInUi: true` for safe fields only. +- [x] Decide whether the lifecycle phase is visible to UI state or only to the client coordinator through the messenger. Persisted controller state; coordinator reads `getState` before unlock; `usedInUi: true` for the phase only. Still open: diff --git a/packages/seedless-onboarding-controller/docs/0003-seedless-password-change-contracts.md b/packages/seedless-onboarding-controller/docs/0003-seedless-password-change-contracts.md index f56654dc47e..034067dbec6 100644 --- a/packages/seedless-onboarding-controller/docs/0003-seedless-password-change-contracts.md +++ b/packages/seedless-onboarding-controller/docs/0003-seedless-password-change-contracts.md @@ -80,65 +80,51 @@ Recovery reuses the existing password-sync flow, which already handles “remote `COMPLETE` in this contract means: remote Seedless password is new, local Seedless vault is new, local Keyring uses the new password, and the current Keyring encryption key is durably stored via `storeKeyringEncryptionKey`. -## Durable persistence hook (extension and mobile) +## Lifecycle persistence -**Decision:** use the plan’s preferred approach — a narrow awaitable persistence hook on the controller, used only at lifecycle boundaries. Do not rely on generic debounced `stateChanged` persistence as the completion boundary. +**Decision:** the password-change lifecycle is persisted as ordinary controller state. The `passwordChangePhase` field has `persist: true` metadata, so it is written through the controller's normal `stateChange` flow (the same debounced persistence path used by every other persisted field). There is no separate awaitable durability hook on the controller. -### Hook +The lifecycle is a **recovery signal only** — it is not proof that a remote or local operation completed, and it is not proof that the lifecycle itself reached durable storage before the next step ran. A crash can leave the durable marker behind the actual cryptographic state. Recovery must therefore always re-verify actual remote and local state (see [No retries, no concurrency](#no-retries-no-concurrency) and [Unlock-time lifecycle read](#unlock-time-lifecycle-read)) before acting on the phase. A missing or stale marker is recoverable: `checkIsPasswordOutdated({ skipCache: true })` detects a remote change with no marker at all, and cryptographic Keyring verification classifies the local state. -```ts -type PersistPasswordChangeLifecycle = (input: { - lifecycle: SeedlessPasswordChangeLifecycle | undefined; - state: SeedlessOnboardingControllerState; -}) => Promise; -``` - -Constructor option: `persistPasswordChangeLifecycle?: PersistPasswordChangeLifecycle`. - -Semantics: - -1. The controller updates in-memory state first (`this.update`), then **awaits** this hook before treating the boundary as durable. -2. The hook must return only after the lifecycle (and any adjacent fields written in the same update, such as `encryptedKeyringEncryptionKey`) is written to the platform’s durable store. -3. Hook rejection is a persistence failure. Surface it to the caller. The client must lock the wallet and keep recovery active. Do not classify the remote password change from this failure. -4. If the hook is omitted, later phases that persist lifecycle **fail closed** before the first remote mutation. Unit tests inject a resolving or rejecting mock. -5. `undefined` lifecycle means the durable record was cleared (`IDLE`). - -Required await points: +Required lifecycle write points (in controller code): - before the first remote mutation (`SEEDLESS_CHANGE_PENDING`); - after authoritative remote commitment (`SEEDLESS_COMMITTED`); -- after local Seedless vault rewrite (`LOCAL_KEYRING_PENDING`); +- after the local Seedless vault rewrite (`LOCAL_KEYRING_PENDING`); - after local Keyring-key storage when that update is coupled to a lifecycle write; -- after `UNKNOWN`; - after `COMPLETE`; - after explicit clear to `IDLE`. -### Platform adapters +On a failed step the controller **preserves the last known phase**; it does not overwrite it with `UNKNOWN`. The last written phase is the recovery signal (e.g. a `changeEncKey` rejection leaves `SEEDLESS_CHANGE_PENDING`, and the client performs an authoritative password-outdated check to choose the branch). If the failure happened before the first lifecycle write, the lifecycle stays `IDLE`. `UNKNOWN` is a recovery-time determination made by the client when an authoritative server check or local cryptographic verification cannot establish the state — not a phase written by `changePassword`'s catch block. + +These are `this.update(...)` calls that publish `SeedlessOnboardingController:stateChange`. They are not awaited durability boundaries. + +### Platform persistence | Client | Durable write | Unlock-time read | | --- | --- | --- | -| Extension | Await the persisted `SeedlessOnboardingController` slice in `chrome.storage` / the client persist pipeline. Bypass debounce for this write. | Read the persisted slice during background/start hydration, before password-unlock error handling. | -| Mobile | Await the filesystem / redux-persist (or equivalent) write for the same slice. Bypass debounce for this write. | Read the rehydrated slice at app start, before treating unlock as a normal invalid-password failure. | +| Extension | The persisted `SeedlessOnboardingController` slice in `chrome.storage` / the client persist pipeline (debounced). | Read the persisted slice during background/start hydration, before password-unlock error handling. | +| Mobile | The filesystem / redux-persist (or equivalent) write for the same slice (debounced). | Read the rehydrated slice at app start, before treating unlock as a normal invalid-password failure. | -The generic ComposableController / redux persist debounce may remain for unrelated state. It must not be the only write that `COMPLETE` waits on. +The generic ComposableController / redux persist debounce remains the persistence path for this field, the same as for all other persisted controller state. ## Unlock-time lifecycle read **Decision:** the lifecycle is persisted controller state. Clients read it through `SeedlessOnboardingController:getState` (or the already-hydrated persisted snapshot) **before** normal Keyring invalid-password handling. Recovery UI may observe safe fields; the coordinator, not the UI, decides the recovery branch. -Metadata for `passwordChangeLifecycle` (Phase 1): +Metadata for `passwordChangePhase` (Phase 1): - `persist: true` - `usedInUi: true` so recovery screens can show phase, without exposing secrets - `includeInDebugSnapshot: false` -- `includeInStateLogs: true` only for non-sensitive fields (`phase`, `lastErrorCode`) +- `includeInStateLogs: true` (the only stored field is `phase`, which is non-sensitive) Missing persisted field ⇒ `IDLE`. Unlock routing: 1. Hydrate durable controller state. -2. Read `passwordChangeLifecycle`; treat missing as `IDLE`. +2. Read `passwordChangePhase`; treat missing as `IDLE`. 3. If phase is `IDLE` or `COMPLETE` (or `COMPLETE` already cleared to `IDLE`): continue normal unlock. 4. Otherwise: recovery-blocked path. Do not report the entered password as an ordinary Keyring unlock failure while recovery is pending. 5. For every unfinished phase, bypass `passwordOutdatedCache` and fetch remote `authPubKey`. @@ -147,28 +133,13 @@ Unlock routing: `COMPLETE` is not a second source of cryptographic truth. After a durable `COMPLETE`, the controller should clear to `IDLE` so the next unlock is normal. -## Error codes stored on the lifecycle - -`lastErrorCode` must be a closed, non-sensitive set. Do not persist error messages, passwords, or server bodies. - -Suggested codes for later phases: - -- `REMOTE_TIMEOUT` -- `REMOTE_AMBIGUOUS` -- `REMOTE_DEFINITIVE_FAILURE` -- `REMOTE_STATUS_UNAVAILABLE` -- `LOCAL_VAULT_FAILURE` -- `LOCAL_KEYRING_FAILURE` -- `KEY_STORE_FAILURE` -- `PERSISTENCE_FAILURE` - ## Implications for later phases -- Phase 1–2 may add lifecycle types and the persistence hook without calling new TOPRF methods. -- Phase 3 must mark `UNKNOWN` on ambiguous `changeEncKey` failures and must not reset to `IDLE` without an **old** remote classification. It must never retry `changeEncKey`. -- Phase 4 couples local Keyring-key storage to the hook; no remote key-sync API is needed. +- Phase 1–2 may add lifecycle types and lifecycle write points without calling new TOPRF methods. +- Phase 3 must **preserve the last known lifecycle phase** on ambiguous `changeEncKey` failures (e.g. leave `SEEDLESS_CHANGE_PENDING` in place) and must not reset to `IDLE` without an **old** remote classification. It must never retry `changeEncKey`. `UNKNOWN` is determined later by recovery, not written by the catch block. +- Phase 4 couples local Keyring-key storage to a lifecycle write; no remote key-sync API is needed. - Phase 5 reuses `submitGlobalPassword` and `syncLatestGlobalPassword` as the recovery mechanism. -- Phase 7 clients must implement the hook and the unlock-time read. They must not start a second password change while the lifecycle is unfinished, and must never retry `changePassword` / `changeEncKey`. +- Phase 7 clients must implement the unlock-time read of the persisted lifecycle. They must not start a second password change while the lifecycle is unfinished, and must never retry `changePassword` / `changeEncKey`. ## Existing TOPRF endpoints used by recovery diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index b800acb9447..792091c21d9 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -70,6 +70,7 @@ import { SeedlessOnboardingMigrationVersion, AuthConnection, SecretType, + SeedlessPasswordChangePhase, } from './constants.js'; import { PasswordSyncError, RecoveryError } from './errors.js'; import { SecretMetadata } from './SecretMetadata.js'; @@ -83,7 +84,6 @@ import type { } from './SeedlessOnboardingController.js'; import type { SeedlessOnboardingControllerState, - SeedlessPasswordChangeLifecycle, } from './types.js'; const authConnection = AuthConnection.Google; @@ -646,7 +646,7 @@ async function decryptVault( * @param options.encryptedSeedlessEncryptionKey - The mock encrypted seedless encryption key. * @param options.pendingToBeRevokedTokens - The mock pending to be revoked tokens. * @param options.migrationVersion - The mock migration version. - * @param options.passwordChangeLifecycle - The mock password-change lifecycle. + * @param options.passwordChangePhase - The mock password-change phase. * @returns The initial controller state with the mock authenticated user. */ function getMockInitialControllerState(options?: { @@ -669,7 +669,7 @@ function getMockInitialControllerState(options?: { }[] | undefined; migrationVersion?: number; - passwordChangeLifecycle?: SeedlessPasswordChangeLifecycle; + passwordChangePhase?: SeedlessPasswordChangePhase; }): Partial { const state = getInitialSeedlessOnboardingControllerStateWithDefaults(); @@ -723,8 +723,8 @@ function getMockInitialControllerState(options?: { state.migrationVersion = options.migrationVersion; } - if (options?.passwordChangeLifecycle !== undefined) { - state.passwordChangeLifecycle = options.passwordChangeLifecycle; + if (options?.passwordChangePhase !== undefined) { + state.passwordChangePhase = options.passwordChangePhase; } return state; @@ -3992,6 +3992,19 @@ describe('SeedlessOnboardingController', () => { const { encKey: newEncKey, authKeyPair: newAuthKeyPair } = mockChangeEncKey(toprfClient, NEW_MOCK_PASSWORD); + // Observe the persisted lifecycle phases as the password change + // progresses: SEEDLESS_CHANGE_PENDING is written before the remote + // mutation, SEEDLESS_COMMITTED after it succeeds, then + // LOCAL_KEYRING_PENDING once the local vault is rewritten. + const observedPhases: (SeedlessPasswordChangePhase | undefined)[] = + []; + baseMessenger.subscribe( + 'SeedlessOnboardingController:stateChange', + (state) => { + observedPhases.push(state.passwordChangePhase); + }, + ); + await baseMessenger.call( 'SeedlessOnboardingController:changePassword', NEW_MOCK_PASSWORD, @@ -4021,6 +4034,18 @@ describe('SeedlessOnboardingController', () => { expect(newEncKeyFromVault).toStrictEqual(newEncKey); expect(newAuthKeyPairFromVault.sk).toStrictEqual(newAuthKeyPair.sk); expect(newAuthKeyPairFromVault.pk).toStrictEqual(newAuthKeyPair.pk); + + // The lifecycle advances through every phase in order and ends on + // LOCAL_KEYRING_PENDING, signalling the local rewrite completed. + expect(observedPhases).toContain( + SeedlessPasswordChangePhase.SeedlessChangePending, + ); + expect(observedPhases).toContain( + SeedlessPasswordChangePhase.SeedlessCommitted, + ); + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.LocalKeyringPending, + ); }, ); }); @@ -4103,12 +4128,16 @@ describe('SeedlessOnboardingController', () => { expect(newEncKeyFromVault).toStrictEqual(newEncKey); expect(newAuthKeyPairFromVault.sk).toStrictEqual(newAuthKeyPair.sk); expect(newAuthKeyPairFromVault.pk).toStrictEqual(newAuthKeyPair.pk); + + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.LocalKeyringPending, + ); }, ); }); it('should throw an error if the controller is locked', async () => { - await withController(async ({ baseMessenger }) => { + await withController(async ({ controller, baseMessenger }) => { await expect( baseMessenger.call( 'SeedlessOnboardingController:changePassword', @@ -4118,6 +4147,9 @@ describe('SeedlessOnboardingController', () => { ).rejects.toThrow( SeedlessOnboardingControllerErrorMessage.ControllerLocked, ); + + // No lifecycle is written when the controller rejects up front. + expect(controller.state.passwordChangePhase).toBeUndefined(); }); }); @@ -4153,6 +4185,11 @@ describe('SeedlessOnboardingController', () => { ).rejects.toThrow( SeedlessOnboardingControllerErrorMessage.FailedToChangePassword, ); + + // The outdated-password check rejects before the first lifecycle + // write (SEEDLESS_CHANGE_PENDING), so there is nothing to recover + // and the lifecycle stays unset/IDLE. + expect(controller.state.passwordChangePhase).toBeUndefined(); }, ); }); @@ -4198,6 +4235,15 @@ describe('SeedlessOnboardingController', () => { ).rejects.toThrow( SeedlessOnboardingControllerErrorMessage.FailedToChangePassword, ); + + // The lifecycle was advanced to SEEDLESS_CHANGE_PENDING before the + // remote mutation, so on remote failure that phase is preserved as + // the recovery signal. The client performs an authoritative + // password-outdated check to decide the branch; the controller does + // not overwrite it to UNKNOWN. + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.SeedlessChangePending, + ); }, ); }); @@ -4252,6 +4298,10 @@ describe('SeedlessOnboardingController', () => { newPassword: NEW_MOCK_PASSWORD, }), ); + + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.LocalKeyringPending, + ); }, ); }); @@ -4364,6 +4414,10 @@ describe('SeedlessOnboardingController', () => { const [legacyTransformed] = transformDataItems?.([legacyItem]) ?? []; expect(legacyTransformed?.version).toBe('v1'); + + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.LocalKeyringPending, + ); }, ); }); @@ -4426,6 +4480,10 @@ describe('SeedlessOnboardingController', () => { newPassword: NEW_MOCK_PASSWORD, }), ); + + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.LocalKeyringPending, + ); }, ); }); @@ -4462,9 +4520,47 @@ describe('SeedlessOnboardingController', () => { ).rejects.toThrow( SeedlessOnboardingControllerErrorMessage.FailedToChangePassword, ); + + // The fetch failure rejects before the first lifecycle write + // (SEEDLESS_CHANGE_PENDING), so there is nothing to recover and the + // lifecycle stays unset/IDLE. + expect(controller.state.passwordChangePhase).toBeUndefined(); }, ); }); + + describe('clearPasswordChangePhase', () => { + it('clears an in-progress lifecycle to IDLE', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + passwordChangePhase: SeedlessPasswordChangePhase.Unknown, + }), + }, + async ({ controller }) => { + await controller.clearPasswordChangePhase(); + + expect(controller.state.passwordChangePhase).toBeUndefined(); + }, + ); + }); + + it('is a no-op when already IDLE', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller }) => { + await controller.clearPasswordChangePhase(); + + expect(controller.state.passwordChangePhase).toBeUndefined(); + }, + ); + }); + }); }); describe('clearState', () => { diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index df28adf1c2b..db3ad10ff1f 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -50,6 +50,7 @@ import { SecretType, SeedlessOnboardingControllerErrorMessage, SeedlessOnboardingMigrationVersion, + SeedlessPasswordChangePhase, Web3AuthNetwork, } from './constants.js'; import { @@ -78,6 +79,7 @@ import { decodeJWTToken, decodeNodeAuthToken, deserializeVaultData, + getPasswordChangePhase, serializeVaultData, } from './utils.js'; @@ -385,8 +387,8 @@ const seedlessOnboardingMetadata: StateMetadata { + state.passwordChangePhase = phase; + }); + } + + /** + * Start a password-change lifecycle before the first remote mutation. + * + * Persists `SEEDLESS_CHANGE_PENDING`. Must be called while the controller + * lock is held, before any remote Seedless mutation. + */ + #startPasswordChangeLifecycle(): void { + this.#writePasswordChangePhase( + SeedlessPasswordChangePhase.SeedlessChangePending, + ); + } + + /** + * Advance the lifecycle to a target phase after an irreversible boundary. + * + * Must be called while the controller lock is held. + * + * @param phase - The target phase. + */ + #advancePasswordChangeLifecycle( + phase: SeedlessPasswordChangePhase, + ): void { + this.#writePasswordChangePhase(phase); + } + + /** + * Clear the password-change lifecycle to `IDLE`. + * + * This is an explicit operation used after a definitive remote failure + * (server did not commit) or after `COMPLETE`. The controller clears to + * `IDLE` so the next unlock is normal. + * + * @returns A promise that resolves once the lifecycle has been cleared. + */ + async clearPasswordChangePhase(): Promise { + await this.#withControllerLock(async () => { + if ( + getPasswordChangePhase(this.state.passwordChangePhase) === + SeedlessPasswordChangePhase.Idle + ) { + return; + } + this.#writePasswordChangePhase(undefined); + }); + } + /** * Parse and deserialize the authentication data from the vault. * diff --git a/packages/seedless-onboarding-controller/src/constants.ts b/packages/seedless-onboarding-controller/src/constants.ts index ce7c8025e35..c4bed28015a 100644 --- a/packages/seedless-onboarding-controller/src/constants.ts +++ b/packages/seedless-onboarding-controller/src/constants.ts @@ -49,23 +49,6 @@ export enum SeedlessPasswordChangePhase { Unknown = 'UNKNOWN', } -/** - * Non-sensitive error codes stored on the password-change lifecycle. - * - * These must never contain passwords, raw error messages, or server response - * bodies — only a closed set of classification labels. - */ -export enum SeedlessPasswordChangeErrorCode { - RemoteTimeout = 'REMOTE_TIMEOUT', - RemoteAmbiguous = 'REMOTE_AMBIGUOUS', - RemoteDefinitiveFailure = 'REMOTE_DEFINITIVE_FAILURE', - RemoteStatusUnavailable = 'REMOTE_STATUS_UNAVAILABLE', - LocalVaultFailure = 'LOCAL_VAULT_FAILURE', - LocalKeyringFailure = 'LOCAL_KEYRING_FAILURE', - KeyStoreFailure = 'KEY_STORE_FAILURE', - PersistenceFailure = 'PERSISTENCE_FAILURE', -} - export enum SeedlessOnboardingControllerErrorMessage { ControllerLocked = `${controllerName} - The operation cannot be completed while the controller is locked.`, VaultLocked = `${controllerName} - The operation cannot be completed while the vault is locked.`, diff --git a/packages/seedless-onboarding-controller/src/index.ts b/packages/seedless-onboarding-controller/src/index.ts index def7594dd2a..231fc5810de 100644 --- a/packages/seedless-onboarding-controller/src/index.ts +++ b/packages/seedless-onboarding-controller/src/index.ts @@ -43,7 +43,6 @@ export type { AuthenticatedUserDetails, SocialBackupsMetadata, SeedlessOnboardingControllerState, - SeedlessPasswordChangeLifecycle, ToprfKeyDeriver, RecoveryErrorData, InvalidPrimarySecretDataTypeErrorData, @@ -55,7 +54,6 @@ export { AuthConnection, SecretType, SeedlessPasswordChangePhase, - SeedlessPasswordChangeErrorCode, } from './constants.js'; export { SecretMetadata } from './SecretMetadata.js'; export { diff --git a/packages/seedless-onboarding-controller/src/types.ts b/packages/seedless-onboarding-controller/src/types.ts index 4a4c9b6aa83..4aab1ccf6cb 100644 --- a/packages/seedless-onboarding-controller/src/types.ts +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -8,7 +8,6 @@ import type { MutexInterface } from 'async-mutex'; import type { AuthConnection, SecretType, - SeedlessPasswordChangeErrorCode, SeedlessPasswordChangePhase, } from './constants.js'; @@ -112,25 +111,6 @@ export type InvalidPrimarySecretDataTypeErrorData = ( // State -/** - * The persisted lifecycle record for a Seedless password-change operation. - * - * This is a recovery signal only — it is not proof that a remote or local - * operation completed. It must never contain a password, SRP, raw encryption - * key, decrypted vault data, or an error message that may contain sensitive - * data. - */ -export type SeedlessPasswordChangeLifecycle = { - /** - * The current lifecycle phase. - */ - phase: SeedlessPasswordChangePhase; - /** - * A non-sensitive error code from the last failed step, if any. - */ - lastErrorCode?: SeedlessPasswordChangeErrorCode; -}; - export type SeedlessOnboardingControllerState = Partial & Partial & { @@ -217,10 +197,10 @@ export type SeedlessOnboardingControllerState = migrationVersion: number; /** - * The persisted lifecycle record for an in-progress or unresolved + * The persisted last-known phase of an in-progress or unresolved * password-change operation. Missing or `undefined` means `IDLE`. */ - passwordChangeLifecycle?: SeedlessPasswordChangeLifecycle; + passwordChangePhase?: SeedlessPasswordChangePhase; }; /** diff --git a/packages/seedless-onboarding-controller/src/utils.test.ts b/packages/seedless-onboarding-controller/src/utils.test.ts index 6339fe82e8c..0fa4c358987 100644 --- a/packages/seedless-onboarding-controller/src/utils.test.ts +++ b/packages/seedless-onboarding-controller/src/utils.test.ts @@ -5,22 +5,18 @@ import { utf8ToBytes } from '@noble/ciphers/utils'; import { createMockJWTToken } from '../tests/mocks/utils.js'; import { SecretType, - SeedlessPasswordChangeErrorCode, SeedlessPasswordChangePhase, } from './constants.js'; import { SecretMetadata } from './SecretMetadata.js'; import type { DecodedNodeAuthToken } from './types.js'; import { - classifyPasswordChangeError, compareAndGetLatestToken, - createPasswordChangeLifecycle, decodeJWTToken, decodeNodeAuthToken, getInvalidPrimarySecretDataTypeErrorData, getPasswordChangePhase, getSecretTypeFromDataType, isValidPasswordChangePhaseTransition, - transitionPasswordChangeLifecycle, } from './utils.js'; describe('utils', () => { @@ -279,92 +275,20 @@ describe('utils', () => { }); }); - describe('createPasswordChangeLifecycle', () => { - it('creates a record with the given phase and no error code', () => { - expect( - createPasswordChangeLifecycle(SeedlessPasswordChangePhase.Idle), - ).toStrictEqual({ phase: SeedlessPasswordChangePhase.Idle }); - }); - - it('creates a record with a pending phase', () => { - expect( - createPasswordChangeLifecycle( - SeedlessPasswordChangePhase.SeedlessChangePending, - ), - ).toStrictEqual({ - phase: SeedlessPasswordChangePhase.SeedlessChangePending, - }); - }); - }); - describe('getPasswordChangePhase', () => { - it('returns IDLE when the lifecycle is undefined', () => { + it('returns IDLE when the phase is undefined', () => { expect(getPasswordChangePhase(undefined)).toBe( SeedlessPasswordChangePhase.Idle, ); }); - it('returns the stored phase when the lifecycle is defined', () => { + it('returns the stored phase when defined', () => { expect( - getPasswordChangePhase( - createPasswordChangeLifecycle( - SeedlessPasswordChangePhase.SeedlessCommitted, - ), - ), + getPasswordChangePhase(SeedlessPasswordChangePhase.SeedlessCommitted), ).toBe(SeedlessPasswordChangePhase.SeedlessCommitted); }); }); - describe('transitionPasswordChangeLifecycle', () => { - it('transitions from undefined (IDLE) to SEEDLESS_CHANGE_PENDING', () => { - const next = transitionPasswordChangeLifecycle( - undefined, - SeedlessPasswordChangePhase.SeedlessChangePending, - ); - expect(next.phase).toBe(SeedlessPasswordChangePhase.SeedlessChangePending); - expect(next.lastErrorCode).toBeUndefined(); - }); - - it('preserves lastErrorCode when transitioning without a new code', () => { - const lifecycle = { - phase: SeedlessPasswordChangePhase.SeedlessChangePending, - lastErrorCode: SeedlessPasswordChangeErrorCode.RemoteTimeout, - }; - const next = transitionPasswordChangeLifecycle( - lifecycle, - SeedlessPasswordChangePhase.Unknown, - ); - expect(next.phase).toBe(SeedlessPasswordChangePhase.Unknown); - expect(next.lastErrorCode).toBe( - SeedlessPasswordChangeErrorCode.RemoteTimeout, - ); - }); - - it('sets a new lastErrorCode when provided', () => { - const next = transitionPasswordChangeLifecycle( - undefined, - SeedlessPasswordChangePhase.Unknown, - SeedlessPasswordChangeErrorCode.RemoteAmbiguous, - ); - expect(next.lastErrorCode).toBe( - SeedlessPasswordChangeErrorCode.RemoteAmbiguous, - ); - }); - - it('clears lastErrorCode when transitioning to IDLE', () => { - const lifecycle = { - phase: SeedlessPasswordChangePhase.Unknown, - lastErrorCode: SeedlessPasswordChangeErrorCode.RemoteTimeout, - }; - const next = transitionPasswordChangeLifecycle( - lifecycle, - SeedlessPasswordChangePhase.Idle, - ); - expect(next.phase).toBe(SeedlessPasswordChangePhase.Idle); - expect(next.lastErrorCode).toBeUndefined(); - }); - }); - describe('isValidPasswordChangePhaseTransition', () => { it('allows IDLE to SEEDLESS_CHANGE_PENDING', () => { expect( @@ -378,9 +302,7 @@ describe('utils', () => { it('allows SEEDLESS_CHANGE_PENDING to SEEDLESS_COMMITTED', () => { expect( isValidPasswordChangePhaseTransition( - createPasswordChangeLifecycle( - SeedlessPasswordChangePhase.SeedlessChangePending, - ), + SeedlessPasswordChangePhase.SeedlessChangePending, SeedlessPasswordChangePhase.SeedlessCommitted, ), ).toBe(true); @@ -389,9 +311,7 @@ describe('utils', () => { it('allows SEEDLESS_CHANGE_PENDING to IDLE (definitive remote failure)', () => { expect( isValidPasswordChangePhaseTransition( - createPasswordChangeLifecycle( - SeedlessPasswordChangePhase.SeedlessChangePending, - ), + SeedlessPasswordChangePhase.SeedlessChangePending, SeedlessPasswordChangePhase.Idle, ), ).toBe(true); @@ -400,9 +320,7 @@ describe('utils', () => { it('allows SEEDLESS_COMMITTED to LOCAL_KEYRING_PENDING', () => { expect( isValidPasswordChangePhaseTransition( - createPasswordChangeLifecycle( - SeedlessPasswordChangePhase.SeedlessCommitted, - ), + SeedlessPasswordChangePhase.SeedlessCommitted, SeedlessPasswordChangePhase.LocalKeyringPending, ), ).toBe(true); @@ -411,9 +329,7 @@ describe('utils', () => { it('allows LOCAL_KEYRING_PENDING to KEY_SYNC_PENDING', () => { expect( isValidPasswordChangePhaseTransition( - createPasswordChangeLifecycle( - SeedlessPasswordChangePhase.LocalKeyringPending, - ), + SeedlessPasswordChangePhase.LocalKeyringPending, SeedlessPasswordChangePhase.KeySyncPending, ), ).toBe(true); @@ -422,9 +338,7 @@ describe('utils', () => { it('allows KEY_SYNC_PENDING to COMPLETE', () => { expect( isValidPasswordChangePhaseTransition( - createPasswordChangeLifecycle( - SeedlessPasswordChangePhase.KeySyncPending, - ), + SeedlessPasswordChangePhase.KeySyncPending, SeedlessPasswordChangePhase.Complete, ), ).toBe(true); @@ -433,7 +347,7 @@ describe('utils', () => { it('allows COMPLETE to IDLE (clear)', () => { expect( isValidPasswordChangePhaseTransition( - createPasswordChangeLifecycle(SeedlessPasswordChangePhase.Complete), + SeedlessPasswordChangePhase.Complete, SeedlessPasswordChangePhase.Idle, ), ).toBe(true); @@ -448,7 +362,7 @@ describe('utils', () => { ]) { expect( isValidPasswordChangePhaseTransition( - createPasswordChangeLifecycle(phase), + phase, SeedlessPasswordChangePhase.Unknown, ), ).toBe(true); @@ -465,7 +379,7 @@ describe('utils', () => { ]) { expect( isValidPasswordChangePhaseTransition( - createPasswordChangeLifecycle(SeedlessPasswordChangePhase.Unknown), + SeedlessPasswordChangePhase.Unknown, target, ), ).toBe(true); @@ -484,9 +398,7 @@ describe('utils', () => { it('rejects SEEDLESS_COMMITTED to IDLE (cannot skip back without definitive failure)', () => { expect( isValidPasswordChangePhaseTransition( - createPasswordChangeLifecycle( - SeedlessPasswordChangePhase.SeedlessCommitted, - ), + SeedlessPasswordChangePhase.SeedlessCommitted, SeedlessPasswordChangePhase.Idle, ), ).toBe(false); @@ -495,80 +407,10 @@ describe('utils', () => { it('rejects COMPLETE to SEEDLESS_CHANGE_PENDING (cannot restart from complete)', () => { expect( isValidPasswordChangePhaseTransition( - createPasswordChangeLifecycle(SeedlessPasswordChangePhase.Complete), + SeedlessPasswordChangePhase.Complete, SeedlessPasswordChangePhase.SeedlessChangePending, ), ).toBe(false); }); }); - - describe('classifyPasswordChangeError', () => { - it('classifies a fetch error as RemoteStatusUnavailable', () => { - expect( - classifyPasswordChangeError(new Error('Failed to fetch auth pub key')), - ).toBe(SeedlessPasswordChangeErrorCode.RemoteStatusUnavailable); - }); - - it('classifies a timeout error as RemoteTimeout', () => { - expect( - classifyPasswordChangeError(new Error('Request timeout')), - ).toBe(SeedlessPasswordChangeErrorCode.RemoteTimeout); - }); - - it('classifies a vault error as LocalVaultFailure', () => { - expect( - classifyPasswordChangeError(new Error('vault decryption failed')), - ).toBe(SeedlessPasswordChangeErrorCode.LocalVaultFailure); - }); - - it('classifies a keyring error as LocalKeyringFailure', () => { - expect( - classifyPasswordChangeError(new Error('Keyring operation failed')), - ).toBe(SeedlessPasswordChangeErrorCode.LocalKeyringFailure); - }); - - it('classifies a persistence error as PersistenceFailure', () => { - expect( - classifyPasswordChangeError(new Error('storage write failed')), - ).toBe(SeedlessPasswordChangeErrorCode.PersistenceFailure); - }); - - it('classifies a FailedToChangePassword error as RemoteAmbiguous', () => { - expect( - classifyPasswordChangeError( - new Error('SeedlessOnboardingController - Failed to change password'), - ), - ).toBe(SeedlessPasswordChangeErrorCode.RemoteAmbiguous); - }); - - it('classifies a changeEncKey error as RemoteAmbiguous', () => { - expect( - classifyPasswordChangeError(new Error('changeEncKey failed')), - ).toBe(SeedlessPasswordChangeErrorCode.RemoteAmbiguous); - }); - - it('classifies an unknown error as RemoteAmbiguous', () => { - expect(classifyPasswordChangeError(new Error('something went wrong'))).toBe( - SeedlessPasswordChangeErrorCode.RemoteAmbiguous, - ); - }); - - it('classifies a string error', () => { - expect( - classifyPasswordChangeError('timeout exceeded'), - ).toBe(SeedlessPasswordChangeErrorCode.RemoteTimeout); - }); - - it('classifies a non-Error non-string value as RemoteAmbiguous', () => { - expect(classifyPasswordChangeError(null)).toBe( - SeedlessPasswordChangeErrorCode.RemoteAmbiguous, - ); - expect(classifyPasswordChangeError(undefined)).toBe( - SeedlessPasswordChangeErrorCode.RemoteAmbiguous, - ); - expect(classifyPasswordChangeError({ foo: 'bar' })).toBe( - SeedlessPasswordChangeErrorCode.RemoteAmbiguous, - ); - }); - }); }); diff --git a/packages/seedless-onboarding-controller/src/utils.ts b/packages/seedless-onboarding-controller/src/utils.ts index 61a063e9656..5b51a2f3ef2 100644 --- a/packages/seedless-onboarding-controller/src/utils.ts +++ b/packages/seedless-onboarding-controller/src/utils.ts @@ -10,7 +10,6 @@ import { bytesToUtf8 } from '@noble/ciphers/utils'; import { SecretType, - SeedlessPasswordChangeErrorCode, SeedlessPasswordChangePhase, } from './constants.js'; import type { SecretMetadata } from './SecretMetadata.js'; @@ -19,7 +18,6 @@ import type { DecodedNodeAuthToken, DeserializedVaultData, InvalidPrimarySecretDataTypeErrorData, - SeedlessPasswordChangeLifecycle, VaultData, } from './types.js'; @@ -241,85 +239,15 @@ const LEGAL_PASSWORD_CHANGE_TRANSITIONS: Record< }; /** - * Create a new password-change lifecycle record at the given phase. + * Resolve a password-change phase, treating `undefined` as `IDLE`. * - * @param phase - The initial phase. - * @returns A new lifecycle record. - */ -export function createPasswordChangeLifecycle( - phase: SeedlessPasswordChangePhase, -): SeedlessPasswordChangeLifecycle { - return { phase }; -} - -/** - * Apply a phase transition to a lifecycle record, returning a new record. - * - * Preserves `lastErrorCode` from the previous record unless a new code is - * provided or the target phase is `IDLE` (which clears it). - * - * @param lifecycle - The current lifecycle record (or `undefined` for `IDLE`). - * @param phase - The target phase. - * @param lastErrorCode - Optional error code to set on the new record. - * @returns A new lifecycle record with the transitioned phase. - */ -export function transitionPasswordChangeLifecycle( - lifecycle: SeedlessPasswordChangeLifecycle | undefined, - phase: SeedlessPasswordChangePhase, - lastErrorCode?: SeedlessPasswordChangeErrorCode, -): SeedlessPasswordChangeLifecycle { - const preservedErrorCode = resolvePasswordChangeErrorCode( - lifecycle, - phase, - lastErrorCode, - ); - const next: SeedlessPasswordChangeLifecycle = { - phase, - ...(preservedErrorCode === undefined - ? {} - : { lastErrorCode: preservedErrorCode }), - }; - return next; -} - -/** - * Resolve the `lastErrorCode` to attach to a transitioned lifecycle record. - * - * A new code wins. Otherwise the previous code is preserved unless the target - * phase is `IDLE` (which clears it). - * - * @param lifecycle - The current lifecycle record (or `undefined` for `IDLE`). - * @param phase - The target phase. - * @param lastErrorCode - Optional error code to set on the new record. - * @returns The error code to attach, or `undefined` to omit it. - */ -function resolvePasswordChangeErrorCode( - lifecycle: SeedlessPasswordChangeLifecycle | undefined, - phase: SeedlessPasswordChangePhase, - lastErrorCode?: SeedlessPasswordChangeErrorCode, -): SeedlessPasswordChangeErrorCode | undefined { - if (lastErrorCode !== undefined) { - return lastErrorCode; - } - if ( - lifecycle?.lastErrorCode !== undefined && - phase !== SeedlessPasswordChangePhase.Idle - ) { - return lifecycle.lastErrorCode; - } - return undefined; -} - -/** - * Return the phase of a lifecycle record, treating `undefined` as `IDLE`. - * - * @param lifecycle - The lifecycle record, or `undefined`. - * @returns The phase, or `IDLE` if the record is missing. + * @param phase - The persisted phase, or `undefined`. + * @returns The phase, or `IDLE` if it is missing. */ export function getPasswordChangePhase( - lifecycle: SeedlessPasswordChangeLifecycle | undefined, + phase: SeedlessPasswordChangePhase | undefined, ): SeedlessPasswordChangePhase { - return lifecycle?.phase ?? SeedlessPasswordChangePhase.Idle; + return phase ?? SeedlessPasswordChangePhase.Idle; } /** @@ -333,56 +261,9 @@ export function getPasswordChangePhase( * @returns `true` if the transition is legal. */ export function isValidPasswordChangePhaseTransition( - from: SeedlessPasswordChangeLifecycle | undefined, + from: SeedlessPasswordChangePhase | undefined, to: SeedlessPasswordChangePhase, ): boolean { const fromPhase = getPasswordChangePhase(from); return LEGAL_PASSWORD_CHANGE_TRANSITIONS[fromPhase].includes(to); } - -/** - * Classify an error into a non-sensitive password-change error code. - * - * This inspects the error's `message` and `name` for known patterns. It must - * never persist the raw error message — only the closed set of codes. - * - * @param error - The error to classify. - * @returns A non-sensitive error code. - */ -export function classifyPasswordChangeError( - error: unknown, -): SeedlessPasswordChangeErrorCode { - let message = ''; - if (error instanceof Error) { - message = error.message; - } else if (typeof error === 'string') { - message = error; - } - - if (message.includes('fetch')) { - return SeedlessPasswordChangeErrorCode.RemoteStatusUnavailable; - } - if ( - message.includes('timeout') || - message.includes('Timeout') || - message.includes('TIMEOUT') - ) { - return SeedlessPasswordChangeErrorCode.RemoteTimeout; - } - if ( - message.includes('FailedToChangePassword') || - message.includes('changeEncKey') - ) { - return SeedlessPasswordChangeErrorCode.RemoteAmbiguous; - } - if (message.includes('vault')) { - return SeedlessPasswordChangeErrorCode.LocalVaultFailure; - } - if (message.includes('keyring') || message.includes('Keyring')) { - return SeedlessPasswordChangeErrorCode.LocalKeyringFailure; - } - if (message.includes('persist') || message.includes('storage')) { - return SeedlessPasswordChangeErrorCode.PersistenceFailure; - } - return SeedlessPasswordChangeErrorCode.RemoteAmbiguous; -} From 74d6643d318187a45f4f753e4d41b542e87bbc8c Mon Sep 17 00:00:00 2001 From: lwin Date: Wed, 9 Sep 2026 20:08:17 +0800 Subject: [PATCH 03/14] feat: password change failure recovery --- ...ess-password-change-implementation-plan.md | 98 +- ...0003-seedless-password-change-contracts.md | 26 +- ...ler-owned-password-change-recovery-plan.md | 90 ++ ...nboardingController-method-action-types.ts | 133 ++- .../src/SeedlessOnboardingController.test.ts | 964 ++++++++++++++++-- .../src/SeedlessOnboardingController.ts | 438 +++++++- .../src/constants.ts | 28 + .../src/index.ts | 7 +- .../src/utils.test.ts | 126 --- .../src/utils.ts | 64 -- 10 files changed, 1581 insertions(+), 393 deletions(-) create mode 100644 packages/seedless-onboarding-controller/docs/0004-controller-owned-password-change-recovery-plan.md diff --git a/packages/seedless-onboarding-controller/docs/0002-seedless-password-change-implementation-plan.md b/packages/seedless-onboarding-controller/docs/0002-seedless-password-change-implementation-plan.md index 7aebce6521e..660649f4592 100644 --- a/packages/seedless-onboarding-controller/docs/0002-seedless-password-change-implementation-plan.md +++ b/packages/seedless-onboarding-controller/docs/0002-seedless-password-change-implementation-plan.md @@ -19,10 +19,10 @@ Update the checkboxes as work is completed. Keep the phase status aligned with i | 0. External prerequisites | Complete | — | | 1. Lifecycle model | Complete | — | | 2. Controller lifecycle operations | Complete | — | -| 3. `changePassword` flow | Not started | Add server-first lifecycle boundaries | -| 4. Keyring-key storage | Not started | Couple encrypted-key storage to lifecycle persistence | -| 5. Recovery primitives | Not started | Preserve existing recovery methods and add recovery safeguards | -| 6. Messenger/package contracts | Not started | Update exports, action types, fixtures, and consumers | +| 3. `changePassword` flow | Complete | — | +| 4. Keyring-key storage | Complete | — | +| 5. Recovery primitives | Complete | — | +| 6. Messenger/package contracts | Complete | — | | 7. Client integration | Not started | Add coordinator, locking, unlock routing, UI, and E2E coverage | At the end of each phase, update its status and remove completed items from the remaining-work description. Keep unresolved items in [Open decisions before implementation](#open-decisions-before-implementation). @@ -185,20 +185,20 @@ Do not add a second mutex unless the existing controller mutex cannot protect th Refactor the current method without duplicating its cryptographic work: -- [ ] Acquire the existing controller lock. -- [ ] Reject a second concurrent password change; recovery must finish before a new one starts. -- [ ] Create/persist the lifecycle as `SEEDLESS_CHANGE_PENDING`. -- [ ] Reuse `verifyVaultPassword(oldPassword, { skipLock: true })`. -- [ ] Reuse `#assertPasswordInSync({ skipCache: true, skipLock: true })`. -- [ ] Reuse `loadKeyringEncryptionKey()` before the remote mutation when an encrypted Keyring key exists. -- [ ] Call `#changeEncryptionKey` through the existing `#executeWithTokenRefresh` wrapper. -- [ ] After authoritative remote commitment, persist `SEEDLESS_COMMITTED`. -- [ ] Reuse `#createNewVaultWithAuthData` to write the new local Seedless vault. -- [ ] Persist `LOCAL_KEYRING_PENDING` after local Seedless state has been updated. -- [ ] Reuse `storeKeyringEncryptionKey` for the encrypted local copy of the current Keyring key. -- [ ] Leave final Keyring re-encryption, local Keyring-key storage, and `COMPLETE` to the client coordinator. -- [ ] Preserve the existing error wrapping with `SeedlessOnboardingError`, but retain the last lifecycle phase when wrapping the error. -- [ ] Reset the password-outdated cache only after the local Seedless password update succeeds, using the existing `#resetPasswordOutdatedCache`. +- [x] Acquire the existing controller lock. +- [x] Reject a second concurrent password change; recovery must finish before a new one starts. +- [x] Create/persist the lifecycle as `SEEDLESS_CHANGE_PENDING`. +- [x] Reuse `verifyVaultPassword(oldPassword, { skipLock: true })`. +- [x] Reuse `#assertPasswordInSync({ skipCache: true, skipLock: true })`. +- [x] Reuse `loadKeyringEncryptionKey()` before the remote mutation when an encrypted Keyring key exists. +- [x] Call `#changeEncryptionKey` through the existing `#executeWithTokenRefresh` wrapper. +- [x] After authoritative remote commitment, persist `SEEDLESS_COMMITTED`. +- [x] Reuse `#createNewVaultWithAuthData` to write the new local Seedless vault. +- [x] Persist `LOCAL_KEYRING_PENDING` after local Seedless state has been updated. +- [x] Reuse `storeKeyringEncryptionKey` for the encrypted local copy of the current Keyring key. +- [x] Leave final Keyring re-encryption, local Keyring-key storage, and `COMPLETE` to the client coordinator. +- [x] Preserve the existing error wrapping with `SeedlessOnboardingError`, but retain the last lifecycle phase when wrapping the error. +- [x] Reset the password-outdated cache only after the local Seedless password update succeeds, using the existing `#resetPasswordOutdatedCache`. Important: a rejected Promise from `#changeEncryptionKey` does not prove that the server did not mutate. Only a definitive server result may return the lifecycle to `IDLE`. @@ -206,26 +206,27 @@ Important: a rejected Promise from `#changeEncryptionKey` does not prove that th Update `storeKeyringEncryptionKey` and its private helper with minimal behavior changes: -- [ ] Keep the current `#unlockVaultAndGetVaultData` call to obtain the Seedless password encryption key. -- [ ] Keep the current AES-GCM encryption and base64 encoding. -- [ ] Update `encryptedKeyringEncryptionKey` and the lifecycle boundary in the same controller update where possible, so observers do not see an unrelated intermediate lifecycle state. -- [ ] Await the selected durable persistence boundary after the encrypted key is stored. -- [ ] Allow the client to mark `KEY_SYNC_PENDING` before synchronization and `COMPLETE` only after synchronization verification and all local writes succeed. -- [ ] Never let `storeKeyringEncryptionKey` mark `COMPLETE` by itself. -- [ ] Keep `loadKeyringEncryptionKey` read-only with respect to lifecycle state; loading a key is not proof of recovery completion. +- [x] Keep the current `#unlockVaultAndGetVaultData` call to obtain the Seedless password encryption key. +- [x] Keep the current AES-GCM encryption and base64 encoding. +- [x] Update `encryptedKeyringEncryptionKey` and the lifecycle boundary in the same controller update where possible, so observers do not see an unrelated intermediate lifecycle state. +- [x] Allow the client to mark `KEY_SYNC_PENDING` before synchronization and `COMPLETE` only after synchronization verification and all local writes succeed. +- [x] Never let `storeKeyringEncryptionKey` mark `COMPLETE` by itself. +- [x] Keep `loadKeyringEncryptionKey` read-only with respect to lifecycle state; loading a key is not proof of recovery completion. + +> **Descoped:** A separate awaitable durable-persistence boundary for lifecycle writes is out of scope for the controller. The lifecycle is persisted as ordinary controller state via the normal debounced `stateChange` path (same as every other persisted field); there is no extra durability hook on the controller. Recovery must therefore always re-verify actual remote and local state before acting on the phase — a missing or stale marker is recoverable via `checkIsPasswordOutdated({ skipCache: true })` and cryptographic Keyring verification. See [Design summary](#design-summary) and [0003](./0003-seedless-password-change-contracts.md). ### Phase 5: Add recovery-facing controller behavior Keep cross-controller orchestration in the client, but make the controller primitives safe and explicit: -- [ ] `submitGlobalPassword({ globalPassword })` remains the entry point to recover the Seedless controller with the new password. -- [ ] `syncLatestGlobalPassword({ globalPassword })` remains the operation that rewrites the local Seedless vault after recovery. -- [ ] `loadKeyringEncryptionKey()` remains the old-Keyring recovery input. -- [ ] `storeKeyringEncryptionKey()` remains the local encrypted-key persistence operation. -- [ ] `checkIsPasswordOutdated({ skipCache: true })` must be used during recovery whenever the client needs a fresh auth-public-key comparison. -- [ ] Do not silently use a cached `passwordOutdatedCache` result on the recovery path. -- [ ] Preserve `#executeWithTokenRefresh` behavior for all existing password-sync operations. -- [ ] Ensure controller lock state is cleaned up correctly when recovery operations fail. +- [x] `submitGlobalPassword({ globalPassword })` remains the entry point to recover the Seedless controller with the new password. +- [x] `syncLatestGlobalPassword({ globalPassword })` remains the operation that rewrites the local Seedless vault after recovery. +- [x] `loadKeyringEncryptionKey()` remains the old-Keyring recovery input. +- [x] `storeKeyringEncryptionKey()` remains the local encrypted-key persistence operation. +- [x] `checkIsPasswordOutdated({ skipCache: true })` must be used during recovery whenever the client needs a fresh auth-public-key comparison. (Controller honors `skipCache`; covered by the "should bypass cache if skipCache is true" test.) +- [x] Do not silently use a cached `passwordOutdatedCache` result on the recovery path. (Satisfied by `skipCache` bypass.) +- [x] Preserve `#executeWithTokenRefresh` behavior for all existing password-sync operations. +- [x] Ensure controller lock state is cleaned up correctly when recovery operations fail. (`withLock` releases in `finally`.) The client coordinator then performs the two ADR branches: @@ -255,26 +256,32 @@ The client coordinator then performs the two ADR branches: If local cryptographic verification or remote status cannot establish the branch, mark `UNKNOWN` and keep the wallet locked. +> **Scope note:** The two ADR branches above (Old / New local Keyring) are client orchestration and are implemented in [Phase 7](#phase-7-implement-client-integration). No controller-package code is required for them beyond the primitives already preserved in this phase. + ### Phase 6: Update messenger and package contracts -- [ ] Update `src/SeedlessOnboardingController-method-action-types.ts` documentation and types for the lifecycle-aware `changePassword` behavior. -- [ ] Export the new lifecycle types and enum from `src/index.ts`. -- [ ] Check all generated/action type references compile without manually editing generated output beyond the source-of-truth file. -- [ ] Update package consumers and mock messengers that call `changePassword`. -- [ ] Preserve the existing `changePassword` signature and behavior for callers that do not opt into lifecycle-aware recovery. +- [x] Update `src/SeedlessOnboardingController-method-action-types.ts` documentation and types for the lifecycle-aware `changePassword` behavior. (Regenerated via `messenger-action-types:generate` after adding `clearPasswordChangePhase`, `markPasswordChangeKeySyncPending`, `completePasswordChange`, `resolvePasswordSyncState`, `recoverPasswordChange` to `MESSENGER_EXPOSED_METHODS`; `messenger-action-types:check` passes.) +- [x] Export the new lifecycle types and enum from `src/index.ts`. (`SeedlessPasswordChangePhase` and `PasswordChangeRecoveryStatus` enums exported from `./constants.js`; added the new action-type exports. The recovery methods return `PasswordChangeRecoveryStatus` directly, so no separate result type is exported.) +- [x] Check all generated/action type references compile without manually editing generated output beyond the source-of-truth file. (Only `MESSENGER_EXPOSED_METHODS` in the controller was hand-edited; the generated file was regenerated, not hand-edited.) +- [x] Update package consumers and mock messengers that call `changePassword`. (Mock messenger auto-derives from `SeedlessOnboardingControllerMessenger`; no external package references the removed `SeedlessPasswordChangeLifecycle`/`passwordChangeLifecycle`.) +- [x] Preserve the existing `changePassword` signature and behavior for callers that do not opt into lifecycle-aware recovery. (Signature unchanged; lifecycle is additive via new state field and methods.) +- [x] Add controller-owned Seedless-side recovery methods (`resolvePasswordSyncState` + `recoverPasswordChange`) so clients do not have to hand-orchestrate the Seedless half of recovery. `resolvePasswordSyncState` merges the legacy `checkIsPasswordOutdated` read with password-change recovery routing into a single unlock-time call. See [0004](./0004-controller-owned-password-change-recovery-plan.md) for the future Option B (full cross-controller recovery) migration. ### Phase 7: Implement client integration -This work is outside the controller package but is required for the ADR to be complete: +This work is outside the controller package but is required for the ADR to be complete. The controller side of recovery is already provided (Option A): `resolvePasswordSyncState()` resolves remote state without a password (merging the legacy `checkIsPasswordOutdated` read with password-change recovery routing), and `recoverPasswordChange({ globalPassword })` reconciles the Seedless side with the new password. The client owns the Keyring-side steps and UI routing based on the returned `PasswordChangeRecoveryStatus`. See [0004](./0004-controller-owned-password-change-recovery-plan.md) for the planned Option B migration where the controller also owns the Keyring side. - [ ] Add a single coordinator lock covering Seedless and Keyring password changes. The controller mutex already serializes controller operations; this lock extends serialization to the cross-controller transaction. - [ ] Persist `SEEDLESS_CHANGE_PENDING` before the first remote mutation. - [ ] Lock the wallet before exposing any password-change or recovery error. - [ ] On unlock, inspect the durable lifecycle before normal invalid-password handling. -- [ ] For every unfinished phase, bypass stale password-outdated cache and query remote state via `checkIsPasswordOutdated({ skipCache: true })`. +- [ ] For every unfinished phase, call `resolvePasswordSyncState()` first (password-less remote-state resolution); only prompt for the new password when it returns `enter-new-password`. +- [ ] After the user supplies the new password, call `recoverPasswordChange({ globalPassword })` to reconcile the Seedless side; on `reconcile-keyring`, run the Keyring-side branch below. - [ ] Use `KeyringController:verifyPassword` to classify old versus new local Keyring state. -- [ ] Use the old-Keyring or new-Keyring branch above (existing `submitGlobalPassword` + `syncLatestGlobalPassword` flow). -- [ ] Never retry `changePassword` or `changeEncKey`; reconcile only via the existing password-sync flow. +- [ ] Old-Keyring branch: `loadKeyringEncryptionKey` → `KeyringController:submitEncryptionKey` → `KeyringController:changePassword` → `storeKeyringEncryptionKey` → `markPasswordChangeKeySyncPending`. +- [ ] New-Keyring branch: `KeyringController:exportEncryptionKey` → `storeKeyringEncryptionKey` → `markPasswordChangeKeySyncPending`. +- [ ] `KEY_SYNC_PENDING`: unlock with the new password, export the current Keyring encryption key, store/sync it to the remote Seedless backup, then `completePasswordChange` → `clearPasswordChangePhase`. +- [ ] Never retry `changePassword` or `changeEncKey`; reconcile only via the recovery methods and the existing password-sync flow. - [ ] Keep the wallet locked and the phase `UNKNOWN` if the result is not distinguishable. - [ ] Persist `COMPLETE` only after local persistence is verified. - [ ] Offer reset wallet only as an explicit last resort for a confirmed unrecoverable state. @@ -300,6 +307,8 @@ Extend `src/SeedlessOnboardingController.test.ts` and add focused tests for: - [ ] Repeated lifecycle transitions being safe to re-run. - [ ] Existing token-refresh retry behavior remaining unchanged. - [ ] Existing `loadKeyringEncryptionKey` and `storeKeyringEncryptionKey` behavior remaining compatible. +- [ ] `resolvePasswordSyncState` returning the correct `PasswordChangeRecoveryStatus` for each phase, clearing to `IDLE` when remote did not commit, advancing to `SEEDLESS_COMMITTED` when remote committed, and returning `unknown` (preserving the phase) when the remote check fails. +- [ ] `recoverPasswordChange` reconciling the Seedless side and advancing to `LOCAL_KEYRING_PENDING` for `SEEDLESS_COMMITTED`/`LOCAL_KEYRING_PENDING`, and returning `unknown` (preserving the phase) when reconciliation fails. Use the existing fixtures and mocks in `tests/__fixtures__` and `tests/mocks`. Add only the remote-status mocks that the new contract requires. @@ -355,14 +364,15 @@ The implementation is ready when: ### This package -- `src/constants.ts` — lifecycle phase enum. +- `src/constants.ts` — lifecycle phase enum and `PasswordChangeRecoveryStatus` enum. - `src/types.ts` — password-change phase state field. - `src/utils.ts` — pure lifecycle helpers, if needed. -- `src/SeedlessOnboardingController.ts` — metadata, transition helpers, lifecycle-aware `changePassword`, and lifecycle-aware key storage. +- `src/SeedlessOnboardingController.ts` — metadata, transition helpers, lifecycle-aware `changePassword`, lifecycle-aware key storage, and controller-owned recovery methods (`resolvePasswordSyncState`, `recoverPasswordChange`). `resolvePasswordSyncState` folds the legacy `checkIsPasswordOutdated` read (now private `#checkIsPasswordOutdated`) into the unlock-time recovery routing. - `src/SeedlessOnboardingController-method-action-types.ts` — public action documentation/signature. - `src/index.ts` — public exports. - `src/SeedlessOnboardingController.test.ts` — unit and fault-injection coverage. - `docs/0003-seedless-password-change-contracts.md` — Phase 0 shared contract. +- `docs/0004-controller-owned-password-change-recovery-plan.md` — Option B (full cross-controller recovery) migration plan. - `tests/__fixtures__/*` and `tests/mocks/*` — lifecycle, status, and persistence fixtures as needed. ### Outside this package diff --git a/packages/seedless-onboarding-controller/docs/0003-seedless-password-change-contracts.md b/packages/seedless-onboarding-controller/docs/0003-seedless-password-change-contracts.md index 034067dbec6..fa03fafe703 100644 --- a/packages/seedless-onboarding-controller/docs/0003-seedless-password-change-contracts.md +++ b/packages/seedless-onboarding-controller/docs/0003-seedless-password-change-contracts.md @@ -58,9 +58,11 @@ Recovery rules: - Do **not** call `changeEncKey` again while remote classification is unknown. - After remote classification is **old** (server did not commit), clear the lifecycle to `IDLE`. A later password change is a fresh operation, not a retry. -- After remote classification is **new** (server committed), do not call `changeEncKey` again. Reconcile local state using the existing password-sync flow: `submitGlobalPassword` (unlock via server password-key history chain) → `syncLatestGlobalPassword` (rewrite local vault) → `loadKeyringEncryptionKey` / `storeKeyringEncryptionKey`. +- After remote classification is **new** (server committed), do not call `changeEncKey` again. Reconcile the Seedless side through the controller-owned recovery methods, which wrap the existing password-sync flow: `resolvePasswordSyncState()` (password-less remote-state resolution; also merges the legacy `checkIsPasswordOutdated` read) → `recoverPasswordChange({ globalPassword })` (runs `submitGlobalPassword` → `syncLatestGlobalPassword` internally and advances to `LOCAL_KEYRING_PENDING`). The client then owns the Keyring-side steps (`loadKeyringEncryptionKey` / `storeKeyringEncryptionKey`, `markPasswordChangeKeySyncPending`, `completePasswordChange`). - `storeKeyringEncryptionKey` of the already-current key is a local overwrite and is safe to re-run. +The controller-owned recovery methods return a `PasswordChangeRecoveryStatus` (`NoChange`, `EnterNewPassword`, `ReconcileKeyring`, `SyncKey`, `Complete`, `Unknown`) that the client routes on. This is Option A (controller owns the Seedless side; client owns the Keyring side). See [0004](./0004-controller-owned-password-change-recovery-plan.md) for the planned Option B migration where the controller also owns the Keyring side. + A transaction ID / idempotency key is out of scope — the TOPRF server does not accept one today, and adding it is not a simple server-side change. It remains a “good to have” for a future TOPRF release. ## Keyring encryption-key storage and recovery @@ -71,12 +73,15 @@ The Keyring encryption key is stored **locally** in controller state (`encrypted - `loadKeyringEncryptionKey` is read-only with respect to lifecycle. Loading a key does not complete recovery. - `storeKeyringEncryptionKey` must never mark `COMPLETE`. -Recovery reuses the existing password-sync flow, which already handles “remote changed, local is outdated”: +Recovery reuses the existing password-sync flow, which already handles “remote changed, local is outdated”. The controller now owns the Seedless-side sequencing through two public methods: + +1. `resolvePasswordSyncState()` — password-less. Merges the legacy `checkIsPasswordOutdated` read (now private `#checkIsPasswordOutdated`) with password-change recovery routing, so the client makes a single call at unlock. For `IDLE` it runs the authoritative outdated check (`skipCache` honored) and returns `no-change` or `password-outdated`. For `SEEDLESS_CHANGE_PENDING` it forces a remote check (ignoring `skipCache`), clears to `IDLE` if remote is **old**, advances to `SEEDLESS_COMMITTED` if remote is **new**, and returns `unknown` (preserving the phase) if the check fails. For all other phases it returns the matching status without a remote call. +2. `recoverPasswordChange({ globalPassword })` — password-consuming. For `SEEDLESS_COMMITTED` / `LOCAL_KEYRING_PENDING` it runs `submitGlobalPassword({ globalPassword })` (`toprfClient.recoverPwEncKey` walks the server-side password-key history chain `maxPwChainLength` to find the `pwEncKey` matching this device’s `authPubKey`, then unlocks the vault) → `syncLatestGlobalPassword` (rewrites the local Seedless vault with the new password’s keys), then advances to `LOCAL_KEYRING_PENDING` and returns `reconcile-keyring`. For `IDLE` it re-checks the remote password and, if outdated, runs the same password-sync flow without advancing any phase (another-device sync); if not outdated it is a no-op. On failure it returns `unknown` and preserves the phase. + +The client then owns the Keyring side based on the returned status: -1. `checkIsPasswordOutdated({ skipCache: true })` — fetches remote `authPubKey`, compares with local. Classifies old vs new. -2. `submitGlobalPassword({ globalPassword })` — calls `toprfClient.recoverPwEncKey`, which walks the server-side password-key history chain (`maxPwChainLength`) to find the `pwEncKey` matching this device’s `authPubKey`, then unlocks the vault with the new password. -3. `syncLatestGlobalPassword({ globalPassword })` — rewrites the local Seedless vault with the new password’s keys. -4. `loadKeyringEncryptionKey()` (old-Keyring branch) or `storeKeyringEncryptionKey(currentKey)` (new-Keyring branch) — recover or persist the Keyring encryption key locally. +3. `loadKeyringEncryptionKey()` (old-Keyring branch) or `storeKeyringEncryptionKey(currentKey)` (new-Keyring branch) — recover or persist the Keyring encryption key locally. +4. `markPasswordChangeKeySyncPending()` → `completePasswordChange()` → `clearPasswordChangePhase()` once the current key is synchronized and persisted. `COMPLETE` in this contract means: remote Seedless password is new, local Seedless vault is new, local Keyring uses the new password, and the current Keyring encryption key is durably stored via `storeKeyringEncryptionKey`. @@ -127,9 +132,9 @@ Unlock routing: 2. Read `passwordChangePhase`; treat missing as `IDLE`. 3. If phase is `IDLE` or `COMPLETE` (or `COMPLETE` already cleared to `IDLE`): continue normal unlock. 4. Otherwise: recovery-blocked path. Do not report the entered password as an ordinary Keyring unlock failure while recovery is pending. -5. For every unfinished phase, bypass `passwordOutdatedCache` and fetch remote `authPubKey`. -6. Classify the local Keyring with `KeyringController:verifyPassword` (new vs old). Do not infer that from the lifecycle phase. -7. If remote or local classification cannot be established, persist `UNKNOWN` and keep the wallet locked. +5. Call `resolvePasswordSyncState()` to resolve remote state without a password. Only prompt for the new password when it returns `enter-new-password`; if it returns `no-change`, unlock with the old password normally. +6. After the user supplies the new password, call `recoverPasswordChange({ globalPassword })` to reconcile the Seedless side. On `reconcile-keyring`, classify the local Keyring with `KeyringController:verifyPassword` (new vs old). Do not infer that from the lifecycle phase. +7. If remote or local classification cannot be established, keep the phase as-is (the recovery methods return `unknown` and preserve the phase) and keep the wallet locked. `COMPLETE` is not a second source of cryptographic truth. After a durable `COMPLETE`, the controller should clear to `IDLE` so the next unlock is normal. @@ -139,7 +144,8 @@ Unlock routing: - Phase 3 must **preserve the last known lifecycle phase** on ambiguous `changeEncKey` failures (e.g. leave `SEEDLESS_CHANGE_PENDING` in place) and must not reset to `IDLE` without an **old** remote classification. It must never retry `changeEncKey`. `UNKNOWN` is determined later by recovery, not written by the catch block. - Phase 4 couples local Keyring-key storage to a lifecycle write; no remote key-sync API is needed. - Phase 5 reuses `submitGlobalPassword` and `syncLatestGlobalPassword` as the recovery mechanism. -- Phase 7 clients must implement the unlock-time read of the persisted lifecycle. They must not start a second password change while the lifecycle is unfinished, and must never retry `changePassword` / `changeEncKey`. +- Phase 6 exposes the controller-owned recovery methods (`resolvePasswordSyncState`, `recoverPasswordChange`) and the `PasswordChangeRecoveryStatus` enum through the messenger and package exports. The legacy `checkIsPasswordOutdated` is folded into `resolvePasswordSyncState` (now private `#checkIsPasswordOutdated`). +- Phase 7 clients must implement the unlock-time read of the persisted lifecycle and route through `resolvePasswordSyncState` / `recoverPasswordChange` for the Seedless side, owning only the Keyring side (Option A). They must not start a second password change while the lifecycle is unfinished, and must never retry `changePassword` / `changeEncKey`. Option B (controller owning the Keyring side too) is planned in [0004](./0004-controller-owned-password-change-recovery-plan.md). ## Existing TOPRF endpoints used by recovery diff --git a/packages/seedless-onboarding-controller/docs/0004-controller-owned-password-change-recovery-plan.md b/packages/seedless-onboarding-controller/docs/0004-controller-owned-password-change-recovery-plan.md new file mode 100644 index 00000000000..16567a736eb --- /dev/null +++ b/packages/seedless-onboarding-controller/docs/0004-controller-owned-password-change-recovery-plan.md @@ -0,0 +1,90 @@ +# Plan 0004: Migrate password-change recovery into the controller (Option B) + +- Status: Planned (post-testing migration) +- Related: [ADR 0001](./0001-seedless-password-change-recovery.md), [Implementation plan 0002](./0002-seedless-password-change-implementation-plan.md), [Contracts 0003](./0003-seedless-password-change-contracts.md) +- Scope: `SeedlessOnboardingController` only + +## Context + +The first implementation ([0002](./0002-seedless-password-change-implementation-plan.md)) ships **Option A**: the controller owns all *Seedless-side* recovery sequencing, but the *Keyring-side* steps (`verifyPassword`, `submitEncryptionKey`, `changePassword`, `exportEncryptionKey`) stay in the client because `SeedlessOnboardingController` has no `KeyringController` dependency (`AllowedActions = never`). + +This document plans the migration to **Option B**: the controller owns the entire recovery, including the Keyring side. Motivation: the recovery transaction spans two controllers, and we cannot rely on every client sequencing the Keyring-side steps correctly. Centralizing the full transaction removes a class of client-integration bugs. + +This migration is deferred until Option A is shipped and tested, so the recovery contract is exercised end-to-end before the coupling is introduced. + +## Goal + +A single controller method performs the entire recovery for any non-IDLE phase and returns only a final status. The client no longer sequences Seedless or Keyring operations; it only supplies the password and reacts to the status. + +## Current state (Option A) + +- `recoverPasswordChange({ globalPassword })` does the Seedless-side steps (`#checkIsPasswordOutdated({ skipCache: true })`, `submitGlobalPassword`, `syncLatestGlobalPassword`, lifecycle advances) and returns a result describing the remaining Keyring-side step. Remote-state resolution for `SeedlessChangePending` is owned by `resolvePasswordSyncState()` (password-less), which the client calls first. +- The client classifies the local Keyring via `KeyringController:verifyPassword`, then runs the old-Keyring or new-Keyring branch itself, calling `KeyringController:submitEncryptionKey` / `changePassword` / `exportEncryptionKey` and the controller's `loadKeyringEncryptionKey` / `storeKeyringEncryptionKey` / `markPasswordChangeKeySyncPending` / `completePasswordChange`. +- `AllowedActions = never`; the controller does not call `KeyringController`. + +## Target state (Option B) + +- `AllowedActions` includes `KeyringController:verifyPassword`, `KeyringController:submitEncryptionKey`, `KeyringController:changePassword`, `KeyringController:exportEncryptionKey` (and `KeyringController:setLocked` if locking is folded in). +- `recoverPasswordChange({ globalPassword })` performs the full transaction: + 1. Resolve remote state for `SeedlessChangePending` via `resolvePasswordSyncState()` (which runs `#checkIsPasswordOutdated({ skipCache: true })`). + 2. Reconcile the Seedless side (`submitGlobalPassword` + `syncLatestGlobalPassword`) for `SeedlessCommitted` / `LocalKeyringPending`. + 3. Classify the local Keyring via `KeyringController:verifyPassword(newPassword)`. + 4. Old-Keyring branch: `loadKeyringEncryptionKey` → `KeyringController:submitEncryptionKey` → `KeyringController:changePassword` → `KeyringController:exportEncryptionKey` → `storeKeyringEncryptionKey` → `markPasswordChangeKeySyncPending`. + 5. New-Keyring branch: `KeyringController:exportEncryptionKey` → `storeKeyringEncryptionKey` → `markPasswordChangeKeySyncPending`. + 6. `KeySyncPending`: `KeyringController:exportEncryptionKey` → `storeKeyringEncryptionKey` → remote key sync → `completePasswordChange` → `clearPasswordChangePhase`. + 7. Return a final status only (`PasswordChangeRecoveryStatus.NoChange | Complete | Unknown`). +- The client supplies the password, calls one method, and routes UI from the status. It performs no cross-controller sequencing. + +## Changes + +### 1. Messenger dependency + +- Add the `KeyringController` action types to `AllowedActions` in `SeedlessOnboardingController.ts`. +- Confirm there is no cycle: `KeyringController` must not depend on `SeedlessOnboardingController`. (Expected to hold; `KeyringController` is lower-level.) +- Update `SeedlessOnboardingControllerMessenger` and any package-level messenger assembly/permission wiring so the controller is granted the `KeyringController:*` actions it calls. +- Update the mock messenger (`tests/__fixtures__/mockMessenger.ts`) so `KeyringController` actions are callable in tests. + +### 2. Controller method + +- Fold the Keyring-side steps into `recoverPasswordChange`. Keep the existing private helpers; replace the "return a plan" shape with a final-status shape. +- Preserve all existing invariants: + - No retries of `changePassword` / `changeEncKey`; reconcile only via the password-sync flow. + - Preserve the last known phase on failure; do not write `UNKNOWN` from the happy path. + - Keep `storeKeyringEncryptionKey` lifecycle-neutral as a public method (the internal coupling still uses the private `#persistKeyringEncryptionKey` with a phase). + - Serialize under `#withControllerLock`. The cross-controller Keyring operations happen while the controller lock is held; document that the client coordinator lock (Phase 7) must not deadlock with it. + +### 3. Contracts and exports + +- Update [0003](./0003-seedless-password-change-contracts.md): the client contract shrinks to "call `recoverPasswordChange`, route on status". The Keyring-side client steps move to the controller. +- Update [0002](./0002-seedless-password-change-implementation-plan.md) Phase 7 controller-side items and the progress tracker. +- Re-export the new result/status types from `src/index.ts`. +- Regenerate `SeedlessOnboardingController-method-action-types.ts` (the method signature change is picked up automatically). + +### 4. Clients + +- Remove client-side Keyring-side recovery sequencing (the old-Keyring / new-Keyring branches). +- Keep: the single coordinator lock, wallet locking on error, unlock routing to call `recoverPasswordChange`, and UI per status. +- Update client tests to assert against the new status-only result. + +## Trade-offs and risks + +- **Coupling:** `SeedlessOnboardingController` gains a hard dependency on `KeyringController`. This is precedented in the monorepo but breaks this controller's current self-contained design. Reuse in contexts without `KeyringController` becomes harder. +- **Lock ordering:** the controller lock now spans `KeyringController` calls. The client coordinator lock must order consistently with it to avoid deadlock. Document the ordering; prefer the controller acquiring its lock first and the coordinator lock wrapping the whole call. +- **Test surface:** controller tests must mock `KeyringController` actions; fault-injection moves from client E2E into controller unit tests. +- **Rollback:** if Option B proves problematic, Option A remains the fallback. Keep the Option A return shape recoverable by reverting the messenger dependency and method body. + +## Test plan + +- Controller unit tests for every phase, each branch (old/new Keyring), and each failure injection point (remote check error, `submitGlobalPassword` error, `verifyPassword` error, `submitEncryptionKey` error, `changePassword` error, `exportEncryptionKey` error, `storeKeyringEncryptionKey` error, remote key-sync error). +- Assert the final status and the resulting `passwordChangePhase` for each. +- Assert no `changePassword`/`changeEncKey` retry occurs on any recovery path. +- Assert the controller lock is released on every failure path. +- Client integration tests reduced to: one call per phase, status routing, locking, and UI. + +## Migration order + +1. Land Option A and ship it; gather client integration feedback. +2. Add the `KeyringController` messenger dependency and mock wiring (behind no behavior change yet). +3. Fold the Keyring-side steps into `recoverPasswordChange`; change the return shape to final status. +4. Update contracts (0003), plan (0002), exports, and clients. +5. Run the full controller + client test suites; remove the now-dead client sequencing code. diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts index 1281dd0c075..fb54809621c 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts @@ -210,21 +210,6 @@ export type SeedlessOnboardingControllerSubmitGlobalPasswordAction = { handler: SeedlessOnboardingController['submitGlobalPassword']; }; -/** - * @description Check if the current password is outdated compare to the global password. - * - * @param options - Optional options object. - * @param options.globalAuthPubKey - The global auth public key to compare with the current auth public key. - * If not provided, the global auth public key will be fetched from the backend. - * @param options.skipCache - If true, bypass the cache and force a fresh check. - * @param options.skipLock - Whether to skip the lock acquisition. (to prevent deadlock in case the caller already acquired the lock) - * @returns A promise that resolves to true if the password is outdated, false otherwise. - */ -export type SeedlessOnboardingControllerCheckIsPasswordOutdatedAction = { - type: `SeedlessOnboardingController:checkIsPasswordOutdated`; - handler: SeedlessOnboardingController['checkIsPasswordOutdated']; -}; - /** * Check if the user is authenticated with the seedless onboarding flow by checking the token values in the state. * @@ -253,6 +238,9 @@ export type SeedlessOnboardingControllerClearStateAction = { * Store the keyring encryption key in state, encrypted under the current * encryption key. * + * Remains lifecycle-neutral: the client calls this during recovery without + * advancing the lifecycle phase. + * * @param keyringEncryptionKey - The keyring encryption key. */ export type SeedlessOnboardingControllerStoreKeyringEncryptionKeyAction = { @@ -271,6 +259,115 @@ export type SeedlessOnboardingControllerLoadKeyringEncryptionKeyAction = { handler: SeedlessOnboardingController['loadKeyringEncryptionKey']; }; +/** + * Clear the password-change lifecycle to `IDLE`. + * + * This is an explicit operation used after a definitive remote failure + * (server did not commit) or after `COMPLETE`. The controller clears to + * `IDLE` so the next unlock is normal. + * + * @returns A promise that resolves once the lifecycle has been cleared. + */ +export type SeedlessOnboardingControllerClearPasswordChangePhaseAction = { + type: `SeedlessOnboardingController:clearPasswordChangePhase`; + handler: SeedlessOnboardingController['clearPasswordChangePhase']; +}; + +/** + * Mark the password-change lifecycle as `KEY_SYNC_PENDING`. + * + * Called by the client coordinator before it synchronizes the current + * Keyring encryption key to Seedless. The controller only records the + * boundary; it does not perform or verify synchronization. + * + * @returns A promise that resolves once the phase has been persisted. + */ +export type SeedlessOnboardingControllerMarkPasswordChangeKeySyncPendingAction = + { + type: `SeedlessOnboardingController:markPasswordChangeKeySyncPending`; + handler: SeedlessOnboardingController['markPasswordChangeKeySyncPending']; + }; + +/** + * Mark the password-change lifecycle as `COMPLETE`. + * + * Called by the client coordinator only after Keyring encryption-key + * synchronization is verified and all required local writes have succeeded. + * The controller only records the boundary; it does not infer completion + * from this call. Follow with `clearPasswordChangePhase` to return to + * `IDLE` once the durable `COMPLETE` state is no longer needed as a signal. + * + * @returns A promise that resolves once the phase has been persisted. + */ +export type SeedlessOnboardingControllerCompletePasswordChangeAction = { + type: `SeedlessOnboardingController:completePasswordChange`; + handler: SeedlessOnboardingController['completePasswordChange']; +}; + +/** + * Resolve the current password-sync state without consuming a password. + * + * Merges the legacy `checkIsPasswordOutdated` read with password-change + * recovery routing, so the client makes a single call at unlock (both on + * page render and on password submit) and routes UI from the returned status. + * + * Phase handling: + * - `IDLE`: run the authoritative outdated check. `skipCache` is honored, so + * the client can read from cache on render and force a remote call on + * submit. Returns `NoChange` (in sync) or `PasswordOutdated` (another device + * changed the remote password). + * - `SEEDLESS_CHANGE_PENDING`: the remote outcome is ambiguous, so `skipCache` + * is ignored and a remote check is forced. Clears to `IDLE` (remote did not + * commit) or advances to `SEEDLESS_COMMITTED` (remote committed). Returns + * `NoChange` or `EnterNewPassword`. + * - Other phases: return the next recovery step without mutating state. + * + * This method does not consume a password; the client prompts for the + * correct password and then calls `recoverPasswordChange`. + * + * @param options - The options. + * @param options.skipCache - Whether to bypass the outdated cache. Ignored + * for `SEEDLESS_CHANGE_PENDING`, which always forces a remote check. + * @returns The sync/recovery resolution. On any failure the last known phase + * is preserved and `PasswordChangeRecoveryStatus.Unknown` is returned. + */ +export type SeedlessOnboardingControllerResolvePasswordSyncStateAction = { + type: `SeedlessOnboardingController:resolvePasswordSyncState`; + handler: SeedlessOnboardingController['resolvePasswordSyncState']; +}; + +/** + * Reconcile the Seedless side of a password-change recovery — or a plain + * remote password sync — with the supplied password. + * + * For `SEEDLESS_COMMITTED` or `LOCAL_KEYRING_PENDING` it re-runs the existing + * password-sync flow (`submitGlobalPassword` + `syncLatestGlobalPassword`) + * with the new password and advances the phase to `LOCAL_KEYRING_PENDING`. + * These operations are idempotent, so re-running them is safe whether or not + * the local Seedless vault was already rewritten. The controller is left + * unlocked. + * + * For `IDLE` it re-checks whether the remote password is outdated and, if so, + * runs the same password-sync flow without advancing any phase (there is no + * local password-change lifecycle in flight — e.g. another device changed + * the remote password). If the remote password is not outdated it is a no-op. + * + * The client remains responsible for the Keyring side (classifying the local + * Keyring via `KeyringController:verifyPassword` and running the old-Keyring + * or new-Keyring branch), because this controller does not depend on + * `KeyringController`. See + * [0004](./docs/0004-controller-owned-password-change-recovery-plan.md). + * + * @param params - The recovery parameters. + * @param params.globalPassword - The new global password. + * @returns The recovery result. On any failure the last known phase is + * preserved and `PasswordChangeRecoveryStatus.Unknown` is returned. + */ +export type SeedlessOnboardingControllerRecoverPasswordChangeAction = { + type: `SeedlessOnboardingController:recoverPasswordChange`; + handler: SeedlessOnboardingController['recoverPasswordChange']; +}; + /** * Refresh expired nodeAuthTokens, accessToken, and metadataAccessToken using * the stored refresh token. @@ -378,11 +475,15 @@ export type SeedlessOnboardingControllerMethodActions = | SeedlessOnboardingControllerSetLockedAction | SeedlessOnboardingControllerSyncLatestGlobalPasswordAction | SeedlessOnboardingControllerSubmitGlobalPasswordAction - | SeedlessOnboardingControllerCheckIsPasswordOutdatedAction | SeedlessOnboardingControllerGetIsUserAuthenticatedAction | SeedlessOnboardingControllerClearStateAction | SeedlessOnboardingControllerStoreKeyringEncryptionKeyAction | SeedlessOnboardingControllerLoadKeyringEncryptionKeyAction + | SeedlessOnboardingControllerClearPasswordChangePhaseAction + | SeedlessOnboardingControllerMarkPasswordChangeKeySyncPendingAction + | SeedlessOnboardingControllerCompletePasswordChangeAction + | SeedlessOnboardingControllerResolvePasswordSyncStateAction + | SeedlessOnboardingControllerRecoverPasswordChangeAction | SeedlessOnboardingControllerRefreshAuthTokensAction | SeedlessOnboardingControllerRotateRefreshTokenAction | SeedlessOnboardingControllerRevokePendingRefreshTokensAction diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 792091c21d9..0d7f2b1a8b8 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -71,6 +71,7 @@ import { AuthConnection, SecretType, SeedlessPasswordChangePhase, + PasswordChangeRecoveryStatus, } from './constants.js'; import { PasswordSyncError, RecoveryError } from './errors.js'; import { SecretMetadata } from './SecretMetadata.js'; @@ -1121,8 +1122,8 @@ describe('SeedlessOnboardingController', () => { }); }); - describe('checkPasswordOutdated', () => { - it('should return false if password is not outdated (authPubKey matches)', async () => { + describe('resolvePasswordSyncState (IDLE phase: outdated check)', () => { + it('should return NoChange if password is not outdated (authPubKey matches)', async () => { await withController( { state: getMockInitialControllerState({ @@ -1134,21 +1135,21 @@ describe('SeedlessOnboardingController', () => { const spy = jest.spyOn(toprfClient, 'fetchAuthPubKey'); mockFetchAuthPubKey(toprfClient, base64ToBytes(MOCK_AUTH_PUB_KEY)); const result = await baseMessenger.call( - 'SeedlessOnboardingController:checkIsPasswordOutdated', + 'SeedlessOnboardingController:resolvePasswordSyncState', ); - expect(result).toBe(false); + expect(result).toBe(PasswordChangeRecoveryStatus.NoChange); // Call again to test cache const result2 = await baseMessenger.call( - 'SeedlessOnboardingController:checkIsPasswordOutdated', + 'SeedlessOnboardingController:resolvePasswordSyncState', ); - expect(result2).toBe(false); + expect(result2).toBe(PasswordChangeRecoveryStatus.NoChange); // Should only call fetchAuthPubKey once due to cache expect(spy).toHaveBeenCalledTimes(1); }, ); }); - it('should return true if password is outdated (authPubKey does not match)', async () => { + it('should return PasswordOutdated if password is outdated (authPubKey does not match)', async () => { await withController( { state: getMockInitialControllerState({ @@ -1160,14 +1161,14 @@ describe('SeedlessOnboardingController', () => { const spy = jest.spyOn(toprfClient, 'fetchAuthPubKey'); mockFetchAuthPubKey(toprfClient, base64ToBytes(MOCK_AUTH_PUB_KEY)); const result = await baseMessenger.call( - 'SeedlessOnboardingController:checkIsPasswordOutdated', + 'SeedlessOnboardingController:resolvePasswordSyncState', ); - expect(result).toBe(true); + expect(result).toBe(PasswordChangeRecoveryStatus.PasswordOutdated); // Call again to test cache const result2 = await baseMessenger.call( - 'SeedlessOnboardingController:checkIsPasswordOutdated', + 'SeedlessOnboardingController:resolvePasswordSyncState', ); - expect(result2).toBe(true); + expect(result2).toBe(PasswordChangeRecoveryStatus.PasswordOutdated); // Should only call fetchAuthPubKey once due to cache expect(spy).toHaveBeenCalledTimes(1); }, @@ -1186,26 +1187,26 @@ describe('SeedlessOnboardingController', () => { const spy = jest.spyOn(toprfClient, 'fetchAuthPubKey'); mockFetchAuthPubKey(toprfClient, base64ToBytes(MOCK_AUTH_PUB_KEY)); const result = await baseMessenger.call( - 'SeedlessOnboardingController:checkIsPasswordOutdated', + 'SeedlessOnboardingController:resolvePasswordSyncState', { skipCache: true, }, ); - expect(result).toBe(false); + expect(result).toBe(PasswordChangeRecoveryStatus.NoChange); // Call again with skipCache: true, should call fetchAuthPubKey again const result2 = await baseMessenger.call( - 'SeedlessOnboardingController:checkIsPasswordOutdated', + 'SeedlessOnboardingController:resolvePasswordSyncState', { skipCache: true, }, ); - expect(result2).toBe(false); + expect(result2).toBe(PasswordChangeRecoveryStatus.NoChange); expect(spy).toHaveBeenCalledTimes(2); }, ); }); - it('should throw SRPNotBackedUpError if no authPubKey in state', async () => { + it('should return Unknown if no authPubKey in state', async () => { await withController( { state: getMockInitialControllerState({ @@ -1213,18 +1214,15 @@ describe('SeedlessOnboardingController', () => { }), }, async ({ baseMessenger }) => { - await expect( - baseMessenger.call( - 'SeedlessOnboardingController:checkIsPasswordOutdated', - ), - ).rejects.toThrow( - SeedlessOnboardingControllerErrorMessage.SRPNotBackedUpError, + const result = await baseMessenger.call( + 'SeedlessOnboardingController:resolvePasswordSyncState', ); + expect(result).toBe(PasswordChangeRecoveryStatus.Unknown); }, ); }); - it('should throw InsufficientAuthToken if no nodeAuthTokens in state', async () => { + it('should return Unknown if no nodeAuthTokens in state', async () => { await withController( { state: { @@ -1236,18 +1234,15 @@ describe('SeedlessOnboardingController', () => { }, }, async ({ baseMessenger }) => { - await expect( - baseMessenger.call( - 'SeedlessOnboardingController:checkIsPasswordOutdated', - ), - ).rejects.toThrow( - SeedlessOnboardingControllerErrorMessage.InsufficientAuthToken, + const result = await baseMessenger.call( + 'SeedlessOnboardingController:resolvePasswordSyncState', ); + expect(result).toBe(PasswordChangeRecoveryStatus.Unknown); }, ); }); - it('should throw FailedToFetchAuthPubKey error when fetchAuthPubKey fails', async () => { + it('should return Unknown when fetchAuthPubKey fails', async () => { await withController( { state: getMockInitialControllerState({ @@ -1261,13 +1256,10 @@ describe('SeedlessOnboardingController', () => { .spyOn(toprfClient, 'fetchAuthPubKey') .mockRejectedValueOnce(new Error('Network error')); - await expect( - baseMessenger.call( - 'SeedlessOnboardingController:checkIsPasswordOutdated', - ), - ).rejects.toThrow( - SeedlessOnboardingControllerErrorMessage.FailedToFetchAuthPubKey, + const result = await baseMessenger.call( + 'SeedlessOnboardingController:resolvePasswordSyncState', ); + expect(result).toBe(PasswordChangeRecoveryStatus.Unknown); }, ); }); @@ -1903,6 +1895,43 @@ describe('SeedlessOnboardingController', () => { }); }); + it('should throw PasswordChangeInProgress if a password change is pending', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + passwordChangePhase: + SeedlessPasswordChangePhase.SeedlessCommitted, + }), + }, + async ({ baseMessenger }) => { + // Unlock first so #assertIsUnlocked() passes; the phase stays + // non-IDLE because submitPassword does not touch it. + await baseMessenger.call( + 'SeedlessOnboardingController:submitPassword', + MOCK_PASSWORD, + ); + + await expect( + baseMessenger.call( + 'SeedlessOnboardingController:addNewSecretData', + NEW_KEY_RING_1.seedPhrase, + EncAccountDataType.ImportedSrp, + { + keyringId: NEW_KEY_RING_1.id, + }, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.PasswordChangeInProgress, + ); + }, + ); + }); + it('should be able to add a new seed phrase backup', async () => { await withController( { @@ -4050,6 +4079,80 @@ describe('SeedlessOnboardingController', () => { ); }); + it('should set LOCAL_KEYRING_PENDING and the re-encrypted keyring key in the same state update', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + }, + async ({ controller, toprfClient, baseMessenger }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + baseMessenger, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + // Store an existing keyring encryption key so changePassword + // exercises the re-encryption path. + await baseMessenger.call( + 'SeedlessOnboardingController:storeKeyringEncryptionKey', + MOCK_KEYRING_ENCRYPTION_KEY, + ); + const oldEncryptedKeyringEncryptionKey = + controller.state.encryptedKeyringEncryptionKey; + + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + mockRecoverEncKey(toprfClient, MOCK_PASSWORD); + mockChangeEncKey(toprfClient, NEW_MOCK_PASSWORD); + + // Capture the encryptedKeyringEncryptionKey value at the moment + // LOCAL_KEYRING_PENDING first appears. If the phase and the + // re-encrypted key are written in the same update, this equals + // the final value; if they are separate updates, it is the stale + // (pre-re-encryption) value. + let keyAtLocalKeyringPending: string | undefined; + baseMessenger.subscribe( + 'SeedlessOnboardingController:stateChange', + (state) => { + if ( + state.passwordChangePhase === + SeedlessPasswordChangePhase.LocalKeyringPending && + keyAtLocalKeyringPending === undefined + ) { + keyAtLocalKeyringPending = state.encryptedKeyringEncryptionKey; + } + }, + ); + + await baseMessenger.call( + 'SeedlessOnboardingController:changePassword', + NEW_MOCK_PASSWORD, + MOCK_PASSWORD, + ); + + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.LocalKeyringPending, + ); + // The phase and the re-encrypted key are set in one update, so the + // key at the moment the phase advances is already the new one. + expect(keyAtLocalKeyringPending).toStrictEqual( + controller.state.encryptedKeyringEncryptionKey, + ); + expect(keyAtLocalKeyringPending).not.toStrictEqual( + oldEncryptedKeyringEncryptionKey, + ); + }, + ); + }); + it('should be able to update new password without groupedAuthConnectionId', async () => { await withController( { @@ -4153,6 +4256,51 @@ describe('SeedlessOnboardingController', () => { }); }); + it('should reject a second password change while one is already in progress', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: + SeedlessPasswordChangePhase.SeedlessChangePending, + }), + }, + async ({ controller, toprfClient, baseMessenger }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + baseMessenger, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + // A previous change left the lifecycle in a non-IDLE phase (e.g. a + // crash after the remote commit). Recovery has not finished, so a + // fresh change must not start. + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.SeedlessChangePending, + ); + + await expect( + baseMessenger.call( + 'SeedlessOnboardingController:changePassword', + NEW_MOCK_PASSWORD, + MOCK_PASSWORD, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.PasswordChangeInProgress, + ); + + // The guard must not mutate the persisted phase. + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.SeedlessChangePending, + ); + }, + ); + }); + it('should throw error if password is outdated', async () => { await withController( { @@ -4561,79 +4709,720 @@ describe('SeedlessOnboardingController', () => { ); }); }); + + describe('markPasswordChangeKeySyncPending', () => { + it('advances the lifecycle to KEY_SYNC_PENDING', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + passwordChangePhase: + SeedlessPasswordChangePhase.LocalKeyringPending, + }), + }, + async ({ controller }) => { + await controller.markPasswordChangeKeySyncPending(); + + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.KeySyncPending, + ); + }, + ); + }); + + it('is a no-op when already KEY_SYNC_PENDING', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + passwordChangePhase: + SeedlessPasswordChangePhase.KeySyncPending, + }), + }, + async ({ controller }) => { + await controller.markPasswordChangeKeySyncPending(); + + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.KeySyncPending, + ); + }, + ); + }); + }); + + describe('completePasswordChange', () => { + it('advances the lifecycle to COMPLETE', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + passwordChangePhase: + SeedlessPasswordChangePhase.KeySyncPending, + }), + }, + async ({ controller }) => { + await controller.completePasswordChange(); + + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.Complete, + ); + }, + ); + }); + + it('is a no-op when already COMPLETE', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + passwordChangePhase: SeedlessPasswordChangePhase.Complete, + }), + }, + async ({ controller }) => { + await controller.completePasswordChange(); + + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.Complete, + ); + }, + ); + }); + }); }); - describe('clearState', () => { - it('should clear the state', async () => { + describe('resolvePasswordSyncState (recovery phases)', () => { + it('returns no-change when the phase is IDLE and the password is in sync', async () => { await withController( { state: getMockInitialControllerState({ withMockAuthenticatedUser: true, + withMockAuthPubKey: true, }), }, - async ({ controller, baseMessenger }) => { - const { state } = controller; - - expect(state.nodeAuthTokens).toBeDefined(); - expect(state.userId).toBeDefined(); - expect(state.authConnectionId).toBeDefined(); + async ({ toprfClient, controller }) => { + mockFetchAuthPubKey(toprfClient, base64ToBytes(MOCK_AUTH_PUB_KEY)); + const result = await controller.resolvePasswordSyncState(); + expect(result).toBe(PasswordChangeRecoveryStatus.NoChange); + expect(controller.state.passwordChangePhase).toBeUndefined(); + }, + ); + }); - baseMessenger.call('SeedlessOnboardingController:clearState'); - expect(controller.state).toStrictEqual( - getInitialSeedlessOnboardingControllerStateWithDefaults(), + it('returns enter-new-password when the phase is SEEDLESS_COMMITTED', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: + SeedlessPasswordChangePhase.SeedlessCommitted, + }), + }, + async ({ controller }) => { + const result = await controller.resolvePasswordSyncState(); + expect(result).toBe( + PasswordChangeRecoveryStatus.EnterNewPassword, + ); + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.SeedlessCommitted, ); }, ); }); - }); - - describe('vault', () => { - const MOCK_PASSWORD = 'mock-password'; - it('should throw an error if the password is an empty string', async () => { + it('returns reconcile-keyring when the phase is LOCAL_KEYRING_PENDING', async () => { await withController( { state: getMockInitialControllerState({ withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: + SeedlessPasswordChangePhase.LocalKeyringPending, }), }, - async ({ toprfClient, baseMessenger }) => { - // create the local enc key - mockcreateLocalKey(toprfClient, MOCK_PASSWORD); - // persist the local enc key - jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); - // mock the secret data add - const mockSecretDataAdd = handleMockSecretDataAdd(); - await expect( - baseMessenger.call( - 'SeedlessOnboardingController:createToprfKeyAndBackupSeedPhrase', - '', - MOCK_SEED_PHRASE, - MOCK_KEYRING_ID, - ), - ).rejects.toThrow( - SeedlessOnboardingControllerErrorMessage.InvalidEmptyPassword, + async ({ controller }) => { + const result = await controller.resolvePasswordSyncState(); + expect(result).toBe( + PasswordChangeRecoveryStatus.ReconcileKeyring, ); - - expect(mockSecretDataAdd.isDone()).toBe(true); }, ); }); - it('should throw an error if the passowrd is of wrong type', async () => { + it('returns sync-key when the phase is KEY_SYNC_PENDING', async () => { await withController( { state: getMockInitialControllerState({ withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: SeedlessPasswordChangePhase.KeySyncPending, }), }, - async ({ toprfClient, baseMessenger }) => { - // create the local enc key - mockcreateLocalKey(toprfClient, MOCK_PASSWORD); - // persist the local enc key - jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); - // mock the secret data add - const mockSecretDataAdd = handleMockSecretDataAdd(); + async ({ controller }) => { + const result = await controller.resolvePasswordSyncState(); + expect(result).toBe(PasswordChangeRecoveryStatus.SyncKey); + }, + ); + }); + + it('returns complete when the phase is COMPLETE', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: SeedlessPasswordChangePhase.Complete, + }), + }, + async ({ controller }) => { + const result = await controller.resolvePasswordSyncState(); + expect(result).toBe(PasswordChangeRecoveryStatus.Complete); + }, + ); + }); + + it('returns unknown when the phase is UNKNOWN', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: SeedlessPasswordChangePhase.Unknown, + }), + }, + async ({ controller }) => { + const result = await controller.resolvePasswordSyncState(); + expect(result).toBe(PasswordChangeRecoveryStatus.Unknown); + }, + ); + }); + + it('treats an unrecognized persisted phase as IDLE (no-change)', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: + 'unrecognized' as unknown as SeedlessPasswordChangePhase, + }), + }, + async ({ controller }) => { + const result = await controller.resolvePasswordSyncState(); + expect(result).toBe(PasswordChangeRecoveryStatus.NoChange); + }, + ); + }); + + it('clears to IDLE when SEEDLESS_CHANGE_PENDING and remote did not commit', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: + SeedlessPasswordChangePhase.SeedlessChangePending, + }), + }, + async ({ toprfClient, controller }) => { + // Remote auth pub key matches the local one -> not outdated. + mockFetchAuthPubKey(toprfClient, base64ToBytes(MOCK_AUTH_PUB_KEY)); + const result = await controller.resolvePasswordSyncState(); + expect(result).toBe(PasswordChangeRecoveryStatus.NoChange); + expect(controller.state.passwordChangePhase).toBeUndefined(); + }, + ); + }); + + it('advances to SEEDLESS_COMMITTED when SEEDLESS_CHANGE_PENDING and remote committed', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + authPubKey: MOCK_AUTH_PUB_KEY_OUTDATED, + passwordChangePhase: + SeedlessPasswordChangePhase.SeedlessChangePending, + }), + }, + async ({ toprfClient, controller }) => { + // Remote auth pub key differs from the stale local one -> outdated. + mockFetchAuthPubKey(toprfClient, base64ToBytes(MOCK_AUTH_PUB_KEY)); + const result = await controller.resolvePasswordSyncState(); + expect(result).toBe( + PasswordChangeRecoveryStatus.EnterNewPassword, + ); + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.SeedlessCommitted, + ); + }, + ); + }); + + it('returns unknown and preserves the phase when the remote check fails', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: + SeedlessPasswordChangePhase.SeedlessChangePending, + }), + }, + async ({ toprfClient, controller }) => { + jest + .spyOn(toprfClient, 'fetchAuthPubKey') + .mockRejectedValueOnce(new Error('network failure')); + const result = await controller.resolvePasswordSyncState(); + expect(result).toBe(PasswordChangeRecoveryStatus.Unknown); + // The phase is preserved as the recovery signal. + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.SeedlessChangePending, + ); + }, + ); + }); + }); + + describe('recoverPasswordChange', () => { + const OLD_PASSWORD = 'old-mock-password'; + const NEW_PASSWORD = 'new-mock-password'; + + it('returns no-change when the phase is IDLE and the password is in sync', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + }, + async ({ toprfClient, controller }) => { + mockFetchAuthPubKey(toprfClient, base64ToBytes(MOCK_AUTH_PUB_KEY)); + const result = await controller.recoverPasswordChange({ + globalPassword: NEW_PASSWORD, + }); + expect(result).toBe(PasswordChangeRecoveryStatus.NoChange); + }, + ); + }); + + it('returns unknown when the phase is SEEDLESS_CHANGE_PENDING', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: + SeedlessPasswordChangePhase.SeedlessChangePending, + }), + }, + async ({ controller }) => { + const result = await controller.recoverPasswordChange({ + globalPassword: NEW_PASSWORD, + }); + expect(result).toBe(PasswordChangeRecoveryStatus.Unknown); + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.SeedlessChangePending, + ); + }, + ); + }); + + it('returns sync-key when the phase is KEY_SYNC_PENDING', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: SeedlessPasswordChangePhase.KeySyncPending, + }), + }, + async ({ controller }) => { + const result = await controller.recoverPasswordChange({ + globalPassword: NEW_PASSWORD, + }); + expect(result).toBe(PasswordChangeRecoveryStatus.SyncKey); + }, + ); + }); + + it('returns complete when the phase is COMPLETE', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: SeedlessPasswordChangePhase.Complete, + }), + }, + async ({ controller }) => { + const result = await controller.recoverPasswordChange({ + globalPassword: NEW_PASSWORD, + }); + expect(result).toBe(PasswordChangeRecoveryStatus.Complete); + }, + ); + }); + + it('returns unknown when the phase is UNKNOWN', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: SeedlessPasswordChangePhase.Unknown, + }), + }, + async ({ controller }) => { + const result = await controller.recoverPasswordChange({ + globalPassword: NEW_PASSWORD, + }); + expect(result).toBe(PasswordChangeRecoveryStatus.Unknown); + }, + ); + }); + + it('treats an unrecognized persisted phase as IDLE (no-change)', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: + 'unrecognized' as unknown as SeedlessPasswordChangePhase, + }), + }, + async ({ controller }) => { + const result = await controller.recoverPasswordChange({ + globalPassword: NEW_PASSWORD, + }); + expect(result).toBe(PasswordChangeRecoveryStatus.NoChange); + }, + ); + }); + + it('syncs the Seedless side without advancing the phase when IDLE and the remote password is outdated', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + }, + async ({ controller, toprfClient, baseMessenger }) => { + // Create a vault under the old password so the password-sync flow + // has a vault to recover and rewrite. + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + baseMessenger, + OLD_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + // Remote auth pub key differs from the local one -> outdated, so + // the IDLE branch re-checks and runs the password-sync flow. + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(MOCK_AUTH_PUB_KEY_OUTDATED), + ); + + // Mock the password-sync flow for the new password. recoverEncKey + // is called by both submitGlobalPassword and syncLatestGlobalPassword. + const mockToprfEncryptor = createMockToprfEncryptor(); + const encKey = mockToprfEncryptor.deriveEncKey(NEW_PASSWORD); + const pwEncKey = mockToprfEncryptor.derivePwEncKey(NEW_PASSWORD); + const authKeyPair = + mockToprfEncryptor.deriveAuthKeyPair(NEW_PASSWORD); + jest.spyOn(toprfClient, 'recoverEncKey').mockResolvedValue({ + encKey, + authKeyPair, + pwEncKey, + rateLimitResetResult: Promise.resolve(), + keyShareIndex: 1, + }); + // recoverPwEncKey recovers the vault key the existing vault was + // encrypted with, so it must return the OLD password's pwEncKey. + jest + .spyOn(toprfClient, 'recoverPwEncKey') + .mockResolvedValueOnce({ + pwEncKey: mockToprfEncryptor.derivePwEncKey(OLD_PASSWORD), + }); + + const result = await controller.recoverPasswordChange({ + globalPassword: NEW_PASSWORD, + }); + + expect(result).toBe(PasswordChangeRecoveryStatus.NoChange); + // No lifecycle is in flight; the phase stays IDLE. + expect(controller.state.passwordChangePhase).toBeUndefined(); + }, + ); + }); + + it('returns unknown when the IDLE outdated check fails', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + }, + async ({ toprfClient, controller }) => { + jest + .spyOn(toprfClient, 'fetchAuthPubKey') + .mockRejectedValueOnce(new Error('Network error')); + + const result = await controller.recoverPasswordChange({ + globalPassword: NEW_PASSWORD, + }); + + expect(result).toBe(PasswordChangeRecoveryStatus.Unknown); + }, + ); + }); + + it('reconciles the Seedless side and advances to LOCAL_KEYRING_PENDING when SEEDLESS_COMMITTED', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: + SeedlessPasswordChangePhase.SeedlessCommitted, + }), + }, + async ({ controller, toprfClient, baseMessenger }) => { + // Create a vault under the old password so the password-sync flow + // has a vault to recover and rewrite. + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + baseMessenger, + OLD_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + // Mock the password-sync flow for the new password. recoverEncKey + // is called by both submitGlobalPassword and syncLatestGlobalPassword. + const mockToprfEncryptor = createMockToprfEncryptor(); + const encKey = mockToprfEncryptor.deriveEncKey(NEW_PASSWORD); + const pwEncKey = mockToprfEncryptor.derivePwEncKey(NEW_PASSWORD); + const authKeyPair = + mockToprfEncryptor.deriveAuthKeyPair(NEW_PASSWORD); + jest.spyOn(toprfClient, 'recoverEncKey').mockResolvedValue({ + encKey, + authKeyPair, + pwEncKey, + rateLimitResetResult: Promise.resolve(), + keyShareIndex: 1, + }); + // recoverPwEncKey recovers the vault key the existing vault was + // encrypted with, so it must return the OLD password's pwEncKey. + jest.spyOn(toprfClient, 'recoverPwEncKey').mockResolvedValueOnce({ + pwEncKey: mockToprfEncryptor.derivePwEncKey(OLD_PASSWORD), + }); + + const result = await controller.recoverPasswordChange({ + globalPassword: NEW_PASSWORD, + }); + + expect(result).toBe( + PasswordChangeRecoveryStatus.ReconcileKeyring, + ); + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.LocalKeyringPending, + ); + }, + ); + }); + + it('returns unknown and preserves the phase when reconciliation fails', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: + SeedlessPasswordChangePhase.SeedlessCommitted, + }), + }, + async ({ controller, toprfClient, baseMessenger }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + baseMessenger, + OLD_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + // Make the password-sync flow fail. + jest + .spyOn(toprfClient, 'recoverPwEncKey') + .mockRejectedValueOnce(new Error('recover failed')); + + const result = await controller.recoverPasswordChange({ + globalPassword: NEW_PASSWORD, + }); + + expect(result).toBe(PasswordChangeRecoveryStatus.Unknown); + // The phase is preserved as the recovery signal. + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.SeedlessCommitted, + ); + }, + ); + }); + }); + + describe('password-change lifecycle neutrality of key storage', () => { + const MOCK_PASSWORD = 'mock-password'; + + it('storeKeyringEncryptionKey does not change the lifecycle phase', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: + SeedlessPasswordChangePhase.LocalKeyringPending, + }), + }, + async ({ controller, toprfClient, baseMessenger }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + baseMessenger, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + await baseMessenger.call( + 'SeedlessOnboardingController:storeKeyringEncryptionKey', + MOCK_KEYRING_ENCRYPTION_KEY, + ); + + // The shared key-storage operation must remain lifecycle-neutral + // so it cannot advance or regress recovery when called by the client. + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.LocalKeyringPending, + ); + }, + ); + }); + + it('loadKeyringEncryptionKey does not change the lifecycle phase', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: + SeedlessPasswordChangePhase.LocalKeyringPending, + }), + }, + async ({ controller, toprfClient, baseMessenger }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + baseMessenger, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + await baseMessenger.call( + 'SeedlessOnboardingController:storeKeyringEncryptionKey', + MOCK_KEYRING_ENCRYPTION_KEY, + ); + + const loaded = await baseMessenger.call( + 'SeedlessOnboardingController:loadKeyringEncryptionKey', + ); + + expect(loaded).toStrictEqual(MOCK_KEYRING_ENCRYPTION_KEY); + // Loading a key is read-only with respect to lifecycle state. + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.LocalKeyringPending, + ); + }, + ); + }); + }); + + describe('clearState', () => { + it('should clear the state', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, baseMessenger }) => { + const { state } = controller; + + expect(state.nodeAuthTokens).toBeDefined(); + expect(state.userId).toBeDefined(); + expect(state.authConnectionId).toBeDefined(); + + baseMessenger.call('SeedlessOnboardingController:clearState'); + expect(controller.state).toStrictEqual( + getInitialSeedlessOnboardingControllerStateWithDefaults(), + ); + }, + ); + }); + }); + + describe('vault', () => { + const MOCK_PASSWORD = 'mock-password'; + + it('should throw an error if the password is an empty string', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ toprfClient, baseMessenger }) => { + // create the local enc key + mockcreateLocalKey(toprfClient, MOCK_PASSWORD); + // persist the local enc key + jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + // mock the secret data add + const mockSecretDataAdd = handleMockSecretDataAdd(); + await expect( + baseMessenger.call( + 'SeedlessOnboardingController:createToprfKeyAndBackupSeedPhrase', + '', + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.InvalidEmptyPassword, + ); + + expect(mockSecretDataAdd.isDone()).toBe(true); + }, + ); + }); + + it('should throw an error if the passowrd is of wrong type', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ toprfClient, baseMessenger }) => { + // create the local enc key + mockcreateLocalKey(toprfClient, MOCK_PASSWORD); + // persist the local enc key + jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + // mock the secret data add + const mockSecretDataAdd = handleMockSecretDataAdd(); await expect( baseMessenger.call( 'SeedlessOnboardingController:createToprfKeyAndBackupSeedPhrase', @@ -6037,7 +6826,7 @@ describe('SeedlessOnboardingController', () => { // This should not trigger token refresh since access token check is skipped when locked await baseMessenger.call( - 'SeedlessOnboardingController:checkIsPasswordOutdated', + 'SeedlessOnboardingController:resolvePasswordSyncState', ); // Verify that refreshAuthTokens was not called @@ -6060,14 +6849,11 @@ describe('SeedlessOnboardingController', () => { .spyOn(toprfClient, 'fetchAuthPubKey') .mockRejectedValue(new Error('Network error')); - // This should throw the wrapped error without retrying - await expect( - baseMessenger.call( - 'SeedlessOnboardingController:checkIsPasswordOutdated', - ), - ).rejects.toThrow( - SeedlessOnboardingControllerErrorMessage.FailedToFetchAuthPubKey, + // This should return Unknown without retrying (non-token error) + const result = await baseMessenger.call( + 'SeedlessOnboardingController:resolvePasswordSyncState', ); + expect(result).toBe(PasswordChangeRecoveryStatus.Unknown); // Verify that fetchAuthPubKey was only called once (no retry) expect(toprfClient.fetchAuthPubKey).toHaveBeenCalledTimes(1); @@ -6209,8 +6995,8 @@ describe('SeedlessOnboardingController', () => { }); }); - describe('checkIsPasswordOutdated with token refresh', () => { - it('should retry checkIsPasswordOutdated after refreshing expired tokens', async () => { + describe('resolvePasswordSyncState with token refresh', () => { + it('should retry resolvePasswordSyncState after refreshing expired tokens', async () => { await withController( { state: { @@ -6246,7 +7032,7 @@ describe('SeedlessOnboardingController', () => { }); await baseMessenger.call( - 'SeedlessOnboardingController:checkIsPasswordOutdated', + 'SeedlessOnboardingController:resolvePasswordSyncState', ); expect(mockRefreshJWTToken).toHaveBeenCalled(); diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index db3ad10ff1f..c06b4f28853 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -51,6 +51,7 @@ import { SeedlessOnboardingControllerErrorMessage, SeedlessOnboardingMigrationVersion, SeedlessPasswordChangePhase, + PasswordChangeRecoveryStatus, Web3AuthNetwork, } from './constants.js'; import { @@ -93,6 +94,11 @@ const MESSENGER_EXPOSED_METHODS = [ 'addNewSecretData', 'fetchAllSecretData', 'changePassword', + 'clearPasswordChangePhase', + 'markPasswordChangeKeySyncPending', + 'completePasswordChange', + 'resolvePasswordSyncState', + 'recoverPasswordChange', 'updateBackupMetadataState', 'verifyVaultPassword', 'getSecretDataBackupState', @@ -100,7 +106,6 @@ const MESSENGER_EXPOSED_METHODS = [ 'setLocked', 'syncLatestGlobalPassword', 'submitGlobalPassword', - 'checkIsPasswordOutdated', 'getIsUserAuthenticated', 'clearState', 'storeKeyringEncryptionKey', @@ -971,6 +976,22 @@ export class SeedlessOnboardingController< ): Promise { return await this.#withControllerLock(async () => { this.#assertIsUnlocked(); + + // Reject a second password change while a previous one is unresolved. + // The controller mutex serializes calls, but a previous change may have + // released the lock with the lifecycle in a non-IDLE phase (recovery + // pending). Starting a fresh `changePassword`/`changeEncKey` then would + // race with recovery and could block the user from their wallet. + // Recovery must finish and clear the lifecycle to IDLE first. + if ( + getPasswordChangePhase(this.state.passwordChangePhase) !== + SeedlessPasswordChangePhase.Idle + ) { + throw new SeedlessOnboardingError( + SeedlessOnboardingControllerErrorMessage.PasswordChangeInProgress, + ); + } + // verify the old password of the encrypted vault await this.verifyVaultPassword(oldPassword, { skipLock: true, // skip lock since we already have the lock @@ -980,6 +1001,10 @@ export class SeedlessOnboardingController< const { latestKeyIndex } = await this.#assertPasswordInSync({ skipCache: true, skipLock: true, // skip lock since we already have the lock + // `changePassword` writes the phase before its token-refresh retry + // and guards concurrency itself at entry, so its own assert must + // not be blocked by the phase it just wrote. + skipPhaseCheck: true, }); // load keyring encryption key if it exists let keyringEncryptionKey: string | undefined; @@ -1018,16 +1043,22 @@ export class SeedlessOnboardingController< rawToprfAuthKeyPair: newAuthKeyPair, }); - // The local Seedless vault has been rewritten with the new password. - this.#advancePasswordChangeLifecycle( - SeedlessPasswordChangePhase.LocalKeyringPending, - ); - this.#resetPasswordOutdatedCache(); - // store the keyring encryption key if it exists + // Re-encrypt the existing Keyring encryption key under the new + // password and persist `LOCAL_KEYRING_PENDING` in the same update, so + // observers never see `LOCAL_KEYRING_PENDING` with a stale + // (pre-re-encryption) key. When there is no key to store, advance the + // boundary on its own. if (keyringEncryptionKey) { - await this.storeKeyringEncryptionKey(keyringEncryptionKey); + await this.#persistKeyringEncryptionKey( + keyringEncryptionKey, + SeedlessPasswordChangePhase.LocalKeyringPending, + ); + } else { + this.#advancePasswordChangeLifecycle( + SeedlessPasswordChangePhase.LocalKeyringPending, + ); } }; @@ -1202,27 +1233,38 @@ export class SeedlessOnboardingController< }): Promise { return await this.#withControllerLock(async () => { this.#assertIsUnlocked(); - const doSyncPassword = async (): Promise => { - // update vault with latest globalPassword - const { encKey, pwEncKey, authKeyPair } = - await this.#recoverEncKey(globalPassword); - // update and encrypt the vault with new password - await this.#createNewVaultWithAuthData({ - password: globalPassword, - rawToprfEncryptionKey: encKey, - rawToprfPwEncryptionKey: pwEncKey, - rawToprfAuthKeyPair: authKeyPair, - }); - - this.#resetPasswordOutdatedCache(); - }; return await this.#executeWithTokenRefresh( - doSyncPassword, + async () => await this.#syncLatestGlobalPasswordInner(globalPassword), 'syncLatestGlobalPassword', ); }); } + /** + * Lock-free implementation of `syncLatestGlobalPassword`. + * + * Rewrites the local Seedless vault under the latest global password and + * resets the password-outdated cache. Must be called while the controller + * lock is held (or from a context that does not hold the lock, in which + * case the caller manages locking). + * + * @param globalPassword - The latest global password. + */ + async #syncLatestGlobalPasswordInner(globalPassword: string): Promise { + // update vault with latest globalPassword + const { encKey, pwEncKey, authKeyPair } = + await this.#recoverEncKey(globalPassword); + // update and encrypt the vault with new password + await this.#createNewVaultWithAuthData({ + password: globalPassword, + rawToprfEncryptionKey: encKey, + rawToprfPwEncryptionKey: pwEncKey, + rawToprfAuthKeyPair: authKeyPair, + }); + + this.#resetPasswordOutdatedCache(); + } + /** * @description Unlock the controller with the latest global password. * @@ -1324,7 +1366,7 @@ export class SeedlessOnboardingController< * @param options.skipLock - Whether to skip the lock acquisition. (to prevent deadlock in case the caller already acquired the lock) * @returns A promise that resolves to true if the password is outdated, false otherwise. */ - async checkIsPasswordOutdated(options?: { + async #checkIsPasswordOutdated(options?: { skipCache?: boolean; skipLock?: boolean; globalAuthPubKey?: SEC1EncodedPublicKey; @@ -1518,36 +1560,34 @@ export class SeedlessOnboardingController< * Store the keyring encryption key in state, encrypted under the current * encryption key. * + * Remains lifecycle-neutral: the client calls this during recovery without + * advancing the lifecycle phase. + * * @param keyringEncryptionKey - The keyring encryption key. */ async storeKeyringEncryptionKey(keyringEncryptionKey: string): Promise { - const { toprfPwEncryptionKey: encKey } = - await this.#unlockVaultAndGetVaultData(); - await this.#storeKeyringEncryptionKey(encKey, keyringEncryptionKey); + await this.#persistKeyringEncryptionKey(keyringEncryptionKey); } /** - * Load the keyring encryption key from state, decrypted under the current - * encryption key. + * Encrypt the keyring encryption key under the current vault password + * encryption key and persist it, optionally advancing the lifecycle + * boundary in the same update. * - * @returns The keyring encryption key. - */ - async loadKeyringEncryptionKey(): Promise { - const { toprfPwEncryptionKey: encKey } = - await this.#unlockVaultAndGetVaultData(); - return await this.#loadKeyringEncryptionKey(encKey); - } - - /** - * Encrypt the keyring encryption key and store it in state. + * When `phase` is provided, the boundary is advanced in the same controller + * update as the encrypted key, so observers never see an intermediate state + * where the phase advanced but the key is stale. Without `phase` the + * lifecycle is untouched. * - * @param encKey - The encryption key. * @param keyringEncryptionKey - The keyring encryption key. + * @param phase - Optional lifecycle phase to advance to in the same update. */ - async #storeKeyringEncryptionKey( - encKey: Uint8Array, + async #persistKeyringEncryptionKey( keyringEncryptionKey: string, + phase?: SeedlessPasswordChangePhase, ): Promise { + const { toprfPwEncryptionKey: encKey } = + await this.#unlockVaultAndGetVaultData(); const aes = managedNonce(gcm)(encKey); const encryptedKeyringEncryptionKey = aes.encrypt( utf8ToBytes(keyringEncryptionKey), @@ -1556,9 +1596,24 @@ export class SeedlessOnboardingController< state.encryptedKeyringEncryptionKey = bytesToBase64( encryptedKeyringEncryptionKey, ); + if (phase) { + state.passwordChangePhase = phase; + } }); } + /** + * Load the keyring encryption key from state, decrypted under the current + * encryption key. + * + * @returns The keyring encryption key. + */ + async loadKeyringEncryptionKey(): Promise { + const { toprfPwEncryptionKey: encKey } = + await this.#unlockVaultAndGetVaultData(); + return await this.#loadKeyringEncryptionKey(encKey); + } + /** * Decrypt the keyring encryption key from state. * @@ -2338,6 +2393,276 @@ export class SeedlessOnboardingController< }); } + /** + * Mark the password-change lifecycle as `KEY_SYNC_PENDING`. + * + * Called by the client coordinator before it synchronizes the current + * Keyring encryption key to Seedless. The controller only records the + * boundary; it does not perform or verify synchronization. + * + * @returns A promise that resolves once the phase has been persisted. + */ + async markPasswordChangeKeySyncPending(): Promise { + await this.#withControllerLock(async () => { + if ( + getPasswordChangePhase(this.state.passwordChangePhase) === + SeedlessPasswordChangePhase.KeySyncPending + ) { + return; + } + this.#writePasswordChangePhase( + SeedlessPasswordChangePhase.KeySyncPending, + ); + }); + } + + /** + * Mark the password-change lifecycle as `COMPLETE`. + * + * Called by the client coordinator only after Keyring encryption-key + * synchronization is verified and all required local writes have succeeded. + * The controller only records the boundary; it does not infer completion + * from this call. Follow with `clearPasswordChangePhase` to return to + * `IDLE` once the durable `COMPLETE` state is no longer needed as a signal. + * + * @returns A promise that resolves once the phase has been persisted. + */ + async completePasswordChange(): Promise { + await this.#withControllerLock(async () => { + if ( + getPasswordChangePhase(this.state.passwordChangePhase) === + SeedlessPasswordChangePhase.Complete + ) { + return; + } + this.#writePasswordChangePhase(SeedlessPasswordChangePhase.Complete); + }); + } + + /** + * Resolve the current password-sync state without consuming a password. + * + * Merges the legacy `checkIsPasswordOutdated` read with password-change + * recovery routing, so the client makes a single call at unlock (both on + * page render and on password submit) and routes UI from the returned status. + * + * Phase handling: + * - `IDLE`: run the authoritative outdated check. `skipCache` is honored, so + * the client can read from cache on render and force a remote call on + * submit. Returns `NoChange` (in sync) or `PasswordOutdated` (another device + * changed the remote password). + * - `SEEDLESS_CHANGE_PENDING`: the remote outcome is ambiguous, so `skipCache` + * is ignored and a remote check is forced. Clears to `IDLE` (remote did not + * commit) or advances to `SEEDLESS_COMMITTED` (remote committed). Returns + * `NoChange` or `EnterNewPassword`. + * - Other phases: return the next recovery step without mutating state. + * + * This method does not consume a password; the client prompts for the + * correct password and then calls `recoverPasswordChange`. + * + * @param options - The options. + * @param options.skipCache - Whether to bypass the outdated cache. Ignored + * for `SEEDLESS_CHANGE_PENDING`, which always forces a remote check. + * @returns The sync/recovery resolution. On any failure the last known phase + * is preserved and `PasswordChangeRecoveryStatus.Unknown` is returned. + */ + async resolvePasswordSyncState(options?: { + skipCache?: boolean; + }): Promise { + const phase = getPasswordChangePhase(this.state.passwordChangePhase); + switch (phase) { + case SeedlessPasswordChangePhase.Idle: { + // Pure read with no state mutation; let the helper acquire the + // controller lock itself (no `skipLock`). + try { + const outdated = await this.#checkIsPasswordOutdated({ + skipCache: options?.skipCache, + }); + return outdated + ? PasswordChangeRecoveryStatus.PasswordOutdated + : PasswordChangeRecoveryStatus.NoChange; + } catch { + // Remote state could not be established. Keep the wallet locked. + return PasswordChangeRecoveryStatus.Unknown; + } + } + case SeedlessPasswordChangePhase.SeedlessChangePending: { + // Mutates the phase, so hold the lock for the whole branch and tell + // the helper we already have it. + return await this.#withControllerLock(async () => { + try { + // Remote outcome is ambiguous; force an authoritative remote + // check regardless of `skipCache`. + const outdated = await this.#checkIsPasswordOutdated({ + skipCache: true, + skipLock: true, + }); + if (!outdated) { + // Remote did not commit. Clear to IDLE; unlock with the old + // password normally. + this.#writePasswordChangePhase(undefined); + return PasswordChangeRecoveryStatus.NoChange; + } + // Remote committed. Advance so recovery reconciles the local + // Seedless side with the new password. + this.#writePasswordChangePhase( + SeedlessPasswordChangePhase.SeedlessCommitted, + ); + return PasswordChangeRecoveryStatus.EnterNewPassword; + } catch { + // Remote state could not be established. Preserve the phase and + // keep the wallet locked. + return PasswordChangeRecoveryStatus.Unknown; + } + }); + } + case SeedlessPasswordChangePhase.SeedlessCommitted: + return PasswordChangeRecoveryStatus.EnterNewPassword; + case SeedlessPasswordChangePhase.LocalKeyringPending: + return PasswordChangeRecoveryStatus.ReconcileKeyring; + default: + // Terminal phases (KEY_SYNC_PENDING, COMPLETE, UNKNOWN) and any + // unrecognized/missing phase (treated as IDLE) share routing. + return this.#statusForTerminalPhase(phase); + } + } + + /** + * Reconcile the Seedless side of a password-change recovery — or a plain + * remote password sync — with the supplied password. + * + * For `SEEDLESS_COMMITTED` or `LOCAL_KEYRING_PENDING` it re-runs the existing + * password-sync flow (`submitGlobalPassword` + `syncLatestGlobalPassword`) + * with the new password and advances the phase to `LOCAL_KEYRING_PENDING`. + * These operations are idempotent, so re-running them is safe whether or not + * the local Seedless vault was already rewritten. The controller is left + * unlocked. + * + * For `IDLE` it re-checks whether the remote password is outdated and, if so, + * runs the same password-sync flow without advancing any phase (there is no + * local password-change lifecycle in flight — e.g. another device changed + * the remote password). If the remote password is not outdated it is a no-op. + * + * The client remains responsible for the Keyring side (classifying the local + * Keyring via `KeyringController:verifyPassword` and running the old-Keyring + * or new-Keyring branch), because this controller does not depend on + * `KeyringController`. See + * [0004](./docs/0004-controller-owned-password-change-recovery-plan.md). + * + * @param params - The recovery parameters. + * @param params.globalPassword - The new global password. + * @returns The recovery result. On any failure the last known phase is + * preserved and `PasswordChangeRecoveryStatus.Unknown` is returned. + */ + async recoverPasswordChange({ + globalPassword, + }: { + globalPassword: string; + }): Promise { + return await this.#withControllerLock(async () => { + const phase = getPasswordChangePhase(this.state.passwordChangePhase); + switch (phase) { + case SeedlessPasswordChangePhase.SeedlessChangePending: + // Remote state must be resolved first via + // resolvePasswordSyncState. + return PasswordChangeRecoveryStatus.Unknown; + case SeedlessPasswordChangePhase.SeedlessCommitted: + case SeedlessPasswordChangePhase.LocalKeyringPending: { + try { + // Re-run the password-sync flow with the new password. This + // unlocks the controller and rewrites the local Seedless vault; + // both operations are idempotent if the vault is already synced. + await this.#runPasswordSyncFlow(globalPassword); + this.#writePasswordChangePhase( + SeedlessPasswordChangePhase.LocalKeyringPending, + ); + return PasswordChangeRecoveryStatus.ReconcileKeyring; + } catch { + // Reconciliation failed (e.g. wrong password or transient + // remote error). Preserve the phase and keep the wallet locked. + return PasswordChangeRecoveryStatus.Unknown; + } + } + case SeedlessPasswordChangePhase.Idle: { + // No local password-change lifecycle is in flight. Another device + // may still have changed the remote password, so re-check and sync + // the Seedless side if it is outdated. No phase is advanced. + try { + const outdated = await this.#checkIsPasswordOutdated({ + skipCache: true, + skipLock: true, + }); + if (!outdated) { + return PasswordChangeRecoveryStatus.NoChange; + } + await this.#runPasswordSyncFlow(globalPassword); + return PasswordChangeRecoveryStatus.NoChange; + } catch { + // Sync failed (e.g. wrong password or transient remote error). + // Keep the wallet locked. + return PasswordChangeRecoveryStatus.Unknown; + } + } + default: + // Terminal phases (KEY_SYNC_PENDING, COMPLETE, UNKNOWN) and any + // unrecognized/missing phase (treated as IDLE) share routing. + return this.#statusForTerminalPhase(phase); + } + }); + } + + /** + * Re-run the password-sync flow (`submitGlobalPassword` + + * `syncLatestGlobalPassword`) with the supplied password. + * + * Both operations are idempotent if the local Seedless vault is already + * synced, so this is safe to re-run during recovery or a plain + * another-device sync. The controller is left unlocked. Caller must hold + * the controller lock. + * + * @param globalPassword - The current global password. + */ + async #runPasswordSyncFlow(globalPassword: string): Promise { + await this.#executeWithTokenRefresh(async () => { + const currentDeviceAuthPubKey = this.#recoverAuthPubKey(); + await this.#submitGlobalPassword({ + targetAuthPubKey: currentDeviceAuthPubKey, + globalPassword, + maxKeyChainLength: 5, + }); + }, 'submitGlobalPassword'); + await this.#executeWithTokenRefresh( + async () => await this.#syncLatestGlobalPasswordInner(globalPassword), + 'syncLatestGlobalPassword', + ); + } + + /** + * Return the recovery status for phases that require no Seedless-side + * mutation. Shared by `resolvePasswordSyncState` (read) and + * `recoverPasswordChange` (apply) so both route the terminal phases + * identically. + * + * @param phase - The persisted password-change phase. + * @returns The status for the phase. A missing or unrecognized phase is + * treated as IDLE and returns `NoChange`. + */ + #statusForTerminalPhase( + phase: SeedlessPasswordChangePhase, + ): PasswordChangeRecoveryStatus { + switch (phase) { + case SeedlessPasswordChangePhase.KeySyncPending: + return PasswordChangeRecoveryStatus.SyncKey; + case SeedlessPasswordChangePhase.Complete: + return PasswordChangeRecoveryStatus.Complete; + case SeedlessPasswordChangePhase.Unknown: + return PasswordChangeRecoveryStatus.Unknown; + default: + // A missing or unrecognized persisted phase is treated as IDLE. + return PasswordChangeRecoveryStatus.NoChange; + } + } + /** * Parse and deserialize the authentication data from the vault. * @@ -2401,17 +2726,44 @@ export class SeedlessOnboardingController< * @param options - The options for asserting the password is in sync. * @param options.skipCache - Whether to skip the cache check. * @param options.skipLock - Whether to skip the lock acquisition. (to prevent deadlock in case the caller already acquired the lock) + * @param options.skipPhaseCheck - Whether to skip the `passwordChangePhase` guard. Only `changePassword` should set this: it guards concurrency itself at entry and writes the phase before its token-refresh retry, so its own internal assert must not be blocked by the phase it just wrote. * @returns The global auth public key and the latest key index. * @throws If the password is outdated. */ async #assertPasswordInSync(options?: { skipCache?: boolean; skipLock?: boolean; + /** + * Skip the `passwordChangePhase` guard. Only `changePassword` should set + * this: it guards concurrency itself at entry and writes the phase + * before its token-refresh retry, so its own internal assert must not + * be blocked by the phase it just wrote. + */ + skipPhaseCheck?: boolean; }): Promise<{ authPubKey: SEC1EncodedPublicKey; latestKeyIndex: number; }> { this.#assertIsAuthenticatedUser(this.state); + + // Block TOPRF operations while a password change is unresolved. The + // controller mutex serializes in-process calls, but a previous change may + // have left a non-IDLE persisted phase after a crash. Running a fresh TOPRF + // operation against ambiguous state could corrupt recovery. Recovery + // itself bypasses this assert (it calls the password-sync primitives + // directly), so this guard does not block reconciliation. `changePassword` + // passes `skipPhaseCheck` because it writes the phase before its own + // token-refresh retry and already guards concurrency at entry. + if ( + !options?.skipPhaseCheck && + getPasswordChangePhase(this.state.passwordChangePhase) !== + SeedlessPasswordChangePhase.Idle + ) { + throw new SeedlessOnboardingError( + SeedlessOnboardingControllerErrorMessage.PasswordChangeInProgress, + ); + } + const { nodeAuthTokens, authConnectionId, @@ -2435,7 +2787,7 @@ export class SeedlessOnboardingController< }, ); }); - const isPasswordOutdated = await this.checkIsPasswordOutdated({ + const isPasswordOutdated = await this.#checkIsPasswordOutdated({ ...options, globalAuthPubKey: authPubKey, }); diff --git a/packages/seedless-onboarding-controller/src/constants.ts b/packages/seedless-onboarding-controller/src/constants.ts index c4bed28015a..8915345e620 100644 --- a/packages/seedless-onboarding-controller/src/constants.ts +++ b/packages/seedless-onboarding-controller/src/constants.ts @@ -49,6 +49,33 @@ export enum SeedlessPasswordChangePhase { Unknown = 'UNKNOWN', } +/** + * The outcome of a password-sync / password-change recovery step, returned by + * `resolvePasswordSyncState` (read + resolve, no password) and + * `recoverPasswordChange` (apply, with password). + * + * The controller owns the Seedless-side recovery sequencing; the client owns + * the Keyring-side steps (it must call `KeyringController` directly) and UI + * routing based on this status. See + * [0004](./docs/0004-controller-owned-password-change-recovery-plan.md). + */ +export enum PasswordChangeRecoveryStatus { + /** Remote did not commit; the phase has been cleared to `IDLE`. Unlock with the old password normally. */ + NoChange = 'no-change', + /** Phase is `IDLE` but the remote password changed (e.g. another device changed it). Prompt for the new password, then call `recoverPasswordChange`. */ + PasswordOutdated = 'password-outdated', + /** Remote committed (or the local Seedless side still needs the new password). Prompt for the new password, then call `recoverPasswordChange`. */ + EnterNewPassword = 'enter-new-password', + /** The Seedless side is reconciled (phase is `LOCAL_KEYRING_PENDING`). The client must cryptographically classify the local Keyring and run the old/new branch. */ + ReconcileKeyring = 'reconcile-keyring', + /** Phase is `KEY_SYNC_PENDING`. The client must export, store, and sync the current Keyring encryption key, then call `completePasswordChange`. */ + SyncKey = 'sync-key', + /** Phase is `COMPLETE`. The client should clear the lifecycle to `IDLE`. */ + Complete = 'complete', + /** The remote or local state could not be established. Keep the wallet locked. The last known phase is preserved. */ + Unknown = 'unknown', +} + export enum SeedlessOnboardingControllerErrorMessage { ControllerLocked = `${controllerName} - The operation cannot be completed while the controller is locked.`, VaultLocked = `${controllerName} - The operation cannot be completed while the vault is locked.`, @@ -75,6 +102,7 @@ export enum SeedlessOnboardingControllerErrorMessage { NoSecretDataFound = `${controllerName} - No secret data found`, InvalidPrimarySecretDataType = `${controllerName} - Primary secret data must be of type mnemonic.`, FailedToChangePassword = `${controllerName} - Failed to change password`, + PasswordChangeInProgress = `${controllerName} - A password change is already in progress; recovery must finish before starting a new one`, TooManyLoginAttempts = `${controllerName} - Too many login attempts`, IncorrectPassword = `${controllerName} - Incorrect password`, OutdatedPassword = `${controllerName} - Outdated password`, diff --git a/packages/seedless-onboarding-controller/src/index.ts b/packages/seedless-onboarding-controller/src/index.ts index 231fc5810de..36c95609f2e 100644 --- a/packages/seedless-onboarding-controller/src/index.ts +++ b/packages/seedless-onboarding-controller/src/index.ts @@ -18,6 +18,9 @@ export type { SeedlessOnboardingControllerAddNewSecretDataAction, SeedlessOnboardingControllerFetchAllSecretDataAction, SeedlessOnboardingControllerChangePasswordAction, + SeedlessOnboardingControllerClearPasswordChangePhaseAction, + SeedlessOnboardingControllerMarkPasswordChangeKeySyncPendingAction, + SeedlessOnboardingControllerCompletePasswordChangeAction, SeedlessOnboardingControllerUpdateBackupMetadataStateAction, SeedlessOnboardingControllerVerifyVaultPasswordAction, SeedlessOnboardingControllerGetSecretDataBackupStateAction, @@ -25,7 +28,6 @@ export type { SeedlessOnboardingControllerSetLockedAction, SeedlessOnboardingControllerSyncLatestGlobalPasswordAction, SeedlessOnboardingControllerSubmitGlobalPasswordAction, - SeedlessOnboardingControllerCheckIsPasswordOutdatedAction, SeedlessOnboardingControllerGetIsUserAuthenticatedAction, SeedlessOnboardingControllerClearStateAction, SeedlessOnboardingControllerStoreKeyringEncryptionKeyAction, @@ -38,6 +40,8 @@ export type { SeedlessOnboardingControllerCheckMetadataAccessTokenExpiredAction, SeedlessOnboardingControllerCheckAccessTokenExpiredAction, SeedlessOnboardingControllerRunMigrationsAction, + SeedlessOnboardingControllerResolvePasswordSyncStateAction, + SeedlessOnboardingControllerRecoverPasswordChangeAction, } from './SeedlessOnboardingController-method-action-types.js'; export type { AuthenticatedUserDetails, @@ -54,6 +58,7 @@ export { AuthConnection, SecretType, SeedlessPasswordChangePhase, + PasswordChangeRecoveryStatus, } from './constants.js'; export { SecretMetadata } from './SecretMetadata.js'; export { diff --git a/packages/seedless-onboarding-controller/src/utils.test.ts b/packages/seedless-onboarding-controller/src/utils.test.ts index 0fa4c358987..31502fa7786 100644 --- a/packages/seedless-onboarding-controller/src/utils.test.ts +++ b/packages/seedless-onboarding-controller/src/utils.test.ts @@ -16,7 +16,6 @@ import { getInvalidPrimarySecretDataTypeErrorData, getPasswordChangePhase, getSecretTypeFromDataType, - isValidPasswordChangePhaseTransition, } from './utils.js'; describe('utils', () => { @@ -288,129 +287,4 @@ describe('utils', () => { ).toBe(SeedlessPasswordChangePhase.SeedlessCommitted); }); }); - - describe('isValidPasswordChangePhaseTransition', () => { - it('allows IDLE to SEEDLESS_CHANGE_PENDING', () => { - expect( - isValidPasswordChangePhaseTransition( - undefined, - SeedlessPasswordChangePhase.SeedlessChangePending, - ), - ).toBe(true); - }); - - it('allows SEEDLESS_CHANGE_PENDING to SEEDLESS_COMMITTED', () => { - expect( - isValidPasswordChangePhaseTransition( - SeedlessPasswordChangePhase.SeedlessChangePending, - SeedlessPasswordChangePhase.SeedlessCommitted, - ), - ).toBe(true); - }); - - it('allows SEEDLESS_CHANGE_PENDING to IDLE (definitive remote failure)', () => { - expect( - isValidPasswordChangePhaseTransition( - SeedlessPasswordChangePhase.SeedlessChangePending, - SeedlessPasswordChangePhase.Idle, - ), - ).toBe(true); - }); - - it('allows SEEDLESS_COMMITTED to LOCAL_KEYRING_PENDING', () => { - expect( - isValidPasswordChangePhaseTransition( - SeedlessPasswordChangePhase.SeedlessCommitted, - SeedlessPasswordChangePhase.LocalKeyringPending, - ), - ).toBe(true); - }); - - it('allows LOCAL_KEYRING_PENDING to KEY_SYNC_PENDING', () => { - expect( - isValidPasswordChangePhaseTransition( - SeedlessPasswordChangePhase.LocalKeyringPending, - SeedlessPasswordChangePhase.KeySyncPending, - ), - ).toBe(true); - }); - - it('allows KEY_SYNC_PENDING to COMPLETE', () => { - expect( - isValidPasswordChangePhaseTransition( - SeedlessPasswordChangePhase.KeySyncPending, - SeedlessPasswordChangePhase.Complete, - ), - ).toBe(true); - }); - - it('allows COMPLETE to IDLE (clear)', () => { - expect( - isValidPasswordChangePhaseTransition( - SeedlessPasswordChangePhase.Complete, - SeedlessPasswordChangePhase.Idle, - ), - ).toBe(true); - }); - - it('allows any phase to UNKNOWN', () => { - for (const phase of [ - SeedlessPasswordChangePhase.SeedlessChangePending, - SeedlessPasswordChangePhase.SeedlessCommitted, - SeedlessPasswordChangePhase.LocalKeyringPending, - SeedlessPasswordChangePhase.KeySyncPending, - ]) { - expect( - isValidPasswordChangePhaseTransition( - phase, - SeedlessPasswordChangePhase.Unknown, - ), - ).toBe(true); - } - }); - - it('allows UNKNOWN to any resolvable phase', () => { - for (const target of [ - SeedlessPasswordChangePhase.Idle, - SeedlessPasswordChangePhase.SeedlessCommitted, - SeedlessPasswordChangePhase.LocalKeyringPending, - SeedlessPasswordChangePhase.KeySyncPending, - SeedlessPasswordChangePhase.Complete, - ]) { - expect( - isValidPasswordChangePhaseTransition( - SeedlessPasswordChangePhase.Unknown, - target, - ), - ).toBe(true); - } - }); - - it('rejects IDLE to COMPLETE (skipping steps)', () => { - expect( - isValidPasswordChangePhaseTransition( - undefined, - SeedlessPasswordChangePhase.Complete, - ), - ).toBe(false); - }); - - it('rejects SEEDLESS_COMMITTED to IDLE (cannot skip back without definitive failure)', () => { - expect( - isValidPasswordChangePhaseTransition( - SeedlessPasswordChangePhase.SeedlessCommitted, - SeedlessPasswordChangePhase.Idle, - ), - ).toBe(false); - }); - - it('rejects COMPLETE to SEEDLESS_CHANGE_PENDING (cannot restart from complete)', () => { - expect( - isValidPasswordChangePhaseTransition( - SeedlessPasswordChangePhase.Complete, - SeedlessPasswordChangePhase.SeedlessChangePending, - ), - ).toBe(false); - }); - }); }); diff --git a/packages/seedless-onboarding-controller/src/utils.ts b/packages/seedless-onboarding-controller/src/utils.ts index 5b51a2f3ef2..d05d3ab2221 100644 --- a/packages/seedless-onboarding-controller/src/utils.ts +++ b/packages/seedless-onboarding-controller/src/utils.ts @@ -191,53 +191,6 @@ export function getInvalidPrimarySecretDataTypeErrorData( return secrets.map((secret) => secret.dataType ?? secret.type); } -/** - * Legal forward transitions for the password-change lifecycle. - * - * This map is used by tests to validate that transitions are sensible. It is - * NOT the source of truth for recovery — a persisted phase may be stale, and - * recovery must always verify actual remote and local state before acting. - * - * `UNKNOWN` is intentionally permissive: recovery may resolve it to any phase - * or clear it to `IDLE`. Any phase may transition to `UNKNOWN` when a result - * is ambiguous. - */ -const LEGAL_PASSWORD_CHANGE_TRANSITIONS: Record< - SeedlessPasswordChangePhase, - SeedlessPasswordChangePhase[] -> = { - [SeedlessPasswordChangePhase.Idle]: [ - SeedlessPasswordChangePhase.SeedlessChangePending, - ], - [SeedlessPasswordChangePhase.SeedlessChangePending]: [ - SeedlessPasswordChangePhase.SeedlessCommitted, - SeedlessPasswordChangePhase.Idle, - SeedlessPasswordChangePhase.Unknown, - ], - [SeedlessPasswordChangePhase.SeedlessCommitted]: [ - SeedlessPasswordChangePhase.LocalKeyringPending, - SeedlessPasswordChangePhase.Unknown, - ], - [SeedlessPasswordChangePhase.LocalKeyringPending]: [ - SeedlessPasswordChangePhase.KeySyncPending, - SeedlessPasswordChangePhase.Unknown, - ], - [SeedlessPasswordChangePhase.KeySyncPending]: [ - SeedlessPasswordChangePhase.Complete, - SeedlessPasswordChangePhase.Unknown, - ], - [SeedlessPasswordChangePhase.Complete]: [ - SeedlessPasswordChangePhase.Idle, - ], - [SeedlessPasswordChangePhase.Unknown]: [ - SeedlessPasswordChangePhase.Idle, - SeedlessPasswordChangePhase.SeedlessCommitted, - SeedlessPasswordChangePhase.LocalKeyringPending, - SeedlessPasswordChangePhase.KeySyncPending, - SeedlessPasswordChangePhase.Complete, - ], -}; - /** * Resolve a password-change phase, treating `undefined` as `IDLE`. * @@ -250,20 +203,3 @@ export function getPasswordChangePhase( return phase ?? SeedlessPasswordChangePhase.Idle; } -/** - * Check whether a phase transition is legal according to the transition map. - * - * This is for test validation only. A persisted phase may be stale; recovery - * must verify actual state rather than relying on this validator. - * - * @param from - The source phase (or `undefined` for `IDLE`). - * @param to - The target phase. - * @returns `true` if the transition is legal. - */ -export function isValidPasswordChangePhaseTransition( - from: SeedlessPasswordChangePhase | undefined, - to: SeedlessPasswordChangePhase, -): boolean { - const fromPhase = getPasswordChangePhase(from); - return LEGAL_PASSWORD_CHANGE_TRANSITIONS[fromPhase].includes(to); -} From 30f64a8c86598f80af1ba1725ffa04f79f68ef58 Mon Sep 17 00:00:00 2001 From: lwin Date: Wed, 9 Sep 2026 20:37:43 +0800 Subject: [PATCH 04/14] chore: docs --- .../CHANGELOG.md | 16 + .../0002-password-change-recovery-flow.md | 253 +++++++++++ ...ess-password-change-implementation-plan.md | 399 ------------------ ...er-owned-password-change-recovery-plan.md} | 10 +- ...0003-seedless-password-change-contracts.md | 166 -------- 5 files changed, 274 insertions(+), 570 deletions(-) create mode 100644 packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md delete mode 100644 packages/seedless-onboarding-controller/docs/0002-seedless-password-change-implementation-plan.md rename packages/seedless-onboarding-controller/docs/{0004-controller-owned-password-change-recovery-plan.md => 0003-controller-owned-password-change-recovery-plan.md} (88%) delete mode 100644 packages/seedless-onboarding-controller/docs/0003-seedless-password-change-contracts.md diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index eb101fee7a6..dcaa0aaefb2 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -7,11 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `SeedlessPasswordChangePhase` enum and a `passwordChangePhase` state field to persist a non-sensitive password-change lifecycle phase used as a recovery signal ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Add `PasswordChangeRecoveryStatus` enum returned by the new password-change recovery methods ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Add `resolvePasswordSyncState({ skipCache })` to resolve remote password-change state without a password at unlock, merging the legacy `checkIsPasswordOutdated` read with password-change recovery routing ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Add `recoverPasswordChange({ globalPassword })` to reconcile the Seedless side with the new password and advance the lifecycle to `LOCAL_KEYRING_PENDING` ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Add `clearPasswordChangePhase`, `markPasswordChangeKeySyncPending`, and `completePasswordChange` lifecycle-advance methods ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Add `PasswordChangeInProgress` error message, thrown when a second password change is attempted while one is already in progress ([#0000](https://github.com/MetaMask/core/pull/0000)) + ### Changed +- **BREAKING:** `changePassword` is now lifecycle-aware: it writes `SEEDLESS_CHANGE_PENDING`, `SEEDLESS_COMMITTED`, and `LOCAL_KEYRING_PENDING` phases and rejects a second concurrent change with `PasswordChangeInProgress`. Clients must not start a second password change while the lifecycle is unfinished; see [0002](./docs/0002-password-change-recovery-flow.md) for the client integration guide ([#0000](https://github.com/MetaMask/core/pull/0000)) - Bump `@metamask/keyring-controller` from `^27.1.0` to `^27.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791)) - Bump `@metamask/utils` from `^11.11.0` to `^11.12.0` ([#10076](https://github.com/MetaMask/core/pull/10076)) +### Removed + +- **BREAKING:** Remove the public `checkIsPasswordOutdated` method and `SeedlessOnboardingControllerCheckIsPasswordOutdatedAction`; the read is folded into `resolvePasswordSyncState` (now private `#checkIsPasswordOutdated`) ([#0000](https://github.com/MetaMask/core/pull/0000)) +- **BREAKING:** Remove `resolvePasswordChangeRecovery` method and `SeedlessOnboardingControllerResolvePasswordChangeRecoveryAction`; replaced by `resolvePasswordSyncState` (password-less resolve) and `recoverPasswordChange` (password-consuming apply) ([#0000](https://github.com/MetaMask/core/pull/0000)) +- **BREAKING:** Remove `PasswordChangeRecoveryResult` type; recovery methods now return `PasswordChangeRecoveryStatus` ([#0000](https://github.com/MetaMask/core/pull/0000)) + ## [10.1.1] ### Added diff --git a/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md b/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md new file mode 100644 index 00000000000..385732fd2e1 --- /dev/null +++ b/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md @@ -0,0 +1,253 @@ +# Password-change recovery flow + +- Related ADR: [0001](./0001-seedless-password-change-recovery.md) +- Related Option B plan: [0004](./0004-controller-owned-password-change-recovery-plan.md) + +This is the operational and technical guide for the Seedless password-change recovery flow: what the controller owns, what the client owns, the public API, the recovery flow, the client integration guide, and the technical invariants. + +## Principles + +- **Server-first.** The remote Seedless password changes first. Recovery then brings local state forward to the new password. There is no rollback. +- **No retries, no concurrency.** A password change is never re-run as a fresh `changePassword` / `changeEncKey` call while the previous outcome is unresolved. The controller mutex serializes controller operations; the client adds a coordinator lock that also covers the `KeyringController` step. +- **Lifecycle is a signal, not proof.** `passwordChangePhase` only tells the client that recovery *may* be needed. Recovery always re-verifies actual remote and local state before acting. +- **Lock before error.** Any password-change or recovery failure locks the wallet *before* an error modal or intermediary screen is shown. +- **`UNKNOWN` is honest.** If remote or local state cannot be established, the wallet stays locked and the phase is preserved. Never infer a result from a rejected Promise. + +## The lifecycle phase + +Persisted on `SeedlessOnboardingControllerState.passwordChangePhase` (`persist: true`). Missing / `undefined` means `IDLE`. The field holds no secrets. + +| Phase | Meaning | +| --- | --- | +| `IDLE` | No change in progress. | +| `SEEDLESS_CHANGE_PENDING` | A change started; the remote outcome is not yet confirmed. | +| `SEEDLESS_COMMITTED` | The remote Seedless password change is confirmed committed. | +| `LOCAL_KEYRING_PENDING` | The local Seedless vault has been rewritten with the new password. | +| `KEY_SYNC_PENDING` | The Keyring encryption key has been stored; awaiting final verification/sync. | +| `COMPLETE` | Fully complete and verified. | +| `UNKNOWN` | The result of one or more steps could not be established. | + +## The recovery status + +Returned by the two controller methods. The client routes UI from this status. + +| Status | Meaning | Client action | +| --- | --- | --- | +| `no-change` | Remote did not commit; phase cleared to `IDLE`. | Unlock with the old password normally. | +| `password-outdated` | Phase is `IDLE` but the remote password changed (another device changed it). | Prompt for the new password, then `recoverPasswordChange`. | +| `enter-new-password` | Remote committed (or the local Seedless side still needs the new password). | Prompt for the new password, then `recoverPasswordChange`. | +| `reconcile-keyring` | Seedless side reconciled (phase is `LOCAL_KEYRING_PENDING`). | Cryptographically classify the local Keyring, then run the old/new branch. | +| `sync-key` | Phase is `KEY_SYNC_PENDING`. | Export, store, and sync the current Keyring encryption key, then `completePasswordChange`. | +| `complete` | Phase is `COMPLETE`. | `clearPasswordChangePhase`, then unlock normally. | +| `unknown` | Remote or local state could not be established. | Keep the wallet locked. Preserve the phase. Offer reset wallet only as an explicit last resort. | + +## Controller public API + +The controller owns the Seedless-side sequencing. The client owns the Keyring-side steps and UI routing. + +### Read / resolve (no password) + +```ts +SeedlessOnboardingController:resolvePasswordSyncState({ + skipCache?: boolean, +}): Promise +``` + +Single unlock-time call (call on page render *and* on password submit). Merges the legacy `checkIsPasswordOutdated` read with password-change recovery routing. + +- `IDLE`: authoritative outdated check; `skipCache` honored (cache on render, force-remote on submit). Returns `no-change` or `password-outdated`. +- `SEEDLESS_CHANGE_PENDING`: forces a remote check (ignores `skipCache`). Clears to `IDLE` (`no-change`) or advances to `SEEDLESS_COMMITTED` (`enter-new-password`). +- Other phases: returns the matching status without a remote call. +- On any failure: returns `unknown` and preserves the phase. + +### Apply (with password) + +```ts +SeedlessOnboardingController:recoverPasswordChange({ + globalPassword: string, +}): Promise +``` + +Reconciles the Seedless side with the supplied password. + +- `SEEDLESS_COMMITTED` / `LOCAL_KEYRING_PENDING`: re-runs `submitGlobalPassword` → `syncLatestGlobalPassword` (idempotent), advances to `LOCAL_KEYRING_PENDING`, returns `reconcile-keyring`. +- `IDLE`: re-checks the remote password and, if outdated, runs the same password-sync flow without advancing any phase (another-device sync); returns `no-change`. If not outdated, a no-op. +- `SEEDLESS_CHANGE_PENDING`: returns `unknown` (resolve remote state via `resolvePasswordSyncState` first). +- On any failure: returns `unknown` and preserves the phase. + +### Lifecycle advance (client-driven) + +```ts +SeedlessOnboardingController:markPasswordChangeKeySyncPending(): Promise +SeedlessOnboardingController:completePasswordChange(): Promise +SeedlessOnboardingController:clearPasswordChangePhase(): Promise +``` + +All idempotent, serialized under the controller lock, with no-op guards. + +- `markPasswordChangeKeySyncPending` — advance to `KEY_SYNC_PENDING` after the Keyring encryption key is stored. +- `completePasswordChange` — advance to `COMPLETE` only after sync verification and durable local persistence. +- `clearPasswordChangePhase` — clear to `IDLE` after `COMPLETE` (or after a definitive remote non-commit). This is the only way back to `IDLE`. + +## Recovery flow + +``` +unlock render / submit + │ + ▼ + resolvePasswordSyncState({ skipCache }) + │ + ▼ + ┌────────────────────┬───────────────────┬──────────────────┬─────────────────┬───────────┬──────────┬─────────┐ + │ no-change │ password-outdated │ enter-new-password │ reconcile-keyring │ sync-key │ complete │ unknown │ + │ unlock w/ old pwd │ prompt new pwd │ prompt new pwd │ classify Keyring │ finish sync│ clear │ locked │ + └───────────────────┴───────────────────┴───────────────────┴─────────────────┴───────────┴──────────┴─────────┘ + │ │ │ │ │ + │ ▼ ▼ ▼ ▼ + │ recoverPasswordChange recoverPasswordChange old/new branch completePasswordChange + │ ({ globalPassword }) ({ globalPassword }) (see below) → clearPasswordChangePhase + ▼ + normal unlock │ │ + ▼ ▼ + Seedless reconciled Seedless reconciled + → reconcile-keyring → reconcile-keyring + │ │ + ▼ ▼ + classify local Keyring via KeyringController:verifyPassword(newPassword) +``` + +### Old-Keyring branch (local Keyring still on the old password) + +1. `loadKeyringEncryptionKey()` — recover the stored Keyring encryption key with the new Seedless password. +2. `KeyringController:submitEncryptionKey` — unlock the old local Keyring. +3. `KeyringController:changePassword(newPassword)` — re-encrypt the local Keyring. +4. `KeyringController:exportEncryptionKey` — export the current Keyring encryption key. +5. `storeKeyringEncryptionKey()` — store the current key locally (encrypted with the new Seedless password). +6. `markPasswordChangeKeySyncPending()` — advance to `KEY_SYNC_PENDING`. +7. Sync the Keyring encryption key to the remote Seedless backup. +8. `completePasswordChange()` → `clearPasswordChangePhase()` — finish. + +### New-Keyring branch (local Keyring already on the new password) + +1. `KeyringController:verifyPassword(newPassword)` — unlock/verify the local Keyring with the new password. +2. `KeyringController:exportEncryptionKey` — export the current Keyring encryption key. +3. `storeKeyringEncryptionKey()` — store the current key locally. +4. `markPasswordChangeKeySyncPending()` — advance to `KEY_SYNC_PENDING`. +5. Sync the Keyring encryption key to the remote Seedless backup. +6. `completePasswordChange()` → `clearPasswordChangePhase()` — finish. + +### `KEY_SYNC_PENDING` (resuming after a restart) + +1. Unlock with the new password. +2. `KeyringController:exportEncryptionKey` — export the current Keyring encryption key. +3. `storeKeyringEncryptionKey()` — re-store/sync the current key. +4. Sync to the remote Seedless backup and verify. +5. `completePasswordChange()` → `clearPasswordChangePhase()`. + +## Client integration guide + +1. **Coordinator lock.** Add a single lock covering the whole Seedless + Keyring transaction. The controller mutex already serializes controller operations; this lock extends serialization across the `KeyringController` step. Reject a second password change while the lifecycle is unfinished. + +2. **Persist `SEEDLESS_CHANGE_PENDING` before the first remote mutation.** The controller writes this itself inside `changePassword`; the client must ensure the controller state slice is persisted (debounced, same as other persisted controller state) before any irreversible step. + +3. **Unlock routing.** On unlock (page render *and* password submit), read `passwordChangePhase` from controller state, then call `resolvePasswordSyncState({ skipCache })`. Route UI from the returned status using the table above. Do not classify a password as invalid until recovery has run. + +4. **Two-step UX.** + - Step 1 (password-less): `resolvePasswordSyncState` decides whether the old or new password is needed. + - Step 2 (password-consuming): only after step 1 returns `enter-new-password` / `password-outdated`, prompt for the new password and call `recoverPasswordChange({ globalPassword })`. + +5. **Keyring classification.** On `reconcile-keyring`, call `KeyringController:verifyPassword(newPassword)` to choose the old-Keyring or new-Keyring branch. Do **not** infer the local Keyring state from the lifecycle phase. + +6. **Lock before error.** Any failure from `changePassword`, `resolvePasswordSyncState`, `recoverPasswordChange`, or any Keyring step must lock the wallet *before* surfacing an error modal, retry screen, or intermediary UI. If the lock itself fails, keep the wallet in a recovery-blocked UI and never expose wallet access. + +7. **`COMPLETE` boundary.** Call `completePasswordChange()` only after the synchronized Keyring encryption key and all required local state are durably persisted. Then `clearPasswordChangePhase()` to return to `IDLE`. + +8. **`UNKNOWN` is terminal for this attempt.** If `resolvePasswordSyncState` or `recoverPasswordChange` returns `unknown`, keep the wallet locked, preserve the phase, and stop. Do not retry `changePassword` / `changeEncKey`. Offer reset wallet only as an explicit last resort for a confirmed unrecoverable state. + +9. **Cache.** `resolvePasswordSyncState` honors `skipCache` for the `IDLE` outdated check only. Use `skipCache: false` (default) on render and `skipCache: true` on submit. `SEEDLESS_CHANGE_PENDING` always forces a remote check. + +## Technical details + +### Lifecycle model and persistence + +The lifecycle is one optional persisted field: + +```ts +passwordChangePhase?: SeedlessPasswordChangePhase; +``` + +It is persisted as ordinary controller state (`persist: true`) through the normal debounced `stateChange` flow — the same path as every other persisted field. There is **no** separate awaitable durability hook on the controller. The lifecycle is a recovery signal only: it is not proof that a remote or local operation completed, and it is not proof that the lifecycle itself reached durable storage before the next step ran. A crash can leave the durable marker behind the actual cryptographic state, so recovery always re-verifies actual remote and local state before acting on the phase. A missing or stale marker is recoverable: `#checkIsPasswordOutdated({ skipCache: true })` detects a remote change with no marker at all, and cryptographic Keyring verification classifies the local state. + +### Lifecycle write points + +Controller `this.update(...)` calls happen: + +- before the first remote mutation (`SEEDLESS_CHANGE_PENDING`); +- after authoritative remote commitment (`SEEDLESS_COMMITTED`); +- after the local Seedless vault rewrite (`LOCAL_KEYRING_PENDING`); +- after local Keyring-key storage when that update is coupled to a lifecycle write; +- after `COMPLETE`; +- after an explicit clear to `IDLE`. + +These publish `SeedlessOnboardingController:stateChange`; they are not awaited durability boundaries. + +### Phase preservation on failure + +On a failed step the controller **preserves the last known phase**; it does not overwrite it with `UNKNOWN` and does not reset to `IDLE` on every error. The last written phase is the recovery signal: e.g. if `#changeEncryptionKey` rejects, the phase stays `SEEDLESS_CHANGE_PENDING` and the client performs an authoritative password-outdated check to choose the recovery branch. If the failure happened before the first lifecycle write, the lifecycle stays `IDLE` (nothing to recover). `UNKNOWN` is a recovery-time determination made by the client when an authoritative server check or local cryptographic verification cannot establish the state — not a phase written by `changePassword`'s catch block. + +### No retries, no concurrency + +A password-change operation must never be retried as a fresh `changePassword` / `changeEncKey` call while the previous outcome is unresolved. Race conditions here are dangerous and could block users from their wallets. The existing controller mutex (`#withControllerLock` / `#controllerOperationMutex`) serializes all mutable controller operations; the client coordinator adds a single lock that also covers the `KeyringController` operation. The lifecycle exists to signal that recovery is needed — not to enable retries. `#assertPasswordInSync` also guards against TOPRF operations while a change is pending (with a `skipPhaseCheck` bypass for `changePassword`'s own token-refresh retry). + +### `changePassword` behavior + +`changePassword` is now lifecycle-aware: it writes `SEEDLESS_CHANGE_PENDING` before the first remote mutation, `SEEDLESS_COMMITTED` after authoritative remote commitment, and `LOCAL_KEYRING_PENDING` after the local Seedless vault rewrite. It rejects a second concurrent change with `PasswordChangeInProgress`. It reuses the existing `verifyVaultPassword`, `#assertPasswordInSync({ skipCache: true })`, `#changeEncryptionKey` (via `#executeWithTokenRefresh`), `#createNewVaultWithAuthData`, and `storeKeyringEncryptionKey`. A rejected `#changeEncryptionKey` Promise is not proof that the server did not mutate; only a definitive server result may return the lifecycle to `IDLE`. + +### `storeKeyringEncryptionKey` behavior + +`storeKeyringEncryptionKey` encrypts the current Keyring encryption key under the current Seedless password encryption key and writes `encryptedKeyringEncryptionKey` on controller state. It never marks `COMPLETE` by itself — completion also requires client confirmation of the local Keyring state, remote synchronization, and durable persistence. `loadKeyringEncryptionKey` is read-only with respect to lifecycle state; loading a key does not complete recovery. + +### Recovery mechanism + +Recovery reuses the existing password-sync flow, which already handles "remote changed, local is outdated" (e.g. another device changed the password): + +- `submitGlobalPassword({ globalPassword })` — `toprfClient.recoverPwEncKey` walks the server-side password-key history chain (`maxPwChainLength`) to find the `pwEncKey` matching this device's `authPubKey`, then unlocks the vault. +- `syncLatestGlobalPassword({ globalPassword })` — `toprfClient.recoverEncKey` derives encryption material from the candidate password and rewrites the local Seedless vault with the new password's keys. + +Both run through `#executeWithTokenRefresh`, which preserves the existing token-refresh retry behavior. `storeKeyringEncryptionKey` of the already-current key is a local overwrite and is safe to re-run. + +### Remote-state classification + +After a lost, timed-out, or incomplete `changeEncKey` response, recovery classifies remote state via `toprfClient.fetchAuthPubKey` (with `skipCache: true`), comparing the remote `authPubKey` with the last durable local `authPubKey`: + +| Observation | Classification | Lifecycle effect | +| --- | --- | --- | +| Fetch succeeds and remote `authPubKey` equals the pre-change local `authPubKey`. | **Old** | Safe to treat as uncommitted. Clear to `IDLE` only after this check. | +| Fetch succeeds and remote `authPubKey` equals the expected post-change key, or the new password recovers remote material. | **New** | Treat as committed. Advance to `SEEDLESS_COMMITTED` or later recovery. | +| Fetch fails, comparison is impossible, or local `authPubKey` is missing/stale. | **Unknown** | Preserve the phase. Keep the wallet locked. Do not retry `changeEncKey`. | + +There is no API that reports partial backup or key-share state; such cases are classified as **Unknown**. The TOPRF server does not accept a transaction ID / idempotency key today; that remains a "good to have" for a future release. Until then, ambiguous remote results stay `UNKNOWN`. + +## What the controller does not do + +- It does not call `KeyringController` (`AllowedActions = never`). Keyring classification, re-encryption, and key export are client responsibilities. +- It does not provide an awaitable durability boundary for lifecycle writes. The lifecycle is persisted as ordinary debounced controller state. Recovery re-verifies actual state, so a stale/missing marker is recoverable. +- It does not retry `changePassword` / `changeEncKey`. Recovery reconciles local state via the existing password-sync flow (`submitGlobalPassword` + `syncLatestGlobalPassword`). +- It does not lock the wallet. Locking is a client responsibility (the client owns navigation/intermediary screens). + +## Controller-side status + +All controller-package work is complete: + +- Lifecycle model, helpers, metadata, exports. +- Lifecycle-aware `changePassword` with concurrency guard and phase preservation on error. +- Lifecycle-aware `storeKeyringEncryptionKey`. +- `resolvePasswordSyncState` + `recoverPasswordChange` (Option A: controller owns the Seedless side). +- `markPasswordChangeKeySyncPending` / `completePasswordChange` / `clearPasswordChangePhase`. +- Messenger action types, package exports, and unit tests (290 tests, 100% statement / 99.22% branch coverage). + +Remaining work is **not** in this package: + +- **Client integration** — coordinator, unlock routing, locking, UI, and E2E coverage (see the [Client integration guide](#client-integration-guide) above). +- **Open decisions** — rate-limit behavior for recovery; whether clients need an explicit migration version bump for persisted state created before `passwordChangePhase` existed. +- **Option B** — future migration where the controller also owns the `KeyringController` side (see [0004](./0004-controller-owned-password-change-recovery-plan.md)). diff --git a/packages/seedless-onboarding-controller/docs/0002-seedless-password-change-implementation-plan.md b/packages/seedless-onboarding-controller/docs/0002-seedless-password-change-implementation-plan.md deleted file mode 100644 index 660649f4592..00000000000 --- a/packages/seedless-onboarding-controller/docs/0002-seedless-password-change-implementation-plan.md +++ /dev/null @@ -1,399 +0,0 @@ -# Implementation plan: Seedless password-change recovery - -- Related ADR: [ADR 0001: Recovering server-first Seedless password changes](./0001-seedless-password-change-recovery.md) -- Related contract: [Phase 0 contract 0003](./0003-seedless-password-change-contracts.md) -- Status: Planned -- Scope: `SeedlessOnboardingController`, its messenger contract, package tests, and the client persistence/orchestration contract required by the controller - -## Progress tracker - -Update the checkboxes as work is completed. Keep the phase status aligned with its checklist: - -- `Not started` — no task in the phase is complete. -- `In progress` — at least one task is complete, but the phase checklist is not complete. -- `Blocked` — progress cannot continue until an open decision or external dependency is resolved. -- `Complete` — all tasks and verification items in the phase are complete. - -| Phase | Status | Remaining work | -| ---------------------------------- | ----------- | -------------------------------------------------------------- | -| 0. External prerequisites | Complete | — | -| 1. Lifecycle model | Complete | — | -| 2. Controller lifecycle operations | Complete | — | -| 3. `changePassword` flow | Complete | — | -| 4. Keyring-key storage | Complete | — | -| 5. Recovery primitives | Complete | — | -| 6. Messenger/package contracts | Complete | — | -| 7. Client integration | Not started | Add coordinator, locking, unlock routing, UI, and E2E coverage | - -At the end of each phase, update its status and remove completed items from the remaining-work description. Keep unresolved items in [Open decisions before implementation](#open-decisions-before-implementation). - -## Goal - -Make Seedless password changes recoverable after a crash, lost response, or partial local update. - -The controller should persist enough non-sensitive lifecycle information to tell the client that recovery is required. The client should then verify the actual remote and local state and finish the operation. The implementation must remain server-first. The password change is never retried; recovery reconciles local state using the existing password-sync flow. - -## Design summary - -Use one persisted field that holds the last-known phase: - -```ts -passwordChangePhase?: SeedlessPasswordChangePhase; -``` - -The field must never contain a password, SRP, raw encryption key, decrypted vault data, or an error message that may contain sensitive data. Missing or `undefined` means `IDLE`. - -Use the lifecycle as a recovery signal only. It is not proof that a remote or local operation completed. Recovery must always: - -1. Check authoritative remote state. -2. Verify the local Keyring cryptographically. -3. Reconcile local state using the existing password-sync flow (`submitGlobalPassword` + `syncLatestGlobalPassword`); never retry `changePassword` or `changeEncKey`. -4. Keep the wallet locked if the state cannot be established. - -**No retries, no concurrency.** A password-change operation must never be retried as a fresh `changePassword`/`changeEncKey` call while the previous outcome is unresolved. Race conditions here are dangerous and could block users from their wallets. The existing controller mutex (`#withControllerLock` / `#controllerOperationMutex`) already serializes all mutable controller operations; the client coordinator must add a single lock that also covers the KeyringController operation. The lifecycle exists to signal that recovery is needed — not to enable retries. - -The lifecycle phases are the names from the ADR: - -`IDLE`, `SEEDLESS_CHANGE_PENDING`, `SEEDLESS_COMMITTED`, `LOCAL_KEYRING_PENDING`, `KEY_SYNC_PENDING`, `COMPLETE`, and `UNKNOWN`. - -## Current implementation and gaps - -The plan should reuse these existing capabilities: - -- `changePassword` already verifies the old Seedless vault password, calls `#assertPasswordInSync({ skipCache: true })`, calls `#changeEncryptionKey`, rewrites the local Seedless vault with `#createNewVaultWithAuthData`, and stores the existing Keyring encryption key with `storeKeyringEncryptionKey`. -- `#executeWithTokenRefresh` already handles the controller’s token-refresh retry path. -- `#withControllerLock` already serializes mutable controller operations. -- `loadKeyringEncryptionKey` and `storeKeyringEncryptionKey` already decrypt and encrypt the Keyring encryption key using the current Seedless password encryption key. -- `submitGlobalPassword` and `syncLatestGlobalPassword` already recover and persist the latest global password locally. This is the **existing password-sync flow** that handles “remote changed, local is outdated” (e.g. another device changed the password). It is reused as the recovery mechanism for a partially committed password change. -- `checkIsPasswordOutdated({ skipCache: true })` already provides a cache-bypassed auth-public-key comparison via `toprfClient.fetchAuthPubKey`. This is the authoritative old-vs-new check. -- `verifyVaultPassword`, `#unlockVaultAndGetVaultData`, and `#updateVault` already provide local Seedless vault verification and rewriting. -- `serializeVaultData`, `deserializeVaultData`, and the existing AES helpers should continue to be used for vault/key handling. - -The gaps that the implementation must address are: - -- `changePassword` has no lifecycle transitions. -- `storeKeyringEncryptionKey` currently updates controller state but does not prove that the state reached durable storage. -- `BaseController.update` is synchronous. It publishes `stateChanged`, but the controller cannot currently await a client’s storage write. -- The current controller mutex protects controller operations, but it does not serialize a client’s KeyringController operation with the controller operation. - -## Proposed public contract - -Keep the existing methods where possible. Add only the lifecycle information needed to make recovery safe. - -### State and constants - -1. Add `SeedlessPasswordChangePhase` to `src/constants.ts`. - - - Use string enum values matching the ADR exactly. - - Add no sensitive values to the enum. - -2. Add `passwordChangePhase?: SeedlessPasswordChangePhase` to `SeedlessOnboardingControllerState` in `src/types.ts`. - - - Make the field optional so old persisted state without it is treated as `IDLE`. - -3. Add metadata for `passwordChangePhase` in `seedlessOnboardingMetadata`. - - - Set `persist: true`. - - Keep state logs and debug snapshots limited to safe fields, or exclude the field if the platform does not need it there. - - Do not expose raw error objects through state. - -4. Export the phase type through `src/index.ts`. - -### Lifecycle helpers - -Add small, pure helpers rather than spreading phase mutations through the controller: - -1. Define a helper for treating a missing phase as `IDLE`. -2. Validate legal transitions in tests. Do not make the transition validator the source of truth for recovery; a persisted phase may be stale. - -These helpers can live in `src/utils.ts` if they remain general and pure. Keep controller-specific transition behavior in private controller methods. - -### Transaction identifier - -Out of scope for this plan. The Seedless/TOPRF server does not accept an idempotency key or transaction ID today, and adding one is not a simple server-side change. Recovery here does not retry the password change — it uses the existing password-sync flow (`checkIsPasswordOutdated` + `submitGlobalPassword` + `syncLatestGlobalPassword`) to reconcile local state once remote state is established. A transaction ID remains a “good to have” for a future TOPRF release; until then, ambiguous remote results stay `UNKNOWN`. - -### Lifecycle persistence - -The lifecycle is persisted as ordinary controller state. The `passwordChangePhase` field has `persist: true` metadata, so it is written through the controller's normal `stateChange` flow (the same debounced persistence path as every other persisted field). There is no separate awaitable durability hook on the controller. - -The lifecycle is a recovery signal only — it is not proof that a remote or local operation completed, and it is not proof that the lifecycle itself reached durable storage before the next step ran. A crash can leave the durable marker behind the actual cryptographic state, so recovery must always re-verify actual remote and local state before acting on the phase. A missing or stale marker is recoverable: `checkIsPasswordOutdated({ skipCache: true })` detects a remote change with no marker at all, and cryptographic Keyring verification classifies the local state. - -Required lifecycle write points (controller `this.update(...)` calls): - -- before the first remote mutation (`SEEDLESS_CHANGE_PENDING`); -- after each irreversible boundary (`SEEDLESS_COMMITTED`, `LOCAL_KEYRING_PENDING`); -- after local Keyring-key storage when that update is coupled to a lifecycle write; -- after `COMPLETE`; -- after explicit clear to `IDLE`. - -On a failed step the controller **preserves the last known phase**; it does not overwrite it with `UNKNOWN`. The last written phase is the recovery signal: e.g. if `changeEncKey` rejected, the phase stays `SEEDLESS_CHANGE_PENDING` and the client performs an authoritative password-outdated check to choose the recovery branch. If the failure happened before the first lifecycle write, the lifecycle stays `IDLE` (nothing to recover). `UNKNOWN` is a recovery-time determination made by the client when an authoritative server check or local cryptographic verification cannot establish the state — not a phase written by `changePassword`'s catch block. - -These publish `SeedlessOnboardingController:stateChange`; they are not awaited durability boundaries. See [0003](./0003-seedless-password-change-contracts.md). - -## Development phases - -### Phase 0: Confirm external prerequisites - -Complete these checks before changing controller behavior: - -- [x] Confirm how Seedless reports the result of a password-change request after a timeout or lost response. -- [x] Confirm whether the password-change request accepts an idempotency key or transaction ID. -- [x] Confirm whether Keyring encryption-key synchronization is a Seedless/TOPRF API, a client persistence operation, or both. -- [x] Define how the remote service reports old, new, partial, and unknown state. -- [x] Define the lifecycle persistence approach for extension and mobile. -- [x] Define how the client reads the lifecycle before attempting normal unlock. - -Deliverable: [0003-seedless-password-change-contracts.md](./0003-seedless-password-change-contracts.md). Authoritative remote status is unavailable in `@metamask/toprf-secure-backup@1.1.0`; lost-response and partial backup paths remain `UNKNOWN` until TOPRF adds that API. - -### Phase 1: Add the lifecycle model - -- [x] Add the phase enum and lifecycle type. -- [x] Add the optional state field and metadata. -- [x] Treat missing state as `IDLE` for backward compatibility. -- [x] Add pure lifecycle helpers and legal-transition tests. -- [x] Add exports and messenger type visibility where required. -- [x] Update the test fixture helpers so lifecycle state can be supplied and inspected. - -Verify: - -- [x] Lifecycle values are persisted. -- [x] Sensitive fields are not included in the lifecycle. -- [x] Old state fixtures still construct successfully. -- [x] Default state behavior remains unchanged except for the new optional field. - -### Phase 2: Add controller lifecycle operations - -Add private methods with names that describe the boundary, for example: - -- `#startPasswordChangeLifecycle` -- `#advancePasswordChangeLifecycle` -- `#writePasswordChangePhase` -- `#completePasswordChangeLifecycle` -- `#clearPasswordChangePhase` - -Implement them in this order: - -- [x] Create the lifecycle before the first remote mutation with `SEEDLESS_CHANGE_PENDING`. -- [x] Preserve the last known phase when any later operation throws; do not overwrite it with `UNKNOWN` and do not reset to `IDLE` on every error. -- [x] Make clearing the lifecycle an explicit operation after definitive remote failure or durable `COMPLETE`. -- [x] Keep all transitions serialized under `#withControllerLock`. -- [x] Route durable lifecycle writes through the persistence contract selected in Phase 0. - -Do not add a second mutex unless the existing controller mutex cannot protect the lifecycle update. The client must use its own coordinator lock for the cross-controller transaction. - -### Phase 3: Refactor `changePassword` around explicit boundaries - -Refactor the current method without duplicating its cryptographic work: - -- [x] Acquire the existing controller lock. -- [x] Reject a second concurrent password change; recovery must finish before a new one starts. -- [x] Create/persist the lifecycle as `SEEDLESS_CHANGE_PENDING`. -- [x] Reuse `verifyVaultPassword(oldPassword, { skipLock: true })`. -- [x] Reuse `#assertPasswordInSync({ skipCache: true, skipLock: true })`. -- [x] Reuse `loadKeyringEncryptionKey()` before the remote mutation when an encrypted Keyring key exists. -- [x] Call `#changeEncryptionKey` through the existing `#executeWithTokenRefresh` wrapper. -- [x] After authoritative remote commitment, persist `SEEDLESS_COMMITTED`. -- [x] Reuse `#createNewVaultWithAuthData` to write the new local Seedless vault. -- [x] Persist `LOCAL_KEYRING_PENDING` after local Seedless state has been updated. -- [x] Reuse `storeKeyringEncryptionKey` for the encrypted local copy of the current Keyring key. -- [x] Leave final Keyring re-encryption, local Keyring-key storage, and `COMPLETE` to the client coordinator. -- [x] Preserve the existing error wrapping with `SeedlessOnboardingError`, but retain the last lifecycle phase when wrapping the error. -- [x] Reset the password-outdated cache only after the local Seedless password update succeeds, using the existing `#resetPasswordOutdatedCache`. - -Important: a rejected Promise from `#changeEncryptionKey` does not prove that the server did not mutate. Only a definitive server result may return the lifecycle to `IDLE`. - -### Phase 4: Make Keyring-key storage lifecycle-aware - -Update `storeKeyringEncryptionKey` and its private helper with minimal behavior changes: - -- [x] Keep the current `#unlockVaultAndGetVaultData` call to obtain the Seedless password encryption key. -- [x] Keep the current AES-GCM encryption and base64 encoding. -- [x] Update `encryptedKeyringEncryptionKey` and the lifecycle boundary in the same controller update where possible, so observers do not see an unrelated intermediate lifecycle state. -- [x] Allow the client to mark `KEY_SYNC_PENDING` before synchronization and `COMPLETE` only after synchronization verification and all local writes succeed. -- [x] Never let `storeKeyringEncryptionKey` mark `COMPLETE` by itself. -- [x] Keep `loadKeyringEncryptionKey` read-only with respect to lifecycle state; loading a key is not proof of recovery completion. - -> **Descoped:** A separate awaitable durable-persistence boundary for lifecycle writes is out of scope for the controller. The lifecycle is persisted as ordinary controller state via the normal debounced `stateChange` path (same as every other persisted field); there is no extra durability hook on the controller. Recovery must therefore always re-verify actual remote and local state before acting on the phase — a missing or stale marker is recoverable via `checkIsPasswordOutdated({ skipCache: true })` and cryptographic Keyring verification. See [Design summary](#design-summary) and [0003](./0003-seedless-password-change-contracts.md). - -### Phase 5: Add recovery-facing controller behavior - -Keep cross-controller orchestration in the client, but make the controller primitives safe and explicit: - -- [x] `submitGlobalPassword({ globalPassword })` remains the entry point to recover the Seedless controller with the new password. -- [x] `syncLatestGlobalPassword({ globalPassword })` remains the operation that rewrites the local Seedless vault after recovery. -- [x] `loadKeyringEncryptionKey()` remains the old-Keyring recovery input. -- [x] `storeKeyringEncryptionKey()` remains the local encrypted-key persistence operation. -- [x] `checkIsPasswordOutdated({ skipCache: true })` must be used during recovery whenever the client needs a fresh auth-public-key comparison. (Controller honors `skipCache`; covered by the "should bypass cache if skipCache is true" test.) -- [x] Do not silently use a cached `passwordOutdatedCache` result on the recovery path. (Satisfied by `skipCache` bypass.) -- [x] Preserve `#executeWithTokenRefresh` behavior for all existing password-sync operations. -- [x] Ensure controller lock state is cleaned up correctly when recovery operations fail. (`withLock` releases in `finally`.) - -The client coordinator then performs the two ADR branches: - -#### Old local Keyring - -- [ ] Confirm remote Seedless is new via `checkIsPasswordOutdated({ skipCache: true })`. -- [ ] Recover the Seedless controller with the new password via `submitGlobalPassword({ globalPassword })` (walks the server password-key history chain). -- [ ] Rewrite the local Seedless vault via `syncLatestGlobalPassword({ globalPassword })`. -- [ ] Load the stored Keyring encryption key via `loadKeyringEncryptionKey()`. -- [ ] Call `KeyringController:submitEncryptionKey`. -- [ ] Call `KeyringController:changePassword(newPassword)`. -- [ ] Export the current Keyring encryption key. -- [ ] Store it locally via `storeKeyringEncryptionKey`. -- [ ] Verify durable local state. -- [ ] Mark `COMPLETE`. - -#### New local Keyring - -- [ ] Confirm remote Seedless is new via `checkIsPasswordOutdated({ skipCache: true })`. -- [ ] Recover the Seedless controller with the new password via `submitGlobalPassword({ globalPassword })`. -- [ ] Rewrite the local Seedless vault via `syncLatestGlobalPassword({ globalPassword })`. -- [ ] Verify/unlock the local Keyring with the new password. -- [ ] Export the current Keyring encryption key. -- [ ] Store it locally via `storeKeyringEncryptionKey`. -- [ ] Verify durable local state. -- [ ] Mark `COMPLETE`. - -If local cryptographic verification or remote status cannot establish the branch, mark `UNKNOWN` and keep the wallet locked. - -> **Scope note:** The two ADR branches above (Old / New local Keyring) are client orchestration and are implemented in [Phase 7](#phase-7-implement-client-integration). No controller-package code is required for them beyond the primitives already preserved in this phase. - -### Phase 6: Update messenger and package contracts - -- [x] Update `src/SeedlessOnboardingController-method-action-types.ts` documentation and types for the lifecycle-aware `changePassword` behavior. (Regenerated via `messenger-action-types:generate` after adding `clearPasswordChangePhase`, `markPasswordChangeKeySyncPending`, `completePasswordChange`, `resolvePasswordSyncState`, `recoverPasswordChange` to `MESSENGER_EXPOSED_METHODS`; `messenger-action-types:check` passes.) -- [x] Export the new lifecycle types and enum from `src/index.ts`. (`SeedlessPasswordChangePhase` and `PasswordChangeRecoveryStatus` enums exported from `./constants.js`; added the new action-type exports. The recovery methods return `PasswordChangeRecoveryStatus` directly, so no separate result type is exported.) -- [x] Check all generated/action type references compile without manually editing generated output beyond the source-of-truth file. (Only `MESSENGER_EXPOSED_METHODS` in the controller was hand-edited; the generated file was regenerated, not hand-edited.) -- [x] Update package consumers and mock messengers that call `changePassword`. (Mock messenger auto-derives from `SeedlessOnboardingControllerMessenger`; no external package references the removed `SeedlessPasswordChangeLifecycle`/`passwordChangeLifecycle`.) -- [x] Preserve the existing `changePassword` signature and behavior for callers that do not opt into lifecycle-aware recovery. (Signature unchanged; lifecycle is additive via new state field and methods.) -- [x] Add controller-owned Seedless-side recovery methods (`resolvePasswordSyncState` + `recoverPasswordChange`) so clients do not have to hand-orchestrate the Seedless half of recovery. `resolvePasswordSyncState` merges the legacy `checkIsPasswordOutdated` read with password-change recovery routing into a single unlock-time call. See [0004](./0004-controller-owned-password-change-recovery-plan.md) for the future Option B (full cross-controller recovery) migration. - -### Phase 7: Implement client integration - -This work is outside the controller package but is required for the ADR to be complete. The controller side of recovery is already provided (Option A): `resolvePasswordSyncState()` resolves remote state without a password (merging the legacy `checkIsPasswordOutdated` read with password-change recovery routing), and `recoverPasswordChange({ globalPassword })` reconciles the Seedless side with the new password. The client owns the Keyring-side steps and UI routing based on the returned `PasswordChangeRecoveryStatus`. See [0004](./0004-controller-owned-password-change-recovery-plan.md) for the planned Option B migration where the controller also owns the Keyring side. - -- [ ] Add a single coordinator lock covering Seedless and Keyring password changes. The controller mutex already serializes controller operations; this lock extends serialization to the cross-controller transaction. -- [ ] Persist `SEEDLESS_CHANGE_PENDING` before the first remote mutation. -- [ ] Lock the wallet before exposing any password-change or recovery error. -- [ ] On unlock, inspect the durable lifecycle before normal invalid-password handling. -- [ ] For every unfinished phase, call `resolvePasswordSyncState()` first (password-less remote-state resolution); only prompt for the new password when it returns `enter-new-password`. -- [ ] After the user supplies the new password, call `recoverPasswordChange({ globalPassword })` to reconcile the Seedless side; on `reconcile-keyring`, run the Keyring-side branch below. -- [ ] Use `KeyringController:verifyPassword` to classify old versus new local Keyring state. -- [ ] Old-Keyring branch: `loadKeyringEncryptionKey` → `KeyringController:submitEncryptionKey` → `KeyringController:changePassword` → `storeKeyringEncryptionKey` → `markPasswordChangeKeySyncPending`. -- [ ] New-Keyring branch: `KeyringController:exportEncryptionKey` → `storeKeyringEncryptionKey` → `markPasswordChangeKeySyncPending`. -- [ ] `KEY_SYNC_PENDING`: unlock with the new password, export the current Keyring encryption key, store/sync it to the remote Seedless backup, then `completePasswordChange` → `clearPasswordChangePhase`. -- [ ] Never retry `changePassword` or `changeEncKey`; reconcile only via the recovery methods and the existing password-sync flow. -- [ ] Keep the wallet locked and the phase `UNKNOWN` if the result is not distinguishable. -- [ ] Persist `COMPLETE` only after local persistence is verified. -- [ ] Offer reset wallet only as an explicit last resort for a confirmed unrecoverable state. - -## Test plan - -### Controller unit tests - -Extend `src/SeedlessOnboardingController.test.ts` and add focused tests for: - -- [ ] Default and legacy state handling. -- [ ] Lifecycle metadata persistence flags. -- [ ] Valid and invalid phase transitions. -- [ ] `changePassword` writing each expected phase. -- [ ] Lifecycle preservation when old-password verification fails. -- [ ] Lifecycle preservation when `#changeEncryptionKey` rejects. -- [ ] Lifecycle preservation when local vault rewriting rejects. -- [ ] Lifecycle preservation when `storeKeyringEncryptionKey` rejects. -- [ ] Definitive remote failure returning to `IDLE` only after authoritative confirmation. -- [ ] Ambiguous remote failure becoming `UNKNOWN`. -- [ ] Keyring-key storage and lifecycle update ordering. -- [ ] Durable persistence failures being surfaced to the caller. -- [ ] Repeated lifecycle transitions being safe to re-run. -- [ ] Existing token-refresh retry behavior remaining unchanged. -- [ ] Existing `loadKeyringEncryptionKey` and `storeKeyringEncryptionKey` behavior remaining compatible. -- [ ] `resolvePasswordSyncState` returning the correct `PasswordChangeRecoveryStatus` for each phase, clearing to `IDLE` when remote did not commit, advancing to `SEEDLESS_COMMITTED` when remote committed, and returning `unknown` (preserving the phase) when the remote check fails. -- [ ] `recoverPasswordChange` reconciling the Seedless side and advancing to `LOCAL_KEYRING_PENDING` for `SEEDLESS_COMMITTED`/`LOCAL_KEYRING_PENDING`, and returning `unknown` (preserving the phase) when reconciliation fails. - -Use the existing fixtures and mocks in `tests/__fixtures__` and `tests/mocks`. Add only the remote-status mocks that the new contract requires. - -### Coordinator/integration tests - -Add tests in each client for: - -- termination before the remote request; -- termination during the remote request; -- remote commitment with an old local Keyring; -- remote commitment with an already-new local Keyring; -- failure during local Seedless persistence; -- failure during local Keyring password change; -- failure during Keyring-key storage; -- lost responses; -- stale or missing lifecycle state; -- stale password-outdated cache; -- persistence failure before `COMPLETE`; -- wallet locking before error UI or recovery UI is shown; -- recovery remaining blocked when the lock operation fails. - -For every fault-injection test, verify both the durable lifecycle and the actual cryptographic/server state after restart. - -## Acceptance checklist - -The implementation is ready when: - -- [ ] A lifecycle marker is durable before the first remote mutation. -- [ ] A remote timeout is never classified as a definitive remote failure without an authoritative status result. -- [ ] The old-Keyring branch can recover through the stored Keyring encryption key without asking for the old Keyring password. -- [ ] The new-Keyring branch can export and store the current Keyring encryption key locally. -- [ ] `COMPLETE` cannot be written before local persistence is durable. -- [ ] Recovery bypasses stale password-outdated cache results. -- [ ] Any password-change or recovery error locks the wallet before error/intermediary UI is exposed. -- [ ] An unresolved server or cryptographic result remains `UNKNOWN`. -- [ ] A second password change cannot run concurrently. -- [ ] `changePassword` / `changeEncKey` is never retried; recovery uses the existing password-sync flow. -- [ ] Existing controller tests, lint, type checks, and changelog validation pass. - -## Suggested implementation order - -- [x] Confirm the remote status and durable persistence contracts. -- [ ] Add lifecycle types, constants, metadata, helpers, and unit tests. -- [ ] Add the persistence boundary and test its failure behavior. -- [ ] Add lifecycle transitions to `changePassword`. -- [ ] Make `storeKeyringEncryptionKey` lifecycle-aware. -- [ ] Update messenger exports and package consumers. -- [ ] Implement client recovery orchestration and locking. -- [ ] Add fault-injection integration tests. -- [ ] Run focused package tests, then lint/type checks and changelog validation. - -## Files expected to change - -### This package - -- `src/constants.ts` — lifecycle phase enum and `PasswordChangeRecoveryStatus` enum. -- `src/types.ts` — password-change phase state field. -- `src/utils.ts` — pure lifecycle helpers, if needed. -- `src/SeedlessOnboardingController.ts` — metadata, transition helpers, lifecycle-aware `changePassword`, lifecycle-aware key storage, and controller-owned recovery methods (`resolvePasswordSyncState`, `recoverPasswordChange`). `resolvePasswordSyncState` folds the legacy `checkIsPasswordOutdated` read (now private `#checkIsPasswordOutdated`) into the unlock-time recovery routing. -- `src/SeedlessOnboardingController-method-action-types.ts` — public action documentation/signature. -- `src/index.ts` — public exports. -- `src/SeedlessOnboardingController.test.ts` — unit and fault-injection coverage. -- `docs/0003-seedless-password-change-contracts.md` — Phase 0 shared contract. -- `docs/0004-controller-owned-password-change-recovery-plan.md` — Option B (full cross-controller recovery) migration plan. -- `tests/__fixtures__/*` and `tests/mocks/*` — lifecycle, status, and persistence fixtures as needed. - -### Outside this package - -- Client password-change coordinator and unlock/recovery routing. -- KeyringController integration for `verifyPassword`, `submitEncryptionKey`, `changePassword`, and `exportEncryptionKey`. -- Client persistence of the `SeedlessOnboardingController` state slice (debounced, same as other persisted controller state). -- Seedless/TOPRF API support for authoritative status (future: idempotent retries keyed by transaction ID). -- Client UI and end-to-end tests. - -## Open decisions before implementation - -Resolved in [0003](./0003-seedless-password-change-contracts.md): - -- [x] Decide the lifecycle persistence approach. Persisted as ordinary controller state (`persist: true`) via the normal `stateChange` flow; no separate awaitable durability hook. Recovery re-verifies actual state, so a stale/missing marker is recoverable. -- [x] Define what exact remote API confirms password-change and Keyring-key synchronization status. Today: `fetchAuthPubKey` plus cryptographic recover. No transaction-status API. Local Keyring-key proof is `storeKeyringEncryptionKey` durability only. -- [x] Confirm whether `transactionId` is accepted by the current Seedless/TOPRF API, or whether server work must land first. Not accepted, and out of scope for this plan. Recovery uses `fetchAuthPubKey` comparison and cryptographic verification instead. -- [x] Define what the remote service returns for a partial backup/key-share update. Nothing; classify as `UNKNOWN`. -- [x] Decide whether the lifecycle phase is visible to UI state or only to the client coordinator through the messenger. Persisted controller state; coordinator reads `getState` before unlock; `usedInUi: true` for the phase only. - -Still open: - -- [ ] Define rate-limit behavior for recovery. -- [ ] Define migration behavior for persisted state created before this field existed. Phase 1 treats a missing field as `IDLE`; confirm whether clients need an explicit migration version bump. diff --git a/packages/seedless-onboarding-controller/docs/0004-controller-owned-password-change-recovery-plan.md b/packages/seedless-onboarding-controller/docs/0003-controller-owned-password-change-recovery-plan.md similarity index 88% rename from packages/seedless-onboarding-controller/docs/0004-controller-owned-password-change-recovery-plan.md rename to packages/seedless-onboarding-controller/docs/0003-controller-owned-password-change-recovery-plan.md index 16567a736eb..696791ff4f9 100644 --- a/packages/seedless-onboarding-controller/docs/0004-controller-owned-password-change-recovery-plan.md +++ b/packages/seedless-onboarding-controller/docs/0003-controller-owned-password-change-recovery-plan.md @@ -1,12 +1,12 @@ # Plan 0004: Migrate password-change recovery into the controller (Option B) - Status: Planned (post-testing migration) -- Related: [ADR 0001](./0001-seedless-password-change-recovery.md), [Implementation plan 0002](./0002-seedless-password-change-implementation-plan.md), [Contracts 0003](./0003-seedless-password-change-contracts.md) +- Related: [ADR 0001](./0001-seedless-password-change-recovery.md), [Recovery flow 0002](./0002-password-change-recovery-flow.md) - Scope: `SeedlessOnboardingController` only ## Context -The first implementation ([0002](./0002-seedless-password-change-implementation-plan.md)) ships **Option A**: the controller owns all *Seedless-side* recovery sequencing, but the *Keyring-side* steps (`verifyPassword`, `submitEncryptionKey`, `changePassword`, `exportEncryptionKey`) stay in the client because `SeedlessOnboardingController` has no `KeyringController` dependency (`AllowedActions = never`). +The first implementation (see [0002](./0002-password-change-recovery-flow.md)) ships **Option A**: the controller owns all *Seedless-side* recovery sequencing, but the *Keyring-side* steps (`verifyPassword`, `submitEncryptionKey`, `changePassword`, `exportEncryptionKey`) stay in the client because `SeedlessOnboardingController` has no `KeyringController` dependency (`AllowedActions = never`). This document plans the migration to **Option B**: the controller owns the entire recovery, including the Keyring side. Motivation: the recovery transaction spans two controllers, and we cannot rely on every client sequencing the Keyring-side steps correctly. Centralizing the full transaction removes a class of client-integration bugs. @@ -55,8 +55,8 @@ A single controller method performs the entire recovery for any non-IDLE phase a ### 3. Contracts and exports -- Update [0003](./0003-seedless-password-change-contracts.md): the client contract shrinks to "call `recoverPasswordChange`, route on status". The Keyring-side client steps move to the controller. -- Update [0002](./0002-seedless-password-change-implementation-plan.md) Phase 7 controller-side items and the progress tracker. +- Update [0002](./0002-password-change-recovery-flow.md): the client contract shrinks to "call `recoverPasswordChange`, route on status". The Keyring-side client steps move to the controller. +- Update the controller-side status and remaining-work notes in [0002](./0002-password-change-recovery-flow.md). - Re-export the new result/status types from `src/index.ts`. - Regenerate `SeedlessOnboardingController-method-action-types.ts` (the method signature change is picked up automatically). @@ -86,5 +86,5 @@ A single controller method performs the entire recovery for any non-IDLE phase a 1. Land Option A and ship it; gather client integration feedback. 2. Add the `KeyringController` messenger dependency and mock wiring (behind no behavior change yet). 3. Fold the Keyring-side steps into `recoverPasswordChange`; change the return shape to final status. -4. Update contracts (0003), plan (0002), exports, and clients. +4. Update the recovery flow guide (0002), exports, and clients. 5. Run the full controller + client test suites; remove the now-dead client sequencing code. diff --git a/packages/seedless-onboarding-controller/docs/0003-seedless-password-change-contracts.md b/packages/seedless-onboarding-controller/docs/0003-seedless-password-change-contracts.md deleted file mode 100644 index fa03fafe703..00000000000 --- a/packages/seedless-onboarding-controller/docs/0003-seedless-password-change-contracts.md +++ /dev/null @@ -1,166 +0,0 @@ -# Phase 0 contract: Seedless password-change recovery - -- Related ADR: [ADR 0001](./0001-seedless-password-change-recovery.md) -- Related plan: [Implementation plan 0002](./0002-seedless-password-change-implementation-plan.md) -- Status: Accepted for controller implementation, with documented `UNKNOWN` gaps -- Date: 2026-09-08 -- Scope: contracts shared by `SeedlessOnboardingController`, clients (extension and mobile), and the Seedless/TOPRF API layer - -This document is the Phase 0 deliverable. Later phases must follow these contracts. They must not treat `this.update(...)` or a rejected remote Promise as proof of remote or durable state. - -## Confirmed current APIs (`@metamask/toprf-secure-backup@1.1.0`) - -These are the APIs this package already calls. None of them accept a transaction identifier or idempotency key. - -| Operation | Current API | What a success proves | What a rejection proves | -| --- | --- | --- | --- | -| Remote password / key-share change | `toprfClient.changeEncKey` | The SDK reported that key shares and re-encrypted secret metadata were updated. | Nothing about whether nodes or metadata already mutated. Timeout, disconnect, and many server errors are ambiguous. | -| Current remote auth public key | `toprfClient.fetchAuthPubKey` | Returns `{ authPubKey, keyIndex }` for the current remote authentication public key. | Fetch failure. It is not a transaction-status API. | -| Local password vs remote | `checkIsPasswordOutdated({ skipCache: true })` | Local `authPubKey` equals or differs from the fetched remote `authPubKey`. | Fetch failure. Cached results must not be used on recovery. | -| Recover with a candidate password | `recoverEncKey` / `submitGlobalPassword` | The candidate password can derive the current remote encryption material. | The candidate is wrong, rate-limited, or the request failed. A failure is not proof that a concurrent change did not commit. | -| OPRF key-share persist | `toprfClient.persistLocalKey` | Used for first-time key setup and related persist paths, not as a password-change status query. | Ambiguous unless the error is a known pre-mutation client error. | -| Local Keyring encryption-key copy | `storeKeyringEncryptionKey` / `loadKeyringEncryptionKey` | Controller state holds an AES-GCM copy of the Keyring encryption key, encrypted under the current Seedless password encryption key. | Local encrypt/state-update failure only. This is not a remote write. | - -`changeEncKey` parameters today: `nodeAuthTokens`, `authConnectionId`, `groupedAuthConnectionId`, `userId`, `oldEncKey`, `oldPwEncKey`, `oldAuthKeyPair`, `newKeyShareIndex`, `newPassword` or `pregeneratedOprfKey`, and optional `transformDataItems`. There is no `transactionId`, `idempotencyKey`, or status-query field. - -`EncAccountDataType` today is `PrimarySrp`, `ImportedSrp`, and `ImportedPrivateKey`. There is no typed remote item for a Keyring encryption key. - -## Remote result after timeout or lost response - -**Contract:** a lost, timed-out, or otherwise incomplete `changeEncKey` response is **not** a definitive remote failure. - -Recovery must then: - -1. Call `fetchAuthPubKey` with no password-outdated cache (`skipCache: true`). -2. Compare the remote `authPubKey` with the last durable local `authPubKey`. -3. Optionally confirm a candidate password with `recoverEncKey` / `submitGlobalPassword` when the user supplies one. - -Classification after that check: - -| Observation | Remote classification | Lifecycle effect | -| --- | --- | --- | -| Fetch succeeds and remote `authPubKey` equals the pre-change local `authPubKey`. | **Old** | Safe to treat as uncommitted. Clear the lifecycle to `IDLE` only after this check. | -| Fetch succeeds and remote `authPubKey` equals the expected post-change public key, or the new password recovers remote material. | **New** | Treat as committed. Advance to `SEEDLESS_COMMITTED` or later recovery. | -| Fetch fails, comparison is impossible, or local `authPubKey` is missing/stale so old vs new cannot be distinguished. | **Unknown** | Persist `UNKNOWN`. Keep the wallet locked. Do not retry `changeEncKey` as if it were a fresh change. | -| Metadata/key shares may have updated without a matching auth-public-key change, or only some nodes/items updated. | **Partial / unknown** | There is **no** API that reports partial backup or key-share state. Classify as **Unknown**. | - -Do not infer “nothing changed” from `FailedToChangePassword` or from a rejected `#changeEncryptionKey` Promise. - -Until TOPRF exposes an authoritative transaction-status API, the `SEEDLESS_CHANGE_PENDING` lost-response path remains `UNKNOWN` whenever step 1 fails or step 2 cannot distinguish old from new. - -## No retries, no concurrency - -**Contract:** a password-change operation must never be retried as a fresh `changePassword` / `changeEncKey` call. Race conditions here are dangerous and could block users from their wallets. - -The existing controller mutex (`#withControllerLock` / `#controllerOperationMutex`) already serializes all mutable controller operations. The client coordinator adds a single lock that also covers the KeyringController operation. The lifecycle is a **recovery signal**, not a retry-enabler. - -Recovery rules: - -- Do **not** call `changeEncKey` again while remote classification is unknown. -- After remote classification is **old** (server did not commit), clear the lifecycle to `IDLE`. A later password change is a fresh operation, not a retry. -- After remote classification is **new** (server committed), do not call `changeEncKey` again. Reconcile the Seedless side through the controller-owned recovery methods, which wrap the existing password-sync flow: `resolvePasswordSyncState()` (password-less remote-state resolution; also merges the legacy `checkIsPasswordOutdated` read) → `recoverPasswordChange({ globalPassword })` (runs `submitGlobalPassword` → `syncLatestGlobalPassword` internally and advances to `LOCAL_KEYRING_PENDING`). The client then owns the Keyring-side steps (`loadKeyringEncryptionKey` / `storeKeyringEncryptionKey`, `markPasswordChangeKeySyncPending`, `completePasswordChange`). -- `storeKeyringEncryptionKey` of the already-current key is a local overwrite and is safe to re-run. - -The controller-owned recovery methods return a `PasswordChangeRecoveryStatus` (`NoChange`, `EnterNewPassword`, `ReconcileKeyring`, `SyncKey`, `Complete`, `Unknown`) that the client routes on. This is Option A (controller owns the Seedless side; client owns the Keyring side). See [0004](./0004-controller-owned-password-change-recovery-plan.md) for the planned Option B migration where the controller also owns the Keyring side. - -A transaction ID / idempotency key is out of scope — the TOPRF server does not accept one today, and adding it is not a simple server-side change. It remains a “good to have” for a future TOPRF release. - -## Keyring encryption-key storage and recovery - -The Keyring encryption key is stored **locally** in controller state (`encryptedKeyringEncryptionKey`), encrypted under the current Seedless password encryption key. There is no separate remote TOPRF API for it, and none is needed for this plan. - -- `storeKeyringEncryptionKey` encrypts the current Keyring encryption key under the current Seedless password encryption key and writes `encryptedKeyringEncryptionKey` on controller state. -- `loadKeyringEncryptionKey` is read-only with respect to lifecycle. Loading a key does not complete recovery. -- `storeKeyringEncryptionKey` must never mark `COMPLETE`. - -Recovery reuses the existing password-sync flow, which already handles “remote changed, local is outdated”. The controller now owns the Seedless-side sequencing through two public methods: - -1. `resolvePasswordSyncState()` — password-less. Merges the legacy `checkIsPasswordOutdated` read (now private `#checkIsPasswordOutdated`) with password-change recovery routing, so the client makes a single call at unlock. For `IDLE` it runs the authoritative outdated check (`skipCache` honored) and returns `no-change` or `password-outdated`. For `SEEDLESS_CHANGE_PENDING` it forces a remote check (ignoring `skipCache`), clears to `IDLE` if remote is **old**, advances to `SEEDLESS_COMMITTED` if remote is **new**, and returns `unknown` (preserving the phase) if the check fails. For all other phases it returns the matching status without a remote call. -2. `recoverPasswordChange({ globalPassword })` — password-consuming. For `SEEDLESS_COMMITTED` / `LOCAL_KEYRING_PENDING` it runs `submitGlobalPassword({ globalPassword })` (`toprfClient.recoverPwEncKey` walks the server-side password-key history chain `maxPwChainLength` to find the `pwEncKey` matching this device’s `authPubKey`, then unlocks the vault) → `syncLatestGlobalPassword` (rewrites the local Seedless vault with the new password’s keys), then advances to `LOCAL_KEYRING_PENDING` and returns `reconcile-keyring`. For `IDLE` it re-checks the remote password and, if outdated, runs the same password-sync flow without advancing any phase (another-device sync); if not outdated it is a no-op. On failure it returns `unknown` and preserves the phase. - -The client then owns the Keyring side based on the returned status: - -3. `loadKeyringEncryptionKey()` (old-Keyring branch) or `storeKeyringEncryptionKey(currentKey)` (new-Keyring branch) — recover or persist the Keyring encryption key locally. -4. `markPasswordChangeKeySyncPending()` → `completePasswordChange()` → `clearPasswordChangePhase()` once the current key is synchronized and persisted. - -`COMPLETE` in this contract means: remote Seedless password is new, local Seedless vault is new, local Keyring uses the new password, and the current Keyring encryption key is durably stored via `storeKeyringEncryptionKey`. - -## Lifecycle persistence - -**Decision:** the password-change lifecycle is persisted as ordinary controller state. The `passwordChangePhase` field has `persist: true` metadata, so it is written through the controller's normal `stateChange` flow (the same debounced persistence path used by every other persisted field). There is no separate awaitable durability hook on the controller. - -The lifecycle is a **recovery signal only** — it is not proof that a remote or local operation completed, and it is not proof that the lifecycle itself reached durable storage before the next step ran. A crash can leave the durable marker behind the actual cryptographic state. Recovery must therefore always re-verify actual remote and local state (see [No retries, no concurrency](#no-retries-no-concurrency) and [Unlock-time lifecycle read](#unlock-time-lifecycle-read)) before acting on the phase. A missing or stale marker is recoverable: `checkIsPasswordOutdated({ skipCache: true })` detects a remote change with no marker at all, and cryptographic Keyring verification classifies the local state. - -Required lifecycle write points (in controller code): - -- before the first remote mutation (`SEEDLESS_CHANGE_PENDING`); -- after authoritative remote commitment (`SEEDLESS_COMMITTED`); -- after the local Seedless vault rewrite (`LOCAL_KEYRING_PENDING`); -- after local Keyring-key storage when that update is coupled to a lifecycle write; -- after `COMPLETE`; -- after explicit clear to `IDLE`. - -On a failed step the controller **preserves the last known phase**; it does not overwrite it with `UNKNOWN`. The last written phase is the recovery signal (e.g. a `changeEncKey` rejection leaves `SEEDLESS_CHANGE_PENDING`, and the client performs an authoritative password-outdated check to choose the branch). If the failure happened before the first lifecycle write, the lifecycle stays `IDLE`. `UNKNOWN` is a recovery-time determination made by the client when an authoritative server check or local cryptographic verification cannot establish the state — not a phase written by `changePassword`'s catch block. - -These are `this.update(...)` calls that publish `SeedlessOnboardingController:stateChange`. They are not awaited durability boundaries. - -### Platform persistence - -| Client | Durable write | Unlock-time read | -| --- | --- | --- | -| Extension | The persisted `SeedlessOnboardingController` slice in `chrome.storage` / the client persist pipeline (debounced). | Read the persisted slice during background/start hydration, before password-unlock error handling. | -| Mobile | The filesystem / redux-persist (or equivalent) write for the same slice (debounced). | Read the rehydrated slice at app start, before treating unlock as a normal invalid-password failure. | - -The generic ComposableController / redux persist debounce remains the persistence path for this field, the same as for all other persisted controller state. - -## Unlock-time lifecycle read - -**Decision:** the lifecycle is persisted controller state. Clients read it through `SeedlessOnboardingController:getState` (or the already-hydrated persisted snapshot) **before** normal Keyring invalid-password handling. Recovery UI may observe safe fields; the coordinator, not the UI, decides the recovery branch. - -Metadata for `passwordChangePhase` (Phase 1): - -- `persist: true` -- `usedInUi: true` so recovery screens can show phase, without exposing secrets -- `includeInDebugSnapshot: false` -- `includeInStateLogs: true` (the only stored field is `phase`, which is non-sensitive) - -Missing persisted field ⇒ `IDLE`. - -Unlock routing: - -1. Hydrate durable controller state. -2. Read `passwordChangePhase`; treat missing as `IDLE`. -3. If phase is `IDLE` or `COMPLETE` (or `COMPLETE` already cleared to `IDLE`): continue normal unlock. -4. Otherwise: recovery-blocked path. Do not report the entered password as an ordinary Keyring unlock failure while recovery is pending. -5. Call `resolvePasswordSyncState()` to resolve remote state without a password. Only prompt for the new password when it returns `enter-new-password`; if it returns `no-change`, unlock with the old password normally. -6. After the user supplies the new password, call `recoverPasswordChange({ globalPassword })` to reconcile the Seedless side. On `reconcile-keyring`, classify the local Keyring with `KeyringController:verifyPassword` (new vs old). Do not infer that from the lifecycle phase. -7. If remote or local classification cannot be established, keep the phase as-is (the recovery methods return `unknown` and preserve the phase) and keep the wallet locked. - -`COMPLETE` is not a second source of cryptographic truth. After a durable `COMPLETE`, the controller should clear to `IDLE` so the next unlock is normal. - -## Implications for later phases - -- Phase 1–2 may add lifecycle types and lifecycle write points without calling new TOPRF methods. -- Phase 3 must **preserve the last known lifecycle phase** on ambiguous `changeEncKey` failures (e.g. leave `SEEDLESS_CHANGE_PENDING` in place) and must not reset to `IDLE` without an **old** remote classification. It must never retry `changeEncKey`. `UNKNOWN` is determined later by recovery, not written by the catch block. -- Phase 4 couples local Keyring-key storage to a lifecycle write; no remote key-sync API is needed. -- Phase 5 reuses `submitGlobalPassword` and `syncLatestGlobalPassword` as the recovery mechanism. -- Phase 6 exposes the controller-owned recovery methods (`resolvePasswordSyncState`, `recoverPasswordChange`) and the `PasswordChangeRecoveryStatus` enum through the messenger and package exports. The legacy `checkIsPasswordOutdated` is folded into `resolvePasswordSyncState` (now private `#checkIsPasswordOutdated`). -- Phase 7 clients must implement the unlock-time read of the persisted lifecycle and route through `resolvePasswordSyncState` / `recoverPasswordChange` for the Seedless side, owning only the Keyring side (Option A). They must not start a second password change while the lifecycle is unfinished, and must never retry `changePassword` / `changeEncKey`. Option B (controller owning the Keyring side too) is planned in [0004](./0004-controller-owned-password-change-recovery-plan.md). - -## Existing TOPRF endpoints used by recovery - -The TOPRF server already exposes the endpoints this recovery model needs. No new server-side API is required for this plan: - -- `fetchAuthPubKey` — returns the current remote auth public key and key index. Used by `checkIsPasswordOutdated({ skipCache: true })` to classify old vs new after a lost response. -- `recoverPwEncKey` — walks the server-side password-key history chain (`maxPwChainLength`) to find the `pwEncKey` matching this device’s `authPubKey`. Used by `submitGlobalPassword` to unlock with the new password from a device still on the old auth key. -- `recoverEncKey` — derives encryption material from a candidate password. Used by `syncLatestGlobalPassword` to rewrite the local vault. - -The only residual risk is a network failure during the `fetchAuthPubKey` status check itself. In that case the result stays `UNKNOWN` and the wallet remains locked — this is not a missing API, just a network failure. - -## Future server/API work (optional, not required by this plan) - -These would improve recovery but are not required. The current plan works without them: - -1. Authoritative password-change status after a lost response (would let recovery distinguish committed vs uncommitted without relying on `authPubKey` comparison). -2. Idempotent `changeEncKey` keyed by a transaction ID (would make retries safe, but this plan does not retry). -3. Explicit partial-state reporting for backup and key-share updates (would reduce `UNKNOWN` outcomes). From e8fc3c4dc5985bf6d9a73e69e0f9b069c0d16560 Mon Sep 17 00:00:00 2001 From: lwin Date: Wed, 9 Sep 2026 22:08:35 +0800 Subject: [PATCH 05/14] fix: fixed missing Keyring Reconcilation for the cross-device password outdated case --- .../CHANGELOG.md | 27 ++- .../0001-seedless-password-change-recovery.md | 62 +++---- .../0002-password-change-recovery-flow.md | 103 +++++------ ...ler-owned-password-change-recovery-plan.md | 12 +- ...nboardingController-method-action-types.ts | 47 ++--- .../src/SeedlessOnboardingController.test.ts | 175 +++++------------- .../src/SeedlessOnboardingController.ts | 164 ++++++---------- .../src/constants.ts | 16 +- .../src/index.ts | 1 - .../src/utils.test.ts | 20 +- .../src/utils.ts | 18 +- 11 files changed, 225 insertions(+), 420 deletions(-) diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index dcaa0aaefb2..5eb694e7313 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -9,24 +9,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `SeedlessPasswordChangePhase` enum and a `passwordChangePhase` state field to persist a non-sensitive password-change lifecycle phase used as a recovery signal ([#0000](https://github.com/MetaMask/core/pull/0000)) -- Add `PasswordChangeRecoveryStatus` enum returned by the new password-change recovery methods ([#0000](https://github.com/MetaMask/core/pull/0000)) -- Add `resolvePasswordSyncState({ skipCache })` to resolve remote password-change state without a password at unlock, merging the legacy `checkIsPasswordOutdated` read with password-change recovery routing ([#0000](https://github.com/MetaMask/core/pull/0000)) -- Add `recoverPasswordChange({ globalPassword })` to reconcile the Seedless side with the new password and advance the lifecycle to `LOCAL_KEYRING_PENDING` ([#0000](https://github.com/MetaMask/core/pull/0000)) -- Add `clearPasswordChangePhase`, `markPasswordChangeKeySyncPending`, and `completePasswordChange` lifecycle-advance methods ([#0000](https://github.com/MetaMask/core/pull/0000)) -- Add `PasswordChangeInProgress` error message, thrown when a second password change is attempted while one is already in progress ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Add `SeedlessPasswordChangePhase` enum and a `passwordChangePhase` state field to persist a non-sensitive password-change lifecycle phase used as a recovery signal. An unset/`undefined` phase means "no change in progress" (there is no dedicated `IDLE` or `COMPLETE` member) ([#10148](https://github.com/MetaMask/core/pull/10148)) +- Add `PasswordChangeRecoveryStatus` enum returned by the new password-change recovery methods. `NoChange` is named `InSync` (`'in-sync'`) to reflect that the local and remote passwords are synchronized ([#10148](https://github.com/MetaMask/core/pull/10148)) +- Add `resolvePasswordSyncState({ skipCache })` to resolve remote password-change state without a password at unlock, merging the legacy `checkIsPasswordOutdated` read with password-change recovery routing ([#10148](https://github.com/MetaMask/core/pull/10148)) +- Add `recoverPasswordChange({ globalPassword })` to reconcile the Seedless side with the new password and advance the lifecycle to `LOCAL_KEYRING_PENDING` ([#10148](https://github.com/MetaMask/core/pull/10148)) +- Add `clearPasswordChangePhase` and `markPasswordChangeKeySyncPending` lifecycle-advance methods. `clearPasswordChangePhase` is the single way back to no change in progress (it both marks completion and clears the phase) ([#10148](https://github.com/MetaMask/core/pull/10148)) +- Add `PasswordChangeInProgress` error message, thrown when a second password change is attempted while one is already in progress ([#10148](https://github.com/MetaMask/core/pull/10148)) ### Changed -- **BREAKING:** `changePassword` is now lifecycle-aware: it writes `SEEDLESS_CHANGE_PENDING`, `SEEDLESS_COMMITTED`, and `LOCAL_KEYRING_PENDING` phases and rejects a second concurrent change with `PasswordChangeInProgress`. Clients must not start a second password change while the lifecycle is unfinished; see [0002](./docs/0002-password-change-recovery-flow.md) for the client integration guide ([#0000](https://github.com/MetaMask/core/pull/0000)) +- **BREAKING:** `changePassword` is now lifecycle-aware: it writes `SEEDLESS_CHANGE_PENDING`, `SEEDLESS_COMMITTED`, and `LOCAL_KEYRING_PENDING` phases and rejects a second concurrent change with `PasswordChangeInProgress`. Clients must not start a second password change while the lifecycle is unfinished; see [0002](./docs/0002-password-change-recovery-flow.md) for the client integration guide ([#10148](https://github.com/MetaMask/core/pull/10148)) - Bump `@metamask/keyring-controller` from `^27.1.0` to `^27.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791)) - Bump `@metamask/utils` from `^11.11.0` to `^11.12.0` ([#10076](https://github.com/MetaMask/core/pull/10076)) ### Removed -- **BREAKING:** Remove the public `checkIsPasswordOutdated` method and `SeedlessOnboardingControllerCheckIsPasswordOutdatedAction`; the read is folded into `resolvePasswordSyncState` (now private `#checkIsPasswordOutdated`) ([#0000](https://github.com/MetaMask/core/pull/0000)) -- **BREAKING:** Remove `resolvePasswordChangeRecovery` method and `SeedlessOnboardingControllerResolvePasswordChangeRecoveryAction`; replaced by `resolvePasswordSyncState` (password-less resolve) and `recoverPasswordChange` (password-consuming apply) ([#0000](https://github.com/MetaMask/core/pull/0000)) -- **BREAKING:** Remove `PasswordChangeRecoveryResult` type; recovery methods now return `PasswordChangeRecoveryStatus` ([#0000](https://github.com/MetaMask/core/pull/0000)) +- **BREAKING:** Remove the public `checkIsPasswordOutdated` method and `SeedlessOnboardingControllerCheckIsPasswordOutdatedAction`; the read is folded into `resolvePasswordSyncState` (now private `#checkIsPasswordOutdated`) ([#10148](https://github.com/MetaMask/core/pull/10148)) +- **BREAKING:** Remove `resolvePasswordChangeRecovery` method and `SeedlessOnboardingControllerResolvePasswordChangeRecoveryAction`; replaced by `resolvePasswordSyncState` (password-less resolve) and `recoverPasswordChange` (password-consuming apply) ([#10148](https://github.com/MetaMask/core/pull/10148)) +- **BREAKING:** Remove `PasswordChangeRecoveryResult` type; recovery methods now return `PasswordChangeRecoveryStatus` ([#10148](https://github.com/MetaMask/core/pull/10148)) +- **BREAKING:** Remove `SeedlessPasswordChangePhase.Idle` and `SeedlessPasswordChangePhase.Complete`; "no change in progress" and "done" are both represented by an unset/`undefined` phase ([#10148](https://github.com/MetaMask/core/pull/10148)) +- **BREAKING:** Remove `PasswordChangeRecoveryStatus.NoChange` and `PasswordChangeRecoveryStatus.Complete`; the former is renamed `InSync` (`'in-sync'`) and the latter is no longer returned (a completed change is just an unset phase, which resolves to `InSync`) ([#10148](https://github.com/MetaMask/core/pull/10148)) +- **BREAKING:** Remove `completePasswordChange` method and `SeedlessOnboardingControllerCompletePasswordChangeAction`; completion is now recorded by calling `clearPasswordChangePhase` once key synchronization and local persistence are verified ([#10148](https://github.com/MetaMask/core/pull/10148)) + +### Fixed + +- Ensure `recoverPasswordChange` advances another-device password recovery to `LOCAL_KEYRING_PENDING` and returns `ReconcileKeyring` after synchronizing the Seedless vault, so clients reconcile the local Keyring before unlocking normally ([#10148](https://github.com/MetaMask/core/pull/10148)) ## [10.1.1] diff --git a/packages/seedless-onboarding-controller/docs/0001-seedless-password-change-recovery.md b/packages/seedless-onboarding-controller/docs/0001-seedless-password-change-recovery.md index 4a581128ad7..6c75f2b6882 100644 --- a/packages/seedless-onboarding-controller/docs/0001-seedless-password-change-recovery.md +++ b/packages/seedless-onboarding-controller/docs/0001-seedless-password-change-recovery.md @@ -33,10 +33,10 @@ Use a durable, idempotent lifecycle state machine around the server-first operat - Use cryptographic verification to determine whether the local Keyring is old or new. - Re-run already-completed operations safely instead of attempting an in-process rollback. - Lock the wallet from the client whenever any password-change or recovery step fails, before exposing an error or intermediary screen. -- Mark `COMPLETE` only after the current Keyring encryption key is synchronized to Seedless and all required local state is durably persisted. +- Clear the lifecycle only after the current Keyring encryption key is synchronized to Seedless and all required local state is durably persisted. (There is no separate `COMPLETE` state: "no change in progress" and "done" are both represented by an unset/`undefined` phase.) - Keep any state that cannot be distinguished safely as `unknown`. -The lifecycle names below are descriptive. They can be mapped to the final implementation enum without changing the recovery semantics. +The lifecycle names below are descriptive. They can be mapped to the final implementation enum without changing the recovery semantics. The implementation uses `undefined` for the "no change in progress / done" state rather than a dedicated `IDLE`/`COMPLETE` enum member. ## Implementation scope @@ -60,14 +60,14 @@ The new controller work is: - Add a persisted password-change lifecycle state/phase to `SeedlessOnboardingControllerState`, with persistence metadata. The lifecycle must not store passwords, SRPs, raw Keyring encryption keys, or decrypted backup material. - Modify `changePassword` to update the lifecycle after each relevant operation: before the remote change, after remote commitment, after the local Seedless vault/state update, and when the operation fails or becomes ambiguous. - Modify `storeKeyringEncryptionKey` to update the lifecycle after the encrypted Keyring encryption key has been stored in controller state. The encrypted-key update and lifecycle update should be adjacent so observers do not see an inconsistent intermediate controller state. -- Do not let `storeKeyringEncryptionKey` mark `COMPLETE` by itself. Completion also requires client confirmation of the local Keyring state, remote synchronization, and durable persistence. +- Do not let `storeKeyringEncryptionKey` clear the lifecycle by itself. Completion also requires client confirmation of the local Keyring state, remote synchronization, and durable persistence. - Ensure a thrown error after a partial mutation does not reset the lifecycle to the pre-operation state. The last known phase must remain available for recovery. - Facilitate the existing password-sync operations for both post-remote-commit recovery branches: - Old local Keyring: submit the new Seedless password, load the stored Keyring encryption key, and allow the client to call `submitEncryptionKey` before re-encrypting locally. - New local Keyring: submit the new password, verify/unlock the local Keyring, export its current Keyring encryption key, and store/synchronize it. - Preserve the existing token-refresh and controller-lock behavior while making lifecycle transitions observable to clients. -The controller must not infer completion from a successful in-memory update or from a rejected Promise. The client remains responsible for coordinating the KeyringController and for the final durable `COMPLETE` transition. +The controller must not infer completion from a successful in-memory update or from a rejected Promise. The client remains responsible for coordinating the KeyringController and for the final durable clear of the lifecycle. #### KeyringController @@ -89,8 +89,8 @@ Wallet locking for password-change errors is also a client responsibility. The c - Write `SEEDLESS_CHANGE_PENDING` before the first remote mutation. - Write `SEEDLESS_COMMITTED` only after remote commitment is confirmed by the server or an authoritative status check. - Advance the lifecycle after each `changePassword` and `storeKeyringEncryptionKey` operation so a later unlock can identify the last known boundary, while treating the phase as advisory when persistence may have been interrupted. -- Use an awaitable durable persistence operation for lifecycle transitions and `COMPLETE`. The generic debounced state-change path must not be the only durability boundary. -- Serialize password-change and recovery operations. A second request must be rejected or queued until the first transaction reaches `COMPLETE` or an explicitly recoverable terminal state. +- Use an awaitable durable persistence operation for lifecycle transitions and the final clear. The generic debounced state-change path must not be the only durability boundary. +- Serialize password-change and recovery operations. A second request must be rejected or queued until the first transaction is cleared (no change in progress) or reaches an explicitly recoverable terminal state. - Make recovery verify the actual cryptographic state before mutating either controller. - Keep the recovery transaction active until Keyring encryption-key synchronization and local persistence are confirmed. Do not clear the lifecycle marker early. @@ -146,13 +146,13 @@ Each client must provide a durable persistence boundary for lifecycle state: - Lifecycle transitions must have an explicit, awaitable durable-write path. - The client must be able to read the last lifecycle state before normal unlock routing begins. -- `COMPLETE` must be written only after the synchronized Keyring encryption key and all required local controller state are durably persisted. +- The lifecycle must be cleared only after the synchronized Keyring encryption key and all required local controller state are durably persisted. - A generic debounce may remain acceptable for unrelated state, but it cannot prove that password-change state is durable. - Lifecycle state must contain only non-sensitive metadata and must never contain passwords, SRPs, raw Keyring encryption keys, or decrypted backup material. #### Client UI and user behavior -Each client must provide UI behavior for `SEEDLESS_CHANGE_PENDING`, `SEEDLESS_COMMITTED`, `LOCAL_KEYRING_PENDING`, `KEY_SYNC_PENDING`, `COMPLETE`, and `UNKNOWN`: +Each client must provide UI behavior for `SEEDLESS_CHANGE_PENDING`, `SEEDLESS_COMMITTED`, `LOCAL_KEYRING_PENDING`, `KEY_SYNC_PENDING`, and `UNKNOWN` (no dedicated UI state is needed for "no change in progress" — that is the normal wallet UI): - Show a recovery-blocked state for every unfinished lifecycle state. - Treat any password-change error as a locked-wallet state before showing an error modal, retry screen, or other intermediary UI. @@ -162,7 +162,7 @@ Each client must provide UI behavior for `SEEDLESS_CHANGE_PENDING`, `SEEDLESS_CO - Preserve retryable recovery actions across app/browser restarts and backgrounding. - Prevent a second password change while recovery is pending. - Do not display raw server/controller errors or sensitive recovery data. -- Do not expose the wallet as fully recovered until key synchronization is verified and `COMPLETE` is durable. +- Do not expose the wallet as fully recovered until key synchronization is verified and the lifecycle is cleared. - Keep reset wallet as an explicit last resort. It must not be triggered automatically for a recoverable partial state or used to hide an unresolved remote result. The client owns this lock/error boundary because it controls navigation and intermediary screens. This allows UX changes without changing the controller’s cryptographic responsibilities, while ensuring that no client-specific screen accidentally leaves a partially changed wallet unlocked. @@ -189,7 +189,7 @@ All clients must agree on: - Which states require a server check before unlock. - The two cryptographic recovery branches. - Idempotency and transaction-identifier semantics. -- The definition of durable synchronization and the `COMPLETE` boundary. +- The definition of durable synchronization and the completion (clear) boundary. - The meaning of `unknown` and the conditions under which reset wallet may be offered. - The requirement that any password-change or recovery error locks the wallet before an error or intermediary screen is shown. @@ -199,27 +199,25 @@ Platform-specific UI can differ, but it must not change the recovery decision or This table defines what the user and UI should experience when the lifecycle state is encountered during unlock. The current server and local states are defined separately below so that recovery behavior is not confused with state observation. -| Lifecycle state | Sync Server state check required before unlock? | User behaviors | UI requirements | -| --- | --- | --- | --- | -| `IDLE` | No. A server check may run as part of normal Seedless behavior, but it is not a recovery prerequisite. | Enter the current wallet password and continue normally. The user may start a new password change. | Show the normal locked or unlocked wallet UI. | -| `SEEDLESS_CHANGE_PENDING` | Yes. The remote request may not have started, may have failed before mutation, or may have committed with a lost response. | Do not assume which password is valid. If the server proves that the change did not commit, enter the old password. If it proves commitment, enter the new password. If the result remains ambiguous, `unknown`. Do not start another password change. | Show a password-change recovery screen. Do not report an entered password as an ordinary unlock failure while recovery is pending. Explain that the previous password change must be resolved first. | -| `SEEDLESS_COMMITTED` | Yes. Confirm the remote Seedless password and required backup/key-share changes. | Enter the new Seedless password. The user should not need the old Keyring password when the stored Keyring encryption key is recoverable from Seedless. | Keep wallet access behind a recovery screen. Explain that the remote change succeeded but local recovery still needs to finish. | -| `LOCAL_KEYRING_PENDING` | Yes. Confirm the remote new-password state before recovering the Keyring encryption key or synchronizing a local key. | Enter the new password. Allow recovery to determine cryptographically whether the local Keyring is old or new; do not ask the user to guess which state occurred. | Keep wallet access blocked until the local Keyring is reconciled and its current encryption key is synchronized. Show progress and retryable errors without clearing the recovery state. | -| `KEY_SYNC_PENDING` | Yes. The remote password is expected to be new, but the remote copy of the Keyring encryption key may be old, new, missing, or unknown. | Enter the new password, unlock the local Keyring, and allow the current Keyring encryption key to be exported and synchronized. Do not start another password change. | Show that the wallet password has changed but backup synchronization is incomplete. Do not expose the wallet as fully recovered until synchronization is verified. | -| `COMPLETE` | No additional recovery check is required. A defensive check may still run. | Enter the new password and unlock normally. | Show the normal wallet UI. | -| `UNKNOWN` | Yes, whenever a server status check or cryptographic verification may resolve the state. If it cannot, remain `unknown`. | unknown | Keep the wallet locked and show a recovery-blocked state. Do not silently retry a non-idempotent operation or claim that either password is authoritative. | +| Lifecycle state | Sync Server state check required before unlock? | User behaviors | UI requirements | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| _No phase (`undefined`)_ | No. A server check may run as part of normal Seedless behavior, but it is not a recovery prerequisite. | Enter the current wallet password and continue normally. The user may start a new password change. | Show the normal locked or unlocked wallet UI. | +| `SEEDLESS_CHANGE_PENDING` | Yes. The remote request may not have started, may have failed before mutation, or may have committed with a lost response. | Do not assume which password is valid. If the server proves that the change did not commit, enter the old password. If it proves commitment, enter the new password. If the result remains ambiguous, `unknown`. Do not start another password change. | Show a password-change recovery screen. Do not report an entered password as an ordinary unlock failure while recovery is pending. Explain that the previous password change must be resolved first. | +| `SEEDLESS_COMMITTED` | Yes. Confirm the remote Seedless password and required backup/key-share changes. | Enter the new Seedless password. The user should not need the old Keyring password when the stored Keyring encryption key is recoverable from Seedless. | Keep wallet access behind a recovery screen. Explain that the remote change succeeded but local recovery still needs to finish. | +| `LOCAL_KEYRING_PENDING` | Yes. Confirm the remote new-password state before recovering the Keyring encryption key or synchronizing a local key. | Enter the new password. Allow recovery to determine cryptographically whether the local Keyring is old or new; do not ask the user to guess which state occurred. | Keep wallet access blocked until the local Keyring is reconciled and its current encryption key is synchronized. Show progress and retryable errors without clearing the recovery state. | +| `KEY_SYNC_PENDING` | Yes. The remote password is expected to be new, but the remote copy of the Keyring encryption key may be old, new, missing, or unknown. | Enter the new password, unlock the local Keyring, and allow the current Keyring encryption key to be exported and synchronized. Do not start another password change. | Show that the wallet password has changed but backup synchronization is incomplete. Do not expose the wallet as fully recovered until synchronization is verified. | +| `UNKNOWN` | Yes, whenever a server status check or cryptographic verification may resolve the state. If it cannot, remain `unknown`. | unknown | Keep the wallet locked and show a recovery-blocked state. Do not silently retry a non-idempotent operation or claim that either password is authoritative. | ## Server and local state matrix -| Lifecycle state | Server State | Local State | Recovered server state | Recovered local state | -| --- | --- | --- | --- | --- | -| `IDLE` | Stable and synchronized. The password is the current password; no change is pending. | Local Keyring, local Seedless state, and the persisted Keyring encryption key are stable and synchronized. | No change. The server remains in its current stable state. | No change. The local state remains in its current stable state. | -| `SEEDLESS_CHANGE_PENDING` | `unknown` until authoritative server status resolves whether the remote password change and backup updates are old, new, or partial. | Normally old/old, but local state may already have changed if lifecycle persistence was delayed or lost. Verify the local Keyring and local Seedless state independently. | Definitively old: return to `IDLE` after durable cleanup. Definitively new: transition to `SEEDLESS_COMMITTED` or `LOCAL_KEYRING_PENDING`. Ambiguous: `unknown`. | Do not mutate until the server result is resolved. After remote commitment, cryptographically classify the local Keyring as old or new and follow the matching branch. | -| `SEEDLESS_COMMITTED` | New Seedless password and new remote backup/key-share state, confirmed through server verification. If this cannot be established, `unknown`. | Local Keyring may be old or new. Local Seedless state and the stored Keyring encryption key may be old, new, or not durably persisted. | Remains new and committed. | Transition to `LOCAL_KEYRING_PENDING`; cryptographically determine whether to recover the old local Keyring or synchronize the already-new local Keyring. | -| `LOCAL_KEYRING_PENDING` | Remote Seedless is new and committed. | Local Keyring state is unresolved: old with a recoverable stored encryption key, new with a locally exportable current key, or `unknown`. | Remains new and committed. | **Old Keyring:** recover the stored key with the new Seedless password, call `submitEncryptionKey`, re-encrypt locally, export the current key, and synchronize it. **New Keyring:** unlock with the new password, export the current key, and synchronize it. In both cases, verify and durably persist before `COMPLETE`. | -| `KEY_SYNC_PENDING` | New Seedless password and remote backup/key-share state. The synchronized Keyring encryption key is not confirmed. | Local Keyring uses the new password. The Seedless copy of its Keyring encryption key is stale, missing, or not durably confirmed. | New password with the current Keyring encryption key synchronized and verified. | Local Keyring, local Seedless state, synchronized key, and lifecycle marker are durably persisted. Only then transition to `COMPLETE`. | -| `COMPLETE` | New Seedless password, new remote backup/key-share state, and the current Keyring encryption key synchronized. | Local Keyring and local Seedless state use the new password. The current Keyring encryption key is durably persisted locally and in Seedless. | No change. The server remains new and synchronized. | No change. The local state remains new and synchronized. | -| `UNKNOWN` | `unknown`. The server may have accepted some, all, or none of the remote password-change or key-synchronization operations. | `unknown`. Local Keyring, local Seedless state, or durable lifecycle state may reflect different points in the operation. | unknown | unknown | +| Lifecycle state | Server State | Local State | Recovered server state | Recovered local state | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| _No phase (`undefined`)_ | Stable and synchronized. The password is the current password; no change is pending. | Local Keyring, local Seedless state, and the persisted Keyring encryption key are stable and synchronized. | No change. The server remains in its current stable state. | No change. The local state remains in its current stable state. | +| `SEEDLESS_CHANGE_PENDING` | `unknown` until authoritative server status resolves whether the remote password change and backup updates are old, new, or partial. | Normally old/old, but local state may already have changed if lifecycle persistence was delayed or lost. Verify the local Keyring and local Seedless state independently. | Definitively old: clear the phase after durable cleanup. Definitively new: transition to `SEEDLESS_COMMITTED` or `LOCAL_KEYRING_PENDING`. Ambiguous: `unknown`. | Do not mutate until the server result is resolved. After remote commitment, cryptographically classify the local Keyring as old or new and follow the matching branch. | +| `SEEDLESS_COMMITTED` | New Seedless password and new remote backup/key-share state, confirmed through server verification. If this cannot be established, `unknown`. | Local Keyring may be old or new. Local Seedless state and the stored Keyring encryption key may be old, new, or not durably persisted. | Remains new and committed. | Transition to `LOCAL_KEYRING_PENDING`; cryptographically determine whether to recover the old local Keyring or synchronize the already-new local Keyring. | +| `LOCAL_KEYRING_PENDING` | Remote Seedless is new and committed. | Local Keyring state is unresolved: old with a recoverable stored encryption key, new with a locally exportable current key, or `unknown`. | Remains new and committed. | **Old Keyring:** recover the stored key with the new Seedless password, call `submitEncryptionKey`, re-encrypt locally, export the current key, and synchronize it. **New Keyring:** unlock with the new password, export the current key, and synchronize it. In both cases, verify and durably persist before clearing the lifecycle. | +| `KEY_SYNC_PENDING` | New Seedless password and remote backup/key-share state. The synchronized Keyring encryption key is not confirmed. | Local Keyring uses the new password. The Seedless copy of its Keyring encryption key is stale, missing, or not durably confirmed. | New password with the current Keyring encryption key synchronized and verified. | Local Keyring, local Seedless state, synchronized key, and lifecycle marker are durably persisted. Only then clear the lifecycle. | +| `UNKNOWN` | `unknown`. The server may have accepted some, all, or none of the remote password-change or key-synchronization operations. | `unknown`. Local Keyring, local Seedless state, or durable lifecycle state may reflect different points in the operation. | unknown | unknown | ## Failure and recovery rules @@ -235,7 +233,7 @@ Remote Seedless, local Seedless, and local Keyring remain old and synchronized. **Recovery plan** -Lock the wallet if required, ask the user to unlock with the old password, and durably clear the pending lifecycle state. A later retry starts from `IDLE`. +Lock the wallet if required, ask the user to unlock with the old password, and durably clear the pending lifecycle state. A later retry starts from no phase set. ### Remote error after a possible mutation @@ -283,7 +281,7 @@ Remote Seedless is new. The remote synchronized Keyring encryption key is old, n **Recovery plan** -Keep `KEY_SYNC_PENDING`. Unlock with the new password, export the current Keyring encryption key, retry using the same transaction identity, and verify the remote result. Do not mark `COMPLETE` until synchronization and local persistence are durable. If the remote result cannot be verified, unknown. +Keep `KEY_SYNC_PENDING`. Unlock with the new password, export the current Keyring encryption key, retry using the same transaction identity, and verify the remote result. Do not clear the lifecycle until synchronization and local persistence are durable. If the remote result cannot be verified, unknown. ### Lifecycle persistence failure @@ -355,7 +353,7 @@ Fault-injection tests must terminate or fail the operation at every boundary: - Before, during, and after local Keyring password change. - Before, during, and after Keyring encryption-key synchronization. - Before and after each lifecycle persistence write. -- Immediately before writing `COMPLETE`. +- Immediately before clearing the lifecycle. After restart, each test must verify that: @@ -364,7 +362,7 @@ After restart, each test must verify that: - Seedless recovers the current Keyring encryption key. - Retrying recovery produces the same final state. - A lost response does not cause a second non-idempotent password change. -- `COMPLETE` is never durable before key synchronization and local persistence. +- The lifecycle is never cleared before key synchronization and local persistence. - An unresolved server result remains `unknown`. ## Open questions diff --git a/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md b/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md index 385732fd2e1..42114651ad6 100644 --- a/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md +++ b/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md @@ -1,7 +1,7 @@ # Password-change recovery flow - Related ADR: [0001](./0001-seedless-password-change-recovery.md) -- Related Option B plan: [0004](./0004-controller-owned-password-change-recovery-plan.md) +- Related Option B plan: [0003](./0003-controller-owned-password-change-recovery-plan.md) This is the operational and technical guide for the Seedless password-change recovery flow: what the controller owns, what the client owns, the public API, the recovery flow, the client integration guide, and the technical invariants. @@ -9,37 +9,34 @@ This is the operational and technical guide for the Seedless password-change rec - **Server-first.** The remote Seedless password changes first. Recovery then brings local state forward to the new password. There is no rollback. - **No retries, no concurrency.** A password change is never re-run as a fresh `changePassword` / `changeEncKey` call while the previous outcome is unresolved. The controller mutex serializes controller operations; the client adds a coordinator lock that also covers the `KeyringController` step. -- **Lifecycle is a signal, not proof.** `passwordChangePhase` only tells the client that recovery *may* be needed. Recovery always re-verifies actual remote and local state before acting. -- **Lock before error.** Any password-change or recovery failure locks the wallet *before* an error modal or intermediary screen is shown. +- **Lifecycle is a signal, not proof.** `passwordChangePhase` only tells the client that recovery _may_ be needed. Recovery always re-verifies actual remote and local state before acting. +- **Lock before error.** Any password-change or recovery failure locks the wallet _before_ an error modal or intermediary screen is shown. - **`UNKNOWN` is honest.** If remote or local state cannot be established, the wallet stays locked and the phase is preserved. Never infer a result from a rejected Promise. ## The lifecycle phase -Persisted on `SeedlessOnboardingControllerState.passwordChangePhase` (`persist: true`). Missing / `undefined` means `IDLE`. The field holds no secrets. +Persisted on `SeedlessOnboardingControllerState.passwordChangePhase` (`persist: true`). Missing / `undefined` means no change is in progress. The field holds no secrets. -| Phase | Meaning | -| --- | --- | -| `IDLE` | No change in progress. | -| `SEEDLESS_CHANGE_PENDING` | A change started; the remote outcome is not yet confirmed. | -| `SEEDLESS_COMMITTED` | The remote Seedless password change is confirmed committed. | -| `LOCAL_KEYRING_PENDING` | The local Seedless vault has been rewritten with the new password. | -| `KEY_SYNC_PENDING` | The Keyring encryption key has been stored; awaiting final verification/sync. | -| `COMPLETE` | Fully complete and verified. | -| `UNKNOWN` | The result of one or more steps could not be established. | +| Phase | Meaning | +| ------------------------- | ----------------------------------------------------------------------------- | +| `SEEDLESS_CHANGE_PENDING` | A change started; the remote outcome is not yet confirmed. | +| `SEEDLESS_COMMITTED` | The remote Seedless password change is confirmed committed. | +| `LOCAL_KEYRING_PENDING` | The local Seedless vault has been rewritten with the new password. | +| `KEY_SYNC_PENDING` | The Keyring encryption key has been stored; awaiting final verification/sync. | +| `UNKNOWN` | The result of one or more steps could not be established. | ## The recovery status Returned by the two controller methods. The client routes UI from this status. -| Status | Meaning | Client action | -| --- | --- | --- | -| `no-change` | Remote did not commit; phase cleared to `IDLE`. | Unlock with the old password normally. | -| `password-outdated` | Phase is `IDLE` but the remote password changed (another device changed it). | Prompt for the new password, then `recoverPasswordChange`. | -| `enter-new-password` | Remote committed (or the local Seedless side still needs the new password). | Prompt for the new password, then `recoverPasswordChange`. | -| `reconcile-keyring` | Seedless side reconciled (phase is `LOCAL_KEYRING_PENDING`). | Cryptographically classify the local Keyring, then run the old/new branch. | -| `sync-key` | Phase is `KEY_SYNC_PENDING`. | Export, store, and sync the current Keyring encryption key, then `completePasswordChange`. | -| `complete` | Phase is `COMPLETE`. | `clearPasswordChangePhase`, then unlock normally. | -| `unknown` | Remote or local state could not be established. | Keep the wallet locked. Preserve the phase. Offer reset wallet only as an explicit last resort. | +| Status | Meaning | Client action | +| -------------------- | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `in-sync` | Local and remote passwords are synchronized; no recovery action is needed. | Unlock normally. | +| `password-outdated` | No lifecycle is in flight but the remote password changed (another device). | Prompt for the new password, then `recoverPasswordChange`. | +| `enter-new-password` | Remote committed (or the local Seedless side still needs the new password). | Prompt for the new password, then `recoverPasswordChange`. | +| `reconcile-keyring` | Seedless side reconciled (phase is `LOCAL_KEYRING_PENDING`). | Cryptographically classify the local Keyring, then run the old/new branch. | +| `sync-key` | Phase is `KEY_SYNC_PENDING`. | Export, store, and sync the current Keyring encryption key, then `clearPasswordChangePhase`. | +| `unknown` | Remote or local state could not be established. | Keep the wallet locked. Preserve the phase. Offer reset wallet only as an explicit last resort. | ## Controller public API @@ -53,10 +50,10 @@ SeedlessOnboardingController:resolvePasswordSyncState({ }): Promise ``` -Single unlock-time call (call on page render *and* on password submit). Merges the legacy `checkIsPasswordOutdated` read with password-change recovery routing. +Single unlock-time call (call on page render _and_ on password submit). Merges the legacy `checkIsPasswordOutdated` read with password-change recovery routing. -- `IDLE`: authoritative outdated check; `skipCache` honored (cache on render, force-remote on submit). Returns `no-change` or `password-outdated`. -- `SEEDLESS_CHANGE_PENDING`: forces a remote check (ignores `skipCache`). Clears to `IDLE` (`no-change`) or advances to `SEEDLESS_COMMITTED` (`enter-new-password`). +- No phase (`undefined`): authoritative outdated check; `skipCache` honored (cache on render, force-remote on submit). Returns `in-sync` or `password-outdated`. +- `SEEDLESS_CHANGE_PENDING`: forces a remote check (ignores `skipCache`). Clears the phase (`in-sync`) or advances to `SEEDLESS_COMMITTED` (`enter-new-password`). - Other phases: returns the matching status without a remote call. - On any failure: returns `unknown` and preserves the phase. @@ -71,7 +68,7 @@ SeedlessOnboardingController:recoverPasswordChange({ Reconciles the Seedless side with the supplied password. - `SEEDLESS_COMMITTED` / `LOCAL_KEYRING_PENDING`: re-runs `submitGlobalPassword` → `syncLatestGlobalPassword` (idempotent), advances to `LOCAL_KEYRING_PENDING`, returns `reconcile-keyring`. -- `IDLE`: re-checks the remote password and, if outdated, runs the same password-sync flow without advancing any phase (another-device sync); returns `no-change`. If not outdated, a no-op. +- No phase (`undefined`): re-checks the remote password and, if outdated, runs the same password-sync flow, advances to `LOCAL_KEYRING_PENDING`, and returns `reconcile-keyring` so the client reconciles the local Keyring after a password change on another device. If not outdated, a no-op that returns `in-sync`. - `SEEDLESS_CHANGE_PENDING`: returns `unknown` (resolve remote state via `resolvePasswordSyncState` first). - On any failure: returns `unknown` and preserves the phase. @@ -79,15 +76,13 @@ Reconciles the Seedless side with the supplied password. ```ts SeedlessOnboardingController:markPasswordChangeKeySyncPending(): Promise -SeedlessOnboardingController:completePasswordChange(): Promise SeedlessOnboardingController:clearPasswordChangePhase(): Promise ``` All idempotent, serialized under the controller lock, with no-op guards. - `markPasswordChangeKeySyncPending` — advance to `KEY_SYNC_PENDING` after the Keyring encryption key is stored. -- `completePasswordChange` — advance to `COMPLETE` only after sync verification and durable local persistence. -- `clearPasswordChangePhase` — clear to `IDLE` after `COMPLETE` (or after a definitive remote non-commit). This is the only way back to `IDLE`. +- `clearPasswordChangePhase` — clear the phase once Keyring encryption-key synchronization is verified and all required local state is durably persisted (or after a definitive remote non-commit). This is the only way back to no change in progress. ## Recovery flow @@ -98,14 +93,14 @@ unlock render / submit resolvePasswordSyncState({ skipCache }) │ ▼ - ┌────────────────────┬───────────────────┬──────────────────┬─────────────────┬───────────┬──────────┬─────────┐ - │ no-change │ password-outdated │ enter-new-password │ reconcile-keyring │ sync-key │ complete │ unknown │ - │ unlock w/ old pwd │ prompt new pwd │ prompt new pwd │ classify Keyring │ finish sync│ clear │ locked │ - └───────────────────┴───────────────────┴───────────────────┴─────────────────┴───────────┴──────────┴─────────┘ + ┌────────────────────┬───────────────────┬──────────────────┬─────────────────┬───────────┬─────────┐ + │ in-sync │ password-outdated │ enter-new-password │ reconcile-keyring │ sync-key │ unknown │ + │ unlock normally │ prompt new pwd │ prompt new pwd │ classify Keyring │ finish sync│ locked │ + └───────────────────┴───────────────────┴───────────────────┴─────────────────┴───────────┴─────────┘ │ │ │ │ │ │ ▼ ▼ ▼ ▼ - │ recoverPasswordChange recoverPasswordChange old/new branch completePasswordChange - │ ({ globalPassword }) ({ globalPassword }) (see below) → clearPasswordChangePhase + │ recoverPasswordChange recoverPasswordChange old/new branch clearPasswordChangePhase + │ ({ globalPassword }) ({ globalPassword }) (see below) (after sync verified) ▼ normal unlock │ │ ▼ ▼ @@ -125,7 +120,7 @@ unlock render / submit 5. `storeKeyringEncryptionKey()` — store the current key locally (encrypted with the new Seedless password). 6. `markPasswordChangeKeySyncPending()` — advance to `KEY_SYNC_PENDING`. 7. Sync the Keyring encryption key to the remote Seedless backup. -8. `completePasswordChange()` → `clearPasswordChangePhase()` — finish. +8. `clearPasswordChangePhase()` — finish (only after sync is verified and local state is durably persisted). ### New-Keyring branch (local Keyring already on the new password) @@ -134,7 +129,7 @@ unlock render / submit 3. `storeKeyringEncryptionKey()` — store the current key locally. 4. `markPasswordChangeKeySyncPending()` — advance to `KEY_SYNC_PENDING`. 5. Sync the Keyring encryption key to the remote Seedless backup. -6. `completePasswordChange()` → `clearPasswordChangePhase()` — finish. +6. `clearPasswordChangePhase()` — finish. ### `KEY_SYNC_PENDING` (resuming after a restart) @@ -142,7 +137,7 @@ unlock render / submit 2. `KeyringController:exportEncryptionKey` — export the current Keyring encryption key. 3. `storeKeyringEncryptionKey()` — re-store/sync the current key. 4. Sync to the remote Seedless backup and verify. -5. `completePasswordChange()` → `clearPasswordChangePhase()`. +5. `clearPasswordChangePhase()`. ## Client integration guide @@ -150,21 +145,22 @@ unlock render / submit 2. **Persist `SEEDLESS_CHANGE_PENDING` before the first remote mutation.** The controller writes this itself inside `changePassword`; the client must ensure the controller state slice is persisted (debounced, same as other persisted controller state) before any irreversible step. -3. **Unlock routing.** On unlock (page render *and* password submit), read `passwordChangePhase` from controller state, then call `resolvePasswordSyncState({ skipCache })`. Route UI from the returned status using the table above. Do not classify a password as invalid until recovery has run. +3. **Unlock routing.** On unlock (page render _and_ password submit), read `passwordChangePhase` from controller state, then call `resolvePasswordSyncState({ skipCache })`. Route UI from the returned status using the table above. Do not classify a password as invalid until recovery has run. 4. **Two-step UX.** + - Step 1 (password-less): `resolvePasswordSyncState` decides whether the old or new password is needed. - Step 2 (password-consuming): only after step 1 returns `enter-new-password` / `password-outdated`, prompt for the new password and call `recoverPasswordChange({ globalPassword })`. 5. **Keyring classification.** On `reconcile-keyring`, call `KeyringController:verifyPassword(newPassword)` to choose the old-Keyring or new-Keyring branch. Do **not** infer the local Keyring state from the lifecycle phase. -6. **Lock before error.** Any failure from `changePassword`, `resolvePasswordSyncState`, `recoverPasswordChange`, or any Keyring step must lock the wallet *before* surfacing an error modal, retry screen, or intermediary UI. If the lock itself fails, keep the wallet in a recovery-blocked UI and never expose wallet access. +6. **Lock before error.** Any failure from `changePassword`, `resolvePasswordSyncState`, `recoverPasswordChange`, or any Keyring step must lock the wallet _before_ surfacing an error modal, retry screen, or intermediary UI. If the lock itself fails, keep the wallet in a recovery-blocked UI and never expose wallet access. -7. **`COMPLETE` boundary.** Call `completePasswordChange()` only after the synchronized Keyring encryption key and all required local state are durably persisted. Then `clearPasswordChangePhase()` to return to `IDLE`. +7. **Completion boundary.** Call `clearPasswordChangePhase()` only after the synchronized Keyring encryption key and all required local state are durably persisted. This clears the lifecycle so the next unlock is normal. 8. **`UNKNOWN` is terminal for this attempt.** If `resolvePasswordSyncState` or `recoverPasswordChange` returns `unknown`, keep the wallet locked, preserve the phase, and stop. Do not retry `changePassword` / `changeEncKey`. Offer reset wallet only as an explicit last resort for a confirmed unrecoverable state. -9. **Cache.** `resolvePasswordSyncState` honors `skipCache` for the `IDLE` outdated check only. Use `skipCache: false` (default) on render and `skipCache: true` on submit. `SEEDLESS_CHANGE_PENDING` always forces a remote check. +9. **Cache.** `resolvePasswordSyncState` honors `skipCache` for the no-phase outdated check only. Use `skipCache: false` (default) on render and `skipCache: true` on submit. `SEEDLESS_CHANGE_PENDING` always forces a remote check. ## Technical details @@ -186,14 +182,13 @@ Controller `this.update(...)` calls happen: - after authoritative remote commitment (`SEEDLESS_COMMITTED`); - after the local Seedless vault rewrite (`LOCAL_KEYRING_PENDING`); - after local Keyring-key storage when that update is coupled to a lifecycle write; -- after `COMPLETE`; -- after an explicit clear to `IDLE`. +- after an explicit clear (no change in progress). These publish `SeedlessOnboardingController:stateChange`; they are not awaited durability boundaries. ### Phase preservation on failure -On a failed step the controller **preserves the last known phase**; it does not overwrite it with `UNKNOWN` and does not reset to `IDLE` on every error. The last written phase is the recovery signal: e.g. if `#changeEncryptionKey` rejects, the phase stays `SEEDLESS_CHANGE_PENDING` and the client performs an authoritative password-outdated check to choose the recovery branch. If the failure happened before the first lifecycle write, the lifecycle stays `IDLE` (nothing to recover). `UNKNOWN` is a recovery-time determination made by the client when an authoritative server check or local cryptographic verification cannot establish the state — not a phase written by `changePassword`'s catch block. +On a failed step the controller **preserves the last known phase**; it does not overwrite it with `UNKNOWN` and does not clear the phase on every error. The last written phase is the recovery signal: e.g. if `#changeEncryptionKey` rejects, the phase stays `SEEDLESS_CHANGE_PENDING` and the client performs an authoritative password-outdated check to choose the recovery branch. If the failure happened before the first lifecycle write, the phase stays unset (nothing to recover). `UNKNOWN` is a recovery-time determination made by the client when an authoritative server check or local cryptographic verification cannot establish the state — not a phase written by `changePassword`'s catch block. ### No retries, no concurrency @@ -201,11 +196,11 @@ A password-change operation must never be retried as a fresh `changePassword` / ### `changePassword` behavior -`changePassword` is now lifecycle-aware: it writes `SEEDLESS_CHANGE_PENDING` before the first remote mutation, `SEEDLESS_COMMITTED` after authoritative remote commitment, and `LOCAL_KEYRING_PENDING` after the local Seedless vault rewrite. It rejects a second concurrent change with `PasswordChangeInProgress`. It reuses the existing `verifyVaultPassword`, `#assertPasswordInSync({ skipCache: true })`, `#changeEncryptionKey` (via `#executeWithTokenRefresh`), `#createNewVaultWithAuthData`, and `storeKeyringEncryptionKey`. A rejected `#changeEncryptionKey` Promise is not proof that the server did not mutate; only a definitive server result may return the lifecycle to `IDLE`. +`changePassword` is now lifecycle-aware: it writes `SEEDLESS_CHANGE_PENDING` before the first remote mutation, `SEEDLESS_COMMITTED` after authoritative remote commitment, and `LOCAL_KEYRING_PENDING` after the local Seedless vault rewrite. It rejects a second concurrent change with `PasswordChangeInProgress`. It reuses the existing `verifyVaultPassword`, `#assertPasswordInSync({ skipCache: true })`, `#changeEncryptionKey` (via `#executeWithTokenRefresh`), `#createNewVaultWithAuthData`, and `storeKeyringEncryptionKey`. A rejected `#changeEncryptionKey` Promise is not proof that the server did not mutate; only a definitive server result may clear the lifecycle. ### `storeKeyringEncryptionKey` behavior -`storeKeyringEncryptionKey` encrypts the current Keyring encryption key under the current Seedless password encryption key and writes `encryptedKeyringEncryptionKey` on controller state. It never marks `COMPLETE` by itself — completion also requires client confirmation of the local Keyring state, remote synchronization, and durable persistence. `loadKeyringEncryptionKey` is read-only with respect to lifecycle state; loading a key does not complete recovery. +`storeKeyringEncryptionKey` encrypts the current Keyring encryption key under the current Seedless password encryption key and writes `encryptedKeyringEncryptionKey` on controller state. It never clears the lifecycle by itself — completion also requires client confirmation of the local Keyring state, remote synchronization, and durable persistence. `loadKeyringEncryptionKey` is read-only with respect to lifecycle state; loading a key does not complete recovery. ### Recovery mechanism @@ -220,11 +215,11 @@ Both run through `#executeWithTokenRefresh`, which preserves the existing token- After a lost, timed-out, or incomplete `changeEncKey` response, recovery classifies remote state via `toprfClient.fetchAuthPubKey` (with `skipCache: true`), comparing the remote `authPubKey` with the last durable local `authPubKey`: -| Observation | Classification | Lifecycle effect | -| --- | --- | --- | -| Fetch succeeds and remote `authPubKey` equals the pre-change local `authPubKey`. | **Old** | Safe to treat as uncommitted. Clear to `IDLE` only after this check. | -| Fetch succeeds and remote `authPubKey` equals the expected post-change key, or the new password recovers remote material. | **New** | Treat as committed. Advance to `SEEDLESS_COMMITTED` or later recovery. | -| Fetch fails, comparison is impossible, or local `authPubKey` is missing/stale. | **Unknown** | Preserve the phase. Keep the wallet locked. Do not retry `changeEncKey`. | +| Observation | Classification | Lifecycle effect | +| ------------------------------------------------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------ | +| Fetch succeeds and remote `authPubKey` equals the pre-change local `authPubKey`. | **Old** | Safe to treat as uncommitted. Clear the phase only after this check. | +| Fetch succeeds and remote `authPubKey` equals the expected post-change key, or the new password recovers remote material. | **New** | Treat as committed. Advance to `SEEDLESS_COMMITTED` or later recovery. | +| Fetch fails, comparison is impossible, or local `authPubKey` is missing/stale. | **Unknown** | Preserve the phase. Keep the wallet locked. Do not retry `changeEncKey`. | There is no API that reports partial backup or key-share state; such cases are classified as **Unknown**. The TOPRF server does not accept a transaction ID / idempotency key today; that remains a "good to have" for a future release. Until then, ambiguous remote results stay `UNKNOWN`. @@ -243,11 +238,11 @@ All controller-package work is complete: - Lifecycle-aware `changePassword` with concurrency guard and phase preservation on error. - Lifecycle-aware `storeKeyringEncryptionKey`. - `resolvePasswordSyncState` + `recoverPasswordChange` (Option A: controller owns the Seedless side). -- `markPasswordChangeKeySyncPending` / `completePasswordChange` / `clearPasswordChangePhase`. +- `markPasswordChangeKeySyncPending` / `clearPasswordChangePhase`. - Messenger action types, package exports, and unit tests (290 tests, 100% statement / 99.22% branch coverage). Remaining work is **not** in this package: - **Client integration** — coordinator, unlock routing, locking, UI, and E2E coverage (see the [Client integration guide](#client-integration-guide) above). - **Open decisions** — rate-limit behavior for recovery; whether clients need an explicit migration version bump for persisted state created before `passwordChangePhase` existed. -- **Option B** — future migration where the controller also owns the `KeyringController` side (see [0004](./0004-controller-owned-password-change-recovery-plan.md)). +- **Option B** — future migration where the controller also owns the `KeyringController` side (see [0003](./0003-controller-owned-password-change-recovery-plan.md)). diff --git a/packages/seedless-onboarding-controller/docs/0003-controller-owned-password-change-recovery-plan.md b/packages/seedless-onboarding-controller/docs/0003-controller-owned-password-change-recovery-plan.md index 696791ff4f9..a26de01191d 100644 --- a/packages/seedless-onboarding-controller/docs/0003-controller-owned-password-change-recovery-plan.md +++ b/packages/seedless-onboarding-controller/docs/0003-controller-owned-password-change-recovery-plan.md @@ -1,4 +1,4 @@ -# Plan 0004: Migrate password-change recovery into the controller (Option B) +# Plan 0003: Migrate password-change recovery into the controller (Option B) - Status: Planned (post-testing migration) - Related: [ADR 0001](./0001-seedless-password-change-recovery.md), [Recovery flow 0002](./0002-password-change-recovery-flow.md) @@ -6,7 +6,7 @@ ## Context -The first implementation (see [0002](./0002-password-change-recovery-flow.md)) ships **Option A**: the controller owns all *Seedless-side* recovery sequencing, but the *Keyring-side* steps (`verifyPassword`, `submitEncryptionKey`, `changePassword`, `exportEncryptionKey`) stay in the client because `SeedlessOnboardingController` has no `KeyringController` dependency (`AllowedActions = never`). +The first implementation (see [0002](./0002-password-change-recovery-flow.md)) ships **Option A**: the controller owns all _Seedless-side_ recovery sequencing, but the _Keyring-side_ steps (`verifyPassword`, `submitEncryptionKey`, `changePassword`, `exportEncryptionKey`) stay in the client because `SeedlessOnboardingController` has no `KeyringController` dependency (`AllowedActions = never`). This document plans the migration to **Option B**: the controller owns the entire recovery, including the Keyring side. Motivation: the recovery transaction spans two controllers, and we cannot rely on every client sequencing the Keyring-side steps correctly. Centralizing the full transaction removes a class of client-integration bugs. @@ -14,12 +14,12 @@ This migration is deferred until Option A is shipped and tested, so the recovery ## Goal -A single controller method performs the entire recovery for any non-IDLE phase and returns only a final status. The client no longer sequences Seedless or Keyring operations; it only supplies the password and reacts to the status. +A single controller method performs the entire recovery for any set phase and returns only a final status. The client no longer sequences Seedless or Keyring operations; it only supplies the password and reacts to the status. ## Current state (Option A) - `recoverPasswordChange({ globalPassword })` does the Seedless-side steps (`#checkIsPasswordOutdated({ skipCache: true })`, `submitGlobalPassword`, `syncLatestGlobalPassword`, lifecycle advances) and returns a result describing the remaining Keyring-side step. Remote-state resolution for `SeedlessChangePending` is owned by `resolvePasswordSyncState()` (password-less), which the client calls first. -- The client classifies the local Keyring via `KeyringController:verifyPassword`, then runs the old-Keyring or new-Keyring branch itself, calling `KeyringController:submitEncryptionKey` / `changePassword` / `exportEncryptionKey` and the controller's `loadKeyringEncryptionKey` / `storeKeyringEncryptionKey` / `markPasswordChangeKeySyncPending` / `completePasswordChange`. +- The client classifies the local Keyring via `KeyringController:verifyPassword`, then runs the old-Keyring or new-Keyring branch itself, calling `KeyringController:submitEncryptionKey` / `changePassword` / `exportEncryptionKey` and the controller's `loadKeyringEncryptionKey` / `storeKeyringEncryptionKey` / `markPasswordChangeKeySyncPending` / `clearPasswordChangePhase`. - `AllowedActions = never`; the controller does not call `KeyringController`. ## Target state (Option B) @@ -31,8 +31,8 @@ A single controller method performs the entire recovery for any non-IDLE phase a 3. Classify the local Keyring via `KeyringController:verifyPassword(newPassword)`. 4. Old-Keyring branch: `loadKeyringEncryptionKey` → `KeyringController:submitEncryptionKey` → `KeyringController:changePassword` → `KeyringController:exportEncryptionKey` → `storeKeyringEncryptionKey` → `markPasswordChangeKeySyncPending`. 5. New-Keyring branch: `KeyringController:exportEncryptionKey` → `storeKeyringEncryptionKey` → `markPasswordChangeKeySyncPending`. - 6. `KeySyncPending`: `KeyringController:exportEncryptionKey` → `storeKeyringEncryptionKey` → remote key sync → `completePasswordChange` → `clearPasswordChangePhase`. - 7. Return a final status only (`PasswordChangeRecoveryStatus.NoChange | Complete | Unknown`). + 6. `KeySyncPending`: `KeyringController:exportEncryptionKey` → `storeKeyringEncryptionKey` → remote key sync → `clearPasswordChangePhase`. + 7. Return a final status only (`PasswordChangeRecoveryStatus.InSync | Unknown`). - The client supplies the password, calls one method, and routes UI from the status. It performs no cross-controller sequencing. ## Changes diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts index fb54809621c..d445eb8b94a 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts @@ -260,11 +260,12 @@ export type SeedlessOnboardingControllerLoadKeyringEncryptionKeyAction = { }; /** - * Clear the password-change lifecycle to `IDLE`. + * Clear the password-change lifecycle. * - * This is an explicit operation used after a definitive remote failure - * (server did not commit) or after `COMPLETE`. The controller clears to - * `IDLE` so the next unlock is normal. + * Used after a definitive remote failure (server did not commit) or once + * Keyring encryption-key synchronization is verified and all required local + * writes have succeeded. The controller clears the phase so the next unlock + * is normal. * * @returns A promise that resolves once the lifecycle has been cleared. */ @@ -288,22 +289,6 @@ export type SeedlessOnboardingControllerMarkPasswordChangeKeySyncPendingAction = handler: SeedlessOnboardingController['markPasswordChangeKeySyncPending']; }; -/** - * Mark the password-change lifecycle as `COMPLETE`. - * - * Called by the client coordinator only after Keyring encryption-key - * synchronization is verified and all required local writes have succeeded. - * The controller only records the boundary; it does not infer completion - * from this call. Follow with `clearPasswordChangePhase` to return to - * `IDLE` once the durable `COMPLETE` state is no longer needed as a signal. - * - * @returns A promise that resolves once the phase has been persisted. - */ -export type SeedlessOnboardingControllerCompletePasswordChangeAction = { - type: `SeedlessOnboardingController:completePasswordChange`; - handler: SeedlessOnboardingController['completePasswordChange']; -}; - /** * Resolve the current password-sync state without consuming a password. * @@ -312,14 +297,14 @@ export type SeedlessOnboardingControllerCompletePasswordChangeAction = { * page render and on password submit) and routes UI from the returned status. * * Phase handling: - * - `IDLE`: run the authoritative outdated check. `skipCache` is honored, so - * the client can read from cache on render and force a remote call on - * submit. Returns `NoChange` (in sync) or `PasswordOutdated` (another device + * - No phase (`undefined`): run the authoritative outdated check. `skipCache` + * is honored, so the client can read from cache on render and force a remote + * call on submit. Returns `InSync` or `PasswordOutdated` (another device * changed the remote password). * - `SEEDLESS_CHANGE_PENDING`: the remote outcome is ambiguous, so `skipCache` - * is ignored and a remote check is forced. Clears to `IDLE` (remote did not + * is ignored and a remote check is forced. Clears the phase (remote did not * commit) or advances to `SEEDLESS_COMMITTED` (remote committed). Returns - * `NoChange` or `EnterNewPassword`. + * `InSync` or `EnterNewPassword`. * - Other phases: return the next recovery step without mutating state. * * This method does not consume a password; the client prompts for the @@ -347,16 +332,17 @@ export type SeedlessOnboardingControllerResolvePasswordSyncStateAction = { * the local Seedless vault was already rewritten. The controller is left * unlocked. * - * For `IDLE` it re-checks whether the remote password is outdated and, if so, - * runs the same password-sync flow without advancing any phase (there is no - * local password-change lifecycle in flight — e.g. another device changed - * the remote password). If the remote password is not outdated it is a no-op. + * For no phase (`undefined`) it re-checks whether the remote password is + * outdated. If it is, it runs the same password-sync flow, advances to + * `LOCAL_KEYRING_PENDING`, and returns `ReconcileKeyring` so the client can + * reconcile the local Keyring (e.g. after another device changed the remote + * password). If the remote password is not outdated it is a no-op. * * The client remains responsible for the Keyring side (classifying the local * Keyring via `KeyringController:verifyPassword` and running the old-Keyring * or new-Keyring branch), because this controller does not depend on * `KeyringController`. See - * [0004](./docs/0004-controller-owned-password-change-recovery-plan.md). + * [0003](./docs/0003-controller-owned-password-change-recovery-plan.md). * * @param params - The recovery parameters. * @param params.globalPassword - The new global password. @@ -481,7 +467,6 @@ export type SeedlessOnboardingControllerMethodActions = | SeedlessOnboardingControllerLoadKeyringEncryptionKeyAction | SeedlessOnboardingControllerClearPasswordChangePhaseAction | SeedlessOnboardingControllerMarkPasswordChangeKeySyncPendingAction - | SeedlessOnboardingControllerCompletePasswordChangeAction | SeedlessOnboardingControllerResolvePasswordSyncStateAction | SeedlessOnboardingControllerRecoverPasswordChangeAction | SeedlessOnboardingControllerRefreshAuthTokensAction diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 0d7f2b1a8b8..9b5fc72f979 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -83,9 +83,7 @@ import type { SeedlessOnboardingControllerMessenger, SeedlessOnboardingControllerOptions, } from './SeedlessOnboardingController.js'; -import type { - SeedlessOnboardingControllerState, -} from './types.js'; +import type { SeedlessOnboardingControllerState } from './types.js'; const authConnection = AuthConnection.Google; const socialLoginEmail = 'user-test@gmail.com'; @@ -1122,8 +1120,8 @@ describe('SeedlessOnboardingController', () => { }); }); - describe('resolvePasswordSyncState (IDLE phase: outdated check)', () => { - it('should return NoChange if password is not outdated (authPubKey matches)', async () => { + describe('resolvePasswordSyncState (no lifecycle: outdated check)', () => { + it('should return InSync if password is not outdated (authPubKey matches)', async () => { await withController( { state: getMockInitialControllerState({ @@ -1137,12 +1135,12 @@ describe('SeedlessOnboardingController', () => { const result = await baseMessenger.call( 'SeedlessOnboardingController:resolvePasswordSyncState', ); - expect(result).toBe(PasswordChangeRecoveryStatus.NoChange); + expect(result).toBe(PasswordChangeRecoveryStatus.InSync); // Call again to test cache const result2 = await baseMessenger.call( 'SeedlessOnboardingController:resolvePasswordSyncState', ); - expect(result2).toBe(PasswordChangeRecoveryStatus.NoChange); + expect(result2).toBe(PasswordChangeRecoveryStatus.InSync); // Should only call fetchAuthPubKey once due to cache expect(spy).toHaveBeenCalledTimes(1); }, @@ -1192,7 +1190,7 @@ describe('SeedlessOnboardingController', () => { skipCache: true, }, ); - expect(result).toBe(PasswordChangeRecoveryStatus.NoChange); + expect(result).toBe(PasswordChangeRecoveryStatus.InSync); // Call again with skipCache: true, should call fetchAuthPubKey again const result2 = await baseMessenger.call( 'SeedlessOnboardingController:resolvePasswordSyncState', @@ -1200,7 +1198,7 @@ describe('SeedlessOnboardingController', () => { skipCache: true, }, ); - expect(result2).toBe(PasswordChangeRecoveryStatus.NoChange); + expect(result2).toBe(PasswordChangeRecoveryStatus.InSync); expect(spy).toHaveBeenCalledTimes(2); }, ); @@ -1904,13 +1902,12 @@ describe('SeedlessOnboardingController', () => { vault: MOCK_VAULT, vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, - passwordChangePhase: - SeedlessPasswordChangePhase.SeedlessCommitted, + passwordChangePhase: SeedlessPasswordChangePhase.SeedlessCommitted, }), }, async ({ baseMessenger }) => { // Unlock first so #assertIsUnlocked() passes; the phase stays - // non-IDLE because submitPassword does not touch it. + // set because submitPassword does not touch it. await baseMessenger.call( 'SeedlessOnboardingController:submitPassword', MOCK_PASSWORD, @@ -4276,7 +4273,7 @@ describe('SeedlessOnboardingController', () => { MOCK_KEYRING_ID, ); - // A previous change left the lifecycle in a non-IDLE phase (e.g. a + // A previous change left the lifecycle set (e.g. a // crash after the remote commit). Recovery has not finished, so a // fresh change must not start. expect(controller.state.passwordChangePhase).toBe( @@ -4336,7 +4333,7 @@ describe('SeedlessOnboardingController', () => { // The outdated-password check rejects before the first lifecycle // write (SEEDLESS_CHANGE_PENDING), so there is nothing to recover - // and the lifecycle stays unset/IDLE. + // and the lifecycle stays unset. expect(controller.state.passwordChangePhase).toBeUndefined(); }, ); @@ -4671,14 +4668,14 @@ describe('SeedlessOnboardingController', () => { // The fetch failure rejects before the first lifecycle write // (SEEDLESS_CHANGE_PENDING), so there is nothing to recover and the - // lifecycle stays unset/IDLE. + // lifecycle stays unset. expect(controller.state.passwordChangePhase).toBeUndefined(); }, ); }); describe('clearPasswordChangePhase', () => { - it('clears an in-progress lifecycle to IDLE', async () => { + it('clears an in-progress lifecycle', async () => { await withController( { state: getMockInitialControllerState({ @@ -4694,7 +4691,7 @@ describe('SeedlessOnboardingController', () => { ); }); - it('is a no-op when already IDLE', async () => { + it('is a no-op when no change is in progress', async () => { await withController( { state: getMockInitialControllerState({ @@ -4735,8 +4732,7 @@ describe('SeedlessOnboardingController', () => { { state: getMockInitialControllerState({ withMockAuthenticatedUser: true, - passwordChangePhase: - SeedlessPasswordChangePhase.KeySyncPending, + passwordChangePhase: SeedlessPasswordChangePhase.KeySyncPending, }), }, async ({ controller }) => { @@ -4749,49 +4745,10 @@ describe('SeedlessOnboardingController', () => { ); }); }); - - describe('completePasswordChange', () => { - it('advances the lifecycle to COMPLETE', async () => { - await withController( - { - state: getMockInitialControllerState({ - withMockAuthenticatedUser: true, - passwordChangePhase: - SeedlessPasswordChangePhase.KeySyncPending, - }), - }, - async ({ controller }) => { - await controller.completePasswordChange(); - - expect(controller.state.passwordChangePhase).toBe( - SeedlessPasswordChangePhase.Complete, - ); - }, - ); - }); - - it('is a no-op when already COMPLETE', async () => { - await withController( - { - state: getMockInitialControllerState({ - withMockAuthenticatedUser: true, - passwordChangePhase: SeedlessPasswordChangePhase.Complete, - }), - }, - async ({ controller }) => { - await controller.completePasswordChange(); - - expect(controller.state.passwordChangePhase).toBe( - SeedlessPasswordChangePhase.Complete, - ); - }, - ); - }); - }); }); describe('resolvePasswordSyncState (recovery phases)', () => { - it('returns no-change when the phase is IDLE and the password is in sync', async () => { + it('returns in-sync when no phase is set and the password is in sync', async () => { await withController( { state: getMockInitialControllerState({ @@ -4802,7 +4759,7 @@ describe('SeedlessOnboardingController', () => { async ({ toprfClient, controller }) => { mockFetchAuthPubKey(toprfClient, base64ToBytes(MOCK_AUTH_PUB_KEY)); const result = await controller.resolvePasswordSyncState(); - expect(result).toBe(PasswordChangeRecoveryStatus.NoChange); + expect(result).toBe(PasswordChangeRecoveryStatus.InSync); expect(controller.state.passwordChangePhase).toBeUndefined(); }, ); @@ -4814,15 +4771,12 @@ describe('SeedlessOnboardingController', () => { state: getMockInitialControllerState({ withMockAuthenticatedUser: true, withMockAuthPubKey: true, - passwordChangePhase: - SeedlessPasswordChangePhase.SeedlessCommitted, + passwordChangePhase: SeedlessPasswordChangePhase.SeedlessCommitted, }), }, async ({ controller }) => { const result = await controller.resolvePasswordSyncState(); - expect(result).toBe( - PasswordChangeRecoveryStatus.EnterNewPassword, - ); + expect(result).toBe(PasswordChangeRecoveryStatus.EnterNewPassword); expect(controller.state.passwordChangePhase).toBe( SeedlessPasswordChangePhase.SeedlessCommitted, ); @@ -4842,9 +4796,7 @@ describe('SeedlessOnboardingController', () => { }, async ({ controller }) => { const result = await controller.resolvePasswordSyncState(); - expect(result).toBe( - PasswordChangeRecoveryStatus.ReconcileKeyring, - ); + expect(result).toBe(PasswordChangeRecoveryStatus.ReconcileKeyring); }, ); }); @@ -4865,22 +4817,6 @@ describe('SeedlessOnboardingController', () => { ); }); - it('returns complete when the phase is COMPLETE', async () => { - await withController( - { - state: getMockInitialControllerState({ - withMockAuthenticatedUser: true, - withMockAuthPubKey: true, - passwordChangePhase: SeedlessPasswordChangePhase.Complete, - }), - }, - async ({ controller }) => { - const result = await controller.resolvePasswordSyncState(); - expect(result).toBe(PasswordChangeRecoveryStatus.Complete); - }, - ); - }); - it('returns unknown when the phase is UNKNOWN', async () => { await withController( { @@ -4897,7 +4833,7 @@ describe('SeedlessOnboardingController', () => { ); }); - it('treats an unrecognized persisted phase as IDLE (no-change)', async () => { + it('treats an unrecognized persisted phase as in-sync', async () => { await withController( { state: getMockInitialControllerState({ @@ -4909,12 +4845,12 @@ describe('SeedlessOnboardingController', () => { }, async ({ controller }) => { const result = await controller.resolvePasswordSyncState(); - expect(result).toBe(PasswordChangeRecoveryStatus.NoChange); + expect(result).toBe(PasswordChangeRecoveryStatus.InSync); }, ); }); - it('clears to IDLE when SEEDLESS_CHANGE_PENDING and remote did not commit', async () => { + it('clears the phase when SEEDLESS_CHANGE_PENDING and remote did not commit', async () => { await withController( { state: getMockInitialControllerState({ @@ -4928,7 +4864,7 @@ describe('SeedlessOnboardingController', () => { // Remote auth pub key matches the local one -> not outdated. mockFetchAuthPubKey(toprfClient, base64ToBytes(MOCK_AUTH_PUB_KEY)); const result = await controller.resolvePasswordSyncState(); - expect(result).toBe(PasswordChangeRecoveryStatus.NoChange); + expect(result).toBe(PasswordChangeRecoveryStatus.InSync); expect(controller.state.passwordChangePhase).toBeUndefined(); }, ); @@ -4948,9 +4884,7 @@ describe('SeedlessOnboardingController', () => { // Remote auth pub key differs from the stale local one -> outdated. mockFetchAuthPubKey(toprfClient, base64ToBytes(MOCK_AUTH_PUB_KEY)); const result = await controller.resolvePasswordSyncState(); - expect(result).toBe( - PasswordChangeRecoveryStatus.EnterNewPassword, - ); + expect(result).toBe(PasswordChangeRecoveryStatus.EnterNewPassword); expect(controller.state.passwordChangePhase).toBe( SeedlessPasswordChangePhase.SeedlessCommitted, ); @@ -4987,7 +4921,7 @@ describe('SeedlessOnboardingController', () => { const OLD_PASSWORD = 'old-mock-password'; const NEW_PASSWORD = 'new-mock-password'; - it('returns no-change when the phase is IDLE and the password is in sync', async () => { + it('returns in-sync when no phase is set and the password is in sync', async () => { await withController( { state: getMockInitialControllerState({ @@ -5000,7 +4934,7 @@ describe('SeedlessOnboardingController', () => { const result = await controller.recoverPasswordChange({ globalPassword: NEW_PASSWORD, }); - expect(result).toBe(PasswordChangeRecoveryStatus.NoChange); + expect(result).toBe(PasswordChangeRecoveryStatus.InSync); }, ); }); @@ -5045,24 +4979,6 @@ describe('SeedlessOnboardingController', () => { ); }); - it('returns complete when the phase is COMPLETE', async () => { - await withController( - { - state: getMockInitialControllerState({ - withMockAuthenticatedUser: true, - withMockAuthPubKey: true, - passwordChangePhase: SeedlessPasswordChangePhase.Complete, - }), - }, - async ({ controller }) => { - const result = await controller.recoverPasswordChange({ - globalPassword: NEW_PASSWORD, - }); - expect(result).toBe(PasswordChangeRecoveryStatus.Complete); - }, - ); - }); - it('returns unknown when the phase is UNKNOWN', async () => { await withController( { @@ -5081,7 +4997,7 @@ describe('SeedlessOnboardingController', () => { ); }); - it('treats an unrecognized persisted phase as IDLE (no-change)', async () => { + it('treats an unrecognized persisted phase as in-sync', async () => { await withController( { state: getMockInitialControllerState({ @@ -5095,12 +5011,12 @@ describe('SeedlessOnboardingController', () => { const result = await controller.recoverPasswordChange({ globalPassword: NEW_PASSWORD, }); - expect(result).toBe(PasswordChangeRecoveryStatus.NoChange); + expect(result).toBe(PasswordChangeRecoveryStatus.InSync); }, ); }); - it('syncs the Seedless side without advancing the phase when IDLE and the remote password is outdated', async () => { + it('syncs the Seedless side and advances to Keyring reconciliation when no phase is set and the remote password is outdated', async () => { await withController( { state: getMockInitialControllerState({ @@ -5121,7 +5037,7 @@ describe('SeedlessOnboardingController', () => { ); // Remote auth pub key differs from the local one -> outdated, so - // the IDLE branch re-checks and runs the password-sync flow. + // the no-phase branch re-checks and runs the password-sync flow. mockFetchAuthPubKey( toprfClient, base64ToBytes(MOCK_AUTH_PUB_KEY_OUTDATED), @@ -5143,24 +5059,25 @@ describe('SeedlessOnboardingController', () => { }); // recoverPwEncKey recovers the vault key the existing vault was // encrypted with, so it must return the OLD password's pwEncKey. - jest - .spyOn(toprfClient, 'recoverPwEncKey') - .mockResolvedValueOnce({ - pwEncKey: mockToprfEncryptor.derivePwEncKey(OLD_PASSWORD), - }); + jest.spyOn(toprfClient, 'recoverPwEncKey').mockResolvedValueOnce({ + pwEncKey: mockToprfEncryptor.derivePwEncKey(OLD_PASSWORD), + }); const result = await controller.recoverPasswordChange({ globalPassword: NEW_PASSWORD, }); - expect(result).toBe(PasswordChangeRecoveryStatus.NoChange); - // No lifecycle is in flight; the phase stays IDLE. - expect(controller.state.passwordChangePhase).toBeUndefined(); + expect(result).toBe(PasswordChangeRecoveryStatus.ReconcileKeyring); + // Another-device recovery must continue through the local Keyring + // reconciliation boundary after the Seedless side is synchronized. + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.LocalKeyringPending, + ); }, ); }); - it('returns unknown when the IDLE outdated check fails', async () => { + it('returns unknown when the no-phase outdated check fails', async () => { await withController( { state: getMockInitialControllerState({ @@ -5188,8 +5105,7 @@ describe('SeedlessOnboardingController', () => { state: getMockInitialControllerState({ withMockAuthenticatedUser: true, withMockAuthPubKey: true, - passwordChangePhase: - SeedlessPasswordChangePhase.SeedlessCommitted, + passwordChangePhase: SeedlessPasswordChangePhase.SeedlessCommitted, }), }, async ({ controller, toprfClient, baseMessenger }) => { @@ -5228,9 +5144,7 @@ describe('SeedlessOnboardingController', () => { globalPassword: NEW_PASSWORD, }); - expect(result).toBe( - PasswordChangeRecoveryStatus.ReconcileKeyring, - ); + expect(result).toBe(PasswordChangeRecoveryStatus.ReconcileKeyring); expect(controller.state.passwordChangePhase).toBe( SeedlessPasswordChangePhase.LocalKeyringPending, ); @@ -5244,8 +5158,7 @@ describe('SeedlessOnboardingController', () => { state: getMockInitialControllerState({ withMockAuthenticatedUser: true, withMockAuthPubKey: true, - passwordChangePhase: - SeedlessPasswordChangePhase.SeedlessCommitted, + passwordChangePhase: SeedlessPasswordChangePhase.SeedlessCommitted, }), }, async ({ controller, toprfClient, baseMessenger }) => { diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index c06b4f28853..bf3c6dd4def 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -80,7 +80,6 @@ import { decodeJWTToken, decodeNodeAuthToken, deserializeVaultData, - getPasswordChangePhase, serializeVaultData, } from './utils.js'; @@ -96,7 +95,6 @@ const MESSENGER_EXPOSED_METHODS = [ 'changePassword', 'clearPasswordChangePhase', 'markPasswordChangeKeySyncPending', - 'completePasswordChange', 'resolvePasswordSyncState', 'recoverPasswordChange', 'updateBackupMetadataState', @@ -162,8 +160,7 @@ export type SeedlessOnboardingControllerOptions< EncryptionKey = encryptionUtils.EncryptionKey, SupportedKeyDerivationParams = encryptionUtils.KeyDerivationOptions, EncryptionResult extends - EncryptionResultConstraint = - DefaultEncryptionResult, + EncryptionResultConstraint = DefaultEncryptionResult, > = { messenger: SeedlessOnboardingControllerMessenger; @@ -405,8 +402,7 @@ export class SeedlessOnboardingController< EncryptionKey = encryptionUtils.EncryptionKey, SupportedKeyDerivationOptions = encryptionUtils.KeyDerivationOptions, EncryptionResult extends - EncryptionResultConstraint = - DefaultEncryptionResult, + EncryptionResultConstraint = DefaultEncryptionResult, > extends BaseController< typeof controllerName, SeedlessOnboardingControllerState, @@ -979,14 +975,11 @@ export class SeedlessOnboardingController< // Reject a second password change while a previous one is unresolved. // The controller mutex serializes calls, but a previous change may have - // released the lock with the lifecycle in a non-IDLE phase (recovery - // pending). Starting a fresh `changePassword`/`changeEncKey` then would - // race with recovery and could block the user from their wallet. - // Recovery must finish and clear the lifecycle to IDLE first. - if ( - getPasswordChangePhase(this.state.passwordChangePhase) !== - SeedlessPasswordChangePhase.Idle - ) { + // released the lock with the lifecycle still set (recovery pending). + // Starting a fresh `changePassword`/`changeEncKey` then would race with + // recovery and could block the user from their wallet. Recovery must + // finish and clear the lifecycle first. + if (this.state.passwordChangePhase !== undefined) { throw new SeedlessOnboardingError( SeedlessOnboardingControllerErrorMessage.PasswordChangeInProgress, ); @@ -1016,7 +1009,9 @@ export class SeedlessOnboardingController< // crash or lost response leaves a recovery signal. The password change // is never retried; recovery reconciles local state via the existing // password-sync flow. - this.#startPasswordChangeLifecycle(); + this.#writePasswordChangePhase( + SeedlessPasswordChangePhase.SeedlessChangePending, + ); // update the encryption key with new password and update the Metadata Store const { @@ -1031,7 +1026,7 @@ export class SeedlessOnboardingController< // The remote Seedless change is committed. Persist the boundary so // recovery knows the remote password is new. - this.#advancePasswordChangeLifecycle( + this.#writePasswordChangePhase( SeedlessPasswordChangePhase.SeedlessCommitted, ); @@ -1056,7 +1051,7 @@ export class SeedlessOnboardingController< SeedlessPasswordChangePhase.LocalKeyringPending, ); } else { - this.#advancePasswordChangeLifecycle( + this.#writePasswordChangePhase( SeedlessPasswordChangePhase.LocalKeyringPending, ); } @@ -2337,7 +2332,8 @@ export class SeedlessOnboardingController< * * Must be called while the controller lock is held. * - * @param phase - The phase to persist, or `undefined` to clear to `IDLE`. + * @param phase - The phase to persist, or `undefined` to clear (no change in + * progress). */ #writePasswordChangePhase( phase: SeedlessPasswordChangePhase | undefined, @@ -2348,45 +2344,18 @@ export class SeedlessOnboardingController< } /** - * Start a password-change lifecycle before the first remote mutation. - * - * Persists `SEEDLESS_CHANGE_PENDING`. Must be called while the controller - * lock is held, before any remote Seedless mutation. - */ - #startPasswordChangeLifecycle(): void { - this.#writePasswordChangePhase( - SeedlessPasswordChangePhase.SeedlessChangePending, - ); - } - - /** - * Advance the lifecycle to a target phase after an irreversible boundary. + * Clear the password-change lifecycle. * - * Must be called while the controller lock is held. - * - * @param phase - The target phase. - */ - #advancePasswordChangeLifecycle( - phase: SeedlessPasswordChangePhase, - ): void { - this.#writePasswordChangePhase(phase); - } - - /** - * Clear the password-change lifecycle to `IDLE`. - * - * This is an explicit operation used after a definitive remote failure - * (server did not commit) or after `COMPLETE`. The controller clears to - * `IDLE` so the next unlock is normal. + * Used after a definitive remote failure (server did not commit) or once + * Keyring encryption-key synchronization is verified and all required local + * writes have succeeded. The controller clears the phase so the next unlock + * is normal. * * @returns A promise that resolves once the lifecycle has been cleared. */ async clearPasswordChangePhase(): Promise { await this.#withControllerLock(async () => { - if ( - getPasswordChangePhase(this.state.passwordChangePhase) === - SeedlessPasswordChangePhase.Idle - ) { + if (this.state.passwordChangePhase === undefined) { return; } this.#writePasswordChangePhase(undefined); @@ -2405,7 +2374,7 @@ export class SeedlessOnboardingController< async markPasswordChangeKeySyncPending(): Promise { await this.#withControllerLock(async () => { if ( - getPasswordChangePhase(this.state.passwordChangePhase) === + this.state.passwordChangePhase === SeedlessPasswordChangePhase.KeySyncPending ) { return; @@ -2416,29 +2385,6 @@ export class SeedlessOnboardingController< }); } - /** - * Mark the password-change lifecycle as `COMPLETE`. - * - * Called by the client coordinator only after Keyring encryption-key - * synchronization is verified and all required local writes have succeeded. - * The controller only records the boundary; it does not infer completion - * from this call. Follow with `clearPasswordChangePhase` to return to - * `IDLE` once the durable `COMPLETE` state is no longer needed as a signal. - * - * @returns A promise that resolves once the phase has been persisted. - */ - async completePasswordChange(): Promise { - await this.#withControllerLock(async () => { - if ( - getPasswordChangePhase(this.state.passwordChangePhase) === - SeedlessPasswordChangePhase.Complete - ) { - return; - } - this.#writePasswordChangePhase(SeedlessPasswordChangePhase.Complete); - }); - } - /** * Resolve the current password-sync state without consuming a password. * @@ -2447,14 +2393,14 @@ export class SeedlessOnboardingController< * page render and on password submit) and routes UI from the returned status. * * Phase handling: - * - `IDLE`: run the authoritative outdated check. `skipCache` is honored, so - * the client can read from cache on render and force a remote call on - * submit. Returns `NoChange` (in sync) or `PasswordOutdated` (another device + * - No phase (`undefined`): run the authoritative outdated check. `skipCache` + * is honored, so the client can read from cache on render and force a remote + * call on submit. Returns `InSync` or `PasswordOutdated` (another device * changed the remote password). * - `SEEDLESS_CHANGE_PENDING`: the remote outcome is ambiguous, so `skipCache` - * is ignored and a remote check is forced. Clears to `IDLE` (remote did not + * is ignored and a remote check is forced. Clears the phase (remote did not * commit) or advances to `SEEDLESS_COMMITTED` (remote committed). Returns - * `NoChange` or `EnterNewPassword`. + * `InSync` or `EnterNewPassword`. * - Other phases: return the next recovery step without mutating state. * * This method does not consume a password; the client prompts for the @@ -2469,9 +2415,9 @@ export class SeedlessOnboardingController< async resolvePasswordSyncState(options?: { skipCache?: boolean; }): Promise { - const phase = getPasswordChangePhase(this.state.passwordChangePhase); + const phase = this.state.passwordChangePhase; switch (phase) { - case SeedlessPasswordChangePhase.Idle: { + case undefined: { // Pure read with no state mutation; let the helper acquire the // controller lock itself (no `skipLock`). try { @@ -2480,7 +2426,7 @@ export class SeedlessOnboardingController< }); return outdated ? PasswordChangeRecoveryStatus.PasswordOutdated - : PasswordChangeRecoveryStatus.NoChange; + : PasswordChangeRecoveryStatus.InSync; } catch { // Remote state could not be established. Keep the wallet locked. return PasswordChangeRecoveryStatus.Unknown; @@ -2498,10 +2444,10 @@ export class SeedlessOnboardingController< skipLock: true, }); if (!outdated) { - // Remote did not commit. Clear to IDLE; unlock with the old + // Remote did not commit. Clear the phase; unlock with the old // password normally. this.#writePasswordChangePhase(undefined); - return PasswordChangeRecoveryStatus.NoChange; + return PasswordChangeRecoveryStatus.InSync; } // Remote committed. Advance so recovery reconciles the local // Seedless side with the new password. @@ -2521,8 +2467,8 @@ export class SeedlessOnboardingController< case SeedlessPasswordChangePhase.LocalKeyringPending: return PasswordChangeRecoveryStatus.ReconcileKeyring; default: - // Terminal phases (KEY_SYNC_PENDING, COMPLETE, UNKNOWN) and any - // unrecognized/missing phase (treated as IDLE) share routing. + // Terminal phases (KEY_SYNC_PENDING, UNKNOWN) and any unrecognized + // persisted value share routing. return this.#statusForTerminalPhase(phase); } } @@ -2538,16 +2484,17 @@ export class SeedlessOnboardingController< * the local Seedless vault was already rewritten. The controller is left * unlocked. * - * For `IDLE` it re-checks whether the remote password is outdated and, if so, - * runs the same password-sync flow without advancing any phase (there is no - * local password-change lifecycle in flight — e.g. another device changed - * the remote password). If the remote password is not outdated it is a no-op. + * For no phase (`undefined`) it re-checks whether the remote password is + * outdated. If it is, it runs the same password-sync flow, advances to + * `LOCAL_KEYRING_PENDING`, and returns `ReconcileKeyring` so the client can + * reconcile the local Keyring (e.g. after another device changed the remote + * password). If the remote password is not outdated it is a no-op. * * The client remains responsible for the Keyring side (classifying the local * Keyring via `KeyringController:verifyPassword` and running the old-Keyring * or new-Keyring branch), because this controller does not depend on * `KeyringController`. See - * [0004](./docs/0004-controller-owned-password-change-recovery-plan.md). + * [0003](./docs/0003-controller-owned-password-change-recovery-plan.md). * * @param params - The recovery parameters. * @param params.globalPassword - The new global password. @@ -2560,7 +2507,7 @@ export class SeedlessOnboardingController< globalPassword: string; }): Promise { return await this.#withControllerLock(async () => { - const phase = getPasswordChangePhase(this.state.passwordChangePhase); + const phase = this.state.passwordChangePhase; switch (phase) { case SeedlessPasswordChangePhase.SeedlessChangePending: // Remote state must be resolved first via @@ -2583,20 +2530,24 @@ export class SeedlessOnboardingController< return PasswordChangeRecoveryStatus.Unknown; } } - case SeedlessPasswordChangePhase.Idle: { + case undefined: { // No local password-change lifecycle is in flight. Another device // may still have changed the remote password, so re-check and sync - // the Seedless side if it is outdated. No phase is advanced. + // the Seedless side if it is outdated. A phase is then recorded so + // the client reconciles the local Keyring. try { const outdated = await this.#checkIsPasswordOutdated({ skipCache: true, skipLock: true, }); if (!outdated) { - return PasswordChangeRecoveryStatus.NoChange; + return PasswordChangeRecoveryStatus.InSync; } await this.#runPasswordSyncFlow(globalPassword); - return PasswordChangeRecoveryStatus.NoChange; + this.#writePasswordChangePhase( + SeedlessPasswordChangePhase.LocalKeyringPending, + ); + return PasswordChangeRecoveryStatus.ReconcileKeyring; } catch { // Sync failed (e.g. wrong password or transient remote error). // Keep the wallet locked. @@ -2604,8 +2555,8 @@ export class SeedlessOnboardingController< } } default: - // Terminal phases (KEY_SYNC_PENDING, COMPLETE, UNKNOWN) and any - // unrecognized/missing phase (treated as IDLE) share routing. + // Terminal phases (KEY_SYNC_PENDING, UNKNOWN) and any unrecognized + // persisted value share routing. return this.#statusForTerminalPhase(phase); } }); @@ -2644,8 +2595,8 @@ export class SeedlessOnboardingController< * identically. * * @param phase - The persisted password-change phase. - * @returns The status for the phase. A missing or unrecognized phase is - * treated as IDLE and returns `NoChange`. + * @returns The status for the phase. An unrecognized persisted value is + * treated as no change in progress and returns `InSync`. */ #statusForTerminalPhase( phase: SeedlessPasswordChangePhase, @@ -2653,13 +2604,11 @@ export class SeedlessOnboardingController< switch (phase) { case SeedlessPasswordChangePhase.KeySyncPending: return PasswordChangeRecoveryStatus.SyncKey; - case SeedlessPasswordChangePhase.Complete: - return PasswordChangeRecoveryStatus.Complete; case SeedlessPasswordChangePhase.Unknown: return PasswordChangeRecoveryStatus.Unknown; default: - // A missing or unrecognized persisted phase is treated as IDLE. - return PasswordChangeRecoveryStatus.NoChange; + // An unrecognized persisted phase is treated as no change in progress. + return PasswordChangeRecoveryStatus.InSync; } } @@ -2748,7 +2697,7 @@ export class SeedlessOnboardingController< // Block TOPRF operations while a password change is unresolved. The // controller mutex serializes in-process calls, but a previous change may - // have left a non-IDLE persisted phase after a crash. Running a fresh TOPRF + // have left a persisted phase after a crash. Running a fresh TOPRF // operation against ambiguous state could corrupt recovery. Recovery // itself bypasses this assert (it calls the password-sync primitives // directly), so this guard does not block reconciliation. `changePassword` @@ -2756,8 +2705,7 @@ export class SeedlessOnboardingController< // token-refresh retry and already guards concurrency at entry. if ( !options?.skipPhaseCheck && - getPasswordChangePhase(this.state.passwordChangePhase) !== - SeedlessPasswordChangePhase.Idle + this.state.passwordChangePhase !== undefined ) { throw new SeedlessOnboardingError( SeedlessOnboardingControllerErrorMessage.PasswordChangeInProgress, diff --git a/packages/seedless-onboarding-controller/src/constants.ts b/packages/seedless-onboarding-controller/src/constants.ts index 8915345e620..39e3fd567be 100644 --- a/packages/seedless-onboarding-controller/src/constants.ts +++ b/packages/seedless-onboarding-controller/src/constants.ts @@ -33,8 +33,6 @@ export enum SeedlessOnboardingMigrationVersion { * state before acting on the phase. */ export enum SeedlessPasswordChangePhase { - /** No password change is in progress. */ - Idle = 'IDLE', /** A password change has started but the remote Seedless result is not yet confirmed. */ SeedlessChangePending = 'SEEDLESS_CHANGE_PENDING', /** The remote Seedless password change is confirmed committed. */ @@ -43,8 +41,6 @@ export enum SeedlessPasswordChangePhase { LocalKeyringPending = 'LOCAL_KEYRING_PENDING', /** The local Keyring encryption key has been stored; awaiting final verification. */ KeySyncPending = 'KEY_SYNC_PENDING', - /** The password change is fully complete and verified. */ - Complete = 'COMPLETE', /** The result of one or more steps could not be established. */ Unknown = 'UNKNOWN', } @@ -57,21 +53,19 @@ export enum SeedlessPasswordChangePhase { * The controller owns the Seedless-side recovery sequencing; the client owns * the Keyring-side steps (it must call `KeyringController` directly) and UI * routing based on this status. See - * [0004](./docs/0004-controller-owned-password-change-recovery-plan.md). + * [0003](./docs/0003-controller-owned-password-change-recovery-plan.md). */ export enum PasswordChangeRecoveryStatus { - /** Remote did not commit; the phase has been cleared to `IDLE`. Unlock with the old password normally. */ - NoChange = 'no-change', - /** Phase is `IDLE` but the remote password changed (e.g. another device changed it). Prompt for the new password, then call `recoverPasswordChange`. */ + /** The local and remote passwords are synchronized; no recovery action is needed. Unlock normally. */ + InSync = 'in-sync', + /** No lifecycle is in flight but the remote password changed (e.g. another device changed it). Prompt for the new password, then call `recoverPasswordChange`. */ PasswordOutdated = 'password-outdated', /** Remote committed (or the local Seedless side still needs the new password). Prompt for the new password, then call `recoverPasswordChange`. */ EnterNewPassword = 'enter-new-password', /** The Seedless side is reconciled (phase is `LOCAL_KEYRING_PENDING`). The client must cryptographically classify the local Keyring and run the old/new branch. */ ReconcileKeyring = 'reconcile-keyring', - /** Phase is `KEY_SYNC_PENDING`. The client must export, store, and sync the current Keyring encryption key, then call `completePasswordChange`. */ + /** Phase is `KEY_SYNC_PENDING`. The client must export, store, and sync the current Keyring encryption key, then call `clearPasswordChangePhase`. */ SyncKey = 'sync-key', - /** Phase is `COMPLETE`. The client should clear the lifecycle to `IDLE`. */ - Complete = 'complete', /** The remote or local state could not be established. Keep the wallet locked. The last known phase is preserved. */ Unknown = 'unknown', } diff --git a/packages/seedless-onboarding-controller/src/index.ts b/packages/seedless-onboarding-controller/src/index.ts index 36c95609f2e..0a2dc329e6b 100644 --- a/packages/seedless-onboarding-controller/src/index.ts +++ b/packages/seedless-onboarding-controller/src/index.ts @@ -20,7 +20,6 @@ export type { SeedlessOnboardingControllerChangePasswordAction, SeedlessOnboardingControllerClearPasswordChangePhaseAction, SeedlessOnboardingControllerMarkPasswordChangeKeySyncPendingAction, - SeedlessOnboardingControllerCompletePasswordChangeAction, SeedlessOnboardingControllerUpdateBackupMetadataStateAction, SeedlessOnboardingControllerVerifyVaultPasswordAction, SeedlessOnboardingControllerGetSecretDataBackupStateAction, diff --git a/packages/seedless-onboarding-controller/src/utils.test.ts b/packages/seedless-onboarding-controller/src/utils.test.ts index 31502fa7786..4f1ed107ea6 100644 --- a/packages/seedless-onboarding-controller/src/utils.test.ts +++ b/packages/seedless-onboarding-controller/src/utils.test.ts @@ -3,10 +3,7 @@ import { bytesToBase64 } from '@metamask/utils'; import { utf8ToBytes } from '@noble/ciphers/utils'; import { createMockJWTToken } from '../tests/mocks/utils.js'; -import { - SecretType, - SeedlessPasswordChangePhase, -} from './constants.js'; +import { SecretType } from './constants.js'; import { SecretMetadata } from './SecretMetadata.js'; import type { DecodedNodeAuthToken } from './types.js'; import { @@ -14,7 +11,6 @@ import { decodeJWTToken, decodeNodeAuthToken, getInvalidPrimarySecretDataTypeErrorData, - getPasswordChangePhase, getSecretTypeFromDataType, } from './utils.js'; @@ -273,18 +269,4 @@ describe('utils', () => { ]); }); }); - - describe('getPasswordChangePhase', () => { - it('returns IDLE when the phase is undefined', () => { - expect(getPasswordChangePhase(undefined)).toBe( - SeedlessPasswordChangePhase.Idle, - ); - }); - - it('returns the stored phase when defined', () => { - expect( - getPasswordChangePhase(SeedlessPasswordChangePhase.SeedlessCommitted), - ).toBe(SeedlessPasswordChangePhase.SeedlessCommitted); - }); - }); }); diff --git a/packages/seedless-onboarding-controller/src/utils.ts b/packages/seedless-onboarding-controller/src/utils.ts index d05d3ab2221..21a08b1f2da 100644 --- a/packages/seedless-onboarding-controller/src/utils.ts +++ b/packages/seedless-onboarding-controller/src/utils.ts @@ -8,10 +8,7 @@ import { } from '@metamask/utils'; import { bytesToUtf8 } from '@noble/ciphers/utils'; -import { - SecretType, - SeedlessPasswordChangePhase, -} from './constants.js'; +import { SecretType } from './constants.js'; import type { SecretMetadata } from './SecretMetadata.js'; import type { DecodedBaseJWTToken, @@ -190,16 +187,3 @@ export function getInvalidPrimarySecretDataTypeErrorData( ): InvalidPrimarySecretDataTypeErrorData { return secrets.map((secret) => secret.dataType ?? secret.type); } - -/** - * Resolve a password-change phase, treating `undefined` as `IDLE`. - * - * @param phase - The persisted phase, or `undefined`. - * @returns The phase, or `IDLE` if it is missing. - */ -export function getPasswordChangePhase( - phase: SeedlessPasswordChangePhase | undefined, -): SeedlessPasswordChangePhase { - return phase ?? SeedlessPasswordChangePhase.Idle; -} - From e8a502b7edd9c8eec423955d1971c78c929c576b Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 10 Sep 2026 00:03:02 +0800 Subject: [PATCH 06/14] fix: KeySync after the remote and local commitment --- .../CHANGELOG.md | 8 +- .../0001-seedless-password-change-recovery.md | 2 +- .../0002-password-change-recovery-flow.md | 60 +- ...ler-owned-password-change-recovery-plan.md | 16 +- ...nboardingController-method-action-types.ts | 50 +- .../src/SeedlessOnboardingController.test.ts | 1551 ++++------------- .../src/SeedlessOnboardingController.ts | 102 +- .../src/constants.ts | 6 +- .../src/index.ts | 4 +- 9 files changed, 445 insertions(+), 1354 deletions(-) diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 5eb694e7313..323c07ba33a 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `SeedlessPasswordChangePhase` enum and a `passwordChangePhase` state field to persist a non-sensitive password-change lifecycle phase used as a recovery signal. An unset/`undefined` phase means "no change in progress" (there is no dedicated `IDLE` or `COMPLETE` member) ([#10148](https://github.com/MetaMask/core/pull/10148)) - Add `PasswordChangeRecoveryStatus` enum returned by the new password-change recovery methods. `NoChange` is named `InSync` (`'in-sync'`) to reflect that the local and remote passwords are synchronized ([#10148](https://github.com/MetaMask/core/pull/10148)) - Add `resolvePasswordSyncState({ skipCache })` to resolve remote password-change state without a password at unlock, merging the legacy `checkIsPasswordOutdated` read with password-change recovery routing ([#10148](https://github.com/MetaMask/core/pull/10148)) -- Add `recoverPasswordChange({ globalPassword })` to reconcile the Seedless side with the new password and advance the lifecycle to `LOCAL_KEYRING_PENDING` ([#10148](https://github.com/MetaMask/core/pull/10148)) +- Add `reconcilePassword({ globalPassword })` to reconcile local Seedless state after either an interrupted local password change or an another-device password change, and advance the lifecycle to `LOCAL_KEYRING_PENDING` ([#10148](https://github.com/MetaMask/core/pull/10148)) - Add `clearPasswordChangePhase` and `markPasswordChangeKeySyncPending` lifecycle-advance methods. `clearPasswordChangePhase` is the single way back to no change in progress (it both marks completion and clears the phase) ([#10148](https://github.com/MetaMask/core/pull/10148)) - Add `PasswordChangeInProgress` error message, thrown when a second password change is attempted while one is already in progress ([#10148](https://github.com/MetaMask/core/pull/10148)) @@ -25,7 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - **BREAKING:** Remove the public `checkIsPasswordOutdated` method and `SeedlessOnboardingControllerCheckIsPasswordOutdatedAction`; the read is folded into `resolvePasswordSyncState` (now private `#checkIsPasswordOutdated`) ([#10148](https://github.com/MetaMask/core/pull/10148)) -- **BREAKING:** Remove `resolvePasswordChangeRecovery` method and `SeedlessOnboardingControllerResolvePasswordChangeRecoveryAction`; replaced by `resolvePasswordSyncState` (password-less resolve) and `recoverPasswordChange` (password-consuming apply) ([#10148](https://github.com/MetaMask/core/pull/10148)) +- **BREAKING:** Remove `resolvePasswordChangeRecovery` method and `SeedlessOnboardingControllerResolvePasswordChangeRecoveryAction`; replaced by `resolvePasswordSyncState` (password-less resolve) and `reconcilePassword` (password-consuming apply) ([#10148](https://github.com/MetaMask/core/pull/10148)) +- **BREAKING:** Remove public `submitGlobalPassword`, `syncLatestGlobalPassword`, `SeedlessOnboardingControllerSubmitGlobalPasswordAction`, and `SeedlessOnboardingControllerSyncLatestGlobalPasswordAction`; password-chain unlock and local vault rewrite are now internal to `reconcilePassword` ([#10148](https://github.com/MetaMask/core/pull/10148)) - **BREAKING:** Remove `PasswordChangeRecoveryResult` type; recovery methods now return `PasswordChangeRecoveryStatus` ([#10148](https://github.com/MetaMask/core/pull/10148)) - **BREAKING:** Remove `SeedlessPasswordChangePhase.Idle` and `SeedlessPasswordChangePhase.Complete`; "no change in progress" and "done" are both represented by an unset/`undefined` phase ([#10148](https://github.com/MetaMask/core/pull/10148)) - **BREAKING:** Remove `PasswordChangeRecoveryStatus.NoChange` and `PasswordChangeRecoveryStatus.Complete`; the former is renamed `InSync` (`'in-sync'`) and the latter is no longer returned (a completed change is just an unset phase, which resolves to `InSync`) ([#10148](https://github.com/MetaMask/core/pull/10148)) @@ -33,7 +34,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Ensure `recoverPasswordChange` advances another-device password recovery to `LOCAL_KEYRING_PENDING` and returns `ReconcileKeyring` after synchronizing the Seedless vault, so clients reconcile the local Keyring before unlocking normally ([#10148](https://github.com/MetaMask/core/pull/10148)) +- Ensure `reconcilePassword` advances another-device password reconciliation to `LOCAL_KEYRING_PENDING` and returns `ReconcileKeyring` after synchronizing the Seedless vault, so clients reconcile the local Keyring before unlocking normally ([#10148](https://github.com/MetaMask/core/pull/10148)) +- Re-encrypt `encryptedKeyringEncryptionKey` when reconciling the latest global password, so `loadKeyringEncryptionKey` still decrypts after an interrupted local password change or another-device password sync ([#10148](https://github.com/MetaMask/core/pull/10148)) ## [10.1.1] diff --git a/packages/seedless-onboarding-controller/docs/0001-seedless-password-change-recovery.md b/packages/seedless-onboarding-controller/docs/0001-seedless-password-change-recovery.md index 6c75f2b6882..844ba886d5e 100644 --- a/packages/seedless-onboarding-controller/docs/0001-seedless-password-change-recovery.md +++ b/packages/seedless-onboarding-controller/docs/0001-seedless-password-change-recovery.md @@ -51,7 +51,7 @@ The controller already provides most of the required recovery primitives: - `changePassword` performs the Seedless password/vault change and handles the controller-level token-refresh path. - `loadKeyringEncryptionKey` can recover the stored Keyring encryption key after the new Seedless password is submitted. - `storeKeyringEncryptionKey` encrypts and stores the current Keyring encryption key in controller state. -- `submitGlobalPassword` and `syncLatestGlobalPassword` provide the password-sync operations needed to rehydrate and update local Seedless state. +- `reconcilePassword` provides the password-sync operation needed to rehydrate and update local Seedless state after either an interrupted local password change or an another-device password change. - `checkIsPasswordOutdated({ skipCache: true })` provides a cache-bypassed password-state check. - Controller locking already serializes controller-level operations. diff --git a/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md b/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md index 42114651ad6..a3c471a6464 100644 --- a/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md +++ b/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md @@ -32,8 +32,8 @@ Returned by the two controller methods. The client routes UI from this status. | Status | Meaning | Client action | | -------------------- | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | `in-sync` | Local and remote passwords are synchronized; no recovery action is needed. | Unlock normally. | -| `password-outdated` | No lifecycle is in flight but the remote password changed (another device). | Prompt for the new password, then `recoverPasswordChange`. | -| `enter-new-password` | Remote committed (or the local Seedless side still needs the new password). | Prompt for the new password, then `recoverPasswordChange`. | +| `password-outdated` | No lifecycle is in flight but the remote password changed (another device). | Prompt for the new password, then `reconcilePassword`. | +| `enter-new-password` | Remote committed (or the local Seedless side still needs the new password). | Prompt for the new password, then `reconcilePassword`. | | `reconcile-keyring` | Seedless side reconciled (phase is `LOCAL_KEYRING_PENDING`). | Cryptographically classify the local Keyring, then run the old/new branch. | | `sync-key` | Phase is `KEY_SYNC_PENDING`. | Export, store, and sync the current Keyring encryption key, then `clearPasswordChangePhase`. | | `unknown` | Remote or local state could not be established. | Keep the wallet locked. Preserve the phase. Offer reset wallet only as an explicit last resort. | @@ -42,6 +42,8 @@ Returned by the two controller methods. The client routes UI from this status. The controller owns the Seedless-side sequencing. The client owns the Keyring-side steps and UI routing. +For password-change recovery and another-device password sync, clients call `resolvePasswordSyncState` followed by `reconcilePassword`, then use the lifecycle advances and Keyring-key methods described below. The old public `submitGlobalPassword` and `syncLatestGlobalPassword` methods have been removed: their sequencing is now internal to `reconcilePassword`, which also re-wraps `encryptedKeyringEncryptionKey` and advances to `LOCAL_KEYRING_PENDING`. + ### Read / resolve (no password) ```ts @@ -60,14 +62,14 @@ Single unlock-time call (call on page render _and_ on password submit). Merges t ### Apply (with password) ```ts -SeedlessOnboardingController:recoverPasswordChange({ +SeedlessOnboardingController:reconcilePassword({ globalPassword: string, }): Promise ``` Reconciles the Seedless side with the supplied password. -- `SEEDLESS_COMMITTED` / `LOCAL_KEYRING_PENDING`: re-runs `submitGlobalPassword` → `syncLatestGlobalPassword` (idempotent), advances to `LOCAL_KEYRING_PENDING`, returns `reconcile-keyring`. +- `SEEDLESS_COMMITTED` / `LOCAL_KEYRING_PENDING`: re-runs the internal chain-unlock and local-vault rewrite (idempotent), advances to `LOCAL_KEYRING_PENDING`, returns `reconcile-keyring`. - No phase (`undefined`): re-checks the remote password and, if outdated, runs the same password-sync flow, advances to `LOCAL_KEYRING_PENDING`, and returns `reconcile-keyring` so the client reconciles the local Keyring after a password change on another device. If not outdated, a no-op that returns `in-sync`. - `SEEDLESS_CHANGE_PENDING`: returns `unknown` (resolve remote state via `resolvePasswordSyncState` first). - On any failure: returns `unknown` and preserves the phase. @@ -99,7 +101,7 @@ unlock render / submit └───────────────────┴───────────────────┴───────────────────┴─────────────────┴───────────┴─────────┘ │ │ │ │ │ │ ▼ ▼ ▼ ▼ - │ recoverPasswordChange recoverPasswordChange old/new branch clearPasswordChangePhase + │ reconcilePassword reconcilePassword old/new branch clearPasswordChangePhase │ ({ globalPassword }) ({ globalPassword }) (see below) (after sync verified) ▼ normal unlock │ │ @@ -150,18 +152,48 @@ unlock render / submit 4. **Two-step UX.** - Step 1 (password-less): `resolvePasswordSyncState` decides whether the old or new password is needed. - - Step 2 (password-consuming): only after step 1 returns `enter-new-password` / `password-outdated`, prompt for the new password and call `recoverPasswordChange({ globalPassword })`. + - Step 2 (password-consuming): only after step 1 returns `enter-new-password` / `password-outdated`, prompt for the new password and call `reconcilePassword({ globalPassword })`. 5. **Keyring classification.** On `reconcile-keyring`, call `KeyringController:verifyPassword(newPassword)` to choose the old-Keyring or new-Keyring branch. Do **not** infer the local Keyring state from the lifecycle phase. -6. **Lock before error.** Any failure from `changePassword`, `resolvePasswordSyncState`, `recoverPasswordChange`, or any Keyring step must lock the wallet _before_ surfacing an error modal, retry screen, or intermediary UI. If the lock itself fails, keep the wallet in a recovery-blocked UI and never expose wallet access. +6. **Lock before error.** Any failure from `changePassword`, `resolvePasswordSyncState`, `reconcilePassword`, or any Keyring step must lock the wallet _before_ surfacing an error modal, retry screen, or intermediary UI. If the lock itself fails, keep the wallet in a recovery-blocked UI and never expose wallet access. 7. **Completion boundary.** Call `clearPasswordChangePhase()` only after the synchronized Keyring encryption key and all required local state are durably persisted. This clears the lifecycle so the next unlock is normal. -8. **`UNKNOWN` is terminal for this attempt.** If `resolvePasswordSyncState` or `recoverPasswordChange` returns `unknown`, keep the wallet locked, preserve the phase, and stop. Do not retry `changePassword` / `changeEncKey`. Offer reset wallet only as an explicit last resort for a confirmed unrecoverable state. +8. **`UNKNOWN` is terminal for this attempt.** If `resolvePasswordSyncState` or `reconcilePassword` returns `unknown`, keep the wallet locked, preserve the phase, and stop. Do not retry `changePassword` / `changeEncKey`. Offer reset wallet only as an explicit last resort for a confirmed unrecoverable state. 9. **Cache.** `resolvePasswordSyncState` honors `skipCache` for the no-phase outdated check only. Use `skipCache: false` (default) on render and `skipCache: true` on submit. `SEEDLESS_CHANGE_PENDING` always forces a remote check. +### Password out of sync (another device changed the remote password) + +No local `changePassword` is in flight (`passwordChangePhase` is unset). The remote Seedless password is newer than this device. + +1. Lock the wallet before any error or “wrong password” UI. +2. On unlock render / submit, call `resolvePasswordSyncState({ skipCache })`. Expect `password-outdated` (or `in-sync` if this device is current). +3. Prompt for the **new** global password. Do not ask for the old Keyring password. +4. Call `reconcilePassword({ globalPassword })`. That rewrites the local Seedless vault, re-wraps `encryptedKeyringEncryptionKey`, sets `LOCAL_KEYRING_PENDING`, and returns `reconcile-keyring`. +5. Classify the local Keyring with `KeyringController:verifyPassword(newPassword)` and run the [old-Keyring](#old-keyring-branch-local-keyring-still-on-the-old-password) or [new-Keyring](#new-keyring-branch-local-keyring-already-on-the-new-password) branch. +6. Finish with `clearPasswordChangePhase()` only after key sync and local persistence. + +The chain-unlock and vault-rewrite steps are internal; clients do not call separate password-sync primitives. + +### Password-change error (this device’s `changePassword` failed) + +A local change started and left a phase. Do **not** retry `changePassword` / `changeEncKey`. + +1. Lock the wallet **before** showing the error, retry screen, or any intermediary UI. +2. On the next unlock, call `resolvePasswordSyncState({ skipCache })` and route on the status: + + | Status | What happened | Client action | + | -------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | + | `in-sync` | Remote did not commit (`SEEDLESS_CHANGE_PENDING` resolved to old). Phase cleared. | Unlock with the **old** password. | + | `enter-new-password` | Remote committed (`SEEDLESS_CHANGE_PENDING` → `SEEDLESS_COMMITTED`, or already `SEEDLESS_COMMITTED`). | Prompt for the **new** password, then `reconcilePassword`. | + | `reconcile-keyring` | Local Seedless vault already rewritten (`LOCAL_KEYRING_PENDING`). | Classify Keyring; run the old/new branch. If Seedless is locked, call `reconcilePassword` first (idempotent). | + | `sync-key` | Keyring is on the new password; backup key sync is unfinished. | Unlock with the new password; export / store / remote-sync; `clearPasswordChangePhase`. | + | `unknown` | Remote or local state could not be established. | Keep locked. Preserve the phase. Do not retry `changePassword`. | + +3. After `reconcilePassword` returns `reconcile-keyring`, run the same Keyring branches as another-device sync. `loadKeyringEncryptionKey` is valid because reconciliation re-wraps the stored key under the new Seedless wrapping key. + ## Technical details ### Lifecycle model and persistence @@ -204,12 +236,12 @@ A password-change operation must never be retried as a fresh `changePassword` / ### Recovery mechanism -Recovery reuses the existing password-sync flow, which already handles "remote changed, local is outdated" (e.g. another device changed the password): +`reconcilePassword` runs the password-sync flow internally: -- `submitGlobalPassword({ globalPassword })` — `toprfClient.recoverPwEncKey` walks the server-side password-key history chain (`maxPwChainLength`) to find the `pwEncKey` matching this device's `authPubKey`, then unlocks the vault. -- `syncLatestGlobalPassword({ globalPassword })` — `toprfClient.recoverEncKey` derives encryption material from the candidate password and rewrites the local Seedless vault with the new password's keys. +- `#submitGlobalPassword` — `toprfClient.recoverPwEncKey` walks the server-side password-key history chain (`maxPwChainLength`) to find the `pwEncKey` matching this device's `authPubKey`, then unlocks the vault. +- `#syncLatestGlobalPasswordInner` — `toprfClient.recoverEncKey` derives encryption material from the candidate password, rewrites the local Seedless vault with the new password's keys, and re-encrypts `encryptedKeyringEncryptionKey` under the new `toprfPwEncryptionKey` so `loadKeyringEncryptionKey` still works. -Both run through `#executeWithTokenRefresh`, which preserves the existing token-refresh retry behavior. `storeKeyringEncryptionKey` of the already-current key is a local overwrite and is safe to re-run. +Both run through `#executeWithTokenRefresh`. `storeKeyringEncryptionKey` of the already-current key is a local overwrite and is safe to re-run. ### Remote-state classification @@ -227,7 +259,7 @@ There is no API that reports partial backup or key-share state; such cases are c - It does not call `KeyringController` (`AllowedActions = never`). Keyring classification, re-encryption, and key export are client responsibilities. - It does not provide an awaitable durability boundary for lifecycle writes. The lifecycle is persisted as ordinary debounced controller state. Recovery re-verifies actual state, so a stale/missing marker is recoverable. -- It does not retry `changePassword` / `changeEncKey`. Recovery reconciles local state via the existing password-sync flow (`submitGlobalPassword` + `syncLatestGlobalPassword`). +- It does not retry `changePassword` / `changeEncKey`. Password reconciliation performs the chain unlock and local vault rewrite internally. - It does not lock the wallet. Locking is a client responsibility (the client owns navigation/intermediary screens). ## Controller-side status @@ -237,7 +269,7 @@ All controller-package work is complete: - Lifecycle model, helpers, metadata, exports. - Lifecycle-aware `changePassword` with concurrency guard and phase preservation on error. - Lifecycle-aware `storeKeyringEncryptionKey`. -- `resolvePasswordSyncState` + `recoverPasswordChange` (Option A: controller owns the Seedless side). +- `resolvePasswordSyncState` + `reconcilePassword` (Option A: controller owns the Seedless side). - `markPasswordChangeKeySyncPending` / `clearPasswordChangePhase`. - Messenger action types, package exports, and unit tests (290 tests, 100% statement / 99.22% branch coverage). diff --git a/packages/seedless-onboarding-controller/docs/0003-controller-owned-password-change-recovery-plan.md b/packages/seedless-onboarding-controller/docs/0003-controller-owned-password-change-recovery-plan.md index a26de01191d..bf86de48a21 100644 --- a/packages/seedless-onboarding-controller/docs/0003-controller-owned-password-change-recovery-plan.md +++ b/packages/seedless-onboarding-controller/docs/0003-controller-owned-password-change-recovery-plan.md @@ -18,16 +18,16 @@ A single controller method performs the entire recovery for any set phase and re ## Current state (Option A) -- `recoverPasswordChange({ globalPassword })` does the Seedless-side steps (`#checkIsPasswordOutdated({ skipCache: true })`, `submitGlobalPassword`, `syncLatestGlobalPassword`, lifecycle advances) and returns a result describing the remaining Keyring-side step. Remote-state resolution for `SeedlessChangePending` is owned by `resolvePasswordSyncState()` (password-less), which the client calls first. +- `reconcilePassword({ globalPassword })` does the Seedless-side steps (`#checkIsPasswordOutdated({ skipCache: true })`, chain unlock, local vault rewrite, lifecycle advances) and returns a result describing the remaining Keyring-side step. Remote-state resolution for `SeedlessChangePending` is owned by `resolvePasswordSyncState()` (password-less), which the client calls first. - The client classifies the local Keyring via `KeyringController:verifyPassword`, then runs the old-Keyring or new-Keyring branch itself, calling `KeyringController:submitEncryptionKey` / `changePassword` / `exportEncryptionKey` and the controller's `loadKeyringEncryptionKey` / `storeKeyringEncryptionKey` / `markPasswordChangeKeySyncPending` / `clearPasswordChangePhase`. - `AllowedActions = never`; the controller does not call `KeyringController`. ## Target state (Option B) - `AllowedActions` includes `KeyringController:verifyPassword`, `KeyringController:submitEncryptionKey`, `KeyringController:changePassword`, `KeyringController:exportEncryptionKey` (and `KeyringController:setLocked` if locking is folded in). -- `recoverPasswordChange({ globalPassword })` performs the full transaction: +- `reconcilePassword({ globalPassword })` performs the full transaction: 1. Resolve remote state for `SeedlessChangePending` via `resolvePasswordSyncState()` (which runs `#checkIsPasswordOutdated({ skipCache: true })`). - 2. Reconcile the Seedless side (`submitGlobalPassword` + `syncLatestGlobalPassword`) for `SeedlessCommitted` / `LocalKeyringPending`. + 2. Reconcile the Seedless side (internal chain unlock + local vault rewrite) for `SeedlessCommitted` / `LocalKeyringPending`. 3. Classify the local Keyring via `KeyringController:verifyPassword(newPassword)`. 4. Old-Keyring branch: `loadKeyringEncryptionKey` → `KeyringController:submitEncryptionKey` → `KeyringController:changePassword` → `KeyringController:exportEncryptionKey` → `storeKeyringEncryptionKey` → `markPasswordChangeKeySyncPending`. 5. New-Keyring branch: `KeyringController:exportEncryptionKey` → `storeKeyringEncryptionKey` → `markPasswordChangeKeySyncPending`. @@ -46,7 +46,7 @@ A single controller method performs the entire recovery for any set phase and re ### 2. Controller method -- Fold the Keyring-side steps into `recoverPasswordChange`. Keep the existing private helpers; replace the "return a plan" shape with a final-status shape. +- Fold the Keyring-side steps into `reconcilePassword`. Keep the existing private helpers; replace the "return a plan" shape with a final-status shape. - Preserve all existing invariants: - No retries of `changePassword` / `changeEncKey`; reconcile only via the password-sync flow. - Preserve the last known phase on failure; do not write `UNKNOWN` from the happy path. @@ -55,7 +55,7 @@ A single controller method performs the entire recovery for any set phase and re ### 3. Contracts and exports -- Update [0002](./0002-password-change-recovery-flow.md): the client contract shrinks to "call `recoverPasswordChange`, route on status". The Keyring-side client steps move to the controller. +- Update [0002](./0002-password-change-recovery-flow.md): the client contract shrinks to "call `reconcilePassword`, route on status". The Keyring-side client steps move to the controller. - Update the controller-side status and remaining-work notes in [0002](./0002-password-change-recovery-flow.md). - Re-export the new result/status types from `src/index.ts`. - Regenerate `SeedlessOnboardingController-method-action-types.ts` (the method signature change is picked up automatically). @@ -63,7 +63,7 @@ A single controller method performs the entire recovery for any set phase and re ### 4. Clients - Remove client-side Keyring-side recovery sequencing (the old-Keyring / new-Keyring branches). -- Keep: the single coordinator lock, wallet locking on error, unlock routing to call `recoverPasswordChange`, and UI per status. +- Keep: the single coordinator lock, wallet locking on error, unlock routing to call `reconcilePassword`, and UI per status. - Update client tests to assert against the new status-only result. ## Trade-offs and risks @@ -75,7 +75,7 @@ A single controller method performs the entire recovery for any set phase and re ## Test plan -- Controller unit tests for every phase, each branch (old/new Keyring), and each failure injection point (remote check error, `submitGlobalPassword` error, `verifyPassword` error, `submitEncryptionKey` error, `changePassword` error, `exportEncryptionKey` error, `storeKeyringEncryptionKey` error, remote key-sync error). +- Controller unit tests for every phase, each branch (old/new Keyring), and each failure injection point (remote check error, chain-unlock error, `verifyPassword` error, `submitEncryptionKey` error, `changePassword` error, `exportEncryptionKey` error, `storeKeyringEncryptionKey` error, remote key-sync error). - Assert the final status and the resulting `passwordChangePhase` for each. - Assert no `changePassword`/`changeEncKey` retry occurs on any recovery path. - Assert the controller lock is released on every failure path. @@ -85,6 +85,6 @@ A single controller method performs the entire recovery for any set phase and re 1. Land Option A and ship it; gather client integration feedback. 2. Add the `KeyringController` messenger dependency and mock wiring (behind no behavior change yet). -3. Fold the Keyring-side steps into `recoverPasswordChange`; change the return shape to final status. +3. Fold the Keyring-side steps into `reconcilePassword`; change the return shape to final status. 4. Update the recovery flow guide (0002), exports, and clients. 5. Run the full controller + client test suites; remove the now-dead client sequencing code. diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts index d445eb8b94a..4fa1799bdd3 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts @@ -183,33 +183,6 @@ export type SeedlessOnboardingControllerSetLockedAction = { handler: SeedlessOnboardingController['setLocked']; }; -/** - * Sync the latest global password to the controller. - * reset vault with latest globalPassword, - * persist the latest global password authPubKey - * - * @param params - The parameters for syncing the latest global password. - * @param params.globalPassword - The latest global password. - * @returns A promise that resolves to the success of the operation. - */ -export type SeedlessOnboardingControllerSyncLatestGlobalPasswordAction = { - type: `SeedlessOnboardingController:syncLatestGlobalPassword`; - handler: SeedlessOnboardingController['syncLatestGlobalPassword']; -}; - -/** - * @description Unlock the controller with the latest global password. - * - * @param params - The parameters for unlocking the controller. - * @param params.maxKeyChainLength - The maximum chain length of the pwd encryption keys. - * @param params.globalPassword - The latest global password. - * @returns A promise that resolves to the success of the operation. - */ -export type SeedlessOnboardingControllerSubmitGlobalPasswordAction = { - type: `SeedlessOnboardingController:submitGlobalPassword`; - handler: SeedlessOnboardingController['submitGlobalPassword']; -}; - /** * Check if the user is authenticated with the seedless onboarding flow by checking the token values in the state. * @@ -308,7 +281,7 @@ export type SeedlessOnboardingControllerMarkPasswordChangeKeySyncPendingAction = * - Other phases: return the next recovery step without mutating state. * * This method does not consume a password; the client prompts for the - * correct password and then calls `recoverPasswordChange`. + * correct password and then calls `reconcilePassword`. * * @param options - The options. * @param options.skipCache - Whether to bypass the outdated cache. Ignored @@ -322,11 +295,10 @@ export type SeedlessOnboardingControllerResolvePasswordSyncStateAction = { }; /** - * Reconcile the Seedless side of a password-change recovery — or a plain - * remote password sync — with the supplied password. + * Reconcile the local Seedless password with the remote password. * * For `SEEDLESS_COMMITTED` or `LOCAL_KEYRING_PENDING` it re-runs the existing - * password-sync flow (`submitGlobalPassword` + `syncLatestGlobalPassword`) + * password-sync flow (chain unlock + local vault rewrite) * with the new password and advances the phase to `LOCAL_KEYRING_PENDING`. * These operations are idempotent, so re-running them is safe whether or not * the local Seedless vault was already rewritten. The controller is left @@ -344,14 +316,14 @@ export type SeedlessOnboardingControllerResolvePasswordSyncStateAction = { * `KeyringController`. See * [0003](./docs/0003-controller-owned-password-change-recovery-plan.md). * - * @param params - The recovery parameters. - * @param params.globalPassword - The new global password. - * @returns The recovery result. On any failure the last known phase is + * @param params - The reconciliation parameters. + * @param params.globalPassword - The current global password. + * @returns The reconciliation result. On any failure the last known phase is * preserved and `PasswordChangeRecoveryStatus.Unknown` is returned. */ -export type SeedlessOnboardingControllerRecoverPasswordChangeAction = { - type: `SeedlessOnboardingController:recoverPasswordChange`; - handler: SeedlessOnboardingController['recoverPasswordChange']; +export type SeedlessOnboardingControllerReconcilePasswordAction = { + type: `SeedlessOnboardingController:reconcilePassword`; + handler: SeedlessOnboardingController['reconcilePassword']; }; /** @@ -459,8 +431,6 @@ export type SeedlessOnboardingControllerMethodActions = | SeedlessOnboardingControllerGetSecretDataBackupStateAction | SeedlessOnboardingControllerSubmitPasswordAction | SeedlessOnboardingControllerSetLockedAction - | SeedlessOnboardingControllerSyncLatestGlobalPasswordAction - | SeedlessOnboardingControllerSubmitGlobalPasswordAction | SeedlessOnboardingControllerGetIsUserAuthenticatedAction | SeedlessOnboardingControllerClearStateAction | SeedlessOnboardingControllerStoreKeyringEncryptionKeyAction @@ -468,7 +438,7 @@ export type SeedlessOnboardingControllerMethodActions = | SeedlessOnboardingControllerClearPasswordChangePhaseAction | SeedlessOnboardingControllerMarkPasswordChangeKeySyncPendingAction | SeedlessOnboardingControllerResolvePasswordSyncStateAction - | SeedlessOnboardingControllerRecoverPasswordChangeAction + | SeedlessOnboardingControllerReconcilePasswordAction | SeedlessOnboardingControllerRefreshAuthTokensAction | SeedlessOnboardingControllerRotateRefreshTokenAction | SeedlessOnboardingControllerRevokePendingRefreshTokensAction diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 9b5fc72f979..27e6b13cdeb 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -38,7 +38,6 @@ import { bytesToBase64, bytesToString, stringToBytes, - bigIntToHex, } from '@metamask/utils'; import { gcm } from '@noble/ciphers/aes'; import { utf8ToBytes } from '@noble/ciphers/utils'; @@ -73,7 +72,7 @@ import { SeedlessPasswordChangePhase, PasswordChangeRecoveryStatus, } from './constants.js'; -import { PasswordSyncError, RecoveryError } from './errors.js'; +import { RecoveryError } from './errors.js'; import { SecretMetadata } from './SecretMetadata.js'; import { SeedlessOnboardingController, @@ -4917,7 +4916,7 @@ describe('SeedlessOnboardingController', () => { }); }); - describe('recoverPasswordChange', () => { + describe('reconcilePassword', () => { const OLD_PASSWORD = 'old-mock-password'; const NEW_PASSWORD = 'new-mock-password'; @@ -4931,7 +4930,7 @@ describe('SeedlessOnboardingController', () => { }, async ({ toprfClient, controller }) => { mockFetchAuthPubKey(toprfClient, base64ToBytes(MOCK_AUTH_PUB_KEY)); - const result = await controller.recoverPasswordChange({ + const result = await controller.reconcilePassword({ globalPassword: NEW_PASSWORD, }); expect(result).toBe(PasswordChangeRecoveryStatus.InSync); @@ -4950,7 +4949,7 @@ describe('SeedlessOnboardingController', () => { }), }, async ({ controller }) => { - const result = await controller.recoverPasswordChange({ + const result = await controller.reconcilePassword({ globalPassword: NEW_PASSWORD, }); expect(result).toBe(PasswordChangeRecoveryStatus.Unknown); @@ -4971,7 +4970,7 @@ describe('SeedlessOnboardingController', () => { }), }, async ({ controller }) => { - const result = await controller.recoverPasswordChange({ + const result = await controller.reconcilePassword({ globalPassword: NEW_PASSWORD, }); expect(result).toBe(PasswordChangeRecoveryStatus.SyncKey); @@ -4989,7 +4988,7 @@ describe('SeedlessOnboardingController', () => { }), }, async ({ controller }) => { - const result = await controller.recoverPasswordChange({ + const result = await controller.reconcilePassword({ globalPassword: NEW_PASSWORD, }); expect(result).toBe(PasswordChangeRecoveryStatus.Unknown); @@ -5008,7 +5007,7 @@ describe('SeedlessOnboardingController', () => { }), }, async ({ controller }) => { - const result = await controller.recoverPasswordChange({ + const result = await controller.reconcilePassword({ globalPassword: NEW_PASSWORD, }); expect(result).toBe(PasswordChangeRecoveryStatus.InSync); @@ -5035,6 +5034,10 @@ describe('SeedlessOnboardingController', () => { MOCK_SEED_PHRASE, MOCK_KEYRING_ID, ); + await baseMessenger.call( + 'SeedlessOnboardingController:storeKeyringEncryptionKey', + MOCK_KEYRING_ENCRYPTION_KEY, + ); // Remote auth pub key differs from the local one -> outdated, so // the no-phase branch re-checks and runs the password-sync flow. @@ -5044,7 +5047,7 @@ describe('SeedlessOnboardingController', () => { ); // Mock the password-sync flow for the new password. recoverEncKey - // is called by both submitGlobalPassword and syncLatestGlobalPassword. + // is called by both chain unlock and local vault rewrite. const mockToprfEncryptor = createMockToprfEncryptor(); const encKey = mockToprfEncryptor.deriveEncKey(NEW_PASSWORD); const pwEncKey = mockToprfEncryptor.derivePwEncKey(NEW_PASSWORD); @@ -5063,7 +5066,7 @@ describe('SeedlessOnboardingController', () => { pwEncKey: mockToprfEncryptor.derivePwEncKey(OLD_PASSWORD), }); - const result = await controller.recoverPasswordChange({ + const result = await controller.reconcilePassword({ globalPassword: NEW_PASSWORD, }); @@ -5073,6 +5076,11 @@ describe('SeedlessOnboardingController', () => { expect(controller.state.passwordChangePhase).toBe( SeedlessPasswordChangePhase.LocalKeyringPending, ); + // Vault rewrite must re-wrap the stored Keyring encryption key so + // the old-Keyring branch can load it after sync. + expect(await controller.loadKeyringEncryptionKey()).toBe( + MOCK_KEYRING_ENCRYPTION_KEY, + ); }, ); }); @@ -5090,7 +5098,7 @@ describe('SeedlessOnboardingController', () => { .spyOn(toprfClient, 'fetchAuthPubKey') .mockRejectedValueOnce(new Error('Network error')); - const result = await controller.recoverPasswordChange({ + const result = await controller.reconcilePassword({ globalPassword: NEW_PASSWORD, }); @@ -5119,9 +5127,13 @@ describe('SeedlessOnboardingController', () => { MOCK_SEED_PHRASE, MOCK_KEYRING_ID, ); + await baseMessenger.call( + 'SeedlessOnboardingController:storeKeyringEncryptionKey', + MOCK_KEYRING_ENCRYPTION_KEY, + ); // Mock the password-sync flow for the new password. recoverEncKey - // is called by both submitGlobalPassword and syncLatestGlobalPassword. + // is called by both chain unlock and local vault rewrite. const mockToprfEncryptor = createMockToprfEncryptor(); const encKey = mockToprfEncryptor.deriveEncKey(NEW_PASSWORD); const pwEncKey = mockToprfEncryptor.derivePwEncKey(NEW_PASSWORD); @@ -5140,7 +5152,7 @@ describe('SeedlessOnboardingController', () => { pwEncKey: mockToprfEncryptor.derivePwEncKey(OLD_PASSWORD), }); - const result = await controller.recoverPasswordChange({ + const result = await controller.reconcilePassword({ globalPassword: NEW_PASSWORD, }); @@ -5148,6 +5160,52 @@ describe('SeedlessOnboardingController', () => { expect(controller.state.passwordChangePhase).toBe( SeedlessPasswordChangePhase.LocalKeyringPending, ); + expect(await controller.loadKeyringEncryptionKey()).toBe( + MOCK_KEYRING_ENCRYPTION_KEY, + ); + }, + ); + }); + + it('reconciles the Seedless side when no Keyring encryption key is stored', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: SeedlessPasswordChangePhase.SeedlessCommitted, + }), + }, + async ({ controller, toprfClient, baseMessenger }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + baseMessenger, + OLD_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + const mockToprfEncryptor = createMockToprfEncryptor(); + jest.spyOn(toprfClient, 'recoverEncKey').mockResolvedValue({ + encKey: mockToprfEncryptor.deriveEncKey(NEW_PASSWORD), + authKeyPair: mockToprfEncryptor.deriveAuthKeyPair(NEW_PASSWORD), + pwEncKey: mockToprfEncryptor.derivePwEncKey(NEW_PASSWORD), + rateLimitResetResult: Promise.resolve(), + keyShareIndex: 1, + }); + jest.spyOn(toprfClient, 'recoverPwEncKey').mockResolvedValueOnce({ + pwEncKey: mockToprfEncryptor.derivePwEncKey(OLD_PASSWORD), + }); + + expect( + await controller.reconcilePassword({ + globalPassword: NEW_PASSWORD, + }), + ).toBe(PasswordChangeRecoveryStatus.ReconcileKeyring); + expect( + controller.state.encryptedKeyringEncryptionKey, + ).toBeUndefined(); }, ); }); @@ -5176,7 +5234,7 @@ describe('SeedlessOnboardingController', () => { .spyOn(toprfClient, 'recoverPwEncKey') .mockRejectedValueOnce(new Error('recover failed')); - const result = await controller.recoverPasswordChange({ + const result = await controller.reconcilePassword({ globalPassword: NEW_PASSWORD, }); @@ -5188,6 +5246,217 @@ describe('SeedlessOnboardingController', () => { }, ); }); + + it('returns unknown when the password-key chain limit is exceeded', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: SeedlessPasswordChangePhase.SeedlessCommitted, + }), + }, + async ({ controller, toprfClient, baseMessenger }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + baseMessenger, + OLD_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + mockRecoverEncKey(toprfClient, NEW_PASSWORD); + jest + .spyOn(toprfClient, 'recoverPwEncKey') + .mockRejectedValueOnce( + new TOPRFError( + TOPRFErrorCode.MaxKeyChainLengthExceeded, + 'Max key chain length exceeded', + ), + ); + + expect( + await controller.reconcilePassword({ + globalPassword: NEW_PASSWORD, + }), + ).toBe(PasswordChangeRecoveryStatus.Unknown); + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.SeedlessCommitted, + ); + }, + ); + }); + + it('returns unknown when recovered vault credentials are stale', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: SeedlessPasswordChangePhase.SeedlessCommitted, + }), + }, + async ({ controller, toprfClient, baseMessenger }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + baseMessenger, + OLD_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + const staleState = { + ...controller.state, + vaultEncryptionSalt: 'stale-salt', + }; + await controller.setLocked(); + ( + controller as unknown as { + update: ( + callback: (state: SeedlessOnboardingControllerState) => void, + ) => void; + } + ).update((state) => { + state.vaultEncryptionSalt = staleState.vaultEncryptionSalt; + }); + + mockRecoverEncKey(toprfClient, NEW_PASSWORD); + jest.spyOn(toprfClient, 'recoverPwEncKey').mockResolvedValueOnce({ + pwEncKey: createMockToprfEncryptor().derivePwEncKey(OLD_PASSWORD), + }); + + expect( + await controller.reconcilePassword({ + globalPassword: NEW_PASSWORD, + }), + ).toBe(PasswordChangeRecoveryStatus.Unknown); + }, + ); + }); + + it('returns unknown when the stored Seedless encryption key is missing', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: SeedlessPasswordChangePhase.SeedlessCommitted, + }), + }, + async ({ controller, toprfClient, baseMessenger }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + baseMessenger, + OLD_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + await controller.setLocked(); + ( + controller as unknown as { + update: ( + callback: (state: SeedlessOnboardingControllerState) => void, + ) => void; + } + ).update((state) => { + delete state.encryptedSeedlessEncryptionKey; + }); + mockRecoverEncKey(toprfClient, NEW_PASSWORD); + jest.spyOn(toprfClient, 'recoverPwEncKey').mockResolvedValueOnce({ + pwEncKey: createMockToprfEncryptor().derivePwEncKey(OLD_PASSWORD), + }); + + expect( + await controller.reconcilePassword({ + globalPassword: NEW_PASSWORD, + }), + ).toBe(PasswordChangeRecoveryStatus.Unknown); + }, + ); + }); + + it('returns unknown when the chain unlock reports a TOPRF error', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: SeedlessPasswordChangePhase.SeedlessCommitted, + }), + }, + async ({ controller, toprfClient, baseMessenger }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + baseMessenger, + OLD_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + mockRecoverEncKey(toprfClient, NEW_PASSWORD); + jest + .spyOn(toprfClient, 'recoverPwEncKey') + .mockRejectedValueOnce( + new TOPRFError( + TOPRFErrorCode.CouldNotFetchPassword, + 'Could not fetch password', + ), + ); + + expect( + await controller.reconcilePassword({ + globalPassword: NEW_PASSWORD, + }), + ).toBe(PasswordChangeRecoveryStatus.Unknown); + }, + ); + }); + + it('returns unknown when token refresh fails during chain unlock', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: SeedlessPasswordChangePhase.SeedlessCommitted, + }), + }, + async ({ + controller, + toprfClient, + baseMessenger, + mockRefreshJWTToken, + }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + baseMessenger, + OLD_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + mockRecoverEncKey(toprfClient, NEW_PASSWORD); + jest + .spyOn(toprfClient, 'recoverPwEncKey') + .mockRejectedValueOnce( + new TOPRFError( + TOPRFErrorCode.AuthTokenExpired, + 'Auth token expired', + ), + ); + mockRefreshJWTToken.mockRejectedValueOnce( + new Error('Failed to refresh token'), + ); + + expect( + await controller.reconcilePassword({ + globalPassword: NEW_PASSWORD, + }), + ).toBe(PasswordChangeRecoveryStatus.Unknown); + }, + ); + }); }); describe('password-change lifecycle neutrality of key storage', () => { @@ -5773,15 +6042,25 @@ describe('SeedlessOnboardingController', () => { const GLOBAL_PASSWORD = 'global-password'; const RECOVERED_PASSWORD = 'recovered-password'; - it('should store and recover keyring encryption key', async () => { + it('should throw if key not set', async () => { await withController( { state: getMockInitialControllerState({ withMockAuthenticatedUser: true, withMockAuthPubKey: true, + vault: 'mock-vault', }), }, async ({ controller, toprfClient, baseMessenger }) => { + await expect( + baseMessenger.call( + 'SeedlessOnboardingController:storeKeyringEncryptionKey', + '', + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.WrongPasswordType, + ); + // Setup and store keyring encryption key. await mockCreateToprfKeyAndBackupSeedPhrase( toprfClient, @@ -5792,173 +6071,18 @@ describe('SeedlessOnboardingController', () => { MOCK_KEYRING_ID, ); - await baseMessenger.call( - 'SeedlessOnboardingController:storeKeyringEncryptionKey', - MOCK_KEYRING_ENCRYPTION_KEY, - ); - - // Mock recoverEncKey for the global password - const mockToprfEncryptor = createMockToprfEncryptor(); - const encKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); - const pwEncKey = mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); - const authKeyPair = - mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); - jest.spyOn(toprfClient, 'recoverEncKey').mockResolvedValueOnce({ - encKey, - authKeyPair, - pwEncKey, - rateLimitResetResult: Promise.resolve(), - keyShareIndex: 1, - }); - - // Mock toprfClient.recoverPassword - const recoveredPwEncKey = - mockToprfEncryptor.derivePwEncKey(RECOVERED_PASSWORD); - jest.spyOn(toprfClient, 'recoverPwEncKey').mockResolvedValueOnce({ - pwEncKey: recoveredPwEncKey, - }); - - await baseMessenger.call('SeedlessOnboardingController:setLocked'); - - await baseMessenger.call( - 'SeedlessOnboardingController:submitGlobalPassword', - { - globalPassword: GLOBAL_PASSWORD, - }, - ); - - const keyringEncryptionKey = await baseMessenger.call( - 'SeedlessOnboardingController:loadKeyringEncryptionKey', - ); - - expect(keyringEncryptionKey).toStrictEqual( - MOCK_KEYRING_ENCRYPTION_KEY, + await expect( + baseMessenger.call( + 'SeedlessOnboardingController:loadKeyringEncryptionKey', + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.EncryptedKeyringEncryptionKeyNotSet, ); - expect(toprfClient.recoverEncKey).toHaveBeenCalled(); - expect(toprfClient.recoverPwEncKey).toHaveBeenCalled(); }, ); }); - it('should throw if key not set', async () => { - await withController( - { - state: getMockInitialControllerState({ - withMockAuthenticatedUser: true, - withMockAuthPubKey: true, - vault: 'mock-vault', - }), - }, - async ({ controller, toprfClient, baseMessenger }) => { - await expect( - baseMessenger.call( - 'SeedlessOnboardingController:storeKeyringEncryptionKey', - '', - ), - ).rejects.toThrow( - SeedlessOnboardingControllerErrorMessage.WrongPasswordType, - ); - - // Setup and store keyring encryption key. - await mockCreateToprfKeyAndBackupSeedPhrase( - toprfClient, - controller, - baseMessenger, - RECOVERED_PASSWORD, - MOCK_SEED_PHRASE, - MOCK_KEYRING_ID, - ); - - await expect( - baseMessenger.call( - 'SeedlessOnboardingController:loadKeyringEncryptionKey', - ), - ).rejects.toThrow( - SeedlessOnboardingControllerErrorMessage.EncryptedKeyringEncryptionKeyNotSet, - ); - }, - ); - }); - - it('should store and load keyring encryption key', async () => { - await withController( - { - state: getMockInitialControllerState({ - withMockAuthenticatedUser: true, - withMockAuthPubKey: true, - }), - }, - async ({ controller, toprfClient, baseMessenger }) => { - // Setup and store keyring encryption key. - await mockCreateToprfKeyAndBackupSeedPhrase( - toprfClient, - controller, - baseMessenger, - RECOVERED_PASSWORD, - MOCK_SEED_PHRASE, - MOCK_KEYRING_ID, - ); - - await baseMessenger.call( - 'SeedlessOnboardingController:storeKeyringEncryptionKey', - MOCK_KEYRING_ENCRYPTION_KEY, - ); - - const result = await baseMessenger.call( - 'SeedlessOnboardingController:loadKeyringEncryptionKey', - ); - expect(result).toStrictEqual(MOCK_KEYRING_ENCRYPTION_KEY); - }, - ); - }); - - it('should load keyring encryption key after change password', async () => { - await withController( - { - state: getMockInitialControllerState({ - withMockAuthenticatedUser: true, - withMockAuthPubKey: true, - }), - }, - async ({ controller, toprfClient, baseMessenger }) => { - // Setup and store keyring encryption key. - await mockCreateToprfKeyAndBackupSeedPhrase( - toprfClient, - controller, - baseMessenger, - RECOVERED_PASSWORD, - MOCK_SEED_PHRASE, - MOCK_KEYRING_ID, - ); - - await baseMessenger.call( - 'SeedlessOnboardingController:storeKeyringEncryptionKey', - MOCK_KEYRING_ENCRYPTION_KEY, - ); - - await mockChangePassword( - controller, - toprfClient, - RECOVERED_PASSWORD, - GLOBAL_PASSWORD, - ); - - await baseMessenger.call( - 'SeedlessOnboardingController:changePassword', - GLOBAL_PASSWORD, - RECOVERED_PASSWORD, - ); - - const result = await baseMessenger.call( - 'SeedlessOnboardingController:loadKeyringEncryptionKey', - ); - - expect(result).toStrictEqual(MOCK_KEYRING_ENCRYPTION_KEY); - }, - ); - }); - - it('should recover keyring encryption key after change password', async () => { + it('should store and load keyring encryption key', async () => { await withController( { state: getMockInitialControllerState({ @@ -5975,730 +6099,63 @@ describe('SeedlessOnboardingController', () => { RECOVERED_PASSWORD, MOCK_SEED_PHRASE, MOCK_KEYRING_ID, - ); - - await baseMessenger.call( - 'SeedlessOnboardingController:storeKeyringEncryptionKey', - MOCK_KEYRING_ENCRYPTION_KEY, - ); - - await mockChangePassword( - controller, - toprfClient, - RECOVERED_PASSWORD, - GLOBAL_PASSWORD, - ); - - await baseMessenger.call( - 'SeedlessOnboardingController:changePassword', - GLOBAL_PASSWORD, - RECOVERED_PASSWORD, - ); - - // Mock recoverEncKey for the global password - const mockToprfEncryptor = createMockToprfEncryptor(); - const encKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); - const pwEncKey = mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); - const authKeyPair = - mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); - jest.spyOn(toprfClient, 'recoverEncKey').mockResolvedValueOnce({ - encKey, - pwEncKey, - authKeyPair, - rateLimitResetResult: Promise.resolve(), - keyShareIndex: 1, - }); - - // Mock toprfClient.recoverPwEncKey - const recoveredPwEncKey = - mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); - jest.spyOn(toprfClient, 'recoverPwEncKey').mockResolvedValueOnce({ - pwEncKey: recoveredPwEncKey, - }); - - await baseMessenger.call('SeedlessOnboardingController:setLocked'); - - await baseMessenger.call( - 'SeedlessOnboardingController:submitGlobalPassword', - { - globalPassword: GLOBAL_PASSWORD, - }, - ); - - const keyringEncryptionKey = await baseMessenger.call( - 'SeedlessOnboardingController:loadKeyringEncryptionKey', - ); - - expect(keyringEncryptionKey).toStrictEqual( - MOCK_KEYRING_ENCRYPTION_KEY, - ); - }, - ); - }); - - it('should throw if encryptedKeyringEncryptionKey not set', async () => { - await withController( - { - state: getMockInitialControllerState({ - withMockAuthenticatedUser: true, - withMockAuthPubKey: true, - }), - }, - async ({ toprfClient, baseMessenger }) => { - // Mock recoverEncKey for the global password - const mockToprfEncryptor = createMockToprfEncryptor(); - const encKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); - const pwEncKey = mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); - const authKeyPair = - mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); - jest.spyOn(toprfClient, 'recoverEncKey').mockResolvedValueOnce({ - encKey, - pwEncKey, - authKeyPair, - rateLimitResetResult: Promise.resolve(), - keyShareIndex: 1, - }); - - // Mock toprfClient.recoverPwEncKey - const recoveredPwEncKey = - mockToprfEncryptor.derivePwEncKey(RECOVERED_PASSWORD); - jest.spyOn(toprfClient, 'recoverPwEncKey').mockResolvedValueOnce({ - pwEncKey: recoveredPwEncKey, - }); - - await expect( - baseMessenger.call( - 'SeedlessOnboardingController:submitGlobalPassword', - { - globalPassword: GLOBAL_PASSWORD, - }, - ), - ).rejects.toThrow( - SeedlessOnboardingControllerErrorMessage.CouldNotRecoverPassword, - ); - }, - ); - }); - - it('should throw SRPNotBackedUpError if no authPubKey in state', async () => { - await withController( - { - state: getMockInitialControllerState({}), - }, - async ({ baseMessenger }) => { - await expect( - baseMessenger.call( - 'SeedlessOnboardingController:submitGlobalPassword', - { - globalPassword: GLOBAL_PASSWORD, - }, - ), - ).rejects.toThrow( - SeedlessOnboardingControllerErrorMessage.SRPNotBackedUpError, - ); - }, - ); - }); - - it('should propagate errors from recoverEncKey', async () => { - await withController( - { - state: getMockInitialControllerState({ - withMockAuthenticatedUser: true, - withMockAuthPubKey: true, - }), - }, - async ({ toprfClient, baseMessenger }) => { - jest - .spyOn(toprfClient, 'recoverEncKey') - .mockRejectedValueOnce( - new TOPRFError( - TOPRFErrorCode.CouldNotDeriveEncryptionKey, - 'Could not derive encryption key', - ), - ); - - await expect( - baseMessenger.call( - 'SeedlessOnboardingController:submitGlobalPassword', - { - globalPassword: GLOBAL_PASSWORD, - }, - ), - ).rejects.toStrictEqual( - new RecoveryError( - SeedlessOnboardingControllerErrorMessage.IncorrectPassword, - ), - ); - }, - ); - }); - - it('should propagate errors from toprfClient.recoverPassword', async () => { - await withController( - { - state: getMockInitialControllerState({ - withMockAuthenticatedUser: true, - withMockAuthPubKey: true, - }), - }, - async ({ toprfClient, baseMessenger }) => { - const mockToprfEncryptor = createMockToprfEncryptor(); - const encKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); - const pwEncKey = mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); - const authKeyPair = - mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); - jest.spyOn(toprfClient, 'recoverEncKey').mockResolvedValueOnce({ - encKey, - pwEncKey, - authKeyPair, - rateLimitResetResult: Promise.resolve(), - keyShareIndex: 1, - }); - - jest - .spyOn(toprfClient, 'recoverPwEncKey') - .mockRejectedValueOnce( - new TOPRFError( - TOPRFErrorCode.CouldNotFetchPassword, - 'Could not fetch password', - ), - ); - - await expect( - baseMessenger.call( - 'SeedlessOnboardingController:submitGlobalPassword', - { - globalPassword: GLOBAL_PASSWORD, - }, - ), - ).rejects.toStrictEqual( - new PasswordSyncError( - SeedlessOnboardingControllerErrorMessage.CouldNotRecoverPassword, - ), - ); - }, - ); - }); - - it('should not propagate unknown errors from #toprfClient.recoverPassword', async () => { - await withController( - { - state: getMockInitialControllerState({ - withMockAuthenticatedUser: true, - withMockAuthPubKey: true, - }), - }, - async ({ toprfClient, baseMessenger }) => { - const mockToprfEncryptor = createMockToprfEncryptor(); - const encKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); - const pwEncKey = mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); - const authKeyPair = - mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); - jest.spyOn(toprfClient, 'recoverEncKey').mockResolvedValueOnce({ - encKey, - pwEncKey, - authKeyPair, - rateLimitResetResult: Promise.resolve(), - keyShareIndex: 1, - }); - - jest - .spyOn(toprfClient, 'recoverPwEncKey') - .mockRejectedValueOnce(new Error('Unknown error')); - - await expect( - baseMessenger.call( - 'SeedlessOnboardingController:submitGlobalPassword', - { - globalPassword: GLOBAL_PASSWORD, - }, - ), - ).rejects.toStrictEqual( - new PasswordSyncError( - SeedlessOnboardingControllerErrorMessage.CouldNotRecoverPassword, - ), - ); - }, - ); - }); - - it('should throw MaxKeyChainLengthExceeded error when max key chain length is exceeded', async () => { - await withController( - { - state: getMockInitialControllerState({ - withMockAuthenticatedUser: true, - withMockAuthPubKey: true, - }), - }, - async ({ toprfClient, baseMessenger }) => { - const mockToprfEncryptor = createMockToprfEncryptor(); - const encKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); - const pwEncKey = mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); - const authKeyPair = - mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); - - // Mock recoverEncKey to succeed - jest.spyOn(toprfClient, 'recoverEncKey').mockResolvedValueOnce({ - encKey, - pwEncKey, - authKeyPair, - rateLimitResetResult: Promise.resolve(), - keyShareIndex: 1, - }); - - // Mock recoverPwEncKey to throw max key chain length error - jest - .spyOn(toprfClient, 'recoverPwEncKey') - .mockRejectedValueOnce( - new TOPRFError( - TOPRFErrorCode.MaxKeyChainLengthExceeded, - 'Max key chain length exceeded', - ), - ); - - await expect( - baseMessenger.call( - 'SeedlessOnboardingController:submitGlobalPassword', - { - globalPassword: GLOBAL_PASSWORD, - }, - ), - ).rejects.toThrow( - SeedlessOnboardingControllerErrorMessage.MaxKeyChainLengthExceeded, - ); - }, - ); - }); - }); - - describe('syncLatestGlobalPassword', () => { - const OLD_PASSWORD = 'old-mock-password'; - const GLOBAL_PASSWORD = 'new-global-password'; - const mockToprfEncryptor = createMockToprfEncryptor(); - let MOCK_VAULT: string; - let MOCK_VAULT_ENCRYPTION_KEY: string; - let MOCK_VAULT_ENCRYPTION_SALT: string; - let INITIAL_AUTH_PUB_KEY: string; - let initialAuthKeyPair: KeyPair; // Store initial keypair for vault creation - let initialEncKey: Uint8Array; // Store initial encKey for vault creation - let initialPwEncKey: Uint8Array; // Store initial pwEncKey for vault creation - let initialEncryptedSeedlessEncryptionKey: Uint8Array; // Store initial encryptedSeedlessEncryptionKey for vault creation - let newEncKey: Uint8Array; - let newPwEncKey: Uint8Array; - let newAuthKeyPair: KeyPair; - - // Generate initial keys and vault state before tests run - beforeAll(async () => { - initialEncKey = mockToprfEncryptor.deriveEncKey(OLD_PASSWORD); - initialPwEncKey = mockToprfEncryptor.derivePwEncKey(OLD_PASSWORD); - initialAuthKeyPair = mockToprfEncryptor.deriveAuthKeyPair(OLD_PASSWORD); - INITIAL_AUTH_PUB_KEY = bytesToBase64(initialAuthKeyPair.pk); - - const mockResult = await createMockVault( - initialEncKey, - initialPwEncKey, - initialAuthKeyPair, - OLD_PASSWORD, - revokeToken, - ); - - MOCK_VAULT = mockResult.encryptedMockVault; - MOCK_VAULT_ENCRYPTION_KEY = mockResult.vaultEncryptionKey; - MOCK_VAULT_ENCRYPTION_SALT = mockResult.vaultEncryptionSalt; - - const aes = managedNonce(gcm)(initialPwEncKey); - initialEncryptedSeedlessEncryptionKey = aes.encrypt( - utf8ToBytes(MOCK_VAULT_ENCRYPTION_KEY), - ); - }); - - // Remove beforeEach as setup is done in beforeAll now - beforeEach(() => { - // Mock recoverEncKey for the new global password - newEncKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); - newPwEncKey = mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); - newAuthKeyPair = mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); - }); - - it('should successfully sync the latest global password', async () => { - const b64EncKey = bytesToBase64(initialEncryptedSeedlessEncryptionKey); - await withController( - { - // Pass the pre-generated state values - state: getMockInitialControllerState({ - withMockAuthenticatedUser: true, - authPubKey: INITIAL_AUTH_PUB_KEY, // Use the base64 encoded key - vault: MOCK_VAULT, - vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, - vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, - withMockAuthPubKey: true, - encryptedSeedlessEncryptionKey: b64EncKey, - }), - }, - async ({ controller, toprfClient, encryptor, baseMessenger }) => { - // Unlock controller first - requires vaultEncryptionKey/Salt or password - // Since we provide key/salt in state, submitPassword isn't strictly needed here - // but we keep it to match the method's requirement of being unlocked - // We'll use the key/salt implicitly by not providing password to unlockVaultAndGetBackupEncKey - await baseMessenger.call( - 'SeedlessOnboardingController:submitPassword', - OLD_PASSWORD, - ); // Unlock using the standard method - - const recoverEncKeySpy = jest.spyOn(toprfClient, 'recoverEncKey'); - const encryptorSpy = jest.spyOn(encryptor, 'encryptWithDetail'); - - recoverEncKeySpy.mockResolvedValueOnce({ - encKey: newEncKey, - pwEncKey: newPwEncKey, - authKeyPair: newAuthKeyPair, - rateLimitResetResult: Promise.resolve(), - keyShareIndex: 1, - }); - - // We still need verifyPassword to work conceptually, even if unlock is bypassed - // verifyPasswordSpy.mockResolvedValueOnce(); // Don't mock, let the real one run inside syncLatestGlobalPassword - - await baseMessenger.call('SeedlessOnboardingController:setLocked'); - - // Mock recoverEncKey for the global password - const encKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); - const pwEncKey = mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); - const authKeyPair = - mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); - jest.spyOn(toprfClient, 'recoverEncKey').mockResolvedValueOnce({ - encKey, - pwEncKey, - authKeyPair, - rateLimitResetResult: Promise.resolve(), - keyShareIndex: 1, - }); - - // Mock toprfClient.recoverPwEncKey - const recoveredPwEncKey = - mockToprfEncryptor.derivePwEncKey(OLD_PASSWORD); - jest.spyOn(toprfClient, 'recoverPwEncKey').mockResolvedValueOnce({ - pwEncKey: recoveredPwEncKey, - }); - - await baseMessenger.call( - 'SeedlessOnboardingController:submitGlobalPassword', - { - globalPassword: GLOBAL_PASSWORD, - }, - ); - - await baseMessenger.call( - 'SeedlessOnboardingController:syncLatestGlobalPassword', - { - globalPassword: GLOBAL_PASSWORD, - }, - ); - - // Assertions - expect(recoverEncKeySpy).toHaveBeenCalledWith( - expect.objectContaining({ password: GLOBAL_PASSWORD }), - ); - - // Check if vault was re-encrypted with the new password and keys - const expectedSerializedVaultData = JSON.stringify({ - toprfEncryptionKey: bytesToBase64(newEncKey), - toprfPwEncryptionKey: bytesToBase64(newPwEncKey), - toprfAuthKeyPair: JSON.stringify({ - sk: bigIntToHex(newAuthKeyPair.sk), - pk: bytesToBase64(newAuthKeyPair.pk), - }), - revokeToken: controller.state.revokeToken, - accessToken: controller.state.accessToken, - }); - expect(encryptorSpy).toHaveBeenCalledWith( - GLOBAL_PASSWORD, - expectedSerializedVaultData, - ); - - // Check if authPubKey was updated in state - expect(controller.state.authPubKey).toBe( - bytesToBase64(newAuthKeyPair.pk), - ); - // Check if vault content actually changed - expect(controller.state.vault).not.toBe(MOCK_VAULT); - }, - ); - }); - - it('should persist the latest accessToken when state token is newer than vault token', async () => { - const futureExp = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now - const newerAccessToken = createMockJWTToken({ exp: futureExp }); // refreshed accessToken - const b64EncKey = bytesToBase64(initialEncryptedSeedlessEncryptionKey); - - await withController( - { - state: getMockInitialControllerState({ - withMockAuthenticatedUser: true, - authPubKey: INITIAL_AUTH_PUB_KEY, // Use the base64 encoded key - vault: MOCK_VAULT, - vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, - vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, - withMockAuthPubKey: true, - encryptedSeedlessEncryptionKey: b64EncKey, - }), - }, - async ({ - controller, - toprfClient, - encryptor, - mockRefreshJWTToken, - baseMessenger, - }) => { - // Unlock controller first - requires vaultEncryptionKey/Salt or password - // Since we provide key/salt in state, submitPassword isn't strictly needed here - // but we keep it to match the method's requirement of being unlocked - // We'll use the key/salt implicitly by not providing password to unlockVaultAndGetBackupEncKey - await baseMessenger.call( - 'SeedlessOnboardingController:submitPassword', - OLD_PASSWORD, - ); // Unlock using the standard method - - const recoverEncKeySpy = jest.spyOn(toprfClient, 'recoverEncKey'); - const encryptorSpy = jest.spyOn(encryptor, 'encryptWithDetail'); - - recoverEncKeySpy.mockResolvedValueOnce({ - encKey: newEncKey, - pwEncKey: newPwEncKey, - authKeyPair: newAuthKeyPair, - rateLimitResetResult: Promise.resolve(), - keyShareIndex: 1, - }); - // Lock the wallet - await baseMessenger.call('SeedlessOnboardingController:setLocked'); - - // The following mocks are to simulate the token expiry and refresh. - // mock token expiry - jest - .spyOn(controller, 'checkNodeAuthTokenExpired') - .mockReturnValueOnce(true); - // mock token refresh - mockRefreshJWTToken.mockResolvedValueOnce({ - idTokens: ['newIdToken'], - accessToken: newerAccessToken, - metadataAccessToken: 'new-metadata-access-token', - }); - // mock toprfClient.authenticate which is called to generate new NodeAuthTokens - jest.spyOn(toprfClient, 'authenticate').mockResolvedValueOnce({ - nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, - isNewUser: false, - }); - - // Mock recoverEncKey for the global password - const encKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); - const pwEncKey = mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); - const authKeyPair = - mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); - jest.spyOn(toprfClient, 'recoverEncKey').mockResolvedValueOnce({ - encKey, - pwEncKey, - authKeyPair, - rateLimitResetResult: Promise.resolve(), - keyShareIndex: 1, - }); - - // Mock toprfClient.recoverPwEncKey - const recoveredPwEncKey = - mockToprfEncryptor.derivePwEncKey(OLD_PASSWORD); - jest.spyOn(toprfClient, 'recoverPwEncKey').mockResolvedValueOnce({ - pwEncKey: recoveredPwEncKey, - }); - - await baseMessenger.call( - 'SeedlessOnboardingController:submitGlobalPassword', - { - globalPassword: GLOBAL_PASSWORD, - }, - ); - - // assert that the newer access token is set in the state - expect(controller.state.accessToken).toBe(newerAccessToken); - - await baseMessenger.call( - 'SeedlessOnboardingController:syncLatestGlobalPassword', - { - globalPassword: GLOBAL_PASSWORD, - }, - ); - - // Check if vault was re-encrypted with the new password and keys - const expectedSerializedVaultData = JSON.stringify({ - toprfEncryptionKey: bytesToBase64(newEncKey), - toprfPwEncryptionKey: bytesToBase64(newPwEncKey), - toprfAuthKeyPair: JSON.stringify({ - sk: bigIntToHex(newAuthKeyPair.sk), - pk: bytesToBase64(newAuthKeyPair.pk), - }), - revokeToken: controller.state.revokeToken, - accessToken: newerAccessToken, - }); - expect(encryptorSpy).toHaveBeenCalledWith( - GLOBAL_PASSWORD, - expectedSerializedVaultData, - ); - }, - ); - }); - - it('should throw an error if recovering the encryption key for the global password fails', async () => { - await withController( - { - state: getMockInitialControllerState({ - withMockAuthenticatedUser: true, - authPubKey: INITIAL_AUTH_PUB_KEY, - vault: MOCK_VAULT, - vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, - vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, - }), - }, - async ({ toprfClient, baseMessenger }) => { - // Unlock controller first - await baseMessenger.call( - 'SeedlessOnboardingController:submitPassword', - OLD_PASSWORD, - ); - - const recoverEncKeySpy = jest - .spyOn(toprfClient, 'recoverEncKey') - .mockRejectedValueOnce( - new RecoveryError( - SeedlessOnboardingControllerErrorMessage.LoginFailedError, - ), - ); - - await expect( - baseMessenger.call( - 'SeedlessOnboardingController:syncLatestGlobalPassword', - { - globalPassword: GLOBAL_PASSWORD, - }, - ), - ).rejects.toThrow( - SeedlessOnboardingControllerErrorMessage.LoginFailedError, - ); - - expect(recoverEncKeySpy).toHaveBeenCalledWith( - expect.objectContaining({ password: GLOBAL_PASSWORD }), - ); - }, - ); - }); - - it('should throw an error if creating the new vault fails', async () => { - const state = getMockInitialControllerState({ - withMockAuthenticatedUser: true, - authPubKey: INITIAL_AUTH_PUB_KEY, - vault: MOCK_VAULT, - vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, - vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, - }); - delete state.revokeToken; - delete state.accessToken; - - await withController( - { - state, - }, - async ({ toprfClient, encryptor, baseMessenger }) => { - // Unlock controller first - await baseMessenger.call( - 'SeedlessOnboardingController:submitPassword', - OLD_PASSWORD, - ); - - const recoverEncKeySpy = jest.spyOn(toprfClient, 'recoverEncKey'); - const encryptorSpy = jest.spyOn(encryptor, 'encryptWithDetail'); - - // Make recoverEncKey succeed - recoverEncKeySpy.mockResolvedValueOnce({ - encKey: newEncKey, - pwEncKey: newPwEncKey, - authKeyPair: newAuthKeyPair, - rateLimitResetResult: Promise.resolve(), - keyShareIndex: 1, - }); - - // Make encryptWithDetail always fail to ensure we catch any call to it - encryptorSpy.mockRejectedValue(new Error('Vault creation failed')); - - await expect( - baseMessenger.call( - 'SeedlessOnboardingController:syncLatestGlobalPassword', - { - globalPassword: GLOBAL_PASSWORD, - }, - ), - ).rejects.toThrow('Vault creation failed'); + ); - expect(recoverEncKeySpy).toHaveBeenCalledWith( - expect.objectContaining({ password: GLOBAL_PASSWORD }), + await baseMessenger.call( + 'SeedlessOnboardingController:storeKeyringEncryptionKey', + MOCK_KEYRING_ENCRYPTION_KEY, + ); + + const result = await baseMessenger.call( + 'SeedlessOnboardingController:loadKeyringEncryptionKey', ); - expect(encryptorSpy).toHaveBeenCalled(); + expect(result).toStrictEqual(MOCK_KEYRING_ENCRYPTION_KEY); }, ); }); - /** - * This test is to verify that the controller throws an error if the encryption salt is expired. - * The test creates a mock vault with a different salt value in the state to simulate an expired salt. - * It then creates mock keys associated with the new global password and uses these values as mock return values for the recoverEncKey and recoverPwEncKey calls. - * The test expects the controller to throw an error indicating that the password could not be recovered since the encryption salt from state is different from the salt in the mock vault. - */ - it('should throw an error if the encryption salt is expired', async () => { - const encryptedSeedlessEncryptionKey = bytesToBase64( - initialEncryptedSeedlessEncryptionKey, - ); + it('should load keyring encryption key after change password', async () => { await withController( { state: getMockInitialControllerState({ withMockAuthenticatedUser: true, - authPubKey: INITIAL_AUTH_PUB_KEY, // Use the base64 encoded key - vault: MOCK_VAULT, - vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, - // Mock a different salt value in state to simulate an expired salt - vaultEncryptionSalt: 'DIFFERENT-SALT', withMockAuthPubKey: true, - encryptedSeedlessEncryptionKey, }), }, - async ({ toprfClient, baseMessenger }) => { - // Here we are creating mock keys associated with the new global password - // and these values are used as mock return values for the recoverEncKey and recoverPwEncKey calls - const recoverEncKeySpy = jest - .spyOn(toprfClient, 'recoverEncKey') - .mockResolvedValueOnce({ - encKey: newEncKey, - pwEncKey: newPwEncKey, - authKeyPair: newAuthKeyPair, - rateLimitResetResult: Promise.resolve(), - keyShareIndex: 1, - }); + async ({ controller, toprfClient, baseMessenger }) => { + // Setup and store keyring encryption key. + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + baseMessenger, + RECOVERED_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); - const recoverPwEncKeySpy = jest - .spyOn(toprfClient, 'recoverPwEncKey') - .mockResolvedValueOnce({ - pwEncKey: initialPwEncKey, - }); + await baseMessenger.call( + 'SeedlessOnboardingController:storeKeyringEncryptionKey', + MOCK_KEYRING_ENCRYPTION_KEY, + ); - await expect( - baseMessenger.call( - 'SeedlessOnboardingController:submitGlobalPassword', - { - globalPassword: GLOBAL_PASSWORD, - }, - ), - ).rejects.toThrow( - SeedlessOnboardingControllerErrorMessage.CouldNotRecoverPassword, + await mockChangePassword( + controller, + toprfClient, + RECOVERED_PASSWORD, + GLOBAL_PASSWORD, + ); + + await baseMessenger.call( + 'SeedlessOnboardingController:changePassword', + GLOBAL_PASSWORD, + RECOVERED_PASSWORD, + ); + + const result = await baseMessenger.call( + 'SeedlessOnboardingController:loadKeyringEncryptionKey', ); - expect(recoverEncKeySpy).toHaveBeenCalled(); - expect(recoverPwEncKeySpy).toHaveBeenCalled(); + expect(result).toStrictEqual(MOCK_KEYRING_ENCRYPTION_KEY); }, ); }); @@ -7158,235 +6615,6 @@ describe('SeedlessOnboardingController', () => { }); }); - describe('syncLatestGlobalPassword with token refresh', () => { - const OLD_PASSWORD = 'old-mock-password'; - const GLOBAL_PASSWORD = 'new-global-password'; - let MOCK_VAULT: string; - let MOCK_VAULT_ENCRYPTION_KEY: string; - let MOCK_VAULT_ENCRYPTION_SALT: string; - let INITIAL_AUTH_PUB_KEY: string; - let initialAuthKeyPair: KeyPair; // Store initial keypair for vault creation - let initialEncKey: Uint8Array; // Store initial encKey for vault creation - let initialPwEncKey: Uint8Array; // Store initial pwEncKey for vault creation - - // Generate initial keys and vault state before tests run - beforeAll(async () => { - const mockToprfEncryptor = createMockToprfEncryptor(); - initialEncKey = mockToprfEncryptor.deriveEncKey(OLD_PASSWORD); - initialPwEncKey = mockToprfEncryptor.derivePwEncKey(OLD_PASSWORD); - initialAuthKeyPair = mockToprfEncryptor.deriveAuthKeyPair(OLD_PASSWORD); - INITIAL_AUTH_PUB_KEY = bytesToBase64(initialAuthKeyPair.pk); - - const mockResult = await createMockVault( - initialEncKey, - initialPwEncKey, - initialAuthKeyPair, - OLD_PASSWORD, - ); - - MOCK_VAULT = mockResult.encryptedMockVault; - MOCK_VAULT_ENCRYPTION_KEY = mockResult.vaultEncryptionKey; - MOCK_VAULT_ENCRYPTION_SALT = mockResult.vaultEncryptionSalt; - }); - - it('should retry syncLatestGlobalPassword after refreshing expired tokens', async () => { - await withController( - { - state: getMockInitialControllerState({ - withMockAuthenticatedUser: true, - authPubKey: INITIAL_AUTH_PUB_KEY, - vault: MOCK_VAULT, - vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, - vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, - }), - }, - async ({ - controller, - toprfClient, - encryptor, - mockRefreshJWTToken, - baseMessenger, - }) => { - // Unlock controller first - await baseMessenger.call( - 'SeedlessOnboardingController:submitPassword', - OLD_PASSWORD, - ); - - // Capture before the call — proactive renewRefreshToken inside - // refreshAuthTokens will rotate state.refreshToken afterwards. - const originalRefreshToken = controller.state.refreshToken; - - const recoverEncKeySpy = jest.spyOn(toprfClient, 'recoverEncKey'); - const encryptorSpy = jest.spyOn(encryptor, 'encryptWithDetail'); - - // Mock recoverEncKey for the new global password - const mockToprfEncryptor = createMockToprfEncryptor(); - const newEncKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); - const newPwEncKey = - mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); - const newAuthKeyPair = - mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); - - // Mock recoverEncKey to fail first with token expired error, then succeed - recoverEncKeySpy - .mockImplementationOnce(() => { - throw new TOPRFError( - TOPRFErrorCode.AuthTokenExpired, - 'Auth token expired', - ); - }) - .mockResolvedValueOnce({ - encKey: newEncKey, - pwEncKey: newPwEncKey, - authKeyPair: newAuthKeyPair, - rateLimitResetResult: Promise.resolve(), - keyShareIndex: 1, - }); - - // Mock authenticate for token refresh - jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ - nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, - isNewUser: false, - }); - - await baseMessenger.call( - 'SeedlessOnboardingController:syncLatestGlobalPassword', - { - globalPassword: GLOBAL_PASSWORD, - }, - ); - - // Verify that getNewRefreshToken was called - expect(mockRefreshJWTToken).toHaveBeenCalledWith({ - connection: controller.state.authConnection, - refreshToken: originalRefreshToken, - }); - - // Verify that recoverEncKey was called twice (once failed, once succeeded) - expect(recoverEncKeySpy).toHaveBeenCalledTimes(2); - - // Verify that authenticate was called during token refresh - expect(toprfClient.authenticate).toHaveBeenCalled(); - - // Check if vault was re-encrypted with the new password and keys - const expectedSerializedVaultData = JSON.stringify({ - toprfEncryptionKey: bytesToBase64(newEncKey), - toprfPwEncryptionKey: bytesToBase64(newPwEncKey), - toprfAuthKeyPair: JSON.stringify({ - sk: bigIntToHex(newAuthKeyPair.sk), - pk: bytesToBase64(newAuthKeyPair.pk), - }), - revokeToken: controller.state.revokeToken, - accessToken: controller.state.accessToken, - }); - expect(encryptorSpy).toHaveBeenCalledWith( - GLOBAL_PASSWORD, - expectedSerializedVaultData, - ); - - // Check if authPubKey was updated in state - expect(controller.state.authPubKey).toBe( - bytesToBase64(newAuthKeyPair.pk), - ); - }, - ); - }); - - it('should fail if token refresh fails during syncLatestGlobalPassword', async () => { - await withController( - { - state: getMockInitialControllerState({ - withMockAuthenticatedUser: true, - authPubKey: INITIAL_AUTH_PUB_KEY, - vault: MOCK_VAULT, - vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, - vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, - }), - }, - async ({ toprfClient, mockRefreshJWTToken, baseMessenger }) => { - // Unlock controller first - await baseMessenger.call( - 'SeedlessOnboardingController:submitPassword', - OLD_PASSWORD, - ); - - // Mock recoverEncKey to fail with token expired error - jest - .spyOn(toprfClient, 'recoverEncKey') - .mockImplementationOnce(() => { - throw new TOPRFError( - TOPRFErrorCode.AuthTokenExpired, - 'Auth token expired', - ); - }); - - // Mock getNewRefreshToken to fail - mockRefreshJWTToken.mockRejectedValueOnce( - new Error('Failed to get new refresh token'), - ); - - await expect( - baseMessenger.call( - 'SeedlessOnboardingController:syncLatestGlobalPassword', - { - globalPassword: GLOBAL_PASSWORD, - }, - ), - ).rejects.toThrow( - SeedlessOnboardingControllerErrorMessage.FailedToRefreshJWTTokens, - ); - - // Verify that getNewRefreshToken was called - expect(mockRefreshJWTToken).toHaveBeenCalled(); - }, - ); - }); - - it('should not retry on non-token-related errors during syncLatestGlobalPassword', async () => { - await withController( - { - state: getMockInitialControllerState({ - withMockAuthenticatedUser: true, - authPubKey: INITIAL_AUTH_PUB_KEY, - vault: MOCK_VAULT, - vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, - vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, - }), - }, - async ({ toprfClient, mockRefreshJWTToken, baseMessenger }) => { - // Unlock controller first - await baseMessenger.call( - 'SeedlessOnboardingController:submitPassword', - OLD_PASSWORD, - ); - - // Mock recoverEncKey to fail with a non-token error - jest - .spyOn(toprfClient, 'recoverEncKey') - .mockRejectedValue(new Error('Some other error')); - - await expect( - baseMessenger.call( - 'SeedlessOnboardingController:syncLatestGlobalPassword', - { - globalPassword: GLOBAL_PASSWORD, - }, - ), - ).rejects.toThrow( - SeedlessOnboardingControllerErrorMessage.LoginFailedError, - ); - - // Verify that getNewRefreshToken was NOT called - expect(mockRefreshJWTToken).not.toHaveBeenCalled(); - - // Verify that recoverEncKey was only called once (no retry) - expect(toprfClient.recoverEncKey).toHaveBeenCalledTimes(1); - }, - ); - }); - }); - describe('addNewSecretData with token refresh', () => { const NEW_KEY_RING = { id: 'new-keyring-1', @@ -7644,103 +6872,6 @@ describe('SeedlessOnboardingController', () => { }); }); - describe('recover keyring encryption key with token refresh', () => { - // const OLD_PASSWORD = 'old-mock-password'; - // const GLOBAL_PASSWORD = 'new-global-password'; - let MOCK_VAULT: string; - let MOCK_VAULT_ENCRYPTION_KEY: string; - let MOCK_VAULT_ENCRYPTION_SALT: string; - let INITIAL_AUTH_PUB_KEY: string; - let initialAuthKeyPair: KeyPair; // Store initial keypair for vault creation - let initialEncKey: Uint8Array; // Store initial encKey for vault creation - let initialPwEncKey: Uint8Array; // Store initial pwEncKey for vault creation - let initialEncryptedSeedlessEncryptionKey: Uint8Array; // Store initial encryptedSeedlessEncryptionKey for vault creation - // Generate initial keys and vault state before tests run - beforeAll(async () => { - const mockToprfEncryptor = createMockToprfEncryptor(); - initialEncKey = mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); - initialPwEncKey = mockToprfEncryptor.derivePwEncKey(MOCK_PASSWORD); - - initialAuthKeyPair = - mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); - INITIAL_AUTH_PUB_KEY = bytesToBase64(initialAuthKeyPair.pk); - - const mockResult = await createMockVault( - initialEncKey, - initialPwEncKey, - initialAuthKeyPair, - MOCK_PASSWORD, - ); - - MOCK_VAULT = mockResult.encryptedMockVault; - MOCK_VAULT_ENCRYPTION_KEY = mockResult.vaultEncryptionKey; - MOCK_VAULT_ENCRYPTION_SALT = mockResult.vaultEncryptionSalt; - const aes = managedNonce(gcm)(mockResult.pwEncKey); - initialEncryptedSeedlessEncryptionKey = aes.encrypt( - utf8ToBytes(MOCK_VAULT_ENCRYPTION_KEY), - ); - }); - - it('should retry after refreshing expired tokens', async () => { - await withController( - { - state: getMockInitialControllerState({ - withMockAuthPubKey: true, - withMockAuthenticatedUser: true, - authPubKey: INITIAL_AUTH_PUB_KEY, - vault: MOCK_VAULT, - vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, - vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, - encryptedSeedlessEncryptionKey: bytesToBase64( - initialEncryptedSeedlessEncryptionKey, - ), - }), - }, - async ({ toprfClient, mockRefreshJWTToken, baseMessenger }) => { - await baseMessenger.call( - 'SeedlessOnboardingController:submitPassword', - MOCK_PASSWORD, - ); - - // Mock recoverEncKey - mockRecoverEncKey(toprfClient, MOCK_PASSWORD); - // second call after refresh token - mockRecoverEncKey(toprfClient, MOCK_PASSWORD); - - // Mock recoverPassword - jest - .spyOn(toprfClient, 'recoverPwEncKey') - .mockImplementationOnce(() => { - // First call fails with token expired error - throw new TOPRFError( - TOPRFErrorCode.AuthTokenExpired, - 'Auth token expired', - ); - }) - .mockResolvedValueOnce({ - pwEncKey: initialPwEncKey, - }); - - // Mock authenticate for token refresh - jest.spyOn(toprfClient, 'authenticate').mockResolvedValueOnce({ - nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, - isNewUser: false, - }); - - await baseMessenger.call( - 'SeedlessOnboardingController:submitGlobalPassword', - { - globalPassword: MOCK_PASSWORD, - }, - ); - - expect(mockRefreshJWTToken).toHaveBeenCalled(); - expect(toprfClient.recoverPwEncKey).toHaveBeenCalledTimes(2); - }, - ); - }); - }); - describe('refreshAuthTokens', () => { const mockToprfEncryptor = createMockToprfEncryptor(); let MOCK_ENCRYPTION_KEY: Uint8Array; diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index bf3c6dd4def..8bbda0eabbe 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -96,14 +96,12 @@ const MESSENGER_EXPOSED_METHODS = [ 'clearPasswordChangePhase', 'markPasswordChangeKeySyncPending', 'resolvePasswordSyncState', - 'recoverPasswordChange', + 'reconcilePassword', 'updateBackupMetadataState', 'verifyVaultPassword', 'getSecretDataBackupState', 'submitPassword', 'setLocked', - 'syncLatestGlobalPassword', - 'submitGlobalPassword', 'getIsUserAuthenticated', 'clearState', 'storeKeyringEncryptionKey', @@ -1213,43 +1211,26 @@ export class SeedlessOnboardingController< } /** - * Sync the latest global password to the controller. - * reset vault with latest globalPassword, - * persist the latest global password authPubKey + * Rewrite the local Seedless vault under the latest global password. * - * @param params - The parameters for syncing the latest global password. - * @param params.globalPassword - The latest global password. - * @returns A promise that resolves to the success of the operation. - */ - async syncLatestGlobalPassword({ - globalPassword, - }: { - globalPassword: string; - }): Promise { - return await this.#withControllerLock(async () => { - this.#assertIsUnlocked(); - return await this.#executeWithTokenRefresh( - async () => await this.#syncLatestGlobalPasswordInner(globalPassword), - 'syncLatestGlobalPassword', - ); - }); - } - - /** - * Lock-free implementation of `syncLatestGlobalPassword`. - * - * Rewrites the local Seedless vault under the latest global password and - * resets the password-outdated cache. Must be called while the controller - * lock is held (or from a context that does not hold the lock, in which - * case the caller manages locking). + * Rewrites the local Seedless vault under the latest global password, + * re-encrypts `encryptedKeyringEncryptionKey` under the new wrapping key, + * and resets the password-outdated cache. Must be called while the + * controller lock is held (or from a context that does not hold the lock, + * in which case the caller manages locking). * * @param globalPassword - The latest global password. */ async #syncLatestGlobalPasswordInner(globalPassword: string): Promise { - // update vault with latest globalPassword + // Decrypt under the old wrapping key before the vault rewrite so the + // ciphertext can be persisted under the new `toprfPwEncryptionKey`. + let keyringEncryptionKey: string | undefined; + if (this.state.encryptedKeyringEncryptionKey) { + keyringEncryptionKey = await this.loadKeyringEncryptionKey(); + } + const { encKey, pwEncKey, authKeyPair } = await this.#recoverEncKey(globalPassword); - // update and encrypt the vault with new password await this.#createNewVaultWithAuthData({ password: globalPassword, rawToprfEncryptionKey: encKey, @@ -1257,34 +1238,11 @@ export class SeedlessOnboardingController< rawToprfAuthKeyPair: authKeyPair, }); - this.#resetPasswordOutdatedCache(); - } + if (keyringEncryptionKey) { + await this.#persistKeyringEncryptionKey(keyringEncryptionKey); + } - /** - * @description Unlock the controller with the latest global password. - * - * @param params - The parameters for unlocking the controller. - * @param params.maxKeyChainLength - The maximum chain length of the pwd encryption keys. - * @param params.globalPassword - The latest global password. - * @returns A promise that resolves to the success of the operation. - */ - async submitGlobalPassword({ - globalPassword, - maxKeyChainLength = 5, - }: { - globalPassword: string; - maxKeyChainLength?: number; - }): Promise { - return await this.#withControllerLock(async () => { - return await this.#executeWithTokenRefresh(async () => { - const currentDeviceAuthPubKey = this.#recoverAuthPubKey(); - await this.#submitGlobalPassword({ - targetAuthPubKey: currentDeviceAuthPubKey, - globalPassword, - maxKeyChainLength, - }); - }, 'submitGlobalPassword'); - }); + this.#resetPasswordOutdatedCache(); } /** @@ -1333,7 +1291,8 @@ export class SeedlessOnboardingController< this.#setUnlocked(); // Pick the latest access token - the token from state might be newer (from refreshAuthTokens) - // than the token stored in the vault. The vault will be updated later by syncLatestGlobalPassword. + // than the token stored in the vault. The vault will be updated later + // by the password-sync flow. this.#pickLatestAccessToken( accessTokenBeforeUnlock, decryptedVaultData.accessToken, @@ -2404,7 +2363,7 @@ export class SeedlessOnboardingController< * - Other phases: return the next recovery step without mutating state. * * This method does not consume a password; the client prompts for the - * correct password and then calls `recoverPasswordChange`. + * correct password and then calls `reconcilePassword`. * * @param options - The options. * @param options.skipCache - Whether to bypass the outdated cache. Ignored @@ -2474,11 +2433,10 @@ export class SeedlessOnboardingController< } /** - * Reconcile the Seedless side of a password-change recovery — or a plain - * remote password sync — with the supplied password. + * Reconcile the local Seedless password with the remote password. * * For `SEEDLESS_COMMITTED` or `LOCAL_KEYRING_PENDING` it re-runs the existing - * password-sync flow (`submitGlobalPassword` + `syncLatestGlobalPassword`) + * password-sync flow (chain unlock + local vault rewrite) * with the new password and advances the phase to `LOCAL_KEYRING_PENDING`. * These operations are idempotent, so re-running them is safe whether or not * the local Seedless vault was already rewritten. The controller is left @@ -2496,12 +2454,12 @@ export class SeedlessOnboardingController< * `KeyringController`. See * [0003](./docs/0003-controller-owned-password-change-recovery-plan.md). * - * @param params - The recovery parameters. - * @param params.globalPassword - The new global password. - * @returns The recovery result. On any failure the last known phase is + * @param params - The reconciliation parameters. + * @param params.globalPassword - The current global password. + * @returns The reconciliation result. On any failure the last known phase is * preserved and `PasswordChangeRecoveryStatus.Unknown` is returned. */ - async recoverPasswordChange({ + async reconcilePassword({ globalPassword, }: { globalPassword: string; @@ -2563,8 +2521,8 @@ export class SeedlessOnboardingController< } /** - * Re-run the password-sync flow (`submitGlobalPassword` + - * `syncLatestGlobalPassword`) with the supplied password. + * Re-run the password-sync flow (chain unlock + local vault rewrite) with + * the supplied password. * * Both operations are idempotent if the local Seedless vault is already * synced, so this is safe to re-run during recovery or a plain @@ -2591,7 +2549,7 @@ export class SeedlessOnboardingController< /** * Return the recovery status for phases that require no Seedless-side * mutation. Shared by `resolvePasswordSyncState` (read) and - * `recoverPasswordChange` (apply) so both route the terminal phases + * `reconcilePassword` (apply) so both route the terminal phases * identically. * * @param phase - The persisted password-change phase. diff --git a/packages/seedless-onboarding-controller/src/constants.ts b/packages/seedless-onboarding-controller/src/constants.ts index 39e3fd567be..d18584a6df2 100644 --- a/packages/seedless-onboarding-controller/src/constants.ts +++ b/packages/seedless-onboarding-controller/src/constants.ts @@ -48,7 +48,7 @@ export enum SeedlessPasswordChangePhase { /** * The outcome of a password-sync / password-change recovery step, returned by * `resolvePasswordSyncState` (read + resolve, no password) and - * `recoverPasswordChange` (apply, with password). + * `reconcilePassword` (apply, with password). * * The controller owns the Seedless-side recovery sequencing; the client owns * the Keyring-side steps (it must call `KeyringController` directly) and UI @@ -58,9 +58,9 @@ export enum SeedlessPasswordChangePhase { export enum PasswordChangeRecoveryStatus { /** The local and remote passwords are synchronized; no recovery action is needed. Unlock normally. */ InSync = 'in-sync', - /** No lifecycle is in flight but the remote password changed (e.g. another device changed it). Prompt for the new password, then call `recoverPasswordChange`. */ + /** No lifecycle is in flight but the remote password changed (e.g. another device changed it). Prompt for the new password, then call `reconcilePassword`. */ PasswordOutdated = 'password-outdated', - /** Remote committed (or the local Seedless side still needs the new password). Prompt for the new password, then call `recoverPasswordChange`. */ + /** Remote committed (or the local Seedless side still needs the new password). Prompt for the new password, then call `reconcilePassword`. */ EnterNewPassword = 'enter-new-password', /** The Seedless side is reconciled (phase is `LOCAL_KEYRING_PENDING`). The client must cryptographically classify the local Keyring and run the old/new branch. */ ReconcileKeyring = 'reconcile-keyring', diff --git a/packages/seedless-onboarding-controller/src/index.ts b/packages/seedless-onboarding-controller/src/index.ts index 0a2dc329e6b..e1083740199 100644 --- a/packages/seedless-onboarding-controller/src/index.ts +++ b/packages/seedless-onboarding-controller/src/index.ts @@ -25,8 +25,6 @@ export type { SeedlessOnboardingControllerGetSecretDataBackupStateAction, SeedlessOnboardingControllerSubmitPasswordAction, SeedlessOnboardingControllerSetLockedAction, - SeedlessOnboardingControllerSyncLatestGlobalPasswordAction, - SeedlessOnboardingControllerSubmitGlobalPasswordAction, SeedlessOnboardingControllerGetIsUserAuthenticatedAction, SeedlessOnboardingControllerClearStateAction, SeedlessOnboardingControllerStoreKeyringEncryptionKeyAction, @@ -40,7 +38,7 @@ export type { SeedlessOnboardingControllerCheckAccessTokenExpiredAction, SeedlessOnboardingControllerRunMigrationsAction, SeedlessOnboardingControllerResolvePasswordSyncStateAction, - SeedlessOnboardingControllerRecoverPasswordChangeAction, + SeedlessOnboardingControllerReconcilePasswordAction, } from './SeedlessOnboardingController-method-action-types.js'; export type { AuthenticatedUserDetails, From abca9bceacbc27108e1b5714e53b3fcbeb4e2cf1 Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 10 Sep 2026 00:26:50 +0800 Subject: [PATCH 07/14] fix: fixed changelog and lint --- .../CHANGELOG.md | 32 ++++++++----------- .../0002-password-change-recovery-flow.md | 1 - .../src/SeedlessOnboardingController.ts | 6 ++-- 3 files changed, 17 insertions(+), 22 deletions(-) diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 323c07ba33a..92c5b3f10b7 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -9,33 +9,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `SeedlessPasswordChangePhase` enum and a `passwordChangePhase` state field to persist a non-sensitive password-change lifecycle phase used as a recovery signal. An unset/`undefined` phase means "no change in progress" (there is no dedicated `IDLE` or `COMPLETE` member) ([#10148](https://github.com/MetaMask/core/pull/10148)) -- Add `PasswordChangeRecoveryStatus` enum returned by the new password-change recovery methods. `NoChange` is named `InSync` (`'in-sync'`) to reflect that the local and remote passwords are synchronized ([#10148](https://github.com/MetaMask/core/pull/10148)) -- Add `resolvePasswordSyncState({ skipCache })` to resolve remote password-change state without a password at unlock, merging the legacy `checkIsPasswordOutdated` read with password-change recovery routing ([#10148](https://github.com/MetaMask/core/pull/10148)) -- Add `reconcilePassword({ globalPassword })` to reconcile local Seedless state after either an interrupted local password change or an another-device password change, and advance the lifecycle to `LOCAL_KEYRING_PENDING` ([#10148](https://github.com/MetaMask/core/pull/10148)) -- Add `clearPasswordChangePhase` and `markPasswordChangeKeySyncPending` lifecycle-advance methods. `clearPasswordChangePhase` is the single way back to no change in progress (it both marks completion and clears the phase) ([#10148](https://github.com/MetaMask/core/pull/10148)) -- Add `PasswordChangeInProgress` error message, thrown when a second password change is attempted while one is already in progress ([#10148](https://github.com/MetaMask/core/pull/10148)) +- Add `SeedlessPasswordChangePhase` enum and an optional `passwordChangePhase` state field that persists a non-sensitive password-change lifecycle phase used as a recovery signal. An unset/`undefined` phase means no password change is in progress ([#10148](https://github.com/MetaMask/core/pull/10148)) +- Add `PasswordChangeRecoveryStatus` enum, returned by `resolvePasswordSyncState` and `reconcilePassword` to tell clients which recovery step to run next ([#10148](https://github.com/MetaMask/core/pull/10148)) +- Add `resolvePasswordSyncState({ skipCache })` and `SeedlessOnboardingControllerResolvePasswordSyncStateAction` to resolve remote password state at unlock without consuming a password, replacing the removed `checkIsPasswordOutdated` read ([#10148](https://github.com/MetaMask/core/pull/10148)) +- Add `reconcilePassword({ globalPassword })` and `SeedlessOnboardingControllerReconcilePasswordAction` to bring local Seedless state up to date with the remote password after either an interrupted local password change or a password change made on another device ([#10148](https://github.com/MetaMask/core/pull/10148)) + - It performs the password-chain unlock and local vault rewrite internally, re-encrypts `encryptedKeyringEncryptionKey` under the new wrapping key so `loadKeyringEncryptionKey` keeps working, and advances the lifecycle to `LOCAL_KEYRING_PENDING` so the client reconciles the local Keyring before unlocking normally. +- Add `clearPasswordChangePhase` and `markPasswordChangeKeySyncPending` lifecycle-advance methods, along with `SeedlessOnboardingControllerClearPasswordChangePhaseAction` and `SeedlessOnboardingControllerMarkPasswordChangeKeySyncPendingAction`. `clearPasswordChangePhase` is the only way back to "no change in progress" and must be called once key synchronization and local persistence are verified ([#10148](https://github.com/MetaMask/core/pull/10148)) +- Add `SeedlessOnboardingControllerErrorMessage.PasswordChangeInProgress`, thrown when a password change is started while another one is unresolved ([#10148](https://github.com/MetaMask/core/pull/10148)) ### Changed -- **BREAKING:** `changePassword` is now lifecycle-aware: it writes `SEEDLESS_CHANGE_PENDING`, `SEEDLESS_COMMITTED`, and `LOCAL_KEYRING_PENDING` phases and rejects a second concurrent change with `PasswordChangeInProgress`. Clients must not start a second password change while the lifecycle is unfinished; see [0002](./docs/0002-password-change-recovery-flow.md) for the client integration guide ([#10148](https://github.com/MetaMask/core/pull/10148)) +- **BREAKING:** `changePassword` is now lifecycle-aware: it writes the `SEEDLESS_CHANGE_PENDING`, `SEEDLESS_COMMITTED`, and `LOCAL_KEYRING_PENDING` phases and rejects a concurrent change with `PasswordChangeInProgress` ([#10148](https://github.com/MetaMask/core/pull/10148)) + - Clients must not start a second password change while a lifecycle is unfinished, and must drive the lifecycle to completion by calling `clearPasswordChangePhase`. See [0002](./docs/0002-password-change-recovery-flow.md) for the client integration guide. - Bump `@metamask/keyring-controller` from `^27.1.0` to `^27.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791)) - Bump `@metamask/utils` from `^11.11.0` to `^11.12.0` ([#10076](https://github.com/MetaMask/core/pull/10076)) ### Removed -- **BREAKING:** Remove the public `checkIsPasswordOutdated` method and `SeedlessOnboardingControllerCheckIsPasswordOutdatedAction`; the read is folded into `resolvePasswordSyncState` (now private `#checkIsPasswordOutdated`) ([#10148](https://github.com/MetaMask/core/pull/10148)) -- **BREAKING:** Remove `resolvePasswordChangeRecovery` method and `SeedlessOnboardingControllerResolvePasswordChangeRecoveryAction`; replaced by `resolvePasswordSyncState` (password-less resolve) and `reconcilePassword` (password-consuming apply) ([#10148](https://github.com/MetaMask/core/pull/10148)) -- **BREAKING:** Remove public `submitGlobalPassword`, `syncLatestGlobalPassword`, `SeedlessOnboardingControllerSubmitGlobalPasswordAction`, and `SeedlessOnboardingControllerSyncLatestGlobalPasswordAction`; password-chain unlock and local vault rewrite are now internal to `reconcilePassword` ([#10148](https://github.com/MetaMask/core/pull/10148)) -- **BREAKING:** Remove `PasswordChangeRecoveryResult` type; recovery methods now return `PasswordChangeRecoveryStatus` ([#10148](https://github.com/MetaMask/core/pull/10148)) -- **BREAKING:** Remove `SeedlessPasswordChangePhase.Idle` and `SeedlessPasswordChangePhase.Complete`; "no change in progress" and "done" are both represented by an unset/`undefined` phase ([#10148](https://github.com/MetaMask/core/pull/10148)) -- **BREAKING:** Remove `PasswordChangeRecoveryStatus.NoChange` and `PasswordChangeRecoveryStatus.Complete`; the former is renamed `InSync` (`'in-sync'`) and the latter is no longer returned (a completed change is just an unset phase, which resolves to `InSync`) ([#10148](https://github.com/MetaMask/core/pull/10148)) -- **BREAKING:** Remove `completePasswordChange` method and `SeedlessOnboardingControllerCompletePasswordChangeAction`; completion is now recorded by calling `clearPasswordChangePhase` once key synchronization and local persistence are verified ([#10148](https://github.com/MetaMask/core/pull/10148)) - -### Fixed - -- Ensure `reconcilePassword` advances another-device password reconciliation to `LOCAL_KEYRING_PENDING` and returns `ReconcileKeyring` after synchronizing the Seedless vault, so clients reconcile the local Keyring before unlocking normally ([#10148](https://github.com/MetaMask/core/pull/10148)) -- Re-encrypt `encryptedKeyringEncryptionKey` when reconciling the latest global password, so `loadKeyringEncryptionKey` still decrypts after an interrupted local password change or another-device password sync ([#10148](https://github.com/MetaMask/core/pull/10148)) +- **BREAKING:** Remove the `checkIsPasswordOutdated` method and `SeedlessOnboardingControllerCheckIsPasswordOutdatedAction` ([#10148](https://github.com/MetaMask/core/pull/10148)) + - Call `resolvePasswordSyncState({ skipCache })` instead. It performs the same remote check and additionally returns the required recovery step, so a boolean `true` result now corresponds to `PasswordChangeRecoveryStatus.PasswordOutdated`. +- **BREAKING:** Remove the `submitGlobalPassword` and `syncLatestGlobalPassword` methods, along with `SeedlessOnboardingControllerSubmitGlobalPasswordAction` and `SeedlessOnboardingControllerSyncLatestGlobalPasswordAction` ([#10148](https://github.com/MetaMask/core/pull/10148)) + - Call `reconcilePassword({ globalPassword })` instead. It runs both steps internally in the correct order, re-wraps the stored Keyring encryption key, and records the lifecycle phase, none of which happened when the two methods were called directly. ## [10.1.1] diff --git a/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md b/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md index a3c471a6464..d1478a5e48e 100644 --- a/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md +++ b/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md @@ -150,7 +150,6 @@ unlock render / submit 3. **Unlock routing.** On unlock (page render _and_ password submit), read `passwordChangePhase` from controller state, then call `resolvePasswordSyncState({ skipCache })`. Route UI from the returned status using the table above. Do not classify a password as invalid until recovery has run. 4. **Two-step UX.** - - Step 1 (password-less): `resolvePasswordSyncState` decides whether the old or new password is needed. - Step 2 (password-consuming): only after step 1 returns `enter-new-password` / `password-outdated`, prompt for the new password and call `reconcilePassword({ globalPassword })`. diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 8bbda0eabbe..c2362213e98 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -158,7 +158,8 @@ export type SeedlessOnboardingControllerOptions< EncryptionKey = encryptionUtils.EncryptionKey, SupportedKeyDerivationParams = encryptionUtils.KeyDerivationOptions, EncryptionResult extends - EncryptionResultConstraint = DefaultEncryptionResult, + EncryptionResultConstraint = + DefaultEncryptionResult, > = { messenger: SeedlessOnboardingControllerMessenger; @@ -400,7 +401,8 @@ export class SeedlessOnboardingController< EncryptionKey = encryptionUtils.EncryptionKey, SupportedKeyDerivationOptions = encryptionUtils.KeyDerivationOptions, EncryptionResult extends - EncryptionResultConstraint = DefaultEncryptionResult, + EncryptionResultConstraint = + DefaultEncryptionResult, > extends BaseController< typeof controllerName, SeedlessOnboardingControllerState, From c2b86ca7275bef94ff275675edc696820efaac45 Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 10 Sep 2026 05:20:31 +0800 Subject: [PATCH 08/14] chore: renames type --- .../CHANGELOG.md | 4 +- .../0002-password-change-recovery-flow.md | 8 +-- ...ler-owned-password-change-recovery-plan.md | 2 +- ...nboardingController-method-action-types.ts | 4 +- .../src/SeedlessOnboardingController.test.ts | 70 +++++++++---------- .../src/SeedlessOnboardingController.ts | 46 ++++++------ .../src/constants.ts | 17 ++--- .../src/index.ts | 2 +- 8 files changed, 77 insertions(+), 76 deletions(-) diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 92c5b3f10b7..fe0670d4d62 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `SeedlessPasswordChangePhase` enum and an optional `passwordChangePhase` state field that persists a non-sensitive password-change lifecycle phase used as a recovery signal. An unset/`undefined` phase means no password change is in progress ([#10148](https://github.com/MetaMask/core/pull/10148)) -- Add `PasswordChangeRecoveryStatus` enum, returned by `resolvePasswordSyncState` and `reconcilePassword` to tell clients which recovery step to run next ([#10148](https://github.com/MetaMask/core/pull/10148)) +- Add `PasswordSyncStatus` enum, returned by `resolvePasswordSyncState` and `reconcilePassword` to tell clients which step to run next after either an interrupted local password change or an another-device password change ([#10148](https://github.com/MetaMask/core/pull/10148)) - Add `resolvePasswordSyncState({ skipCache })` and `SeedlessOnboardingControllerResolvePasswordSyncStateAction` to resolve remote password state at unlock without consuming a password, replacing the removed `checkIsPasswordOutdated` read ([#10148](https://github.com/MetaMask/core/pull/10148)) - Add `reconcilePassword({ globalPassword })` and `SeedlessOnboardingControllerReconcilePasswordAction` to bring local Seedless state up to date with the remote password after either an interrupted local password change or a password change made on another device ([#10148](https://github.com/MetaMask/core/pull/10148)) - It performs the password-chain unlock and local vault rewrite internally, re-encrypts `encryptedKeyringEncryptionKey` under the new wrapping key so `loadKeyringEncryptionKey` keeps working, and advances the lifecycle to `LOCAL_KEYRING_PENDING` so the client reconciles the local Keyring before unlocking normally. @@ -27,7 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - **BREAKING:** Remove the `checkIsPasswordOutdated` method and `SeedlessOnboardingControllerCheckIsPasswordOutdatedAction` ([#10148](https://github.com/MetaMask/core/pull/10148)) - - Call `resolvePasswordSyncState({ skipCache })` instead. It performs the same remote check and additionally returns the required recovery step, so a boolean `true` result now corresponds to `PasswordChangeRecoveryStatus.PasswordOutdated`. + - Call `resolvePasswordSyncState({ skipCache })` instead. It performs the same remote check and additionally returns the required recovery step, so a boolean `true` result now corresponds to `PasswordSyncStatus.PasswordOutdated`. - **BREAKING:** Remove the `submitGlobalPassword` and `syncLatestGlobalPassword` methods, along with `SeedlessOnboardingControllerSubmitGlobalPasswordAction` and `SeedlessOnboardingControllerSyncLatestGlobalPasswordAction` ([#10148](https://github.com/MetaMask/core/pull/10148)) - Call `reconcilePassword({ globalPassword })` instead. It runs both steps internally in the correct order, re-wraps the stored Keyring encryption key, and records the lifecycle phase, none of which happened when the two methods were called directly. diff --git a/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md b/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md index d1478a5e48e..b3a7c3ed767 100644 --- a/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md +++ b/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md @@ -25,9 +25,9 @@ Persisted on `SeedlessOnboardingControllerState.passwordChangePhase` (`persist: | `KEY_SYNC_PENDING` | The Keyring encryption key has been stored; awaiting final verification/sync. | | `UNKNOWN` | The result of one or more steps could not be established. | -## The recovery status +## The sync status -Returned by the two controller methods. The client routes UI from this status. +Returned by `resolvePasswordSyncState` and `reconcilePassword` as `PasswordSyncStatus`. The client routes UI from this status. | Status | Meaning | Client action | | -------------------- | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | @@ -49,7 +49,7 @@ For password-change recovery and another-device password sync, clients call `res ```ts SeedlessOnboardingController:resolvePasswordSyncState({ skipCache?: boolean, -}): Promise +}): Promise ``` Single unlock-time call (call on page render _and_ on password submit). Merges the legacy `checkIsPasswordOutdated` read with password-change recovery routing. @@ -64,7 +64,7 @@ Single unlock-time call (call on page render _and_ on password submit). Merges t ```ts SeedlessOnboardingController:reconcilePassword({ globalPassword: string, -}): Promise +}): Promise ``` Reconciles the Seedless side with the supplied password. diff --git a/packages/seedless-onboarding-controller/docs/0003-controller-owned-password-change-recovery-plan.md b/packages/seedless-onboarding-controller/docs/0003-controller-owned-password-change-recovery-plan.md index bf86de48a21..95ac3d8669b 100644 --- a/packages/seedless-onboarding-controller/docs/0003-controller-owned-password-change-recovery-plan.md +++ b/packages/seedless-onboarding-controller/docs/0003-controller-owned-password-change-recovery-plan.md @@ -32,7 +32,7 @@ A single controller method performs the entire recovery for any set phase and re 4. Old-Keyring branch: `loadKeyringEncryptionKey` → `KeyringController:submitEncryptionKey` → `KeyringController:changePassword` → `KeyringController:exportEncryptionKey` → `storeKeyringEncryptionKey` → `markPasswordChangeKeySyncPending`. 5. New-Keyring branch: `KeyringController:exportEncryptionKey` → `storeKeyringEncryptionKey` → `markPasswordChangeKeySyncPending`. 6. `KeySyncPending`: `KeyringController:exportEncryptionKey` → `storeKeyringEncryptionKey` → remote key sync → `clearPasswordChangePhase`. - 7. Return a final status only (`PasswordChangeRecoveryStatus.InSync | Unknown`). + 7. Return a final status only (`PasswordSyncStatus.InSync | Unknown`). - The client supplies the password, calls one method, and routes UI from the status. It performs no cross-controller sequencing. ## Changes diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts index 4fa1799bdd3..7f2da4d5d1f 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts @@ -287,7 +287,7 @@ export type SeedlessOnboardingControllerMarkPasswordChangeKeySyncPendingAction = * @param options.skipCache - Whether to bypass the outdated cache. Ignored * for `SEEDLESS_CHANGE_PENDING`, which always forces a remote check. * @returns The sync/recovery resolution. On any failure the last known phase - * is preserved and `PasswordChangeRecoveryStatus.Unknown` is returned. + * is preserved and `PasswordSyncStatus.Unknown` is returned. */ export type SeedlessOnboardingControllerResolvePasswordSyncStateAction = { type: `SeedlessOnboardingController:resolvePasswordSyncState`; @@ -319,7 +319,7 @@ export type SeedlessOnboardingControllerResolvePasswordSyncStateAction = { * @param params - The reconciliation parameters. * @param params.globalPassword - The current global password. * @returns The reconciliation result. On any failure the last known phase is - * preserved and `PasswordChangeRecoveryStatus.Unknown` is returned. + * preserved and `PasswordSyncStatus.Unknown` is returned. */ export type SeedlessOnboardingControllerReconcilePasswordAction = { type: `SeedlessOnboardingController:reconcilePassword`; diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 27e6b13cdeb..f14f3a43921 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -70,7 +70,7 @@ import { AuthConnection, SecretType, SeedlessPasswordChangePhase, - PasswordChangeRecoveryStatus, + PasswordSyncStatus, } from './constants.js'; import { RecoveryError } from './errors.js'; import { SecretMetadata } from './SecretMetadata.js'; @@ -1134,12 +1134,12 @@ describe('SeedlessOnboardingController', () => { const result = await baseMessenger.call( 'SeedlessOnboardingController:resolvePasswordSyncState', ); - expect(result).toBe(PasswordChangeRecoveryStatus.InSync); + expect(result).toBe(PasswordSyncStatus.InSync); // Call again to test cache const result2 = await baseMessenger.call( 'SeedlessOnboardingController:resolvePasswordSyncState', ); - expect(result2).toBe(PasswordChangeRecoveryStatus.InSync); + expect(result2).toBe(PasswordSyncStatus.InSync); // Should only call fetchAuthPubKey once due to cache expect(spy).toHaveBeenCalledTimes(1); }, @@ -1160,12 +1160,12 @@ describe('SeedlessOnboardingController', () => { const result = await baseMessenger.call( 'SeedlessOnboardingController:resolvePasswordSyncState', ); - expect(result).toBe(PasswordChangeRecoveryStatus.PasswordOutdated); + expect(result).toBe(PasswordSyncStatus.PasswordOutdated); // Call again to test cache const result2 = await baseMessenger.call( 'SeedlessOnboardingController:resolvePasswordSyncState', ); - expect(result2).toBe(PasswordChangeRecoveryStatus.PasswordOutdated); + expect(result2).toBe(PasswordSyncStatus.PasswordOutdated); // Should only call fetchAuthPubKey once due to cache expect(spy).toHaveBeenCalledTimes(1); }, @@ -1189,7 +1189,7 @@ describe('SeedlessOnboardingController', () => { skipCache: true, }, ); - expect(result).toBe(PasswordChangeRecoveryStatus.InSync); + expect(result).toBe(PasswordSyncStatus.InSync); // Call again with skipCache: true, should call fetchAuthPubKey again const result2 = await baseMessenger.call( 'SeedlessOnboardingController:resolvePasswordSyncState', @@ -1197,7 +1197,7 @@ describe('SeedlessOnboardingController', () => { skipCache: true, }, ); - expect(result2).toBe(PasswordChangeRecoveryStatus.InSync); + expect(result2).toBe(PasswordSyncStatus.InSync); expect(spy).toHaveBeenCalledTimes(2); }, ); @@ -1214,7 +1214,7 @@ describe('SeedlessOnboardingController', () => { const result = await baseMessenger.call( 'SeedlessOnboardingController:resolvePasswordSyncState', ); - expect(result).toBe(PasswordChangeRecoveryStatus.Unknown); + expect(result).toBe(PasswordSyncStatus.Unknown); }, ); }); @@ -1234,7 +1234,7 @@ describe('SeedlessOnboardingController', () => { const result = await baseMessenger.call( 'SeedlessOnboardingController:resolvePasswordSyncState', ); - expect(result).toBe(PasswordChangeRecoveryStatus.Unknown); + expect(result).toBe(PasswordSyncStatus.Unknown); }, ); }); @@ -1256,7 +1256,7 @@ describe('SeedlessOnboardingController', () => { const result = await baseMessenger.call( 'SeedlessOnboardingController:resolvePasswordSyncState', ); - expect(result).toBe(PasswordChangeRecoveryStatus.Unknown); + expect(result).toBe(PasswordSyncStatus.Unknown); }, ); }); @@ -4758,7 +4758,7 @@ describe('SeedlessOnboardingController', () => { async ({ toprfClient, controller }) => { mockFetchAuthPubKey(toprfClient, base64ToBytes(MOCK_AUTH_PUB_KEY)); const result = await controller.resolvePasswordSyncState(); - expect(result).toBe(PasswordChangeRecoveryStatus.InSync); + expect(result).toBe(PasswordSyncStatus.InSync); expect(controller.state.passwordChangePhase).toBeUndefined(); }, ); @@ -4775,7 +4775,7 @@ describe('SeedlessOnboardingController', () => { }, async ({ controller }) => { const result = await controller.resolvePasswordSyncState(); - expect(result).toBe(PasswordChangeRecoveryStatus.EnterNewPassword); + expect(result).toBe(PasswordSyncStatus.EnterNewPassword); expect(controller.state.passwordChangePhase).toBe( SeedlessPasswordChangePhase.SeedlessCommitted, ); @@ -4795,7 +4795,7 @@ describe('SeedlessOnboardingController', () => { }, async ({ controller }) => { const result = await controller.resolvePasswordSyncState(); - expect(result).toBe(PasswordChangeRecoveryStatus.ReconcileKeyring); + expect(result).toBe(PasswordSyncStatus.ReconcileKeyring); }, ); }); @@ -4811,7 +4811,7 @@ describe('SeedlessOnboardingController', () => { }, async ({ controller }) => { const result = await controller.resolvePasswordSyncState(); - expect(result).toBe(PasswordChangeRecoveryStatus.SyncKey); + expect(result).toBe(PasswordSyncStatus.SyncKey); }, ); }); @@ -4827,7 +4827,7 @@ describe('SeedlessOnboardingController', () => { }, async ({ controller }) => { const result = await controller.resolvePasswordSyncState(); - expect(result).toBe(PasswordChangeRecoveryStatus.Unknown); + expect(result).toBe(PasswordSyncStatus.Unknown); }, ); }); @@ -4844,7 +4844,7 @@ describe('SeedlessOnboardingController', () => { }, async ({ controller }) => { const result = await controller.resolvePasswordSyncState(); - expect(result).toBe(PasswordChangeRecoveryStatus.InSync); + expect(result).toBe(PasswordSyncStatus.InSync); }, ); }); @@ -4863,7 +4863,7 @@ describe('SeedlessOnboardingController', () => { // Remote auth pub key matches the local one -> not outdated. mockFetchAuthPubKey(toprfClient, base64ToBytes(MOCK_AUTH_PUB_KEY)); const result = await controller.resolvePasswordSyncState(); - expect(result).toBe(PasswordChangeRecoveryStatus.InSync); + expect(result).toBe(PasswordSyncStatus.InSync); expect(controller.state.passwordChangePhase).toBeUndefined(); }, ); @@ -4883,7 +4883,7 @@ describe('SeedlessOnboardingController', () => { // Remote auth pub key differs from the stale local one -> outdated. mockFetchAuthPubKey(toprfClient, base64ToBytes(MOCK_AUTH_PUB_KEY)); const result = await controller.resolvePasswordSyncState(); - expect(result).toBe(PasswordChangeRecoveryStatus.EnterNewPassword); + expect(result).toBe(PasswordSyncStatus.EnterNewPassword); expect(controller.state.passwordChangePhase).toBe( SeedlessPasswordChangePhase.SeedlessCommitted, ); @@ -4906,7 +4906,7 @@ describe('SeedlessOnboardingController', () => { .spyOn(toprfClient, 'fetchAuthPubKey') .mockRejectedValueOnce(new Error('network failure')); const result = await controller.resolvePasswordSyncState(); - expect(result).toBe(PasswordChangeRecoveryStatus.Unknown); + expect(result).toBe(PasswordSyncStatus.Unknown); // The phase is preserved as the recovery signal. expect(controller.state.passwordChangePhase).toBe( SeedlessPasswordChangePhase.SeedlessChangePending, @@ -4933,7 +4933,7 @@ describe('SeedlessOnboardingController', () => { const result = await controller.reconcilePassword({ globalPassword: NEW_PASSWORD, }); - expect(result).toBe(PasswordChangeRecoveryStatus.InSync); + expect(result).toBe(PasswordSyncStatus.InSync); }, ); }); @@ -4952,7 +4952,7 @@ describe('SeedlessOnboardingController', () => { const result = await controller.reconcilePassword({ globalPassword: NEW_PASSWORD, }); - expect(result).toBe(PasswordChangeRecoveryStatus.Unknown); + expect(result).toBe(PasswordSyncStatus.Unknown); expect(controller.state.passwordChangePhase).toBe( SeedlessPasswordChangePhase.SeedlessChangePending, ); @@ -4973,7 +4973,7 @@ describe('SeedlessOnboardingController', () => { const result = await controller.reconcilePassword({ globalPassword: NEW_PASSWORD, }); - expect(result).toBe(PasswordChangeRecoveryStatus.SyncKey); + expect(result).toBe(PasswordSyncStatus.SyncKey); }, ); }); @@ -4991,7 +4991,7 @@ describe('SeedlessOnboardingController', () => { const result = await controller.reconcilePassword({ globalPassword: NEW_PASSWORD, }); - expect(result).toBe(PasswordChangeRecoveryStatus.Unknown); + expect(result).toBe(PasswordSyncStatus.Unknown); }, ); }); @@ -5010,7 +5010,7 @@ describe('SeedlessOnboardingController', () => { const result = await controller.reconcilePassword({ globalPassword: NEW_PASSWORD, }); - expect(result).toBe(PasswordChangeRecoveryStatus.InSync); + expect(result).toBe(PasswordSyncStatus.InSync); }, ); }); @@ -5070,7 +5070,7 @@ describe('SeedlessOnboardingController', () => { globalPassword: NEW_PASSWORD, }); - expect(result).toBe(PasswordChangeRecoveryStatus.ReconcileKeyring); + expect(result).toBe(PasswordSyncStatus.ReconcileKeyring); // Another-device recovery must continue through the local Keyring // reconciliation boundary after the Seedless side is synchronized. expect(controller.state.passwordChangePhase).toBe( @@ -5102,7 +5102,7 @@ describe('SeedlessOnboardingController', () => { globalPassword: NEW_PASSWORD, }); - expect(result).toBe(PasswordChangeRecoveryStatus.Unknown); + expect(result).toBe(PasswordSyncStatus.Unknown); }, ); }); @@ -5156,7 +5156,7 @@ describe('SeedlessOnboardingController', () => { globalPassword: NEW_PASSWORD, }); - expect(result).toBe(PasswordChangeRecoveryStatus.ReconcileKeyring); + expect(result).toBe(PasswordSyncStatus.ReconcileKeyring); expect(controller.state.passwordChangePhase).toBe( SeedlessPasswordChangePhase.LocalKeyringPending, ); @@ -5202,7 +5202,7 @@ describe('SeedlessOnboardingController', () => { await controller.reconcilePassword({ globalPassword: NEW_PASSWORD, }), - ).toBe(PasswordChangeRecoveryStatus.ReconcileKeyring); + ).toBe(PasswordSyncStatus.ReconcileKeyring); expect( controller.state.encryptedKeyringEncryptionKey, ).toBeUndefined(); @@ -5238,7 +5238,7 @@ describe('SeedlessOnboardingController', () => { globalPassword: NEW_PASSWORD, }); - expect(result).toBe(PasswordChangeRecoveryStatus.Unknown); + expect(result).toBe(PasswordSyncStatus.Unknown); // The phase is preserved as the recovery signal. expect(controller.state.passwordChangePhase).toBe( SeedlessPasswordChangePhase.SeedlessCommitted, @@ -5279,7 +5279,7 @@ describe('SeedlessOnboardingController', () => { await controller.reconcilePassword({ globalPassword: NEW_PASSWORD, }), - ).toBe(PasswordChangeRecoveryStatus.Unknown); + ).toBe(PasswordSyncStatus.Unknown); expect(controller.state.passwordChangePhase).toBe( SeedlessPasswordChangePhase.SeedlessCommitted, ); @@ -5329,7 +5329,7 @@ describe('SeedlessOnboardingController', () => { await controller.reconcilePassword({ globalPassword: NEW_PASSWORD, }), - ).toBe(PasswordChangeRecoveryStatus.Unknown); + ).toBe(PasswordSyncStatus.Unknown); }, ); }); @@ -5371,7 +5371,7 @@ describe('SeedlessOnboardingController', () => { await controller.reconcilePassword({ globalPassword: NEW_PASSWORD, }), - ).toBe(PasswordChangeRecoveryStatus.Unknown); + ).toBe(PasswordSyncStatus.Unknown); }, ); }); @@ -5408,7 +5408,7 @@ describe('SeedlessOnboardingController', () => { await controller.reconcilePassword({ globalPassword: NEW_PASSWORD, }), - ).toBe(PasswordChangeRecoveryStatus.Unknown); + ).toBe(PasswordSyncStatus.Unknown); }, ); }); @@ -5453,7 +5453,7 @@ describe('SeedlessOnboardingController', () => { await controller.reconcilePassword({ globalPassword: NEW_PASSWORD, }), - ).toBe(PasswordChangeRecoveryStatus.Unknown); + ).toBe(PasswordSyncStatus.Unknown); }, ); }); @@ -6223,7 +6223,7 @@ describe('SeedlessOnboardingController', () => { const result = await baseMessenger.call( 'SeedlessOnboardingController:resolvePasswordSyncState', ); - expect(result).toBe(PasswordChangeRecoveryStatus.Unknown); + expect(result).toBe(PasswordSyncStatus.Unknown); // Verify that fetchAuthPubKey was only called once (no retry) expect(toprfClient.fetchAuthPubKey).toHaveBeenCalledTimes(1); diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index c2362213e98..906ca47618c 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -51,7 +51,7 @@ import { SeedlessOnboardingControllerErrorMessage, SeedlessOnboardingMigrationVersion, SeedlessPasswordChangePhase, - PasswordChangeRecoveryStatus, + PasswordSyncStatus, Web3AuthNetwork, } from './constants.js'; import { @@ -2371,11 +2371,11 @@ export class SeedlessOnboardingController< * @param options.skipCache - Whether to bypass the outdated cache. Ignored * for `SEEDLESS_CHANGE_PENDING`, which always forces a remote check. * @returns The sync/recovery resolution. On any failure the last known phase - * is preserved and `PasswordChangeRecoveryStatus.Unknown` is returned. + * is preserved and `PasswordSyncStatus.Unknown` is returned. */ async resolvePasswordSyncState(options?: { skipCache?: boolean; - }): Promise { + }): Promise { const phase = this.state.passwordChangePhase; switch (phase) { case undefined: { @@ -2386,11 +2386,11 @@ export class SeedlessOnboardingController< skipCache: options?.skipCache, }); return outdated - ? PasswordChangeRecoveryStatus.PasswordOutdated - : PasswordChangeRecoveryStatus.InSync; + ? PasswordSyncStatus.PasswordOutdated + : PasswordSyncStatus.InSync; } catch { // Remote state could not be established. Keep the wallet locked. - return PasswordChangeRecoveryStatus.Unknown; + return PasswordSyncStatus.Unknown; } } case SeedlessPasswordChangePhase.SeedlessChangePending: { @@ -2408,25 +2408,25 @@ export class SeedlessOnboardingController< // Remote did not commit. Clear the phase; unlock with the old // password normally. this.#writePasswordChangePhase(undefined); - return PasswordChangeRecoveryStatus.InSync; + return PasswordSyncStatus.InSync; } // Remote committed. Advance so recovery reconciles the local // Seedless side with the new password. this.#writePasswordChangePhase( SeedlessPasswordChangePhase.SeedlessCommitted, ); - return PasswordChangeRecoveryStatus.EnterNewPassword; + return PasswordSyncStatus.EnterNewPassword; } catch { // Remote state could not be established. Preserve the phase and // keep the wallet locked. - return PasswordChangeRecoveryStatus.Unknown; + return PasswordSyncStatus.Unknown; } }); } case SeedlessPasswordChangePhase.SeedlessCommitted: - return PasswordChangeRecoveryStatus.EnterNewPassword; + return PasswordSyncStatus.EnterNewPassword; case SeedlessPasswordChangePhase.LocalKeyringPending: - return PasswordChangeRecoveryStatus.ReconcileKeyring; + return PasswordSyncStatus.ReconcileKeyring; default: // Terminal phases (KEY_SYNC_PENDING, UNKNOWN) and any unrecognized // persisted value share routing. @@ -2459,20 +2459,20 @@ export class SeedlessOnboardingController< * @param params - The reconciliation parameters. * @param params.globalPassword - The current global password. * @returns The reconciliation result. On any failure the last known phase is - * preserved and `PasswordChangeRecoveryStatus.Unknown` is returned. + * preserved and `PasswordSyncStatus.Unknown` is returned. */ async reconcilePassword({ globalPassword, }: { globalPassword: string; - }): Promise { + }): Promise { return await this.#withControllerLock(async () => { const phase = this.state.passwordChangePhase; switch (phase) { case SeedlessPasswordChangePhase.SeedlessChangePending: // Remote state must be resolved first via // resolvePasswordSyncState. - return PasswordChangeRecoveryStatus.Unknown; + return PasswordSyncStatus.Unknown; case SeedlessPasswordChangePhase.SeedlessCommitted: case SeedlessPasswordChangePhase.LocalKeyringPending: { try { @@ -2483,11 +2483,11 @@ export class SeedlessOnboardingController< this.#writePasswordChangePhase( SeedlessPasswordChangePhase.LocalKeyringPending, ); - return PasswordChangeRecoveryStatus.ReconcileKeyring; + return PasswordSyncStatus.ReconcileKeyring; } catch { // Reconciliation failed (e.g. wrong password or transient // remote error). Preserve the phase and keep the wallet locked. - return PasswordChangeRecoveryStatus.Unknown; + return PasswordSyncStatus.Unknown; } } case undefined: { @@ -2501,17 +2501,17 @@ export class SeedlessOnboardingController< skipLock: true, }); if (!outdated) { - return PasswordChangeRecoveryStatus.InSync; + return PasswordSyncStatus.InSync; } await this.#runPasswordSyncFlow(globalPassword); this.#writePasswordChangePhase( SeedlessPasswordChangePhase.LocalKeyringPending, ); - return PasswordChangeRecoveryStatus.ReconcileKeyring; + return PasswordSyncStatus.ReconcileKeyring; } catch { // Sync failed (e.g. wrong password or transient remote error). // Keep the wallet locked. - return PasswordChangeRecoveryStatus.Unknown; + return PasswordSyncStatus.Unknown; } } default: @@ -2560,15 +2560,15 @@ export class SeedlessOnboardingController< */ #statusForTerminalPhase( phase: SeedlessPasswordChangePhase, - ): PasswordChangeRecoveryStatus { + ): PasswordSyncStatus { switch (phase) { case SeedlessPasswordChangePhase.KeySyncPending: - return PasswordChangeRecoveryStatus.SyncKey; + return PasswordSyncStatus.SyncKey; case SeedlessPasswordChangePhase.Unknown: - return PasswordChangeRecoveryStatus.Unknown; + return PasswordSyncStatus.Unknown; default: // An unrecognized persisted phase is treated as no change in progress. - return PasswordChangeRecoveryStatus.InSync; + return PasswordSyncStatus.InSync; } } diff --git a/packages/seedless-onboarding-controller/src/constants.ts b/packages/seedless-onboarding-controller/src/constants.ts index d18584a6df2..bbc3b51ae79 100644 --- a/packages/seedless-onboarding-controller/src/constants.ts +++ b/packages/seedless-onboarding-controller/src/constants.ts @@ -46,16 +46,17 @@ export enum SeedlessPasswordChangePhase { } /** - * The outcome of a password-sync / password-change recovery step, returned by - * `resolvePasswordSyncState` (read + resolve, no password) and - * `reconcilePassword` (apply, with password). + * The next step for the client after a password-sync or password-change + * recovery check. Returned by `resolvePasswordSyncState` (read + resolve, no + * password) and `reconcilePassword` (apply, with password). * - * The controller owns the Seedless-side recovery sequencing; the client owns - * the Keyring-side steps (it must call `KeyringController` directly) and UI - * routing based on this status. See - * [0003](./docs/0003-controller-owned-password-change-recovery-plan.md). + * Covers both an interrupted local password change and an another-device + * password change. The controller owns Seedless-side sequencing; the client + * owns the Keyring-side steps (it must call `KeyringController` directly) + * and UI routing based on this status. See + * [0002](./docs/0002-password-change-recovery-flow.md). */ -export enum PasswordChangeRecoveryStatus { +export enum PasswordSyncStatus { /** The local and remote passwords are synchronized; no recovery action is needed. Unlock normally. */ InSync = 'in-sync', /** No lifecycle is in flight but the remote password changed (e.g. another device changed it). Prompt for the new password, then call `reconcilePassword`. */ diff --git a/packages/seedless-onboarding-controller/src/index.ts b/packages/seedless-onboarding-controller/src/index.ts index e1083740199..7f9289db5cb 100644 --- a/packages/seedless-onboarding-controller/src/index.ts +++ b/packages/seedless-onboarding-controller/src/index.ts @@ -55,7 +55,7 @@ export { AuthConnection, SecretType, SeedlessPasswordChangePhase, - PasswordChangeRecoveryStatus, + PasswordSyncStatus, } from './constants.js'; export { SecretMetadata } from './SecretMetadata.js'; export { From 264d60c253109c6f49b3604957c927a3dca14fa3 Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 10 Sep 2026 15:15:56 +0800 Subject: [PATCH 09/14] fix: handle unexcepted error during keyringEncKey import --- .../0001-seedless-password-change-recovery.md | 4 +- .../0002-password-change-recovery-flow.md | 10 +- ...ler-owned-password-change-recovery-plan.md | 69 +++- .../src/SeedlessOnboardingController.test.ts | 270 +++++++++++---- .../src/SeedlessOnboardingController.ts | 322 +++++++++++------- 5 files changed, 466 insertions(+), 209 deletions(-) diff --git a/packages/seedless-onboarding-controller/docs/0001-seedless-password-change-recovery.md b/packages/seedless-onboarding-controller/docs/0001-seedless-password-change-recovery.md index 844ba886d5e..acfb376d3fd 100644 --- a/packages/seedless-onboarding-controller/docs/0001-seedless-password-change-recovery.md +++ b/packages/seedless-onboarding-controller/docs/0001-seedless-password-change-recovery.md @@ -59,7 +59,7 @@ The new controller work is: - Add a persisted password-change lifecycle state/phase to `SeedlessOnboardingControllerState`, with persistence metadata. The lifecycle must not store passwords, SRPs, raw Keyring encryption keys, or decrypted backup material. - Modify `changePassword` to update the lifecycle after each relevant operation: before the remote change, after remote commitment, after the local Seedless vault/state update, and when the operation fails or becomes ambiguous. -- Modify `storeKeyringEncryptionKey` to update the lifecycle after the encrypted Keyring encryption key has been stored in controller state. The encrypted-key update and lifecycle update should be adjacent so observers do not see an inconsistent intermediate controller state. +- Keep `storeKeyringEncryptionKey` lifecycle-neutral. Password-change-specific commits persist the encrypted Keyring encryption key and lifecycle phase together, while later client-driven synchronization uses the explicit lifecycle-advance methods. - Do not let `storeKeyringEncryptionKey` clear the lifecycle by itself. Completion also requires client confirmation of the local Keyring state, remote synchronization, and durable persistence. - Ensure a thrown error after a partial mutation does not reset the lifecycle to the pre-operation state. The last known phase must remain available for recovery. - Facilitate the existing password-sync operations for both post-remote-commit recovery branches: @@ -88,7 +88,7 @@ Wallet locking for password-change errors is also a client responsibility. The c - Persist only non-sensitive transaction data, such as lifecycle phase, transaction identifier, timestamps, retry metadata, and non-sensitive error classification. - Write `SEEDLESS_CHANGE_PENDING` before the first remote mutation. - Write `SEEDLESS_COMMITTED` only after remote commitment is confirmed by the server or an authoritative status check. -- Advance the lifecycle after each `changePassword` and `storeKeyringEncryptionKey` operation so a later unlock can identify the last known boundary, while treating the phase as advisory when persistence may have been interrupted. +- Advance the lifecycle at the explicit password-change and recovery boundaries so a later unlock can identify the last known boundary. `storeKeyringEncryptionKey` only persists the encrypted key and does not advance or clear the lifecycle. - Use an awaitable durable persistence operation for lifecycle transitions and the final clear. The generic debounced state-change path must not be the only durability boundary. - Serialize password-change and recovery operations. A second request must be rejected or queued until the first transaction is cleared (no change in progress) or reaches an explicitly recoverable terminal state. - Make recovery verify the actual cryptographic state before mutating either controller. diff --git a/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md b/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md index b3a7c3ed767..98a82ba1953 100644 --- a/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md +++ b/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md @@ -150,6 +150,7 @@ unlock render / submit 3. **Unlock routing.** On unlock (page render _and_ password submit), read `passwordChangePhase` from controller state, then call `resolvePasswordSyncState({ skipCache })`. Route UI from the returned status using the table above. Do not classify a password as invalid until recovery has run. 4. **Two-step UX.** + - Step 1 (password-less): `resolvePasswordSyncState` decides whether the old or new password is needed. - Step 2 (password-consuming): only after step 1 returns `enter-new-password` / `password-outdated`, prompt for the new password and call `reconcilePassword({ globalPassword })`. @@ -212,7 +213,8 @@ Controller `this.update(...)` calls happen: - before the first remote mutation (`SEEDLESS_CHANGE_PENDING`); - after authoritative remote commitment (`SEEDLESS_COMMITTED`); - after the local Seedless vault rewrite (`LOCAL_KEYRING_PENDING`); -- after local Keyring-key storage when that update is coupled to a lifecycle write; +- the password-change commit writes the vault, `authPubKey`, encrypted + Keyring key, and lifecycle phase together; - after an explicit clear (no change in progress). These publish `SeedlessOnboardingController:stateChange`; they are not awaited durability boundaries. @@ -227,7 +229,7 @@ A password-change operation must never be retried as a fresh `changePassword` / ### `changePassword` behavior -`changePassword` is now lifecycle-aware: it writes `SEEDLESS_CHANGE_PENDING` before the first remote mutation, `SEEDLESS_COMMITTED` after authoritative remote commitment, and `LOCAL_KEYRING_PENDING` after the local Seedless vault rewrite. It rejects a second concurrent change with `PasswordChangeInProgress`. It reuses the existing `verifyVaultPassword`, `#assertPasswordInSync({ skipCache: true })`, `#changeEncryptionKey` (via `#executeWithTokenRefresh`), `#createNewVaultWithAuthData`, and `storeKeyringEncryptionKey`. A rejected `#changeEncryptionKey` Promise is not proof that the server did not mutate; only a definitive server result may clear the lifecycle. +`changePassword` is now lifecycle-aware: it writes `SEEDLESS_CHANGE_PENDING` before the first remote mutation, `SEEDLESS_COMMITTED` after authoritative remote commitment, and `LOCAL_KEYRING_PENDING` after the local Seedless vault rewrite. It rejects a second concurrent change with `PasswordChangeInProgress`. It reuses the existing `verifyVaultPassword`, `#assertPasswordInSync({ skipCache: true })`, `#changeEncryptionKey` (via `#executeWithTokenRefresh`), and `#commitPasswordChangeState`. The final commit writes the rewritten vault, `authPubKey`, encrypted Keyring key, and lifecycle phase in one state update. A rejected `#changeEncryptionKey` Promise is not proof that the server did not mutate; only a definitive server result may clear the lifecycle. ### `storeKeyringEncryptionKey` behavior @@ -267,7 +269,9 @@ All controller-package work is complete: - Lifecycle model, helpers, metadata, exports. - Lifecycle-aware `changePassword` with concurrency guard and phase preservation on error. -- Lifecycle-aware `storeKeyringEncryptionKey`. +- Lifecycle-neutral `storeKeyringEncryptionKey`; password-change lifecycle + phases are committed by the password-change flow and explicit lifecycle + methods. - `resolvePasswordSyncState` + `reconcilePassword` (Option A: controller owns the Seedless side). - `markPasswordChangeKeySyncPending` / `clearPasswordChangePhase`. - Messenger action types, package exports, and unit tests (290 tests, 100% statement / 99.22% branch coverage). diff --git a/packages/seedless-onboarding-controller/docs/0003-controller-owned-password-change-recovery-plan.md b/packages/seedless-onboarding-controller/docs/0003-controller-owned-password-change-recovery-plan.md index 95ac3d8669b..ccd65da1780 100644 --- a/packages/seedless-onboarding-controller/docs/0003-controller-owned-password-change-recovery-plan.md +++ b/packages/seedless-onboarding-controller/docs/0003-controller-owned-password-change-recovery-plan.md @@ -12,6 +12,13 @@ This document plans the migration to **Option B**: the controller owns the entir This migration is deferred until Option A is shipped and tested, so the recovery contract is exercised end-to-end before the coupling is introduced. +The `encryptedKeyringEncryptionKey` state field remains optional during the +compatibility period. Users created before that field was introduced may have +valid Seedless state without it, and the controller cannot reconstruct the +missing value from persisted data. The migration therefore includes an +unlock-time backfill before the field becomes a precondition for password +changes and password reconciliation. + ## Goal A single controller method performs the entire recovery for any set phase and returns only a final status. The client no longer sequences Seedless or Keyring operations; it only supplies the password and reacts to the status. @@ -21,22 +28,47 @@ A single controller method performs the entire recovery for any set phase and re - `reconcilePassword({ globalPassword })` does the Seedless-side steps (`#checkIsPasswordOutdated({ skipCache: true })`, chain unlock, local vault rewrite, lifecycle advances) and returns a result describing the remaining Keyring-side step. Remote-state resolution for `SeedlessChangePending` is owned by `resolvePasswordSyncState()` (password-less), which the client calls first. - The client classifies the local Keyring via `KeyringController:verifyPassword`, then runs the old-Keyring or new-Keyring branch itself, calling `KeyringController:submitEncryptionKey` / `changePassword` / `exportEncryptionKey` and the controller's `loadKeyringEncryptionKey` / `storeKeyringEncryptionKey` / `markPasswordChangeKeySyncPending` / `clearPasswordChangePhase`. - `AllowedActions = never`; the controller does not call `KeyringController`. +- `encryptedKeyringEncryptionKey` may be missing for legacy users or users whose initial Keyring-key synchronization did not complete. Missing state is a migration signal, not proof that the user has no Keyring encryption key. +- During unlock, the client must backfill a missing value by calling `KeyringController:exportEncryptionKey` and then `SeedlessOnboardingController:storeKeyringEncryptionKey` before exposing the normal unlocked wallet flow. The backfill is lifecycle-neutral. ## Target state (Option B) - `AllowedActions` includes `KeyringController:verifyPassword`, `KeyringController:submitEncryptionKey`, `KeyringController:changePassword`, `KeyringController:exportEncryptionKey` (and `KeyringController:setLocked` if locking is folded in). - `reconcilePassword({ globalPassword })` performs the full transaction: - 1. Resolve remote state for `SeedlessChangePending` via `resolvePasswordSyncState()` (which runs `#checkIsPasswordOutdated({ skipCache: true })`). - 2. Reconcile the Seedless side (internal chain unlock + local vault rewrite) for `SeedlessCommitted` / `LocalKeyringPending`. - 3. Classify the local Keyring via `KeyringController:verifyPassword(newPassword)`. - 4. Old-Keyring branch: `loadKeyringEncryptionKey` → `KeyringController:submitEncryptionKey` → `KeyringController:changePassword` → `KeyringController:exportEncryptionKey` → `storeKeyringEncryptionKey` → `markPasswordChangeKeySyncPending`. - 5. New-Keyring branch: `KeyringController:exportEncryptionKey` → `storeKeyringEncryptionKey` → `markPasswordChangeKeySyncPending`. - 6. `KeySyncPending`: `KeyringController:exportEncryptionKey` → `storeKeyringEncryptionKey` → remote key sync → `clearPasswordChangePhase`. - 7. Return a final status only (`PasswordSyncStatus.InSync | Unknown`). + 1. During unlock, backfill a missing `encryptedKeyringEncryptionKey` from `KeyringController:exportEncryptionKey` before allowing password-change recovery. + 2. Resolve remote state for `SeedlessChangePending` via `resolvePasswordSyncState()` (which runs `#checkIsPasswordOutdated({ skipCache: true })`). + 3. Reconcile the Seedless side (internal chain unlock + local vault rewrite) for `SeedlessCommitted` / `LocalKeyringPending`. + 4. Classify the local Keyring via `KeyringController:verifyPassword(newPassword)`. + 5. Old-Keyring branch: `loadKeyringEncryptionKey` → `KeyringController:submitEncryptionKey` → `KeyringController:changePassword` → `KeyringController:exportEncryptionKey` → `storeKeyringEncryptionKey` → `markPasswordChangeKeySyncPending`. + 6. New-Keyring branch: `KeyringController:exportEncryptionKey` → `storeKeyringEncryptionKey` → `markPasswordChangeKeySyncPending`. + 7. `KeySyncPending`: `KeyringController:exportEncryptionKey` → `storeKeyringEncryptionKey` → remote key sync → `clearPasswordChangePhase`. + 8. Return a final status only (`PasswordSyncStatus.InSync | Unknown`). - The client supplies the password, calls one method, and routes UI from the status. It performs no cross-controller sequencing. ## Changes +### 0. Keyring encryption-key backfill and precondition + +- Keep `encryptedKeyringEncryptionKey` optional for backward-compatible state + loading and migration. +- Treat a missing value as an incomplete local synchronization state. It must + not be interpreted as evidence that the local Keyring encryption key does + not exist. +- During unlock, after the Keyring is available, call + `KeyringController:exportEncryptionKey` when the Seedless controller has no + `encryptedKeyringEncryptionKey`, then persist the result through + `SeedlessOnboardingController:storeKeyringEncryptionKey`. +- Perform this backfill for both legacy users and any new account or + rehydration flow that reaches unlock without a stored value. +- Keep the backfill idempotent and lifecycle-neutral. It must not clear or + advance `passwordChangePhase`. +- If export or persistence fails, keep the wallet locked and do not begin + `changePassword` or `reconcilePassword`. +- After the migration is complete, require the value as a precondition for + `changePassword` and `reconcilePassword`. A missing value must fail closed + into the recovery/unknown path rather than allowing a password mutation to + proceed without a recoverable Keyring encryption key. + ### 1. Messenger dependency - Add the `KeyringController` action types to `AllowedActions` in `SeedlessOnboardingController.ts`. @@ -50,7 +82,8 @@ A single controller method performs the entire recovery for any set phase and re - Preserve all existing invariants: - No retries of `changePassword` / `changeEncKey`; reconcile only via the password-sync flow. - Preserve the last known phase on failure; do not write `UNKNOWN` from the happy path. - - Keep `storeKeyringEncryptionKey` lifecycle-neutral as a public method (the internal coupling still uses the private `#persistKeyringEncryptionKey` with a phase). + - Require `encryptedKeyringEncryptionKey` before starting a password-change or reconciliation transaction after the backfill migration. + - Keep `storeKeyringEncryptionKey` lifecycle-neutral as a public method. Its private persistence helper only updates the encrypted key; lifecycle phases are committed separately with the password-change state. - Serialize under `#withControllerLock`. The cross-controller Keyring operations happen while the controller lock is held; document that the client coordinator lock (Phase 7) must not deadlock with it. ### 3. Contracts and exports @@ -75,6 +108,15 @@ A single controller method performs the entire recovery for any set phase and re ## Test plan +- Legacy unlock with no `encryptedKeyringEncryptionKey` calls + `KeyringController:exportEncryptionKey`, stores the result, and does not + advance the password-change lifecycle. +- New-account creation and rehydration persist the exported Keyring + encryption key before normal unlock completes. +- Export or persistence failure keeps the wallet locked and prevents + `changePassword` and `reconcilePassword` from starting. +- After backfill, `changePassword` and `reconcilePassword` reject or route to + `Unknown` when the required encrypted Keyring key is missing. - Controller unit tests for every phase, each branch (old/new Keyring), and each failure injection point (remote check error, chain-unlock error, `verifyPassword` error, `submitEncryptionKey` error, `changePassword` error, `exportEncryptionKey` error, `storeKeyringEncryptionKey` error, remote key-sync error). - Assert the final status and the resulting `passwordChangePhase` for each. - Assert no `changePassword`/`changeEncKey` retry occurs on any recovery path. @@ -84,7 +126,10 @@ A single controller method performs the entire recovery for any set phase and re ## Migration order 1. Land Option A and ship it; gather client integration feedback. -2. Add the `KeyringController` messenger dependency and mock wiring (behind no behavior change yet). -3. Fold the Keyring-side steps into `reconcilePassword`; change the return shape to final status. -4. Update the recovery flow guide (0002), exports, and clients. -5. Run the full controller + client test suites; remove the now-dead client sequencing code. +2. Add unlock-time backfill for missing `encryptedKeyringEncryptionKey` using `KeyringController:exportEncryptionKey` and `storeKeyringEncryptionKey`. +3. Ensure new-account creation and rehydration persist the exported Keyring encryption key before normal unlock. +4. Add the `KeyringController` messenger dependency and mock wiring (behind no behavior change yet). +5. Enforce the stored encrypted Keyring key as a precondition for `changePassword` and `reconcilePassword`. +6. Fold the Keyring-side steps into `reconcilePassword`; change the return shape to final status. +7. Update the recovery flow guide (0002), exports, and clients. +8. Run the full controller + client test suites; remove the now-dead client sequencing code. diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index f14f3a43921..1f27dea5529 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -529,6 +529,34 @@ async function mockCreateToprfKeyAndBackupSeedPhrase< ); } +/** + * Creates a mock user with a vault and stores the Keyring encryption key. + * + * @param toprfClient - The ToprfSecureBackup instance. + * @param controller - The SeedlessOnboardingController instance. + * @param baseMessenger - The root messenger to call the methods through. + * @param password - The mock password. + */ +async function newUserSetup( + toprfClient: ToprfSecureBackup, + controller: SeedlessOnboardingController, + baseMessenger: RootMessenger, + password: string, +): Promise { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + baseMessenger, + password, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + await baseMessenger.call( + 'SeedlessOnboardingController:storeKeyringEncryptionKey', + MOCK_KEYRING_ENCRYPTION_KEY, + ); +} + /** * Creates a mock vault. * @@ -4017,16 +4045,22 @@ describe('SeedlessOnboardingController', () => { const { encKey: newEncKey, authKeyPair: newAuthKeyPair } = mockChangeEncKey(toprfClient, NEW_MOCK_PASSWORD); - // Observe the persisted lifecycle phases as the password change - // progresses: SEEDLESS_CHANGE_PENDING is written before the remote - // mutation, SEEDLESS_COMMITTED after it succeeds, then + // Observe lifecycle transitions as the password change progresses: + // SEEDLESS_CHANGE_PENDING is written before the remote mutation, + // SEEDLESS_COMMITTED after it succeeds, then // LOCAL_KEYRING_PENDING once the local vault is rewritten. - const observedPhases: (SeedlessPasswordChangePhase | undefined)[] = - []; + const observedPhaseTransitions: ( + | SeedlessPasswordChangePhase + | undefined + )[] = []; + let previousPhase = controller.state.passwordChangePhase; baseMessenger.subscribe( 'SeedlessOnboardingController:stateChange', (state) => { - observedPhases.push(state.passwordChangePhase); + if (state.passwordChangePhase !== previousPhase) { + observedPhaseTransitions.push(state.passwordChangePhase); + } + previousPhase = state.passwordChangePhase; }, ); @@ -4062,12 +4096,11 @@ describe('SeedlessOnboardingController', () => { // The lifecycle advances through every phase in order and ends on // LOCAL_KEYRING_PENDING, signalling the local rewrite completed. - expect(observedPhases).toContain( + expect(observedPhaseTransitions).toStrictEqual([ SeedlessPasswordChangePhase.SeedlessChangePending, - ); - expect(observedPhases).toContain( SeedlessPasswordChangePhase.SeedlessCommitted, - ); + SeedlessPasswordChangePhase.LocalKeyringPending, + ]); expect(controller.state.passwordChangePhase).toBe( SeedlessPasswordChangePhase.LocalKeyringPending, ); @@ -4084,20 +4117,11 @@ describe('SeedlessOnboardingController', () => { }), }, async ({ controller, toprfClient, baseMessenger }) => { - await mockCreateToprfKeyAndBackupSeedPhrase( + await newUserSetup( toprfClient, controller, baseMessenger, MOCK_PASSWORD, - MOCK_SEED_PHRASE, - MOCK_KEYRING_ID, - ); - - // Store an existing keyring encryption key so changePassword - // exercises the re-encryption path. - await baseMessenger.call( - 'SeedlessOnboardingController:storeKeyringEncryptionKey', - MOCK_KEYRING_ENCRYPTION_KEY, ); const oldEncryptedKeyringEncryptionKey = controller.state.encryptedKeyringEncryptionKey; @@ -5024,19 +5048,11 @@ describe('SeedlessOnboardingController', () => { }), }, async ({ controller, toprfClient, baseMessenger }) => { - // Create a vault under the old password so the password-sync flow - // has a vault to recover and rewrite. - await mockCreateToprfKeyAndBackupSeedPhrase( + await newUserSetup( toprfClient, controller, baseMessenger, OLD_PASSWORD, - MOCK_SEED_PHRASE, - MOCK_KEYRING_ID, - ); - await baseMessenger.call( - 'SeedlessOnboardingController:storeKeyringEncryptionKey', - MOCK_KEYRING_ENCRYPTION_KEY, ); // Remote auth pub key differs from the local one -> outdated, so @@ -5066,11 +5082,38 @@ describe('SeedlessOnboardingController', () => { pwEncKey: mockToprfEncryptor.derivePwEncKey(OLD_PASSWORD), }); + const observedPhases: SeedlessPasswordChangePhase[] = []; + let localKeyringPendingStateUpdates = 0; + let previousPhase = controller.state.passwordChangePhase; + baseMessenger.subscribe( + 'SeedlessOnboardingController:stateChange', + (state) => { + if ( + state.passwordChangePhase === + SeedlessPasswordChangePhase.LocalKeyringPending + ) { + localKeyringPendingStateUpdates += 1; + } + if ( + state.passwordChangePhase !== undefined && + state.passwordChangePhase !== previousPhase + ) { + observedPhases.push(state.passwordChangePhase); + } + previousPhase = state.passwordChangePhase; + }, + ); + const result = await controller.reconcilePassword({ globalPassword: NEW_PASSWORD, }); expect(result).toBe(PasswordSyncStatus.ReconcileKeyring); + expect(observedPhases).toStrictEqual([ + SeedlessPasswordChangePhase.SeedlessCommitted, + SeedlessPasswordChangePhase.LocalKeyringPending, + ]); + expect(localKeyringPendingStateUpdates).toBe(1); // Another-device recovery must continue through the local Keyring // reconciliation boundary after the Seedless side is synchronized. expect(controller.state.passwordChangePhase).toBe( @@ -5117,19 +5160,11 @@ describe('SeedlessOnboardingController', () => { }), }, async ({ controller, toprfClient, baseMessenger }) => { - // Create a vault under the old password so the password-sync flow - // has a vault to recover and rewrite. - await mockCreateToprfKeyAndBackupSeedPhrase( + await newUserSetup( toprfClient, controller, baseMessenger, OLD_PASSWORD, - MOCK_SEED_PHRASE, - MOCK_KEYRING_ID, - ); - await baseMessenger.call( - 'SeedlessOnboardingController:storeKeyringEncryptionKey', - MOCK_KEYRING_ENCRYPTION_KEY, ); // Mock the password-sync flow for the new password. recoverEncKey @@ -5152,6 +5187,19 @@ describe('SeedlessOnboardingController', () => { pwEncKey: mockToprfEncryptor.derivePwEncKey(OLD_PASSWORD), }); + let localKeyringPendingStateUpdates = 0; + baseMessenger.subscribe( + 'SeedlessOnboardingController:stateChange', + (state) => { + if ( + state.passwordChangePhase === + SeedlessPasswordChangePhase.LocalKeyringPending + ) { + localKeyringPendingStateUpdates += 1; + } + }, + ); + const result = await controller.reconcilePassword({ globalPassword: NEW_PASSWORD, }); @@ -5160,6 +5208,114 @@ describe('SeedlessOnboardingController', () => { expect(controller.state.passwordChangePhase).toBe( SeedlessPasswordChangePhase.LocalKeyringPending, ); + expect(localKeyringPendingStateUpdates).toBe(1); + expect(await controller.loadKeyringEncryptionKey()).toBe( + MOCK_KEYRING_ENCRYPTION_KEY, + ); + }, + ); + }); + + it('persists recovery state atomically so an interrupted rewrite can be retried', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + passwordChangePhase: SeedlessPasswordChangePhase.SeedlessCommitted, + }), + }, + async ({ controller, toprfClient, baseMessenger }) => { + await newUserSetup( + toprfClient, + controller, + baseMessenger, + OLD_PASSWORD, + ); + + const vaultBeforeSync = controller.state.vault; + const oldEncryptedKeyringEncryptionKey = + controller.state.encryptedKeyringEncryptionKey; + + const mockToprfEncryptor = createMockToprfEncryptor(); + const newEncKey = mockToprfEncryptor.deriveEncKey(NEW_PASSWORD); + const newPwEncKey = mockToprfEncryptor.derivePwEncKey(NEW_PASSWORD); + const newAuthKeyPair = + mockToprfEncryptor.deriveAuthKeyPair(NEW_PASSWORD); + jest.spyOn(toprfClient, 'recoverEncKey').mockResolvedValue({ + encKey: newEncKey, + authKeyPair: newAuthKeyPair, + pwEncKey: newPwEncKey, + rateLimitResetResult: Promise.resolve(), + keyShareIndex: 1, + }); + jest + .spyOn(toprfClient, 'recoverPwEncKey') + .mockResolvedValueOnce({ + // The first attempt unlocks the old local vault. + pwEncKey: mockToprfEncryptor.derivePwEncKey(OLD_PASSWORD), + }) + .mockResolvedValue({ + // A retry after the rewrite unlocks the new local vault. + pwEncKey: newPwEncKey, + }); + + let interruptPersistence = true; + const interruptingListener = ( + state: SeedlessOnboardingControllerState, + ): void => { + if (interruptPersistence && state.vault !== vaultBeforeSync) { + interruptPersistence = false; + throw new Error('simulated persistence interruption'); + } + }; + baseMessenger.subscribe( + 'SeedlessOnboardingController:stateChange', + interruptingListener, + ); + + expect( + await controller.reconcilePassword({ + globalPassword: NEW_PASSWORD, + }), + ).toBe(PasswordSyncStatus.Unknown); + + baseMessenger.unsubscribe( + 'SeedlessOnboardingController:stateChange', + interruptingListener, + ); + + // The rewritten vault must never be persisted without its matching + // auth key, Keyring ciphertext, and recovery phase. + expect(controller.state.authPubKey).toBe( + bytesToBase64(newAuthKeyPair.pk), + ); + expect(controller.state.encryptedKeyringEncryptionKey).not.toBe( + oldEncryptedKeyringEncryptionKey, + ); + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.LocalKeyringPending, + ); + const recoveredKeyringEncryptionKey = managedNonce(gcm)( + newPwEncKey, + ).decrypt( + base64ToBytes( + controller.state.encryptedKeyringEncryptionKey as string, + ), + ); + expect(bytesToString(recoveredKeyringEncryptionKey)).toBe( + MOCK_KEYRING_ENCRYPTION_KEY, + ); + + // Simulate a restart so the retry cannot use a stale decrypted-vault + // cache from before the interrupted rewrite. + await controller.setLocked(); + + expect( + await controller.reconcilePassword({ + globalPassword: NEW_PASSWORD, + }), + ).toBe(PasswordSyncStatus.ReconcileKeyring); expect(await controller.loadKeyringEncryptionKey()).toBe( MOCK_KEYRING_ENCRYPTION_KEY, ); @@ -5473,18 +5629,11 @@ describe('SeedlessOnboardingController', () => { }), }, async ({ controller, toprfClient, baseMessenger }) => { - await mockCreateToprfKeyAndBackupSeedPhrase( + await newUserSetup( toprfClient, controller, baseMessenger, MOCK_PASSWORD, - MOCK_SEED_PHRASE, - MOCK_KEYRING_ID, - ); - - await baseMessenger.call( - 'SeedlessOnboardingController:storeKeyringEncryptionKey', - MOCK_KEYRING_ENCRYPTION_KEY, ); // The shared key-storage operation must remain lifecycle-neutral @@ -5507,17 +5656,11 @@ describe('SeedlessOnboardingController', () => { }), }, async ({ controller, toprfClient, baseMessenger }) => { - await mockCreateToprfKeyAndBackupSeedPhrase( + await newUserSetup( toprfClient, controller, baseMessenger, MOCK_PASSWORD, - MOCK_SEED_PHRASE, - MOCK_KEYRING_ID, - ); - await baseMessenger.call( - 'SeedlessOnboardingController:storeKeyringEncryptionKey', - MOCK_KEYRING_ENCRYPTION_KEY, ); const loaded = await baseMessenger.call( @@ -6061,7 +6204,6 @@ describe('SeedlessOnboardingController', () => { SeedlessOnboardingControllerErrorMessage.WrongPasswordType, ); - // Setup and store keyring encryption key. await mockCreateToprfKeyAndBackupSeedPhrase( toprfClient, controller, @@ -6091,19 +6233,11 @@ describe('SeedlessOnboardingController', () => { }), }, async ({ controller, toprfClient, baseMessenger }) => { - // Setup and store keyring encryption key. - await mockCreateToprfKeyAndBackupSeedPhrase( + await newUserSetup( toprfClient, controller, baseMessenger, RECOVERED_PASSWORD, - MOCK_SEED_PHRASE, - MOCK_KEYRING_ID, - ); - - await baseMessenger.call( - 'SeedlessOnboardingController:storeKeyringEncryptionKey', - MOCK_KEYRING_ENCRYPTION_KEY, ); const result = await baseMessenger.call( @@ -6123,19 +6257,11 @@ describe('SeedlessOnboardingController', () => { }), }, async ({ controller, toprfClient, baseMessenger }) => { - // Setup and store keyring encryption key. - await mockCreateToprfKeyAndBackupSeedPhrase( + await newUserSetup( toprfClient, controller, baseMessenger, RECOVERED_PASSWORD, - MOCK_SEED_PHRASE, - MOCK_KEYRING_ID, - ); - - await baseMessenger.call( - 'SeedlessOnboardingController:storeKeyringEncryptionKey', - MOCK_KEYRING_ENCRYPTION_KEY, ); await mockChangePassword( diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 906ca47618c..84d07c024ab 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -85,6 +85,14 @@ import { const log = createModuleLogger(projectLogger, controllerName); +type UpdatedVaultState = Pick< + SeedlessOnboardingControllerState, + | 'vault' + | 'vaultEncryptionKey' + | 'vaultEncryptionSalt' + | 'encryptedSeedlessEncryptionKey' +>; + const MESSENGER_EXPOSED_METHODS = [ 'fetchMetadataAccessCreds', 'preloadToprfNodeDetails', @@ -1030,31 +1038,16 @@ export class SeedlessOnboardingController< SeedlessPasswordChangePhase.SeedlessCommitted, ); - // update and encrypt the vault with new password - await this.#createNewVaultWithAuthData({ - password: newPassword, - rawToprfEncryptionKey: newEncKey, - rawToprfPwEncryptionKey: newPwEncKey, - rawToprfAuthKeyPair: newAuthKeyPair, - }); - this.#resetPasswordOutdatedCache(); - // Re-encrypt the existing Keyring encryption key under the new - // password and persist `LOCAL_KEYRING_PENDING` in the same update, so - // observers never see `LOCAL_KEYRING_PENDING` with a stale - // (pre-re-encryption) key. When there is no key to store, advance the - // boundary on its own. - if (keyringEncryptionKey) { - await this.#persistKeyringEncryptionKey( - keyringEncryptionKey, - SeedlessPasswordChangePhase.LocalKeyringPending, - ); - } else { - this.#writePasswordChangePhase( - SeedlessPasswordChangePhase.LocalKeyringPending, - ); - } + await this.#commitPasswordChangeState({ + password: newPassword, + encKey: newEncKey, + pwEncKey: newPwEncKey, + authKeyPair: newAuthKeyPair, + keyringEncryptionKey, + phase: SeedlessPasswordChangePhase.LocalKeyringPending, + }); }; try { @@ -1233,18 +1226,15 @@ export class SeedlessOnboardingController< const { encKey, pwEncKey, authKeyPair } = await this.#recoverEncKey(globalPassword); - await this.#createNewVaultWithAuthData({ + this.#resetPasswordOutdatedCache(); + await this.#commitPasswordChangeState({ password: globalPassword, - rawToprfEncryptionKey: encKey, - rawToprfPwEncryptionKey: pwEncKey, - rawToprfAuthKeyPair: authKeyPair, + encKey, + pwEncKey, + authKeyPair, + keyringEncryptionKey, + phase: SeedlessPasswordChangePhase.LocalKeyringPending, }); - - if (keyringEncryptionKey) { - await this.#persistKeyringEncryptionKey(keyringEncryptionKey); - } - - this.#resetPasswordOutdatedCache(); } /** @@ -1522,39 +1512,14 @@ export class SeedlessOnboardingController< * @param keyringEncryptionKey - The keyring encryption key. */ async storeKeyringEncryptionKey(keyringEncryptionKey: string): Promise { - await this.#persistKeyringEncryptionKey(keyringEncryptionKey); - } - - /** - * Encrypt the keyring encryption key under the current vault password - * encryption key and persist it, optionally advancing the lifecycle - * boundary in the same update. - * - * When `phase` is provided, the boundary is advanced in the same controller - * update as the encrypted key, so observers never see an intermediate state - * where the phase advanced but the key is stale. Without `phase` the - * lifecycle is untouched. - * - * @param keyringEncryptionKey - The keyring encryption key. - * @param phase - Optional lifecycle phase to advance to in the same update. - */ - async #persistKeyringEncryptionKey( - keyringEncryptionKey: string, - phase?: SeedlessPasswordChangePhase, - ): Promise { const { toprfPwEncryptionKey: encKey } = await this.#unlockVaultAndGetVaultData(); - const aes = managedNonce(gcm)(encKey); - const encryptedKeyringEncryptionKey = aes.encrypt( - utf8ToBytes(keyringEncryptionKey), + const encryptedKeyringEncryptionKey = this.#encryptKeyringEncryptionKey( + keyringEncryptionKey, + encKey, ); this.update((state) => { - state.encryptedKeyringEncryptionKey = bytesToBase64( - encryptedKeyringEncryptionKey, - ); - if (phase) { - state.passwordChangePhase = phase; - } + state.encryptedKeyringEncryptionKey = encryptedKeyringEncryptionKey; }); } @@ -2120,74 +2085,191 @@ export class SeedlessOnboardingController< pwEncKey: Uint8Array; }): Promise { await this.#withVaultLock(async () => { - const serializedVaultData = serializeVaultData(vaultData); + const updatedState = await this.#createUpdatedVaultState({ + password, + vaultData, + pwEncKey, + }); - const { vaultEncryptionKey, vaultEncryptionSalt, vault } = this.state; + // update the state with the updated vault data + this.update((state) => { + state.vault = updatedState.vault; + state.vaultEncryptionKey = updatedState.vaultEncryptionKey; + state.vaultEncryptionSalt = updatedState.vaultEncryptionSalt; + state.encryptedSeedlessEncryptionKey = + updatedState.encryptedSeedlessEncryptionKey; + }); - const updatedState: Partial = { - vault, - vaultEncryptionKey, - vaultEncryptionSalt, - encryptedSeedlessEncryptionKey: - this.state.encryptedSeedlessEncryptionKey, - }; + // cache the vault data to avoid decrypting the vault data multiple times + this.#cachedDecryptedVaultData = vaultData; + }); + } - // if the password is provided (not undefined), encrypt the vault with the password - // We gonna prioritize the password encryption here, in case of the operation is `Change Password`. - // We don't wanna re-use the old encryption key from the state. - if (password !== undefined) { - assertIsValidPassword(password); - - // Note that vault encryption using the password is a very costly operation as it involves deriving the encryption key - // from the password using an intentionally slow key derivation function. - // We should make sure that we only call it very intentionally. - const { vault: updatedEncVault, exportedKeyString } = - await this.#vaultEncryptor.encryptWithDetail( - password, - serializedVaultData, - ); + /** + * Create the updated vault state without persisting it. + * + * This method must be called while the vault lock is held. Keeping vault + * encryption separate from the state update allows password-change flows to + * combine the vault fields with their other state changes in one update. + * + * @param params - The parameters for updating the vault. + * @param params.password - The optional password to encrypt the vault. + * @param params.vaultData - The raw vault data to update the vault with. + * @param params.pwEncKey - The global password encryption key. + * @returns The prepared vault state. + */ + async #createUpdatedVaultState({ + password, + vaultData, + pwEncKey, + }: { + password?: string; + vaultData: DeserializedVaultData; + pwEncKey: Uint8Array; + }): Promise { + const serializedVaultData = serializeVaultData(vaultData); + + const { vaultEncryptionKey, vaultEncryptionSalt, vault } = this.state; - updatedState.vault = updatedEncVault; - updatedState.vaultEncryptionKey = exportedKeyString; - updatedState.vaultEncryptionSalt = JSON.parse(updatedEncVault).salt; - - // encrypt the seedless encryption key with the password encryption key from TOPRF network - updatedState.encryptedSeedlessEncryptionKey = - this.#encryptSeedlessEncryptionKey(exportedKeyString, pwEncKey); - } else if (vaultEncryptionKey && vaultEncryptionSalt) { - const encryptionKey = - await this.#vaultEncryptor.importKey(vaultEncryptionKey); - const updatedEncVault = await this.#vaultEncryptor.encryptWithKey( - encryptionKey, + const updatedState: UpdatedVaultState = { + vault, + vaultEncryptionKey, + vaultEncryptionSalt, + encryptedSeedlessEncryptionKey: this.state.encryptedSeedlessEncryptionKey, + }; + + // if the password is provided (not undefined), encrypt the vault with the password + // We gonna prioritize the password encryption here, in case of the operation is `Change Password`. + // We don't wanna re-use the old encryption key from the state. + if (password !== undefined) { + assertIsValidPassword(password); + + // Note that vault encryption using the password is a very costly operation as it involves deriving the encryption key + // from the password using an intentionally slow key derivation function. + // We should make sure that we only call it very intentionally. + const { vault: updatedEncVault, exportedKeyString } = + await this.#vaultEncryptor.encryptWithDetail( + password, serializedVaultData, ); - // NOTE: Referenced from keyring-controller! - // We need to include the salt used to derive the encryption key, to be able to derive it from password again. - updatedEncVault.salt = vaultEncryptionSalt; + updatedState.vault = updatedEncVault; + updatedState.vaultEncryptionKey = exportedKeyString; + updatedState.vaultEncryptionSalt = JSON.parse(updatedEncVault).salt; + + // encrypt the seedless encryption key with the password encryption key from TOPRF network + updatedState.encryptedSeedlessEncryptionKey = + this.#encryptSeedlessEncryptionKey(exportedKeyString, pwEncKey); + } else if (vaultEncryptionKey && vaultEncryptionSalt) { + const encryptionKey = + await this.#vaultEncryptor.importKey(vaultEncryptionKey); + const updatedEncVault = await this.#vaultEncryptor.encryptWithKey( + encryptionKey, + serializedVaultData, + ); - updatedState.vault = JSON.stringify(updatedEncVault); - updatedState.vaultEncryptionKey = vaultEncryptionKey; - updatedState.vaultEncryptionSalt = vaultEncryptionSalt; - } else { - // neither password nor encryption key is provided - throw new Error( - SeedlessOnboardingControllerErrorMessage.MissingCredentials, - ); - } + // NOTE: Referenced from keyring-controller! + // We need to include the salt used to derive the encryption key, to be able to derive it from password again. + updatedEncVault.salt = vaultEncryptionSalt; + + updatedState.vault = JSON.stringify(updatedEncVault); + updatedState.vaultEncryptionKey = vaultEncryptionKey; + updatedState.vaultEncryptionSalt = vaultEncryptionSalt; + } else { + // neither password nor encryption key is provided + throw new Error( + SeedlessOnboardingControllerErrorMessage.MissingCredentials, + ); + } + + return updatedState; + } + + /** + * Persist the local Seedless state for a password change in one update. + * + * This is intentionally separate from the generic vault creation path. The + * password-change boundary includes the vault, authentication public key, + * Keyring encryption key, and lifecycle phase. + * + * @param params - The password-change state to persist. + * @param params.password - The password to encrypt the vault with. + * @param params.encKey - The TOPRF encryption key. + * @param params.pwEncKey - The TOPRF password encryption key. + * @param params.authKeyPair - The TOPRF authentication key pair. + * @param params.keyringEncryptionKey - The decrypted Keyring encryption key. + * @param params.phase - The lifecycle phase to persist. + */ + async #commitPasswordChangeState({ + password, + encKey, + pwEncKey, + authKeyPair, + keyringEncryptionKey, + phase, + }: { + password: string; + encKey: Uint8Array; + pwEncKey: Uint8Array; + authKeyPair: KeyPair; + keyringEncryptionKey?: string; + phase: SeedlessPasswordChangePhase; + }): Promise { + this.#assertIsAuthenticatedUser(this.state); + + const { accessToken, revokeToken } = + await this.#getAccessTokenAndRevokeToken(password); + const vaultData: DeserializedVaultData = { + toprfAuthKeyPair: authKeyPair, + toprfEncryptionKey: encKey, + toprfPwEncryptionKey: pwEncKey, + revokeToken, + accessToken, + }; + + await this.#withVaultLock(async () => { + const updatedVaultState = await this.#createUpdatedVaultState({ + password, + vaultData, + pwEncKey, + }); + const encryptedKeyringEncryptionKey = + keyringEncryptionKey === undefined + ? undefined + : this.#encryptKeyringEncryptionKey(keyringEncryptionKey, pwEncKey); - // update the state with the updated vault data this.update((state) => { - state.vault = updatedState.vault; - state.vaultEncryptionKey = updatedState.vaultEncryptionKey; - state.vaultEncryptionSalt = updatedState.vaultEncryptionSalt; + state.vault = updatedVaultState.vault; + state.vaultEncryptionKey = updatedVaultState.vaultEncryptionKey; + state.vaultEncryptionSalt = updatedVaultState.vaultEncryptionSalt; state.encryptedSeedlessEncryptionKey = - updatedState.encryptedSeedlessEncryptionKey; + updatedVaultState.encryptedSeedlessEncryptionKey; + state.authPubKey = bytesToBase64(authKeyPair.pk); + if (encryptedKeyringEncryptionKey !== undefined) { + state.encryptedKeyringEncryptionKey = encryptedKeyringEncryptionKey; + } + state.passwordChangePhase = phase; }); - // cache the vault data to avoid decrypting the vault data multiple times this.#cachedDecryptedVaultData = vaultData; }); + + this.#setUnlocked(); + } + + /** + * Encrypt the Keyring encryption key with the TOPRF password encryption key. + * + * @param keyringEncryptionKey - The Keyring encryption key. + * @param pwEncKey - The TOPRF password encryption key. + * @returns The encrypted Keyring encryption key in base64 format. + */ + #encryptKeyringEncryptionKey( + keyringEncryptionKey: string, + pwEncKey: Uint8Array, + ): string { + const aes = managedNonce(gcm)(pwEncKey); + return bytesToBase64(aes.encrypt(utf8ToBytes(keyringEncryptionKey))); } /** @@ -2480,9 +2562,6 @@ export class SeedlessOnboardingController< // unlocks the controller and rewrites the local Seedless vault; // both operations are idempotent if the vault is already synced. await this.#runPasswordSyncFlow(globalPassword); - this.#writePasswordChangePhase( - SeedlessPasswordChangePhase.LocalKeyringPending, - ); return PasswordSyncStatus.ReconcileKeyring; } catch { // Reconciliation failed (e.g. wrong password or transient @@ -2503,10 +2582,13 @@ export class SeedlessOnboardingController< if (!outdated) { return PasswordSyncStatus.InSync; } - await this.#runPasswordSyncFlow(globalPassword); + // The remote password is known to be newer. Record that boundary + // before starting the local Seedless rewrite so an interrupted + // flow remains recoverable. this.#writePasswordChangePhase( - SeedlessPasswordChangePhase.LocalKeyringPending, + SeedlessPasswordChangePhase.SeedlessCommitted, ); + await this.#runPasswordSyncFlow(globalPassword); return PasswordSyncStatus.ReconcileKeyring; } catch { // Sync failed (e.g. wrong password or transient remote error). From 0adf1828589b35aba6aa2054ebfb7ff6f7b3b302 Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 10 Sep 2026 15:29:33 +0800 Subject: [PATCH 10/14] fix: fixed CI --- .../src/SeedlessOnboardingController.test.ts | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 1f27dea5529..208d51ed2db 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -5261,18 +5261,25 @@ describe('SeedlessOnboardingController', () => { }); let interruptPersistence = true; - const interruptingListener = ( - state: SeedlessOnboardingControllerState, - ): void => { - if (interruptPersistence && state.vault !== vaultBeforeSync) { - interruptPersistence = false; - throw new Error('simulated persistence interruption'); - } + const controllerWithUpdate = controller as unknown as { + update: (...args: unknown[]) => unknown; }; - baseMessenger.subscribe( - 'SeedlessOnboardingController:stateChange', - interruptingListener, - ); + const originalUpdate = controllerWithUpdate.update.bind(controller); + jest + .spyOn(controllerWithUpdate, 'update') + .mockImplementation((...args: unknown[]) => { + const result = originalUpdate(...args) as { + nextState: SeedlessOnboardingControllerState; + }; + if ( + interruptPersistence && + result.nextState.vault !== vaultBeforeSync + ) { + interruptPersistence = false; + throw new Error('simulated persistence interruption'); + } + return result; + }); expect( await controller.reconcilePassword({ @@ -5280,11 +5287,6 @@ describe('SeedlessOnboardingController', () => { }), ).toBe(PasswordSyncStatus.Unknown); - baseMessenger.unsubscribe( - 'SeedlessOnboardingController:stateChange', - interruptingListener, - ); - // The rewritten vault must never be persisted without its matching // auth key, Keyring ciphertext, and recovery phase. expect(controller.state.authPubKey).toBe( From 89df8df10a6f6c41b486322c865687e1cb350242 Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 10 Sep 2026 15:37:21 +0800 Subject: [PATCH 11/14] fix: fixed lint --- .../docs/0002-password-change-recovery-flow.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md b/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md index 98a82ba1953..8d922017cb6 100644 --- a/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md +++ b/packages/seedless-onboarding-controller/docs/0002-password-change-recovery-flow.md @@ -150,7 +150,6 @@ unlock render / submit 3. **Unlock routing.** On unlock (page render _and_ password submit), read `passwordChangePhase` from controller state, then call `resolvePasswordSyncState({ skipCache })`. Route UI from the returned status using the table above. Do not classify a password as invalid until recovery has run. 4. **Two-step UX.** - - Step 1 (password-less): `resolvePasswordSyncState` decides whether the old or new password is needed. - Step 2 (password-consuming): only after step 1 returns `enter-new-password` / `password-outdated`, prompt for the new password and call `reconcilePassword({ globalPassword })`. From 81c0adfd8888f96d4623c97d832013da950910d7 Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 10 Sep 2026 16:21:05 +0800 Subject: [PATCH 12/14] fix: fixed RC between changePassword and resolvePasswordSyncState --- .../src/SeedlessOnboardingController.test.ts | 88 +++++++++++++++++++ .../src/SeedlessOnboardingController.ts | 60 ++++++------- 2 files changed, 118 insertions(+), 30 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 208d51ed2db..f89968c21e6 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -4915,6 +4915,94 @@ describe('SeedlessOnboardingController', () => { ); }); + it('re-reads the lifecycle phase after waiting for an in-flight password change', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + }, + async ({ controller, toprfClient, baseMessenger }) => { + const oldPassword = 'old-mock-password'; + const newPassword = 'new-mock-password'; + + await newUserSetup( + toprfClient, + controller, + baseMessenger, + oldPassword, + ); + + const currentAuthPubKey = base64ToBytes( + controller.state.authPubKey as string, + ); + const newToprfEncryptor = createMockToprfEncryptor(); + const changeEncryptionKeyResult: ChangeEncryptionKeyResult = { + encKey: newToprfEncryptor.deriveEncKey(newPassword), + pwEncKey: newToprfEncryptor.derivePwEncKey(newPassword), + authKeyPair: newToprfEncryptor.deriveAuthKeyPair(newPassword), + }; + + let resolveChangeEncKeyStarted!: () => void; + const changeEncKeyStarted = new Promise((resolve) => { + resolveChangeEncKeyStarted = resolve; + }); + let resolveChangeEncKey!: ( + result: ChangeEncryptionKeyResult, + ) => void; + const changeEncKeyResult = new Promise( + (resolve) => { + resolveChangeEncKey = resolve; + }, + ); + + jest + .spyOn(toprfClient, 'fetchAuthPubKey') + .mockResolvedValueOnce({ + authPubKey: currentAuthPubKey, + keyIndex: 1, + }) + .mockResolvedValue({ + authPubKey: changeEncryptionKeyResult.authKeyPair.pk, + keyIndex: 1, + }); + jest + .spyOn(toprfClient, 'changeEncKey') + .mockImplementation(() => { + resolveChangeEncKeyStarted(); + return changeEncKeyResult; + }); + + const changePasswordPromise = baseMessenger.call( + 'SeedlessOnboardingController:changePassword', + newPassword, + oldPassword, + ); + await changeEncKeyStarted; + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.SeedlessChangePending, + ); + + // resolvePasswordSyncState snapshots the pending phase before it + // waits for the controller lock. The password change completes first, + // so the resolver must use LOCAL_KEYRING_PENDING instead of clearing + // the lifecycle and returning in-sync. + const resolvePasswordSyncStatePromise = + controller.resolvePasswordSyncState(); + resolveChangeEncKey(changeEncryptionKeyResult); + await changePasswordPromise; + + await expect(resolvePasswordSyncStatePromise).resolves.toBe( + PasswordSyncStatus.ReconcileKeyring, + ); + expect(controller.state.passwordChangePhase).toBe( + SeedlessPasswordChangePhase.LocalKeyringPending, + ); + }, + ); + }); + it('returns unknown and preserves the phase when the remote check fails', async () => { await withController( { diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 84d07c024ab..482ec5d9b86 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -2458,27 +2458,27 @@ export class SeedlessOnboardingController< async resolvePasswordSyncState(options?: { skipCache?: boolean; }): Promise { - const phase = this.state.passwordChangePhase; - switch (phase) { - case undefined: { - // Pure read with no state mutation; let the helper acquire the - // controller lock itself (no `skipLock`). - try { - const outdated = await this.#checkIsPasswordOutdated({ - skipCache: options?.skipCache, - }); - return outdated - ? PasswordSyncStatus.PasswordOutdated - : PasswordSyncStatus.InSync; - } catch { - // Remote state could not be established. Keep the wallet locked. - return PasswordSyncStatus.Unknown; + // The phase snapshot and any resulting check or transition must share the + // controller lock. Otherwise a concurrent password change can advance the + // phase after it is read and before this method acts on it. + return await this.#withControllerLock(async () => { + const phase = this.state.passwordChangePhase; + switch (phase) { + case undefined: { + try { + const outdated = await this.#checkIsPasswordOutdated({ + skipCache: options?.skipCache, + skipLock: true, + }); + return outdated + ? PasswordSyncStatus.PasswordOutdated + : PasswordSyncStatus.InSync; + } catch { + // Remote state could not be established. Keep the wallet locked. + return PasswordSyncStatus.Unknown; + } } - } - case SeedlessPasswordChangePhase.SeedlessChangePending: { - // Mutates the phase, so hold the lock for the whole branch and tell - // the helper we already have it. - return await this.#withControllerLock(async () => { + case SeedlessPasswordChangePhase.SeedlessChangePending: { try { // Remote outcome is ambiguous; force an authoritative remote // check regardless of `skipCache`. @@ -2503,17 +2503,17 @@ export class SeedlessOnboardingController< // keep the wallet locked. return PasswordSyncStatus.Unknown; } - }); + } + case SeedlessPasswordChangePhase.SeedlessCommitted: + return PasswordSyncStatus.EnterNewPassword; + case SeedlessPasswordChangePhase.LocalKeyringPending: + return PasswordSyncStatus.ReconcileKeyring; + default: + // Terminal phases (KEY_SYNC_PENDING, UNKNOWN) and any unrecognized + // persisted value share routing. + return this.#statusForTerminalPhase(phase); } - case SeedlessPasswordChangePhase.SeedlessCommitted: - return PasswordSyncStatus.EnterNewPassword; - case SeedlessPasswordChangePhase.LocalKeyringPending: - return PasswordSyncStatus.ReconcileKeyring; - default: - // Terminal phases (KEY_SYNC_PENDING, UNKNOWN) and any unrecognized - // persisted value share routing. - return this.#statusForTerminalPhase(phase); - } + }); } /** From d96f9e7a5dcd3b778069d9df9c030ce8364b54fc Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 10 Sep 2026 16:54:14 +0800 Subject: [PATCH 13/14] fix: fixed tests --- .../docs/0004-controller-state-lock-audit.md | 359 ++++++++++++++++++ .../src/SeedlessOnboardingController.test.ts | 112 +++++- .../src/SeedlessOnboardingController.ts | 15 +- 3 files changed, 471 insertions(+), 15 deletions(-) create mode 100644 packages/seedless-onboarding-controller/docs/0004-controller-state-lock-audit.md diff --git a/packages/seedless-onboarding-controller/docs/0004-controller-state-lock-audit.md b/packages/seedless-onboarding-controller/docs/0004-controller-state-lock-audit.md new file mode 100644 index 00000000000..6dbd2445bdd --- /dev/null +++ b/packages/seedless-onboarding-controller/docs/0004-controller-state-lock-audit.md @@ -0,0 +1,359 @@ +# Audit 0004: Controller state-lock and password-sync races + +- Status: Finding F-001 fixed; follow-up findings remain open +- Date: 2026-09-10 +- Scope: `SeedlessOnboardingController` +- Related: [ADR 0001](./0001-seedless-password-change-recovery.md), [Recovery flow 0002](./0002-password-change-recovery-flow.md), [Option B plan 0003](./0003-controller-owned-password-change-recovery-plan.md) + +## Executive summary + +The controller has an in-memory controller mutex, but the mutex only protects +the callbacks that acquire it. It does not protect state snapshots taken before +lock acquisition, public methods that do not use the mutex, or operations that +mutate controller state through another lock. + +One high-impact race was verified between `resolvePasswordSyncState` and +`changePassword`. `resolvePasswordSyncState` selected its recovery branch from +a phase read taken before it acquired the controller lock. A concurrent +`changePassword` could finish the Seedless-side rewrite and advance the phase to +`LOCAL_KEYRING_PENDING`; the resolver could then use its stale +`SEEDLESS_CHANGE_PENDING` branch, clear the newer phase, and return `in-sync` +while the local Keyring still required reconciliation. + +That finding is fixed in the current implementation. A deterministic regression +test reproduces the interleaving and verifies that the resolver returns +`reconcile-keyring` and preserves `LOCAL_KEYRING_PENDING`. + +The broader audit remains open. The recommended model is operation-level +serialization around lifecycle-sensitive operations, not a mutex around each +individual `BaseController.update` call. + +## Audit question + +Can password synchronization, password changes, and other public controller +operations concurrently observe and mutate related state such that the +controller returns a status that no longer describes the state it acted on? + +## Relevant implementation model + +### Controller and vault locks + +`SeedlessOnboardingController` defines separate controller and vault mutexes: + +- [`#controllerOperationMutex` and `#vaultOperationMutex`](../src/SeedlessOnboardingController.ts#L425-L427) +- [`#withControllerLock`](../src/SeedlessOnboardingController.ts#L2345-L2349) +- [`#withVaultLock`](../src/SeedlessOnboardingController.ts#L2362-L2366) + +The controller mutex serializes callbacks that use `#withControllerLock`. It is +not re-entrant, so internal helpers use explicit `skipLock` options when their +caller already owns the lock. + +The vault mutex protects encryption, decryption, and vault writes. It does not +by itself serialize lifecycle state, authentication state, or all controller +state updates. + +### State update behavior + +`BaseController.update` calculates and installs the next state synchronously, +then publishes state-change events: + +- [`BaseController.update`](../../base-controller/src/BaseController.ts#L323-L355) + +This prevents a single update from being torn apart. It does not prevent an +asynchronous operation from reading state, awaiting I/O, and later applying a +decision based on that stale read. Therefore, locking individual updates would +not fix the verified race. + +## Finding F-001: stale lifecycle branch selection + +- Severity: High +- Status: Fixed +- Affected operation: `resolvePasswordSyncState` +- Concurrent operation: `changePassword` +- Failure class: time-of-check/time-of-use race + +### Pre-fix behavior + +The resolver read `passwordChangePhase`, selected a `switch` branch, and only +then acquired the controller lock for the mutating pending-phase branch. The +branch selection was therefore outside the critical section. + +`changePassword` holds the controller lock while it performs the remote change +and local Seedless rewrite: + +- [`changePassword`](../src/SeedlessOnboardingController.ts#L977-L1076) +- [`#commitPasswordChangeState`](../src/SeedlessOnboardingController.ts#L2203-L2258) + +### Reproduction sequence + +1. The controller is unlocked and `changePassword` starts. +2. `changePassword` records `SEEDLESS_CHANGE_PENDING` and waits for the remote + password-change operation. +3. `resolvePasswordSyncState` reads `SEEDLESS_CHANGE_PENDING` before waiting for + the controller lock. +4. `changePassword` completes the remote operation and commits the local + Seedless vault with `LOCAL_KEYRING_PENDING`. +5. `changePassword` releases the controller lock. +6. The resolver acquires the lock but continues through its previously selected + `SEEDLESS_CHANGE_PENDING` branch. +7. The remote check now observes the new Seedless state. The resolver can clear + the phase and return `in-sync`. +8. The client skips the required local Keyring reconciliation because the + lifecycle marker and returned status no longer describe the actual state. + +The same stale-decision pattern can occur when the resolver initially observes +an unset phase while a concurrent password change later starts and completes. + +### Remediation applied + +`resolvePasswordSyncState` now: + +1. Acquires the controller lock before reading `passwordChangePhase`. +2. Holds that lock through the remote check and any lifecycle transition. +3. Calls the password-outdated helper with `skipLock: true` because the + resolver already owns the controller lock. +4. Routes a phase that changed while the resolver was waiting using the latest + state. + +- [Fixed `resolvePasswordSyncState`](../src/SeedlessOnboardingController.ts#L2458-L2517) +- [Regression test](../src/SeedlessOnboardingController.test.ts#L4918-L5004) + +### Required invariant + +The phase snapshot, remote-state check, returned status, and lifecycle +transition must be one controller-serialized operation. A resolver must never +clear or advance a phase based on a snapshot taken before it acquired the +controller lock. + +## Locking decision + +### Use operation-level locking + +Lifecycle-sensitive operations should acquire the controller lock before their +first state read and hold it across all awaits that affect the decision. This +includes: + +- The password-change lifecycle phase. +- The local Seedless vault and authentication key transition. +- The encrypted Keyring encryption key. +- Password-outdated cache updates. +- Authentication and refresh-token state used by the operation. + +Internal helpers should not independently acquire the same non-reentrant mutex. +They should be private unlocked helpers, called only from a locked public +operation or from another helper with an explicit ownership contract. + +### Do not mutex individual state updates + +Adding a mutex around `BaseController.update` would not solve stale snapshots +and would make synchronous state updates difficult to compose with existing +async APIs. It could also create deadlocks when an operation already holds the +controller lock and calls a helper that tries to update state. + +The lock should protect the logical transaction, not only the final assignment. + +### Preserve lock ordering + +Where both locks are needed, use this ordering: + +1. Controller lock. +2. Vault lock. +3. State update while both relevant operation invariants are held. + +No path should acquire the vault lock and then wait for the controller lock. +Public wrappers and private helpers should make this ordering explicit. + +The controller lock also cannot serialize operations performed directly by the +`KeyringController`. A client coordinator, or a future controller-owned +implementation, must cover the cross-controller transaction. + +## Follow-up findings + +These findings were identified during the audit and are not fixed by F-001. + +### F-002: Keyring encryption-key methods bypass the controller lock + +- Severity: High +- Status: Open +- Affected methods: + - [`storeKeyringEncryptionKey`](../src/SeedlessOnboardingController.ts#L1514-L1524) + - [`loadKeyringEncryptionKey`](../src/SeedlessOnboardingController.ts#L1532-L1535) + +`storeKeyringEncryptionKey` awaits vault access and then updates controller +state without the controller mutex. It can therefore write an encrypted key +after a password-change commit, lifecycle clear, or another recovery step has +changed the wrapping key or phase. + +`loadKeyringEncryptionKey` is a read, but it is used to make recovery decisions. +It can observe a different combination of vault and encrypted-key state from +the one that existed when its operation started. + +Recommended remediation: + +- Add locked public wrappers and private unlocked helpers. +- Keep the public calls under the controller-to-vault lock order. +- Ensure key reads and writes used by a lifecycle transition share the same + operation boundary as the phase transition. + +### F-003: Token refresh and refresh-token rotation are not controller-serialized + +- Severity: High +- Status: Open +- Affected methods: + - [`fetchMetadataAccessCreds`](../src/SeedlessOnboardingController.ts#L519-L545) + - [`refreshAuthTokens`](../src/SeedlessOnboardingController.ts#L2808-L2824) + - [`rotateRefreshToken`](../src/SeedlessOnboardingController.ts#L2981-L3024) + +`refreshAuthTokens` coalesces concurrent refresh requests, but that only +deduplicates refresh calls. It does not serialize refresh state and vault writes +against password changes or recovery. The refresh path re-authenticates, updates +tokens, rewrites the vault, and may rotate refresh tokens. + +Recommended remediation: + +- Split public locked wrappers from private refresh implementations. +- Ensure internal callers that already hold the controller lock use the private + implementation rather than recursively acquiring the mutex. +- Serialize token/vault rewrites with password-change commits. +- Preserve one lock ordering for refresh, password change, and recovery. + +### F-004: `clearState` can replace state during an in-flight operation + +- Severity: High +- Status: Open +- Affected method: [`clearState`](../src/SeedlessOnboardingController.ts#L1447-L1453) + +`clearState` replaces the complete controller state without acquiring the +controller mutex. If called while a remote operation or vault rewrite is +awaiting, it can remove lifecycle, authentication, vault, and recovery data. +A later continuation can then write a partial or newly inconsistent state. + +Recommended remediation: + +- Serialize state clearing with the controller operation mutex. +- Reject or cancel it while a password-change/recovery transaction is active. +- Make the caller explicitly confirm that destructive state reset is intended. + +### F-005: Public lock bypasses weaken the lock contract + +- Severity: Medium +- Status: Open +- Affected methods: + - [`authenticate({ skipLock })`](../src/SeedlessOnboardingController.ts#L577-L647) + - [`verifyVaultPassword({ skipLock })`](../src/SeedlessOnboardingController.ts#L1104-L1119) + +These options are useful for internal callers that already own the mutex, but +they are present in public method signatures and messenger action handlers. +External callers can bypass serialization and mutate or inspect state while a +password operation is in flight. + +Recommended remediation: + +- Move unlocked variants to private helpers. +- Remove lock bypass options from public action signatures where possible. +- If an escape hatch must remain, document and enforce that it is internal-only. + +### F-006: Lifecycle advance methods do not validate the transition owner + +- Severity: Medium +- Status: Open +- Affected methods: + - [`clearPasswordChangePhase`](../src/SeedlessOnboardingController.ts#L2399-L2406) + - [`markPasswordChangeKeySyncPending`](../src/SeedlessOnboardingController.ts#L2417-L2429) + +Both methods use the controller lock, but they accept no expected phase, +transaction identifier, or generation. A delayed client call can therefore +clear or overwrite a newer lifecycle transaction after it acquires the lock. + +Recommended remediation: + +- Add a non-sensitive transaction identifier or monotonically increasing + lifecycle generation. +- Require the caller to provide the expected current phase/generation. +- Reject transitions that are not valid for the current lifecycle state. + +### F-007: Other direct state mutators are outside the operation lock + +- Severity: Medium +- Status: Open +- Affected methods: + - [`setMigrationVersion`](../src/SeedlessOnboardingController.ts#L794-L795) + - [`updateBackupMetadataState`](../src/SeedlessOnboardingController.ts#L1085-L1093) + +These methods are synchronous and do not necessarily lose fields because +`update` derives each patch from the current state. They can nevertheless +change state between awaited steps of a lifecycle operation and are not covered +by a single operation-level invariant. + +Recommended remediation: + +- Classify each method as lifecycle-sensitive, safe concurrent metadata, or + initialization-only. +- Lock lifecycle-sensitive methods. +- Keep unrelated metadata operations out of password-change critical sections + only if their invariants are explicitly independent. + +### F-008: In-memory phase updates are not proof of durable persistence + +- Severity: High for crash recovery +- Status: Open + +`#writePasswordChangePhase` calls `update`, which synchronously changes +in-memory state and publishes state-change events: + +- [`#writePasswordChangePhase`](../src/SeedlessOnboardingController.ts#L2381-L2387) + +The public lifecycle methods await the in-memory operation, but do not expose an +explicit acknowledgement that the downstream persisted state has been durably +written. A crash after an in-memory transition can leave durable state behind +the actual remote or local cryptographic state. + +Recommended remediation: + +- Provide an awaitable durable persistence boundary for lifecycle transitions. +- Do not clear the phase until key synchronization and required local writes + have been durably confirmed. +- Treat the persisted phase as a recovery trigger, never as proof of completion. + +## Test plan + +The controller should add deterministic concurrency tests for each operation +that crosses an `await` and later updates state. + +Required scenarios: + +- `resolvePasswordSyncState` waits behind `changePassword` and observes + `LOCAL_KEYRING_PENDING`. +- `resolvePasswordSyncState` waits behind a phase transition and never clears a + newer phase. +- `storeKeyringEncryptionKey` overlaps with a password-change state commit. +- Token refresh overlaps with password-change vault rewriting. +- `clearState` is rejected or serialized while recovery is active. +- Delayed `clearPasswordChangePhase` and + `markPasswordChangeKeySyncPending` cannot modify a newer transaction. +- Every failure path releases the controller and vault locks. +- Lifecycle state remains recoverable after an interrupted durable write. + +The F-001 regression test currently passes with: + +```sh +yarn workspace @metamask/seedless-onboarding-controller run test --no-coverage --runInBand +yarn workspace @metamask/seedless-onboarding-controller run build +``` + +## Acceptance criteria + +The state-lock design is complete when: + +- Every lifecycle-sensitive public operation reads state only after acquiring + the shared operation lock. +- Every lifecycle transition is validated against the current transaction or + generation. +- Controller and vault lock ordering is documented and enforced. +- Public lock bypasses are removed or made internal-only. +- Token refresh and vault rewrites cannot interleave with password changes. +- Durable lifecycle writes have an awaitable completion signal. +- Cross-controller Keyring operations are covered by a coordinator lock. +- Concurrency and process-interruption tests verify the final state, returned + status, and recoverability rather than only individual method results. + diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index f89968c21e6..f2450a3d82f 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -1145,6 +1145,43 @@ describe('SeedlessOnboardingController', () => { }, ); }); + + it('should preserve the existing revoke token when it is omitted', async () => { + const existingRevokeToken = 'existing-revoke-token'; + + await withController( + { + state: { + ...getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + revokeToken: existingRevokeToken, + }, + }, + async ({ controller, toprfClient, baseMessenger }) => { + jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + await baseMessenger.call( + 'SeedlessOnboardingController:authenticate', + { + idTokens, + authConnectionId, + userId, + authConnection, + socialLoginEmail, + refreshToken, + accessToken, + metadataAccessToken, + }, + ); + + expect(controller.state.revokeToken).toBe(existingRevokeToken); + }, + ); + }); }); describe('resolvePasswordSyncState (no lifecycle: outdated check)', () => { @@ -4993,7 +5030,7 @@ describe('SeedlessOnboardingController', () => { resolveChangeEncKey(changeEncryptionKeyResult); await changePasswordPromise; - await expect(resolvePasswordSyncStatePromise).resolves.toBe( + expect(await resolvePasswordSyncStatePromise).toBe( PasswordSyncStatus.ReconcileKeyring, ); expect(controller.state.passwordChangePhase).toBe( @@ -7490,6 +7527,44 @@ describe('SeedlessOnboardingController', () => { ); }); + it('should not clear a newer in-flight refresh promise', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ + controller, + toprfClient, + mockRefreshJWTToken, + }) => { + jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + let nestedRefreshPromise!: Promise; + mockRefreshJWTToken.mockImplementationOnce(() => { + // The nested call starts before the outer call assigns its + // in-flight promise. The outer assignment must not be cleared + // by the nested call's finally handler. + nestedRefreshPromise = controller.refreshAuthTokens(); + return Promise.resolve({ + idTokens: ['newIdToken'], + metadataAccessToken: 'mock-metadata-access-token', + accessToken, + }); + }); + + await controller.refreshAuthTokens(); + await nestedRefreshPromise; + + expect(mockRefreshJWTToken).toHaveBeenCalledTimes(2); + }, + ); + }); + it('should clear the in-flight promise after failure, allowing subsequent calls to succeed', async () => { await withController( { @@ -8945,6 +9020,41 @@ describe('SeedlessOnboardingController', () => { }, ); }); + + it('should retain all pending tokens when every revocation fails', async () => { + const pendingTokens = [ + { + refreshToken: 'old-refresh-token-1', + revokeToken: 'old-revoke-token-1', + }, + { + refreshToken: 'old-refresh-token-2', + revokeToken: 'old-revoke-token-2', + }, + ]; + + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + pendingToBeRevokedTokens: pendingTokens, + }), + }, + async ({ controller, mockRevokeRefreshToken, baseMessenger }) => { + mockRevokeRefreshToken.mockRejectedValue( + new Error('Revoke failed'), + ); + + await baseMessenger.call( + 'SeedlessOnboardingController:revokePendingRefreshTokens', + ); + + expect(controller.state.pendingToBeRevokedTokens).toStrictEqual( + pendingTokens, + ); + }, + ); + }); }); describe('metadata', () => { diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 482ec5d9b86..9e2e4881905 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -733,7 +733,6 @@ export class SeedlessOnboardingController< const performBackup = async (): Promise => { await this.#assertPasswordInSync({ skipCache: true, - skipLock: true, // skip lock since we already have the lock }); // verify the password and unlock the vault @@ -773,7 +772,6 @@ export class SeedlessOnboardingController< await this.#assertPasswordInSync({ skipCache: true, - skipLock: true, // skip lock since we already have the lock }); if (this.state.migrationVersion < SeedlessOnboardingMigrationVersion.V1) { @@ -1001,7 +999,6 @@ export class SeedlessOnboardingController< const attemptChangePassword = async (): Promise => { const { latestKeyIndex } = await this.#assertPasswordInSync({ skipCache: true, - skipLock: true, // skip lock since we already have the lock // `changePassword` writes the phase before its token-refresh retry // and guards concurrency itself at entry, so its own assert must // not be blocked by the phase it just wrote. @@ -1309,12 +1306,10 @@ export class SeedlessOnboardingController< * @param options.globalAuthPubKey - The global auth public key to compare with the current auth public key. * If not provided, the global auth public key will be fetched from the backend. * @param options.skipCache - If true, bypass the cache and force a fresh check. - * @param options.skipLock - Whether to skip the lock acquisition. (to prevent deadlock in case the caller already acquired the lock) * @returns A promise that resolves to true if the password is outdated, false otherwise. */ async #checkIsPasswordOutdated(options?: { skipCache?: boolean; - skipLock?: boolean; globalAuthPubKey?: SEC1EncodedPublicKey; }): Promise { const doCheckIsPasswordExpired = async (): Promise => { @@ -1376,10 +1371,7 @@ export class SeedlessOnboardingController< }; return await this.#executeWithTokenRefresh( - async () => - options?.skipLock - ? await doCheckIsPasswordExpired() - : await this.#withControllerLock(doCheckIsPasswordExpired), + async () => await doCheckIsPasswordExpired(), 'checkIsPasswordOutdated', ); } @@ -2468,7 +2460,6 @@ export class SeedlessOnboardingController< try { const outdated = await this.#checkIsPasswordOutdated({ skipCache: options?.skipCache, - skipLock: true, }); return outdated ? PasswordSyncStatus.PasswordOutdated @@ -2484,7 +2475,6 @@ export class SeedlessOnboardingController< // check regardless of `skipCache`. const outdated = await this.#checkIsPasswordOutdated({ skipCache: true, - skipLock: true, }); if (!outdated) { // Remote did not commit. Clear the phase; unlock with the old @@ -2577,7 +2567,6 @@ export class SeedlessOnboardingController< try { const outdated = await this.#checkIsPasswordOutdated({ skipCache: true, - skipLock: true, }); if (!outdated) { return PasswordSyncStatus.InSync; @@ -2716,14 +2705,12 @@ export class SeedlessOnboardingController< * * @param options - The options for asserting the password is in sync. * @param options.skipCache - Whether to skip the cache check. - * @param options.skipLock - Whether to skip the lock acquisition. (to prevent deadlock in case the caller already acquired the lock) * @param options.skipPhaseCheck - Whether to skip the `passwordChangePhase` guard. Only `changePassword` should set this: it guards concurrency itself at entry and writes the phase before its token-refresh retry, so its own internal assert must not be blocked by the phase it just wrote. * @returns The global auth public key and the latest key index. * @throws If the password is outdated. */ async #assertPasswordInSync(options?: { skipCache?: boolean; - skipLock?: boolean; /** * Skip the `passwordChangePhase` guard. Only `changePassword` should set * this: it guards concurrency itself at entry and writes the phase From ae94d42612c83e648526f5e590f8b89a0489e17d Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 10 Sep 2026 17:03:35 +0800 Subject: [PATCH 14/14] fix: fixed lint --- .../docs/0004-controller-state-lock-audit.md | 1 - .../src/SeedlessOnboardingController.test.ts | 24 ++++++------------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/packages/seedless-onboarding-controller/docs/0004-controller-state-lock-audit.md b/packages/seedless-onboarding-controller/docs/0004-controller-state-lock-audit.md index 6dbd2445bdd..d65f42f0b65 100644 --- a/packages/seedless-onboarding-controller/docs/0004-controller-state-lock-audit.md +++ b/packages/seedless-onboarding-controller/docs/0004-controller-state-lock-audit.md @@ -356,4 +356,3 @@ The state-lock design is complete when: - Cross-controller Keyring operations are covered by a coordinator lock. - Concurrency and process-interruption tests verify the final state, returned status, and recoverability rather than only individual method results. - diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index f2450a3d82f..507f4c45d15 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -4985,9 +4985,7 @@ describe('SeedlessOnboardingController', () => { const changeEncKeyStarted = new Promise((resolve) => { resolveChangeEncKeyStarted = resolve; }); - let resolveChangeEncKey!: ( - result: ChangeEncryptionKeyResult, - ) => void; + let resolveChangeEncKey!: (result: ChangeEncryptionKeyResult) => void; const changeEncKeyResult = new Promise( (resolve) => { resolveChangeEncKey = resolve; @@ -5004,12 +5002,10 @@ describe('SeedlessOnboardingController', () => { authPubKey: changeEncryptionKeyResult.authKeyPair.pk, keyIndex: 1, }); - jest - .spyOn(toprfClient, 'changeEncKey') - .mockImplementation(() => { - resolveChangeEncKeyStarted(); - return changeEncKeyResult; - }); + jest.spyOn(toprfClient, 'changeEncKey').mockImplementation(() => { + resolveChangeEncKeyStarted(); + return changeEncKeyResult; + }); const changePasswordPromise = baseMessenger.call( 'SeedlessOnboardingController:changePassword', @@ -7534,11 +7530,7 @@ describe('SeedlessOnboardingController', () => { withMockAuthenticatedUser: true, }), }, - async ({ - controller, - toprfClient, - mockRefreshJWTToken, - }) => { + async ({ controller, toprfClient, mockRefreshJWTToken }) => { jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, isNewUser: false, @@ -9041,9 +9033,7 @@ describe('SeedlessOnboardingController', () => { }), }, async ({ controller, mockRevokeRefreshToken, baseMessenger }) => { - mockRevokeRefreshToken.mockRejectedValue( - new Error('Revoke failed'), - ); + mockRevokeRefreshToken.mockRejectedValue(new Error('Revoke failed')); await baseMessenger.call( 'SeedlessOnboardingController:revokePendingRefreshTokens',