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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.

39 changes: 21 additions & 18 deletions docs/implementation-plan/08-phase-8-recovery.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Phase 8: Password & Recovery
# Phase 8: Password & Recovery

## Step 29 — Change Password Flow + UI ✅

Expand Down Expand Up @@ -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<void>`
1. Generate new random 256-bit field key
- Rotation service in `features/fields/model/`:
- `rotateFieldKey(userId, fieldName): Promise<number>` — 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<RotationOutcome[]>` — 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
6 changes: 3 additions & 3 deletions docs/implementation-plan/09-phase-9-polish.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
4 changes: 2 additions & 2 deletions docs/implementation-plan/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<script>
;(function () {
var redirect = sessionStorage.redirect
Expand All @@ -20,4 +20,4 @@
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</html>
4 changes: 2 additions & 2 deletions src/app/layouts/EntryNavItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function EntryNavItem({ entryId, index, isVaultLocked, isActive, onClick,
<button
onClick={onClick}
className={cn(
'flex flex-col items-center gap-0.5 px-2 py-1 text-xs',
'flex min-h-11 min-w-11 flex-col items-center justify-center gap-0.5 px-2 py-1 text-xs',
isActive ? 'bg-muted' : 'hover:bg-muted/50 text-muted-foreground',
)}
>
Expand All @@ -37,7 +37,7 @@ export function EntryNavItem({ entryId, index, isVaultLocked, isActive, onClick,
<button
onClick={onClick}
className={cn(
'focus-visible:ring-ring/50 flex w-full cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-left text-sm outline-none focus-visible:ring-2',
'focus-visible:ring-ring/50 flex min-h-11 w-full cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-left text-sm outline-none focus-visible:ring-2 md:min-h-0',
isActive ? 'bg-muted font-medium' : 'hover:bg-muted/50 text-muted-foreground',
)}
>
Expand Down
14 changes: 8 additions & 6 deletions src/app/layouts/ProtectedLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,11 @@ function AuthenticatedLayout() {
>
<Sidebar onLogout={logoutUser} />
</aside>
<ResizeHandle isDragging={isDragging} handleProps={handleProps} />

{/* Right column: header + main */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Header */}
<header className="bg-muted/30 flex h-14 items-center justify-between border-b px-4">
<header className="bg-muted/30 flex min-h-14 items-center justify-between border-b px-4 pt-[env(safe-area-inset-top)]">
<div className="flex items-center gap-2">
{/* Mobile hamburger menu */}
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
Expand All @@ -94,10 +93,13 @@ function AuthenticatedLayout() {
{/* Offline status banner */}
<OfflineBanner />

{/* Main content */}
<main className="mb-10 flex flex-1 flex-col overflow-y-auto p-6 pb-20 md:pb-6">
<Outlet />
</main>
{/* Main content area with resize handle */}
<div className="relative flex flex-1 flex-col">
<ResizeHandle isDragging={isDragging} handleProps={handleProps} />
<main className="mb-10 flex flex-1 flex-col overflow-y-auto p-6 pb-20 md:pb-6">
<Outlet />
</main>
</div>
</div>

{/* Mobile bottom navigation */}
Expand Down
4 changes: 2 additions & 2 deletions src/features/auth/ui/MnemonicDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@ function MnemonicDialog({ open, mnemonic, onContinue }: MnemonicDialogProps) {
<p className="text-destructive text-sm">{t('mnemonic.warning')}</p>
</div>

<div className="grid grid-cols-3 gap-2">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{words.map((word, index) => (
<div key={index} className="bg-muted rounded-md px-3 py-2 text-center font-mono text-sm">
<div key={index} className="bg-muted rounded-md px-3 py-2 text-center font-mono text-sm break-words">
<span className="text-muted-foreground mr-1">{index + 1}.</span>
{word}
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/features/auth/ui/MnemonicInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ function MnemonicInput({ value, onChange, disabled, error, onValidityChange }: M

return (
<div>
<div className="grid grid-cols-3 gap-2">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{Array.from({ length: WORD_COUNT }, (_, index) => {
const isInvalid = invalidWords.has(index)
return (
Expand Down
2 changes: 1 addition & 1 deletion src/features/vault/ui/VaultIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function VaultIndicator() {
type="button"
onClick={openUnlockDialog}
className={cn(
'flex items-center gap-1.5 text-sm transition-colors duration-300',
'flex min-h-11 min-w-11 items-center gap-1.5 text-sm transition-colors duration-300 md:min-h-8 md:min-w-8',
'text-muted-foreground hover:text-foreground cursor-pointer',
)}
aria-label={t('unlock')}
Expand Down
2 changes: 1 addition & 1 deletion src/shared/lib/use-resizable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ function clamp(value: number, min: number, max: number) {
function useResizable({
storedWidth,
onWidthChange,
minWidth = 150,
minWidth = 200,
maxWidth = 1000,
}: UseResizableOptions): UseResizableReturn {
const [localWidth, setLocalWidth] = useState<number | null>(null)
Expand Down
14 changes: 8 additions & 6 deletions src/shared/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,17 @@ const buttonVariants = cva(
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
default:
'h-8 min-h-11 md:min-h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
icon: 'size-8',
sm: "h-7 min-h-11 md:min-h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 min-h-11 md:min-h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
icon: 'size-8 min-h-11 min-w-11 md:min-h-8 md:min-w-8',
'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
'icon-sm':
'size-7 min-h-11 min-w-11 md:min-h-7 md:min-w-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9 min-h-11 min-w-11 md:min-h-9 md:min-w-9',
},
},
defaultVariants: {
Expand Down
2 changes: 1 addition & 1 deletion src/shared/ui/form/FormField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function FormField({ id, label, error, children }: FormFieldProps) {
return (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
<div className="h-8">{inputWithAria}</div>
<div className="min-h-11 md:min-h-8">{inputWithAria}</div>
{error ? (
<p id={errorId} className="text-destructive text-sm" role="alert">
{error}
Expand Down
2 changes: 1 addition & 1 deletion src/shared/ui/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
type={type}
data-slot="input"
className={cn(
'border-input file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 disabled:bg-input/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 h-8 w-full min-w-0 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-3 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 md:text-sm',
'border-input file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 disabled:bg-input/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 h-8 min-h-11 w-full min-w-0 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-3 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 md:min-h-8 md:text-sm',
className,
)}
{...props}
Expand Down
2 changes: 1 addition & 1 deletion src/shared/ui/nav/NavLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Link, type LinkComponentProps } from '@tanstack/react-router'
import { cn } from '@/shared/lib/utils'

const navLinkClassName =
'hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:rounded-md flex items-center rounded-md px-3 py-2 text-sm font-medium outline-none'
'hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:rounded-md flex min-h-11 items-center rounded-md px-3 py-2 text-sm font-medium outline-none md:min-h-0'

function NavLink({ className, ...props }: LinkComponentProps) {
return <Link className={cn(navLinkClassName, className)} {...props} />
Expand Down
2 changes: 1 addition & 1 deletion src/shared/ui/nav/PublicHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ function PublicHeader() {

return (
<header className="right-0 left-0 z-50 border-b border-transparent backdrop-blur-md">
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-6">
<div className="mx-auto flex min-h-16 max-w-6xl items-center justify-between px-4 pt-[env(safe-area-inset-top)] sm:px-6">
<Link to="/" className="flex items-center gap-2" aria-label={t('nav.backToHome')}>
<CipherNoteIcon className="size-7" />
<span className="text-foreground text-lg font-semibold">Cipher Note</span>
Expand Down
Loading
Loading