Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,11 @@ main.tsx → AppProviders (QueryClientProvider > AuthProvider > RouterProvider)
- No DELETE policy on `recovery_keys` (updatable but not removable)
- Username availability: `check_username_availability()` RPC with IP-based rate limiting (10 req/2 min/IP)
- Login salts: `get_login_salts()` RPC, pre-auth, rate-limited (5 req/2 min/IP)
- Recovery RPCs: `get_recovery_data(p_username)` pre-auth (5 req/2 min/IP), `recover_account(...)` pre-auth (3 req/15 min/IP), `save_recovery_data(...)` authenticated (bcrypt-hashes `recoveryAuthHash`)
- `recovery_keys` table has a `recovery_auth_hash` column (bcrypt hash of HKDF-derived proof-of-knowledge)

### Adapter Pattern
Backend abstracted behind interfaces: `IAuthAdapter`, `IRealtimeAdapter`. There is no `IApiAdapter` — the data layer uses direct Supabase functions instead. Current implementations: auth in `supabase-adapter.ts`, entry CRUD in `supabase-entries.ts`, field CRUD in `supabase-fields.ts`, key operations in `supabase-keys.ts`, recovery in `supabase-recovery.ts`, registration upload in `supabase-registration.ts`, realtime in `supabase-realtime.ts`.
Backend abstracted behind interfaces: `IAuthAdapter`, `IRealtimeAdapter`. There is no `IApiAdapter` — the data layer uses direct Supabase functions instead. Current implementations: auth in `supabase-adapter.ts`, entry CRUD in `supabase-entries.ts`, field CRUD in `supabase-fields.ts`, key operations in `supabase-keys.ts`, recovery RPCs in `supabase-recovery.ts`, registration upload in `supabase-registration.ts`, realtime in `supabase-realtime.ts`.

## Key Conventions

Expand Down Expand Up @@ -185,14 +187,15 @@ See `docs/implementation-plan/README.md` for the full 36-step plan.
- Step 28 (Multi-Device Session Handling) — complete
- Step 29 (Change Password Flow + UI) — complete
- Step 30 (Regenerate Seed Phrase) — complete
- Step 31 (Seed Phrase Recovery Flow + UI) — complete

### Implementation Notes

Non-obvious decisions not visible from code alone:

