diff --git a/CLAUDE.md b/CLAUDE.md index 35fa90e..9141910 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`). +- Vault lock purges key vault Map + Zustand state + TanStack Query cache. ### App Hierarchy ``` @@ -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`. @@ -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 @@ -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). @@ -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. diff --git a/IMPLEMENTATION-PLAN.md b/IMPLEMENTATION-PLAN.md index 41f7276..5a993f2 100644 --- a/IMPLEMENTATION-PLAN.md +++ b/IMPLEMENTATION-PLAN.md @@ -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` (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`. `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` 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` 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 --- diff --git a/package.json b/package.json index d04ec20..03e792b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/flows/auth-flow.test.ts b/src/app/flows/auth-flow.test.ts index 9705b20..a704745 100644 --- a/src/app/flows/auth-flow.test.ts +++ b/src/app/flows/auth-flow.test.ts @@ -1,12 +1,27 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +// Helper to create a mock CryptoKey for testing +function createCryptoKeyMock(): CryptoKey { + return { + type: 'secret', + extractable: false, + algorithm: { name: 'AES-GCM', length: 256 }, + usages: ['encrypt', 'decrypt'], + [Symbol.toStringTag]: 'CryptoKey', + } as unknown as CryptoKey +} + const mockSetLoading = vi.fn<(isLoading: boolean) => void>() -const mockSetAuth = vi.fn<(user: import('@/shared/types/entities/user.types').User, session: import('@/shared/types/entities/user.types').UserSession) => void>() +const mockSetAuth = + vi.fn< + ( + user: import('@/shared/types/entities/user.types').User, + session: import('@/shared/types/entities/user.types').UserSession, + ) => void + >() const mockSetRestoringSession = vi.fn<(isRestoringSession: boolean) => void>() const mockReset = vi.fn<() => void>() -const mockSetKeys = vi.fn<(masterKey: string, kek: string, fieldKeys: Record) => void>(() => { - cryptoStoreState.isVaultLocked = false -}) +const mockSetKeys = vi.fn<(fieldKeyNames: string[]) => void>() const mockSetEnvelope = vi.fn<(envelope: import('@/shared/types/api.types').CachedVaultEnvelope) => void>() // Mock registration module @@ -15,12 +30,11 @@ vi.mock('@/features/encryption/model/registration', () => ({ authHash: 'a'.repeat(64), authSalt: new Uint8Array(16).fill(0x01), keySalt: new Uint8Array(16).fill(0x02), - masterKey: new Uint8Array(32).fill(0x03), - kek: new Uint8Array(32).fill(0x04), + kek: createCryptoKeyMock(), fieldKeys: new Map([ - ['note', new Uint8Array(32).fill(0x10)], - ['website', new Uint8Array(32).fill(0x20)], - ['email', new Uint8Array(32).fill(0x30)], + ['note', createCryptoKeyMock()], + ['website', createCryptoKeyMock()], + ['email', createCryptoKeyMock()], ]), wrappedMasterKey: new Uint8Array(48).fill(0x05), masterKeyIV: new Uint8Array(12).fill(0x06), @@ -34,27 +48,17 @@ vi.mock('@/features/encryption/model/registration', () => ({ }), })) -// Mock login crypto module -vi.mock('@/features/encryption/model/login', () => ({ - deriveLoginKeys: vi.fn().mockResolvedValue({ - masterKey: new Uint8Array(32).fill(0x03), - kek: {}, // CryptoKey mock - fieldKeys: new Map([ - ['note', new Uint8Array(32).fill(0x10)], - ['website', new Uint8Array(32).fill(0x20)], - ['email', new Uint8Array(32).fill(0x30)], - ]), - }), -})) - -// Mock vault-lock module +// Mock key-vault module const { mockClearVault } = vi.hoisted(() => ({ mockClearVault: vi.fn<() => void>(), })) -vi.mock('@/features/encryption/model/vault-lock', () => ({ - lockVault: vi.fn<() => void>(), - clearVault: mockClearVault, - unlockVault: vi.fn<() => Promise>().mockResolvedValue(undefined), +vi.mock('@/features/encryption/model/key-vault', () => ({ + keyVault: { + lockVault: vi.fn<() => void>(), + storeKey: vi.fn<() => void>(), + storeFieldKeys: vi.fn<(kek: CryptoKey, fieldKeys: Map) => void>(), + clearVault: mockClearVault, + }, })) // Mock Supabase registration @@ -78,20 +82,16 @@ const { mockEnvelopeData, mockFieldKeysData } = vi.hoisted(() => ({ })) vi.mock('@/shared/api/supabase-keys', () => ({ - getLoginSalts: vi.fn().mockResolvedValue({ + fetchLoginSalts: vi.fn().mockResolvedValue({ authSalt: '01'.repeat(16), keySalt: '02'.repeat(16), }), - getMasterKeyEnvelope: vi.fn().mockResolvedValue(mockEnvelopeData), - getFieldKeys: vi.fn().mockResolvedValue(mockFieldKeysData), + fetchMasterKeyEnvelope: vi.fn().mockResolvedValue(mockEnvelopeData), + fetchFieldKeys: vi.fn().mockResolvedValue(mockFieldKeysData), })) // Mock Split KDF vi.mock('@/shared/crypto/split-kdf', () => ({ - deriveLoginCredentials: vi.fn().mockResolvedValue({ - authHash: 'a'.repeat(64), - passwordKey: new Uint8Array(32).fill(0x07), - }), deriveAuthCredentials: vi.fn().mockResolvedValue({ authHash: 'a'.repeat(64), passwordKey: new Uint8Array(32).fill(0x07), @@ -100,6 +100,13 @@ vi.mock('@/shared/crypto/split-kdf', () => ({ }), })) +// Mock Argon2id +vi.mock('@/shared/crypto/argon2id', () => ({ + deriveAuthHash: vi.fn().mockResolvedValue('a'.repeat(64)), + derivePasswordKey: vi.fn().mockResolvedValue(new Uint8Array(32).fill(0x07)), + terminateWorker: vi.fn(), +})) + // Mock crypto memory vi.mock('@/shared/crypto/crypto-utils', async () => ({ ...(await vi.importActual('@/shared/crypto/crypto-utils')), @@ -113,9 +120,25 @@ vi.mock('@/shared/crypto/crypto-utils', async () => ({ // Mock AES-GCM vi.mock('@/shared/crypto/aes-gcm', () => ({ exportKey: vi.fn().mockResolvedValue(new Uint8Array(32).fill(0x04)), - importKey: vi.fn(), + importKey: vi.fn().mockResolvedValue(createCryptoKeyMock()), encrypt: vi.fn(), - decrypt: vi.fn(), + decrypt: vi.fn().mockResolvedValue(new Uint8Array(32).fill(0x03)), +})) + +// Mock key-hierarchy +vi.mock('@/shared/crypto/key-hierarchy', () => ({ + unwrapFieldKeys: vi.fn().mockResolvedValue( + new Map([ + ['note', createCryptoKeyMock()], + ['website', createCryptoKeyMock()], + ['email', createCryptoKeyMock()], + ]), + ), +})) + +// Mock HKDF +vi.mock('@/shared/crypto/hkdf', () => ({ + deriveKEK: vi.fn().mockResolvedValue(new Uint8Array(32).fill(0x08)), })) // Mock auth adapter @@ -151,11 +174,10 @@ vi.mock('@/features/auth/model/auth-store', () => ({ // Mock crypto store const cryptoStoreState = { - masterKey: null as string | null, - kek: null as string | null, - fieldKeys: {} as Record, + loadedFieldKeys: {} as Record, isVaultLocked: true, lastActivity: 0, + cachedEnvelope: null as import('@/shared/types/api.types').CachedVaultEnvelope | null, setKeys: mockSetKeys, setCachedEnvelope: mockSetEnvelope, lockVault: vi.fn<() => void>(), @@ -169,20 +191,27 @@ vi.mock('@/features/encryption/model/crypto-store', () => ({ }, })) -import { signUpUser, loginUser, logoutUser, restoreSession, subscribeToAuthChanges } from '@/app/flows/auth-flow' +import { + signUpUser, + loginUser, + logoutUser, + restoreSession, + subscribeToAuthChanges, + unlockVault, +} from '@/app/flows/auth-flow' import { deriveRegistrationKeys } from '@/features/encryption/model/registration' -import { deriveLoginKeys } from '@/features/encryption/model/login' -import { clearVault as clearVaultMock } from '@/features/encryption/model/vault-lock' -import { useCryptoStore } from '@/features/encryption/model/crypto-store' -import { hexEncode } from '@/shared/crypto/crypto-utils' -import { exportKey } from '@/shared/crypto/aes-gcm' import { authAdapter } from '@/shared/auth/supabase-adapter' import { uploadRegistrationData } from '@/shared/api/supabase-registration' -import { getLoginSalts, getMasterKeyEnvelope, getFieldKeys } from '@/shared/api/supabase-keys' -import { deriveLoginCredentials } from '@/shared/crypto/split-kdf' +import { fetchLoginSalts, fetchMasterKeyEnvelope, fetchFieldKeys } from '@/shared/api/supabase-keys' import { useAuthStore } from '@/features/auth/model/auth-store' import { AuthError, AuthErrorCode } from '@/shared/auth/auth-errors' import type { AuthResult } from '@/shared/auth/auth.types' +import { keyVault } from '@/features/encryption/model/key-vault' +import { deriveAuthHash, derivePasswordKey, terminateWorker } from '@/shared/crypto/argon2id' +import { unwrapFieldKeys } from '@/shared/crypto/key-hierarchy' +import { deriveKEK } from '@/shared/crypto/hkdf' +import { decrypt, importKey } from '@/shared/crypto/aes-gcm' +import { DecryptionError } from '@/shared/crypto/errors' describe('signUpUser', () => { beforeEach(() => { @@ -208,11 +237,10 @@ describe('signUpUser', () => { expect(uploadRegistrationData).toHaveBeenCalledWith(regResult, signupResult.user.id) }) - it('populates crypto store with hex-encoded keys', async () => { + it('stores field keys via keyVault.storeFieldKeys', async () => { await signUpUser('testuser', 'testpass123') - expect(hexEncode).toHaveBeenCalledWith(new Uint8Array(32).fill(0x03)) - expect(hexEncode).toHaveBeenCalledWith(new Uint8Array(32).fill(0x04)) - expect(useCryptoStore.getState().isVaultLocked).toBe(false) + const regResult = await (deriveRegistrationKeys as ReturnType).mock.results[0].value + expect(keyVault.storeFieldKeys).toHaveBeenCalledWith(regResult.kek, regResult.fieldKeys) }) it('caches envelope data after signup', async () => { @@ -259,37 +287,30 @@ describe('loginUser', () => { vi.clearAllMocks() }) - it('fetches salts, derives credentials, and authenticates', async () => { + it('fetches salts, derives auth hash, and authenticates', async () => { await loginUser('testuser', 'testpass123') - expect(getLoginSalts).toHaveBeenCalledWith('testuser') - expect(deriveLoginCredentials).toHaveBeenCalledWith('testpass123', expect.any(Uint8Array), expect.any(Uint8Array)) + expect(fetchLoginSalts).toHaveBeenCalledWith('testuser') + expect(deriveAuthHash).toHaveBeenCalledWith('testpass123', expect.any(Uint8Array)) expect(authAdapter.login).toHaveBeenCalledWith('testuser', 'a'.repeat(64)) }) - it('fetches keys and field keys after authentication', async () => { - await loginUser('testuser', 'testpass123') - - expect(getMasterKeyEnvelope).toHaveBeenCalledWith('1') - expect(getFieldKeys).toHaveBeenCalledWith('1') - }) - - it('calls deriveLoginKeys with passwordKey and decoded key material', async () => { + it('fetches envelope and field keys after authentication', async () => { await loginUser('testuser', 'testpass123') - expect(deriveLoginKeys).toHaveBeenCalledWith({ - passwordKey: expect.any(Uint8Array), - wrappedMasterKey: expect.any(Uint8Array), - masterKeyIV: expect.any(Uint8Array), - serverFieldKeys: expect.any(Array), - }) + expect(fetchMasterKeyEnvelope).toHaveBeenCalledWith('1') + expect(fetchFieldKeys).toHaveBeenCalledWith('1') }) - it('populates crypto store with hex-encoded keys', async () => { + it('derives KEK from password and envelope, then stores field keys', async () => { await loginUser('testuser', 'testpass123') - expect(exportKey).toHaveBeenCalled() - expect(mockSetKeys).toHaveBeenCalled() + expect(derivePasswordKey).toHaveBeenCalledWith('testpass123', expect.any(Uint8Array)) + expect(importKey).toHaveBeenCalled() + expect(decrypt).toHaveBeenCalled() + expect(deriveKEK).toHaveBeenCalled() + expect(unwrapFieldKeys).toHaveBeenCalledWith(mockFieldKeysData, expect.any(Object)) + expect(keyVault.storeFieldKeys).toHaveBeenCalled() }) it('caches envelope data after login', async () => { @@ -321,7 +342,7 @@ describe('loginUser', () => { await expect(loginUser('testuser', 'wrongpass')).rejects.toThrow() - expect(mockSetKeys).not.toHaveBeenCalled() + expect(keyVault.storeFieldKeys).not.toHaveBeenCalled() expect(mockSetAuth).not.toHaveBeenCalled() }) @@ -333,22 +354,22 @@ describe('loginUser', () => { expect(mockSetLoading).toHaveBeenCalledWith(false) }) - it('does not populate crypto store when key unwrapping fails after auth succeeds', async () => { - vi.mocked(deriveLoginKeys).mockRejectedValueOnce(new Error('Decryption failed')) + it('does not populate crypto store when key derivation fails after auth succeeds', async () => { + vi.mocked(decrypt).mockRejectedValueOnce(new Error('Decryption failed')) await expect(loginUser('testuser', 'testpass123')).rejects.toThrow('Decryption failed') - expect(mockSetKeys).not.toHaveBeenCalled() + expect(keyVault.storeFieldKeys).not.toHaveBeenCalled() expect(mockSetAuth).not.toHaveBeenCalled() expect(mockSetLoading).toHaveBeenCalledWith(false) }) it('does not populate crypto store when fetching keys fails after auth succeeds', async () => { - vi.mocked(getMasterKeyEnvelope).mockRejectedValueOnce(new AuthError(AuthErrorCode.NETWORK_ERROR)) + vi.mocked(fetchMasterKeyEnvelope).mockRejectedValueOnce(new AuthError(AuthErrorCode.NETWORK_ERROR)) await expect(loginUser('testuser', 'testpass123')).rejects.toThrow(AuthError) - expect(mockSetKeys).not.toHaveBeenCalled() + expect(keyVault.storeFieldKeys).not.toHaveBeenCalled() expect(mockSetAuth).not.toHaveBeenCalled() }) }) @@ -358,21 +379,23 @@ describe('logoutUser', () => { vi.clearAllMocks() }) - it('calls adapter logout, clears vault, and resets store', async () => { + it('calls adapter logout, clears vault, resets store, and terminates worker', async () => { await logoutUser() expect(authAdapter.logout).toHaveBeenCalled() - expect(clearVaultMock).toHaveBeenCalled() + expect(mockClearVault).toHaveBeenCalled() expect(mockReset).toHaveBeenCalled() + expect(terminateWorker).toHaveBeenCalled() }) - it('clears vault and resets store even when adapter logout fails', async () => { + it('clears vault, resets store, and terminates worker even when adapter logout fails', async () => { vi.mocked(authAdapter.logout).mockRejectedValueOnce(new Error('Network error')) await logoutUser() - expect(clearVaultMock).toHaveBeenCalled() + expect(mockClearVault).toHaveBeenCalled() expect(mockReset).toHaveBeenCalled() + expect(terminateWorker).toHaveBeenCalled() }) }) @@ -396,19 +419,6 @@ describe('restoreSession', () => { setSession: vi.fn(), }) - vi.mock('@/features/auth/model/auth-store', () => ({ - useAuthStore: { - getState: vi.fn(() => ({ - setLoading: mockSetLoading, - setAuth: mockSetAuth, - setRestoringSession: mockSetRestoringSession, - reset: mockReset, - isRestoringSession: false, - })), - setState: vi.fn(), - }, - })) - await restoreSession() expect(mockSetRestoringSession).toHaveBeenCalledWith(false) }) @@ -545,7 +555,7 @@ describe('subscribeToAuthChanges', () => { ) }) - it('callback calls clearVault and reset on null result', () => { + it('callback calls clearVault, reset, and terminateWorker on null result', () => { let capturedCallback: ((result: unknown) => void) | undefined vi.mocked(authAdapter.onAuthStateChange).mockImplementation((cb) => { capturedCallback = cb as (result: unknown) => void @@ -555,7 +565,124 @@ describe('subscribeToAuthChanges', () => { subscribeToAuthChanges() capturedCallback!(null) - expect(clearVaultMock).toHaveBeenCalled() + expect(mockClearVault).toHaveBeenCalled() expect(mockReset).toHaveBeenCalled() + expect(terminateWorker).toHaveBeenCalled() + }) +}) + +describe('unlockVault', () => { + const mockUser = { id: 'user-1', username: 'testuser', createdAt: '' } + + beforeEach(() => { + vi.clearAllMocks() + cryptoStoreState.cachedEnvelope = null + vi.mocked(useAuthStore.getState).mockReturnValue({ + setLoading: mockSetLoading, + setAuth: mockSetAuth, + setRestoringSession: mockSetRestoringSession, + reset: mockReset, + isRestoringSession: false, + user: mockUser, + session: null, + isLoading: false, + setUser: vi.fn(), + setSession: vi.fn(), + }) + }) + + it('fetches from server and populates vault when no cached envelope', async () => { + await unlockVault('test-password-123') + + expect(fetchMasterKeyEnvelope).toHaveBeenCalledWith('user-1') + expect(fetchFieldKeys).toHaveBeenCalledWith('user-1') + expect(derivePasswordKey).toHaveBeenCalled() + expect(keyVault.storeFieldKeys).toHaveBeenCalled() + expect(mockSetEnvelope).toHaveBeenCalled() + }) + + it('uses cached envelope without network calls', async () => { + cryptoStoreState.cachedEnvelope = { + ...mockEnvelopeData, + fieldKeys: mockFieldKeysData, + } + + await unlockVault('test-password-123') + + expect(fetchMasterKeyEnvelope).not.toHaveBeenCalled() + expect(fetchFieldKeys).not.toHaveBeenCalled() + expect(derivePasswordKey).toHaveBeenCalled() + expect(keyVault.storeFieldKeys).toHaveBeenCalled() + }) + + it('does not call setCachedEnvelope when envelope is already cached', async () => { + cryptoStoreState.cachedEnvelope = { + ...mockEnvelopeData, + fieldKeys: mockFieldKeysData, + } + + await unlockVault('test-password-123') + + expect(mockSetEnvelope).not.toHaveBeenCalled() + }) + + it('throws when no user is authenticated', async () => { + vi.mocked(useAuthStore.getState).mockReturnValue({ + setLoading: mockSetLoading, + setAuth: mockSetAuth, + setRestoringSession: mockSetRestoringSession, + reset: mockReset, + isRestoringSession: false, + user: null, + session: null, + isLoading: false, + setUser: vi.fn(), + setSession: vi.fn(), + }) + + await expect(unlockVault('test-password-123')).rejects.toThrow('Cannot unlock vault: no authenticated user') + }) + + it('clears cache and retries from server on DecryptionError', async () => { + cryptoStoreState.cachedEnvelope = { + ...mockEnvelopeData, + fieldKeys: mockFieldKeysData, + } + // First call (cached envelope) throws DecryptionError + vi.mocked(deriveKEK) + .mockRejectedValueOnce(new DecryptionError()) + .mockResolvedValueOnce(new Uint8Array(32).fill(0x08)) + + await unlockVault('test-password-123') + + expect(mockClearVault).toHaveBeenCalled() + expect(fetchMasterKeyEnvelope).toHaveBeenCalledWith('user-1') + expect(fetchFieldKeys).toHaveBeenCalledWith('user-1') + expect(mockSetEnvelope).toHaveBeenCalled() + expect(keyVault.storeFieldKeys).toHaveBeenCalled() + }) + + it('re-throws if retry also fails', async () => { + cryptoStoreState.cachedEnvelope = { + ...mockEnvelopeData, + fieldKeys: mockFieldKeysData, + } + vi.mocked(deriveKEK).mockRejectedValue(new DecryptionError()) + + await expect(unlockVault('test-password-123')).rejects.toThrow(DecryptionError) + expect(mockClearVault).toHaveBeenCalled() + expect(fetchMasterKeyEnvelope).toHaveBeenCalled() + }) + + it('does not retry on non-DecryptionError', async () => { + cryptoStoreState.cachedEnvelope = { + ...mockEnvelopeData, + fieldKeys: mockFieldKeysData, + } + vi.mocked(derivePasswordKey).mockRejectedValueOnce(new Error('Some other error')) + + await expect(unlockVault('test-password-123')).rejects.toThrow('Some other error') + expect(mockClearVault).not.toHaveBeenCalled() + expect(fetchMasterKeyEnvelope).not.toHaveBeenCalled() }) }) diff --git a/src/app/flows/auth-flow.ts b/src/app/flows/auth-flow.ts index 29db0ac..b26a693 100644 --- a/src/app/flows/auth-flow.ts +++ b/src/app/flows/auth-flow.ts @@ -3,12 +3,16 @@ import { useCryptoStore } from '@/features/encryption/model/crypto-store' import { useAuthStore } from '@/features/auth/model/auth-store' import { authAdapter } from '@/shared/auth/supabase-adapter' import { uploadRegistrationData } from '@/shared/api/supabase-registration' -import { getLoginSalts, getMasterKeyEnvelope, getFieldKeys } from '@/shared/api/supabase-keys' -import { deriveLoginCredentials } from '@/shared/crypto/split-kdf' -import { deriveLoginKeys } from '@/features/encryption/model/login' -import { hexDecode, hexEncode, encodeFieldKeysToHex } from '@/shared/crypto/crypto-utils' -import { exportKey } from '@/shared/crypto/aes-gcm' -import { clearVault } from '@/features/encryption/model/vault-lock' +import { fetchLoginSalts, fetchMasterKeyEnvelope, fetchFieldKeys } from '@/shared/api/supabase-keys' +import { hexDecode, hexEncode, zeroFill } from '@/shared/crypto/crypto-utils' +import { decrypt, importKey } from '@/shared/crypto/aes-gcm' +import { keyVault } from '@/features/encryption/model/key-vault' +import type { CachedVaultEnvelope, ServerFieldKey } from '@/shared/types/api.types' +import { DecryptionError } from '@/shared/crypto/errors' +import { unwrapFieldKeys } from '@/shared/crypto/key-hierarchy' +import { deriveKEK } from '@/shared/crypto/hkdf' +import { MASTER_KEY_PASSWORD_AAD } from '@/shared/types/crypto.types' +import { deriveAuthHash, derivePasswordKey, terminateWorker } from '@/shared/crypto/argon2id' /** * Registers a new user: derives keys, signs up on the server, uploads encrypted @@ -39,10 +43,9 @@ export async function signUpUser(username: string, password: string): Promise void { if (result) { useAuthStore.getState().setAuth(result.user, result.session) } else { - clearVault() - useAuthStore.getState().reset() + logoutCleanup() + } + }) +} + +/** + * Unlock the vault by deriving the KEK from the password and populating the + * KeyVault with non-extractable CryptoKey objects. + * + * Uses the cached envelope when available to skip network calls. If decryption + * fails with the cached envelope (e.g., stale cache from a password change in + * another session), clears the cache and fetches fresh key material from the server. + */ +export async function unlockVault(password: string): Promise { + const user = useAuthStore.getState().user + if (!user) { + throw new Error('Cannot unlock vault: no authenticated user') + } + + let staleCache = false + const cachedEnvelope = useCryptoStore.getState().cachedEnvelope + if (cachedEnvelope) { + try { + const kek = await deriveKekFromEnvelope(password, cachedEnvelope) + await storeFieldKeys(kek, cachedEnvelope.fieldKeys) + } catch (error) { + if (error instanceof DecryptionError) { + // Cached envelope may be stale (password changed in another session). + // Clear the stale cache and retry the full network + derivation path. + keyVault.clearVault() + staleCache = true + } else { + throw error + } } + } + + if (!cachedEnvelope || staleCache) { + const freshEnvelope = await fetchFreshEnvelope(user.id) + const kek = await deriveKekFromEnvelope(password, freshEnvelope) + await storeFieldKeys(kek, freshEnvelope.fieldKeys) + } +} + +async function fetchFreshEnvelope(userId: string): Promise { + // Sequential: both calls require an active auth session; + // parallel requests can race on session initialization + const masterKeyEnvelope = await fetchMasterKeyEnvelope(userId) + const serverFieldKeys = await fetchFieldKeys(userId) + const freshEnvelope = { ...masterKeyEnvelope, fieldKeys: serverFieldKeys } + useCryptoStore.getState().setCachedEnvelope(freshEnvelope) + return freshEnvelope +} + +async function deriveKekFromEnvelope(password: string, envelope: CachedVaultEnvelope): Promise { + // Derive password key + const passwordKey = await derivePasswordKey(password, hexDecode(envelope.keySalt)) + const cryptoPasswordKey = await importKey(passwordKey) + zeroFill(passwordKey) + + // Unwrap master key → derive KEK + const wrappedMasterKey = hexDecode(envelope.wrappedMasterKey) + const masterKey = await decrypt(wrappedMasterKey, cryptoPasswordKey, { + iv: hexDecode(envelope.masterKeyIV), + aad: MASTER_KEY_PASSWORD_AAD, }) + const kekBytes = await deriveKEK(masterKey) + const kek = await importKey(kekBytes) + zeroFill(kekBytes) + zeroFill(masterKey) + return kek +} + +async function storeFieldKeys(kek: CryptoKey, fieldKeys: ServerFieldKey[]): Promise { + // Unwrap field keys with KEK (verifies AAD = fieldName + version) + const unwrappedFieldKeys = await unwrapFieldKeys(fieldKeys, kek) + + // Store KEK and field keys in the vault (non-extractable CryptoKeys) + keyVault.storeFieldKeys(kek, unwrappedFieldKeys) } diff --git a/src/app/routes/-_public.login.test.tsx b/src/app/routes/-_public.login.test.tsx index a3a7beb..3a5c996 100644 --- a/src/app/routes/-_public.login.test.tsx +++ b/src/app/routes/-_public.login.test.tsx @@ -3,88 +3,8 @@ import { render, screen, waitFor } from '@/test/utils' import userEvent from '@testing-library/user-event' import React from 'react' -vi.mock('@/shared/crypto/split-kdf', () => ({ - deriveLoginCredentials: vi.fn().mockResolvedValue({ - authHash: 'a'.repeat(64), - passwordKey: new Uint8Array(32).fill(0x07), - }), - deriveAuthCredentials: vi.fn().mockResolvedValue({ - authHash: 'a'.repeat(64), - passwordKey: new Uint8Array(32).fill(0x07), - authSalt: new Uint8Array(16).fill(0x01), - keySalt: new Uint8Array(16).fill(0x02), - }), -})) - -vi.mock('@/shared/api/supabase-keys', () => ({ - getLoginSalts: vi.fn().mockResolvedValue({ - authSalt: '01'.repeat(16), - keySalt: '02'.repeat(16), - }), - getMasterKeyEnvelope: vi.fn().mockResolvedValue({ - authSalt: '01'.repeat(16), - keySalt: '02'.repeat(16), - wrappedMasterKey: '05'.repeat(48), - masterKeyIV: '06'.repeat(12), - }), - getFieldKeys: vi - .fn() - .mockResolvedValue([{ fieldName: 'note', version: 1, wrappedKey: 'aa'.repeat(48), keyIV: 'bb'.repeat(12) }]), -})) - -vi.mock('@/features/encryption/model/login', () => ({ - deriveLoginKeys: vi.fn().mockResolvedValue({ - masterKey: new Uint8Array(32).fill(0x03), - kek: {}, - fieldKeys: new Map([['note', new Uint8Array(32).fill(0x10)]]), - }), -})) - -vi.mock('@/features/encryption/model/vault-lock', () => ({ - lockVault: vi.fn(), - unlockVault: vi.fn().mockResolvedValue(undefined), -})) - -vi.mock('@/shared/crypto/crypto-utils', () => ({ - hexEncode: vi.fn((data: Uint8Array) => - Array.from(data) - .map((b: number) => b.toString(16).padStart(2, '0')) - .join(''), - ), - hexDecode: vi.fn((hex: string) => { - const bytes = new Uint8Array(hex.length / 2) - for (let i = 0; i < hex.length; i += 2) { - bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16) - } - return bytes - }), - encodeFieldKeysToHex: vi.fn((fieldKeys: Map) => { - const result: Record = {} - for (const [name, key] of fieldKeys) { - result[name] = Array.from(key) - .map((b) => b.toString(16).padStart(2, '0')) - .join('') - } - return result - }), -})) - -vi.mock('@/shared/crypto/aes-gcm', () => ({ - exportKey: vi.fn().mockResolvedValue(new Uint8Array(32).fill(0x04)), - importKey: vi.fn(), - encrypt: vi.fn(), - decrypt: vi.fn(), -})) - -vi.mock('@/shared/auth/supabase-adapter', () => ({ - authAdapter: { - login: vi.fn().mockResolvedValue({ - user: { id: '1', username: 'testuser', createdAt: '2024-01-01' }, - session: { accessToken: 'tok', expiresAt: 0 }, - }), - signup: vi.fn(), - logout: vi.fn(), - }, +vi.mock('@/app/flows/auth-flow', () => ({ + loginUser: vi.fn().mockResolvedValue(undefined), })) vi.mock('sonner', () => ({ @@ -98,7 +18,7 @@ vi.mock('@tanstack/react-router', () => ({ })) import { LoginPage } from '@/features/auth/ui/LoginPage' -import { authAdapter } from '@/shared/auth/supabase-adapter' +import { loginUser } from '@/app/flows/auth-flow' import { AuthError, AuthErrorCode } from '@/shared/auth/auth-errors' import { useAuthStore } from '@/features/auth/model/auth-store' import { toast } from 'sonner' @@ -141,7 +61,7 @@ describe('LoginPage', () => { }) it('disables inputs and button during submission', async () => { - vi.mocked(authAdapter.login).mockImplementation(() => new Promise(() => {})) + vi.mocked(loginUser).mockImplementation(() => new Promise(() => {})) const user = userEvent.setup() render() @@ -165,12 +85,12 @@ describe('LoginPage', () => { await user.click(screen.getByRole('button', { name: /log in/i })) await waitFor(() => { - expect(authAdapter.login).toHaveBeenCalled() + expect(loginUser).toHaveBeenCalledWith('testuser', 'testpass123') }) }) it('shows error toast on login failure', async () => { - vi.mocked(authAdapter.login).mockRejectedValueOnce(new AuthError(AuthErrorCode.INVALID_CREDENTIALS)) + vi.mocked(loginUser).mockRejectedValueOnce(new AuthError(AuthErrorCode.INVALID_CREDENTIALS)) const user = userEvent.setup() render() diff --git a/src/features/auth/model/use-username-availability.test.ts b/src/features/auth/model/use-username-availability.test.ts index c02fb5e..1be4924 100644 --- a/src/features/auth/model/use-username-availability.test.ts +++ b/src/features/auth/model/use-username-availability.test.ts @@ -152,4 +152,4 @@ describe('useUsernameAvailability', () => { rerender({ username: 'user2' }) expect(result.current.status).toBe('checking') }) -}) \ No newline at end of file +}) diff --git a/src/features/encryption/model/crypto-store.test.ts b/src/features/encryption/model/crypto-store.test.ts index c56e0e5..6a22343 100644 --- a/src/features/encryption/model/crypto-store.test.ts +++ b/src/features/encryption/model/crypto-store.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -import { useCryptoStore, selectFieldKey, hasCachedEnvelope, setQueryClient } from './crypto-store' import type { CachedVaultEnvelope } from '@/shared/types/api.types' +import { useCryptoStore, hasCachedEnvelope, setQueryClient } from './crypto-store' const mockRemoveQueries = vi.fn() const mockQueryClient = { removeQueries: mockRemoveQueries } as unknown as import('@tanstack/react-query').QueryClient @@ -20,15 +20,14 @@ const sampleEnvelope: CachedVaultEnvelope = { describe('crypto-store', () => { beforeEach(() => { + vi.clearAllMocks() mockRemoveQueries.mockClear() useCryptoStore.getState().clearVault() }) - it('initializes with locked vault and empty keys', () => { + it('initializes with locked vault and empty loadedFieldKeys', () => { const state = useCryptoStore.getState() - expect(state.masterKey).toBeNull() - expect(state.kek).toBeNull() - expect(state.fieldKeys).toEqual({}) + expect(state.loadedFieldKeys).toEqual({}) expect(state.isVaultLocked).toBe(true) expect(state.lastActivity).toBe(0) }) @@ -38,16 +37,14 @@ describe('crypto-store', () => { expect(state.cachedEnvelope).toBeNull() }) - it('setKeys stores hex-encoded keys and unlocks vault', () => { - useCryptoStore.getState().setKeys('a1b2c3', 'd4e5f6', { note: 'aa11bb22', website: 'cc33dd44', email: 'ee55ff66' }) + it('setKeys loads field keys and unlocks vault', () => { + useCryptoStore.getState().setKeys(['note', 'website', 'email']) const state = useCryptoStore.getState() - expect(state.masterKey).toBe('a1b2c3') - expect(state.kek).toBe('d4e5f6') - expect(state.fieldKeys).toEqual({ - note: 'aa11bb22', - website: 'cc33dd44', - email: 'ee55ff66', + expect(state.loadedFieldKeys).toEqual({ + note: true, + website: true, + email: true, }) expect(state.isVaultLocked).toBe(false) expect(state.lastActivity).toBeGreaterThan(0) @@ -60,65 +57,32 @@ describe('crypto-store', () => { expect(state.cachedEnvelope).toEqual(sampleEnvelope) }) - it('selectFieldKey returns correct key by field name', () => { - useCryptoStore.getState().setKeys('mk', 'kk', { - note: 'notekey', - website: 'webkey', - email: 'emailkey', - }) - - const state = useCryptoStore.getState() - expect(selectFieldKey('note')(state)).toBe('notekey') - expect(selectFieldKey('website')(state)).toBe('webkey') - expect(selectFieldKey('email')(state)).toBe('emailkey') - }) - - it('selectFieldKey returns null for unknown field', () => { - useCryptoStore.getState().setKeys('mk', 'kk', { - note: 'notekey', - }) - - expect(selectFieldKey('unknown')(useCryptoStore.getState())).toBeNull() - }) - it('lockVault zeros keys but preserves envelope cache', () => { - useCryptoStore.getState().setKeys('mk', 'kk', { note: 'notekey' }) + useCryptoStore.getState().setKeys(['note']) useCryptoStore.getState().setCachedEnvelope(sampleEnvelope) useCryptoStore.getState().lockVault() const state = useCryptoStore.getState() - expect(state.masterKey).toBeNull() - expect(state.kek).toBeNull() - expect(state.fieldKeys).toEqual({}) + expect(state.loadedFieldKeys).toEqual({}) expect(state.isVaultLocked).toBe(true) expect(state.lastActivity).toBe(0) - // Envelope cache is preserved expect(state.cachedEnvelope).toEqual(sampleEnvelope) }) it('clearVault zeros everything including envelope cache', () => { - useCryptoStore.getState().setKeys('mk', 'kk', { note: 'notekey' }) + useCryptoStore.getState().setKeys(['note']) useCryptoStore.getState().setCachedEnvelope(sampleEnvelope) useCryptoStore.getState().clearVault() const state = useCryptoStore.getState() - expect(state.masterKey).toBeNull() - expect(state.kek).toBeNull() - expect(state.fieldKeys).toEqual({}) + expect(state.loadedFieldKeys).toEqual({}) expect(state.isVaultLocked).toBe(true) expect(state.lastActivity).toBe(0) expect(state.cachedEnvelope).toBeNull() }) - it('selectFieldKey returns null after lockVault', () => { - useCryptoStore.getState().setKeys('mk', 'kk', { note: 'notekey' }) - useCryptoStore.getState().lockVault() - - expect(selectFieldKey('note')(useCryptoStore.getState())).toBeNull() - }) - it('updateActivity updates lastActivity timestamp', () => { useCryptoStore.getState().updateActivity() const time1 = useCryptoStore.getState().lastActivity @@ -160,27 +124,23 @@ describe('crypto-store', () => { }) it('integration: setKeys → clearVault zeroes all keys and purges query cache', () => { - useCryptoStore.getState().setKeys('mk', 'kk', { note: 'nk' }) + useCryptoStore.getState().setKeys(['note']) useCryptoStore.getState().setCachedEnvelope(sampleEnvelope) - expect(selectFieldKey('note')(useCryptoStore.getState())).toBe('nk') + expect(useCryptoStore.getState().loadedFieldKeys['note']).toBe(true) expect(useCryptoStore.getState().isVaultLocked).toBe(false) useCryptoStore.getState().clearVault() - expect(useCryptoStore.getState().masterKey).toBeNull() - expect(useCryptoStore.getState().kek).toBeNull() - expect(useCryptoStore.getState().fieldKeys).toEqual({}) + expect(useCryptoStore.getState().loadedFieldKeys).toEqual({}) expect(useCryptoStore.getState().isVaultLocked).toBe(true) expect(useCryptoStore.getState().cachedEnvelope).toBeNull() - expect(selectFieldKey('note')(useCryptoStore.getState())).toBeNull() expect(mockRemoveQueries).toHaveBeenCalledWith({ queryKey: ['field'] }) }) it('never persists keys to localStorage or sessionStorage', () => { - useCryptoStore.getState().setKeys('deadbeef', 'cafe', { note: 'key123' }) + useCryptoStore.getState().setKeys(['note']) - // Crypto store must not write to Web Storage — only the UI store uses persist const localStorageKeys = Object.keys(localStorage) const sessionStorageKeys = Object.keys(sessionStorage) expect(localStorageKeys.every((k) => !k.includes('crypto') && !k.includes('auth'))).toBe(true) diff --git a/src/features/encryption/model/crypto-store.ts b/src/features/encryption/model/crypto-store.ts index d875121..7698436 100644 --- a/src/features/encryption/model/crypto-store.ts +++ b/src/features/encryption/model/crypto-store.ts @@ -1,12 +1,9 @@ import { create } from 'zustand' import type { QueryClient } from '@tanstack/react-query' import type { CachedVaultEnvelope } from '@/shared/types/api.types' -import { terminateWorker } from '@/shared/crypto/argon2id' interface CryptoState { - masterKey: string | null - kek: string | null - fieldKeys: Record + loadedFieldKeys: Record isVaultLocked: boolean lastActivity: number // Cached envelope data — survives lock, purged on logout @@ -14,21 +11,17 @@ interface CryptoState { } interface CryptoActions { - setKeys: (masterKey: string, kek: string, fieldKeys: Record) => void + setKeys: (fieldKeyNames: string[]) => void setCachedEnvelope: (envelope: CachedVaultEnvelope) => void lockVault: () => void clearVault: () => void updateActivity: () => void } -const selectFieldKey = (fieldName: string) => (state: CryptoState) => state.fieldKeys[fieldName] ?? null - const hasCachedEnvelope = (state: CryptoState) => state.cachedEnvelope !== null const initialState: CryptoState = { - masterKey: null, - kek: null, - fieldKeys: {}, + loadedFieldKeys: {}, isVaultLocked: true, lastActivity: 0, cachedEnvelope: null, @@ -42,20 +35,16 @@ function setQueryClient(client: QueryClient) { const useCryptoStore = create()((set) => ({ ...initialState, - setKeys: (masterKey, kek, fieldKeys) => + setKeys: (fieldKeyNames) => set({ - masterKey, - kek, - fieldKeys, + loadedFieldKeys: Object.fromEntries(fieldKeyNames.map((name) => [name, true])), isVaultLocked: false, lastActivity: Date.now(), }), setCachedEnvelope: (envelope) => set({ cachedEnvelope: envelope }), lockVault: () => { set({ - masterKey: null, - kek: null, - fieldKeys: {}, + loadedFieldKeys: {}, isVaultLocked: true, lastActivity: 0, }) @@ -64,10 +53,9 @@ const useCryptoStore = create()((set) => ({ clearVault: () => { set(initialState) queryClientRef?.removeQueries({ queryKey: ['field'] }) - terminateWorker() }, updateActivity: () => set({ lastActivity: Date.now() }), })) -export { useCryptoStore, selectFieldKey, hasCachedEnvelope, setQueryClient } +export { useCryptoStore, hasCachedEnvelope, setQueryClient } export type { CryptoState, CryptoActions } diff --git a/src/features/encryption/model/key-vault.test.ts b/src/features/encryption/model/key-vault.test.ts new file mode 100644 index 0000000..6f81989 --- /dev/null +++ b/src/features/encryption/model/key-vault.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock crypto store +const mockSetKeys = vi.fn() +const mockLockVault = vi.fn() +const mockClearVault = vi.fn() + +const createStoreState = () => ({ + loadedFieldKeys: {} as Record, + isVaultLocked: true, + lastActivity: 0, + cachedEnvelope: null, + setKeys: mockSetKeys, + lockVault: mockLockVault, + clearVault: mockClearVault, + setCachedEnvelope: vi.fn(), + updateActivity: vi.fn(), +}) + +vi.mock('@/features/encryption/model/crypto-store', () => ({ + useCryptoStore: { + getState: vi.fn(() => createStoreState()), + }, +})) + +// Mock argon2id (only terminateWorker is needed) +vi.mock('@/shared/crypto/argon2id', () => ({ + terminateWorker: vi.fn(), +})) + +import { keyVault } from '@/features/encryption/model/key-vault' + +describe('key-vault', () => { + beforeEach(() => { + keyVault.zeroKeys() + vi.clearAllMocks() + }) + + it('stores and retrieves a CryptoKey', async () => { + const keyData = new Uint8Array(32).fill(0x42) + const key = await crypto.subtle.importKey('raw', keyData, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']) + + keyVault.storeKey('test-key', key) + + const retrieved = keyVault.getKey('test-key') + expect(retrieved).toBe(key) + }) + + it('returns undefined for missing key', () => { + expect(keyVault.getKey('nonexistent')).toBeUndefined() + }) + + it('keyVault.hasKey returns true for existing key', async () => { + const keyData = new Uint8Array(32).fill(0x42) + const key = await crypto.subtle.importKey('raw', keyData, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']) + + keyVault.storeKey('existing', key) + + expect(keyVault.hasKey('existing')).toBe(true) + }) + + it('keyVault.hasKey returns false for missing key', () => { + expect(keyVault.hasKey('missing')).toBe(false) + }) + + it('zeroKeys removes all entries', async () => { + const keyData1 = new Uint8Array(32).fill(0x01) + const keyData2 = new Uint8Array(32).fill(0x02) + + const key1 = await crypto.subtle.importKey('raw', keyData1, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']) + const key2 = await crypto.subtle.importKey('raw', keyData2, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']) + + keyVault.storeKey('key1', key1) + keyVault.storeKey('key2', key2) + + keyVault.zeroKeys() + + expect(keyVault.getKey('key1')).toBeUndefined() + expect(keyVault.getKey('key2')).toBeUndefined() + expect(keyVault.hasKey('key1')).toBe(false) + expect(keyVault.hasKey('key2')).toBe(false) + }) + + it('keys are non-extractable (exportKey throws)', async () => { + const keyData = new Uint8Array(32).fill(0x55) + const key = await crypto.subtle.importKey('raw', keyData, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']) + + keyVault.storeKey('non-extractable', key) + + await expect(crypto.subtle.exportKey('raw', key)).rejects.toThrow() + }) + + it('zeroKeys is idempotent', () => { + // Should not throw when clearing empty vault + keyVault.zeroKeys() + keyVault.zeroKeys() + + // Verify vault is empty by checking keyVault.hasKey for non-existent keys + expect(keyVault.hasKey('nonexistent')).toBe(false) + }) + + it('storeFieldKeys stores KEK and field keys in vault', async () => { + const kekData = new Uint8Array(32).fill(0x01) + const noteKeyData = new Uint8Array(32).fill(0x10) + const websiteKeyData = new Uint8Array(32).fill(0x20) + const emailKeyData = new Uint8Array(32).fill(0x30) + + const kek = await crypto.subtle.importKey('raw', kekData, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']) + const noteKey = await crypto.subtle.importKey('raw', noteKeyData, { name: 'AES-GCM' }, false, [ + 'encrypt', + 'decrypt', + ]) + const websiteKey = await crypto.subtle.importKey('raw', websiteKeyData, { name: 'AES-GCM' }, false, [ + 'encrypt', + 'decrypt', + ]) + const emailKey = await crypto.subtle.importKey('raw', emailKeyData, { name: 'AES-GCM' }, false, [ + 'encrypt', + 'decrypt', + ]) + + const fieldKeys = new Map([ + ['note', noteKey], + ['website', websiteKey], + ['email', emailKey], + ]) + + await keyVault.storeFieldKeys(kek, fieldKeys) + + expect(keyVault.getKey('kek')).toBe(kek) + expect(keyVault.getKey('note')).toBe(noteKey) + expect(keyVault.getKey('website')).toBe(websiteKey) + expect(keyVault.getKey('email')).toBe(emailKey) + expect(mockSetKeys).toHaveBeenCalledWith(['note', 'website', 'email']) + }) +}) + +describe('keyVault.lockVault', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('calls cryptoStore.lockVault', () => { + keyVault.lockVault() + expect(mockLockVault).toHaveBeenCalled() + }) +}) + +describe('keyVault.clearVault', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('calls cryptoStore.clearVault', () => { + keyVault.clearVault() + expect(mockClearVault).toHaveBeenCalled() + }) +}) diff --git a/src/features/encryption/model/key-vault.ts b/src/features/encryption/model/key-vault.ts new file mode 100644 index 0000000..ff5da9a --- /dev/null +++ b/src/features/encryption/model/key-vault.ts @@ -0,0 +1,52 @@ +import { useCryptoStore } from '@/features/encryption/model/crypto-store' + +/** + * Module-scoped crypto key vault. + * + * Stores CryptoKey objects with extractable: false, so exportKey() fails. + * Keys are identified by well-known IDs: 'kek', 'note', 'website', 'email'. + */ +class KeyVault { + private vault = new Map() + + storeKey(id: string, key: CryptoKey): void { + this.vault.set(id, key) + } + + async storeFieldKeys(kek: CryptoKey, fieldKeys: Map): Promise { + this.storeKey('kek', kek) + + const fieldKeyNames: Array = [] + for (const [name, key] of fieldKeys) { + this.storeKey(name, key) + fieldKeyNames.push(name) + } + useCryptoStore.getState().setKeys(fieldKeyNames) + } + + getKey(id: string): CryptoKey | undefined { + return this.vault.get(id) + } + + hasKey(id: string): boolean { + return this.vault.has(id) + } + + zeroKeys(): void { + this.vault.clear() + } + + /** Zero keys, set isVaultLocked, purge query cache. Preserves cached envelope. */ + lockVault(): void { + this.zeroKeys() + useCryptoStore.getState().lockVault() + } + + /** Zero all state including cached envelope. Used on logout. */ + clearVault(): void { + this.zeroKeys() + useCryptoStore.getState().clearVault() + } +} + +export const keyVault = new KeyVault() diff --git a/src/features/encryption/model/login.test.ts b/src/features/encryption/model/login.test.ts deleted file mode 100644 index 17bff4c..0000000 --- a/src/features/encryption/model/login.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -// Mock argon2id module (Web Worker won't run in jsdom) -vi.mock('@/shared/crypto/argon2id', () => ({ - deriveKey: vi.fn().mockResolvedValue(new Uint8Array(32).fill(0xab)), - deriveAuthHash: vi.fn().mockResolvedValue('a'.repeat(64)), - derivePasswordKey: vi.fn().mockResolvedValue(new Uint8Array(32).fill(0xcd)), -})) - -// Mock @scure/bip39 (lazy-loaded, won't run in jsdom) -vi.mock('@scure/bip39', () => ({ - generateMnemonic: vi - .fn() - .mockReturnValue('word0 word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11'), - validateMnemonic: vi.fn().mockReturnValue(true), - mnemonicToSeedSync: vi.fn().mockReturnValue(new Uint8Array(64).fill(0x42)), -})) - -import { deriveLoginKeys } from '@/features/encryption/model/login' -import { deriveRegistrationKeys } from '@/features/encryption/model/registration' -import { hexEncode } from '@/shared/crypto/crypto-utils' -import type { ServerFieldKey } from '@/shared/types/api.types' - -/** - * Helper: run the full registration flow to produce realistic key material, - * then convert wrapped keys to ServerFieldKey format for login tests. - */ -async function setupRegistration() { - const regResult = await deriveRegistrationKeys('test-password-123') - - // Convert wrapped field keys to ServerFieldKey format (hex strings) - const serverFieldKeys: ServerFieldKey[] = regResult.wrappedFieldKeys.map((fk) => ({ - fieldName: fk.fieldName, - version: fk.version, - wrappedKey: hexEncode(fk.wrappedKey), - keyIV: hexEncode(fk.iv), - })) - - return { - regResult, - serverFieldKeys, - passwordKey: regResult.fieldKeys, // Not actually the password key, just for reference - wrappedMasterKey: regResult.wrappedMasterKey, - masterKeyIV: regResult.masterKeyIV, - } -} - -describe('deriveLoginKeys', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('round-trip: register then login derives matching keys', async () => { - const { regResult, serverFieldKeys, wrappedMasterKey, masterKeyIV } = await setupRegistration() - - // Derive login credentials using the same password and salts - const { deriveLoginCredentials } = await import('@/shared/crypto/split-kdf') - const loginCreds = await deriveLoginCredentials('test-password-123', regResult.authSalt, regResult.keySalt) - - // Login: unwrap keys - const loginResult = await deriveLoginKeys({ - passwordKey: loginCreds.passwordKey, - wrappedMasterKey, - masterKeyIV, - serverFieldKeys, - }) - - // Verify master key matches - expect(loginResult.masterKey).toEqual(regResult.masterKey) - - // Verify field keys match - for (const fieldName of ['note', 'website', 'email']) { - const originalKey = regResult.fieldKeys.get(fieldName)! - const loginKey = loginResult.fieldKeys.get(fieldName)! - expect(loginKey).toEqual(originalKey) - } - }) - - it('throws DecryptionError with wrong passwordKey', async () => { - const { serverFieldKeys, wrappedMasterKey, masterKeyIV } = await setupRegistration() - - // Use a random wrong key - const wrongKey = crypto.getRandomValues(new Uint8Array(32)) - - await expect( - deriveLoginKeys({ - passwordKey: wrongKey, - wrappedMasterKey, - masterKeyIV, - serverFieldKeys, - }), - ).rejects.toThrow() - }) - - it('throws DecryptionError with corrupted wrappedMasterKey', async () => { - const { regResult, serverFieldKeys, wrappedMasterKey, masterKeyIV } = await setupRegistration() - - const { deriveLoginCredentials } = await import('@/shared/crypto/split-kdf') - const loginCreds = await deriveLoginCredentials('test-password-123', regResult.authSalt, regResult.keySalt) - - // Corrupt the wrapped master key - const corruptedMasterKey = new Uint8Array(wrappedMasterKey.byteLength) - corruptedMasterKey.set(wrappedMasterKey) - corruptedMasterKey[0] ^= 0xff // flip a byte - - await expect( - deriveLoginKeys({ - passwordKey: loginCreds.passwordKey, - wrappedMasterKey: corruptedMasterKey, - masterKeyIV, - serverFieldKeys, - }), - ).rejects.toThrow() - }) - - it('returns empty Map for empty serverFieldKeys', async () => { - const { regResult, wrappedMasterKey, masterKeyIV } = await setupRegistration() - - const { deriveLoginCredentials } = await import('@/shared/crypto/split-kdf') - const loginCreds = await deriveLoginCredentials('test-password-123', regResult.authSalt, regResult.keySalt) - - const loginResult = await deriveLoginKeys({ - passwordKey: loginCreds.passwordKey, - wrappedMasterKey, - masterKeyIV, - serverFieldKeys: [], - }) - - expect(loginResult.fieldKeys.size).toBe(0) - }) - - it('unwraps all three field keys (note, website, email)', async () => { - const { regResult, serverFieldKeys, wrappedMasterKey, masterKeyIV } = await setupRegistration() - - const { deriveLoginCredentials } = await import('@/shared/crypto/split-kdf') - const loginCreds = await deriveLoginCredentials('test-password-123', regResult.authSalt, regResult.keySalt) - - const loginResult = await deriveLoginKeys({ - passwordKey: loginCreds.passwordKey, - wrappedMasterKey, - masterKeyIV, - serverFieldKeys, - }) - - expect(loginResult.fieldKeys.has('note')).toBe(true) - expect(loginResult.fieldKeys.has('website')).toBe(true) - expect(loginResult.fieldKeys.has('email')).toBe(true) - }) - - it('produces KEK that can re-unwrap field keys', async () => { - const { regResult, serverFieldKeys, wrappedMasterKey, masterKeyIV } = await setupRegistration() - - const { deriveLoginCredentials } = await import('@/shared/crypto/split-kdf') - const loginCreds = await deriveLoginCredentials('test-password-123', regResult.authSalt, regResult.keySalt) - - const loginResult = await deriveLoginKeys({ - passwordKey: loginCreds.passwordKey, - wrappedMasterKey, - masterKeyIV, - serverFieldKeys, - }) - - // Verify KEK is a CryptoKey - expect(loginResult.kek).toBeInstanceOf(CryptoKey) - }) -}) diff --git a/src/features/encryption/model/login.ts b/src/features/encryption/model/login.ts deleted file mode 100644 index d2b45fa..0000000 --- a/src/features/encryption/model/login.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Login crypto flow: derives keys from a password-derived key and unwraps - * the master key and field keys from server data. - * - * This is a pure crypto function — no auth calls, no DB reads, no side effects. - * The caller (auth-flow.ts) handles Supabase Auth login, data fetches, and - * store writes. - */ - -import { importKey, decrypt } from '@/shared/crypto/aes-gcm' -import { deriveFullKeyHierarchy, unwrapFieldKeys } from '@/shared/crypto/key-hierarchy' -import { hexDecode } from '@/shared/crypto/crypto-utils' -import { MASTER_KEY_PASSWORD_AAD } from '@/shared/types/crypto.types' -import type { LoginKeysInput, LoginResult } from '@/shared/types/crypto.types' - -/** - * Unwraps the master key and field keys from server-stored encrypted material - * using a password-derived key. No side effects - pure crypto. - */ -export async function deriveLoginKeys({ - passwordKey, - wrappedMasterKey, - masterKeyIV, - serverFieldKeys, -}: LoginKeysInput): Promise { - // Import passwordKey → unwrap master key (AAD = password context) → derive KEK - const passwordCryptoKey = await importKey(passwordKey) - const masterKey = await decrypt(wrappedMasterKey, passwordCryptoKey, { - iv: masterKeyIV, - aad: MASTER_KEY_PASSWORD_AAD, - }) - const { kek } = await deriveFullKeyHierarchy(masterKey) - - // Decode hex field keys → unwrap with KEK (verifies AAD = fieldName + version) - const wrappedFieldKeys = serverFieldKeys.map((sfk) => ({ - fieldName: sfk.fieldName, - version: sfk.version, - wrappedKey: hexDecode(sfk.wrappedKey), - iv: hexDecode(sfk.keyIV), - })) - const fieldKeys = await unwrapFieldKeys(wrappedFieldKeys, kek) - - return { masterKey, kek, fieldKeys } -} diff --git a/src/features/encryption/model/registration.test.ts b/src/features/encryption/model/registration.test.ts index 80d47a6..0136228 100644 --- a/src/features/encryption/model/registration.test.ts +++ b/src/features/encryption/model/registration.test.ts @@ -1,9 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { decrypt, importKey } from '@/shared/crypto/aes-gcm' +import { decrypt, encrypt, importKey } from '@/shared/crypto/aes-gcm' import { unwrapFieldKeys } from '@/shared/crypto/key-hierarchy' import { unwrapMasterKeyWithRecovery } from '@/shared/crypto/mnemonic' import { FIELD_KEY_VERSION, MASTER_KEY_PASSWORD_AAD } from '@/shared/types/crypto.types' import type { RegistrationResult } from '@/shared/types/crypto.types' +import type { ServerFieldKey } from '@/shared/types/api.types' +import { generateIV } from '@/shared/crypto/crypto-utils' +import { hexEncode } from '@/shared/crypto/crypto-utils' // Mock Argon2id module — Web Worker won't run in jsdom vi.mock('@/shared/crypto/argon2id', () => ({ @@ -71,19 +74,20 @@ describe('deriveRegistrationKeys', () => { expect(result.keySalt).toHaveLength(16) }) - it('returns 32-byte masterKey', () => { - expect(result.masterKey).toHaveLength(32) + it('returns wrappedMasterKey of 48 bytes and 12-byte IV', () => { + expect(result.wrappedMasterKey).toHaveLength(48) + expect(result.masterKeyIV).toHaveLength(12) }) - it('returns 32-byte kek (raw bytes, not CryptoKey)', () => { - expect(result.kek).toHaveLength(32) + it('returns kek as CryptoKey', () => { + expect(result.kek).toBeInstanceOf(CryptoKey) }) - it('returns fieldKeys map with 3 entries, each 32 bytes', () => { + it('returns fieldKeys map with 3 CryptoKey entries', () => { expect(result.fieldKeys.size).toBe(3) - expect(result.fieldKeys.get('note')).toHaveLength(32) - expect(result.fieldKeys.get('website')).toHaveLength(32) - expect(result.fieldKeys.get('email')).toHaveLength(32) + expect(result.fieldKeys.get('note')).toBeInstanceOf(CryptoKey) + expect(result.fieldKeys.get('website')).toBeInstanceOf(CryptoKey) + expect(result.fieldKeys.get('email')).toBeInstanceOf(CryptoKey) }) it('returns 3 wrapped field keys, all version 1', () => { @@ -95,11 +99,6 @@ describe('deriveRegistrationKeys', () => { } }) - it('returns wrapped master key of 48 bytes and 12-byte IV', () => { - expect(result.wrappedMasterKey).toHaveLength(48) - expect(result.masterKeyIV).toHaveLength(12) - }) - it('returns recovery data with correct sizes', () => { expect(result.recoveryData.recoverySalt).toHaveLength(16) expect(result.recoveryData.wrappedMasterKey).toHaveLength(48) @@ -117,14 +116,34 @@ describe('deriveRegistrationKeys', () => { iv: result.masterKeyIV, aad: MASTER_KEY_PASSWORD_AAD, }) - expect(decrypted).toEqual(result.masterKey) + // Verify unwrapped key is 32 bytes (master key length) + expect(decrypted).toHaveLength(32) }) it('unwraps field keys with derived KEK', async () => { - const kekCryptoKey = await importKey(result.kek) - const unwrapped = await unwrapFieldKeys(result.wrappedFieldKeys, kekCryptoKey) - for (const [name, key] of result.fieldKeys) { - expect(unwrapped.get(name)).toEqual(key) + // Convert WrappedFieldKey[] to ServerFieldKey[] format (hex strings) + const serverFieldKeys: ServerFieldKey[] = result.wrappedFieldKeys.map((wfk) => ({ + fieldName: wfk.fieldName, + version: wfk.version, + wrappedKey: hexEncode(wfk.wrappedKey), + keyIV: hexEncode(wfk.iv), + })) + + // unwrapFieldKeys now returns Map + const unwrapped = await unwrapFieldKeys(serverFieldKeys, result.kek) + + // Verify unwrapped keys are CryptoKeys and can encrypt/decrypt correctly + expect(unwrapped.size).toBe(3) + for (const [, cryptoKey] of unwrapped) { + expect(cryptoKey).toBeInstanceOf(CryptoKey) + + // Verify key works via encrypt-decrypt round-trip + const plaintext = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + const iv = generateIV() + const aad = new Uint8Array([1]) + const ciphertext = await encrypt(plaintext, cryptoKey, { iv, aad }) + const decrypted = await decrypt(ciphertext, cryptoKey, { iv, aad }) + expect(decrypted).toEqual(plaintext) } }) @@ -133,7 +152,17 @@ describe('deriveRegistrationKeys', () => { iv: result.recoveryData.recoveryIV, salt: result.recoveryData.recoverySalt, }) - expect(masterKey).toEqual(result.masterKey) + // Verify unwrapped key is 32 bytes (master key length) + expect(masterKey).toHaveLength(32) + + // Verify key works by importing and doing encrypt-decrypt round-trip + const cryptoKey = await importKey(masterKey) + const plaintext = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + const iv = generateIV() + const aad = new Uint8Array([2]) + const ciphertext = await encrypt(plaintext, cryptoKey, { iv, aad }) + const decrypted = await decrypt(ciphertext, cryptoKey, { iv, aad }) + expect(decrypted).toEqual(plaintext) }) it('calls deriveAuthCredentials with password', () => { diff --git a/src/features/encryption/model/registration.ts b/src/features/encryption/model/registration.ts index ae62374..3568e89 100644 --- a/src/features/encryption/model/registration.ts +++ b/src/features/encryption/model/registration.ts @@ -1,12 +1,5 @@ -/** - * Registration crypto flow: derives all keys and wraps them for server storage. - * - * This is a pure crypto function — no auth calls, no DB writes, no side effects. - * The caller (auth-flow.ts) handles Supabase Auth signup and data upload. - */ - -import { importKey, encrypt, exportKey } from '@/shared/crypto/aes-gcm' -import { generateIV, generateSalt } from '@/shared/crypto/crypto-utils' +import { importKey, encrypt } from '@/shared/crypto/aes-gcm' +import { generateIV, generateSalt, zeroFill } from '@/shared/crypto/crypto-utils' import { generateMnemonic, wrapMasterKeyWithRecovery } from '@/shared/crypto/mnemonic' import { generateMasterKey, @@ -18,6 +11,12 @@ import { deriveAuthCredentials } from '@/shared/crypto/split-kdf' import { FIELD_KEY_VERSION, MASTER_KEY_PASSWORD_AAD } from '@/shared/types/crypto.types' import type { RegistrationResult } from '@/shared/types/crypto.types' +/** + * Derive all keys needed for registration and wrap them for server storage. + * + * This is a pure crypto function - no auth calls, no DB writes, no side effects. + * The caller (auth-flow.ts) needs to handle Supabase Auth signup and data upload. + */ export async function deriveRegistrationKeys(password: string): Promise { // Derive auth credentials + master key + key hierarchy const { authHash, passwordKey, authSalt, keySalt } = await deriveAuthCredentials(password) @@ -25,9 +24,10 @@ export async function deriveRegistrationKeys(password: string): Promise [name, FIELD_KEY_VERSION] as const)) - const wrappedFieldKeys = await wrapFieldKeys(fieldKeys, hierarchy.kek, versions) + const { rawFieldKeys, cryptoFieldKeys } = await generateFieldKeys() + const versions = new Map(Array.from(rawFieldKeys.keys()).map((name) => [name, FIELD_KEY_VERSION] as const)) + const wrappedFieldKeys = await wrapFieldKeys(rawFieldKeys, hierarchy.kek, versions) + zeroFill(rawFieldKeys.values()) // Wrap master key with password key (AAD prevents cross-context decryption) const passwordCryptoKey = await importKey(passwordKey) @@ -36,20 +36,19 @@ export async function deriveRegistrationKeys(password: string): Promise ({ - useAuthStore: { - getState: vi.fn(() => ({ - user: mockUser, - })), - }, -})) - -// Mock crypto store -const mockSetKeys = vi.fn() -const mockLockVault = vi.fn() -const mockClearVault = vi.fn() -const mockSetEnvelope = vi.fn() - -const createStoreState = (overrides?: Partial<{ cachedEnvelope: CachedVaultEnvelope | null }>) => ({ - masterKey: null as string | null, - kek: null as string | null, - fieldKeys: {} as Record, - isVaultLocked: true, - lastActivity: 0, - cachedEnvelope: null as CachedVaultEnvelope | null, - setKeys: mockSetKeys, - lockVault: mockLockVault, - clearVault: mockClearVault, - setCachedEnvelope: mockSetEnvelope, - updateActivity: vi.fn(), - ...overrides, -}) - -vi.mock('@/features/encryption/model/crypto-store', () => ({ - useCryptoStore: { - getState: vi.fn(() => createStoreState()), - }, -})) - -// Mock login crypto module -vi.mock('@/features/encryption/model/login', () => ({ - deriveLoginKeys: vi.fn().mockResolvedValue({ - masterKey: new Uint8Array(32).fill(0x03), - kek: {}, // CryptoKey mock - fieldKeys: new Map([ - ['note', new Uint8Array(32).fill(0x10)], - ['website', new Uint8Array(32).fill(0x20)], - ['email', new Uint8Array(32).fill(0x30)], - ]), - }), -})) - -// Mock Split KDF -vi.mock('@/shared/crypto/split-kdf', () => ({ - deriveLoginCredentials: vi.fn().mockResolvedValue({ - authHash: 'a'.repeat(64), - passwordKey: new Uint8Array(32).fill(0x07), - }), -})) - -// Mock Supabase keys -vi.mock('@/shared/api/supabase-keys', () => ({ - getMasterKeyEnvelope: vi.fn().mockResolvedValue({ - authSalt: '01'.repeat(16), - keySalt: '02'.repeat(16), - wrappedMasterKey: '05'.repeat(48), - masterKeyIV: '06'.repeat(12), - }), - getFieldKeys: vi.fn().mockResolvedValue([ - { fieldName: 'note', version: 1, wrappedKey: 'aa'.repeat(48), keyIV: 'bb'.repeat(12) }, - { fieldName: 'website', version: 1, wrappedKey: 'cc'.repeat(48), keyIV: 'dd'.repeat(12) }, - { fieldName: 'email', version: 1, wrappedKey: 'ee'.repeat(48), keyIV: 'ff'.repeat(12) }, - ]), -})) - -// Mock AES-GCM -vi.mock('@/shared/crypto/aes-gcm', () => ({ - exportKey: vi.fn().mockResolvedValue(new Uint8Array(32).fill(0x04)), - importKey: vi.fn(), - encrypt: vi.fn(), - decrypt: vi.fn(), -})) - -import { lockVault, unlockVault, clearVault } from '@/features/encryption/model/vault-lock' -import { useAuthStore } from '@/features/auth/model/auth-store' -import { useCryptoStore } from '@/features/encryption/model/crypto-store' -import { deriveLoginKeys } from '@/features/encryption/model/login' -import { deriveLoginCredentials } from '@/shared/crypto/split-kdf' -import { getMasterKeyEnvelope, getFieldKeys } from '@/shared/api/supabase-keys' -import { DecryptionError } from '@/shared/crypto/errors' - -const cachedEnvelope: CachedVaultEnvelope = { - authSalt: '01'.repeat(16), - keySalt: '02'.repeat(16), - wrappedMasterKey: '05'.repeat(48), - masterKeyIV: '06'.repeat(12), - fieldKeys: [ - { fieldName: 'note', version: 1, wrappedKey: 'aa'.repeat(48), keyIV: 'bb'.repeat(12) }, - { fieldName: 'website', version: 1, wrappedKey: 'cc'.repeat(48), keyIV: 'dd'.repeat(12) }, - { fieldName: 'email', version: 1, wrappedKey: 'ee'.repeat(48), keyIV: 'ff'.repeat(12) }, - ], -} - -describe('lockVault', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('calls cryptoStore.lockVault', () => { - lockVault() - expect(mockLockVault).toHaveBeenCalled() - }) -}) - -describe('clearVault', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('calls cryptoStore.clearVault', () => { - clearVault() - expect(mockClearVault).toHaveBeenCalled() - }) -}) - -describe('unlockVault (network path)', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('fetches envelope from server and populates crypto store', async () => { - await unlockVault('test-password-123') - - expect(getMasterKeyEnvelope).toHaveBeenCalledWith('user-1') - expect(getFieldKeys).toHaveBeenCalledWith('user-1') - expect(deriveLoginCredentials).toHaveBeenCalledWith( - 'test-password-123', - expect.any(Uint8Array), - expect.any(Uint8Array), - ) - expect(deriveLoginKeys).toHaveBeenCalled() - expect(mockSetKeys).toHaveBeenCalled() - }) - - it('caches envelope after fetching from server', async () => { - await unlockVault('test-password-123') - - expect(mockSetEnvelope).toHaveBeenCalledWith({ - authSalt: '01'.repeat(16), - keySalt: '02'.repeat(16), - wrappedMasterKey: '05'.repeat(48), - masterKeyIV: '06'.repeat(12), - fieldKeys: [ - { fieldName: 'note', version: 1, wrappedKey: 'aa'.repeat(48), keyIV: 'bb'.repeat(12) }, - { fieldName: 'website', version: 1, wrappedKey: 'cc'.repeat(48), keyIV: 'dd'.repeat(12) }, - { fieldName: 'email', version: 1, wrappedKey: 'ee'.repeat(48), keyIV: 'ff'.repeat(12) }, - ], - }) - }) - - it('throws when no user is authenticated', async () => { - vi.mocked(useAuthStore.getState as ReturnType).mockReturnValueOnce({ - user: null, - }) - - await expect(unlockVault('test-password-123')).rejects.toThrow('Cannot unlock vault: no authenticated user') - }) - - it('does not populate crypto store when getMasterKeyEnvelope fails', async () => { - vi.mocked(getMasterKeyEnvelope).mockRejectedValueOnce(new Error('Network error')) - - await expect(unlockVault('test-password-123')).rejects.toThrow('Network error') - - expect(mockSetKeys).not.toHaveBeenCalled() - }) - - it('does not populate crypto store when key unwrapping fails', async () => { - vi.mocked(deriveLoginKeys).mockRejectedValueOnce(new Error('Decryption failed')) - - await expect(unlockVault('test-password-123')).rejects.toThrow('Decryption failed') - - expect(mockSetKeys).not.toHaveBeenCalled() - }) -}) - -describe('unlockVault (cached envelope)', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.mocked(useCryptoStore.getState).mockReturnValue(createStoreState({ cachedEnvelope })) - }) - - afterEach(() => { - vi.mocked(useCryptoStore.getState).mockReturnValue(createStoreState()) - }) - - it('skips network calls and uses cached envelope', async () => { - await unlockVault('test-password-123') - - expect(getMasterKeyEnvelope).not.toHaveBeenCalled() - expect(getFieldKeys).not.toHaveBeenCalled() - expect(deriveLoginCredentials).toHaveBeenCalledWith( - 'test-password-123', - expect.any(Uint8Array), - expect.any(Uint8Array), - ) - expect(mockSetKeys).toHaveBeenCalled() - }) - - it('does not call setCachedEnvelope again when envelope is already cached', async () => { - await unlockVault('test-password-123') - - expect(mockSetEnvelope).not.toHaveBeenCalled() - }) - - it('clears cache and retries from server on DecryptionError', async () => { - vi.mocked(deriveLoginKeys) - .mockRejectedValueOnce(new DecryptionError()) - .mockResolvedValueOnce({ - masterKey: new Uint8Array(32).fill(0x03), - kek: {} as CryptoKey, - fieldKeys: new Map([ - ['note', new Uint8Array(32).fill(0x10)], - ['website', new Uint8Array(32).fill(0x20)], - ['email', new Uint8Array(32).fill(0x30)], - ]), - }) - - await unlockVault('test-password-123') - - expect(mockClearVault).toHaveBeenCalled() - expect(getMasterKeyEnvelope).toHaveBeenCalledWith('user-1') - expect(getFieldKeys).toHaveBeenCalledWith('user-1') - expect(mockSetEnvelope).toHaveBeenCalled() - expect(mockSetKeys).toHaveBeenCalled() - }) - - it('re-throws if retry also fails', async () => { - vi.mocked(deriveLoginKeys).mockRejectedValueOnce(new DecryptionError()).mockRejectedValueOnce(new DecryptionError()) - - await expect(unlockVault('test-password-123')).rejects.toThrow(DecryptionError) - expect(mockClearVault).toHaveBeenCalled() - expect(getMasterKeyEnvelope).toHaveBeenCalled() - }) - - it('does not retry on non-DecryptionError', async () => { - vi.mocked(deriveLoginKeys).mockRejectedValueOnce(new Error('Some other error')) - - await expect(unlockVault('test-password-123')).rejects.toThrow('Some other error') - expect(mockClearVault).not.toHaveBeenCalled() - expect(getMasterKeyEnvelope).not.toHaveBeenCalled() - }) -}) diff --git a/src/features/encryption/model/vault-lock.ts b/src/features/encryption/model/vault-lock.ts deleted file mode 100644 index c6e038d..0000000 --- a/src/features/encryption/model/vault-lock.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { useCryptoStore } from '@/features/encryption/model/crypto-store' -import { useAuthStore } from '@/features/auth/model/auth-store' -import { deriveLoginKeys } from '@/features/encryption/model/login' -import { deriveLoginCredentials } from '@/shared/crypto/split-kdf' -import { getMasterKeyEnvelope, getFieldKeys } from '@/shared/api/supabase-keys' -import { hexDecode, hexEncode, encodeFieldKeysToHex } from '@/shared/crypto/crypto-utils' -import { exportKey } from '@/shared/crypto/aes-gcm' -import { DecryptionError } from '@/shared/crypto/errors' -import type { CachedVaultEnvelope } from '@/shared/types/api.types' - -/** Zero keys, set isVaultLocked, purge query cache. Preserves cached envelope. */ -export function lockVault(): void { - useCryptoStore.getState().lockVault() -} - -/** Zero all state including cached envelope. Used on logout. */ -export function clearVault(): void { - useCryptoStore.getState().clearVault() -} - -/** - * Unlock the vault: re-derive keys from password and populate the crypto store. - * Uses cached envelope when available (skips network calls). - * On decryption failure with cached envelope, clears cache and retries from server. - */ -export async function unlockVault(password: string): Promise { - const user = useAuthStore.getState().user - if (!user) { - throw new Error('Cannot unlock vault: no authenticated user') - } - - let staleCache = false - const cachedEnvelope = useCryptoStore.getState().cachedEnvelope - if (cachedEnvelope) { - try { - await unlockWithEnvelope(password, cachedEnvelope) - } catch (error) { - if (error instanceof DecryptionError) { - // Cached envelope may be stale (password changed in another session). - // Clear the stale cache and retry the full network + derivation path. - useCryptoStore.getState().clearVault() - staleCache = true - } else { - throw error - } - } - } - - if (!cachedEnvelope || staleCache) { - // Sequential: both calls require an active auth session; - // parallel requests can race on session initialization - const masterKeyEnvelope = await getMasterKeyEnvelope(user.id) - const serverFieldKeys = await getFieldKeys(user.id) - const freshEnvelope = { ...masterKeyEnvelope, fieldKeys: serverFieldKeys } - useCryptoStore.getState().setCachedEnvelope(freshEnvelope) - await unlockWithEnvelope(password, freshEnvelope) - } -} - -async function unlockWithEnvelope(password: string, envelope: CachedVaultEnvelope): Promise { - const { passwordKey } = await deriveLoginCredentials( - password, - hexDecode(envelope.authSalt), - hexDecode(envelope.keySalt), - ) - - const { masterKey, kek, fieldKeys } = await deriveLoginKeys({ - passwordKey, - wrappedMasterKey: hexDecode(envelope.wrappedMasterKey), - masterKeyIV: hexDecode(envelope.masterKeyIV), - serverFieldKeys: envelope.fieldKeys, - }) - - const kekBytes = await exportKey(kek) - useCryptoStore.getState().setKeys(hexEncode(masterKey), hexEncode(kekBytes), encodeFieldKeysToHex(fieldKeys)) -} diff --git a/src/features/encryption/model/vault-timeout.test.ts b/src/features/encryption/model/vault-timeout.test.ts index ee6bafa..23f9e05 100644 --- a/src/features/encryption/model/vault-timeout.test.ts +++ b/src/features/encryption/model/vault-timeout.test.ts @@ -4,11 +4,13 @@ import { act } from 'react' import { useCryptoStore } from '@/features/encryption/model/crypto-store' import { DEFAULT_VAULT_TIMEOUT_MS } from './vault-timeout' -vi.mock('@/features/encryption/model/vault-lock', () => ({ - lockVault: vi.fn<() => void>(), +vi.mock('@/features/encryption/model/key-vault', () => ({ + keyVault: { + lockVault: vi.fn<() => void>(), + }, })) -import { lockVault } from '@/features/encryption/model/vault-lock' +import { keyVault } from '@/features/encryption/model/key-vault' import { useVaultTimeout } from './vault-timeout' import { renderHook } from '@/test/utils' @@ -29,7 +31,7 @@ describe('useVaultTimeout', () => { vi.advanceTimersByTime(DEFAULT_VAULT_TIMEOUT_MS + 1000) - expect(lockVault).not.toHaveBeenCalled() + expect(keyVault.lockVault).not.toHaveBeenCalled() }) it('starts timer when vault is unlocked', () => { @@ -38,7 +40,7 @@ describe('useVaultTimeout', () => { vi.advanceTimersByTime(DEFAULT_VAULT_TIMEOUT_MS) - expect(lockVault).toHaveBeenCalledTimes(1) + expect(keyVault.lockVault).toHaveBeenCalledTimes(1) }) it('resets timer on mousemove', () => { @@ -49,10 +51,10 @@ describe('useVaultTimeout', () => { document.dispatchEvent(new Event('mousemove')) vi.advanceTimersByTime(500) - expect(lockVault).not.toHaveBeenCalled() + expect(keyVault.lockVault).not.toHaveBeenCalled() vi.advanceTimersByTime(DEFAULT_VAULT_TIMEOUT_MS) - expect(lockVault).toHaveBeenCalledTimes(1) + expect(keyVault.lockVault).toHaveBeenCalledTimes(1) }) it('resets timer on keydown', () => { @@ -63,7 +65,7 @@ describe('useVaultTimeout', () => { document.dispatchEvent(new Event('keydown')) vi.advanceTimersByTime(500) - expect(lockVault).not.toHaveBeenCalled() + expect(keyVault.lockVault).not.toHaveBeenCalled() }) it('resets timer on mousedown', () => { @@ -74,7 +76,7 @@ describe('useVaultTimeout', () => { document.dispatchEvent(new Event('mousedown')) vi.advanceTimersByTime(500) - expect(lockVault).not.toHaveBeenCalled() + expect(keyVault.lockVault).not.toHaveBeenCalled() }) it('resets timer on touchstart', () => { @@ -85,7 +87,7 @@ describe('useVaultTimeout', () => { document.dispatchEvent(new Event('touchstart')) vi.advanceTimersByTime(500) - expect(lockVault).not.toHaveBeenCalled() + expect(keyVault.lockVault).not.toHaveBeenCalled() }) it('resets timer on scroll', () => { @@ -96,7 +98,7 @@ describe('useVaultTimeout', () => { document.dispatchEvent(new Event('scroll')) vi.advanceTimersByTime(500) - expect(lockVault).not.toHaveBeenCalled() + expect(keyVault.lockVault).not.toHaveBeenCalled() }) it('stops timer when vault becomes locked', () => { @@ -111,7 +113,7 @@ describe('useVaultTimeout', () => { vi.advanceTimersByTime(DEFAULT_VAULT_TIMEOUT_MS) - expect(lockVault).not.toHaveBeenCalled() + expect(keyVault.lockVault).not.toHaveBeenCalled() }) it('cleans up event listeners on unmount', () => { @@ -122,7 +124,7 @@ describe('useVaultTimeout', () => { vi.advanceTimersByTime(DEFAULT_VAULT_TIMEOUT_MS) - expect(lockVault).not.toHaveBeenCalled() + expect(keyVault.lockVault).not.toHaveBeenCalled() }) it('supports custom timeout', () => { @@ -132,6 +134,6 @@ describe('useVaultTimeout', () => { vi.advanceTimersByTime(customTimeout) - expect(lockVault).toHaveBeenCalledTimes(1) + expect(keyVault.lockVault).toHaveBeenCalledTimes(1) }) }) diff --git a/src/features/encryption/model/vault-timeout.ts b/src/features/encryption/model/vault-timeout.ts index 30616c2..0dfdea8 100644 --- a/src/features/encryption/model/vault-timeout.ts +++ b/src/features/encryption/model/vault-timeout.ts @@ -1,7 +1,7 @@ import { useEffect, useRef } from 'react' import { useCryptoStore } from '@/features/encryption/model/crypto-store' -import { lockVault } from '@/features/encryption/model/vault-lock' +import { keyVault } from '@/features/encryption/model/key-vault' export const DEFAULT_VAULT_TIMEOUT_MS = 15 * 60 * 1000 @@ -25,7 +25,7 @@ export function useVaultTimeout(timeoutMs: number = DEFAULT_VAULT_TIMEOUT_MS): v clearTimeout(timeoutRef.current) } timeoutRef.current = setTimeout(() => { - lockVault() + keyVault.lockVault() }, timeoutMs) } diff --git a/src/features/encryption/ui/VaultUnlockDialog.test.tsx b/src/features/encryption/ui/VaultUnlockDialog.test.tsx index f96a27b..35a5095 100644 --- a/src/features/encryption/ui/VaultUnlockDialog.test.tsx +++ b/src/features/encryption/ui/VaultUnlockDialog.test.tsx @@ -9,8 +9,7 @@ const { mockUnlockVault } = vi.hoisted(() => ({ mockUnlockVault: vi.fn(), })) -vi.mock('@/features/encryption/model/vault-lock', () => ({ - lockVault: vi.fn(), +vi.mock('@/app/flows/auth-flow', () => ({ unlockVault: mockUnlockVault, })) diff --git a/src/features/encryption/ui/VaultUnlockDialog.tsx b/src/features/encryption/ui/VaultUnlockDialog.tsx index 5566865..0080aba 100644 --- a/src/features/encryption/ui/VaultUnlockDialog.tsx +++ b/src/features/encryption/ui/VaultUnlockDialog.tsx @@ -7,7 +7,7 @@ import { z } from 'zod' import { useCryptoStore } from '@/features/encryption/model/crypto-store' import { useVaultDialogStore } from '@/features/encryption/model/vault-dialog-store' -import { unlockVault } from '@/features/encryption/model/vault-lock' +import { unlockVault } from '@/app/flows/auth-flow' import { getCryptoErrorMessage } from '@/features/encryption/model/crypto-error-messages' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/shared/ui/dialog' import { Button } from '@/shared/ui/button' diff --git a/src/shared/api/api.types.ts b/src/shared/api/api.types.ts index 2f95eba..a1b850a 100644 --- a/src/shared/api/api.types.ts +++ b/src/shared/api/api.types.ts @@ -9,8 +9,8 @@ import type { } from '@/shared/types/api.types' export interface IApiAdapter { - getMasterKeyEnvelope(userId: string): Promise - getFieldKeys(userId: string): Promise + fetchMasterKeyEnvelope(userId: string): Promise + fetchFieldKeys(userId: string): Promise saveWrappedKey(userId: string, data: SaveWrappedKeyData): Promise getField(userId: string, fieldName: string): Promise diff --git a/src/shared/api/supabase-keys.test.ts b/src/shared/api/supabase-keys.test.ts index dc2e25b..3318725 100644 --- a/src/shared/api/supabase-keys.test.ts +++ b/src/shared/api/supabase-keys.test.ts @@ -15,9 +15,9 @@ vi.mock('@/shared/api/supabase-client', () => ({ }), })) -import { getLoginSalts, getMasterKeyEnvelope, getFieldKeys } from '@/shared/api/supabase-keys' +import { fetchLoginSalts, fetchMasterKeyEnvelope, fetchFieldKeys } from '@/shared/api/supabase-keys' -describe('getLoginSalts', () => { +describe('fetchLoginSalts', () => { beforeEach(() => { vi.clearAllMocks() }) @@ -28,7 +28,7 @@ describe('getLoginSalts', () => { error: null, }) - const result = await getLoginSalts('testuser') + const result = await fetchLoginSalts('testuser') expect(mockRpc).toHaveBeenCalledWith('get_login_salts', { p_username: 'testuser' }) expect(result).toEqual({ @@ -43,7 +43,7 @@ describe('getLoginSalts', () => { error: { message: 'RPC error' }, }) - await expect(getLoginSalts('testuser')).rejects.toThrow() + await expect(fetchLoginSalts('testuser')).rejects.toThrow() }) it('throws INVALID_CREDENTIALS when no data returned', async () => { @@ -53,7 +53,7 @@ describe('getLoginSalts', () => { }) try { - await getLoginSalts('nonexistent') + await fetchLoginSalts('nonexistent') expect.unreachable('should have thrown') } catch (e) { expect(e).toBeInstanceOf(AuthError) @@ -68,7 +68,7 @@ describe('getLoginSalts', () => { }) try { - await getLoginSalts('nonexistent') + await fetchLoginSalts('nonexistent') expect.unreachable('should have thrown') } catch (e) { expect(e).toBeInstanceOf(AuthError) @@ -77,9 +77,9 @@ describe('getLoginSalts', () => { }) it('throws without calling RPC when username format is invalid', async () => { - await expect(getLoginSalts('ab')).rejects.toThrow(AuthError) - await expect(getLoginSalts('user@name')).rejects.toThrow(AuthError) - await expect(getLoginSalts('')).rejects.toThrow(AuthError) + await expect(fetchLoginSalts('ab')).rejects.toThrow(AuthError) + await expect(fetchLoginSalts('user@name')).rejects.toThrow(AuthError) + await expect(fetchLoginSalts('')).rejects.toThrow(AuthError) expect(mockRpc).not.toHaveBeenCalled() }) @@ -90,13 +90,13 @@ describe('getLoginSalts', () => { error: null, }) - await getLoginSalts('TestUser') + await fetchLoginSalts('TestUser') expect(mockRpc).toHaveBeenCalledWith('get_login_salts', { p_username: 'TestUser' }) }) }) -describe('getMasterKeyEnvelope', () => { +describe('fetchMasterKeyEnvelope', () => { beforeEach(() => { vi.clearAllMocks() @@ -121,7 +121,7 @@ describe('getMasterKeyEnvelope', () => { error: null, }) - const result = await getMasterKeyEnvelope('user-1') + const result = await fetchMasterKeyEnvelope('user-1') expect(mockFrom).toHaveBeenCalledWith('keys') expect(mockSelect).toHaveBeenCalledWith('auth_salt, key_salt, wrapped_master_key, master_key_iv') @@ -140,7 +140,7 @@ describe('getMasterKeyEnvelope', () => { error: { message: 'Query error' }, }) - await expect(getMasterKeyEnvelope('user-1')).rejects.toThrow() + await expect(fetchMasterKeyEnvelope('user-1')).rejects.toThrow() }) it('throws KEYS_NOT_FOUND when no data found', async () => { @@ -150,7 +150,7 @@ describe('getMasterKeyEnvelope', () => { }) try { - await getMasterKeyEnvelope('user-1') + await fetchMasterKeyEnvelope('user-1') expect.unreachable('should have thrown') } catch (e) { expect(e).toBeInstanceOf(AuthError) @@ -159,7 +159,7 @@ describe('getMasterKeyEnvelope', () => { }) }) -describe('getFieldKeys', () => { +describe('fetchFieldKeys', () => { beforeEach(() => { vi.clearAllMocks() @@ -181,7 +181,7 @@ describe('getFieldKeys', () => { error: null, }) - const result = await getFieldKeys('user-1') + const result = await fetchFieldKeys('user-1') expect(mockFrom).toHaveBeenCalledWith('field_keys') expect(mockSelect).toHaveBeenCalledWith('field_name, version, wrapped_key, key_iv') @@ -199,7 +199,7 @@ describe('getFieldKeys', () => { error: null, }) - const result = await getFieldKeys('user-1') + const result = await fetchFieldKeys('user-1') expect(result).toEqual([]) }) @@ -210,7 +210,7 @@ describe('getFieldKeys', () => { }) try { - await getFieldKeys('user-1') + await fetchFieldKeys('user-1') expect.unreachable('should have thrown') } catch (e) { expect(e).toBeInstanceOf(AuthError) @@ -224,6 +224,6 @@ describe('getFieldKeys', () => { error: { message: 'Query error' }, }) - await expect(getFieldKeys('user-1')).rejects.toThrow() + await expect(fetchFieldKeys('user-1')).rejects.toThrow() }) }) diff --git a/src/shared/api/supabase-keys.ts b/src/shared/api/supabase-keys.ts index 9f5ef97..60927e7 100644 --- a/src/shared/api/supabase-keys.ts +++ b/src/shared/api/supabase-keys.ts @@ -13,7 +13,7 @@ export interface LoginSalts { * Callable before authentication (uses SECURITY DEFINER RPC). * Validates username format client-side to avoid wasting rate-limited RPC calls. */ -export async function getLoginSalts(username: string): Promise { +export async function fetchLoginSalts(username: string): Promise { if (!USERNAME_PATTERN.test(username)) { throw new AuthError(AuthErrorCode.INVALID_CREDENTIALS) } @@ -34,7 +34,7 @@ export async function getLoginSalts(username: string): Promise { * Fetch the user's key material (requires authenticated user). * Returns salts, wrapped master key, and IV. */ -export async function getMasterKeyEnvelope(userId: string): Promise { +export async function fetchMasterKeyEnvelope(userId: string): Promise { const supabase = getSupabase() const { data, error } = await supabase .from('keys') @@ -56,7 +56,7 @@ export async function getMasterKeyEnvelope(userId: string): Promise { +export async function fetchFieldKeys(userId: string): Promise { const supabase = getSupabase() const { data, error } = await supabase .from('field_keys') diff --git a/src/shared/api/supabase-registration.test.ts b/src/shared/api/supabase-registration.test.ts index 5c2665e..97439df 100644 --- a/src/shared/api/supabase-registration.test.ts +++ b/src/shared/api/supabase-registration.test.ts @@ -30,12 +30,11 @@ function makeRegistrationResult(): RegistrationResult { authHash: 'a'.repeat(64), authSalt: mockBytes(16, 0x01), keySalt: mockBytes(16, 0x02), - masterKey: mockBytes(32, 0x03), - kek: mockBytes(32, 0x04), + kek: {} as CryptoKey, fieldKeys: new Map([ - ['note', mockBytes(32, 0x10)], - ['website', mockBytes(32, 0x20)], - ['email', mockBytes(32, 0x30)], + ['note', {} as CryptoKey], + ['website', {} as CryptoKey], + ['email', {} as CryptoKey], ]), wrappedMasterKey: mockBytes(48, 0x05), masterKeyIV: mockBytes(12, 0x06), diff --git a/src/shared/crypto/crypto-integration.test.ts b/src/shared/crypto/crypto-integration.test.ts index 739d310..3f100f4 100644 --- a/src/shared/crypto/crypto-integration.test.ts +++ b/src/shared/crypto/crypto-integration.test.ts @@ -7,11 +7,13 @@ import { wrapFieldKeys, unwrapFieldKeys, } from '@/shared/crypto/key-hierarchy' -import { deriveAuthCredentials, deriveLoginCredentials, changePassword } from '@/shared/crypto/split-kdf' +import { deriveAuthCredentials, changePassword } from '@/shared/crypto/split-kdf' import { wrapMasterKeyWithRecovery, unwrapMasterKeyWithRecovery } from '@/shared/crypto/mnemonic' import { DecryptionError } from '@/shared/crypto/errors' import { MASTER_KEY_PASSWORD_AAD } from '@/shared/types/crypto.types' +import { hexEncode } from '@/shared/crypto/crypto-utils' import type { WrappedFieldKey } from '@/shared/types/crypto.types' +import type { ServerFieldKey } from '../types/api.types' // Mock Argon2id module — Web Worker won't run in jsdom vi.mock('@/shared/crypto/argon2id', () => ({ @@ -62,13 +64,19 @@ async function setupRegistration() { const masterKey = generateMasterKey() const authCreds = await deriveAuthCredentials(PASSWORD) const hierarchy = await deriveFullKeyHierarchy(masterKey) - const fieldKeys = generateFieldKeys() + const { rawFieldKeys, cryptoFieldKeys } = await generateFieldKeys() const versions = new Map([ ['note', 1], ['website', 1], ['email', 1], ]) - const wrappedFieldKeys = await wrapFieldKeys(fieldKeys, hierarchy.kek, versions) + const wrappedFieldKeys = await wrapFieldKeys(rawFieldKeys, hierarchy.kek, versions) + const serverFieldKeys: ServerFieldKey[] = wrappedFieldKeys.map((w) => ({ + fieldName: w.fieldName, + version: w.version, + wrappedKey: hexEncode(w.wrappedKey), + keyIV: hexEncode(w.iv), + })) const passwordCryptoKey = await importKey(authCreds.passwordKey) const iv = generateIV() @@ -89,8 +97,10 @@ async function setupRegistration() { masterKey, authCreds, hierarchy, - fieldKeys, + rawFieldKeys, + cryptoFieldKeys, wrappedFieldKeys, + serverFieldKeys, wrappedMasterKey, masterKeyIV: iv, authSalt, @@ -109,8 +119,9 @@ describe('crypto integration', () => { const { masterKey, hierarchy, - fieldKeys, - wrappedFieldKeys, + rawFieldKeys, + cryptoFieldKeys, + serverFieldKeys, wrappedMasterKey, masterKeyIV, authCreds, @@ -120,13 +131,20 @@ describe('crypto integration', () => { expect(masterKey.byteLength).toBe(32) expect(hierarchy.kek.type).toBe('secret') expect(hierarchy.signingKeySeed.byteLength).toBe(32) - expect(fieldKeys.size).toBe(3) - expect(wrappedFieldKeys).toHaveLength(3) + expect(rawFieldKeys.size).toBe(3) + expect(cryptoFieldKeys.size).toBe(3) + expect(serverFieldKeys).toHaveLength(3) - // Unwrap field keys - const unwrappedFieldKeys = await unwrapFieldKeys(wrappedFieldKeys, hierarchy.kek) + // Unwrap field keys - returns Map, verify via round-trip + const unwrappedFieldKeys = await unwrapFieldKeys(serverFieldKeys, hierarchy.kek) for (const name of ['note', 'website', 'email']) { - expect(unwrappedFieldKeys.get(name)).toEqual(fieldKeys.get(name)) + const cryptoKey = unwrappedFieldKeys.get(name)! + const plaintext = new Uint8Array([0x42]) + const iv = generateIV() + const aad = new Uint8Array([1]) + const ciphertext = await encrypt(plaintext, cryptoKey, { iv, aad }) + const decrypted = await decrypt(ciphertext, cryptoKey, { iv, aad }) + expect(decrypted).toEqual(plaintext) } // Unwrap master key with password key @@ -149,19 +167,22 @@ describe('crypto integration', () => { describe('login flow', () => { it('recovers all keys from stored server data', async () => { - const { masterKey, authCreds, wrappedFieldKeys, wrappedMasterKey, masterKeyIV, authSalt, keySalt, fieldKeys } = + const { masterKey, authCreds, serverFieldKeys, wrappedMasterKey, masterKeyIV, authSalt, keySalt } = await setupRegistration() vi.clearAllMocks() vi.mocked(deriveAuthHash).mockResolvedValue(authCreds.authHash) vi.mocked(derivePasswordKey).mockResolvedValue(authCreds.passwordKey) - const loginCreds = await deriveLoginCredentials(PASSWORD, authSalt, keySalt) - expect(loginCreds.authHash).toBe(authCreds.authHash) - expect(loginCreds.passwordKey).toEqual(authCreds.passwordKey) + // Login now uses deriveAuthHash + derivePasswordKey + const authHash = await deriveAuthHash(PASSWORD, authSalt) + const passwordKey = await derivePasswordKey(PASSWORD, keySalt) + expect(authHash).toBe(authCreds.authHash) + expect(passwordKey).toEqual(authCreds.passwordKey) expect(generateSalt).not.toHaveBeenCalled() - const passwordCryptoKey = await importKey(loginCreds.passwordKey) + // Manual KEK derivation: importKey(passwordKey) -> decrypt(wrappedMasterKey) -> deriveKEK(masterKey) -> importKey(kekBytes) + const passwordCryptoKey = await importKey(passwordKey) const unwrappedMasterKey = await decrypt(wrappedMasterKey, passwordCryptoKey, { iv: masterKeyIV, aad: MASTER_KEY_PASSWORD_AAD, @@ -169,14 +190,21 @@ describe('crypto integration', () => { expect(unwrappedMasterKey).toEqual(masterKey) const hierarchy = await deriveFullKeyHierarchy(unwrappedMasterKey) - const unwrappedFieldKeys = await unwrapFieldKeys(wrappedFieldKeys, hierarchy.kek) + // unwrapFieldKeys now takes ServerFieldKey[], returns Map + const unwrappedFieldKeys = await unwrapFieldKeys(serverFieldKeys, hierarchy.kek) + // Compare CryptoKey results via encrypt/decrypt round-trip for (const name of ['note', 'website', 'email']) { - expect(unwrappedFieldKeys.get(name)).toEqual(fieldKeys.get(name)) + const cryptoKey = unwrappedFieldKeys.get(name)! + const plaintext = new Uint8Array([0x42]) + const iv = generateIV() + const aad = new Uint8Array([1]) + const ciphertext = await encrypt(plaintext, cryptoKey, { iv, aad }) + const decrypted = await decrypt(ciphertext, cryptoKey, { iv, aad }) + expect(decrypted).toEqual(plaintext) } // Decrypt actual field content - const noteKey = unwrappedFieldKeys.get('note')! - const noteCryptoKey = await importKey(noteKey) + const noteCryptoKey = unwrappedFieldKeys.get('note')! const plaintext = new TextEncoder().encode('My secret note') const iv = generateIV() const aad = new Uint8Array([1]) @@ -188,7 +216,7 @@ describe('crypto integration', () => { describe('password change', () => { it('re-wraps master key without changing field keys', async () => { - const { masterKey, hierarchy, fieldKeys, wrappedFieldKeys, wrappedMasterKey, masterKeyIV, authCreds } = + const { masterKey, hierarchy, serverFieldKeys, wrappedMasterKey, masterKeyIV, authCreds } = await setupRegistration() vi.clearAllMocks() @@ -215,15 +243,20 @@ describe('crypto integration', () => { }) expect(unwrapped).toEqual(masterKey) - // Field keys still decryptable with same KEK - const unwrappedFieldKeys = await unwrapFieldKeys(wrappedFieldKeys, hierarchy.kek) + // Field keys still decryptable with same KEK - unwrapFieldKeys returns Map + const unwrappedFieldKeys = await unwrapFieldKeys(serverFieldKeys, hierarchy.kek) for (const name of ['note', 'website', 'email']) { - expect(unwrappedFieldKeys.get(name)).toEqual(fieldKeys.get(name)) + const cryptoKey = unwrappedFieldKeys.get(name)! + const plaintext = new Uint8Array([0x42]) + const iv = generateIV() + const aad = new Uint8Array([1]) + const ciphertext = await encrypt(plaintext, cryptoKey, { iv, aad }) + const decrypted = await decrypt(ciphertext, cryptoKey, { iv, aad }) + expect(decrypted).toEqual(plaintext) } // Field content survives password change - const noteKey = unwrappedFieldKeys.get('note')! - const noteCryptoKey = await importKey(noteKey) + const noteCryptoKey = unwrappedFieldKeys.get('note')! const plaintext = new TextEncoder().encode('Persistent data') const iv = generateIV() const aad = new Uint8Array([1]) @@ -241,7 +274,7 @@ describe('crypto integration', () => { describe('seed phrase recovery', () => { it('recovers master key and decrypts all fields', async () => { - const { masterKey, fieldKeys, wrappedFieldKeys, recoveryData } = await setupRegistration() + const { masterKey, serverFieldKeys, recoveryData } = await setupRegistration() vi.clearAllMocks() vi.mocked(deriveKey).mockResolvedValue(mockBytes(32, RECOVERY_KEK_FILL)) @@ -252,15 +285,21 @@ describe('crypto integration', () => { expect(recoveredMasterKey).toEqual(masterKey) const recoveredHierarchy = await deriveFullKeyHierarchy(recoveredMasterKey) - const recoveredFieldKeys = await unwrapFieldKeys(wrappedFieldKeys, recoveredHierarchy.kek) + // unwrapFieldKeys returns Map — verify via encrypt/decrypt round-trip + const recoveredFieldKeys = await unwrapFieldKeys(serverFieldKeys, recoveredHierarchy.kek) for (const name of ['note', 'website', 'email']) { - expect(recoveredFieldKeys.get(name)).toEqual(fieldKeys.get(name)) + const cryptoKey = recoveredFieldKeys.get(name)! + const plaintext = new Uint8Array([0x42]) + const iv = generateIV() + const aad = new Uint8Array([1]) + const ciphertext = await encrypt(plaintext, cryptoKey, { iv, aad }) + const decrypted = await decrypt(ciphertext, cryptoKey, { iv, aad }) + expect(decrypted).toEqual(plaintext) } // Decrypt all field content for (const name of ['note', 'website', 'email']) { - const key = recoveredFieldKeys.get(name)! - const cryptoKey = await importKey(key) + const cryptoKey = recoveredFieldKeys.get(name)! const plaintext = new TextEncoder().encode(`content for ${name}`) const iv = generateIV() const aad = new Uint8Array([1]) @@ -282,11 +321,11 @@ describe('crypto integration', () => { describe('key rotation', () => { it('rotates one field key without affecting others', async () => { - const { hierarchy, fieldKeys } = await setupRegistration() + const { hierarchy, rawFieldKeys } = await setupRegistration() vi.clearAllMocks() // Import original v1 key for later comparison - const originalNoteKey = fieldKeys.get('note')! + const originalNoteKey = rawFieldKeys.get('note')! const originalNoteCryptoKey = await importKey(originalNoteKey) const plaintext = new TextEncoder().encode('Sensitive note content') @@ -294,7 +333,7 @@ describe('crypto integration', () => { const newNoteKey = crypto.getRandomValues(new Uint8Array(32)) as Uint8Array expect(newNoteKey).not.toEqual(originalNoteKey) - const rotatedFieldKeys = new Map(fieldKeys) + const rotatedFieldKeys = new Map(rawFieldKeys) rotatedFieldKeys.set('note', newNoteKey) const newVersions = new Map([ ['note', 2], @@ -302,6 +341,12 @@ describe('crypto integration', () => { ['email', 1], ]) const rotatedWrapped = await wrapFieldKeys(rotatedFieldKeys, hierarchy.kek, newVersions) + const rotatedServerFieldKeys: ServerFieldKey[] = rotatedWrapped.map((w) => ({ + fieldName: w.fieldName, + version: w.version, + wrappedKey: hexEncode(w.wrappedKey), + keyIV: hexEncode(w.iv), + })) // Re-encrypt note content with new key const newNoteCryptoKey = await importKey(newNoteKey) @@ -318,15 +363,30 @@ describe('crypto integration', () => { const decrypted = await decrypt(v2Ciphertext, newNoteCryptoKey, { iv: v2IV, aad: v2AAD }) expect(new TextDecoder().decode(decrypted)).toBe('Sensitive note content') - // Website/email keys unaffected - const unwrapped = await unwrapFieldKeys(rotatedWrapped, hierarchy.kek) - expect(unwrapped.get('website')).toEqual(fieldKeys.get('website')) - expect(unwrapped.get('email')).toEqual(fieldKeys.get('email')) + // Website/email keys unaffected - unwrapFieldKeys returns Map + const unwrapped = await unwrapFieldKeys(rotatedServerFieldKeys, hierarchy.kek) + // Verify via round-trip since we can't compare CryptoKey directly + const websiteCryptoKey = unwrapped.get('website')! + const emailCryptoKey = unwrapped.get('email')! + const websitePlaintext = new Uint8Array([0x42]) + const emailPlaintext = new Uint8Array([0x43]) + const iv = generateIV() + const aad = new Uint8Array([1]) + const websiteCiphertext = await encrypt(websitePlaintext, websiteCryptoKey, { iv, aad }) + const emailCiphertext = await encrypt(emailPlaintext, emailCryptoKey, { iv, aad }) + expect(await decrypt(websiteCiphertext, websiteCryptoKey, { iv, aad })).toEqual(websitePlaintext) + expect(await decrypt(emailCiphertext, emailCryptoKey, { iv, aad })).toEqual(emailPlaintext) // Version rollback protection: unwrap v2 wrapped key with v1 AAD fails const v2WrappedNote = rotatedWrapped.find((k) => k.fieldName === 'note')! const tampered: WrappedFieldKey = { ...v2WrappedNote, version: 1 } - await expect(unwrapFieldKeys([tampered], hierarchy.kek)).rejects.toThrow(DecryptionError) + const tamperedServer: ServerFieldKey = { + fieldName: tampered.fieldName, + version: tampered.version, + wrappedKey: hexEncode(tampered.wrappedKey), + keyIV: hexEncode(tampered.iv), + } + await expect(unwrapFieldKeys([tamperedServer], hierarchy.kek)).rejects.toThrow(DecryptionError) }) }) @@ -339,22 +399,21 @@ describe('crypto integration', () => { }) it('login flow completes within 5 seconds', async () => { - const { authCreds, wrappedMasterKey, masterKeyIV, wrappedFieldKeys, authSalt, keySalt } = - await setupRegistration() + const { authCreds, wrappedMasterKey, masterKeyIV, serverFieldKeys, keySalt } = await setupRegistration() vi.clearAllMocks() vi.mocked(deriveAuthHash).mockResolvedValue(authCreds.authHash) vi.mocked(derivePasswordKey).mockResolvedValue(authCreds.passwordKey) const start = Date.now() - const loginCreds = await deriveLoginCredentials(PASSWORD, authSalt, keySalt) - const passwordCryptoKey = await importKey(loginCreds.passwordKey) + const passwordKey = await derivePasswordKey(PASSWORD, keySalt) + const passwordCryptoKey = await importKey(passwordKey) const masterKey = await decrypt(wrappedMasterKey, passwordCryptoKey, { iv: masterKeyIV, aad: MASTER_KEY_PASSWORD_AAD, }) const hierarchy = await deriveFullKeyHierarchy(masterKey) - await unwrapFieldKeys(wrappedFieldKeys, hierarchy.kek) + await unwrapFieldKeys(serverFieldKeys, hierarchy.kek) const elapsed = Date.now() - start expect(elapsed).toBeLessThan(5000) diff --git a/src/shared/crypto/crypto-utils.ts b/src/shared/crypto/crypto-utils.ts index c9012dc..ea84244 100644 --- a/src/shared/crypto/crypto-utils.ts +++ b/src/shared/crypto/crypto-utils.ts @@ -84,6 +84,12 @@ export function copyToUint8Array(data: ArrayBuffer | Uint8Array): Uint8Array): void { + if (buffer instanceof Uint8Array) { + buffer.fill(0) + } else { + for (const item of buffer) { + item.fill(0) + } + } } diff --git a/src/shared/crypto/hkdf.ts b/src/shared/crypto/hkdf.ts index 747a425..49b93b5 100644 --- a/src/shared/crypto/hkdf.ts +++ b/src/shared/crypto/hkdf.ts @@ -10,7 +10,7 @@ import { CRYPTO_KEY_LENGTH } from '@/shared/types/crypto.types' -const HKDF_HASH = 'SHA-256' +const HKDF_ALGORITHM = { name: 'HKDF', hash: 'SHA-256' } const encoder = new TextEncoder() @@ -35,14 +35,11 @@ export async function deriveSubKey( throw new Error(`Invalid master key length: expected ${CRYPTO_KEY_LENGTH} bytes, got ${masterKey.length}`) } - const baseKey = await crypto.subtle.importKey('raw', masterKey, { name: 'HKDF', hash: HKDF_HASH }, false, [ - 'deriveBits', - ]) + const baseKey = await crypto.subtle.importKey('raw', masterKey, HKDF_ALGORITHM, false, ['deriveBits']) const derivedBits = await crypto.subtle.deriveBits( { - name: 'HKDF', - hash: HKDF_HASH, + ...HKDF_ALGORITHM, // empty salt: master key is already a cryptographically random 256-bit value salt: new Uint8Array(0), info: encoder.encode(info), diff --git a/src/shared/crypto/key-hierarchy.test.ts b/src/shared/crypto/key-hierarchy.test.ts index 00e8176..dd8b6de 100644 --- a/src/shared/crypto/key-hierarchy.test.ts +++ b/src/shared/crypto/key-hierarchy.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest' import { DecryptionError } from '@/shared/crypto/errors' +import { hexEncode } from '@/shared/crypto/crypto-utils' import { generateMasterKey, generateFieldKeys, @@ -7,6 +8,7 @@ import { wrapFieldKeys, unwrapFieldKeys, } from '@/shared/crypto/key-hierarchy' +import type { ServerFieldKey } from '@/shared/types/api.types' describe('key-hierarchy', () => { describe('generateMasterKey', () => { @@ -23,33 +25,44 @@ describe('key-hierarchy', () => { }) describe('generateFieldKeys', () => { - it('returns a Map with note, website, and email keys', () => { - const fieldKeys = generateFieldKeys() - expect(fieldKeys.size).toBe(3) - expect(fieldKeys.has('note')).toBe(true) - expect(fieldKeys.has('website')).toBe(true) - expect(fieldKeys.has('email')).toBe(true) + it('returns rawFieldKeys and cryptoFieldKeys with note, website, and email', async () => { + const { rawFieldKeys, cryptoFieldKeys } = await generateFieldKeys() + expect(rawFieldKeys.size).toBe(3) + expect(cryptoFieldKeys.size).toBe(3) + expect(rawFieldKeys.has('note')).toBe(true) + expect(rawFieldKeys.has('website')).toBe(true) + expect(rawFieldKeys.has('email')).toBe(true) + expect(cryptoFieldKeys.has('note')).toBe(true) + expect(cryptoFieldKeys.has('website')).toBe(true) + expect(cryptoFieldKeys.has('email')).toBe(true) }) - it('produces 32-byte keys for each field', () => { - const fieldKeys = generateFieldKeys() - for (const key of fieldKeys.values()) { + it('produces 32-byte raw keys for each field', async () => { + const { rawFieldKeys } = await generateFieldKeys() + for (const key of rawFieldKeys.values()) { expect(key.length).toBe(32) } }) - it('produces unique keys for each field', () => { - const fieldKeys = generateFieldKeys() - const keys = [...fieldKeys.values()] + it('produces CryptoKey instances for each field', async () => { + const { cryptoFieldKeys } = await generateFieldKeys() + for (const key of cryptoFieldKeys.values()) { + expect(key).toBeInstanceOf(CryptoKey) + } + }) + + it('produces unique raw keys for each field', async () => { + const { rawFieldKeys } = await generateFieldKeys() + const keys = [...rawFieldKeys.values()] expect(keys[0]).not.toEqual(keys[1]) expect(keys[0]).not.toEqual(keys[2]) expect(keys[1]).not.toEqual(keys[2]) }) - it('produces unique keys on successive calls', () => { - const fieldKeys1 = generateFieldKeys() - const fieldKeys2 = generateFieldKeys() - expect(fieldKeys1.get('note')).not.toEqual(fieldKeys2.get('note')) + it('produces unique keys on successive calls', async () => { + const { rawFieldKeys: r1 } = await generateFieldKeys() + const { rawFieldKeys: r2 } = await generateFieldKeys() + expect(r1.get('note')).not.toEqual(r2.get('note')) }) }) @@ -68,9 +81,14 @@ describe('key-hierarchy', () => { const h1 = await deriveFullKeyHierarchy(masterKey) const h2 = await deriveFullKeyHierarchy(masterKey) - const kek1 = await crypto.subtle.exportKey('raw', h1.kek) - const kek2 = await crypto.subtle.exportKey('raw', h2.kek) - expect(new Uint8Array(kek1)).toEqual(new Uint8Array(kek2)) + // Compare ciphertexts from encrypting same data with both KEKs + const testPlaintext = new Uint8Array(32).fill(0x42) + const iv = new Uint8Array(12).fill(0x00) + + const ciphertext1 = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, h1.kek, testPlaintext) + const ciphertext2 = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, h2.kek, testPlaintext) + + expect(new Uint8Array(ciphertext1)).toEqual(new Uint8Array(ciphertext2)) }) it('produces different KEK for different master keys', async () => { @@ -79,9 +97,14 @@ describe('key-hierarchy', () => { const h1 = await deriveFullKeyHierarchy(mk1) const h2 = await deriveFullKeyHierarchy(mk2) - const kek1 = await crypto.subtle.exportKey('raw', h1.kek) - const kek2 = await crypto.subtle.exportKey('raw', h2.kek) - expect(new Uint8Array(kek1)).not.toEqual(new Uint8Array(kek2)) + // Compare ciphertexts from encrypting same data with both KEKs + const testPlaintext = new Uint8Array(32).fill(0x42) + const iv = new Uint8Array(12).fill(0x00) + + const ciphertext1 = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, h1.kek, testPlaintext) + const ciphertext2 = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, h2.kek, testPlaintext) + + expect(new Uint8Array(ciphertext1)).not.toEqual(new Uint8Array(ciphertext2)) }) }) @@ -90,7 +113,7 @@ describe('key-hierarchy', () => { const masterKey = generateMasterKey() const hierarchy = await deriveFullKeyHierarchy(masterKey) - const fieldKeys = generateFieldKeys() + const { rawFieldKeys, cryptoFieldKeys } = await generateFieldKeys() const versions = new Map([ ['note', 1], @@ -98,24 +121,53 @@ describe('key-hierarchy', () => { ['email', 1], ]) - return { hierarchy, fieldKeys, versions } + return { hierarchy, rawFieldKeys, cryptoFieldKeys, versions } } - it('round-trips all field keys through wrap and unwrap', async () => { - const { hierarchy, fieldKeys, versions } = await setupHierarchy() - - const wrapped = await wrapFieldKeys(fieldKeys, hierarchy.kek, versions) - const unwrapped = await unwrapFieldKeys(wrapped, hierarchy.kek) + function toServerFieldKeys( + wrapped: { + fieldName: string + version: number + wrappedKey: Uint8Array + iv: Uint8Array + }[], + ): ServerFieldKey[] { + return wrapped.map((w) => ({ + fieldName: w.fieldName, + version: w.version, + wrappedKey: hexEncode(w.wrappedKey), + keyIV: hexEncode(w.iv), + })) + } - for (const [fieldName, originalKey] of fieldKeys) { - expect(unwrapped.get(fieldName)).toEqual(originalKey) + it('round-trips all field keys through wrap and unwrap', async () => { + const { hierarchy, rawFieldKeys, versions } = await setupHierarchy() + + const wrapped = await wrapFieldKeys(rawFieldKeys, hierarchy.kek, versions) + const serverFieldKeys = toServerFieldKeys(wrapped) + const unwrapped = await unwrapFieldKeys(serverFieldKeys, hierarchy.kek) + + // Compare by encrypting same data with both original CryptoKey and unwrapped CryptoKey + for (const [fieldName, originalKey] of rawFieldKeys) { + const unwrappedKey = unwrapped.get(fieldName)! + const testPlaintext = new Uint8Array(32).fill(0x42) + const iv = new Uint8Array(12).fill(0x00) + + const ciphertextOriginal = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + await crypto.subtle.importKey('raw', originalKey, { name: 'AES-GCM' }, false, ['encrypt']), + testPlaintext, + ) + const ciphertextUnwrapped = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, unwrappedKey, testPlaintext) + + expect(new Uint8Array(ciphertextOriginal)).toEqual(new Uint8Array(ciphertextUnwrapped)) } }) it('preserves field names and versions in wrapped output', async () => { - const { hierarchy, fieldKeys, versions } = await setupHierarchy() + const { hierarchy, rawFieldKeys, versions } = await setupHierarchy() - const wrapped = await wrapFieldKeys(fieldKeys, hierarchy.kek, versions) + const wrapped = await wrapFieldKeys(rawFieldKeys, hierarchy.kek, versions) const fieldNames = wrapped.map((w) => w.fieldName).sort() expect(fieldNames).toEqual(['email', 'note', 'website']) @@ -126,35 +178,36 @@ describe('key-hierarchy', () => { }) it('produces wrapped keys that differ from plaintext keys', async () => { - const { hierarchy, fieldKeys, versions } = await setupHierarchy() + const { hierarchy, rawFieldKeys, versions } = await setupHierarchy() - const wrapped = await wrapFieldKeys(fieldKeys, hierarchy.kek, versions) + const wrapped = await wrapFieldKeys(rawFieldKeys, hierarchy.kek, versions) for (const w of wrapped) { - expect(w.wrappedKey).not.toEqual(fieldKeys.get(w.fieldName)) + expect(w.wrappedKey).not.toEqual(rawFieldKeys.get(w.fieldName)) } }) it('throws DecryptionError when unwrapping with wrong KEK', async () => { - const { fieldKeys, versions } = await setupHierarchy() + const { rawFieldKeys, versions } = await setupHierarchy() const wrongHierarchy = await deriveFullKeyHierarchy(generateMasterKey()) - const wrapped = await wrapFieldKeys(fieldKeys, wrongHierarchy.kek, versions) + const wrapped = await wrapFieldKeys(rawFieldKeys, wrongHierarchy.kek, versions) // Try to unwrap with a different KEK const anotherHierarchy = await deriveFullKeyHierarchy(generateMasterKey()) - await expect(unwrapFieldKeys(wrapped, anotherHierarchy.kek)).rejects.toThrow(DecryptionError) + const serverFieldKeys = toServerFieldKeys(wrapped) + await expect(unwrapFieldKeys(serverFieldKeys, anotherHierarchy.kek)).rejects.toThrow(DecryptionError) }) it('throws DecryptionError when unwrapping with wrong version (rollback protection)', async () => { - const { hierarchy, fieldKeys } = await setupHierarchy() + const { hierarchy, rawFieldKeys } = await setupHierarchy() const versions = new Map([ ['note', 1], ['website', 1], ['email', 1], ]) - const wrapped = await wrapFieldKeys(fieldKeys, hierarchy.kek, versions) + const wrapped = await wrapFieldKeys(rawFieldKeys, hierarchy.kek, versions) // Tamper with version to simulate rollback const tampered = wrapped.map((w) => ({ @@ -162,18 +215,18 @@ describe('key-hierarchy', () => { version: w.version + 1, })) - await expect(unwrapFieldKeys(tampered, hierarchy.kek)).rejects.toThrow(DecryptionError) + await expect(unwrapFieldKeys(toServerFieldKeys(tampered), hierarchy.kek)).rejects.toThrow(DecryptionError) }) it('throws if version is missing for a field name', async () => { - const { hierarchy, fieldKeys } = await setupHierarchy() + const { hierarchy, rawFieldKeys } = await setupHierarchy() // Missing 'email' version const incompleteVersions = new Map([ ['note', 1], ['website', 1], ]) - await expect(wrapFieldKeys(fieldKeys, hierarchy.kek, incompleteVersions)).rejects.toThrow( + await expect(wrapFieldKeys(rawFieldKeys, hierarchy.kek, incompleteVersions)).rejects.toThrow( 'Missing version for field "email"', ) }) @@ -181,13 +234,29 @@ describe('key-hierarchy', () => { it('wraps and unwraps a single field key', async () => { const masterKey = generateMasterKey() const hierarchy = await deriveFullKeyHierarchy(masterKey) - const fieldKeys = new Map>([['note', crypto.getRandomValues(new Uint8Array(32))]]) + const rawFieldKeys = new Map>([ + ['note', crypto.getRandomValues(new Uint8Array(32))], + ]) const versions = new Map([['note', 1]]) - const wrapped = await wrapFieldKeys(fieldKeys, hierarchy.kek, versions) - const unwrapped = await unwrapFieldKeys(wrapped, hierarchy.kek) + const wrapped = await wrapFieldKeys(rawFieldKeys, hierarchy.kek, versions) + const unwrapped = await unwrapFieldKeys(toServerFieldKeys(wrapped), hierarchy.kek) + + // Verify by encrypting same data + const testPlaintext = new Uint8Array(32).fill(0x42) + const iv = new Uint8Array(12).fill(0x00) + const ciphertextOriginal = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + await crypto.subtle.importKey('raw', rawFieldKeys.get('note')!, { name: 'AES-GCM' }, false, ['encrypt']), + testPlaintext, + ) + const ciphertextUnwrapped = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + unwrapped.get('note')!, + testPlaintext, + ) - expect(unwrapped.get('note')).toEqual(fieldKeys.get('note')) + expect(new Uint8Array(ciphertextOriginal)).toEqual(new Uint8Array(ciphertextUnwrapped)) }) it('returns empty array for empty fieldKeys map', async () => { @@ -212,7 +281,7 @@ describe('key-hierarchy', () => { const hierarchy = await deriveFullKeyHierarchy(masterKey) // 3. Generate field keys - const fieldKeys = generateFieldKeys() + const { rawFieldKeys } = await generateFieldKeys() const versions = new Map([ ['note', 1], ['website', 1], @@ -220,15 +289,35 @@ describe('key-hierarchy', () => { ]) // 4. Wrap field keys with KEK - const wrapped = await wrapFieldKeys(fieldKeys, hierarchy.kek, versions) - - // 5. Unwrap field keys with KEK - const unwrapped = await unwrapFieldKeys(wrapped, hierarchy.kek) - - // 6. Verify all field keys match originals - expect(unwrapped.get('note')).toEqual(fieldKeys.get('note')) - expect(unwrapped.get('website')).toEqual(fieldKeys.get('website')) - expect(unwrapped.get('email')).toEqual(fieldKeys.get('email')) + const wrapped = await wrapFieldKeys(rawFieldKeys, hierarchy.kek, versions) + + // 5. Convert to server format and unwrap + const serverFieldKeys = wrapped.map((w) => ({ + fieldName: w.fieldName, + version: w.version, + wrappedKey: hexEncode(w.wrappedKey), + keyIV: hexEncode(w.iv), + })) + const unwrapped = await unwrapFieldKeys(serverFieldKeys, hierarchy.kek) + + // 6. Verify by encrypting same data with both original and unwrapped keys + const testPlaintext = new Uint8Array(32).fill(0x42) + const iv = new Uint8Array(12).fill(0x00) + + for (const [fieldName, originalKey] of rawFieldKeys) { + const ciphertextOriginal = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + await crypto.subtle.importKey('raw', originalKey, { name: 'AES-GCM' }, false, ['encrypt']), + testPlaintext, + ) + const ciphertextUnwrapped = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + unwrapped.get(fieldName)!, + testPlaintext, + ) + + expect(new Uint8Array(ciphertextOriginal)).toEqual(new Uint8Array(ciphertextUnwrapped)) + } }) }) }) diff --git a/src/shared/crypto/key-hierarchy.ts b/src/shared/crypto/key-hierarchy.ts index 9474c0c..a704390 100644 --- a/src/shared/crypto/key-hierarchy.ts +++ b/src/shared/crypto/key-hierarchy.ts @@ -18,19 +18,28 @@ */ import { importKey } from '@/shared/crypto/aes-gcm' -import { generateKey, generateIV, encodeAAD } from '@/shared/crypto/crypto-utils' +import { generateKey, generateIV, encodeAAD, hexDecode } from '@/shared/crypto/crypto-utils' import { encrypt, decrypt } from '@/shared/crypto/aes-gcm' import { deriveKEK, deriveSigningKeySeed } from '@/shared/crypto/hkdf' import type { KeyHierarchy, WrappedFieldKey } from '@/shared/types/crypto.types' +import type { ServerFieldKey } from '../types/api.types' const FIELD_NAMES = ['note', 'website', 'email'] as const /** * Generate all three field keys (note, website, email) at once. - * Each is a 256-bit random key. Returns a Map of field name to key bytes. + * Each is a 256-bit random key. Returns both the raw key bytes (for wrapping) + * and imported CryptoKeys (for encryption). */ -export function generateFieldKeys(): Map> { - return new Map>(FIELD_NAMES.map((name) => [name, generateKey()])) +export async function generateFieldKeys(): Promise<{ + rawFieldKeys: Map> + cryptoFieldKeys: Map +}> { + const entries = FIELD_NAMES.map((name) => [name, generateKey()] as [string, Uint8Array]) + const cryptoFieldKeys = new Map( + await Promise.all(entries.map(async ([name, key]) => [name, await importKey(key)] as const)), + ) + return { rawFieldKeys: new Map(entries), cryptoFieldKeys } } /** Generate a 256-bit random master key. Used once during registration. */ @@ -41,15 +50,17 @@ export function generateMasterKey(): Uint8Array { /** * Derive the full key hierarchy from a master key. * - * Runs KEK and signing key seed derivation in parallel, then imports the KEK - * bytes as an AES-GCM CryptoKey so it can be used directly for wrapping. + * Derives KEK (for wrapping field keys) and signing key seed (for integrity + * verification) from the master key using HKDF. Imports the KEK bytes as an + * AES-GCM CryptoKey for direct use in key wrapping operations. * - * @returns The master key, KEK (as CryptoKey), and signing key seed + * @param masterKey - 256-bit random master key + * @returns KeyHierarchy containing master key, KEK (CryptoKey), and signing key seed */ export async function deriveFullKeyHierarchy(masterKey: Uint8Array): Promise { const [kekBytes, signingKeySeed] = await Promise.all([deriveKEK(masterKey), deriveSigningKeySeed(masterKey)]) - const kek = await importKey(kekBytes, true) + const kek = await importKey(kekBytes, false) return { masterKey, kek, signingKeySeed } } @@ -91,24 +102,24 @@ export async function wrapFieldKeys( /** * Unwrap multiple field keys with the KEK. * - * Verifies the AAD (field name + version) for each key, so any version - * mismatch or data tampering will cause a DecryptionError. + * Decrypts each wrapped field key using AES-256-GCM with AAD (field name + version). + * The AAD is verified during decryption, so any version mismatch or data tampering + * will cause a DecryptionError. Returns imported CryptoKeys ready for encryption. * - * @param wrappedKeys - Wrapped field keys fetched from server + * @param fieldKeys - Wrapped field keys fetched from server * @param kek - Key Encryption Key (CryptoKey) to unwrap with - * @returns Map of field name → plaintext field key + * @returns Map of field name → decrypted field key as CryptoKey */ -export async function unwrapFieldKeys( - wrappedKeys: WrappedFieldKey[], - kek: CryptoKey, -): Promise>> { +export async function unwrapFieldKeys(fieldKeys: ServerFieldKey[], kek: CryptoKey): Promise> { const entries = await Promise.all( - wrappedKeys.map(async ({ fieldName, version, wrappedKey, iv }) => { + fieldKeys.map(async ({ fieldName, version, wrappedKey, keyIV }) => { const aad = encodeAAD(fieldName, version) - const key = await decrypt(wrappedKey, kek, { iv, aad }) - return [fieldName, key] as [string, Uint8Array] + const iv = hexDecode(keyIV) + const unwrappedKey = await decrypt(hexDecode(wrappedKey), kek, { iv, aad }) + const key = await importKey(unwrappedKey) + return [fieldName, key] as [string, CryptoKey] }), ) - return new Map>(entries) + return new Map(entries) } diff --git a/src/shared/crypto/split-kdf.test.ts b/src/shared/crypto/split-kdf.test.ts index 5a8b41f..d7145f2 100644 --- a/src/shared/crypto/split-kdf.test.ts +++ b/src/shared/crypto/split-kdf.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { DecryptionError } from '@/shared/crypto/errors' -import { deriveAuthCredentials, deriveLoginCredentials, changePassword } from '@/shared/crypto/split-kdf' +import { deriveAuthCredentials, changePassword } from '@/shared/crypto/split-kdf' import { importKey, encrypt, decrypt } from '@/shared/crypto/aes-gcm' import { generateMasterKey } from '@/shared/crypto/key-hierarchy' import { MASTER_KEY_PASSWORD_AAD } from '@/shared/types/crypto.types' -import type { AuthCredentials, LoginCredentials, PasswordChangeResult } from '@/shared/types/crypto.types' +import type { AuthCredentials, PasswordChangeResult } from '@/shared/types/crypto.types' // Mock Argon2id module to avoid WASM/worker dependency in tests vi.mock('@/shared/crypto/argon2id', () => ({ @@ -69,54 +69,6 @@ describe('split-kdf', () => { }) }) - describe('deriveLoginCredentials', () => { - it('derives authHash and passwordKey from existing salts', async () => { - const authSalt = mockBytes(16, 0x11) - const keySalt = mockBytes(16, 0x22) - vi.mocked(deriveAuthHash).mockResolvedValue('c'.repeat(64)) - vi.mocked(derivePasswordKey).mockResolvedValue(mockBytes(32, 0xef)) - - const result: LoginCredentials = await deriveLoginCredentials('password123', authSalt, keySalt) - - expect(deriveAuthHash).toHaveBeenCalledWith('password123', authSalt) - expect(derivePasswordKey).toHaveBeenCalledWith('password123', keySalt) - expect(result).toEqual({ - authHash: 'c'.repeat(64), - passwordKey: mockBytes(32, 0xef), - }) - }) - - it('does not generate new salts', async () => { - const authSalt = mockBytes(16, 0x11) - const keySalt = mockBytes(16, 0x22) - vi.mocked(deriveAuthHash).mockResolvedValue('d'.repeat(64)) - vi.mocked(derivePasswordKey).mockResolvedValue(mockBytes(32, 0xff)) - - await deriveLoginCredentials('password', authSalt, keySalt) - - expect(generateSalt).not.toHaveBeenCalled() - }) - - it('matches deriveAuthCredentials output for same password and salts', async () => { - const authSalt = mockBytes(16, 0x01) - const keySalt = mockBytes(16, 0x02) - - vi.mocked(generateSalt).mockReturnValueOnce(authSalt).mockReturnValueOnce(keySalt) - vi.mocked(deriveAuthHash).mockResolvedValue('e'.repeat(64)) - vi.mocked(derivePasswordKey).mockResolvedValue(mockBytes(32, 0xaa)) - - const regResult = await deriveAuthCredentials('password123') - - vi.mocked(deriveAuthHash).mockResolvedValue('e'.repeat(64)) - vi.mocked(derivePasswordKey).mockResolvedValue(mockBytes(32, 0xaa)) - - const loginResult = await deriveLoginCredentials('password123', regResult.authSalt, regResult.keySalt) - - expect(loginResult.authHash).toBe(regResult.authHash) - expect(loginResult.passwordKey).toEqual(regResult.passwordKey) - }) - }) - describe('changePassword', () => { // Use real AES-GCM for wrapping/unwrapping since changePassword // composes real crypto operations on the master key. diff --git a/src/shared/crypto/split-kdf.ts b/src/shared/crypto/split-kdf.ts index e960c88..4d66661 100644 --- a/src/shared/crypto/split-kdf.ts +++ b/src/shared/crypto/split-kdf.ts @@ -12,19 +12,7 @@ import { deriveAuthHash, derivePasswordKey } from '@/shared/crypto/argon2id' import { importKey, encrypt, decrypt } from '@/shared/crypto/aes-gcm' import { generateSalt, generateIV, zeroFill } from '@/shared/crypto/crypto-utils' import { MASTER_KEY_PASSWORD_AAD } from '@/shared/types/crypto.types' -import type { AuthCredentials, LoginCredentials, PasswordChangeResult } from '@/shared/types/crypto.types' - -async function deriveCredentials( - password: string, - authSalt: Uint8Array, - keySalt: Uint8Array, -): Promise { - const [authHash, passwordKey] = await Promise.all([ - deriveAuthHash(password, authSalt), - derivePasswordKey(password, keySalt), - ]) - return { authHash, passwordKey } -} +import type { AuthCredentials, PasswordChangeResult } from '@/shared/types/crypto.types' /** * Derive authentication credentials for a new registration. @@ -35,22 +23,14 @@ async function deriveCredentials( export async function deriveAuthCredentials(password: string): Promise { const authSalt = generateSalt() const keySalt = generateSalt() - const { authHash, passwordKey } = await deriveCredentials(password, authSalt, keySalt) + const [authHash, passwordKey] = await Promise.all([ + deriveAuthHash(password, authSalt), + derivePasswordKey(password, keySalt), + ]) return { authHash, passwordKey, authSalt, keySalt } } -/** - * Derive login credentials using existing salts from the server. - */ -export async function deriveLoginCredentials( - password: string, - authSalt: Uint8Array, - keySalt: Uint8Array, -): Promise { - return deriveCredentials(password, authSalt, keySalt) -} - /** * Change the user's password by re-wrapping the master key. * @@ -71,17 +51,11 @@ export async function changePassword( const masterKey = await decrypt(wrappedMasterKey, oldWrappingKey, { iv: masterKeyIV, aad: MASTER_KEY_PASSWORD_AAD }) // Generate new salts and derive new credentials - const newAuthSalt = generateSalt() - const newKeySalt = generateSalt() - const { authHash: newAuthHash, passwordKey: newPasswordKey } = await deriveCredentials( - newPassword, - newAuthSalt, - newKeySalt, - ) + const newCredentials = await deriveAuthCredentials(newPassword) // Re-wrap master key with new password key - const newWrappingKey = await importKey(newPasswordKey) - zeroFill(newPasswordKey) + const newWrappingKey = await importKey(newCredentials.passwordKey) + zeroFill(newCredentials.passwordKey) const newMasterKeyIV = generateIV() const newWrappedMasterKey = await encrypt(masterKey, newWrappingKey, { iv: newMasterKeyIV, @@ -90,9 +64,9 @@ export async function changePassword( zeroFill(masterKey) return { - newAuthHash, - newAuthSalt, - newKeySalt, + newAuthHash: newCredentials.authHash, + newAuthSalt: newCredentials.authSalt, + newKeySalt: newCredentials.keySalt, newWrappedMasterKey, newMasterKeyIV, } diff --git a/src/shared/lib/use-resizable.test.ts b/src/shared/lib/use-resizable.test.ts index 63faf8a..8f65898 100644 --- a/src/shared/lib/use-resizable.test.ts +++ b/src/shared/lib/use-resizable.test.ts @@ -56,7 +56,9 @@ describe('useResizable', () => { const { result } = renderHook(() => useResizable({ storedWidth: 240, onWidthChange, minWidth: 200 })) act(() => { - result.current.handleProps.onPointerDown(new PointerEvent('pointerdown', { clientX: 300 }) as unknown as React.PointerEvent) + result.current.handleProps.onPointerDown( + new PointerEvent('pointerdown', { clientX: 300 }) as unknown as React.PointerEvent, + ) }) act(() => { @@ -70,7 +72,9 @@ describe('useResizable', () => { const { result } = renderHook(() => useResizable({ storedWidth: 240, onWidthChange, maxWidth: 400 })) act(() => { - result.current.handleProps.onPointerDown(new PointerEvent('pointerdown', { clientX: 100 }) as unknown as React.PointerEvent) + result.current.handleProps.onPointerDown( + new PointerEvent('pointerdown', { clientX: 100 }) as unknown as React.PointerEvent, + ) }) act(() => { @@ -84,7 +88,9 @@ describe('useResizable', () => { const { result } = renderHook(() => useResizable({ storedWidth: 250, onWidthChange, minWidth: 150, maxWidth: 500 })) act(() => { - result.current.handleProps.onPointerDown(new PointerEvent('pointerdown', { clientX: 0 }) as unknown as React.PointerEvent) + result.current.handleProps.onPointerDown( + new PointerEvent('pointerdown', { clientX: 0 }) as unknown as React.PointerEvent, + ) }) act(() => { @@ -100,7 +106,9 @@ describe('useResizable', () => { const removeSpy = vi.spyOn(document, 'removeEventListener') act(() => { - result.current.handleProps.onPointerDown(new PointerEvent('pointerdown', { clientX: 100 }) as unknown as React.PointerEvent) + result.current.handleProps.onPointerDown( + new PointerEvent('pointerdown', { clientX: 100 }) as unknown as React.PointerEvent, + ) }) act(() => { @@ -116,7 +124,9 @@ describe('useResizable', () => { const { result } = renderHook(() => useResizable({ storedWidth: 240, onWidthChange })) act(() => { - result.current.handleProps.onPointerDown(new PointerEvent('pointerdown', { clientX: 100 }) as unknown as React.PointerEvent) + result.current.handleProps.onPointerDown( + new PointerEvent('pointerdown', { clientX: 100 }) as unknown as React.PointerEvent, + ) }) expect(document.body.style.cursor).toBe('col-resize') diff --git a/src/shared/types/crypto.types.ts b/src/shared/types/crypto.types.ts index a909107..93a32f2 100644 --- a/src/shared/types/crypto.types.ts +++ b/src/shared/types/crypto.types.ts @@ -1,5 +1,3 @@ -import type { ServerFieldKey } from '@/shared/types/api.types' - /** System-wide cryptographic key length in bytes (256 bits). */ export const CRYPTO_KEY_LENGTH = 32 as const @@ -65,11 +63,6 @@ export interface AuthCredentials { keySalt: Uint8Array } -export interface LoginCredentials { - authHash: string - passwordKey: Uint8Array -} - export interface PasswordChangeResult { newAuthHash: string newAuthSalt: Uint8Array @@ -88,29 +81,11 @@ export interface RegistrationResult { authHash: string authSalt: Uint8Array keySalt: Uint8Array - masterKey: Uint8Array - kek: Uint8Array - fieldKeys: Map> + kek: CryptoKey + fieldKeys: Map wrappedMasterKey: Uint8Array masterKeyIV: Uint8Array wrappedFieldKeys: WrappedFieldKey[] recoveryData: RecoveryData mnemonic: string } - -export interface LoginKeysInput { - /** Raw 32-byte key derived from Argon2id (from deriveLoginCredentials) */ - passwordKey: Uint8Array - /** Encrypted master key from server (binary) */ - wrappedMasterKey: Uint8Array - /** IV used to encrypt the master key (binary) */ - masterKeyIV: Uint8Array - /** Wrapped field key data from server (hex strings) */ - serverFieldKeys: ServerFieldKey[] -} - -export interface LoginResult { - masterKey: Uint8Array - kek: CryptoKey - fieldKeys: Map> -} diff --git a/src/shared/ui/nav/MobileNav.test.tsx b/src/shared/ui/nav/MobileNav.test.tsx index 55c5281..6230182 100644 --- a/src/shared/ui/nav/MobileNav.test.tsx +++ b/src/shared/ui/nav/MobileNav.test.tsx @@ -15,8 +15,10 @@ vi.mock('@tanstack/react-router', () => ({ useNavigate: () => vi.fn(), })) -vi.mock('@/features/encryption/model/vault-lock', () => ({ - lockVault: mockLockVault, +vi.mock('@/features/encryption/model/key-vault', () => ({ + keyVault: { + lockVault: mockLockVault, + }, })) import { MobileNav } from './MobileNav' diff --git a/src/shared/ui/nav/MobileNav.tsx b/src/shared/ui/nav/MobileNav.tsx index 2efb374..c5ad9a8 100644 --- a/src/shared/ui/nav/MobileNav.tsx +++ b/src/shared/ui/nav/MobileNav.tsx @@ -5,7 +5,7 @@ import { Button } from '@/shared/ui/button' import { NavLink } from '@/shared/ui/nav/NavLink' import { useCryptoStore } from '@/features/encryption/model/crypto-store' import { useVaultDialogStore } from '@/features/encryption/model/vault-dialog-store' -import { lockVault } from '@/features/encryption/model/vault-lock' +import { keyVault } from '@/features/encryption/model/key-vault' function MobileNav() { const { t } = useTranslation(['common', 'crypto']) @@ -16,7 +16,7 @@ function MobileNav() { if (isVaultLocked) { openUnlockDialog() } else { - lockVault() + keyVault.lockVault() } } diff --git a/src/shared/ui/nav/Sidebar.test.tsx b/src/shared/ui/nav/Sidebar.test.tsx index c82d08b..b33e160 100644 --- a/src/shared/ui/nav/Sidebar.test.tsx +++ b/src/shared/ui/nav/Sidebar.test.tsx @@ -17,8 +17,10 @@ vi.mock('@tanstack/react-router', () => ({ useNavigate: () => mockNavigate, })) -vi.mock('@/features/encryption/model/vault-lock', () => ({ - lockVault: mockLockVault, +vi.mock('@/features/encryption/model/key-vault', () => ({ + keyVault: { + lockVault: mockLockVault, + }, })) import { Sidebar } from './Sidebar' diff --git a/src/shared/ui/nav/Sidebar.tsx b/src/shared/ui/nav/Sidebar.tsx index 51c7d7d..e26caf3 100644 --- a/src/shared/ui/nav/Sidebar.tsx +++ b/src/shared/ui/nav/Sidebar.tsx @@ -10,7 +10,7 @@ import { AppLogo } from '@/shared/ui/brand/AppLogo' import { useAuthStore } from '@/features/auth/model/auth-store' import { useCryptoStore } from '@/features/encryption/model/crypto-store' import { useVaultDialogStore } from '@/features/encryption/model/vault-dialog-store' -import { lockVault } from '@/features/encryption/model/vault-lock' +import { keyVault } from '@/features/encryption/model/key-vault' interface SidebarProps { onClose?: () => void @@ -39,7 +39,7 @@ function Sidebar({ onClose, onLogout, className }: SidebarProps) { if (isVaultLocked) { openUnlockDialog() } else { - lockVault() + keyVault.lockVault() } } diff --git a/src/test/setup.ts b/src/test/setup.ts index 79c0b81..447997f 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -26,9 +26,7 @@ afterEach(() => { isRestoringSession: false, }) useCryptoStore.setState({ - masterKey: null, - kek: null, - fieldKeys: {}, + loadedFieldKeys: {}, isVaultLocked: true, lastActivity: 0, })