diff --git a/openspec/changes/add-app-lock/design.md b/openspec/changes/add-app-lock/design.md new file mode 100644 index 000000000..d92987304 --- /dev/null +++ b/openspec/changes/add-app-lock/design.md @@ -0,0 +1,74 @@ +## Context + +KeyApp currently uses per-action password confirmation, but PDR Story 6.1 requires an “App Lock” that blocks access on the next app open. This change defines a password-only MVP App Lock with a router guard and a dedicated lock route. + +## Goals / Non-Goals + +### Goals + +- Enforce a password lock on cold start when enabled. +- Verify passwords by decrypting existing encrypted wallet secrets (no raw password storage). +- Avoid lockout scenarios when the current wallet changes. +- Keep the first implementation small, testable, and reversible. + +### Non-Goals (MVP) + +- Biometric flows (fingerprint unlock/payment). +- Resume-timeout semantics parity with mpay (e.g. “lock after background for N minutes”). +- Password reset/tips UX. + +## Decisions + +### Decision: Route guard runs in `beforeLoad` and redirects using `throw redirect(...)` + +- Implement enforcement as a TanStack Router `beforeLoad` guard. +- On “locked” navigation, `throw redirect({ to: '/lock', search: { to: location.href } })`. +- The lock route itself and onboarding routes are allowlisted. + +Rationale: + +- `beforeLoad` runs before route rendering, so it can prevent protected content flashing. +- Router APIs mark `navigate` as deprecated inside lifecycle contexts; `throw redirect(...)` is the supported approach. + +### Decision: Guard MUST not rely on `AppLayout` effects for wallet initialization + +Problem: + +- Current wallet initialization runs in `AppLayout` via `useEffect`, but route `beforeLoad` can run before React effects. +- If the guard reads store state too early (e.g. `wallets: []`), it may incorrectly skip lock enforcement on cold start. + +Chosen approach: + +- Ensure the guard has access to persisted wallet state before deciding to enforce/skip the lock. +- Prefer one of: + - Add an idempotent “initialize-once” path and `await` it from the guard when needed, or + - Read the persisted wallet payload synchronously from `localStorage` for the guard decision. + +Acceptance: + +- With `passwordLockEnabled=true` and a decryptable encrypted secret present, cold start SHALL redirect to `/lock` before any protected content is shown. +- With no wallet (or no wallet has an encrypted secret), cold start SHALL NOT redirect to `/lock`. + +### Decision: Password verification strategy + +- Enable/disable toggle verification uses the **current wallet** encrypted secret. +- Unlock accepts a password that can decrypt **any wallet** encrypted secret. +- Implementation MAY prefer `verifyPassword(encrypted, password)` when plaintext is not needed. + +### Decision: Treat `/authorize/**` as protected when locked + +The system SHALL treat `/authorize/**` (DWEB/Plaoc) routes as protected when password lock is enabled and the session is not unlocked. + +Rationale: + +- These routes typically surface signature/authorization actions and should not be reachable without first verifying the user. +- Compatibility is preserved by redirecting to `/lock?to=` and returning to the original `/authorize/**` route after unlock. + +## Risks / Trade-offs + +- Multi-wallet passwords: enabling is scoped to current wallet; unlocking can accept any wallet password. +- Storage format evolution: wallet storage is currently localStorage-backed; future storage migrations should keep App Lock guard decisions stable. + +## Open Questions + +None (for MVP). diff --git a/openspec/changes/add-app-lock/proposal.md b/openspec/changes/add-app-lock/proposal.md new file mode 100644 index 000000000..76bf4d3e7 --- /dev/null +++ b/openspec/changes/add-app-lock/proposal.md @@ -0,0 +1,61 @@ +# Change: Add App Lock (Password-Only MVP) + +## Status + +**DRAFT** - Pending scope approval (PDR Story 6.1). + +## Why + +PDR Story 6.1 requires an “App Lock” so that users must verify identity (password) on the next app open. KeyApp currently has only per-action password confirmation (e.g. dangerous actions), and Settings “应用锁” is still TODO. + +Without an explicit scope decision and spec, “mpay parity” wording becomes misleading and implementation risks rework. + +## What Changes + +### In Scope (MVP) + +- **Password Lock toggle** in Settings → Security → “应用锁” + - Enabling requires verifying current wallet password. + - Disabling stops enforcing lock on next app open. +- **Lock screen route** (e.g. `/lock`) + - Password input + unlock action. + - After unlock, redirect back to the originally requested route. +- **Router guard** (TanStack Router) + - If password lock enabled and not unlocked, redirect to `/lock` for non-allowlisted routes. + - Allowlist onboarding routes and the lock route itself. +- **Cold start enforcement** + - When password lock is enabled and a wallet with an encrypted secret exists, the first navigation after a reload (cold start) is blocked by the lock route (no protected content flash). +- **Password verification** without storing raw passwords + - Verify by attempting to decrypt at least one existing wallet secret (try current wallet first, then fall back to other wallets) so users don’t get locked out when the “current wallet” changes. + - Do not enforce App Lock when there is no wallet (or no decryptable encrypted secret exists). + +### Out of Scope (Deferred) + +- Fingerprint unlock / fingerprint payment branches (PDR Story 6.1 side flows) +- Retry backoff UX parity +- Password tips / reset UX +- Background “timeout lock” (resume vs cold start semantics). (mpay triggers sign-in on `resume` after a timeout; this MVP intentionally does not.) + +## Impact + +- **Affected specs**: new capability `app-lock` (added in this change) +- **Affected code (planned)**: + - Settings entry: `src/pages/settings/index.tsx` (wire “应用锁” to page/route) + - Routing/guards: `src/routes/**`, `src/components/layout/app-layout.tsx` (hide TabBar on lock route) + - Store + persistence: `src/stores/**`, `src/services/storage/**` + - Security UI reuse: `src/components/security/password-confirm-sheet.tsx` (optional reuse) + +## Implementation Notes (non-normative) + +- **Guard vs store init timing**: wallet store initialization currently happens in `AppLayout` via `useEffect`, while route `beforeLoad` guards run before React effects. Implementation MUST ensure persisted wallet state is available before deciding to enforce/skip lock on cold start (to avoid a bypass window). +- **TanStack Router guard API**: implement guard via `beforeLoad` and `throw redirect(...)` (avoid deprecated lifecycle `navigate`). + +## References + +- PDR: Story 6.1 “设置应用锁” (`PDR.md#L353`) +- Current TODO entry: Settings “应用锁” (`src/pages/settings/index.tsx#L85`) +- mpay reference: + - `../legacy-apps/apps/mpay/src/app/app.component.ts` (resume-timeout lock trigger) + - `../legacy-apps/apps/mpay/src/guards/wallet-sign-in.guard.ts` (route guard gates) + - `../legacy-apps/apps/mpay/src/pages/wallet-sign-in/wallet-sign-in.component.ts` + - `../legacy-apps/apps/mpay/src/pages/mime/pages/application-lock/application-lock.component.ts` diff --git a/openspec/changes/add-app-lock/specs/app-lock/spec.md b/openspec/changes/add-app-lock/specs/app-lock/spec.md new file mode 100644 index 000000000..a51297452 --- /dev/null +++ b/openspec/changes/add-app-lock/specs/app-lock/spec.md @@ -0,0 +1,111 @@ +## ADDED Requirements + +### Requirement: Password Lock Toggle + +The system SHALL allow the user to enable or disable “Password Lock”. + +#### Scenario: Enable password lock requires verifying current wallet password + +- **GIVEN** the user is on Settings → Security → “应用锁” +- **WHEN** the user enables the password lock toggle +- **THEN** the system requires verifying the current wallet password before saving the setting + +#### Scenario: Enable password lock fails when there is no decryptable wallet secret + +- **GIVEN** the user is on Settings → Security → “应用锁” +- **AND** there is no wallet with a decryptable encrypted secret +- **WHEN** the user enables the password lock toggle +- **THEN** the system SHALL NOT enable password lock +- **AND** shows a user-visible error explaining why + +#### Scenario: Disable password lock stops future lock enforcement + +- **GIVEN** password lock is enabled +- **WHEN** the user disables password lock +- **THEN** the system SHALL NOT require unlock on the next app open + +### Requirement: Lock On Next App Open + +When password lock is enabled, the system SHALL require unlocking before accessing protected routes on the next app open. + +#### Scenario: Cold start redirects to lock when a wallet exists + +- **GIVEN** password lock is enabled +- **AND** the app has at least one wallet with an encrypted secret +- **WHEN** the user opens the app (cold start) +- **THEN** the system redirects to the lock route before showing protected content + +#### Scenario: Redirect to lock route when locked + +- **GIVEN** password lock is enabled +- **AND** the app is not currently unlocked +- **WHEN** the user navigates to a protected route +- **THEN** the system redirects to the lock route +- **AND** preserves the originally requested route for post-unlock redirect + +#### Scenario: No wallet does not trigger lock + +- **GIVEN** password lock is enabled +- **AND** there is no wallet in the app +- **WHEN** the user opens the app +- **THEN** the system SHALL NOT redirect to the lock route + +#### Scenario: Unlock redirects back to intended destination + +- **GIVEN** the user is on the lock route with an intended destination recorded +- **WHEN** the user successfully unlocks +- **THEN** the system marks the session as unlocked +- **AND** navigates to the intended destination + +### Requirement: Allowlist For Onboarding + +The system SHALL NOT block onboarding routes when no wallet exists or when the user is in onboarding flow. + +#### Scenario: Onboarding remains accessible + +- **GIVEN** the user is in an onboarding route +- **WHEN** password lock is enabled +- **THEN** the system SHALL NOT redirect to the lock route + +### Requirement: Password Verification Without Raw Password Storage + +The system SHALL verify a password by decrypting at least one existing encrypted wallet secret and SHALL NOT store the raw password. + +#### Scenario: Correct password unlocks + +- **GIVEN** the wallet has an encrypted secret +- **WHEN** the user enters the correct password on the lock route +- **THEN** the system successfully decrypts the secret +- **AND** unlock succeeds + +#### Scenario: Correct password for any wallet unlocks + +- **GIVEN** the app has multiple wallets +- **AND** at least one wallet has an encrypted secret +- **WHEN** the user enters a password that decrypts any wallet’s encrypted secret +- **THEN** unlock succeeds + +#### Scenario: Wrong password does not unlock + +- **GIVEN** the wallet has an encrypted secret +- **WHEN** the user enters an incorrect password on the lock route +- **THEN** the system fails to decrypt the secret +- **AND** unlock fails with a user-visible error + +### Requirement: Cold Start Lock Decision Uses Persisted Wallet State + +The system SHALL load persisted wallet state before deciding to enforce or skip App Lock on cold start. + +#### Scenario: Avoid cold-start bypass when wallets exist + +- **GIVEN** password lock is enabled +- **AND** persisted storage contains at least one wallet with an encrypted secret +- **WHEN** the app cold-starts into a protected route +- **THEN** the system redirects to the lock route before rendering protected content + +#### Scenario: Determine “no wallet” only after loading persistence + +- **GIVEN** password lock is enabled +- **WHEN** the app cold-starts +- **THEN** the system loads persisted wallet data before deciding there is “no wallet” +- **AND** if no wallet exists after loading, the system SHALL NOT redirect to the lock route diff --git a/openspec/changes/add-app-lock/tasks.md b/openspec/changes/add-app-lock/tasks.md new file mode 100644 index 000000000..ada521e0d --- /dev/null +++ b/openspec/changes/add-app-lock/tasks.md @@ -0,0 +1,36 @@ +# Tasks: App Lock (Password-Only MVP) + +## 1. Settings & Routing + +- [ ] 1.1 Add “应用锁” settings page + route +- [ ] 1.2 Hide TabBar on lock route (avoid visual clash) + +## 2. State & Persistence + +- [ ] 2.1 Add `appLockStore` persisted setting: `passwordLockEnabled` +- [ ] 2.2 Add runtime state (non-persisted): `isUnlocked` + `redirectTo` + +## 3. Guard + Lock Screen + +- [ ] 3.1 Add lock screen route (e.g. `/lock?to=...`) +- [ ] 3.2 Add root guard (`beforeLoad` + `throw redirect(...)`) with allowlist (onboarding + lock route) +- [ ] 3.3 Redirect back to requested route after unlock +- [ ] 3.4 Do not enforce lock when no wallet exists +- [ ] 3.5 Ensure cold start guard sees persisted wallet state (avoid bypass window) + +## 4. Password Verification + +- [ ] 4.1 Verify password by decrypting at least one wallet encrypted secret (try current wallet first) +- [ ] 4.2 Handle wallets missing encrypted secret (user-visible error) + +## 5. Tests + +- [ ] 5.1 Vitest: store + guard decisions (allowlist, locked/unlocked) +- [ ] 5.2 Vitest: password verification (success/fail) +- [ ] 5.3 Playwright: enable lock → reload → lock screen blocks → unlock redirects +- [ ] 5.4 Vitest: cold start enforcement (no-wallet vs has-wallet cases) + +## 6. i18n + Storybook + +- [ ] 6.1 Add i18n keys for Settings + lock screen +- [ ] 6.2 Storybook stories for lock screen and settings page states