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
12 changes: 6 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ Recovery: BIP-39 mnemonic → Argon2id → recovery KEK → wraps master key
Field Keys (one per field) → wrapped by KEK with AAD(fieldName, version)
```
- All keys: 32 bytes (256 bits), salts: 16 bytes. Argon2id params: m=47104, t=3, p=1.
- Zustand crypto store uses **hex-encoded strings** (not Uint8Array) for reactivity.
- Vault lock purges Zustand keys + TanStack Query cache.
- `KeyVault` class (`key-vault.ts`) stores non-extractable `CryptoKey` objects in a module-scoped `Map`. Zustand crypto store only tracks which field names are loaded (`loadedFieldKeys: Record<string, boolean>`).
- Vault lock purges key vault Map + Zustand state + TanStack Query cache.

### App Hierarchy
```
Expand Down Expand Up @@ -82,7 +82,7 @@ Backend abstracted behind interfaces: `IAuthAdapter`, `IApiAdapter`, `IRealtimeA
- These MUST be dynamically imported. For `argon2-browser`, use the bundled build to avoid Vite WASM loading issues: `const argon2 = await import('argon2-browser/dist/argon2-bundled.min.js')`. The default import (`argon2-browser`) tries to load a `.wasm` file which Vite cannot handle. The bundled build embeds WASM as base64 in JS. A module declaration in `src/env.d.ts` maps the bundled path to `argon2-browser` types.
- The Vite config already has manual chunks for these modules to keep them out of the initial bundle.
- NEVER persist crypto keys to localStorage, sessionStorage, or IndexedDB.
- Use hex-encoded strings in Zustand stores (not Uint8Array or Map) for proper reactivity.
- Crypto keys live in `KeyVault` as non-extractable `CryptoKey` objects, not in Zustand.

### Styling
- Tailwind CSS v4 with `@import "tailwindcss"` and `@theme` in `src/app/styles/globals.css`.
Expand Down Expand Up @@ -170,7 +170,7 @@ See `IMPLEMENTATION-PLAN.md` for the full 36-step plan.
- Step 20 (Registration UI) — complete
- Step 21 (Login Crypto Flow) — complete
- Step 22 (Login UI + Vault Unlock) — complete
- Step 23 (Crypto Session Store in Zustand + Query Cache Purge) — complete
- Step 23 (Non-Extractable Key Vault + Zustand Store Refactor) — complete

### Implementation Notes

Expand All @@ -179,7 +179,7 @@ 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` 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
- **`lockVault()` vs `clearVault()`**: `lockVault()` preserves the cached envelope (so re-unlock can skip network calls), while `clearVault()` (called on logout) zeros everything including the cache. `unlockVault()` tries the cached envelope first and retries from server on `DecryptionError` (stale cache can happen if the password was changed in another session)
- **`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. `unlockVault()` (in `auth-flow.ts`) 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 `useUiStore` (including `sidebarWidth: 240`) in `afterEach`. Router mocking (`@tanstack/react-router`) is done per-file in each test that needs it, not centralized
- **Argon2id Web Worker**: `argon2id.ts` delegates all derivation to `argon2id.worker.ts` via `postMessage`. The worker lazy-loads `argon2-browser/dist/argon2-bundled.min.js` (not the default `argon2-browser` import — the default tries to load a `.wasm` file which Vite cannot handle; the bundled build embeds WASM as base64 in JS). Tests mock the Worker constructor; actual Argon2id computation is tested in E2E (Step 36).
Expand All @@ -195,6 +195,6 @@ Non-obvious decisions not visible from code alone:
- **Crypto integration tests mock `deriveKey` re-consumption**: In `crypto-integration.test.ts`, `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/encryption/model/registration.ts`, it has no side effects (no auth, no DB, no store writes). The orchestration (signup + upload + store population) lives in `auth-flow.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 `keys` table is RLS-protected. After auth succeeds, `getMasterKeyEnvelope` and `getFieldKeys` fetch wrapped key material through standard RLS-protected queries.
- **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 `keys` table is RLS-protected. After auth succeeds, `fetchMasterKeyEnvelope` and `fetchFieldKeys` fetch wrapped key material through standard RLS-protected queries.
- **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.
- **Network errors can bypass the adapter boundary**: `getAuthErrorMessage` in `auth-error-messages.ts` has an `isNetworkError` fallback because raw `TypeError('Failed to fetch')` from the browser can reach the UI without being wrapped by the adapter. The adapter wraps what it can, but the fallback catches the rest.
38 changes: 22 additions & 16 deletions IMPLEMENTATION-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -986,27 +986,33 @@ Dependency rules: `routes` → `features` → `shared`. No cross-feature imports

