From f6028e7e037af7ad30440efefa954e3dfb2abd61 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Tue, 7 Jul 2026 15:48:44 +0200 Subject: [PATCH] feat: refine mobile responsive UI --- CLAUDE.md | 4 +- .../08-phase-8-recovery.md | 39 ++++++++++--------- docs/implementation-plan/09-phase-9-polish.md | 6 +-- docs/implementation-plan/README.md | 4 +- index.html | 4 +- src/app/layouts/EntryNavItem.tsx | 4 +- src/app/layouts/ProtectedLayout.tsx | 14 ++++--- src/features/auth/ui/MnemonicDialog.tsx | 4 +- src/features/auth/ui/MnemonicInput.tsx | 2 +- src/features/vault/ui/VaultIndicator.tsx | 2 +- src/shared/lib/use-resizable.ts | 2 +- src/shared/ui/button.tsx | 14 ++++--- src/shared/ui/form/FormField.tsx | 2 +- src/shared/ui/input.tsx | 2 +- src/shared/ui/nav/NavLink.tsx | 2 +- src/shared/ui/nav/PublicHeader.tsx | 2 +- src/shared/ui/nav/ResizeHandle.tsx | 6 +-- 17 files changed, 61 insertions(+), 52 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 02b969d..076cf3b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,7 @@ main.tsx → AppProviders (QueryClientProvider > AuthProvider > RouterProvider) - 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`) +- Key rotation RPC: `rotate_field_key(p_payload)` authenticated SECURITY DEFINER — atomic: insert new wrapped key version, update all ciphertexts for that field, delete old key versions in one transaction - `recovery_keys` table has a `recovery_auth_hash` column (bcrypt hash of HKDF-derived proof-of-knowledge) ### Adapter Pattern @@ -188,6 +189,7 @@ See `docs/implementation-plan/README.md` for the full 36-step plan. - Step 29 (Change Password Flow + UI) — complete - Step 30 (Regenerate Seed Phrase) — complete - Step 31 (Seed Phrase Recovery Flow + UI) — complete +- Step 32 (Key Rotation + UI) — complete ### Implementation Notes @@ -219,7 +221,7 @@ Non-obvious decisions not visible from code alone: - **`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`. +- **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 (from password change or field-key rotation on another session), `syncFieldKeys` tries to unwrap with the current KEK; a `DecryptionError` means the KEK is stale, so `clearVault()` forces re-auth. If the vault is locked, the cached envelope is cleared so the next unlock fetches fresh key material. Local rotations are echo-suppressed via `markLocalKeyRotation`/`isLocalKeyRotationEcho`. (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. diff --git a/docs/implementation-plan/08-phase-8-recovery.md b/docs/implementation-plan/08-phase-8-recovery.md index f29696a..cc00994 100644 --- a/docs/implementation-plan/08-phase-8-recovery.md +++ b/docs/implementation-plan/08-phase-8-recovery.md @@ -1,4 +1,4 @@ -# Phase 8: Password & Recovery +# Phase 8: Password & Recovery ✅ ## Step 29 — Change Password Flow + UI ✅ @@ -119,32 +119,35 @@ --- -## Step 32 — Key Rotation + UI +## Step 32 — Key Rotation + UI ✅ -**Goal:** Rotate individual field keys (re-encrypt one field's data without affecting others). +**Goal:** Rotate individual field keys (re-encrypt one field's data without affecting others), with support for rotating all fields at once. **Code:** -- `src/shared/crypto/key-rotation.ts`: - - `rotateFieldKey(fieldName: string): Promise` - 1. Generate new random 256-bit field key +- Rotation service in `features/fields/model/`: + - `rotateFieldKey(userId, fieldName): Promise` — returns new version number + 1. Generate new random 256-bit field key via `generateFieldKey` 2. Increment version for this field (v1 → v2) 3. Wrap new field key with KEK (AAD = fieldName + newVersion) - 4. Decrypt current field content with old field key - 5. Re-encrypt field content with new field key - 6. Upload new wrapped field key + new encrypted field content to server - 7. Update crypto store with new field key - 8. Old wrapped key and old ciphertext are replaced on server -- `src/features/settings/ui/KeyRotationSection.tsx`: - - Shows current key versions for each field (note v1, website v1, email v1) - - "Rotate key" button for each field - - Confirmation dialog: "This will re-encrypt your [field name] data. This cannot be undone." - - Success/error feedback + 4. Fetch all encrypted fields for this field name across all entries (multi-entry aware) + 5. Decrypt each field's content with old field key, re-encrypt with new field key + 6. Commit via atomic server RPC (new wrapped key + re-encrypted ciphertexts + delete old versions in one transaction) + 7. Suppress realtime echo via `markLocalKeyRotation` + 8. Update key vault and crypto store with new field key and cached envelope + - `rotateAllFields(userId): Promise` — rotates all four fields sequentially; partial success is surfaced per field (failed fields can be retried independently) + - `generateFieldKey(kek, fieldName, version)` — extracted from `generateAllFieldKeys`, generates a single field key + wraps it +- `KeyManagementSubsection` — collapsible section inside SecuritySection showing current key versions per field, "Rotate" button per field, and "Rotate all" button +- `RotateFieldKeyDialog` — confirmation dialog handling both single-field and all-fields rotation, with progress indicator for "rotate all" +- Dedicated error-mapping module using shared `mapErrorToMessage` pattern +- Server RPC: `rotate_field_key` SECURITY DEFINER function (atomic: insert new key version, update all ciphertexts, delete old key versions in one transaction) +- Realtime echo suppression: `markLocalKeyRotation` / `isLocalKeyRotationEcho` to avoid processing own rotation events - Add i18n strings to `settings.json` and `vault.json` **Tests:** - Integration: rotate note key → verify note v2 in DB → verify note content decrypts correctly - Integration: after rotation, old key can no longer decrypt (old ciphertext replaced) - Integration: website and email field keys are unaffected by note key rotation -- Component test: key rotation section shows current versions -- Component test: confirmation dialog appears before rotation +- Component test: key rotation subsection shows current versions, rotate buttons +- Component test: confirmation dialog appears before rotation (single and all-fields) - Unit: key version increments correctly +- Unit: error-mapping module covers all error types diff --git a/docs/implementation-plan/09-phase-9-polish.md b/docs/implementation-plan/09-phase-9-polish.md index ac5836e..45f5c7f 100644 --- a/docs/implementation-plan/09-phase-9-polish.md +++ b/docs/implementation-plan/09-phase-9-polish.md @@ -9,11 +9,11 @@ - Login/Register: stacked form, full-width inputs - Dashboard: stacked field cards (no sidebar), bottom navigation - Settings: stacked sections, full-width inputs - - Mnemonic dialog: scrollable word list, larger tap targets + - Mnemonic dialog: responsive 2→3 column grid on mobile, `break-words` for long mnemonics - Vault unlock dialog: full-screen on mobile -- Touch targets: minimum 44px tap area for all interactive elements +- Touch targets: mobile-first 44px minimum (`min-h-11` / `min-w-11`), reverting to standard sizes at `md:` breakpoint - Keyboard handling: scroll to focused input on mobile -- Safe area insets: respect `env(safe-area-inset-*)` for notched devices +- Safe area insets: `viewport-fit=cover` meta + `env(safe-area-inset-top)` padding on headers - Swipe gestures: consider swipe to lock vault on mobile **Tests:** diff --git a/docs/implementation-plan/README.md b/docs/implementation-plan/README.md index 63fc2c0..644fac8 100644 --- a/docs/implementation-plan/README.md +++ b/docs/implementation-plan/README.md @@ -69,8 +69,8 @@ This is the implementation plan for Cipher Note, an end-to-end encrypted note-ta - [x] Step 29 — Change Password Flow + UI - [x] Step 30 — Seed Phrase Backup View - [x] Step 31 — Seed Phrase Recovery Flow + UI -- [ ] Step 32 — Key Rotation + UI -- [ ] Step 33 — Mobile Responsive Refinements +- [x] Step 32 — Key Rotation + UI +- [x] Step 33 — Mobile Responsive Refinements - [ ] Step 34 — Loading States, Error Boundaries, Toast Notifications - [ ] Step 35 — Security Hardening - [ ] Step 36 — E2E Tests (Playwright) diff --git a/index.html b/index.html index 686d0dd..9e0fedd 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - + - \ No newline at end of file + diff --git a/src/app/layouts/EntryNavItem.tsx b/src/app/layouts/EntryNavItem.tsx index a3459e1..a32c0e1 100644 --- a/src/app/layouts/EntryNavItem.tsx +++ b/src/app/layouts/EntryNavItem.tsx @@ -23,7 +23,7 @@ export function EntryNavItem({ entryId, index, isVaultLocked, isActive, onClick,