- **Auth store `isRestoringSession`**: defaults `true`; `reset()` does NOT touch it (logout doesn't re-trigger initialization)
- **Auto-lock**: `useVaultTimeout` hook in ProtectedLayout resets a 15-minute inactivity timer on user activity (mousemove, keydown, mousedown, touchstart, scroll); calls `lockVault()` on expiry
- **VaultUnlockDialog**: uses a separate `vault-dialog-store` (in `features/vault/model/vault-dialog-store.ts`) so the dialog can be opened/closed independently of vault lock state. This lets the user dismiss the dialog without unlocking, and lets the sidebar/mobile nav trigger `openUnlockDialog()` directly
- **VaultUnlockDialog**: uses a separate `vault-dialog-store` (in `features/vault/model/vault-dialog-store.ts`) created via `createDialogStore` factory. The dialog can be opened/closed independently of vault lock state. This lets the user dismiss the dialog without unlocking, and lets the sidebar/mobile nav trigger `open()` directly
- **`keyVault.lockVault()` vs `keyVault.clearVault()`**: `lockVault()` preserves the cached envelope (so re-unlock can skip network calls), while `clearVault()` (called on logout) zeros everything including the cache. `keyVault.unlockVault()` tries the cached envelope first and retries from server on `DecryptionError` (stale cache can happen if the password was changed in another session)
- **Test file naming**: prefix with `-` in `src/app/routes/` to exclude from TanStack Router route tree generation
- **Test setup**: shared setup (`src/test/setup.ts`) resets `useAuthStore` (with `isRestoringSession: false`), `useCryptoStore`, and `useLayoutStore` (including `sidebarWidth: 240`) in `afterEach`. Router mocking (`@tanstack/react-router`) is done per-file in each test that needs it, not centralized
Expand All @@ -206,14 +209,17 @@ Non-obvious decisions not visible from code alone:
- **Master key wrapping uses AAD constants**: All key wrapping is done directly with `encrypt`/`decrypt` from `aes-gcm.ts` using `{iv, aad}` options. `rewrapMasterKey` in `master-key.ts` uses `MASTER_KEY_PASSWORD_AAD`, recovery wrapping in `mnemonic.ts` uses `MASTER_KEY_RECOVERY_AAD`, field key wrapping uses `encodeAAD(fieldName, version)` from `crypto-utils.ts`.
- **HKDF uses `deriveBits`, not `deriveKey`**: `hkdfExpand` in `hkdf.ts` returns raw `Uint8Array` bytes because the KEK bytes need to be imported as an AES-GCM CryptoKey via `importKey()` separately. `deriveKEK` and `deriveSigningKeySeed` are convenience wrappers. HKDF uses empty salt since the PRK is already random.
- **BIP-39 mnemonic functions are async**: `generateMnemonic`, `validateMnemonic`, `mnemonicToSeed` in `mnemonic.ts` must be `async` despite the underlying `@scure/bip39` functions being synchronous, because the lazy-loading pattern (`await loadBip39()`) requires it. Same as how `argon2id.ts` wraps sync Argon2 in async.
- **`deriveRecoveryKEK` uses mnemonic string directly**: In `mnemonic.ts`, the mnemonic phrase is passed as the Argon2id "password" parameter, not the BIP-39 binary seed. The human-readable phrase is the input because it is what the user supplies and remembers; the binary seed is an internal derivation artifact. `mnemonicToSeed` is a utility function not used in the recovery KEK path.
- **`deriveRecoveryKEK` uses mnemonic string directly**: In `mnemonic.ts`, the mnemonic phrase is passed as the Argon2id "password" parameter, not the BIP-39 binary seed. The human-readable phrase is the input because it is what the user supplies and remembers; the binary seed is an internal derivation artifact. `mnemonicToSeed` is a utility function not used in the recovery KEK path. `wrapMasterKeyWithRecovery` and `unwrapMasterKeyWithRecovery` zero-fill the recovery KEK in a `finally` block and also derive `recoveryAuthHash` via `HKDF_INFO.RECOVERY_AUTH`.
- **Crypto integration tests mock `deriveKey` re-consumption**: In `crypto-integration.test.ts` (in `shared/crypto/`), `unwrapMasterKeyWithRecovery` requires a fresh `deriveKey` mock even after `wrapMasterKeyWithRecovery` consumed one during setup. The `setupRegistration` helper uses `mockResolvedValueOnce` which is consumed, so the test must re-mock before calling unwrap.
- **`deriveRegistrationKeys` is a pure crypto function**: in `features/auth/model/registration-crypto.ts`, it has no side effects (no auth, no DB, no store writes). The orchestration (signup + upload + store population) lives in `features/auth/model/auth-service.ts` `signUpUser`. Do not add side effects to this function.
- **`signUpUser` error cleanup**: on any error after `deriveRegistrationKeys` succeeds, attempts `authAdapter.logout()` as best-effort cleanup (harmless if no session exists, since Supabase signOut with no session is a no-op).
- **Login salt fetch is pre-auth**: `get_login_salts(p_username)` is a SECURITY DEFINER RPC callable by anonymous users, rate-limited (5 req/2 min/IP). Salts must be fetched before auth to derive `authHash` for Supabase Auth, but the `master_keys` table is RLS-protected. After auth succeeds, `fetchMasterKeyEnvelope` and `fetchFieldKeys` fetch wrapped key material through standard RLS-protected queries.
- **`saveRecoveryData` uses RPC**: Registration and regeneration both call `saveRecoveryData()` in `supabase-recovery.ts`, which invokes the `save_recovery_data` SECURITY DEFINER function. This RPC bcrypt-hashes `recoveryAuthHash` before storage, ensuring the raw HKDF-derived value never appears in the DB. Direct table inserts into `recovery_keys` are not used anywhere.
- **Pre-auth RPCs**: `get_login_salts(p_username)` and `get_recovery_data(p_username)` are SECURITY DEFINER RPCs callable by anonymous users, rate-limited (5 req/2 min/IP). Salts must be fetched before auth to derive `authHash` for Supabase Auth, but the `master_keys` table is RLS-protected. After auth succeeds, `fetchMasterKeyEnvelope` and `fetchFieldKeys` fetch wrapped key material through standard RLS-protected queries. `recover_account` is also pre-auth but more strictly rate-limited (3 req/15 min/IP).
- **Auth error codes fold username format into invalid credentials**: `AuthErrorCode.INVALID_USERNAME_FORMAT` doesn't exist — `supabase-keys.ts` throws `INVALID_CREDENTIALS` for invalid username format. This is deliberate: showing a different error for "wrong format" vs "wrong password" would leak whether a username exists. Missing key data is `ApiError(NOT_FOUND)`, not an auth error — `KEYS_NOT_FOUND` was removed from `AuthErrorCode` because "data not found" is a data-layer concern, not an auth concern.
- **`AuthError` vs `ApiError` domain boundary**: `AuthError` (in `shared/auth/auth-errors.ts`) covers authentication errors (`INVALID_CREDENTIALS`, `USERNAME_TAKEN`, `NETWORK_ERROR`, `UNEXPECTED`). `ApiError` (in `shared/api/api-errors.ts`) covers data-layer errors (`NETWORK_ERROR`, `NOT_FOUND`, `UNEXPECTED`). `fetchLoginSalts` throws `AuthError` because it's a pre-auth RPC that's part of the login flow; all other data queries throw `ApiError`. Each domain has its own `wrapXxxError` that classifies raw errors using the shared `isNetworkError` helper.
- **Network errors can bypass the adapter boundary**: `isNetworkError` (in `shared/lib/network-errors.ts`) is shared by both `wrapAuthError` and `wrapApiError`. Raw `TypeError('Failed to fetch')` from the browser can reach the UI without being wrapped by any adapter, so `getAuthErrorMessage` in `auth-error-messages.ts` also calls `isNetworkError` as a final fallback.
- **Change password rollback**: `changeUserPassword` in `auth-service.ts` is a 4-step flow (derive → upload envelope → update auth → update store). If step 3 (Supabase Auth update) fails after step 2 (DB envelope upload) succeeds, it attempts to roll back the DB write with the old envelope values. If rollback also fails, it forces logout to prevent inconsistent state.
- **Stale KEK detection**: Stale KEK from a password change on another device is detected in two places: (1) `use-realtime-sync.ts` `onKeyRotation` — when a key rotation event arrives, `syncFieldKeys` tries to unwrap with the current KEK; a `DecryptionError` means the KEK is stale, so `clearVault()` forces re-auth. (2) `key-vault.ts` `unlockVault` — if the cached envelope is stale, `clearVault` + retry from server. The save path (`useSaveField`) cannot produce a `DecryptionError` — `encryptField` only encrypts, and `getFieldKey` throws a generic `Error` when the vault is locked, not `DecryptionError`.
- **`recoveryAuthHash` for proof-of-knowledge**: `wrapMasterKeyWithRecovery` and `unwrapMasterKeyWithRecovery` in `mnemonic.ts` derive a `recoveryAuthHash` via `HKDF_INFO.RECOVERY_AUTH` from the recovery KEK, and zero-fill the KEK in a `finally` block. The server stores a bcrypt hash of this value (via `save_recovery_data` RPC — never a direct table insert), so the raw HKDF-derived value never appears in the DB. The `recover_account` RPC verifies this proof before atomically updating auth password, salts, and master key.
- **Account recovery is a two-step client flow**: `RecoveryFlow` class in `mnemonic-service.ts` holds state (`username`, `masterKey`, `recoveryAuthHash`) between `validateMnemonic()` and `setNewPassword()`. Master key is zero-filled in `clear()` (called on unmount, not in React state — to avoid devtools exposure). `recover_account` RPC is atomic on the server side; if the RPC succeeds but automatic login fails, a `RecoveryLoginError` is thrown and the user is redirected to `/login` with a success toast.

65 changes: 42 additions & 23 deletions docs/implementation-plan/08-phase-8-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,40 +63,59 @@

---

## Step 31 — Seed Phrase Recovery Flow + UI
## Step 31 — Seed Phrase Recovery Flow + UI

**Goal:** Recover account using seed phrase when password is lost.

**Code:**
- `src/app/routes/_public.recover.tsx` — replace placeholder with full recovery form:
- Username input + `MnemonicInput` (12-word input with BIP-39 validation)
- On submit:
1. Fetch recovery salts via `get_recovery_salts(p_username)` RPC (pre-auth, rate-limited)
2. Derive recovery KEK from mnemonic + recovery salt via `deriveRecoveryKEK`
3. Unwrap master key with `unwrapMasterKeyWithRecovery`
4. Derive full key hierarchy, unlock vault
5. Prompt user to set a new password (required — old auth hash is invalid after recovery)
- Error states: invalid mnemonic (BIP-39 validation), wrong mnemonic (DecryptionError), network error
- `src/app/routes/_public.recover.tsx` — route renders `RecoverPage` component
- `src/features/auth/ui/RecoverPage.tsx` — two-step form (mnemonic → new password):
- Step 1: username input + `MnemonicInput`; calls `recoveryFlow.validateMnemonic()`
- Step 2: new password + confirm; calls `recoveryFlow.setNewPassword()`
- On success, navigates to `/dashboard`; on `RecoveryLoginError`, navigates to `/login` with toast
- Zero-fills recovery state on unmount via `recoveryFlow.clear()`
- `src/features/auth/ui/MnemonicInput.tsx`:
- 12 individual word inputs with BIP-39 word validation
- Auto-advance to next word on space/tab; paste support (split pasted text into words)
- Highlight invalid words in red; disable submit until all 12 words are valid BIP-39 words
- Controlled component with `value`, `onChange`, `onValidityChange` props
- 12 individual word inputs with BIP-39 word validation (async via `getBip39Wordlist()`)
- Auto-advance on space/tab; paste support (multi-word paste splits into inputs)
- Invalid words highlighted with `border-destructive`; submit disabled until all valid
- `src/features/auth/ui/VerifyMnemonicDialog.tsx`:
- From SecuritySection, let user verify their stored mnemonic can unwrap the master key
- Uses `MnemonicInput` → derive recovery KEK → `unwrapMasterKeyWithRecovery` with cached envelope
- Success: "Your recovery phrase is valid" / Failure: "Recovery phrase does not match"
- `src/features/auth/model/recovery-service.ts`:
- Orchestrates: fetch salts → derive KEK → unwrap master key → derive key hierarchy → set new password
- Reuses `deriveFullKeyHierarchy` and password-setting logic from `changeUserPassword`
- `src/shared/api/supabase-recovery.ts` — add `get_recovery_salts(p_username)` RPC call for pre-auth salt fetch
- Add i18n strings to `auth.json` for recovery flow
- From SecuritySection, verify mnemonic can unwrap the master key
- Uses `verifyMnemonic()` from `mnemonic-service.ts` → returns true/false
- Success toast / failure error message
- `src/features/auth/model/mnemonic-service.ts` — `RecoveryFlow` class:
- `validateMnemonic(username, mnemonic)` → fetches recovery data pre-auth, unwraps master key, stores state
- `setNewPassword(newPassword)` → re-wraps master key, calls `recoverAccount` RPC, logs in, unlocks vault
- `clear()` → zero-fills master key and clears state
- `RecoveryLoginError` thrown when recovery succeeds but auto-login fails
- `verifyMnemonic(mnemonic)` → standalone function for verifying mnemonic from SecuritySection
- `src/features/auth/model/recovery-schema.ts` — Zod schemas for both steps
- `src/features/auth/model/recovery-error-messages.ts` — error mapping for recovery + regenerate mnemonic flows
- `src/shared/api/supabase-recovery.ts` — three RPCs:
- `fetchRecoveryDataPreAuth(username)` — `get_recovery_data` RPC (pre-auth, rate-limited 5 req/2 min/IP)
- `recoverAccount(username, data)` — `recover_account` RPC (atomic: verify proof + update auth/salts/keys, rate-limited 3 req/15 min/IP)
- `saveRecoveryData` — changed from direct table insert to `save_recovery_data` RPC (bcrypt-hashes `recoveryAuthHash`)
- `supabase/migrations/00006_recovery_rpc.sql` — migration with all three RPCs
- Recovery uses `recoveryAuthHash` (HKDF of recovery KEK) as proof-of-knowledge:
- `wrapMasterKeyWithRecovery` and `unwrapMasterKeyWithRecovery` now return `recoveryAuthHash`
- Server stores bcrypt hash of `recoveryAuthHash`; `recover_account` verifies it before updating
- `RecoveryCredentials` type: `{newPasswordAuthHash, newKeySalt}` (no `mnemonic` field)
- `src/shared/ui/create-dialog-store.ts` — factory for simple open/close dialog stores
- `src/shared/auth/auth-dialogs-store.ts` — consolidated auth dialog stores using factory
- Dialog stores refactored: `change-password-dialog-store`, `regenerate-mnemonic-dialog-store`, `vault-dialog-store` → use `createDialogStore` with `isOpen/open/close` API
- Login page: "Forgot password?" link to `/recover`
- Add i18n strings to `auth.json` (recovery, verify mnemonic) and `settings.json` (verify seed phrase)

**Tests:**
- Integration: register → write down mnemonic → recover with mnemonic → set new password → login with new password
- Integration: register → recover with mnemonic → set new password → login with new password
- Component: MnemonicInput validates BIP-39 words, highlights invalid, supports paste
- Unit: wrong mnemonic → `unwrapMasterKeyWithRecovery` throws `DecryptionError`
- Unit: recovery + new password → vault unlocks with new credentials
- Unit: `RecoveryFlow` class — validate mnemonic, set new password, clear
- Component: VerifyMnemonicDialog shows success/failure based on mnemonic validity
- Component: RecoverPage — step transitions, error handling
- Unit: `recovery-schema` validation
- Unit: `recovery-error-messages` covers all error types
- Unit: `getBip39Wordlist()` caching, HKDF `RECOVERY_AUTH` branch

---

Expand Down
2 changes: 1 addition & 1 deletion docs/implementation-plan/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ This is the implementation plan for Cipher Note, an end-to-end encrypted note-ta
- [x] Step 28 — Multi-Device Session Handling
- [x] Step 29 — Change Password Flow + UI
- [x] Step 30 — Seed Phrase Backup View
- [ ] Step 31 — Seed Phrase Recovery Flow + UI
- [x] Step 31 — Seed Phrase Recovery Flow + UI
- [ ] Step 32 — Key Rotation + UI
- [ ] Step 33 — Mobile Responsive Refinements
- [ ] Step 34 — Loading States, Error Boundaries, Toast Notifications
Expand Down
4 changes: 2 additions & 2 deletions src/app/layouts/MobileNav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,11 +173,11 @@ describe('MobileNav', () => {

it('opens unlock dialog when unlock button is clicked while locked', async () => {
useCryptoStore.setState({ isVaultLocked: true })
useVaultDialogStore.setState({ isUnlockDialogOpen: false })
useVaultDialogStore.setState({ isOpen: false })
const user = userEvent.setup()
render(<MobileNav />)
await user.click(screen.getByRole('button', { name: /unlock vault/i }))
expect(useVaultDialogStore.getState().isUnlockDialogOpen).toBe(true)
expect(useVaultDialogStore.getState().isOpen).toBe(true)
expect(mockLockVault).not.toHaveBeenCalled()
})
})
2 changes: 2 additions & 0 deletions src/app/layouts/ProtectedLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { VaultIndicator } from '@/features/vault/ui/VaultIndicator'
import { VaultUnlockDialog } from '@/features/vault/ui/VaultUnlockDialog'
import { ChangePasswordDialog } from '@/features/auth/ui/ChangePasswordDialog'
import { RegenerateMnemonicDialog } from '@/features/auth/ui/RegenerateMnemonicDialog'
import { VerifyMnemonicDialog } from '@/features/auth/ui/VerifyMnemonicDialog'
import { OfflineBanner } from '@/shared/ui/OfflineBanner'
import { useLayoutStore } from './layout-store'
import { useResizable } from '@/shared/lib/use-resizable'
Expand Down Expand Up @@ -105,6 +106,7 @@ function AuthenticatedLayout() {
<VaultUnlockDialog />
<ChangePasswordDialog />
<RegenerateMnemonicDialog />
<VerifyMnemonicDialog />
</div>
)
}
Expand Down
Loading
Loading