diff --git a/CLAUDE.md b/CLAUDE.md index e44909c..e598709 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -182,7 +182,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 -- **`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) +- **`keyVault.lockVault()` vs `keyVault.clearVault()`**: `lockVault()` preserves the cached envelope (so re-unlock can skip network calls), while `clearVault()` (called on logout) zeros everything including the cache. `keyVault.unlockVault()` tries the cached envelope first and retries from server on `DecryptionError` (stale cache can happen if the password was changed in another session) - **Test file naming**: prefix with `-` in `src/app/routes/` to exclude from TanStack Router route tree generation - **Test setup**: shared setup (`src/test/setup.ts`) resets `useAuthStore` (with `isRestoringSession: false`), `useCryptoStore`, and `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,7 +195,7 @@ Non-obvious decisions not visible from code alone: - **BIP-39 mnemonic functions are async**: `generateMnemonic`, `validateMnemonic`, `mnemonicToSeed` must be `async` despite the underlying `@scure/bip39` functions being synchronous, because the lazy-loading pattern (`await loadBip39()`) requires it. Same as how `argon2id.ts` wraps sync Argon2 in async. - **`deriveRecoveryKEK` uses mnemonic string directly**: The mnemonic phrase is passed as the Argon2id "password" parameter, not the BIP-39 binary seed. The human-readable phrase is the input because it is what the user supplies and remembers; the binary seed is an internal derivation artifact. `mnemonicToSeed` is a utility function not used in the recovery KEK path. - **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. +- **`deriveRegistrationKeys` is a pure crypto function**: in `features/auth/model/registration-crypto.ts`, it has no side effects (no auth, no DB, no store writes). The orchestration (signup + upload + store population) lives in `src/features/auth/model/auth-service.ts` `signUpUser`. Do not add side effects to this function. - **`signUpUser` error cleanup**: on any error after `deriveRegistrationKeys` succeeds, attempts `authAdapter.logout()` as best-effort cleanup (harmless if no session exists, since Supabase signOut with no session is a no-op). - **Login salt fetch is pre-auth**: `get_login_salts(p_username)` is a SECURITY DEFINER RPC callable by anonymous users, rate-limited (5 req/2 min/IP). Salts must be fetched before auth to derive `authHash` for Supabase Auth, but the `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. Missing key data is `ApiError(NOT_FOUND)`, not an auth error — `KEYS_NOT_FOUND` was removed from `AuthErrorCode` because "data not found" is a data-layer concern, not an auth concern. diff --git a/docs/implementation-plan/00-overview.md b/docs/implementation-plan/00-overview.md index 44a0ea6..faff19a 100644 --- a/docs/implementation-plan/00-overview.md +++ b/docs/implementation-plan/00-overview.md @@ -65,8 +65,6 @@ cipher-note-react/ src/ app/ Providers.tsx # QueryClientProvider, i18n, AuthProvider - flows/ - auth-flow.ts # Orchestrate: signup, login, logout, session restore, unlock vault router.tsx # TanStack Router route tree ErrorBoundary.tsx # Root error boundary with crypto error handling styles/ @@ -95,9 +93,12 @@ cipher-note-react/ ui/ AuthLayout.tsx # Shared layout for auth pages AuthProvider.tsx # Wires auth store into AuthContext (depends on features, so not in shared/) + LoginPage.tsx # Login page with password input + RegisterPage.tsx # Registration page with mnemonic display MnemonicDialog.tsx # Seed phrase display MnemonicInput.tsx # 12-word input with BIP-39 validation PasswordStrength.tsx # Password strength indicator + UsernameAvailability.tsx # Real-time username availability check lib/ RequireAuth.tsx # Redirect to /login if not authenticated GuestOnly.tsx # Redirect to /dashboard if authenticated @@ -105,8 +106,11 @@ cipher-note-react/ model/ field-crypto.ts # encrypt/decrypt field content field-service.ts # load/save fields via API - auto-save.ts # Debounced auto-save with optimistic updates - sync-status.ts # Zustand: per-field sync status + use-field-editor.ts # Local draft + debounce logic + use-save-scheduler.ts # Debounced save with refs + use-field-query.ts # TanStack Query hook for field content + use-field-mutation.ts # TanStack Query mutation for saves + sync-status-store.ts # Zustand: per-field sync status ui/ FieldCard.tsx # Locked/unlocked field display NoteField.tsx # Textarea for note content @@ -114,19 +118,24 @@ cipher-note-react/ EmailField.tsx # Input for email address SaveIndicator.tsx # "Saving..." / "Saved" / "Error" ConflictNotification.tsx + DashboardPage.tsx # Main dashboard with all three fields encryption/ model/ - vault-timeout.ts # Auto-lock after inactivity - key-rotation.ts # Rotate individual field keys - multi-device.ts # Handle key changes from other sessions - registration.ts # Pure crypto: deriveRegistrationKeys - encryption-facade.ts # Thin public API for other features to call + key-rotation.ts # Rotate individual field keys + multi-device.ts # Handle key changes from other sessions + encryption-facade.ts # Thin public API for other features to call + use-vault-timeout.ts # Auto-lock after inactivity (15-min idle timer) + crypto-error-messages.ts # Maps crypto errors to i18n keys + registration-crypto.ts # Pure crypto: deriveRegistrationKeys ui/ VaultUnlockDialog.tsx VaultIndicator.tsx settings/ + model/ + ui-store.ts # Zustand: settings UI state (expanded sections) ui/ SecuritySection.tsx # Change password, seed phrase, key versions + SettingsPage.tsx # Settings page shell PreferencesSection.tsx # Language switcher AccountSection.tsx # Username, delete account ChangePasswordDialog.tsx @@ -150,6 +159,12 @@ cipher-note-react/ dialog.tsx sonner.tsx # etc. — each shadcn component in its own file, imported directly + lib/ + network-errors.ts # isNetworkError helper (shared by auth + api adapters) + utils.ts # cn() utility for conditional classes + theme-provider.tsx # NextThemes provider wrapper + use-debounced-value.ts # Debounce hook for search inputs + use-resizable.ts # Resizable panel hook for sidebar crypto/ aes-gcm.ts # AES-256-GCM encrypt/decrypt/importKey/exportKey argon2id.ts # Argon2id derivation via argon2-browser bundled build (lazy-loaded) @@ -158,7 +173,7 @@ cipher-note-react/ split-kdf.ts # Split KDF (auth + key derivation from password) mnemonic.ts # BIP-39 generate/validate/wrap/unwrap (lazy-loaded) crypto-utils.ts # hexEncode, hexDecode, generateIV, generateSalt, generateKey, encodeAAD, zeroFill, copyToUint8Array - key-vault.ts # Module-scoped CryptoKey vault (non-extractable keys) + key-vault.ts # KeyVault class: storeFieldKeys, getKey, unlockVault, lockVault, clearVault crypto-store.ts # Zustand: loadedFieldKeys, isVaultLocked (memory only, no devtools) vault-dialog-store.ts # Zustand: vault unlock dialog state (open/close) api/ @@ -175,33 +190,33 @@ cipher-note-react/ supabase-adapter.ts # Supabase Auth implementation auth-context.tsx # React context + useAuth hook (AuthProvider is in features/auth/) username-utils.ts # toSupabaseEmail, fromSupabaseEmail + password-utils.ts # Password strength validation # future: custom-jwt-adapter.ts, opaque-adapter.ts realtime/ realtime.types.ts # IRealtimeAdapter interface supabase-realtime.ts # Supabase Realtime implementation # future: ws-realtime.ts i18n/ - config.ts # i18next init + language detector + http backend - locales/ - en/ - common.json - auth.json - fields.json - settings.json - crypto.json - cs/ - common.json - auth.json - fields.json - settings.json - crypto.json + config.ts # i18next initialization + resource bundles + locales/en/ # English translations (auth, crypto, landing) + locales/cs/ # Czech translations (auth, crypto, landing) types/ - crypto.types.ts # AesGcmOptions, RecoveryWrapOptions, WrappedFieldKey, EncryptedField, etc. - api.types.ts # ServerKeys, ServerFieldKey, etc. - entities/ - user.types.ts # User entity types - field.types.ts # Field entity types - key.types.ts # Key entity types + api.types.ts # IApiAdapter interface, ApiError types + crypto.types.ts # Crypto primitives, KeyHierarchy, WrappedFieldKey + entities/ # Typed entities: field, key, user + test/ + setup.ts # Vitest setup: store resets, mocks + utils.tsx # Custom render with ThemeProvider wrapper + supabase-mock.ts # Supabase client mocks + features/ + landing/ + ui/ + LandingPage.tsx # Landing page shell + HeroSection.tsx # Hero with headline + CTA + FeaturesGrid.tsx # Feature cards + HowItWorks.tsx # Step-by-step explainer + SecurityBanner.tsx # "No credit card" banner + CtaButtons.tsx # Primary/secondary CTA buttons e2e/ auth.spec.ts fields.spec.ts diff --git a/docs/implementation-plan/04-phase-4-crypto.md b/docs/implementation-plan/04-phase-4-crypto.md index 164ce31..7c5e5be 100644 --- a/docs/implementation-plan/04-phase-4-crypto.md +++ b/docs/implementation-plan/04-phase-4-crypto.md @@ -167,7 +167,7 @@ - Re-wrap master key with new password key using AES-256-GCM (AAD = "master-key-password'") - Return `{ newAuthHash, newAuthSalt, newKeySalt, newWrappedMasterKey, newMasterKeyIV }` - `AuthCredentials`, `LoginCredentials`, `PasswordChangeResult` types already exist in crypto.types.ts — no changes needed -- `derive-placeholder.ts` remains in place until Step 21 replaces its consumer in `auth-flow.ts` `loginUser` +- `derive-placeholder.ts` remains in place until Step 21 replaces its consumer in `src/features/auth/model/auth-service.ts` `loginUser` **Tests:** - `deriveAuthCredentials`: generates two salts, calls Argon2id with correct args, returns correct types diff --git a/docs/implementation-plan/05-phase-5-reg-login.md b/docs/implementation-plan/05-phase-5-reg-login.md index e401299..445ac87 100644 --- a/docs/implementation-plan/05-phase-5-reg-login.md +++ b/docs/implementation-plan/05-phase-5-reg-login.md @@ -5,7 +5,7 @@ **Goal:** Wire up the full registration flow: derive keys, wrap, store on server. **Code:** -- `src/features/encryption/model/registration.ts`: +- `src/features/auth/model/registration-crypto.ts`: - `deriveRegistrationKeys(password: string): Promise` — pure crypto function with no side effects (no auth calls, no DB writes). The auth orchestration (signup, upload, store population) remains in the auth operations module. 1. Generate salts (auth_salt, key_salt) via `generateSalt()` from `crypto-utils.ts` 2. Derive auth credentials: auth_hash + password_key @@ -25,7 +25,7 @@ - `src/shared/crypto/crypto-utils.ts` (defined in Step 12): - `hexEncode` / `hexDecode` — used by registration flow to encode binary keys for Zustand storage and decode server hex strings for crypto operations - `zeroFill` — securely overwrite sensitive key material after use -- `src/app/flows/auth-flow.ts` +- `src/features/auth/model/auth-service.ts` - `signUpUser(username: string, password: string): Promise` — orchestrates the full registration flow: derives keys, signs up via auth adapter, uploads registration data, populates crypto store with hex-encoded keys, returns mnemonic as a string. Sets auth store loading state. On upload failure after successful signup, attempts best-effort cleanup via `authAdapter.logout()` - `loginUser`, `logoutUser`, `restoreSession`, `subscribeToAuthChanges` — move from `features/auth/model/auth-credentials.ts` (and delete). These functions will be replaced by proper flow-level implementations in Steps 21–23. - Update `IAuthAdapter.signup` to remove `keySalt` parameter — salts are stored in the `keys` table by `supabase-registration.ts`, not in `user_metadata` @@ -82,7 +82,7 @@ - Login crypto module (pure function, no side effects): - `deriveLoginKeys(passwordKey, wrappedMasterKey, masterKeyIV, serverFieldKeys): Promise` — takes already-derived passwordKey (avoids double Argon2id), unwraps master key, derives KEK, unwraps field keys. Hex-decodes server field key data internally. - `LoginResult` type: `{ masterKey, kek (CryptoKey), fieldKeys (Map) }` — no authHash (caller already has it) -- Auth flow orchestration (in existing auth-flow module): +- Auth flow orchestration (in existing auth-service module): - `loginUser(username, password)` — fetches salts via pre-auth RPC, derives credentials, authenticates, fetches key material, unwraps via `deriveLoginKeys`, populates crypto store with hex-encoded keys - `logoutUser` — calls `clearVault()` before resetting auth store - `subscribeToAuthChanges` — calls `clearVault()` when auth state becomes null (sign-out from another tab) @@ -106,8 +106,8 @@ - Unit: `unlockVault` with valid password populates crypto store - Unit: `unlockVault` without authenticated user throws - Unit: `getLoginSalts`, `getMasterKeyEnvelope`, `getFieldKeys` server data access -- Unit: auth-flow `loginUser` calls correct sequence of operations -- Unit: auth-flow `logoutUser` calls `lockVault()` before resetting store +- Unit: `src/features/auth/model/auth-service.ts` `loginUser` calls correct sequence of operations +- Unit: `src/features/auth/model/auth-service.ts` `logoutUser` calls `lockVault()` before resetting store --- @@ -130,7 +130,7 @@ - Auto-closes dialog and resets form when vault transitions from locked → unlocked (uses `wasLockedRef` + `useEffect` watching `isVaultLocked`) - `crypto-error-messages.ts` — maps crypto error types to i18n keys for vault unlock error display - Sidebar/MobileNav lock button — calls `lockVault()` when vault is unlocked; unlock button calls `openUnlockDialog()` from `vault-dialog-store` (VaultUnlockDialog handles the actual unlock) -- `vault-timeout.ts` (`useVaultTimeout` hook): +- `use-vault-timeout.ts` (`useVaultTimeout` hook): - Default 15-minute timeout (exported as `DEFAULT_VAULT_TIMEOUT_MS`) - Resets timer on user activity: `mousemove`, `keydown`, `mousedown`, `touchstart`, `scroll` - Does not start timer when vault is already locked @@ -159,13 +159,13 @@ **Code:** - 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 +- Move `unlockVault()` from `vault-lock.ts` into `src/features/auth/model/auth-service.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 `src/features/auth/model/auth-service.ts`. Update all callers (`Sidebar`, `MobileNav`, `VaultUnlockDialog`, `use-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()` +- `clearVault()` in crypto store no longer calls `terminateWorker()` — worker termination moved to `logoutCleanup()` in `src/features/auth/model/auth-service.ts` alongside `keyVault.clearVault()` and `store.reset()` - Verify `devtools` middleware is not in auth store or crypto store (secrets must not appear in browser DevTools). Verify no crypto keys appear in localStorage, sessionStorage, or IndexedDB. Verify `setQueryClient(client)` is wired in app providers **Tests:** diff --git a/src/app/Providers.tsx b/src/app/Providers.tsx index 90c39fe..e2abb9a 100644 --- a/src/app/Providers.tsx +++ b/src/app/Providers.tsx @@ -4,7 +4,7 @@ import { AuthProvider } from '@/features/auth/ui/auth-provider' import { useAuth } from '@/shared/auth/auth-context' import { RouterProvider } from '@tanstack/react-router' import { createAppRouter } from './router' -import { restoreSession, subscribeToAuthChanges } from '@/app/flows/auth-flow' +import { restoreSession, subscribeToAuthChanges } from '@/features/auth/model/auth-service' import { setQueryClient } from '@/shared/crypto/crypto-store' import { PageSkeleton } from '@/app/Pending' diff --git a/src/app/layouts/ProtectedLayout.tsx b/src/app/layouts/ProtectedLayout.tsx index a95be2c..93b35de 100644 --- a/src/app/layouts/ProtectedLayout.tsx +++ b/src/app/layouts/ProtectedLayout.tsx @@ -11,8 +11,8 @@ import { VaultIndicator } from '@/features/encryption/ui/VaultIndicator' import { VaultUnlockDialog } from '@/features/encryption/ui/VaultUnlockDialog' import { useUiStore } from '@/features/settings/model/ui-store' import { useResizable } from '@/shared/lib/use-resizable' -import { useVaultTimeout } from '@/features/encryption/model/vault-timeout' -import { logoutUser } from '@/app/flows/auth-flow' +import { useVaultTimeout } from '@/features/encryption/model/use-vault-timeout' +import { logoutUser } from '@/features/auth/model/auth-service' function ProtectedLayout() { const { t } = useTranslation('common') diff --git a/src/app/routes/-_public.login.test.tsx b/src/app/routes/-_public.login.test.tsx index 3a5c996..03bb3b1 100644 --- a/src/app/routes/-_public.login.test.tsx +++ b/src/app/routes/-_public.login.test.tsx @@ -3,7 +3,7 @@ import { render, screen, waitFor } from '@/test/utils' import userEvent from '@testing-library/user-event' import React from 'react' -vi.mock('@/app/flows/auth-flow', () => ({ +vi.mock('@/features/auth/model/auth-service', () => ({ loginUser: vi.fn().mockResolvedValue(undefined), })) @@ -18,7 +18,7 @@ vi.mock('@tanstack/react-router', () => ({ })) import { LoginPage } from '@/features/auth/ui/LoginPage' -import { loginUser } from '@/app/flows/auth-flow' +import { loginUser } from '@/features/auth/model/auth-service' import { AuthError, AuthErrorCode } from '@/shared/auth/auth-errors' import { useAuthStore } from '@/features/auth/model/auth-store' import { toast } from 'sonner' diff --git a/src/app/routes/_public.register.tsx b/src/app/routes/_public.register.tsx index 0a1148d..2273f97 100644 --- a/src/app/routes/_public.register.tsx +++ b/src/app/routes/_public.register.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' import { RegisterPage } from '@/features/auth/ui/RegisterPage' -import { signUpUser } from '@/app/flows/auth-flow' +import { signUpUser } from '@/features/auth/model/auth-service' const Route = createFileRoute('/_public/register')({ component: () => , diff --git a/src/app/flows/auth-flow.test.ts b/src/features/auth/model/auth-service.test.ts similarity index 62% rename from src/app/flows/auth-flow.test.ts rename to src/features/auth/model/auth-service.test.ts index 80b7aaf..c10c163 100644 --- a/src/app/flows/auth-flow.test.ts +++ b/src/features/auth/model/auth-service.test.ts @@ -1,16 +1,5 @@ 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< @@ -21,20 +10,19 @@ const mockSetAuth = >() const mockSetRestoringSession = vi.fn<(isRestoringSession: boolean) => void>() const mockReset = vi.fn<() => void>() -const mockSetKeys = vi.fn<(fieldKeyNames: string[]) => void>() const mockSetEnvelope = vi.fn<(envelope: import('@/shared/types/api.types').CachedVaultEnvelope) => void>() // Mock registration module -vi.mock('@/features/encryption/model/registration', () => ({ +vi.mock('@/features/auth/model/registration-crypto', () => ({ deriveRegistrationKeys: vi.fn().mockResolvedValue({ authHash: 'a'.repeat(64), authSalt: new Uint8Array(16).fill(0x01), keySalt: new Uint8Array(16).fill(0x02), - kek: createCryptoKeyMock(), - fieldKeys: new Map([ - ['note', createCryptoKeyMock()], - ['website', createCryptoKeyMock()], - ['email', createCryptoKeyMock()], + kek: {} as CryptoKey, + fieldKeys: new Map([ + ['note', {} as CryptoKey], + ['website', {} as CryptoKey], + ['email', {} as CryptoKey], ]), wrappedMasterKey: new Uint8Array(48).fill(0x05), masterKeyIV: new Uint8Array(12).fill(0x06), @@ -55,8 +43,9 @@ const { mockClearVault } = vi.hoisted(() => ({ vi.mock('@/shared/crypto/key-vault', () => ({ keyVault: { lockVault: vi.fn<() => void>(), + unlockVault: vi.fn<(userId: string, password: string) => void>(), storeKey: vi.fn<() => void>(), - storeFieldKeys: vi.fn<(kek: CryptoKey, fieldKeys: Map) => void>(), + storeFieldKeys: vi.fn<(fieldKeys: Map) => void>(), clearVault: mockClearVault, }, })) @@ -67,43 +56,18 @@ vi.mock('@/shared/api/supabase-registration', () => ({ })) // Mock Supabase keys -const { mockEnvelopeData, mockFieldKeysData } = vi.hoisted(() => ({ - mockEnvelopeData: { - authSalt: '01'.repeat(16), - keySalt: '02'.repeat(16), - wrappedMasterKey: '05'.repeat(48), - masterKeyIV: '06'.repeat(12), - }, - mockFieldKeysData: [ - { 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) }, - ], -})) - vi.mock('@/shared/api/supabase-keys', () => ({ fetchLoginSalts: vi.fn().mockResolvedValue({ authSalt: '01'.repeat(16), keySalt: '02'.repeat(16), }), - fetchMasterKeyEnvelope: vi.fn().mockResolvedValue(mockEnvelopeData), - fetchFieldKeys: vi.fn().mockResolvedValue(mockFieldKeysData), -})) - -// Mock Split KDF -vi.mock('@/shared/crypto/split-kdf', () => ({ - 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), - }), + fetchMasterKeyEnvelope: vi.fn(), + fetchFieldKeys: vi.fn(), })) // 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(), })) @@ -117,30 +81,6 @@ 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().mockResolvedValue(createCryptoKeyMock()), - encrypt: 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 vi.mock('@/shared/auth/supabase-adapter', () => ({ authAdapter: { @@ -178,7 +118,6 @@ const cryptoStoreState = { isVaultLocked: true, lastActivity: 0, cachedEnvelope: null as import('@/shared/types/api.types').CachedVaultEnvelope | null, - setKeys: mockSetKeys, setCachedEnvelope: mockSetEnvelope, lockVault: vi.fn<() => void>(), clearVault: mockClearVault, @@ -191,27 +130,27 @@ vi.mock('@/shared/crypto/crypto-store', () => ({ }, })) +// Mock populateKeyVault +vi.mock('@/shared/crypto/key-vault-service', () => ({ + populateKeyVault: vi.fn().mockResolvedValue(undefined), +})) + import { signUpUser, loginUser, logoutUser, restoreSession, subscribeToAuthChanges, - unlockVault, -} from '@/app/flows/auth-flow' -import { deriveRegistrationKeys } from '@/features/encryption/model/registration' +} from '@/features/auth/model/auth-service' +import { deriveRegistrationKeys } from '@/features/auth/model/registration-crypto' import { authAdapter } from '@/shared/auth/supabase-adapter' import { uploadRegistrationData } from '@/shared/api/supabase-registration' -import { fetchLoginSalts, fetchMasterKeyEnvelope, fetchFieldKeys } from '@/shared/api/supabase-keys' +import { fetchLoginSalts } 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 '@/shared/crypto/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' +import { terminateWorker } from '@/shared/crypto/argon2id' describe('signUpUser', () => { beforeEach(() => { @@ -240,7 +179,8 @@ describe('signUpUser', () => { it('stores field keys via keyVault.storeFieldKeys', async () => { await signUpUser('testuser', 'testpass123') const regResult = await (deriveRegistrationKeys as ReturnType).mock.results[0].value - expect(keyVault.storeFieldKeys).toHaveBeenCalledWith(regResult.kek, regResult.fieldKeys) + expect(keyVault.storeKey).toHaveBeenCalledWith('kek', regResult.kek) + expect(keyVault.storeFieldKeys).toHaveBeenCalledWith(regResult.fieldKeys) }) it('caches envelope data after signup', async () => { @@ -287,38 +227,17 @@ describe('loginUser', () => { vi.clearAllMocks() }) - it('fetches salts, derives auth hash, and authenticates', async () => { + it('fetches salts and authenticates', async () => { await loginUser('testuser', 'testpass123') expect(fetchLoginSalts).toHaveBeenCalledWith('testuser') - expect(deriveAuthHash).toHaveBeenCalledWith('testpass123', expect.any(Uint8Array)) expect(authAdapter.login).toHaveBeenCalledWith('testuser', 'a'.repeat(64)) }) - it('fetches envelope and field keys after authentication', async () => { + it('populates key vault after authentication', async () => { await loginUser('testuser', 'testpass123') - expect(fetchMasterKeyEnvelope).toHaveBeenCalledWith('1') - expect(fetchFieldKeys).toHaveBeenCalledWith('1') - }) - - it('derives KEK from password and envelope, then stores field keys', async () => { - await loginUser('testuser', 'testpass123') - - 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 () => { - await loginUser('testuser', 'testpass123') - expect(mockSetEnvelope).toHaveBeenCalledWith({ - ...mockEnvelopeData, - fieldKeys: mockFieldKeysData, - }) + expect(keyVault.unlockVault).toHaveBeenCalledWith('1', 'testpass123') }) it('sets auth state on success', async () => { @@ -342,7 +261,7 @@ describe('loginUser', () => { await expect(loginUser('testuser', 'wrongpass')).rejects.toThrow() - expect(keyVault.storeFieldKeys).not.toHaveBeenCalled() + expect(keyVault.unlockVault).not.toHaveBeenCalled() expect(mockSetAuth).not.toHaveBeenCalled() }) @@ -354,24 +273,14 @@ describe('loginUser', () => { expect(mockSetLoading).toHaveBeenCalledWith(false) }) - it('does not populate crypto store when key derivation fails after auth succeeds', async () => { - vi.mocked(decrypt).mockRejectedValueOnce(new Error('Decryption failed')) + it('does not set auth when populateKeyVault fails after auth succeeds', async () => { + vi.mocked(keyVault.unlockVault).mockRejectedValueOnce(new Error('Unlock failed')) - await expect(loginUser('testuser', 'testpass123')).rejects.toThrow('Decryption failed') + await expect(loginUser('testuser', 'testpass123')).rejects.toThrow('Unlock failed') - 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(fetchMasterKeyEnvelope).mockRejectedValueOnce(new AuthError(AuthErrorCode.NETWORK_ERROR)) - - await expect(loginUser('testuser', 'testpass123')).rejects.toThrow(AuthError) - - expect(keyVault.storeFieldKeys).not.toHaveBeenCalled() - expect(mockSetAuth).not.toHaveBeenCalled() - }) }) describe('logoutUser', () => { @@ -570,119 +479,3 @@ describe('subscribeToAuthChanges', () => { 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/features/auth/model/auth-service.ts similarity index 51% rename from src/app/flows/auth-flow.ts rename to src/features/auth/model/auth-service.ts index 0519f04..00bc322 100644 --- a/src/app/flows/auth-flow.ts +++ b/src/features/auth/model/auth-service.ts @@ -1,18 +1,12 @@ -import { deriveRegistrationKeys } from '@/features/encryption/model/registration' -import { useCryptoStore } from '@/shared/crypto/crypto-store' +import { deriveRegistrationKeys } from '@/features/auth/model/registration-crypto' import { useAuthStore } from '@/features/auth/model/auth-store' +import { useCryptoStore } from '@/shared/crypto/crypto-store' import { authAdapter } from '@/shared/auth/supabase-adapter' import { uploadRegistrationData } from '@/shared/api/supabase-registration' -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 { fetchLoginSalts } from '@/shared/api/supabase-keys' +import { hexDecode, hexEncode } from '@/shared/crypto/crypto-utils' +import { deriveAuthHash, terminateWorker } from '@/shared/crypto/argon2id' import { keyVault } from '@/shared/crypto/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 @@ -44,7 +38,8 @@ export async function signUpUser(username: string, password: string): Promise void { } }) } - -/** - * 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/features/encryption/model/registration.test.ts b/src/features/auth/model/registration-crypto.test.ts similarity index 98% rename from src/features/encryption/model/registration.test.ts rename to src/features/auth/model/registration-crypto.test.ts index 0136228..44eef38 100644 --- a/src/features/encryption/model/registration.test.ts +++ b/src/features/auth/model/registration-crypto.test.ts @@ -37,7 +37,7 @@ vi.mock('@/shared/crypto/crypto-utils', async () => ({ import { deriveAuthHash, derivePasswordKey, deriveKey } from '@/shared/crypto/argon2id' import { generateSalt } from '@/shared/crypto/crypto-utils' -import { deriveRegistrationKeys } from '@/features/encryption/model/registration' +import { deriveRegistrationKeys } from '@/features/auth/model/registration-crypto' function mockBytes(length: number, fill: number): Uint8Array { return new Uint8Array(length).fill(fill) as Uint8Array diff --git a/src/features/encryption/model/registration.ts b/src/features/auth/model/registration-crypto.ts similarity index 100% rename from src/features/encryption/model/registration.ts rename to src/features/auth/model/registration-crypto.ts diff --git a/src/features/auth/ui/LoginPage.tsx b/src/features/auth/ui/LoginPage.tsx index d98c695..7d75abc 100644 --- a/src/features/auth/ui/LoginPage.tsx +++ b/src/features/auth/ui/LoginPage.tsx @@ -4,7 +4,7 @@ import { standardSchemaResolver } from '@hookform/resolvers/standard-schema' import { useTranslation } from 'react-i18next' import { Loader2 } from 'lucide-react' import { loginSchema, type LoginFormData } from '@/features/auth/model/login-schema' -import { loginUser } from '@/app/flows/auth-flow' +import { loginUser } from '@/features/auth/model/auth-service' import { AuthLayout } from '@/features/auth/ui/AuthLayout' import { FormField } from '@/shared/ui/form/FormField' import { Input } from '@/shared/ui/input' diff --git a/src/features/encryption/model/vault-timeout.test.ts b/src/features/encryption/model/use-vault-timeout.test.ts similarity index 97% rename from src/features/encryption/model/vault-timeout.test.ts rename to src/features/encryption/model/use-vault-timeout.test.ts index 402bd22..4e6004e 100644 --- a/src/features/encryption/model/vault-timeout.test.ts +++ b/src/features/encryption/model/use-vault-timeout.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { act } from 'react' import { useCryptoStore } from '@/shared/crypto/crypto-store' -import { DEFAULT_VAULT_TIMEOUT_MS } from './vault-timeout' +import { DEFAULT_VAULT_TIMEOUT_MS } from './use-vault-timeout' vi.mock('@/shared/crypto/key-vault', () => ({ keyVault: { @@ -11,7 +11,7 @@ vi.mock('@/shared/crypto/key-vault', () => ({ })) import { keyVault } from '@/shared/crypto/key-vault' -import { useVaultTimeout } from './vault-timeout' +import { useVaultTimeout } from './use-vault-timeout' import { renderHook } from '@/test/utils' describe('useVaultTimeout', () => { diff --git a/src/features/encryption/model/vault-timeout.ts b/src/features/encryption/model/use-vault-timeout.ts similarity index 100% rename from src/features/encryption/model/vault-timeout.ts rename to src/features/encryption/model/use-vault-timeout.ts diff --git a/src/features/encryption/ui/VaultUnlockDialog.test.tsx b/src/features/encryption/ui/VaultUnlockDialog.test.tsx index 56212fb..430a4b3 100644 --- a/src/features/encryption/ui/VaultUnlockDialog.test.tsx +++ b/src/features/encryption/ui/VaultUnlockDialog.test.tsx @@ -9,19 +9,40 @@ const { mockUnlockVault } = vi.hoisted(() => ({ mockUnlockVault: vi.fn(), })) -vi.mock('@/app/flows/auth-flow', () => ({ - unlockVault: mockUnlockVault, +vi.mock('@/shared/crypto/key-vault', () => ({ + keyVault: { + unlockVault: mockUnlockVault, + }, })) +// Mock auth context — keep real AuthContext (needed by AuthProvider), mock useAuth +vi.mock('@/shared/auth/auth-context', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useAuth: vi.fn(), + } +}) + import { VaultUnlockDialog } from './VaultUnlockDialog' +import { useAuth } from '@/shared/auth/auth-context' describe('VaultUnlockDialog', () => { + const mockUser = { id: 'user-1', username: 'testuser' } + beforeEach(() => { vi.clearAllMocks() // Reset stores to clean state useCryptoStore.getState().clearVault() useCryptoStore.setState({ isVaultLocked: true }) useVaultDialogStore.setState({ isUnlockDialogOpen: true }) + vi.mocked(useAuth).mockReturnValue({ + isAuthenticated: true, + user: mockUser, + isLoading: false, + isRestoringSession: false, + adapter: {} as import('@/shared/auth/auth.types').IAuthAdapter, + }) }) it('renders dialog when isUnlockDialogOpen is true', () => { @@ -49,7 +70,7 @@ describe('VaultUnlockDialog', () => { await user.type(screen.getByLabelText(/password/i), 'my-password') await user.click(screen.getByRole('button', { name: /unlock/i })) - expect(mockUnlockVault).toHaveBeenCalledWith('my-password') + expect(mockUnlockVault).toHaveBeenCalledWith(mockUser.id, 'my-password') }) it('shows loading spinner during unlock', async () => { diff --git a/src/features/encryption/ui/VaultUnlockDialog.tsx b/src/features/encryption/ui/VaultUnlockDialog.tsx index 067d290..8eb8816 100644 --- a/src/features/encryption/ui/VaultUnlockDialog.tsx +++ b/src/features/encryption/ui/VaultUnlockDialog.tsx @@ -7,7 +7,8 @@ import { z } from 'zod' import { useCryptoStore } from '@/shared/crypto/crypto-store' import { useVaultDialogStore } from '@/shared/crypto/vault-dialog-store' -import { unlockVault } from '@/app/flows/auth-flow' +import { useAuth } from '@/shared/auth/auth-context' +import { keyVault } from '@/shared/crypto/key-vault' 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' @@ -22,6 +23,7 @@ type UnlockFormData = z.infer function VaultUnlockDialog() { const { t } = useTranslation('crypto') + const { user } = useAuth() const isVaultLocked = useCryptoStore((s) => s.isVaultLocked) const isUnlockDialogOpen = useVaultDialogStore((s) => s.isUnlockDialogOpen) const closeUnlockDialog = useVaultDialogStore((s) => s.closeUnlockDialog) @@ -56,8 +58,12 @@ function VaultUnlockDialog() { async function onSubmit(data: UnlockFormData) { setError(null) + if (!user) { + setError('No authenticated user') + return + } try { - await unlockVault(data.password) + await keyVault.unlockVault(user.id, data.password) } catch (err) { setError(getCryptoErrorMessage(err, t)) } diff --git a/src/shared/crypto/key-vault.test.ts b/src/shared/crypto/key-vault.test.ts index 74ac663..d3ab280 100644 --- a/src/shared/crypto/key-vault.test.ts +++ b/src/shared/crypto/key-vault.test.ts @@ -1,35 +1,91 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -// Mock crypto store +import { keyVault } from '@/shared/crypto/key-vault' +import { decrypt } from '@/shared/crypto/aes-gcm' +import { importKey } from '@/shared/crypto/aes-gcm' +import { deriveKEK } from '@/shared/crypto/hkdf' +import { unwrapFieldKeys } from '@/shared/crypto/key-hierarchy' +import { DecryptionError } from '@/shared/crypto/errors' +import { derivePasswordKey } from '@/shared/crypto/argon2id' +import { fetchMasterKeyEnvelope, fetchFieldKeys } from '@/shared/api/supabase-keys' + +// Shared mock data used across legacy key vault tests +const { mockEnvelopeData, mockFieldKeysData } = vi.hoisted(() => ({ + mockEnvelopeData: { + authSalt: '01'.repeat(16), + keySalt: '02'.repeat(16), + wrappedMasterKey: '05'.repeat(48), + masterKeyIV: '06'.repeat(12), + }, + mockFieldKeysData: [ + { 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) }, + ], +})) + +// Unified mock store helpers const mockSetKeys = vi.fn() +const mockSetEnvelope = vi.fn() const mockLockVault = vi.fn() const mockClearVault = vi.fn() -const createStoreState = () => ({ +const cryptoStoreState = { loadedFieldKeys: {} as Record, isVaultLocked: true, lastActivity: 0, - cachedEnvelope: null, + cachedEnvelope: null as import('@/shared/types/api.types').CachedVaultEnvelope | null, setKeys: mockSetKeys, lockVault: mockLockVault, clearVault: mockClearVault, - setCachedEnvelope: vi.fn(), + setCachedEnvelope: mockSetEnvelope, updateActivity: vi.fn(), -}) - -vi.mock('@/shared/crypto/crypto-store', () => ({ - useCryptoStore: { - getState: vi.fn(() => createStoreState()), - }, -})) +} -// Mock argon2id (only terminateWorker is needed) +// Mocks for modules used by the key vault service vi.mock('@/shared/crypto/argon2id', () => ({ + derivePasswordKey: vi.fn().mockResolvedValue(new Uint8Array(32).fill(0x07)), terminateWorker: vi.fn(), })) -import { keyVault } from '@/shared/crypto/key-vault' +vi.mock('@/shared/crypto/crypto-utils', async () => ({ + ...(await vi.importActual('@/shared/crypto/crypto-utils')), + hexDecode: vi.fn((data: string) => new Uint8Array(data.length / 2).fill(0x05)), + zeroFill: vi.fn(), +})) +vi.mock('@/shared/crypto/aes-gcm', () => ({ + importKey: vi.fn().mockResolvedValue({} as CryptoKey), + decrypt: vi.fn().mockResolvedValue(new Uint8Array(32).fill(0x03)), +})) + +vi.mock('@/shared/crypto/key-hierarchy', () => ({ + unwrapFieldKeys: vi.fn().mockResolvedValue( + new Map([ + ['note', {} as CryptoKey], + ['website', {} as CryptoKey], + ['email', {} as CryptoKey], + ]), + ), +})) + +vi.mock('@/shared/crypto/hkdf', () => ({ + deriveKEK: vi.fn().mockResolvedValue(new Uint8Array(32).fill(0x08)), +})) + +vi.mock('@/shared/api/supabase-keys', () => ({ + fetchMasterKeyEnvelope: vi.fn().mockResolvedValue(mockEnvelopeData), + fetchFieldKeys: vi.fn().mockResolvedValue(mockFieldKeysData), +})) + +vi.mock('@/shared/crypto/crypto-store', () => ({ + useCryptoStore: { + getState: vi.fn(() => cryptoStoreState), + setState: vi.fn(), + }, +})) + +// Existing tests for the KeyVault data structure describe('key-vault', () => { beforeEach(() => { keyVault.zeroKeys() @@ -125,7 +181,8 @@ describe('key-vault', () => { ['email', emailKey], ]) - await keyVault.storeFieldKeys(kek, fieldKeys) + await keyVault.storeKey('kek', kek) + await keyVault.storeFieldKeys(fieldKeys) expect(keyVault.getKey('kek')).toBe(kek) expect(keyVault.getKey('note')).toBe(noteKey) @@ -135,24 +192,121 @@ describe('key-vault', () => { }) }) -describe('keyVault.lockVault', () => { +describe('unlockVault', () => { beforeEach(() => { vi.clearAllMocks() + keyVault.zeroKeys() + cryptoStoreState.isVaultLocked = true + cryptoStoreState.cachedEnvelope = null + keyVault.zeroKeys() }) - it('calls cryptoStore.lockVault', () => { - keyVault.lockVault() - expect(mockLockVault).toHaveBeenCalled() + it('fetches envelope and field keys after authentication', async () => { + await keyVault.unlockVault('1', 'testpass123') + + expect(fetchMasterKeyEnvelope).toHaveBeenCalledWith('1') + expect(fetchFieldKeys).toHaveBeenCalledWith('1') }) -}) -describe('keyVault.clearVault', () => { - beforeEach(() => { - vi.clearAllMocks() + it('derives KEK from password and envelope, then stores field keys', async () => { + await keyVault.unlockVault('1', 'testpass123') + + expect(derivePasswordKey).toHaveBeenCalledWith('testpass123', expect.any(Uint8Array)) + expect(importKey).toHaveBeenCalled() + expect(decrypt).toHaveBeenCalled() + expect(deriveKEK).toHaveBeenCalled() + expect(unwrapFieldKeys).toHaveBeenCalledWith(mockFieldKeysData, expect.any(Object)) + expect(mockSetKeys).toHaveBeenCalledWith(['note', 'website', 'email']) + }) + + it('caches envelope data after login', async () => { + await keyVault.unlockVault('1', 'testpass123') + + expect(mockSetEnvelope).toHaveBeenCalledWith({ + ...mockEnvelopeData, + fieldKeys: mockFieldKeysData, + }) + }) + + it('uses cached envelope when available instead of fetching from server', async () => { + const cachedEnvelope = { + authSalt: 'aa'.repeat(16), + keySalt: 'bb'.repeat(16), + wrappedMasterKey: 'cc'.repeat(48), + masterKeyIV: 'dd'.repeat(12), + fieldKeys: mockFieldKeysData, + } + cryptoStoreState.cachedEnvelope = cachedEnvelope + + await keyVault.unlockVault('1', 'testpass123') + + expect(fetchMasterKeyEnvelope).not.toHaveBeenCalled() + expect(fetchFieldKeys).not.toHaveBeenCalled() + expect(mockSetEnvelope).not.toHaveBeenCalled() + expect(derivePasswordKey).toHaveBeenCalled() + expect(mockSetKeys).toHaveBeenCalled() + }) + + it('does not call setCachedEnvelope when envelope is already cached', async () => { + const cachedEnvelope = { + authSalt: 'aa'.repeat(16), + keySalt: 'bb'.repeat(16), + wrappedMasterKey: 'cc'.repeat(48), + masterKeyIV: 'dd'.repeat(12), + fieldKeys: mockFieldKeysData, + } + cryptoStoreState.cachedEnvelope = cachedEnvelope + await keyVault.unlockVault('1', 'testpass123') + expect(mockSetEnvelope).not.toHaveBeenCalled() }) - it('calls cryptoStore.clearVault', () => { - keyVault.clearVault() + it('clears cache and retries from server on DecryptionError', async () => { + const storeFieldKeysSpy = vi.spyOn(keyVault, 'storeFieldKeys') + const cachedEnvelope = { + authSalt: 'aa'.repeat(16), + keySalt: 'bb'.repeat(16), + wrappedMasterKey: 'cc'.repeat(48), + masterKeyIV: 'dd'.repeat(12), + fieldKeys: mockFieldKeysData, + } + cryptoStoreState.cachedEnvelope = cachedEnvelope + vi.mocked(deriveKEK).mockRejectedValueOnce(new DecryptionError()) + vi.mocked(deriveKEK).mockResolvedValueOnce(new Uint8Array(32).fill(0x08)) + await keyVault.unlockVault('1', 'testpass123') expect(mockClearVault).toHaveBeenCalled() + expect(fetchMasterKeyEnvelope).toHaveBeenCalledWith('1') + expect(fetchFieldKeys).toHaveBeenCalledWith('1') + expect(mockSetEnvelope).toHaveBeenCalled() + expect(storeFieldKeysSpy).toHaveBeenCalled() + }) + + it('re-throws if retry also fails', async () => { + const cachedEnvelope = { + authSalt: 'aa'.repeat(16), + keySalt: 'bb'.repeat(16), + wrappedMasterKey: 'cc'.repeat(48), + masterKeyIV: 'dd'.repeat(12), + fieldKeys: mockFieldKeysData, + } + cryptoStoreState.cachedEnvelope = cachedEnvelope + vi.mocked(deriveKEK).mockRejectedValue(new DecryptionError()) + await expect(keyVault.unlockVault('1', 'testpass123')).rejects.toThrow(DecryptionError) + expect(mockClearVault).toHaveBeenCalled() + expect(fetchMasterKeyEnvelope).toHaveBeenCalled() + }) + + it('does not retry on non-DecryptionError', async () => { + const cachedEnvelope = { + authSalt: 'aa'.repeat(16), + keySalt: 'bb'.repeat(16), + wrappedMasterKey: 'cc'.repeat(48), + masterKeyIV: 'dd'.repeat(12), + fieldKeys: mockFieldKeysData, + } + cryptoStoreState.cachedEnvelope = cachedEnvelope + vi.mocked(derivePasswordKey).mockRejectedValueOnce(new Error('Some other error')) + await expect(keyVault.unlockVault('1', 'testpass123')).rejects.toThrow('Some other error') + expect(mockClearVault).not.toHaveBeenCalled() + expect(fetchMasterKeyEnvelope).not.toHaveBeenCalled() }) }) diff --git a/src/shared/crypto/key-vault.ts b/src/shared/crypto/key-vault.ts index 3ffe693..d7d7f01 100644 --- a/src/shared/crypto/key-vault.ts +++ b/src/shared/crypto/key-vault.ts @@ -1,4 +1,13 @@ import { useCryptoStore } from '@/shared/crypto/crypto-store' +import { fetchMasterKeyEnvelope, fetchFieldKeys } from '@/shared/api/supabase-keys' +import { hexDecode, zeroFill } from '@/shared/crypto/crypto-utils' +import { decrypt, importKey } from '@/shared/crypto/aes-gcm' +import { unwrapFieldKeys } from '@/shared/crypto/key-hierarchy' +import { deriveKEK } from '@/shared/crypto/hkdf' +import { MASTER_KEY_PASSWORD_AAD } from '@/shared/types/crypto.types' +import { derivePasswordKey } from '@/shared/crypto/argon2id' +import type { CachedVaultEnvelope } from '@/shared/types/api.types' +import { DecryptionError } from './errors' /** * Module-scoped crypto key vault. @@ -13,9 +22,7 @@ class KeyVault { this.vault.set(id, key) } - async storeFieldKeys(kek: CryptoKey, fieldKeys: Map): Promise { - this.storeKey('kek', kek) - + async storeFieldKeys(fieldKeys: Map): Promise { const fieldKeyNames: Array = [] for (const [name, key] of fieldKeys) { this.storeKey(name, key) @@ -47,6 +54,78 @@ class KeyVault { this.zeroKeys() useCryptoStore.getState().clearVault() } + + /** + * Unlock the vault by populating the KeyVault with non-extractable CryptoKey objects. + * + * Uses the cached envelope to skip network calls. If decryption fails (e.g., stale + * cache from a password change in another session), fetches fresh key material from + * the server. + */ + async unlockVault(userId: string, password: string): Promise { + let staleCache = false + const cachedEnvelope = useCryptoStore.getState().cachedEnvelope + if (cachedEnvelope) { + try { + await this.populateKeyVault(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. + this.clearVault() + staleCache = true + } else { + throw error + } + } + } + + if (!cachedEnvelope || staleCache) { + const freshEnvelope = await this.fetchFreshEnvelope(userId) + useCryptoStore.getState().setCachedEnvelope(freshEnvelope) + await this.populateKeyVault(password, freshEnvelope) + } + } + + /** + * Derives the KEK from a password and a master key envelope, unwraps field keys, + * and stores them in the KeyVault as non-extractable CryptoKeys - making the vault operational. + */ + private async populateKeyVault(password: string, envelope: CachedVaultEnvelope) { + // Store KEK and field keys in the vault (non-extractable CryptoKeys) + const kek = await this.deriveKekFromEnvelope(password, envelope) + this.storeKey('kek', kek) + const unwrappedFieldKeys = await unwrapFieldKeys(envelope.fieldKeys, kek) + this.storeFieldKeys(unwrappedFieldKeys) + } + + private async 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 } + return freshEnvelope + } + + private async 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 + } } export const keyVault = new KeyVault()