From cb7415c16f8e5de1fd2ecf9573fa5bd8c9f39cd9 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Tue, 23 Jun 2026 19:16:52 +0200 Subject: [PATCH 1/3] feat: add change password flow with re-wrapped master key --- CLAUDE.md | 2 + .../08-phase-8-recovery.md | 37 ++-- docs/implementation-plan/README.md | 2 +- src/app/layouts/ProtectedLayout.test.tsx | 9 + src/app/layouts/ProtectedLayout.tsx | 4 +- src/features/auth/model/auth-service.test.ts | 185 +++++++++++++++++- src/features/auth/model/auth-service.ts | 69 ++++++- .../change-password-error-messages.test.ts | 57 ++++++ .../model/change-password-error-messages.ts | 43 ++++ .../auth/model/change-password-schema.test.ts | 77 ++++++++ .../auth/model/change-password-schema.ts | 19 ++ .../auth/ui/ChangePasswordDialog.test.tsx | 104 ++++++++++ src/features/auth/ui/ChangePasswordDialog.tsx | 135 +++++++++++++ src/features/auth/ui/PasswordStrength.tsx | 5 +- .../settings/ui/SecuritySection.test.tsx | 20 +- src/features/settings/ui/SecuritySection.tsx | 18 +- src/shared/api/supabase-keys.test.ts | 176 ++++++++++++++++- src/shared/api/supabase-keys.ts | 38 +++- src/shared/auth/auth.types.ts | 1 + .../auth/change-password-dialog-store.test.ts | 23 +++ .../auth/change-password-dialog-store.ts | 21 ++ src/shared/auth/supabase-adapter.test.ts | 37 ++++ src/shared/auth/supabase-adapter.ts | 5 + src/shared/crypto/crypto-integration.test.ts | 17 +- src/shared/crypto/key-vault.test.ts | 19 +- src/shared/crypto/key-vault.ts | 13 +- src/shared/crypto/split-kdf.test.ts | 43 ++-- src/shared/crypto/split-kdf.ts | 15 +- src/shared/i18n/locales/cs/auth.json | 22 +++ src/shared/i18n/locales/en/auth.json | 22 +++ src/shared/types/api.types.ts | 7 + 31 files changed, 1166 insertions(+), 79 deletions(-) create mode 100644 src/features/auth/model/change-password-error-messages.test.ts create mode 100644 src/features/auth/model/change-password-error-messages.ts create mode 100644 src/features/auth/model/change-password-schema.test.ts create mode 100644 src/features/auth/model/change-password-schema.ts create mode 100644 src/features/auth/ui/ChangePasswordDialog.test.tsx create mode 100644 src/features/auth/ui/ChangePasswordDialog.tsx create mode 100644 src/shared/auth/change-password-dialog-store.test.ts create mode 100644 src/shared/auth/change-password-dialog-store.ts diff --git a/CLAUDE.md b/CLAUDE.md index cbde2b5..53d3454 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -183,6 +183,7 @@ See `IMPLEMENTATION-PLAN.md` for the full 36-step plan. - Step 26 (Auto-Save + Sync Flow) — complete - Step 27 (Supabase Realtime Adapter) — complete - Step 28 (Multi-Device Session Handling) — complete +- Step 29 (Change Password Flow + UI) — complete ### Implementation Notes @@ -212,5 +213,6 @@ Non-obvious decisions not visible from code alone: - **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. - **`AuthError` vs `ApiError` domain boundary**: `AuthError` (in `shared/auth/auth-errors.ts`) covers authentication errors (`INVALID_CREDENTIALS`, `USERNAME_TAKEN`, `NETWORK_ERROR`, `UNEXPECTED`). `ApiError` (in `shared/api/api-errors.ts`) covers data-layer errors (`NETWORK_ERROR`, `NOT_FOUND`, `UNEXPECTED`). `fetchLoginSalts` throws `AuthError` because it's a pre-auth RPC that's part of the login flow; all other data queries throw `ApiError`. Each domain has its own `wrapXxxError` that classifies raw errors using the shared `isNetworkError` helper. - **Network errors can bypass the adapter boundary**: `isNetworkError` (in `shared/lib/network-errors.ts`) is shared by both `wrapAuthError` and `wrapApiError`. Raw `TypeError('Failed to fetch')` from the browser can reach the UI without being wrapped by any adapter, so `getAuthErrorMessage` in `auth-error-messages.ts` also calls `isNetworkError` as a final fallback. +- **Change password rollback**: `changeUserPassword` in `auth-service.ts` is a 4-step flow (derive → upload envelope → update auth → update store). If step 3 (Supabase Auth update) fails after step 2 (DB envelope upload) succeeds, it attempts to roll back the DB write with the old envelope values. If rollback also fails, it forces logout to prevent inconsistent state. - **Stale KEK detection**: Stale KEK from a password change on another device is detected in two places: (1) `use-realtime-sync.ts` `onKeyRotation` — when a key rotation event arrives, `syncFieldKeys` tries to unwrap with the current KEK; a `DecryptionError` means the KEK is stale, so `clearVault()` forces re-auth. (2) `key-vault.ts` `unlockVault` — if the cached envelope is stale, `clearVault` + retry from server. The save path (`useSaveField`) cannot produce a `DecryptionError` — `encryptField` only encrypts, and `getFieldKey` throws a generic `Error` when the vault is locked, not `DecryptionError`. diff --git a/docs/implementation-plan/08-phase-8-recovery.md b/docs/implementation-plan/08-phase-8-recovery.md index 5801430..78c2535 100644 --- a/docs/implementation-plan/08-phase-8-recovery.md +++ b/docs/implementation-plan/08-phase-8-recovery.md @@ -1,29 +1,32 @@ # Phase 8: Password & Recovery -## Step 29 — Change Password Flow + UI +## Step 29 — Change Password Flow + UI ✅ **Goal:** Change password without re-encrypting field data (only re-wrap master key). **Code:** -- `src/shared/crypto/change-password.ts`: - - Pure crypto function: derive new split KDF, unwrap master key with old password key, re-wrap with new password key - - No side effects (same pattern as `registration-crypto.ts`) -- `src/features/settings/ui/ChangePasswordDialog.tsx`: - - Current password input + new password input + confirm new password - - Validation: new password must differ from current, confirm matches - - On submit: - 1. Call change-password crypto function - 2. Upload new wrapped master key + new salts to server +- Validate `changePassword` pure crypto function in split-kdf module: + - Derive old password key from existing `keySalt`, unwrap master key + - Generate fresh `authSalt` and `keySalt`, derive new credentials via `deriveAuthCredentials` + - Re-wrap master key with new password key, return new auth hash + salts + wrapped master key + IV + - Zero out sensitive intermediate values +- `src/features/auth/ui/ChangePasswordDialog.tsx`: + - Current password input + new password input (with password strength) + confirm new password + - Zod schema validation: required fields, min length, new password must differ from current, passwords must match + - On submit — 4-step orchestration: + 1. Call pure crypto function to derive new credentials + 2. Upload new key envelope to server 3. Update Supabase Auth password (new auth_hash) - 4. Update crypto store with new keys - - Show success/error feedback -- Add i18n strings to `settings.json` for password change + 4. Update cached envelope in crypto store + - Rollback: if step 3 fails after step 2 succeeds, attempt DB rollback with old envelope values; if rollback also fails, force logout + - Error mapping: `DecryptionError` → incorrect password, `AuthErrorCode` / `ApiErrorCode` → appropriate messages, network error fallback + - Show toast notifications for success/error feedback +- `change-password-dialog-store.ts` — Zustand store for dialog open/close state (rendered at app layout level, triggered from SecuritySection) +- Add i18n strings to `auth.json` (not `settings.json` — this is an auth feature) **Tests:** -- Integration: change password → logout → login with new password → verify vault unlocks -- Integration: change password → old password no longer works -- Component test: form validation works -- Security test: field data is unchanged after password change (no re-encryption) +- Integration: change password → old password can no longer unwrap master key; field keys still decrypt unchanged data +- Component test: form validation rejects mismatched/empty/too-short passwords --- diff --git a/docs/implementation-plan/README.md b/docs/implementation-plan/README.md index 496cab1..5556129 100644 --- a/docs/implementation-plan/README.md +++ b/docs/implementation-plan/README.md @@ -66,7 +66,7 @@ This is the implementation plan for Cipher Note, an end-to-end encrypted note-ta - [x] Step 26 — Auto-Save + Sync Flow - [x] Step 27 — Supabase Realtime Adapter - [x] Step 28 — Multi-Device Session Handling -- [ ] Step 29 — Change Password Flow + UI +- [x] Step 29 — Change Password Flow + UI - [ ] Step 30 — Seed Phrase Backup View - [ ] Step 31 — Seed Phrase Recovery Flow + UI - [ ] Step 32 — Key Rotation + UI diff --git a/src/app/layouts/ProtectedLayout.test.tsx b/src/app/layouts/ProtectedLayout.test.tsx index 79103ed..40717e4 100644 --- a/src/app/layouts/ProtectedLayout.test.tsx +++ b/src/app/layouts/ProtectedLayout.test.tsx @@ -17,6 +17,10 @@ vi.mock('@/features/vault/ui/VaultUnlockDialog', () => ({ VaultUnlockDialog: () => React.createElement('div', { 'data-testid': 'vault-unlock-dialog' }), })) +vi.mock('@/features/auth/ui/ChangePasswordDialog', () => ({ + ChangePasswordDialog: () => React.createElement('div', { 'data-testid': 'change-password-dialog' }), +})) + vi.mock('@/features/vault/model/vault-timeout', () => ({ useVaultTimeout: () => {}, })) @@ -71,4 +75,9 @@ describe('ProtectedLayout', () => { render() expect(screen.getByTestId('vault-unlock-dialog')).toBeInTheDocument() }) + + it('renders change password dialog', () => { + render() + expect(screen.getByTestId('change-password-dialog')).toBeInTheDocument() + }) }) diff --git a/src/app/layouts/ProtectedLayout.tsx b/src/app/layouts/ProtectedLayout.tsx index 1e62bec..87bc175 100644 --- a/src/app/layouts/ProtectedLayout.tsx +++ b/src/app/layouts/ProtectedLayout.tsx @@ -9,6 +9,7 @@ import { MobileNav } from './MobileNav' import { ResizeHandle } from '@/shared/ui/nav/ResizeHandle' import { VaultIndicator } from '@/features/vault/ui/VaultIndicator' import { VaultUnlockDialog } from '@/features/vault/ui/VaultUnlockDialog' +import { ChangePasswordDialog } from '@/features/auth/ui/ChangePasswordDialog' import { OfflineBanner } from '@/shared/ui/OfflineBanner' import { useLayoutStore } from './layout-store' import { useResizable } from '@/shared/lib/use-resizable' @@ -79,8 +80,9 @@ function ProtectedLayout() { {/* Mobile bottom navigation */} - {/* Vault unlock dialog */} + {/* Dialogs */} + ) } diff --git a/src/features/auth/model/auth-service.test.ts b/src/features/auth/model/auth-service.test.ts index c10c163..26802c3 100644 --- a/src/features/auth/model/auth-service.test.ts +++ b/src/features/auth/model/auth-service.test.ts @@ -56,6 +56,16 @@ vi.mock('@/shared/api/supabase-registration', () => ({ })) // Mock Supabase keys +const { mockFetchedEnvelope } = vi.hoisted(() => ({ + mockFetchedEnvelope: { + authSalt: 'f1e2d3c4'.repeat(4), + keySalt: 'b5a6g7h8'.repeat(4), + wrappedMasterKey: 'ff'.repeat(48), + masterKeyIV: 'ee'.repeat(12), + fieldKeys: [] as import('@/shared/types/api.types').ServerFieldKey[], + }, +})) + vi.mock('@/shared/api/supabase-keys', () => ({ fetchLoginSalts: vi.fn().mockResolvedValue({ authSalt: '01'.repeat(16), @@ -63,6 +73,8 @@ vi.mock('@/shared/api/supabase-keys', () => ({ }), fetchMasterKeyEnvelope: vi.fn(), fetchFieldKeys: vi.fn(), + fetchFreshEnvelope: vi.fn().mockResolvedValue(mockFetchedEnvelope), + updateMasterKeyEnvelope: vi.fn().mockResolvedValue(undefined), })) // Mock Argon2id @@ -95,6 +107,7 @@ vi.mock('@/shared/auth/supabase-adapter', () => ({ logout: vi.fn().mockResolvedValue(undefined), getSession: vi.fn().mockResolvedValue(null), onAuthStateChange: vi.fn().mockReturnValue(vi.fn()), + updatePassword: vi.fn().mockResolvedValue(undefined), }, })) @@ -135,22 +148,39 @@ vi.mock('@/shared/crypto/key-vault-service', () => ({ populateKeyVault: vi.fn().mockResolvedValue(undefined), })) +// Mock split-kdf changePassword (hoisted mock — factory must not reference external variables) +vi.mock('@/shared/crypto/split-kdf', async () => { + const actual = await vi.importActual('@/shared/crypto/split-kdf') + return { + ...actual, + changePassword: vi.fn().mockResolvedValue({ + newAuthHash: 'newhash'.padEnd(64, '0'), + newAuthSalt: new Uint8Array(16).fill(0x11), + newKeySalt: new Uint8Array(16).fill(0x22), + newWrappedMasterKey: new Uint8Array(48).fill(0x33), + newMasterKeyIV: new Uint8Array(12).fill(0x44), + }), + } +}) + import { signUpUser, loginUser, logoutUser, restoreSession, subscribeToAuthChanges, + changeUserPassword, } 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 } from '@/shared/api/supabase-keys' +import { fetchLoginSalts, updateMasterKeyEnvelope, fetchFreshEnvelope } 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 { terminateWorker } from '@/shared/crypto/argon2id' +import { changePassword } from '@/shared/crypto/split-kdf' describe('signUpUser', () => { beforeEach(() => { @@ -479,3 +509,156 @@ describe('subscribeToAuthChanges', () => { expect(terminateWorker).toHaveBeenCalled() }) }) + +describe('changeUserPassword', () => { + const mockEnvelope = { + authSalt: 'a1b2c3d4'.repeat(4), + keySalt: 'e5f6g7h8'.repeat(4), + wrappedMasterKey: 'aa'.repeat(48), + masterKeyIV: 'bb'.repeat(12), + fieldKeys: [], + } + + const mockChangeResult = { + newAuthHash: 'newhash'.padEnd(64, '0'), + newAuthSalt: new Uint8Array(16).fill(0x11) as Uint8Array, + newKeySalt: new Uint8Array(16).fill(0x22) as Uint8Array, + newWrappedMasterKey: new Uint8Array(48).fill(0x33) as Uint8Array, + newMasterKeyIV: new Uint8Array(12).fill(0x44) as Uint8Array, + } + + beforeEach(() => { + vi.clearAllMocks() + // Reset crypto store mock with envelope + cryptoStoreState.cachedEnvelope = mockEnvelope as unknown as import('@/shared/types/api.types').CachedVaultEnvelope + // Reset auth store mock with user + vi.mocked(useAuthStore.getState).mockReturnValue({ + setLoading: mockSetLoading, + setAuth: mockSetAuth, + setRestoringSession: mockSetRestoringSession, + reset: mockReset, + isRestoringSession: false, + user: { id: 'user-1', username: 'testuser', createdAt: '2024-01-01' }, + session: { accessToken: 'tok', expiresAt: 0 }, + setUser: vi.fn(), + setSession: vi.fn(), + isLoading: false, + }) + }) + + it('calls changePassword with envelope and passwords', async () => { + vi.mocked(changePassword).mockResolvedValueOnce(mockChangeResult) + vi.mocked(updateMasterKeyEnvelope).mockResolvedValueOnce(undefined) + vi.mocked(authAdapter.updatePassword).mockResolvedValueOnce(undefined) + + await changeUserPassword('oldPassword', 'newPassword') + + expect(changePassword).toHaveBeenCalledWith('oldPassword', 'newPassword', mockEnvelope) + }) + + it('uploads new key envelope to DB', async () => { + vi.mocked(changePassword).mockResolvedValueOnce(mockChangeResult) + vi.mocked(updateMasterKeyEnvelope).mockResolvedValueOnce(undefined) + vi.mocked(authAdapter.updatePassword).mockResolvedValueOnce(undefined) + + await changeUserPassword('oldPassword', 'newPassword') + + expect(updateMasterKeyEnvelope).toHaveBeenCalledWith('user-1', { + authSalt: '11111111111111111111111111111111', + keySalt: '22222222222222222222222222222222', + wrappedMasterKey: '33'.repeat(48), + masterKeyIV: '44'.repeat(12), + }) + }) + + it('updates Supabase Auth password with new auth hash', async () => { + vi.mocked(changePassword).mockResolvedValueOnce(mockChangeResult) + vi.mocked(updateMasterKeyEnvelope).mockResolvedValueOnce(undefined) + vi.mocked(authAdapter.updatePassword).mockResolvedValueOnce(undefined) + + await changeUserPassword('oldPassword', 'newPassword') + + expect(authAdapter.updatePassword).toHaveBeenCalledWith(mockChangeResult.newAuthHash) + }) + + it('updates cached envelope after success', async () => { + vi.mocked(changePassword).mockResolvedValueOnce(mockChangeResult) + vi.mocked(updateMasterKeyEnvelope).mockResolvedValueOnce(undefined) + vi.mocked(authAdapter.updatePassword).mockResolvedValueOnce(undefined) + + await changeUserPassword('oldPassword', 'newPassword') + + expect(mockSetEnvelope).toHaveBeenCalledWith( + expect.objectContaining({ + authSalt: '11111111111111111111111111111111', + keySalt: '22222222222222222222222222222222', + wrappedMasterKey: '33'.repeat(48), + masterKeyIV: '44'.repeat(12), + }), + ) + }) + + 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(changeUserPassword('oldPassword', 'newPassword')).rejects.toThrow('No authenticated user') + }) + + it('fetches vault envelope when cache is empty', async () => { + cryptoStoreState.cachedEnvelope = null + vi.mocked(changePassword).mockResolvedValueOnce(mockChangeResult) + vi.mocked(updateMasterKeyEnvelope).mockResolvedValueOnce(undefined) + vi.mocked(authAdapter.updatePassword).mockResolvedValueOnce(undefined) + + await changeUserPassword('oldPassword', 'newPassword') + + expect(fetchFreshEnvelope).toHaveBeenCalledWith('user-1') + expect(changePassword).toHaveBeenCalledWith('oldPassword', 'newPassword', mockFetchedEnvelope) + }) + + it('throws when fetchFreshEnvelope fails and no cache exists', async () => { + cryptoStoreState.cachedEnvelope = null + vi.mocked(fetchFreshEnvelope).mockRejectedValueOnce(new Error('Network error')) + + await expect(changeUserPassword('oldPassword', 'newPassword')).rejects.toThrow('Network error') + }) + + it('rolls back DB on auth update failure', async () => { + vi.mocked(changePassword).mockResolvedValueOnce(mockChangeResult) + vi.mocked(updateMasterKeyEnvelope).mockResolvedValueOnce(undefined) + vi.mocked(authAdapter.updatePassword).mockRejectedValueOnce(new AuthError(AuthErrorCode.NETWORK_ERROR)) + // Rollback call + vi.mocked(updateMasterKeyEnvelope).mockResolvedValueOnce(undefined) + + await expect(changeUserPassword('oldPassword', 'newPassword')).rejects.toThrow() + + // First call: upload new data; second call: rollback with old data + expect(updateMasterKeyEnvelope).toHaveBeenCalledTimes(2) + expect(updateMasterKeyEnvelope).toHaveBeenNthCalledWith(2, 'user-1', { + authSalt: mockEnvelope.authSalt, + keySalt: mockEnvelope.keySalt, + wrappedMasterKey: mockEnvelope.wrappedMasterKey, + masterKeyIV: mockEnvelope.masterKeyIV, + }) + }) + + it('throws DB error when DB update fails', async () => { + vi.mocked(changePassword).mockResolvedValueOnce(mockChangeResult) + const dbError = new Error('DB update failed') + vi.mocked(updateMasterKeyEnvelope).mockRejectedValueOnce(dbError) + + await expect(changeUserPassword('oldPassword', 'newPassword')).rejects.toThrow('DB update failed') + expect(authAdapter.updatePassword).not.toHaveBeenCalled() + }) +}) diff --git a/src/features/auth/model/auth-service.ts b/src/features/auth/model/auth-service.ts index 00bc322..f703122 100644 --- a/src/features/auth/model/auth-service.ts +++ b/src/features/auth/model/auth-service.ts @@ -3,9 +3,10 @@ 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 } from '@/shared/api/supabase-keys' +import { fetchLoginSalts, updateMasterKeyEnvelope, fetchFreshEnvelope } from '@/shared/api/supabase-keys' import { hexDecode, hexEncode } from '@/shared/crypto/crypto-utils' import { deriveAuthHash, terminateWorker } from '@/shared/crypto/argon2id' +import { changePassword } from '@/shared/crypto/split-kdf' import { keyVault } from '@/shared/crypto/key-vault' /** @@ -82,6 +83,72 @@ export async function loginUser(username: string, password: string) { } } +/** + * Changes the user's password by re-wrapping the master key. + * + * The master key itself never changes — only its wrapping with the new + * password-derived key. Field keys (encrypted with KEK) are unaffected. + * + * Flow: + * 1. Pure crypto: unwrap master key with old password, re-wrap with new + * 2. Upload new key envelope to DB + * 3. Update Supabase Auth password (new auth hash) + * 4. Update cached envelope in crypto store + * + * If step 3 fails after step 2 succeeds, attempts to roll back the DB update + * with the old envelope values. If rollback also fails, forces logout. + */ +export async function changeUserPassword(currentPassword: string, newPassword: string): Promise { + const { user } = useAuthStore.getState() + + if (!user) throw new Error('No authenticated user') + + const envelope = useCryptoStore.getState().cachedEnvelope ?? (await fetchFreshEnvelope(user.id)) + + // Step 1: Pure crypto — derive new credentials and re-wrap master key + const result = await changePassword(currentPassword, newPassword, envelope) + + // Step 2: Upload new key envelope to DB + const updateData = { + authSalt: hexEncode(result.newAuthSalt), + keySalt: hexEncode(result.newKeySalt), + wrappedMasterKey: hexEncode(result.newWrappedMasterKey), + masterKeyIV: hexEncode(result.newMasterKeyIV), + } + + await updateMasterKeyEnvelope(user.id, updateData) + + // Step 3: Update Supabase Auth password + try { + await authAdapter.updatePassword(result.newAuthHash) + } catch (authError) { + // Auth update failed — DB has new keys but auth still uses old hash. + // Attempt rollback of DB update. + try { + await updateMasterKeyEnvelope(user.id, { + authSalt: envelope.authSalt, + keySalt: envelope.keySalt, + wrappedMasterKey: envelope.wrappedMasterKey, + masterKeyIV: envelope.masterKeyIV, + }) + } catch { + // Rollback failed — force logout to prevent inconsistent state + await logoutUser() + throw new Error('Password update partially failed. Please log in again.') + } + throw authError + } + + // Step 4: Update cached envelope with new values + useCryptoStore.getState().setCachedEnvelope({ + ...envelope, + authSalt: updateData.authSalt, + keySalt: updateData.keySalt, + wrappedMasterKey: updateData.wrappedMasterKey, + masterKeyIV: updateData.masterKeyIV, + }) +} + function logoutCleanup() { keyVault.clearVault() useAuthStore.getState().reset() diff --git a/src/features/auth/model/change-password-error-messages.test.ts b/src/features/auth/model/change-password-error-messages.test.ts new file mode 100644 index 0000000..d775352 --- /dev/null +++ b/src/features/auth/model/change-password-error-messages.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest' +import { getChangePasswordErrorMessage } from '@/features/auth/model/change-password-error-messages' +import { AuthError, AuthErrorCode } from '@/shared/auth/auth-errors' +import { ApiError, ApiErrorCode } from '@/shared/api/api-errors' +import { DecryptionError } from '@/shared/crypto/errors' +import type { TFunction } from 'i18next' + +// Mock t function that returns the key — typed to satisfy TFunction<'auth'> +const mockT = ((key: string) => key) as unknown as TFunction<'auth'> + +describe('getChangePasswordErrorMessage', () => { + it('maps DecryptionError to wrongCurrentPassword', () => { + const message = getChangePasswordErrorMessage(new DecryptionError(), mockT) + expect(message).toBe('changePassword.errors.wrongCurrentPassword') + }) + + it('maps AuthError INVALID_CREDENTIALS to authFailed', () => { + const message = getChangePasswordErrorMessage(new AuthError(AuthErrorCode.INVALID_CREDENTIALS), mockT) + expect(message).toBe('changePassword.errors.authFailed') + }) + + it('maps AuthError NETWORK_ERROR to networkError', () => { + const message = getChangePasswordErrorMessage(new AuthError(AuthErrorCode.NETWORK_ERROR), mockT) + expect(message).toBe('changePassword.errors.networkError') + }) + + it('maps AuthError UNEXPECTED to unexpectedError', () => { + const message = getChangePasswordErrorMessage(new AuthError(AuthErrorCode.UNEXPECTED), mockT) + expect(message).toBe('changePassword.errors.unexpectedError') + }) + + it('maps ApiError NETWORK_ERROR to networkError', () => { + const message = getChangePasswordErrorMessage(new ApiError(ApiErrorCode.NETWORK_ERROR), mockT) + expect(message).toBe('changePassword.errors.networkError') + }) + + it('maps ApiError NOT_FOUND to notFound', () => { + const message = getChangePasswordErrorMessage(new ApiError(ApiErrorCode.NOT_FOUND), mockT) + expect(message).toBe('changePassword.errors.notFound') + }) + + it('maps ApiError UNEXPECTED to unexpectedError', () => { + const message = getChangePasswordErrorMessage(new ApiError(ApiErrorCode.UNEXPECTED), mockT) + expect(message).toBe('changePassword.errors.unexpectedError') + }) + + it('maps network errors to networkError', () => { + const networkError = new TypeError('Failed to fetch') + const message = getChangePasswordErrorMessage(networkError, mockT) + expect(message).toBe('changePassword.errors.networkError') + }) + + it('maps unknown errors to unexpectedError', () => { + const message = getChangePasswordErrorMessage(new Error('unknown'), mockT) + expect(message).toBe('changePassword.errors.unexpectedError') + }) +}) diff --git a/src/features/auth/model/change-password-error-messages.ts b/src/features/auth/model/change-password-error-messages.ts new file mode 100644 index 0000000..ec689d0 --- /dev/null +++ b/src/features/auth/model/change-password-error-messages.ts @@ -0,0 +1,43 @@ +import type { TFunction } from 'i18next' +import { isNetworkError } from '@/shared/lib/network-errors' +import { isAuthError, AuthErrorCode } from '@/shared/auth/auth-errors' +import { isApiError, ApiErrorCode } from '@/shared/api/api-errors' +import { DecryptionError } from '@/shared/crypto/errors' + +/** + * Maps errors from the change password flow to user-facing i18n strings + * in the 'auth' namespace. + */ +export function getChangePasswordErrorMessage(error: unknown, t: TFunction<'auth'>): string { + if (error instanceof DecryptionError) { + return t('changePassword.errors.wrongCurrentPassword') + } + + if (isAuthError(error)) { + switch (error.code) { + case AuthErrorCode.INVALID_CREDENTIALS: + return t('changePassword.errors.authFailed') + case AuthErrorCode.NETWORK_ERROR: + return t('changePassword.errors.networkError') + default: + return t('changePassword.errors.unexpectedError') + } + } + + if (isApiError(error)) { + switch (error.code) { + case ApiErrorCode.NETWORK_ERROR: + return t('changePassword.errors.networkError') + case ApiErrorCode.NOT_FOUND: + return t('changePassword.errors.notFound') + default: + return t('changePassword.errors.unexpectedError') + } + } + + if (isNetworkError(error)) { + return t('changePassword.errors.networkError') + } + + return t('changePassword.errors.unexpectedError') +} diff --git a/src/features/auth/model/change-password-schema.test.ts b/src/features/auth/model/change-password-schema.test.ts new file mode 100644 index 0000000..8efb243 --- /dev/null +++ b/src/features/auth/model/change-password-schema.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest' +import { changePasswordSchema } from '@/features/auth/model/change-password-schema' + +describe('changePasswordSchema', () => { + it('passes with valid data', async () => { + const result = await changePasswordSchema.safeParseAsync({ + currentPassword: 'oldPassword123', + newPassword: 'newPassword456', + confirmPassword: 'newPassword456', + }) + expect(result.success).toBe(true) + }) + + it('fails when current password is empty', async () => { + const result = await changePasswordSchema.safeParseAsync({ + currentPassword: '', + newPassword: 'newPassword456', + confirmPassword: 'newPassword456', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.some((i) => i.path[0] === 'currentPassword')).toBe(true) + } + }) + + it('fails when new password is too short', async () => { + const result = await changePasswordSchema.safeParseAsync({ + currentPassword: 'oldPassword', + newPassword: 'short', + confirmPassword: 'short', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.some((i) => i.path[0] === 'newPassword')).toBe(true) + } + }) + + it('fails when new password matches current password', async () => { + const result = await changePasswordSchema.safeParseAsync({ + currentPassword: 'samePassword', + newPassword: 'samePassword', + confirmPassword: 'samePassword', + }) + expect(result.success).toBe(false) + if (!result.success) { + const sameError = result.error.issues.find((i) => i.message === 'changePassword.errors.sameAsCurrent') + expect(sameError).toBeDefined() + expect(sameError!.path).toContain('newPassword') + } + }) + + it('fails when confirm password does not match', async () => { + const result = await changePasswordSchema.safeParseAsync({ + currentPassword: 'oldPassword', + newPassword: 'newPassword1', + confirmPassword: 'newPassword2', + }) + expect(result.success).toBe(false) + if (!result.success) { + const mismatchError = result.error.issues.find((i) => i.message === 'changePassword.errors.passwordMismatch') + expect(mismatchError).toBeDefined() + expect(mismatchError!.path).toContain('confirmPassword') + } + }) + + it('fails when confirm password is empty', async () => { + const result = await changePasswordSchema.safeParseAsync({ + currentPassword: 'oldPassword', + newPassword: 'newPassword1', + confirmPassword: '', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.some((i) => i.path[0] === 'confirmPassword')).toBe(true) + } + }) +}) diff --git a/src/features/auth/model/change-password-schema.ts b/src/features/auth/model/change-password-schema.ts new file mode 100644 index 0000000..6ee815a --- /dev/null +++ b/src/features/auth/model/change-password-schema.ts @@ -0,0 +1,19 @@ +import { z } from 'zod' +import { PASSWORD_MIN_LENGTH } from '@/shared/auth/password-utils' + +export const changePasswordSchema = z + .object({ + currentPassword: z.string().min(1, 'changePassword.errors.currentRequired'), + newPassword: z.string().min(PASSWORD_MIN_LENGTH, 'changePassword.errors.newPasswordMin'), + confirmPassword: z.string().min(1, 'changePassword.errors.confirmRequired'), + }) + .refine((data) => data.newPassword !== data.currentPassword, { + message: 'changePassword.errors.sameAsCurrent', + path: ['newPassword'], + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: 'changePassword.errors.passwordMismatch', + path: ['confirmPassword'], + }) + +export type ChangePasswordFormData = z.infer diff --git a/src/features/auth/ui/ChangePasswordDialog.test.tsx b/src/features/auth/ui/ChangePasswordDialog.test.tsx new file mode 100644 index 0000000..f51a63d --- /dev/null +++ b/src/features/auth/ui/ChangePasswordDialog.test.tsx @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@/test/utils' +import userEvent from '@testing-library/user-event' + +// Mock the auth service module +vi.mock('@/features/auth/model/auth-service', () => ({ + changeUserPassword: vi.fn(), +})) + +import { ChangePasswordDialog } from './ChangePasswordDialog' +import { changeUserPassword } from '@/features/auth/model/auth-service' +import { useChangePasswordDialogStore } from '@/shared/auth/change-password-dialog-store' + +const mockChangeUserPassword = vi.mocked(changeUserPassword) + +describe('ChangePasswordDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + useChangePasswordDialogStore.setState({ isChangePasswordDialogOpen: true }) + }) + + it('renders the dialog with all form fields when open', () => { + render() + + expect(screen.getByLabelText('Current password')).toBeInTheDocument() + expect(screen.getByLabelText('New password')).toBeInTheDocument() + expect(screen.getByLabelText('Confirm new password')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Change password' })).toBeInTheDocument() + }) + + it('does not render form fields when closed', () => { + useChangePasswordDialogStore.setState({ isChangePasswordDialogOpen: false }) + render() + + expect(screen.queryByLabelText('Current password')).not.toBeInTheDocument() + }) + + it('shows validation errors on empty submit', async () => { + const user = userEvent.setup() + render() + + const submitButton = screen.getByRole('button', { name: 'Change password' }) + await user.click(submitButton) + + // Should show validation errors for required fields + await vi.waitFor(() => { + expect(screen.getAllByRole('alert').length).toBeGreaterThan(0) + }) + }) + + it('calls changeUserPassword on valid form submission', async () => { + const user = userEvent.setup() + mockChangeUserPassword.mockResolvedValue(undefined) + + render() + + await user.type(screen.getByLabelText('Current password'), 'oldPassword123') + await user.type(screen.getByLabelText('New password'), 'newPassword456') + await user.type(screen.getByLabelText('Confirm new password'), 'newPassword456') + + const submitButton = screen.getByRole('button', { name: 'Change password' }) + await user.click(submitButton) + + await vi.waitFor(() => { + expect(mockChangeUserPassword).toHaveBeenCalledWith('oldPassword123', 'newPassword456') + }) + }) + + it('closes the dialog after successful submission', async () => { + const user = userEvent.setup() + mockChangeUserPassword.mockResolvedValue(undefined) + + render() + + await user.type(screen.getByLabelText('Current password'), 'oldPassword123') + await user.type(screen.getByLabelText('New password'), 'newPassword456') + await user.type(screen.getByLabelText('Confirm new password'), 'newPassword456') + + const submitButton = screen.getByRole('button', { name: 'Change password' }) + await user.click(submitButton) + + await vi.waitFor(() => { + expect(useChangePasswordDialogStore.getState().isChangePasswordDialogOpen).toBe(false) + }) + }) + + it('shows error toast when changeUserPassword fails', async () => { + const user = userEvent.setup() + mockChangeUserPassword.mockRejectedValue(new Error('Test error')) + + render() + + await user.type(screen.getByLabelText('Current password'), 'oldPassword123') + await user.type(screen.getByLabelText('New password'), 'newPassword456') + await user.type(screen.getByLabelText('Confirm new password'), 'newPassword456') + + const submitButton = screen.getByRole('button', { name: 'Change password' }) + await user.click(submitButton) + + await vi.waitFor(() => { + expect(mockChangeUserPassword).toHaveBeenCalled() + }) + }) +}) diff --git a/src/features/auth/ui/ChangePasswordDialog.tsx b/src/features/auth/ui/ChangePasswordDialog.tsx new file mode 100644 index 0000000..464bf4e --- /dev/null +++ b/src/features/auth/ui/ChangePasswordDialog.tsx @@ -0,0 +1,135 @@ +import { useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useForm, useWatch } from 'react-hook-form' +import { standardSchemaResolver } from '@hookform/resolvers/standard-schema' +import { Loader2 } from 'lucide-react' +import { toast } from 'sonner' + +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/shared/ui/dialog' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { FormField } from '@/shared/ui/form/FormField' +import { PasswordStrength } from '@/features/auth/ui/PasswordStrength' +import { changePasswordSchema, type ChangePasswordFormData } from '@/features/auth/model/change-password-schema' +import { changeUserPassword } from '@/features/auth/model/auth-service' +import { getChangePasswordErrorMessage } from '@/features/auth/model/change-password-error-messages' +import { useChangePasswordDialogStore } from '@/shared/auth/change-password-dialog-store' + +function ChangePasswordDialog() { + const { t } = useTranslation('auth') + const isChangePasswordDialogOpen = useChangePasswordDialogStore((s) => s.isChangePasswordDialogOpen) + const closeChangePasswordDialog = useChangePasswordDialogStore((s) => s.closeChangePasswordDialog) + const [passwordFocused, setPasswordFocused] = useState(false) + const cardRef = useRef(null) + + const { + register, + handleSubmit, + control, + formState: { errors, isSubmitting }, + reset, + } = useForm({ + resolver: standardSchemaResolver(changePasswordSchema), + defaultValues: { + currentPassword: '', + newPassword: '', + confirmPassword: '', + }, + }) + + const watchedNewPassword = useWatch({ control, name: 'newPassword' }) + + async function onFormSubmit(data: ChangePasswordFormData) { + try { + await changeUserPassword(data.currentPassword, data.newPassword) + toast.success(t('changePassword.success')) + reset() + closeChangePasswordDialog() + } catch (error) { + toast.error(getChangePasswordErrorMessage(error, t)) + } + } + + const { ref: passwordRef, ...passwordRest } = register('newPassword') + + return ( + { + if (!open) closeChangePasswordDialog() + }} + > + + + {t('changePassword.title')} + {t('changePassword.description')} + + +
+ + + + + + setPasswordFocused(true)} + onBlur={(e: React.FocusEvent) => { + passwordRest.onBlur(e) + setPasswordFocused(false) + }} + /> + + + + + + + + + +
+
+ ) +} + +export { ChangePasswordDialog } diff --git a/src/features/auth/ui/PasswordStrength.tsx b/src/features/auth/ui/PasswordStrength.tsx index 6319abe..735ef47 100644 --- a/src/features/auth/ui/PasswordStrength.tsx +++ b/src/features/auth/ui/PasswordStrength.tsx @@ -24,9 +24,10 @@ interface PasswordStrengthProps { open: boolean onOpenChange: (open: boolean) => void anchorRef: RefObject + container?: RefObject } -function PasswordStrength({ password, open, onOpenChange, anchorRef }: PasswordStrengthProps) { +function PasswordStrength({ password, open, onOpenChange, anchorRef, container }: PasswordStrengthProps) { const { t } = useTranslation('auth') const { score, criteria } = useMemo(() => calculateStrength(password), [password]) @@ -35,7 +36,7 @@ function PasswordStrength({ password, open, onOpenChange, anchorRef }: PasswordS return ( 0} onOpenChange={onOpenChange}> - +
diff --git a/src/features/settings/ui/SecuritySection.test.tsx b/src/features/settings/ui/SecuritySection.test.tsx index b2fd5eb..1f362e9 100644 --- a/src/features/settings/ui/SecuritySection.test.tsx +++ b/src/features/settings/ui/SecuritySection.test.tsx @@ -1,9 +1,15 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { render, screen } from '@/test/utils' +import userEvent from '@testing-library/user-event' +import { useChangePasswordDialogStore } from '@/shared/auth/change-password-dialog-store' import { SecuritySection } from './SecuritySection' describe('SecuritySection', () => { + beforeEach(() => { + useChangePasswordDialogStore.setState({ isChangePasswordDialogOpen: false }) + }) + it('renders section title and description', () => { render() expect(screen.getByText('Security')).toBeInTheDocument() @@ -17,9 +23,19 @@ describe('SecuritySection', () => { expect(screen.getByText('Key versions')).toBeInTheDocument() }) - it('renders three separator dividers between action items', () => { + it('renders two separator dividers between action items', () => { render() const separators = screen.getAllByRole('separator') expect(separators).toHaveLength(2) }) + + it('opens change password dialog when clicking "Change password"', async () => { + const user = userEvent.setup() + render() + + const changePasswordButton = screen.getByText('Change password') + await user.click(changePasswordButton) + + expect(useChangePasswordDialogStore.getState().isChangePasswordDialogOpen).toBe(true) + }) }) diff --git a/src/features/settings/ui/SecuritySection.tsx b/src/features/settings/ui/SecuritySection.tsx index b44824d..b5ee067 100644 --- a/src/features/settings/ui/SecuritySection.tsx +++ b/src/features/settings/ui/SecuritySection.tsx @@ -4,6 +4,7 @@ import { ChevronRight, KeyRound, ShieldCheck, Fingerprint, type LucideIcon } fro import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/shared/ui/card' import { Separator } from '@/shared/ui/separator' +import { useChangePasswordDialogStore } from '@/shared/auth/change-password-dialog-store' const ITEMS: { icon: LucideIcon; labelKey: string }[] = [ { icon: KeyRound, labelKey: 'security.changePassword' }, @@ -11,9 +12,15 @@ const ITEMS: { icon: LucideIcon; labelKey: string }[] = [ { icon: Fingerprint, labelKey: 'security.keyVersions' }, ] -function SecurityItem({ icon: Icon, label }: { icon: LucideIcon; label: string }) { +function SecurityItem({ icon: Icon, label, onClick }: { icon: LucideIcon; label: string; onClick?: () => void }) { return ( -
+
e.key === 'Enter' && onClick() : undefined} + > {label} @@ -25,6 +32,7 @@ function SecurityItem({ icon: Icon, label }: { icon: LucideIcon; label: string } function SecuritySection() { const { t } = useTranslation('settings') + const openChangePasswordDialog = useChangePasswordDialogStore((s) => s.openChangePasswordDialog) return ( @@ -36,7 +44,11 @@ function SecuritySection() { {ITEMS.map((item, i) => ( {i > 0 && } - + ))} diff --git a/src/shared/api/supabase-keys.test.ts b/src/shared/api/supabase-keys.test.ts index 17a4eba..a8692cf 100644 --- a/src/shared/api/supabase-keys.test.ts +++ b/src/shared/api/supabase-keys.test.ts @@ -10,6 +10,7 @@ const mockSelect = vi.fn() const mockEq = vi.fn() const mockSingle = vi.fn() const mockUpsert = vi.fn() +const mockUpdate = vi.fn() vi.mock('@/shared/api/supabase-client', () => ({ getSupabase: () => ({ @@ -18,7 +19,14 @@ vi.mock('@/shared/api/supabase-client', () => ({ }), })) -import { fetchLoginSalts, fetchMasterKeyEnvelope, fetchFieldKeys, saveWrappedKey } from '@/shared/api/supabase-keys' +import { + fetchLoginSalts, + fetchMasterKeyEnvelope, + fetchFieldKeys, + fetchFreshEnvelope, + saveWrappedKey, + updateMasterKeyEnvelope, +} from '@/shared/api/supabase-keys' describe('fetchLoginSalts', () => { beforeEach(() => { @@ -248,6 +256,120 @@ describe('fetchFieldKeys', () => { }) }) +describe('fetchFreshEnvelope', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('combines master key envelope and field keys', async () => { + // Use separate mock functions for each query chain to avoid shared state issues + const keysSelect = vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: mockSingle, + }), + }) + const keysFrom = vi.fn().mockReturnValue({ select: keysSelect }) + + const fieldKeysEq = vi.fn().mockResolvedValue({ + data: [ + { field_name: 'note', version: 1, wrapped_key: 'cc'.repeat(48), key_iv: 'dd'.repeat(12) }, + { field_name: 'title', version: 1, wrapped_key: 'ee'.repeat(48), key_iv: 'ff'.repeat(12) }, + ], + error: null, + }) + const fieldKeysSelect = vi.fn().mockReturnValue({ eq: fieldKeysEq }) + const fieldKeysFrom = vi.fn().mockReturnValue({ select: fieldKeysSelect }) + + mockSingle.mockResolvedValueOnce({ + data: { + auth_salt: 'a1b2c3d4'.repeat(4), + key_salt: 'e5f6g7h8'.repeat(4), + wrapped_master_key: 'aa'.repeat(48), + master_key_iv: 'bb'.repeat(12), + }, + error: null, + }) + + // First call returns keys chain, second returns field_keys chain + mockFrom.mockImplementation((table: string) => { + if (table === 'keys') return keysFrom() + return fieldKeysFrom() + }) + + const result = await fetchFreshEnvelope('user-1') + + expect(result).toEqual({ + authSalt: 'a1b2c3d4'.repeat(4), + keySalt: 'e5f6g7h8'.repeat(4), + wrappedMasterKey: 'aa'.repeat(48), + masterKeyIV: 'bb'.repeat(12), + fieldKeys: [ + { fieldName: 'note', version: 1, wrappedKey: 'cc'.repeat(48), keyIV: 'dd'.repeat(12) }, + { fieldName: 'title', version: 1, wrappedKey: 'ee'.repeat(48), keyIV: 'ff'.repeat(12) }, + ], + }) + }) + + it('propagates errors from fetchMasterKeyEnvelope', async () => { + mockFrom.mockReturnValue({ + select: mockSelect.mockReturnValue({ + eq: mockEq.mockReturnValue({ + single: mockSingle, + }), + }), + }) + + mockSingle.mockResolvedValueOnce({ + data: null, + error: { message: 'Query error' }, + }) + + try { + await fetchFreshEnvelope('user-1') + expect.unreachable('should have thrown') + } catch (e) { + expect(e).toBeInstanceOf(ApiError) + } + }) + + it('propagates errors from fetchFieldKeys', async () => { + // Use separate mock functions for the keys query to avoid polluting shared mocks + const keysSelect = vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: mockSingle, + }), + }) + + mockSingle.mockResolvedValueOnce({ + data: { + auth_salt: 'a1b2c3d4'.repeat(4), + key_salt: 'e5f6g7h8'.repeat(4), + wrapped_master_key: 'aa'.repeat(48), + master_key_iv: 'bb'.repeat(12), + }, + error: null, + }) + + const fieldKeysEq = vi.fn().mockResolvedValue({ + data: null, + error: { message: 'Query error' }, + }) + const fieldKeysSelect = vi.fn().mockReturnValue({ eq: fieldKeysEq }) + + mockFrom.mockImplementation((table: string) => { + if (table === 'keys') return { select: keysSelect } + return { select: fieldKeysSelect } + }) + + try { + await fetchFreshEnvelope('user-1') + expect.unreachable('should have thrown') + } catch (e) { + expect(e).toBeInstanceOf(ApiError) + } + }) +}) + describe('saveWrappedKey', () => { beforeEach(() => { vi.clearAllMocks() @@ -300,3 +422,55 @@ describe('saveWrappedKey', () => { } }) }) + +describe('updateMasterKeyEnvelope', () => { + beforeEach(() => { + vi.clearAllMocks() + + mockFrom.mockReturnValue({ + update: mockUpdate.mockReturnValue({ + eq: mockEq, + }), + }) + }) + + it('updates keys table with correct data', async () => { + mockEq.mockResolvedValueOnce({ data: null, error: null }) + + await updateMasterKeyEnvelope('user-1', { + authSalt: 'a1b2c3d4'.repeat(4), + keySalt: 'e5f6g7h8'.repeat(4), + wrappedMasterKey: 'aa'.repeat(48), + masterKeyIV: 'bb'.repeat(12), + }) + + expect(mockFrom).toHaveBeenCalledWith('keys') + expect(mockUpdate).toHaveBeenCalledWith({ + auth_salt: 'a1b2c3d4'.repeat(4), + key_salt: 'e5f6g7h8'.repeat(4), + wrapped_master_key: 'aa'.repeat(48), + master_key_iv: 'bb'.repeat(12), + }) + expect(mockEq).toHaveBeenCalledWith('user_id', 'user-1') + }) + + it('throws ApiError on update error', async () => { + mockEq.mockResolvedValueOnce({ + data: null, + error: { message: 'Update failed' }, + }) + + try { + await updateMasterKeyEnvelope('user-1', { + authSalt: 'a1b2c3d4'.repeat(4), + keySalt: 'e5f6g7h8'.repeat(4), + wrappedMasterKey: 'aa'.repeat(48), + masterKeyIV: 'bb'.repeat(12), + }) + expect.unreachable('should have thrown') + } catch (e) { + expect(e).toBeInstanceOf(ApiError) + expect((e as ApiError).code).toBe(ApiErrorCode.UNEXPECTED) + } + }) +}) diff --git a/src/shared/api/supabase-keys.ts b/src/shared/api/supabase-keys.ts index c0d9d2c..233ae3c 100644 --- a/src/shared/api/supabase-keys.ts +++ b/src/shared/api/supabase-keys.ts @@ -2,7 +2,13 @@ import { getSupabase } from '@/shared/api/supabase-client' import { USERNAME_PATTERN } from '@/shared/auth/username-utils' import { AuthError, AuthErrorCode, wrapAuthError } from '@/shared/auth/auth-errors' import { ApiError, ApiErrorCode, wrapApiError } from '@/shared/api/api-errors' -import type { ServerMasterKeyEnvelope, ServerFieldKey, SaveWrappedKeyData } from '@/shared/types/api.types' +import type { + ServerMasterKeyEnvelope, + ServerFieldKey, + CachedVaultEnvelope, + SaveWrappedKeyData, + UpdateMasterKeyEnvelopeData, +} from '@/shared/types/api.types' import { FIELD_KEYS_TABLE } from '@/shared/types/supabase-schema' export interface LoginSalts { @@ -78,6 +84,17 @@ export async function fetchFieldKeys(userId: string): Promise })) } +/** + * Fetch fresh master key envelope + field keys. + */ +export 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 fieldKeys = await fetchFieldKeys(userId) + return { ...masterKeyEnvelope, fieldKeys } +} + /** * Upsert a wrapped field key for a user. * Uses onConflict to handle the unique (user_id, field_name, version) constraint. @@ -97,3 +114,22 @@ export async function saveWrappedKey(userId: string, data: SaveWrappedKeyData): if (error) throw wrapApiError(error) } + +/** + * Update the user's key envelope (auth_salt, key_salt, wrapped_master_key, master_key_iv). + * Used after a password change to store the re-wrapped master key. + */ +export async function updateMasterKeyEnvelope(userId: string, data: UpdateMasterKeyEnvelopeData): Promise { + const supabase = getSupabase() + const { error } = await supabase + .from('keys') + .update({ + auth_salt: data.authSalt, + key_salt: data.keySalt, + wrapped_master_key: data.wrappedMasterKey, + master_key_iv: data.masterKeyIV, + }) + .eq('user_id', userId) + + if (error) throw wrapApiError(error) +} diff --git a/src/shared/auth/auth.types.ts b/src/shared/auth/auth.types.ts index d482aea..63b96ae 100644 --- a/src/shared/auth/auth.types.ts +++ b/src/shared/auth/auth.types.ts @@ -19,6 +19,7 @@ export interface IAuthAdapter { getSession(): Promise signup(username: string, authHash: string): Promise recoverPassword(username: string, recoveryData: RecoveryCredentials): Promise + updatePassword(newAuthHash: string): Promise onAuthStateChange(callback: AuthStateChangeCallback): AuthUnsubscribe } diff --git a/src/shared/auth/change-password-dialog-store.test.ts b/src/shared/auth/change-password-dialog-store.test.ts new file mode 100644 index 0000000..c6e3b7c --- /dev/null +++ b/src/shared/auth/change-password-dialog-store.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useChangePasswordDialogStore } from './change-password-dialog-store' + +describe('change-password-dialog-store', () => { + beforeEach(() => { + useChangePasswordDialogStore.setState({ isChangePasswordDialogOpen: false }) + }) + + it('initializes with dialog closed', () => { + expect(useChangePasswordDialogStore.getState().isChangePasswordDialogOpen).toBe(false) + }) + + it('openChangePasswordDialog sets isChangePasswordDialogOpen to true', () => { + useChangePasswordDialogStore.getState().openChangePasswordDialog() + expect(useChangePasswordDialogStore.getState().isChangePasswordDialogOpen).toBe(true) + }) + + it('closeChangePasswordDialog sets isChangePasswordDialogOpen to false', () => { + useChangePasswordDialogStore.getState().openChangePasswordDialog() + useChangePasswordDialogStore.getState().closeChangePasswordDialog() + expect(useChangePasswordDialogStore.getState().isChangePasswordDialogOpen).toBe(false) + }) +}) diff --git a/src/shared/auth/change-password-dialog-store.ts b/src/shared/auth/change-password-dialog-store.ts new file mode 100644 index 0000000..e7d33c3 --- /dev/null +++ b/src/shared/auth/change-password-dialog-store.ts @@ -0,0 +1,21 @@ +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' + +interface ChangePasswordDialogStore { + isChangePasswordDialogOpen: boolean + openChangePasswordDialog: () => void + closeChangePasswordDialog: () => void +} + +const useChangePasswordDialogStore = create()( + devtools( + (set) => ({ + isChangePasswordDialogOpen: false, + openChangePasswordDialog: () => set({ isChangePasswordDialogOpen: true }, false, 'changePasswordDialog/open'), + closeChangePasswordDialog: () => set({ isChangePasswordDialogOpen: false }, false, 'changePasswordDialog/close'), + }), + { name: 'ChangePasswordDialogStore' }, + ), +) + +export { useChangePasswordDialogStore } diff --git a/src/shared/auth/supabase-adapter.test.ts b/src/shared/auth/supabase-adapter.test.ts index 58e1c8d..14ef6f1 100644 --- a/src/shared/auth/supabase-adapter.test.ts +++ b/src/shared/auth/supabase-adapter.test.ts @@ -97,6 +97,7 @@ const mockSignInWithPassword = vi.fn() const mockSignOut = vi.fn() const mockGetSession = vi.fn() const mockOnAuthStateChange = vi.fn() +const mockUpdateUser = vi.fn() vi.mock('@/shared/api/supabase-client', () => ({ getSupabase: () => ({ @@ -106,6 +107,7 @@ vi.mock('@/shared/api/supabase-client', () => ({ signOut: mockSignOut, getSession: mockGetSession, onAuthStateChange: mockOnAuthStateChange, + updateUser: mockUpdateUser, }, }), })) @@ -250,3 +252,38 @@ describe('SupabaseAuthAdapter — onAuthStateChange', () => { expect(receivedResults[0]).toBeNull() }) }) + +describe('SupabaseAuthAdapter — updatePassword', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('calls updateUser with new auth hash', async () => { + const { authAdapter } = await import('./supabase-adapter') + + mockUpdateUser.mockResolvedValueOnce({ data: { user: {} }, error: null }) + + await authAdapter.updatePassword('newauthhash12345678901234567890123456789012345678901234567890123456') + + expect(mockUpdateUser).toHaveBeenCalledWith({ + password: 'newauthhash12345678901234567890123456789012345678901234567890123456', + }) + }) + + it('throws AuthError on update failure', async () => { + const { authAdapter } = await import('./supabase-adapter') + + mockUpdateUser.mockResolvedValueOnce({ + data: null, + error: { status: 400, code: 'invalid_credentials', message: 'Invalid credentials' }, + }) + + try { + await authAdapter.updatePassword('newauthhash') + expect.unreachable('should have thrown') + } catch (e) { + expect(e).toBeInstanceOf(AuthError) + expect((e as AuthError).code).toBe(AuthErrorCode.INVALID_CREDENTIALS) + } + }) +}) diff --git a/src/shared/auth/supabase-adapter.ts b/src/shared/auth/supabase-adapter.ts index 861ab3c..d69608c 100644 --- a/src/shared/auth/supabase-adapter.ts +++ b/src/shared/auth/supabase-adapter.ts @@ -63,6 +63,11 @@ class SupabaseAuthAdapter implements IAuthAdapter { throw new AuthError(AuthErrorCode.UNEXPECTED) } + async updatePassword(newAuthHash: string): Promise { + const { error } = await getSupabase().auth.updateUser({ password: newAuthHash }) + if (error) throw wrapSupabaseAuthError(error) + } + onAuthStateChange(callback: AuthStateChangeCallback): AuthUnsubscribe { const { data } = getSupabase().auth.onAuthStateChange((_event, supabaseSession) => { if (!supabaseSession) { diff --git a/src/shared/crypto/crypto-integration.test.ts b/src/shared/crypto/crypto-integration.test.ts index c4d40c0..1980c7d 100644 --- a/src/shared/crypto/crypto-integration.test.ts +++ b/src/shared/crypto/crypto-integration.test.ts @@ -13,7 +13,7 @@ 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' +import type { ServerFieldKey, ServerMasterKeyEnvelope } from '../types/api.types' // Mock Argon2id module — Web Worker won't run in jsdom vi.mock('@/shared/crypto/argon2id', () => ({ @@ -230,13 +230,14 @@ describe('crypto integration', () => { vi.mocked(deriveAuthHash).mockResolvedValue('b'.repeat(64)) vi.mocked(generateSalt).mockReturnValueOnce(newAuthSalt).mockReturnValueOnce(newKeySalt) - const result = await changePassword( - PASSWORD, - 'new-password-456', - authCreds.keySalt, - wrappedMasterKey, - masterKeyIV, - ) + const envelope: ServerMasterKeyEnvelope = { + authSalt: hexEncode(authCreds.authSalt), + keySalt: hexEncode(authCreds.keySalt), + wrappedMasterKey: hexEncode(wrappedMasterKey), + masterKeyIV: hexEncode(masterKeyIV), + } + + const result = await changePassword(PASSWORD, 'new-password-456', envelope) const newCryptoKey = await importKey(mockBytes(32, NEW_PASSWORD_KEY_FILL)) const unwrapped = await decrypt(result.newWrappedMasterKey, newCryptoKey, { diff --git a/src/shared/crypto/key-vault.test.ts b/src/shared/crypto/key-vault.test.ts index 070fda6..77e9e1a 100644 --- a/src/shared/crypto/key-vault.test.ts +++ b/src/shared/crypto/key-vault.test.ts @@ -9,7 +9,7 @@ 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' +import { fetchFreshEnvelope, fetchFieldKeys } from '@/shared/api/supabase-keys' // Shared mock data used across legacy key vault tests const { mockEnvelopeData, mockFieldKeysData } = vi.hoisted(() => ({ @@ -76,7 +76,7 @@ vi.mock('@/shared/crypto/hkdf', () => ({ })) vi.mock('@/shared/api/supabase-keys', () => ({ - fetchMasterKeyEnvelope: vi.fn().mockResolvedValue(mockEnvelopeData), + fetchFreshEnvelope: vi.fn().mockResolvedValue({ ...mockEnvelopeData, fieldKeys: mockFieldKeysData }), fetchFieldKeys: vi.fn().mockResolvedValue(mockFieldKeysData), })) @@ -203,11 +203,10 @@ describe('unlockVault', () => { keyVault.zeroKeys() }) - it('fetches envelope and field keys after authentication', async () => { + it('fetches vault envelope after authentication', async () => { await keyVault.unlockVault('1', 'testpass123') - expect(fetchMasterKeyEnvelope).toHaveBeenCalledWith('1') - expect(fetchFieldKeys).toHaveBeenCalledWith('1') + expect(fetchFreshEnvelope).toHaveBeenCalledWith('1') }) it('derives KEK from password and envelope, then stores field keys', async () => { @@ -242,8 +241,7 @@ describe('unlockVault', () => { await keyVault.unlockVault('1', 'testpass123') - expect(fetchMasterKeyEnvelope).not.toHaveBeenCalled() - expect(fetchFieldKeys).not.toHaveBeenCalled() + expect(fetchFreshEnvelope).not.toHaveBeenCalled() expect(mockSetEnvelope).not.toHaveBeenCalled() expect(derivePasswordKey).toHaveBeenCalled() expect(mockMarkKeysLoaded).toHaveBeenCalled() @@ -276,8 +274,7 @@ describe('unlockVault', () => { 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(fetchFreshEnvelope).toHaveBeenCalledWith('1') expect(mockSetEnvelope).toHaveBeenCalled() expect(storeFieldKeysSpy).toHaveBeenCalled() }) @@ -294,7 +291,7 @@ describe('unlockVault', () => { vi.mocked(deriveKEK).mockRejectedValue(new DecryptionError()) await expect(keyVault.unlockVault('1', 'testpass123')).rejects.toThrow(DecryptionError) expect(mockClearVault).toHaveBeenCalled() - expect(fetchMasterKeyEnvelope).toHaveBeenCalled() + expect(fetchFreshEnvelope).toHaveBeenCalled() }) it('does not retry on non-DecryptionError', async () => { @@ -309,7 +306,7 @@ describe('unlockVault', () => { 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() + expect(fetchFreshEnvelope).not.toHaveBeenCalled() }) }) diff --git a/src/shared/crypto/key-vault.ts b/src/shared/crypto/key-vault.ts index 7897b78..1033f38 100644 --- a/src/shared/crypto/key-vault.ts +++ b/src/shared/crypto/key-vault.ts @@ -1,5 +1,5 @@ import { useCryptoStore } from '@/shared/crypto/crypto-store' -import { fetchMasterKeyEnvelope, fetchFieldKeys } from '@/shared/api/supabase-keys' +import { fetchFieldKeys, fetchFreshEnvelope } 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' @@ -117,7 +117,7 @@ class KeyVault { } if (!cachedEnvelope || staleCache) { - const freshEnvelope = await this.fetchFreshEnvelope(userId) + const freshEnvelope = await fetchFreshEnvelope(userId) useCryptoStore.getState().setCachedEnvelope(freshEnvelope) await this.populateKeyVault(password, freshEnvelope) } @@ -135,15 +135,6 @@ class KeyVault { 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)) diff --git a/src/shared/crypto/split-kdf.test.ts b/src/shared/crypto/split-kdf.test.ts index d7145f2..b7c9f68 100644 --- a/src/shared/crypto/split-kdf.test.ts +++ b/src/shared/crypto/split-kdf.test.ts @@ -5,6 +5,7 @@ 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, PasswordChangeResult } from '@/shared/types/crypto.types' +import type { ServerMasterKeyEnvelope } from '@/shared/types/api.types' // Mock Argon2id module to avoid WASM/worker dependency in tests vi.mock('@/shared/crypto/argon2id', () => ({ @@ -19,7 +20,7 @@ vi.mock('@/shared/crypto/crypto-utils', async () => ({ ...(await vi.importActual('@/shared/crypto/crypto-utils')), generateSalt: vi.fn(), })) -import { generateIV, generateSalt } from '@/shared/crypto/crypto-utils' +import { generateIV, generateSalt, hexEncode } from '@/shared/crypto/crypto-utils' function mockBytes(length: number, fill: number): Uint8Array { return new Uint8Array(length).fill(fill) as Uint8Array @@ -83,6 +84,20 @@ describe('split-kdf', () => { return { wrappedMasterKey, iv } } + /** Build a ServerMasterKeyEnvelope from raw bytes (hex-encodes the values). */ + function makeEnvelope( + keySalt: Uint8Array, + wrapped: Uint8Array, + iv: Uint8Array, + ): ServerMasterKeyEnvelope { + return { + authSalt: hexEncode(mockBytes(16, 0xaa)), + keySalt: hexEncode(keySalt), + wrappedMasterKey: hexEncode(wrapped), + masterKeyIV: hexEncode(iv), + } + } + const OLD_KEY_FILL = 0x11 const NEW_KEY_FILL = 0x22 @@ -94,14 +109,9 @@ describe('split-kdf', () => { vi.mocked(deriveAuthHash).mockResolvedValue('newhash'.padEnd(64, '0')) const { wrappedMasterKey, iv: oldIV } = await wrapMasterKey(masterKey, OLD_KEY_FILL) + const envelope = makeEnvelope(mockBytes(16, 0x02), wrappedMasterKey, oldIV) - const result: PasswordChangeResult = await changePassword( - 'oldPassword', - 'newPassword', - mockBytes(16, 0x02), - wrappedMasterKey, - oldIV, - ) + const result: PasswordChangeResult = await changePassword('oldPassword', 'newPassword', envelope) const newWrappingKey = await importKey(mockBytes(32, NEW_KEY_FILL)) const unwrappedMasterKey = await decrypt(result.newWrappedMasterKey, newWrappingKey, { @@ -122,8 +132,9 @@ describe('split-kdf', () => { vi.mocked(generateSalt).mockReturnValueOnce(newAuthSalt).mockReturnValueOnce(newKeySalt) const { wrappedMasterKey, iv: oldIV } = await wrapMasterKey(masterKey, OLD_KEY_FILL) + const envelope = makeEnvelope(mockBytes(16, 0x02), wrappedMasterKey, oldIV) - const result = await changePassword('oldPw', 'newPw', mockBytes(16, 0x02), wrappedMasterKey, oldIV) + const result = await changePassword('oldPw', 'newPw', envelope) expect(result.newAuthSalt).toEqual(newAuthSalt) expect(result.newKeySalt).toEqual(newKeySalt) @@ -139,8 +150,9 @@ describe('split-kdf', () => { vi.mocked(generateSalt).mockReturnValueOnce(mockBytes(16, 0xaa)).mockReturnValueOnce(mockBytes(16, 0xbb)) const { wrappedMasterKey, iv: oldIV } = await wrapMasterKey(masterKey, OLD_KEY_FILL) + const envelope = makeEnvelope(mockBytes(16, 0x02), wrappedMasterKey, oldIV) - const result = await changePassword('oldPw', 'newPw', mockBytes(16, 0x02), wrappedMasterKey, oldIV) + const result = await changePassword('oldPw', 'newPw', envelope) expect(result.newAuthHash).toBe('newauthhash00000000000000000000000000000000000000000000000') }) @@ -153,10 +165,9 @@ describe('split-kdf', () => { // Wrap master key with a DIFFERENT key than the mock returns const masterKey = generateMasterKey() const { wrappedMasterKey, iv: oldIV } = await wrapMasterKey(masterKey, OLD_KEY_FILL) + const envelope = makeEnvelope(mockBytes(16, 0x02), wrappedMasterKey, oldIV) - await expect( - changePassword('wrongPassword', 'newPw', mockBytes(16, 0x02), wrappedMasterKey, oldIV), - ).rejects.toThrow(DecryptionError) + await expect(changePassword('wrongPassword', 'newPw', envelope)).rejects.toThrow(DecryptionError) }) it('calls generateSalt twice for new salts', async () => { @@ -168,8 +179,9 @@ describe('split-kdf', () => { vi.mocked(generateSalt).mockReturnValueOnce(mockBytes(16, 0xaa)).mockReturnValueOnce(mockBytes(16, 0xbb)) const { wrappedMasterKey, iv: oldIV } = await wrapMasterKey(masterKey, OLD_KEY_FILL) + const envelope = makeEnvelope(mockBytes(16, 0x02), wrappedMasterKey, oldIV) - await changePassword('oldPw', 'newPw', mockBytes(16, 0x02), wrappedMasterKey, oldIV) + await changePassword('oldPw', 'newPw', envelope) expect(generateSalt).toHaveBeenCalledTimes(2) }) @@ -183,8 +195,9 @@ describe('split-kdf', () => { vi.mocked(generateSalt).mockReturnValueOnce(mockBytes(16, 0xaa)).mockReturnValueOnce(mockBytes(16, 0xbb)) const { wrappedMasterKey, iv: oldIV } = await wrapMasterKey(masterKey, OLD_KEY_FILL) + const envelope = makeEnvelope(mockBytes(16, 0x02), wrappedMasterKey, oldIV) - const result = await changePassword('oldPw', 'newPw', mockBytes(16, 0x02), wrappedMasterKey, oldIV) + const result = await changePassword('oldPw', 'newPw', envelope) const newWrappingKey = await importKey(mockBytes(32, NEW_KEY_FILL)) const unwrapped = await decrypt(result.newWrappedMasterKey, newWrappingKey, { diff --git a/src/shared/crypto/split-kdf.ts b/src/shared/crypto/split-kdf.ts index 4d66661..c749982 100644 --- a/src/shared/crypto/split-kdf.ts +++ b/src/shared/crypto/split-kdf.ts @@ -10,9 +10,10 @@ 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 { generateSalt, generateIV, hexDecode, zeroFill } from '@/shared/crypto/crypto-utils' import { MASTER_KEY_PASSWORD_AAD } from '@/shared/types/crypto.types' import type { AuthCredentials, PasswordChangeResult } from '@/shared/types/crypto.types' +import type { ServerMasterKeyEnvelope } from '@/shared/types/api.types' /** * Derive authentication credentials for a new registration. @@ -36,14 +37,20 @@ export async function deriveAuthCredentials(password: string): Promise, - wrappedMasterKey: Uint8Array, - masterKeyIV: Uint8Array, + envelope: ServerMasterKeyEnvelope, ): Promise { + const keySalt = hexDecode(envelope.keySalt) + const wrappedMasterKey = hexDecode(envelope.wrappedMasterKey) + const masterKeyIV = hexDecode(envelope.masterKeyIV) + // Derive old password key and unwrap master key const oldPasswordKey = await derivePasswordKey(oldPassword, keySalt) const oldWrappingKey = await importKey(oldPasswordKey) diff --git a/src/shared/i18n/locales/cs/auth.json b/src/shared/i18n/locales/cs/auth.json index e730bbe..d7e4b57 100644 --- a/src/shared/i18n/locales/cs/auth.json +++ b/src/shared/i18n/locales/cs/auth.json @@ -72,5 +72,27 @@ "mnemonicFailed": "Neplatná seed fráze. Zkontrolujte prosím seed frázi a zkuste to znovu.", "loginSaltsNotFound": "Účet nenalezen. Zkontrolujte prosím uživatelské jméno.", "keysNotFound": "Šifrovací klíče nenalezeny. Kontaktujte prosím podporu." + }, + "changePassword": { + "title": "Změnit heslo", + "description": "Aktualizujte své heslo. Vaše šifrovaná data zůstanou přístupná.", + "currentPassword": "Aktuální heslo", + "newPassword": "Nové heslo", + "confirmPassword": "Potvrďte nové heslo", + "submit": "Změnit heslo", + "submitting": "Měním heslo...", + "success": "Heslo bylo úspěšně změněno", + "errors": { + "currentRequired": "Aktuální heslo je povinné", + "newPasswordMin": "Nové heslo musí mít alespoň 8 znaků", + "confirmRequired": "Potvrďte nové heslo", + "sameAsCurrent": "Nové heslo musí být jiné než aktuální heslo", + "passwordMismatch": "Hesla se neshodují", + "wrongCurrentPassword": "Aktuální heslo je nesprávné", + "authFailed": "Nepodařilo se aktualizovat ověření. Zkuste to znovu.", + "networkError": "Chyba sítě. Zkuste to znovu.", + "notFound": "Účet nenalezen. Přihlaste se znovu.", + "unexpectedError": "Došlo k neočekávané chybě. Zkuste to znovu." + } } } diff --git a/src/shared/i18n/locales/en/auth.json b/src/shared/i18n/locales/en/auth.json index 5a44fe2..0000005 100644 --- a/src/shared/i18n/locales/en/auth.json +++ b/src/shared/i18n/locales/en/auth.json @@ -72,5 +72,27 @@ "mnemonicFailed": "Invalid seed phrase. Please check your seed phrase and try again.", "loginSaltsNotFound": "Account not found. Please check your username.", "keysNotFound": "Encryption keys not found. Please contact support." + }, + "changePassword": { + "title": "Change Password", + "description": "Update your password. Your encrypted data will remain accessible.", + "currentPassword": "Current password", + "newPassword": "New password", + "confirmPassword": "Confirm new password", + "submit": "Change password", + "submitting": "Changing password...", + "success": "Password changed successfully", + "errors": { + "currentRequired": "Current password is required", + "newPasswordMin": "New password must be at least 8 characters", + "confirmRequired": "Please confirm your new password", + "sameAsCurrent": "New password must be different from current password", + "passwordMismatch": "Passwords do not match", + "wrongCurrentPassword": "Current password is incorrect", + "authFailed": "Failed to update authentication. Please try again.", + "networkError": "Network error. Please try again.", + "notFound": "Account not found. Please log in again.", + "unexpectedError": "An unexpected error occurred. Please try again." + } } } diff --git a/src/shared/types/api.types.ts b/src/shared/types/api.types.ts index b80408d..fbea766 100644 --- a/src/shared/types/api.types.ts +++ b/src/shared/types/api.types.ts @@ -51,3 +51,10 @@ export interface SaveRecoveryData { wrappedMasterKey: string recoveryIV: string } + +export interface UpdateMasterKeyEnvelopeData { + authSalt: string + keySalt: string + wrappedMasterKey: string + masterKeyIV: string +} From cb3af19561ab8b4f44ca21461c0a96369ec5acb2 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Wed, 24 Jun 2026 09:23:40 +0200 Subject: [PATCH 2/3] fix: improve change password dialog reset on close, button accessibility, and submit button clarity --- src/features/auth/ui/ChangePasswordDialog.tsx | 8 +++- .../settings/ui/SecuritySection.test.tsx | 13 ++++++- src/features/settings/ui/SecuritySection.tsx | 39 ++++++++++++------- 3 files changed, 42 insertions(+), 18 deletions(-) diff --git a/src/features/auth/ui/ChangePasswordDialog.tsx b/src/features/auth/ui/ChangePasswordDialog.tsx index 464bf4e..9f77cfd 100644 --- a/src/features/auth/ui/ChangePasswordDialog.tsx +++ b/src/features/auth/ui/ChangePasswordDialog.tsx @@ -56,7 +56,11 @@ function ChangePasswordDialog() { { - if (!open) closeChangePasswordDialog() + if (!open) { + reset() + setPasswordFocused(false) + closeChangePasswordDialog() + } }} > @@ -123,7 +127,7 @@ function ChangePasswordDialog() { diff --git a/src/features/settings/ui/SecuritySection.test.tsx b/src/features/settings/ui/SecuritySection.test.tsx index 1f362e9..7659eaa 100644 --- a/src/features/settings/ui/SecuritySection.test.tsx +++ b/src/features/settings/ui/SecuritySection.test.tsx @@ -33,9 +33,20 @@ describe('SecuritySection', () => { const user = userEvent.setup() render() - const changePasswordButton = screen.getByText('Change password') + const changePasswordButton = screen.getByRole('button', { name: /Change password/i }) await user.click(changePasswordButton) expect(useChangePasswordDialogStore.getState().isChangePasswordDialogOpen).toBe(true) }) + + it('opens change password dialog with Space key', async () => { + const user = userEvent.setup() + render() + + const changePasswordButton = screen.getByRole('button', { name: /Change password/i }) + changePasswordButton.focus() + await user.keyboard(' ') + + expect(useChangePasswordDialogStore.getState().isChangePasswordDialogOpen).toBe(true) + }) }) diff --git a/src/features/settings/ui/SecuritySection.tsx b/src/features/settings/ui/SecuritySection.tsx index b5ee067..a8a648b 100644 --- a/src/features/settings/ui/SecuritySection.tsx +++ b/src/features/settings/ui/SecuritySection.tsx @@ -6,21 +6,35 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/sha import { Separator } from '@/shared/ui/separator' import { useChangePasswordDialogStore } from '@/shared/auth/change-password-dialog-store' -const ITEMS: { icon: LucideIcon; labelKey: string }[] = [ - { icon: KeyRound, labelKey: 'security.changePassword' }, +const ITEMS: { icon: LucideIcon; labelKey: string; onClick?: () => void }[] = [ + { + icon: KeyRound, + labelKey: 'security.changePassword', + onClick: () => useChangePasswordDialogStore.getState().openChangePasswordDialog(), + }, { icon: ShieldCheck, labelKey: 'security.seedPhrase' }, { icon: Fingerprint, labelKey: 'security.keyVersions' }, ] function SecurityItem({ icon: Icon, label, onClick }: { icon: LucideIcon; label: string; onClick?: () => void }) { + if (onClick) { + return ( + + ) + } + return ( -
e.key === 'Enter' && onClick() : undefined} - > +
{label} @@ -32,7 +46,6 @@ function SecurityItem({ icon: Icon, label, onClick }: { icon: LucideIcon; label: function SecuritySection() { const { t } = useTranslation('settings') - const openChangePasswordDialog = useChangePasswordDialogStore((s) => s.openChangePasswordDialog) return ( @@ -44,11 +57,7 @@ function SecuritySection() { {ITEMS.map((item, i) => ( {i > 0 && } - + ))} From c7d85a27785caaecb6e4ea04c3c0dcc133435fb8 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Wed, 24 Jun 2026 09:47:38 +0200 Subject: [PATCH 3/3] feat: add preventClose prop to Dialog, block close during submission --- .../auth/ui/ChangePasswordDialog.test.tsx | 28 ++++++++++++++++ src/features/auth/ui/ChangePasswordDialog.tsx | 1 + .../vault/ui/VaultUnlockDialog.test.tsx | 24 ++++++++++++++ src/features/vault/ui/VaultUnlockDialog.tsx | 2 +- src/shared/ui/dialog.tsx | 33 ++++++++++++++++--- 5 files changed, 83 insertions(+), 5 deletions(-) diff --git a/src/features/auth/ui/ChangePasswordDialog.test.tsx b/src/features/auth/ui/ChangePasswordDialog.test.tsx index f51a63d..83f8320 100644 --- a/src/features/auth/ui/ChangePasswordDialog.test.tsx +++ b/src/features/auth/ui/ChangePasswordDialog.test.tsx @@ -101,4 +101,32 @@ describe('ChangePasswordDialog', () => { expect(mockChangeUserPassword).toHaveBeenCalled() }) }) + + it('hides close button and blocks Escape during submission', async () => { + let resolveChange: () => void = () => {} + mockChangeUserPassword.mockReturnValue( + new Promise((resolve) => { + resolveChange = resolve + }), + ) + const user = userEvent.setup() + render() + + await user.type(screen.getByLabelText('Current password'), 'oldPassword123') + await user.type(screen.getByLabelText('New password'), 'newPassword456') + await user.type(screen.getByLabelText('Confirm new password'), 'newPassword456') + + const submitButton = screen.getByRole('button', { name: 'Change password' }) + await user.click(submitButton) + + // Close button should be hidden during submission + expect(screen.queryByRole('button', { name: /close/i })).not.toBeInTheDocument() + + // Escape should not close the dialog + await user.keyboard('{Escape}') + expect(useChangePasswordDialogStore.getState().isChangePasswordDialogOpen).toBe(true) + + // Clean up: resolve the promise + resolveChange() + }) }) diff --git a/src/features/auth/ui/ChangePasswordDialog.tsx b/src/features/auth/ui/ChangePasswordDialog.tsx index 9f77cfd..cbbf099 100644 --- a/src/features/auth/ui/ChangePasswordDialog.tsx +++ b/src/features/auth/ui/ChangePasswordDialog.tsx @@ -55,6 +55,7 @@ function ChangePasswordDialog() { return ( { if (!open) { reset() diff --git a/src/features/vault/ui/VaultUnlockDialog.test.tsx b/src/features/vault/ui/VaultUnlockDialog.test.tsx index a3ebc58..f204000 100644 --- a/src/features/vault/ui/VaultUnlockDialog.test.tsx +++ b/src/features/vault/ui/VaultUnlockDialog.test.tsx @@ -168,4 +168,28 @@ describe('VaultUnlockDialog', () => { expect(useVaultDialogStore.getState().isUnlockDialogOpen).toBe(false) }) + + it('hides close button and blocks Escape during submission', async () => { + let resolveUnlock: () => void = () => {} + mockUnlockVault.mockReturnValue( + new Promise((resolve) => { + resolveUnlock = resolve + }), + ) + const user = userEvent.setup() + render() + + await user.type(screen.getByLabelText(/password/i), 'my-password') + await user.click(screen.getByRole('button', { name: /unlock/i })) + + // Close button should be hidden during submission + expect(screen.queryByRole('button', { name: /close/i })).not.toBeInTheDocument() + + // Escape should not close the dialog + await user.keyboard('{Escape}') + expect(useVaultDialogStore.getState().isUnlockDialogOpen).toBe(true) + + // Clean up: resolve the promise + resolveUnlock() + }) }) diff --git a/src/features/vault/ui/VaultUnlockDialog.tsx b/src/features/vault/ui/VaultUnlockDialog.tsx index 11de96e..c9b9de9 100644 --- a/src/features/vault/ui/VaultUnlockDialog.tsx +++ b/src/features/vault/ui/VaultUnlockDialog.tsx @@ -70,7 +70,7 @@ function VaultUnlockDialog() { } return ( - + {t('vaultUnlockDialog.title')} diff --git a/src/shared/ui/dialog.tsx b/src/shared/ui/dialog.tsx index fae4e26..8f58852 100644 --- a/src/shared/ui/dialog.tsx +++ b/src/shared/ui/dialog.tsx @@ -5,8 +5,29 @@ import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/ui/button' import { XIcon } from 'lucide-react' -function Dialog({ ...props }: DialogPrimitive.Root.Props) { - return +/** + * Context that signals whether the dialog should prevent user-initiated close + * actions (overlay click, Escape key, close button). When true, onOpenChange(false) + * is blocked and the close button is hidden. + */ +const PreventCloseContext = React.createContext(false) + +function Dialog({ preventClose, onOpenChange, ...props }: DialogPrimitive.Root.Props & { preventClose?: boolean }) { + return ( + + { + if (open) onOpenChange?.(open, eventDetails) + } + : onOpenChange + } + {...props} + /> + + ) } function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { @@ -42,6 +63,7 @@ function DialogContent({ }: DialogPrimitive.Popup.Props & { showCloseButton?: boolean }) { + const preventClose = React.useContext(PreventCloseContext) return ( @@ -54,7 +76,7 @@ function DialogContent({ {...props} > {children} - {showCloseButton && ( + {showCloseButton && !preventClose && ( } @@ -80,6 +102,7 @@ function DialogFooter({ }: React.ComponentProps<'div'> & { showCloseButton?: boolean }) { + const preventClose = React.useContext(PreventCloseContext) return (
{children} - {showCloseButton && }>Close} + {showCloseButton && !preventClose && ( + }>Close + )}
) }