---

### Step 23 — Crypto Session Store (Zustand) + Query Cache Purge
### Step 23 — Non-Extractable Key Vault + Zustand Store Refactor

**Goal:** Verify and finalize the Zustand crypto store — already has hex-encoded keys, `lockVault()` with query cache purge, `setKeys`, `updateActivity`, and `selectFieldKey`. Remove devtools middleware from stores holding sensitive data (auth store, crypto store) since Redux DevTools would expose secrets in browser devtools.
**Goal:** Replace hex-encoded key strings in Zustand with a module-scoped `KeyVault` class holding non-extractable `CryptoKey` objects (so `exportKey()` fails). Consolidate vault lock/unlock logic, zero-fill intermediate key material, and remove the now-unnecessary `login.ts` and `vault-lock.ts` modules.

**Code:**
- Verify `copyToUint8Array` (in `crypto-utils.ts`) is used in AES-GCM module for `encrypt`, `decrypt`, `exportKey` results (replacing bare `new Uint8Array(buffer)` calls)
- Verify that `devtools` middleware is not in auth store and crypto store — these hold secrets (tokens, crypto keys) that must not be visible in browser DevTools. Only non-sensitive stores (UI store, vault dialog store) keep devtools with named actions
- `lockVault()` (inactivity timeout) preserves cached envelope for faster re-unlock; `clearVault()` (logout) zeros everything including cached envelope. Both purge TanStack Query cache for `['field']`
- Verify `setQueryClient(client)` is wired in app providers
- Verify that no crypto keys appear in localStorage, sessionStorage, or IndexedDB (crypto store and auth store use no persist middleware)
- Replace hex-encoded keys in crypto store (`masterKey`, `kek`, `fieldKeys`) with a `KeyVault` class (`key-vault.ts`) that stores non-extractable `CryptoKey` objects in a module-scoped `Map`. Crypto store now only tracks `loadedFieldKeys: Record<string, boolean>` (which field names are loaded, not the actual key bytes). Remove `selectFieldKey` — consumers call `keyVault.getKey(id)` instead
- `KeyVault.storeFieldKeys(kek, fieldKeys)` stores KEK + field CryptoKeys and calls `setKeys(fieldKeyNames)` on the Zustand store. `keyVault.lockVault()` clears the Map and sets `isVaultLocked`; `keyVault.clearVault()` additionally purges the cached envelope and query cache
- Move `unlockVault()` from `vault-lock.ts` into `auth-flow.ts`, inlining the derivation steps into focused helpers (`fetchFreshEnvelope`, `deriveKekFromEnvelope`, `storeFieldKeys`). Zero-fill all intermediate key material (`passwordKey`, `masterKey`, `kekBytes`) after use. On stale-cache `DecryptionError`, clear the vault and retry from server
- Delete `vault-lock.ts` and `login.ts` — their logic absorbed by `key-vault.ts` and `auth-flow.ts`. Update all callers (`Sidebar`, `MobileNav`, `VaultUnlockDialog`, `vault-timeout`) to use `keyVault.lockVault()` instead of the removed `lockVault()` function
- `generateFieldKeys()` now returns `{ rawFieldKeys, cryptoFieldKeys }` — raw bytes for wrapping, `CryptoKey` objects for encryption. `deriveFullKeyHierarchy` imports KEK as non-extractable (`extractable: false`). `unwrapFieldKeys` accepts `ServerFieldKey[]` directly and returns `Map<string, CryptoKey>`. `RegistrationResult` uses `CryptoKey` types for `kek` and `fieldKeys`; `masterKey` removed from the return type
- Simplify `split-kdf.ts`: remove `deriveLoginCredentials` and `LoginCredentials` type; `changePassword` reuses `deriveAuthCredentials` instead of a separate derivation path. `deriveAuthCredentials` returns `authHash` + `passwordKey` directly (login no longer needs both salts in one call since `authHash` is derived first, then `passwordKey` separately from the envelope's `keySalt`)
- Rename API fetchers: `getLoginSalts` → `fetchLoginSalts`, `getMasterKeyEnvelope` → `fetchMasterKeyEnvelope`, `getFieldKeys` → `fetchFieldKeys` (consistent verb convention). Update `IApiAdapter` interface accordingly
- Extend `zeroFill` in `crypto-utils.ts` to accept `Iterable<Uint8Array>` so you can zero-fill `rawFieldKeys.values()` in one call. Extract `HKDF_ALGORITHM` constant in `hkdf.ts` for DRY
- `clearVault()` in crypto store no longer calls `terminateWorker()` — worker termination moved to `logoutCleanup()` in `auth-flow.ts` alongside `keyVault.clearVault()` and `store.reset()`
- Verify `devtools` middleware is not in auth store or crypto store (secrets must not appear in Redux DevTools). Verify no crypto keys appear in localStorage, sessionStorage, or IndexedDB. Verify `setQueryClient(client)` is wired in app providers

**Tests:**
- Unit: `copyToUint8Array` copies ArrayBuffer and Uint8Array data, returns independent copy, handles empty input
- Unit: `setKeys` stores all keys correctly (hex-encoded)
- Unit: `selectFieldKey('note')` returns correct hex-encoded key
- Unit: `lockVault` zeros keys and sets isVaultLocked = true, preserves cached envelope
- Unit: `clearVault` zeros everything including cached envelope
- Unit: after `lockVault`/`clearVault`, `selectFieldKey` returns null
- Unit: `lockVault` and `clearVault` purge TanStack Query cache for `['field']`
- Integration: `setKeys` → `clearVault` → verify all keys zeroed and query cache purged
- Security: verify crypto store never persists keys to localStorage or sessionStorage
- Unit: `KeyVault.storeFieldKeys` stores KEK and field keys, `getKey` retrieves them, `hasKey` checks existence
- Unit: `keyVault.lockVault()` clears vault Map, sets `isVaultLocked`, preserves cached envelope
- Unit: `keyVault.clearVault()` clears everything including cached envelope and purges query cache
- Unit: after `lockVault`/`clearVault`, `keyVault.getKey()` returns undefined and `loadedFieldKeys` is empty
- Unit: `generateFieldKeys` returns both raw and CryptoKey variants; CryptoKeys are non-extractable
- Unit: `unwrapFieldKeys` returns `Map<string, CryptoKey>` from `ServerFieldKey[]` input
- Unit: `zeroFill` handles single Uint8Array and iterable of Uint8Arrays
- Integration: `signUpUser` stores keys via `keyVault` and populates `loadedFieldKeys`
- Integration: `loginUser` → `unlockVault` → `lockVault` round-trip with cached envelope
- Security: verify crypto store never persists keys to storage; verify `exportKey()` fails on vault keys

---

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json",
"format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\""
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"",
"validate": "pnpm format && pnpm test:run && pnpm lint && pnpm typecheck"
},
"dependencies": {
"@base-ui/react": "^1.4.1",
Expand Down
Loading
Loading