diff --git a/src/app/layouts/ProtectedLayout.tsx b/src/app/layouts/ProtectedLayout.tsx index ee4527a..9ff9479 100644 --- a/src/app/layouts/ProtectedLayout.tsx +++ b/src/app/layouts/ProtectedLayout.tsx @@ -12,6 +12,7 @@ import { VaultUnlockDialog } from '@/features/vault/ui/VaultUnlockDialog' import { ChangePasswordDialog } from '@/features/auth/ui/ChangePasswordDialog' import { RegenerateMnemonicDialog } from '@/features/auth/ui/RegenerateMnemonicDialog' import { VerifyMnemonicDialog } from '@/features/auth/ui/VerifyMnemonicDialog' +import { RotateFieldKeyDialog } from '@/features/fields/ui/RotateFieldKeyDialog' import { OfflineBanner } from '@/shared/ui/OfflineBanner' import { useLayoutStore } from './layout-store' import { useResizable } from '@/shared/lib/use-resizable' @@ -107,6 +108,7 @@ function AuthenticatedLayout() { + ) } diff --git a/src/features/auth/model/auth-service.test.ts b/src/features/auth/model/auth-service.test.ts index 62b986f..65c3064 100644 --- a/src/features/auth/model/auth-service.test.ts +++ b/src/features/auth/model/auth-service.test.ts @@ -141,6 +141,7 @@ const cryptoStoreState = { lastActivity: 0, cachedEnvelope: null as import('@/shared/types/api.types').CachedVaultEnvelope | null, setCachedEnvelope: mockSetEnvelope, + clearCachedEnvelope: vi.fn(), lockVault: vi.fn<() => void>(), clearVault: mockClearVault, } diff --git a/src/features/auth/model/mnemonic-service.test.ts b/src/features/auth/model/mnemonic-service.test.ts index 317fb4e..cf9fc40 100644 --- a/src/features/auth/model/mnemonic-service.test.ts +++ b/src/features/auth/model/mnemonic-service.test.ts @@ -199,10 +199,12 @@ describe('regenerateMnemonic', () => { lastActivity: 0, cachedEnvelope: null, setCachedEnvelope: mockSetCachedEnvelope, + clearCachedEnvelope: vi.fn(), markKeysLoaded: vi.fn(), lockVault: vi.fn(), clearVault: vi.fn(), updateActivity: vi.fn(), + updateCachedFieldKey: vi.fn(), } as ReturnType) await regenerateMnemonic('password') diff --git a/src/features/auth/model/registration-crypto.ts b/src/features/auth/model/registration-crypto.ts index d2e782e..b3f3cb4 100644 --- a/src/features/auth/model/registration-crypto.ts +++ b/src/features/auth/model/registration-crypto.ts @@ -1,6 +1,6 @@ import { zeroFill, generateSalt } from '@/shared/crypto/core/crypto-utils' import { createRecoveryData } from '@/shared/crypto/keys/mnemonic' -import { generateAndWrapFieldKeys } from '@/shared/crypto/keys/field-keys' +import { generateAllFieldKeys } from '@/shared/crypto/keys/field-keys' import { deriveAuthCredentials } from '@/shared/crypto/keys/split-kdf' import { generateMasterKey, wrapMasterKeyWithPassword } from '@/shared/crypto/keys/master-key' import { deriveKEK } from '@/shared/crypto/core/hkdf' @@ -29,7 +29,7 @@ export async function deriveRegistrationKeys(password: string): Promise { const plaintextBytes = new TextEncoder().encode(plaintext) as Uint8Array const ciphertextIV = generateIV() - const aad = encodeAAD(fieldName, FIELD_KEY_VERSION) + const aad = encodeAAD(fieldName, FIELD_CONTENT_VERSION) const ciphertext = await encrypt(plaintextBytes, fieldKey, { iv: ciphertextIV, aad }) return { ciphertext, ciphertextIV } } @@ -26,7 +26,7 @@ export async function encryptField( /** * Decrypt an encrypted field value back to a plaintext string. * - * The AAD is reconstructed from fieldName + FIELD_KEY_VERSION, so the caller + * The AAD is reconstructed from fieldName + FIELD_CONTENT_VERSION, so the caller * must pass the same field name used during encryption. */ export async function decryptField( @@ -34,7 +34,7 @@ export async function decryptField( fieldKey: CryptoKey, fieldName: FieldName, ): Promise { - const aad = encodeAAD(fieldName, FIELD_KEY_VERSION) + const aad = encodeAAD(fieldName, FIELD_CONTENT_VERSION) const plaintextBytes = await decrypt(encryptedData.ciphertext, fieldKey, { iv: encryptedData.ciphertextIV, aad }) return new TextDecoder().decode(plaintextBytes) } diff --git a/src/features/fields/model/key-rotation-error-messages.ts b/src/features/fields/model/key-rotation-error-messages.ts new file mode 100644 index 0000000..5ded6d9 --- /dev/null +++ b/src/features/fields/model/key-rotation-error-messages.ts @@ -0,0 +1,34 @@ +import type { TFunction } from 'i18next' +import { DecryptionError } from '@/shared/crypto/core/errors' +import { AuthErrorCode } from '@/shared/auth/auth-errors' +import { ApiErrorCode } from '@/shared/api/api-errors' +import { mapErrorToMessage, type ErrorKeySpec } from '@/shared/lib/error-messages' + +/** + * Error spec for field-key rotation. + * + * The RPC is atomic, so any failure means nothing changed server-side. The + * messages reflect that: a failed rotation never leaves the vault in a broken + * state. The fallback covers the vault-locked throw and any other generic + * Error. + */ +const KEY_ROTATION_ERROR_SPEC: ErrorKeySpec = { + instanceChecks: [[DecryptionError, 'rotation.staleVault']], + authCodes: { + [AuthErrorCode.NETWORK_ERROR]: 'rotation.networkError', + }, + apiCodes: { + [ApiErrorCode.NETWORK_ERROR]: 'rotation.networkError', + [ApiErrorCode.UNEXPECTED]: 'rotation.failed', + }, + networkKey: 'rotation.networkError', + fallbackKey: 'rotation.locked', +} + +/** + * Maps field-key rotation errors to user-facing i18n strings in the 'vault' + * namespace. + */ +export function getKeyRotationErrorMessage(error: unknown, t: TFunction<'vault'>): string { + return mapErrorToMessage(error, t, KEY_ROTATION_ERROR_SPEC) +} diff --git a/src/features/fields/model/key-rotation-service.test.ts b/src/features/fields/model/key-rotation-service.test.ts new file mode 100644 index 0000000..3141fba --- /dev/null +++ b/src/features/fields/model/key-rotation-service.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { ServerEncryptedField, CachedVaultEnvelope } from '@/shared/types/api.types' +import type { FieldName } from '@/shared/types/entities/field.types' +import type { GeneratedFieldKey } from '@/shared/crypto/keys/field-keys' +import type { EncryptedFieldData } from '@/shared/types/crypto.types' + +const { + mockGetKey, + mockStoreKey, + mockFetchAll, + mockRotateRpc, + mockGenerateFieldKey, + mockDecryptField, + mockEncryptField, + mockMarkLocal, + mockUpdateCachedFieldKey, + mockEnvelope, + mockKek, + mockOldKey, + mockNewKey, +} = vi.hoisted(() => ({ + mockGetKey: vi.fn<(id: string) => CryptoKey | undefined>(), + mockStoreKey: vi.fn<(id: string, key: CryptoKey) => void>(), + mockFetchAll: vi.fn<(userId: string, fieldName: FieldName) => Promise>(), + mockRotateRpc: vi.fn<(input: unknown) => Promise>(), + mockGenerateFieldKey: vi.fn<(kek: CryptoKey, fieldName: FieldName, version: number) => Promise>(), + mockDecryptField: vi.fn<(data: EncryptedFieldData, key: CryptoKey, fieldName: FieldName) => Promise>(), + mockEncryptField: vi.fn<(plaintext: string, key: CryptoKey, fieldName: FieldName) => Promise>(), + mockMarkLocal: vi.fn<(fieldName: FieldName, version: number) => void>(), + mockUpdateCachedFieldKey: vi.fn<(input: unknown) => void>(), + mockEnvelope: { + kdfSalt: 'a1b2c3d4'.repeat(4), + wrappedMasterKey: 'aa'.repeat(48), + masterKeyIV: 'bb'.repeat(12), + fieldKeys: [ + { fieldName: 'title', version: 1, wrappedFieldKey: '01'.repeat(48), fieldKeyIV: '02'.repeat(12) }, + { fieldName: 'note', version: 1, wrappedFieldKey: '03'.repeat(48), fieldKeyIV: '04'.repeat(12) }, + { fieldName: 'website', version: 1, wrappedFieldKey: '05'.repeat(48), fieldKeyIV: '06'.repeat(12) }, + { fieldName: 'email', version: 1, wrappedFieldKey: '07'.repeat(48), fieldKeyIV: '08'.repeat(12) }, + ], + } as CachedVaultEnvelope, + mockKek: {} as CryptoKey, + mockOldKey: {} as CryptoKey, + mockNewKey: {} as CryptoKey, +})) + +vi.mock('@/shared/crypto/vault/key-vault', () => ({ + keyVault: { getKey: mockGetKey, storeKey: mockStoreKey }, +})) + +vi.mock('@/shared/crypto/vault/crypto-store', () => ({ + useCryptoStore: { + getState: () => ({ + cachedEnvelope: mockEnvelope, + updateCachedFieldKey: mockUpdateCachedFieldKey, + }), + }, +})) + +vi.mock('@/shared/api/supabase-fields', () => ({ + fetchAllEncryptedFieldsForUser: mockFetchAll, +})) + +vi.mock('@/shared/api/supabase-keys', () => ({ + rotateFieldKeyRpc: mockRotateRpc, +})) + +vi.mock('@/shared/crypto/keys/field-keys', () => ({ + generateFieldKey: mockGenerateFieldKey, +})) + +vi.mock('@/features/fields/model/field-crypto', () => ({ + decryptField: mockDecryptField, + encryptField: mockEncryptField, +})) + +vi.mock('@/shared/realtime/realtime-echo', () => ({ + markLocalKeyRotation: mockMarkLocal, +})) + +import { rotateFieldKey, rotateAllFields } from '@/features/fields/model/key-rotation-service' + +const USER_ID = 'user-1' + +function twoServerFields(): ServerEncryptedField[] { + return [ + { + entryId: 'entry-1', + fieldName: 'note', + ciphertext: 'aa'.repeat(16), + ciphertextIV: 'bb'.repeat(12), + updatedAt: '2025-01-01T00:00:00Z', + }, + { + entryId: 'entry-2', + fieldName: 'note', + ciphertext: 'cc'.repeat(16), + ciphertextIV: 'dd'.repeat(12), + updatedAt: '2025-01-02T00:00:00Z', + }, + ] +} + +function setupCryptoMocks() { + mockGenerateFieldKey.mockResolvedValue({ + cryptoKey: mockNewKey, + wrappedFieldKey: new Uint8Array(48).fill(0xff), + fieldKeyIV: new Uint8Array(12).fill(0xee), + }) + + mockDecryptField.mockResolvedValue('decrypted') + + mockEncryptField.mockResolvedValue({ + ciphertext: new Uint8Array(16).fill(0xcc), + ciphertextIV: new Uint8Array(12).fill(0xdd), + }) +} + +describe('rotateFieldKey', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetKey.mockImplementation((id: string) => (id === 'kek' ? mockKek : mockOldKey)) + mockFetchAll.mockResolvedValue(twoServerFields()) + mockRotateRpc.mockResolvedValue(undefined) + setupCryptoMocks() + }) + + it('happy path: fetches ciphertexts, rotates, calls RPC, updates vault and cache', async () => { + await rotateFieldKey(USER_ID, 'note') + + // Inputs pulled from the vault. + expect(mockGetKey).toHaveBeenCalledWith('kek') + expect(mockGetKey).toHaveBeenCalledWith('note') + + // All ciphertexts for the field fetched across entries. + expect(mockFetchAll).toHaveBeenCalledWith(USER_ID, 'note') + + // Crypto called with vault inputs + current version from the cached envelope. + expect(mockGenerateFieldKey).toHaveBeenCalledWith(mockKek, 'note', 2) + + // Each ciphertext decrypted with the old key, then re-encrypted with the new key. + expect(mockDecryptField).toHaveBeenCalledTimes(2) + expect(mockEncryptField).toHaveBeenCalledTimes(2) + + // Local-echo marker set before the RPC fires. + expect(mockMarkLocal).toHaveBeenCalledWith('note', 2) + expect(mockMarkLocal.mock.invocationCallOrder[0]).toBeLessThan(mockRotateRpc.mock.invocationCallOrder[0]!) + + // Atomic server swap called with hex-encoded crypto results. + expect(mockRotateRpc).toHaveBeenCalledWith({ + fieldName: 'note', + newVersion: 2, + newWrappedFieldKey: 'ff'.repeat(48), + newFieldKeyIV: 'ee'.repeat(12), + reEncryptedFields: [ + { entryId: 'entry-1', ciphertext: 'cc'.repeat(16), ciphertextIV: 'dd'.repeat(12) }, + { entryId: 'entry-2', ciphertext: 'cc'.repeat(16), ciphertextIV: 'dd'.repeat(12) }, + ], + }) + + // Local vault + cache updated with the new key. + expect(mockStoreKey).toHaveBeenCalledWith('note', mockNewKey) + expect(mockUpdateCachedFieldKey).toHaveBeenCalledWith({ + fieldName: 'note', + version: 2, + wrappedFieldKey: 'ff'.repeat(48), + fieldKeyIV: 'ee'.repeat(12), + }) + }) + + it('throws before any network call when the vault is locked', async () => { + mockGetKey.mockReturnValue(undefined) + + await expect(rotateFieldKey(USER_ID, 'note')).rejects.toThrow('Vault is locked — cannot rotate') + + expect(mockFetchAll).not.toHaveBeenCalled() + expect(mockGenerateFieldKey).not.toHaveBeenCalled() + expect(mockRotateRpc).not.toHaveBeenCalled() + expect(mockStoreKey).not.toHaveBeenCalled() + expect(mockUpdateCachedFieldKey).not.toHaveBeenCalled() + }) + + it('does not mutate vault or cache when the RPC fails (server rolled back)', async () => { + mockRotateRpc.mockRejectedValueOnce(new Error('rpc failed')) + + await expect(rotateFieldKey(USER_ID, 'note')).rejects.toThrow('rpc failed') + + // Crypto ran and the marker was set, but the local state was NOT updated. + expect(mockGenerateFieldKey).toHaveBeenCalledTimes(1) + expect(mockRotateRpc).toHaveBeenCalledTimes(1) + expect(mockStoreKey).not.toHaveBeenCalled() + expect(mockUpdateCachedFieldKey).not.toHaveBeenCalled() + }) + + it('still calls the RPC with an empty re-encrypted fields list when there are no entries', async () => { + mockFetchAll.mockResolvedValueOnce([]) + + await rotateFieldKey(USER_ID, 'note') + + expect(mockDecryptField).not.toHaveBeenCalled() + expect(mockEncryptField).not.toHaveBeenCalled() + expect(mockRotateRpc).toHaveBeenCalledWith(expect.objectContaining({ reEncryptedFields: [] })) + expect(mockStoreKey).toHaveBeenCalledWith('note', mockNewKey) + expect(mockUpdateCachedFieldKey).toHaveBeenCalledTimes(1) + }) +}) + +describe('rotateAllFields', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetKey.mockImplementation((id: string) => (id === 'kek' ? mockKek : mockOldKey)) + mockFetchAll.mockResolvedValue([]) + mockRotateRpc.mockResolvedValue(undefined) + setupCryptoMocks() + // Reflect successful rotations in the cached envelope so currentVersionFor + // reports the new version for subsequent rotations in the loop. + mockUpdateCachedFieldKey.mockImplementation((input: unknown) => { + const { fieldName, version } = input as { fieldName: FieldName; version: number } + const others = mockEnvelope.fieldKeys.filter((k) => k.fieldName !== fieldName) + const rotated = mockEnvelope.fieldKeys.find((k) => k.fieldName === fieldName) + if (rotated) others.push({ ...rotated, version }) + mockEnvelope.fieldKeys = others + }) + }) + + it('rotates all four fields sequentially and surfaces a per-field outcome', async () => { + const outcomes = await rotateAllFields(USER_ID) + + expect(outcomes).toHaveLength(4) + expect(outcomes.every((o) => o.ok)).toBe(true) + expect(outcomes.map((o) => o.fieldName)).toEqual(['title', 'note', 'website', 'email']) + expect(mockRotateRpc).toHaveBeenCalledTimes(4) + expect(mockStoreKey).toHaveBeenCalledTimes(4) + }) + + it('partial failure: 3rd field fails, first two stay rotated, 4th untouched', async () => { + // Restore envelope versions before the run. + mockEnvelope.fieldKeys = [ + { fieldName: 'title', version: 1, wrappedFieldKey: '01'.repeat(48), fieldKeyIV: '02'.repeat(12) }, + { fieldName: 'note', version: 1, wrappedFieldKey: '03'.repeat(48), fieldKeyIV: '04'.repeat(12) }, + { fieldName: 'website', version: 1, wrappedFieldKey: '05'.repeat(48), fieldKeyIV: '06'.repeat(12) }, + { fieldName: 'email', version: 1, wrappedFieldKey: '07'.repeat(48), fieldKeyIV: '08'.repeat(12) }, + ] + mockRotateRpc.mockImplementation((input: unknown) => { + const { fieldName } = input as { fieldName: FieldName } + if (fieldName === 'website') return Promise.reject(new Error('website failed')) + return Promise.resolve() + }) + + const outcomes = await rotateAllFields(USER_ID) + + expect(outcomes).toEqual([ + { fieldName: 'title', ok: true, newVersion: 2 }, + { fieldName: 'note', ok: true, newVersion: 2 }, + { fieldName: 'website', ok: false, error: expect.any(Error) }, + { fieldName: 'email', ok: true, newVersion: 2 }, + ]) + + // RPC attempted for all four (website's rejects). + expect(mockRotateRpc).toHaveBeenCalledTimes(4) + // Local state updated only for the three that succeeded. + expect(mockStoreKey).toHaveBeenCalledTimes(3) + expect(mockStoreKey).toHaveBeenCalledWith('title', mockNewKey) + expect(mockStoreKey).toHaveBeenCalledWith('note', mockNewKey) + expect(mockStoreKey).toHaveBeenCalledWith('email', mockNewKey) + expect(mockStoreKey).not.toHaveBeenCalledWith('website', mockNewKey) + }) +}) diff --git a/src/features/fields/model/key-rotation-service.ts b/src/features/fields/model/key-rotation-service.ts new file mode 100644 index 0000000..e556968 --- /dev/null +++ b/src/features/fields/model/key-rotation-service.ts @@ -0,0 +1,119 @@ +/** + * Field-key rotation. + * + * Pulls inputs from the vault + cached envelope, runs the pure crypto rotation, + * commits it via the atomic server RPC, then updates the local vault + cache. + */ + +import { keyVault } from '@/shared/crypto/vault/key-vault' +import { useCryptoStore } from '@/shared/crypto/vault/crypto-store' +import { fetchAllEncryptedFieldsForUser } from '@/shared/api/supabase-fields' +import { rotateFieldKeyRpc } from '@/shared/api/supabase-keys' +import { markLocalKeyRotation } from '@/shared/realtime/realtime-echo' +import { FIELD_NAMES } from '@/shared/types/entities/field.types' +import type { FieldName } from '@/shared/types/entities/field.types' +import { generateFieldKey } from '@/shared/crypto/keys/field-keys' +import { hexEncode, hexDecode } from '@/shared/crypto/core/crypto-utils' +import { encryptField, decryptField } from '@/features/fields/model/field-crypto' +import type { ServerEncryptedField } from '@/shared/types/api.types' + +/** A field that rotated successfully. */ +export type RotationSuccess = { fieldName: FieldName; ok: true; newVersion: number } + +/** A field whose rotation failed — others are unaffected. */ +export type RotationFailure = { fieldName: FieldName; ok: false; error: unknown } + +export type RotationOutcome = RotationSuccess | RotationFailure + +/** Next version number for a field key (current version + 1). */ +function nextVersionFor(fieldName: FieldName): number { + const envelope = useCryptoStore.getState().cachedEnvelope + if (!envelope) throw new Error('No cached envelope — vault is locked') + const key = envelope.fieldKeys.find((k) => k.fieldName === fieldName) + if (!key) throw new Error(`No field key found for "${fieldName}"`) + return key.version + 1 +} + +/** Re-encrypt all ciphertexts for a field from the old key to the new key. */ +async function reEncryptCiphertexts( + ciphertexts: ServerEncryptedField[], + newFieldKey: CryptoKey, + fieldName: FieldName, +): Promise<{ entryId: string; ciphertext: string; ciphertextIV: string }[]> { + const oldFieldKey = keyVault.getKey(fieldName) + if (!oldFieldKey) throw new Error('Vault is locked — cannot re-encrypt') + + return Promise.all( + ciphertexts.map(async ({ entryId, ciphertext, ciphertextIV }) => { + const plaintext = await decryptField( + { ciphertext: hexDecode(ciphertext), ciphertextIV: hexDecode(ciphertextIV) }, + oldFieldKey, + fieldName, + ) + const newEncrypted = await encryptField(plaintext, newFieldKey, fieldName) + return { + entryId, + ciphertext: hexEncode(newEncrypted.ciphertext), + ciphertextIV: hexEncode(newEncrypted.ciphertextIV), + } + }), + ) +} + +/** + * Atomically rotate one field's key: re-encrypt every entry's ciphertext for + * that field and swap the wrapped key server-side in a single transaction, + * then update the local vault + cache. + */ +export async function rotateFieldKey(userId: string, fieldName: FieldName): Promise { + const kek = keyVault.getKey('kek') + if (!kek || !keyVault.getKey(fieldName)) throw new Error('Vault is locked — cannot rotate') + + const currentCiphertexts = await fetchAllEncryptedFieldsForUser(userId, fieldName) + + // --- Pure crypto: generate, wrap, and re-encrypt --- + const newVersion = nextVersionFor(fieldName) + const { cryptoKey: newFieldKey, wrappedFieldKey, fieldKeyIV } = await generateFieldKey(kek, fieldName, newVersion) + const reEncryptedFields = await reEncryptCiphertexts(currentCiphertexts, newFieldKey, fieldName) + + markLocalKeyRotation(fieldName, newVersion) + + // --- Server commit --- + await rotateFieldKeyRpc({ + fieldName, + newVersion, + newWrappedFieldKey: hexEncode(wrappedFieldKey), + newFieldKeyIV: hexEncode(fieldKeyIV), + reEncryptedFields, + }) + + // Local state update only. Store the new key and update the cached envelope. + keyVault.storeKey(fieldName, newFieldKey) + useCryptoStore.getState().updateCachedFieldKey({ + fieldName, + version: newVersion, + wrappedFieldKey: hexEncode(wrappedFieldKey), + fieldKeyIV: hexEncode(fieldKeyIV), + }) + + return newVersion +} + +/** + * Rotate all four field keys sequentially. Each field is an independent atomic + * RPC, so partial success is expected and surfaced per field: a failure on the + * 3rd field leaves the first two rotated and the 4th untouched. Failed fields + * can be retried independently. + */ +export async function rotateAllFields(userId: string): Promise { + const outcomes: RotationOutcome[] = [] + for (const fieldName of FIELD_NAMES) { + try { + const newVersion = await rotateFieldKey(userId, fieldName) + outcomes.push({ fieldName, ok: true, newVersion }) + } catch (error) { + outcomes.push({ fieldName, ok: false, error }) + } + } + return outcomes +} diff --git a/src/features/fields/model/use-realtime-sync.test.ts b/src/features/fields/model/use-realtime-sync.test.ts index ea693ee..73ece5c 100644 --- a/src/features/fields/model/use-realtime-sync.test.ts +++ b/src/features/fields/model/use-realtime-sync.test.ts @@ -14,7 +14,8 @@ const ctx = vi.hoisted(() => { const toastSuccess = vi.fn<(msg: string, options?: unknown) => string | number>() const toastError = vi.fn<(msg: string, options?: unknown) => string | number>() const mockSyncFieldKeys = vi.fn<(userId: string) => Promise>() - const cryptoStoreState = { isVaultLocked: false } + const mockClearCachedEnvelope = vi.fn<() => void>() + const cryptoStoreState = { isVaultLocked: false, clearCachedEnvelope: mockClearCachedEnvelope } return { callbacksRef, mockSubscribe, @@ -23,6 +24,7 @@ const ctx = vi.hoisted(() => { toastSuccess, toastError, mockSyncFieldKeys, + mockClearCachedEnvelope, cryptoStoreState, } }) @@ -53,7 +55,7 @@ vi.mock('@/shared/crypto/vault/crypto-store', () => ({ import { useRealtimeSync } from '@/features/fields/model/use-realtime-sync' import { useSyncStatusStore, SYNC_STATUS } from '@/features/fields/model/sync-status-store' -import { markLocalSave, clearEchoMarkers } from '@/shared/realtime/realtime-echo' +import { markLocalSave, markLocalKeyRotation, clearEchoMarkers } from '@/shared/realtime/realtime-echo' import { queryKeys } from '@/shared/lib/query-keys' import { DecryptionError } from '@/shared/crypto/core/errors' import type { RealtimeCallbacks } from '@/shared/realtime/realtime.types' @@ -243,13 +245,14 @@ describe('useRealtimeSync', () => { expect(invalidateSpy).not.toHaveBeenCalled() }) - it('skips onKeyRotation when the vault is locked', async () => { + it('clears cached envelope and skips onKeyRotation when the vault is locked', async () => { ctx.cryptoStoreState.isVaultLocked = true renderHook(() => useRealtimeSync(), { wrapper: createWrapper(queryClient) }) callbacks().onKeyRotation('note', 2) + expect(ctx.mockClearCachedEnvelope).toHaveBeenCalledOnce() // Give the async IIFE a chance to run (it shouldn't) await waitFor(() => { expect(ctx.mockSyncFieldKeys).not.toHaveBeenCalled() @@ -268,6 +271,36 @@ describe('useRealtimeSync', () => { expect(ctx.toastSuccess).toHaveBeenCalledTimes(1) }) expect(ctx.toastSuccess.mock.calls[0][0]).toEqual(expect.any(String)) + // Unlocked vault processes rotation normally — no need to clear cache + expect(ctx.mockClearCachedEnvelope).not.toHaveBeenCalled() + }) + + it('skips onKeyRotation entirely when it is a local echo', async () => { + markLocalKeyRotation('note', 2) + + renderHook(() => useRealtimeSync(), { wrapper: createWrapper(queryClient) }) + + callbacks().onKeyRotation('note', 2) + + // Give the async IIFE a chance to run (it shouldn't) + await waitFor(() => { + expect(ctx.mockSyncFieldKeys).not.toHaveBeenCalled() + }) + expect(invalidateSpy).not.toHaveBeenCalled() + expect(ctx.toastSuccess).not.toHaveBeenCalled() + expect(ctx.toastError).not.toHaveBeenCalled() + }) + + it('still toasts when the marker version does not match (not a true echo)', async () => { + markLocalKeyRotation('note', 3) + + renderHook(() => useRealtimeSync(), { wrapper: createWrapper(queryClient) }) + + callbacks().onKeyRotation('note', 2) + + await waitFor(() => { + expect(ctx.toastSuccess).toHaveBeenCalledTimes(1) + }) }) it('shows keyRotationFailed toast on DecryptionError (stale KEK)', async () => { diff --git a/src/features/fields/model/use-realtime-sync.ts b/src/features/fields/model/use-realtime-sync.ts index a123b93..b68cb6f 100644 --- a/src/features/fields/model/use-realtime-sync.ts +++ b/src/features/fields/model/use-realtime-sync.ts @@ -9,7 +9,7 @@ import { queryKeys } from '@/shared/lib/query-keys' import { keyVault } from '@/shared/crypto/vault/key-vault' import { DecryptionError } from '@/shared/crypto/core/errors' import { useCryptoStore } from '@/shared/crypto/vault/crypto-store' -import { isLocalEcho, scheduleRemoteUpdateClear } from '@/shared/realtime/realtime-echo' +import { isLocalSaveEcho, scheduleRemoteUpdateClear, isLocalKeyRotationEcho } from '@/shared/realtime/realtime-echo' import type { ServerEncryptedField } from '@/shared/types/api.types' import type { FieldName } from '@/shared/types/entities/field.types' @@ -45,7 +45,7 @@ function useRealtimeSync(): void { if (useCryptoStore.getState().isVaultLocked) return // 1. Echo suppression: if this is our own write bouncing back, skip entirely - if (isLocalEcho(data.entryId, data.fieldName, data.updatedAt)) return + if (isLocalSaveEcho(data.entryId, data.fieldName, data.updatedAt)) return // 2. Conflict: a local save is in flight, last-write-wins, don't invalidate. if (hasPendingSave(queryClient, data.entryId, data.fieldName)) { @@ -74,19 +74,24 @@ function useRealtimeSync(): void { } }, onKeyRotation: (fieldName, newVersion) => { - // Vault is locked: no KEK to refresh field keys. A locked vault - // will fetch fresh keys on the next unlock, so skip processing - // and avoid a misleading "network error" toast. - if (useCryptoStore.getState().isVaultLocked) return + // 1. Echo suppression: if this is our own key rotation bouncing back, skip entirely + if (isLocalKeyRotationEcho(fieldName, newVersion)) return + + // Vault is locked: no KEK to refresh field keys. Clear the cached + // envelope so the next unlock fetches fresh key material from the + // server (the rotation may have changed field keys). + if (useCryptoStore.getState().isVaultLocked) { + useCryptoStore.getState().clearCachedEnvelope() + return + } // Void IIFE: the type contract says void, so we must not return the // Promise. Any unhandled rejection is caught here, not by the adapter void (async () => { - const { t, queryClient } = cbRef.current + const { t } = cbRef.current try { await keyVault.syncFieldKeys(userId) - // Invalidate all field queries: any entry's field could be affected - queryClient.invalidateQueries({ queryKey: queryKeys.field.all }) + // No need to invalidate field queries, `onFieldChange` event will be fired. toast.success(t('realtime.keyRotationApplied', { field: fieldName, version: newVersion })) } catch (error) { if (error instanceof DecryptionError) { diff --git a/src/features/fields/ui/RotateFieldKeyDialog.test.tsx b/src/features/fields/ui/RotateFieldKeyDialog.test.tsx new file mode 100644 index 0000000..4faec9d --- /dev/null +++ b/src/features/fields/ui/RotateFieldKeyDialog.test.tsx @@ -0,0 +1,206 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@/test/utils' +import userEvent from '@testing-library/user-event' +import type { FieldName } from '@/shared/types/entities/field.types' +import type { CachedVaultEnvelope } from '@/shared/types/api.types' + +const { mockInvalidateQueries, mockRotateFieldKey } = vi.hoisted(() => ({ + mockInvalidateQueries: vi.fn(), + mockRotateFieldKey: vi.fn<(userId: string, fieldName: FieldName) => Promise>(), +})) + +vi.mock('@tanstack/react-query', async () => { + const actual = await vi.importActual('@tanstack/react-query') + return { ...actual, useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }) } +}) + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +vi.mock('@/features/fields/model/key-rotation-service', () => ({ + rotateFieldKey: mockRotateFieldKey, +})) + +import { RotateFieldKeyDialog } from './RotateFieldKeyDialog' +import { toast } from 'sonner' +import { useRotateFieldKeyDialogStore } from '@/shared/auth/auth-dialogs-store' +import { useAuthStore } from '@/features/auth/model/auth-store' +import { useCryptoStore } from '@/shared/crypto/vault/crypto-store' +import { queryKeys } from '@/shared/lib/query-keys' +import { ApiError, ApiErrorCode } from '@/shared/api/api-errors' + +const mockToast = vi.mocked(toast) + +function envelopeWith(versions: Partial> = {}): CachedVaultEnvelope { + const base = [ + { fieldName: 'title', version: 1, wrappedFieldKey: '01'.repeat(48), fieldKeyIV: '02'.repeat(12) }, + { fieldName: 'note', version: 1, wrappedFieldKey: '03'.repeat(48), fieldKeyIV: '04'.repeat(12) }, + { fieldName: 'website', version: 1, wrappedFieldKey: '05'.repeat(48), fieldKeyIV: '06'.repeat(12) }, + { fieldName: 'email', version: 1, wrappedFieldKey: '07'.repeat(48), fieldKeyIV: '08'.repeat(12) }, + ] + return { + kdfSalt: 'a1b2c3d4'.repeat(4), + wrappedMasterKey: 'aa'.repeat(48), + masterKeyIV: 'bb'.repeat(12), + fieldKeys: base.map((k) => ({ ...k, version: versions[k.fieldName] ?? k.version })), + } +} + +/** Mock rotation that simulates the service bumping the cached-envelope version. */ +function rotatingMock(failingFields: Set = new Set()) { + mockRotateFieldKey.mockImplementation(async (_userId: string, fieldName: FieldName) => { + if (failingFields.has(fieldName)) throw new ApiError(ApiErrorCode.NETWORK_ERROR) + const key = useCryptoStore.getState().cachedEnvelope?.fieldKeys.find((k) => k.fieldName === fieldName) + const newVersion = (key?.version ?? 1) + 1 + useCryptoStore.setState((s) => { + if (!s.cachedEnvelope) return {} + return { + cachedEnvelope: { + ...s.cachedEnvelope, + fieldKeys: s.cachedEnvelope.fieldKeys.map((k) => + k.fieldName === fieldName ? { ...k, version: k.version + 1 } : k, + ), + }, + } + }) + return newVersion + }) +} + +function envelopeVersion(fieldName: FieldName): number { + const k = useCryptoStore.getState().cachedEnvelope?.fieldKeys.find((f) => f.fieldName === fieldName) + return k ? k.version : 1 +} + +describe('RotateFieldKeyDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + useRotateFieldKeyDialogStore.setState({ isOpen: false, payload: null }) + useAuthStore.setState({ user: { id: 'user-1', username: 'testuser', createdAt: '2024-01-01' } }) + useCryptoStore.setState({ isVaultLocked: false, cachedEnvelope: envelopeWith() }) + }) + + it('renders single-field confirmation copy when the payload names a field', () => { + useRotateFieldKeyDialogStore.setState({ isOpen: true, payload: { fieldName: 'note' } }) + + render() + + expect(screen.getByText('Rotate Note key?')).toBeInTheDocument() + expect( + screen.getByText('This re-encrypts your Note data across all entries. This cannot be undone.'), + ).toBeInTheDocument() + }) + + it('renders rotate-all confirmation copy when the payload field is null', () => { + useRotateFieldKeyDialogStore.setState({ isOpen: true, payload: { fieldName: null } }) + + render() + + expect(screen.getByText('Rotate all field keys?')).toBeInTheDocument() + expect(screen.getByText('This re-encrypts all of your data. This cannot be undone.')).toBeInTheDocument() + }) + + it('does not render the confirmation when closed', () => { + useRotateFieldKeyDialogStore.setState({ isOpen: false, payload: null }) + + render() + + expect(screen.queryByText('Rotate all field keys?')).not.toBeInTheDocument() + }) + + it('cancel closes the dialog without calling the service and clears the payload', async () => { + const user = userEvent.setup() + useRotateFieldKeyDialogStore.setState({ isOpen: true, payload: { fieldName: 'note' } }) + rotatingMock() + + render() + + await user.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(useRotateFieldKeyDialogStore.getState().isOpen).toBe(false) + expect(useRotateFieldKeyDialogStore.getState().payload).toBeNull() + expect(mockRotateFieldKey).not.toHaveBeenCalled() + }) + + it('confirm (single field) calls rotateFieldKey, invalidates field queries, toasts success, and closes', async () => { + const user = userEvent.setup() + useRotateFieldKeyDialogStore.setState({ isOpen: true, payload: { fieldName: 'note' } }) + rotatingMock() + + render() + + await user.click(screen.getByRole('button', { name: 'Rotate' })) + + await vi.waitFor(() => { + expect(mockRotateFieldKey).toHaveBeenCalledWith('user-1', 'note') + }) + + await vi.waitFor(() => { + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: queryKeys.field.all }) + expect(mockToast.success).toHaveBeenCalledWith('Note key rotated to v2') + expect(useRotateFieldKeyDialogStore.getState().isOpen).toBe(false) + }) + }) + + it('confirm (rotate-all) rotates all four fields sequentially and surfaces per-field results', async () => { + const user = userEvent.setup() + useRotateFieldKeyDialogStore.setState({ isOpen: true, payload: { fieldName: null } }) + + // 3rd field (website) fails; first two and the 4th succeed. + rotatingMock(new Set(['website'])) + + render() + + await user.click(screen.getByRole('button', { name: 'Rotate all field keys' })) + + await vi.waitFor(() => { + expect(mockRotateFieldKey).toHaveBeenCalledTimes(4) + }) + expect(mockRotateFieldKey).toHaveBeenNthCalledWith(1, 'user-1', 'title') + expect(mockRotateFieldKey).toHaveBeenNthCalledWith(2, 'user-1', 'note') + expect(mockRotateFieldKey).toHaveBeenNthCalledWith(3, 'user-1', 'website') + expect(mockRotateFieldKey).toHaveBeenNthCalledWith(4, 'user-1', 'email') + + await vi.waitFor(() => { + // Success toasts for the three that rotated. + expect(mockToast.success).toHaveBeenCalledWith('Title key rotated to v2') + expect(mockToast.success).toHaveBeenCalledWith('Note key rotated to v2') + expect(mockToast.success).toHaveBeenCalledWith('Email key rotated to v2') + // Error toast for the field that failed. + expect(mockToast.error).toHaveBeenCalledWith('Network error — rotation not applied.', { + description: 'Website', + }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: queryKeys.field.all }) + expect(useRotateFieldKeyDialogStore.getState().isOpen).toBe(false) + }) + + // First two + 4th rotated; 3rd untouched. + expect(envelopeVersion('title')).toBe(2) + expect(envelopeVersion('note')).toBe(2) + expect(envelopeVersion('website')).toBe(1) + expect(envelopeVersion('email')).toBe(2) + }) + + it('service throws ApiError(NETWORK_ERROR): mapped network toast, no invalidation, no vault mutation', async () => { + const user = userEvent.setup() + useRotateFieldKeyDialogStore.setState({ isOpen: true, payload: { fieldName: 'note' } }) + mockRotateFieldKey.mockRejectedValueOnce(new ApiError(ApiErrorCode.NETWORK_ERROR)) + + render() + + await user.click(screen.getByRole('button', { name: 'Rotate' })) + + await vi.waitFor(() => { + expect(mockToast.error).toHaveBeenCalledWith('Network error — rotation not applied.', { + description: 'Note', + }) + expect(useRotateFieldKeyDialogStore.getState().isOpen).toBe(false) + }) + + expect(mockInvalidateQueries).not.toHaveBeenCalled() + expect(mockToast.success).not.toHaveBeenCalled() + // The cached envelope was not bumped. + expect(envelopeVersion('note')).toBe(1) + }) +}) diff --git a/src/features/fields/ui/RotateFieldKeyDialog.tsx b/src/features/fields/ui/RotateFieldKeyDialog.tsx new file mode 100644 index 0000000..db044d8 --- /dev/null +++ b/src/features/fields/ui/RotateFieldKeyDialog.tsx @@ -0,0 +1,161 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/shared/ui/alert-dialog' +import { useRotateFieldKeyDialogStore } from '@/shared/auth/auth-dialogs-store' +import { useRequiredUserId } from '@/shared/auth/use-current-user' +import { queryKeys } from '@/shared/lib/query-keys' +import { FIELD_NAMES } from '@/shared/types/entities/field.types' +import type { FieldName } from '@/shared/types/entities/field.types' +import { rotateFieldKey } from '@/features/fields/model/key-rotation-service' +import { getKeyRotationErrorMessage } from '@/features/fields/model/key-rotation-error-messages' + +// Static keys so i18next-parser can discover them. +const FIELD_LABEL_KEYS: Record = { + title: 'keyRotation.field.title', + note: 'keyRotation.field.note', + website: 'keyRotation.field.website', + email: 'keyRotation.field.email', +} + +type Progress = { field: FieldName; done: number; total: number } | null + +function RotateFieldKeyDialog() { + const { t } = useTranslation('settings') + const { t: tVault } = useTranslation('vault') + const { t: tc } = useTranslation('common') + const userId = useRequiredUserId() + const queryClient = useQueryClient() + + const isOpen = useRotateFieldKeyDialogStore((s) => s.isOpen) + const payload = useRotateFieldKeyDialogStore((s) => s.payload) + const closeDialog = useRotateFieldKeyDialogStore((s) => s.close) + + const [isSubmitting, setIsSubmitting] = useState(false) + const [progress, setProgress] = useState(null) + + const isRotateAll = payload?.fieldName === null + const fieldName = payload?.fieldName ?? null + + function handleClose() { + if (isSubmitting) return + closeDialog() + } + + function toastError(error: unknown, name: FieldName) { + toast.error(getKeyRotationErrorMessage(error, tVault), { + description: t(FIELD_LABEL_KEYS[name]), + }) + } + + async function handleConfirmSingle(name: FieldName) { + setIsSubmitting(true) + try { + const newVersion = await rotateFieldKey(userId, name) + queryClient.invalidateQueries({ queryKey: queryKeys.field.all }) + toast.success(t('keyRotation.success', { field: t(FIELD_LABEL_KEYS[name]), version: newVersion })) + closeDialog() + } catch (error) { + toastError(error, name) + closeDialog() + } finally { + setIsSubmitting(false) + } + } + + async function handleConfirmAll() { + setIsSubmitting(true) + const total = FIELD_NAMES.length + const failed: { name: FieldName; error: unknown }[] = [] + const succeeded: { name: FieldName; version: number }[] = [] + let done = 0 + for (const name of FIELD_NAMES) { + setProgress({ field: name, done, total }) + try { + const newVersion = await rotateFieldKey(userId, name) + succeeded.push({ name, version: newVersion }) + } catch (error) { + failed.push({ name, error }) + } + done++ + } + setProgress(null) + queryClient.invalidateQueries({ queryKey: queryKeys.field.all }) + + if (failed.length === 0) { + toast.success(t('keyRotation.successAll')) + } else { + for (const { name, version } of succeeded) { + toast.success(t('keyRotation.success', { field: t(FIELD_LABEL_KEYS[name]), version })) + } + for (const { name, error } of failed) { + toastError(error, name) + } + } + closeDialog() + setIsSubmitting(false) + } + + function handleConfirm() { + if (!payload || isSubmitting) return + if (fieldName) { + void handleConfirmSingle(fieldName) + } else { + void handleConfirmAll() + } + } + + const confirmLabel = isRotateAll ? t('keyRotation.rotateAll') : t('keyRotation.rotate') + + return ( + { + if (!open) handleClose() + }} + > + + + + {isRotateAll + ? t('keyRotation.confirmAll.title') + : t('keyRotation.confirm.title', { field: fieldName ? t(FIELD_LABEL_KEYS[fieldName]) : '' })} + + + {isRotateAll + ? t('keyRotation.confirmAll.body') + : t('keyRotation.confirm.body', { field: fieldName ? t(FIELD_LABEL_KEYS[fieldName]) : '' })} + + + {progress && ( +

+ {t('keyRotation.progress', { + field: t(FIELD_LABEL_KEYS[progress.field]), + done: progress.done, + total: progress.total, + })} +

+ )} + + {tc('actions.cancel')} + + {isSubmitting ? t('keyRotation.rotating') : confirmLabel} + + +
+
+ ) +} + +export { RotateFieldKeyDialog } diff --git a/src/features/settings/ui/AccountSection.test.tsx b/src/features/settings/ui/AccountSection.test.tsx index a6fa9a3..87ee421 100644 --- a/src/features/settings/ui/AccountSection.test.tsx +++ b/src/features/settings/ui/AccountSection.test.tsx @@ -1,14 +1,20 @@ -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 { useAuthStore } from '@/features/auth/model/auth-store' +import { useChangePasswordDialogStore } from '@/shared/auth/auth-dialogs-store' import { AccountSection } from './AccountSection' describe('AccountSection', () => { + beforeEach(() => { + useChangePasswordDialogStore.setState({ isOpen: false }) + }) + it('renders section title and description', () => { render() expect(screen.getByText('Account')).toBeInTheDocument() - expect(screen.getByText('Manage your account settings.')).toBeInTheDocument() + expect(screen.getByText('Manage your account and login credentials.')).toBeInTheDocument() }) it('displays username from auth store', () => { @@ -23,9 +29,23 @@ describe('AccountSection', () => { expect(screen.getByText('—')).toBeInTheDocument() }) - it('renders delete account button as disabled', () => { + it('renders Change password button', () => { + render() + expect(screen.getByRole('button', { name: /Change password/i })).toBeInTheDocument() + }) + + it('opens change password dialog when clicking "Change password"', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: /Change password/i })) + + expect(useChangePasswordDialogStore.getState().isOpen).toBe(true) + }) + + it('renders delete account item as inactive (no onClick handler)', () => { render() - const deleteButton = screen.getByRole('button', { name: /delete account/i }) - expect(deleteButton).toBeDisabled() + expect(screen.getByText('Delete account')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /delete account/i })).not.toBeInTheDocument() }) }) diff --git a/src/features/settings/ui/AccountSection.tsx b/src/features/settings/ui/AccountSection.tsx index c756f7e..1433d54 100644 --- a/src/features/settings/ui/AccountSection.tsx +++ b/src/features/settings/ui/AccountSection.tsx @@ -1,10 +1,11 @@ import { useTranslation } from 'react-i18next' -import { Trash2 } from 'lucide-react' +import { KeyRound, Trash2, User } from 'lucide-react' import { useCurrentUser } from '@/shared/auth/use-current-user' -import { Button } from '@/shared/ui/button' +import { useChangePasswordDialogStore } from '@/shared/auth/auth-dialogs-store' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/shared/ui/card' import { Separator } from '@/shared/ui/separator' +import { SettingsItem } from '@/features/settings/ui/SettingsItem' function AccountSection() { const { t } = useTranslation('settings') @@ -18,14 +19,20 @@ function AccountSection() {
- {t('account.username')} + + + {t('account.username')} + {user?.username ?? '—'}
- + useChangePasswordDialogStore.getState().open()} + /> + +
) diff --git a/src/features/settings/ui/KeyManagementSubsection.test.tsx b/src/features/settings/ui/KeyManagementSubsection.test.tsx new file mode 100644 index 0000000..34bca7f --- /dev/null +++ b/src/features/settings/ui/KeyManagementSubsection.test.tsx @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen } from '@/test/utils' +import userEvent from '@testing-library/user-event' + +import { useCryptoStore } from '@/shared/crypto/vault/crypto-store' +import { useRotateFieldKeyDialogStore } from '@/shared/auth/auth-dialogs-store' +import { KeyManagementSubsection } from './KeyManagementSubsection' + +const unlockedState = { + isVaultLocked: false, + cachedEnvelope: null, +} + +describe('KeyManagementSubsection', () => { + beforeEach(() => { + useCryptoStore.setState({ isVaultLocked: true, cachedEnvelope: null, loadedFieldKeys: {} }) + useRotateFieldKeyDialogStore.setState({ isOpen: false, payload: null }) + }) + + it('renders collapsed by default with trigger visible', () => { + render() + expect(screen.getByText('Key management')).toBeInTheDocument() + }) + + it('expands on trigger click to show field rows with version badges', async () => { + useCryptoStore.setState(unlockedState) + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Key management')) + + expect(screen.getByText('Title')).toBeInTheDocument() + expect(screen.getByText('Note')).toBeInTheDocument() + expect(screen.getByText('Website')).toBeInTheDocument() + expect(screen.getByText('Email')).toBeInTheDocument() + }) + + it('shows v1 for fields when no cached envelope', async () => { + useCryptoStore.setState(unlockedState) + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Key management')) + + const versionBadges = screen.getAllByText('v1') + expect(versionBadges).toHaveLength(4) + }) + + it('disables rotate buttons and shows locked hint when vault is locked', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Key management')) + + const rotateButtons = screen.getAllByRole('button', { name: 'Rotate' }) + rotateButtons.forEach((btn) => expect(btn).toBeDisabled()) + + const rotateAllButton = screen.getByRole('button', { name: /Rotate all field keys/i }) + expect(rotateAllButton).toBeDisabled() + + expect(screen.getByText('Unlock the vault to rotate keys')).toBeInTheDocument() + }) + + it('opens rotation dialog for single field on Rotate click', async () => { + useCryptoStore.setState(unlockedState) + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Key management')) + const rotateButtons = screen.getAllByRole('button', { name: 'Rotate' }) + await user.click(rotateButtons[0]) + + expect(useRotateFieldKeyDialogStore.getState().isOpen).toBe(true) + expect(useRotateFieldKeyDialogStore.getState().payload).toEqual({ fieldName: 'title' }) + }) + + it('opens rotate-all dialog on Rotate all field keys click', async () => { + useCryptoStore.setState(unlockedState) + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Key management')) + await user.click(screen.getByRole('button', { name: /Rotate all field keys/i })) + + expect(useRotateFieldKeyDialogStore.getState().isOpen).toBe(true) + expect(useRotateFieldKeyDialogStore.getState().payload).toEqual({ fieldName: null }) + }) + + it('reflects updated versions when cached envelope changes', async () => { + useCryptoStore.setState({ + isVaultLocked: false, + cachedEnvelope: { + kdfSalt: 'abc', + wrappedMasterKey: 'def', + masterKeyIV: 'ghi', + fieldKeys: [ + { fieldName: 'title', version: 3, wrappedFieldKey: 'a', fieldKeyIV: 'b' }, + { fieldName: 'note', version: 2, wrappedFieldKey: 'c', fieldKeyIV: 'd' }, + { fieldName: 'website', version: 1, wrappedFieldKey: 'e', fieldKeyIV: 'f' }, + { fieldName: 'email', version: 4, wrappedFieldKey: 'g', fieldKeyIV: 'h' }, + ], + }, + }) + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Key management')) + + expect(screen.getByText('v3')).toBeInTheDocument() + expect(screen.getByText('v2')).toBeInTheDocument() + expect(screen.getByText('v1')).toBeInTheDocument() + expect(screen.getByText('v4')).toBeInTheDocument() + }) +}) diff --git a/src/features/settings/ui/KeyManagementSubsection.tsx b/src/features/settings/ui/KeyManagementSubsection.tsx new file mode 100644 index 0000000..26e158f --- /dev/null +++ b/src/features/settings/ui/KeyManagementSubsection.tsx @@ -0,0 +1,75 @@ +import { Fragment } from 'react' +import { useTranslation } from 'react-i18next' +import { ChevronRight, KeyRound } from 'lucide-react' + +import { CollapsibleRoot, CollapsibleTrigger, CollapsiblePanel } from '@/shared/ui/collapsible' +import { Button } from '@/shared/ui/button' +import { Separator } from '@/shared/ui/separator' +import { useCryptoStore } from '@/shared/crypto/vault/crypto-store' +import { useRotateFieldKeyDialogStore } from '@/shared/auth/auth-dialogs-store' +import { FIELD_NAMES } from '@/shared/types/entities/field.types' +import type { FieldName } from '@/shared/types/entities/field.types' + +// Static keys so i18next-parser can discover them (template literals would not be scanned). +const FIELD_LABEL_KEYS: Record = { + title: 'keyRotation.field.title', + note: 'keyRotation.field.note', + website: 'keyRotation.field.website', + email: 'keyRotation.field.email', +} + +/** Highest wrapped-key version stored for a field, from the cached envelope. */ +function versionFor(fieldKeys: { fieldName: string; version: number }[] | undefined, fieldName: FieldName): number { + const versions = (fieldKeys ?? []).filter((k) => k.fieldName === fieldName).map((k) => k.version) + return versions.length > 0 ? Math.max(...versions) : 1 +} + +function KeyManagementSubsection() { + const { t } = useTranslation('settings') + const isVaultLocked = useCryptoStore((s) => s.isVaultLocked) + const fieldKeys = useCryptoStore((s) => s.cachedEnvelope?.fieldKeys) + const openDialog = useRotateFieldKeyDialogStore((s) => s.open) + + return ( + + + + + {t('security.keyManagement')} + + + + + + {FIELD_NAMES.map((fieldName, i) => ( + + {i > 0 && } +
+ + {t(FIELD_LABEL_KEYS[fieldName])} + + {t('keyRotation.version', { version: versionFor(fieldKeys, fieldName) })} + + + +
+
+ ))} + + + {isVaultLocked &&

{t('keyRotation.locked')}

} +
+
+ ) +} + +export { KeyManagementSubsection } diff --git a/src/features/settings/ui/SecuritySection.test.tsx b/src/features/settings/ui/SecuritySection.test.tsx index 9fe8ae0..5b66d52 100644 --- a/src/features/settings/ui/SecuritySection.test.tsx +++ b/src/features/settings/ui/SecuritySection.test.tsx @@ -2,58 +2,43 @@ import { describe, it, expect, beforeEach } from 'vitest' import { render, screen } from '@/test/utils' import userEvent from '@testing-library/user-event' -import { - useChangePasswordDialogStore, - useRegenerateMnemonicDialogStore, - useVerifyMnemonicDialogStore, -} from '@/shared/auth/auth-dialogs-store' +import { useRegenerateMnemonicDialogStore, useVerifyMnemonicDialogStore } from '@/shared/auth/auth-dialogs-store' +import { useCryptoStore } from '@/shared/crypto/vault/crypto-store' import { SecuritySection } from './SecuritySection' describe('SecuritySection', () => { beforeEach(() => { - useChangePasswordDialogStore.setState({ isOpen: false }) useRegenerateMnemonicDialogStore.setState({ isOpen: false }) useVerifyMnemonicDialogStore.setState({ isOpen: false }) + useCryptoStore.setState({ isVaultLocked: true, cachedEnvelope: null, loadedFieldKeys: {} }) }) it('renders section title and description', () => { render() expect(screen.getByText('Security')).toBeInTheDocument() - expect(screen.getByText('Manage your password and security settings.')).toBeInTheDocument() + expect(screen.getByText('Manage your encryption keys and seed phrase.')).toBeInTheDocument() }) - it('renders three action items', () => { + it('renders the seed phrase action items', () => { render() - expect(screen.getByText('Change password')).toBeInTheDocument() expect(screen.getByText('Regenerate seed phrase')).toBeInTheDocument() - expect(screen.getByText('Key versions')).toBeInTheDocument() + expect(screen.getByText('Verify seed phrase')).toBeInTheDocument() }) - it('renders three separator dividers between action items', () => { + it('renders Key management trigger', () => { render() - const separators = screen.getAllByRole('separator') - expect(separators).toHaveLength(3) + expect(screen.getByText('Key management')).toBeInTheDocument() }) - it('opens change password dialog when clicking "Change password"', async () => { - const user = userEvent.setup() + it('does not render Change password in SecuritySection', () => { render() - - const changePasswordButton = screen.getByRole('button', { name: /Change password/i }) - await user.click(changePasswordButton) - - expect(useChangePasswordDialogStore.getState().isOpen).toBe(true) + expect(screen.queryByText('Change password')).not.toBeInTheDocument() }) - it('opens change password dialog with Space key', async () => { - const user = userEvent.setup() + it('renders separator dividers between action items and Key management', () => { render() - - const changePasswordButton = screen.getByRole('button', { name: /Change password/i }) - changePasswordButton.focus() - await user.keyboard(' ') - - expect(useChangePasswordDialogStore.getState().isOpen).toBe(true) + const separators = screen.getAllByRole('separator') + expect(separators.length).toBeGreaterThanOrEqual(2) }) it('opens regenerate mnemonic dialog when clicking "Regenerate seed phrase"', async () => { diff --git a/src/features/settings/ui/SecuritySection.tsx b/src/features/settings/ui/SecuritySection.tsx index 0a7ce88..83ebdfe 100644 --- a/src/features/settings/ui/SecuritySection.tsx +++ b/src/features/settings/ui/SecuritySection.tsx @@ -1,21 +1,14 @@ import { Fragment } from 'react' import { useTranslation } from 'react-i18next' -import { ChevronRight, KeyRound, ShieldCheck, ScanEye, Fingerprint, type LucideIcon } from 'lucide-react' +import { ShieldCheck, ScanEye, type LucideIcon } from 'lucide-react' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/shared/ui/card' import { Separator } from '@/shared/ui/separator' -import { - useChangePasswordDialogStore, - useRegenerateMnemonicDialogStore, - useVerifyMnemonicDialogStore, -} from '@/shared/auth/auth-dialogs-store' +import { useRegenerateMnemonicDialogStore, useVerifyMnemonicDialogStore } from '@/shared/auth/auth-dialogs-store' +import { SettingsItem } from '@/features/settings/ui/SettingsItem' +import { KeyManagementSubsection } from '@/features/settings/ui/KeyManagementSubsection' const ITEMS: { icon: LucideIcon; labelKey: string; onClick?: () => void }[] = [ - { - icon: KeyRound, - labelKey: 'security.changePassword', - onClick: () => useChangePasswordDialogStore.getState().open(), - }, { icon: ShieldCheck, labelKey: 'security.seedPhrase', @@ -26,37 +19,8 @@ const ITEMS: { icon: LucideIcon; labelKey: string; onClick?: () => void }[] = [ labelKey: 'security.verifySeedPhrase', onClick: () => useVerifyMnemonicDialogStore.getState().open(), }, - { icon: Fingerprint, labelKey: 'security.keyVersions' }, ] -function SecurityItem({ icon: Icon, label, onClick }: { icon: LucideIcon; label: string; onClick?: () => void }) { - if (onClick) { - return ( - - ) - } - - return ( -
- - - {label} - - -
- ) -} - function SecuritySection() { const { t } = useTranslation('settings') @@ -70,9 +34,11 @@ function SecuritySection() { {ITEMS.map((item, i) => ( {i > 0 && } - + ))} + + ) diff --git a/src/features/settings/ui/SettingsItem.test.tsx b/src/features/settings/ui/SettingsItem.test.tsx new file mode 100644 index 0000000..b0fbde8 --- /dev/null +++ b/src/features/settings/ui/SettingsItem.test.tsx @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@/test/utils' +import userEvent from '@testing-library/user-event' +import { User } from 'lucide-react' + +import { SettingsItem } from './SettingsItem' + +describe('SettingsItem', () => { + it('renders with icon and label', () => { + render() + expect(screen.getByText('Username')).toBeInTheDocument() + }) + + it('renders as button when onClick is provided and calls handler on click', async () => { + const user = userEvent.setup() + const handleClick = vi.fn() + render() + + const button = screen.getByRole('button', { name: /Username/i }) + expect(button).toBeInTheDocument() + await user.click(button) + expect(handleClick).toHaveBeenCalledTimes(1) + }) + + it('renders as div without onClick', () => { + render() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(screen.getByText('Username')).toBeInTheDocument() + }) + + it('button has correct role and accessible name', () => { + render( {}} />) + const button = screen.getByRole('button', { name: /Change password/i }) + expect(button).toHaveAttribute('type', 'button') + }) +}) diff --git a/src/features/settings/ui/SettingsItem.tsx b/src/features/settings/ui/SettingsItem.tsx new file mode 100644 index 0000000..590ced7 --- /dev/null +++ b/src/features/settings/ui/SettingsItem.tsx @@ -0,0 +1,45 @@ +import { ChevronRight, type LucideIcon } from 'lucide-react' + +import { cn } from '@/shared/lib/utils' + +interface SettingsItemProps { + icon: LucideIcon + label: string + onClick?: () => void + variant?: 'default' | 'destructive' +} + +function SettingsItem({ icon: Icon, label, onClick, variant = 'default' }: SettingsItemProps) { + const isDestructive = variant === 'destructive' + + if (onClick) { + return ( + + ) + } + + return ( +
+ + + {label} + +
+ ) +} + +export { SettingsItem } +export type { SettingsItemProps } diff --git a/src/features/settings/ui/SettingsPage.test.tsx b/src/features/settings/ui/SettingsPage.test.tsx index 1d58eb9..5b25fcd 100644 --- a/src/features/settings/ui/SettingsPage.test.tsx +++ b/src/features/settings/ui/SettingsPage.test.tsx @@ -12,9 +12,16 @@ describe('SettingsPage', () => { it('renders all three section titles', () => { render() - expect(screen.getByText('Security')).toBeInTheDocument() - expect(screen.getByText('Preferences')).toBeInTheDocument() expect(screen.getByText('Account')).toBeInTheDocument() + expect(screen.getByText('Preferences')).toBeInTheDocument() + expect(screen.getByText('Security')).toBeInTheDocument() + }) + + it('renders sections in Account → Preferences → Security order', () => { + const { container } = render() + const sectionTitles = Array.from(container.querySelectorAll('[data-slot="card-title"]')) + const titleTexts = sectionTitles.map((el) => el.textContent) + expect(titleTexts).toEqual(['Account', 'Preferences', 'Security']) }) it('renders username from auth store in account section', () => { diff --git a/src/features/settings/ui/SettingsPage.tsx b/src/features/settings/ui/SettingsPage.tsx index cef2432..f47b8ee 100644 --- a/src/features/settings/ui/SettingsPage.tsx +++ b/src/features/settings/ui/SettingsPage.tsx @@ -1,8 +1,8 @@ import { useTranslation } from 'react-i18next' -import { SecuritySection } from '@/features/settings/ui/SecuritySection' -import { PreferencesSection } from '@/features/settings/ui/PreferencesSection' import { AccountSection } from '@/features/settings/ui/AccountSection' +import { PreferencesSection } from '@/features/settings/ui/PreferencesSection' +import { SecuritySection } from '@/features/settings/ui/SecuritySection' function SettingsPage() { const { t } = useTranslation('settings') @@ -11,9 +11,9 @@ function SettingsPage() {

{t('title')}

- - + +
) diff --git a/src/shared/api/supabase-fields.test.ts b/src/shared/api/supabase-fields.test.ts index cef49e4..faa6b59 100644 --- a/src/shared/api/supabase-fields.test.ts +++ b/src/shared/api/supabase-fields.test.ts @@ -12,7 +12,7 @@ vi.mock('@/shared/api/supabase-client', () => ({ }), })) -import { fetchField, saveField } from '@/shared/api/supabase-fields' +import { fetchField, saveField, fetchAllEncryptedFieldsForUser } from '@/shared/api/supabase-fields' describe('fetchField', () => { let qb: ReturnType @@ -135,3 +135,94 @@ describe('saveField', () => { } }) }) + +describe('fetchAllEncryptedFieldsForUser', () => { + function buildChain(terminal: ReturnType) { + const firstEq = vi.fn().mockReturnValue({ eq: terminal }) + const selectMock = vi.fn().mockReturnValue({ eq: firstEq }) + mockFrom.mockReturnValue({ select: selectMock }) + return { selectMock, firstEq } + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('queries with user_id and field_name filters and maps rows to ServerEncryptedField', async () => { + const terminalEq = vi.fn().mockResolvedValueOnce({ + data: [ + { + entry_id: 'entry-1', + field_name: 'note', + ciphertext: 'aa'.repeat(16), + ciphertext_iv: 'bb'.repeat(12), + updated_at: '2025-01-01T00:00:00Z', + }, + { + entry_id: 'entry-2', + field_name: 'note', + ciphertext: 'cc'.repeat(16), + ciphertext_iv: 'dd'.repeat(12), + updated_at: '2025-01-02T00:00:00Z', + }, + ], + error: null, + }) + const { selectMock, firstEq } = buildChain(terminalEq) + + const result = await fetchAllEncryptedFieldsForUser('user-1', 'note') + + expect(mockFrom).toHaveBeenCalledWith(ENCRYPTED_FIELDS_TABLE) + expect(selectMock).toHaveBeenCalledWith('entry_id, field_name, ciphertext, ciphertext_iv, updated_at') + expect(firstEq).toHaveBeenCalledWith('user_id', 'user-1') + expect(terminalEq).toHaveBeenCalledWith('field_name', 'note') + expect(result).toEqual([ + { + entryId: 'entry-1', + fieldName: 'note', + ciphertext: 'aa'.repeat(16), + ciphertextIV: 'bb'.repeat(12), + updatedAt: '2025-01-01T00:00:00Z', + }, + { + entryId: 'entry-2', + fieldName: 'note', + ciphertext: 'cc'.repeat(16), + ciphertextIV: 'dd'.repeat(12), + updatedAt: '2025-01-02T00:00:00Z', + }, + ]) + }) + + it('returns an empty array when no rows match', async () => { + const terminalEq = vi.fn().mockResolvedValueOnce({ data: [], error: null }) + buildChain(terminalEq) + + const result = await fetchAllEncryptedFieldsForUser('user-1', 'note') + expect(result).toEqual([]) + }) + + it('returns an empty array when data is null', async () => { + const terminalEq = vi.fn().mockResolvedValueOnce({ data: null, error: null }) + buildChain(terminalEq) + + const result = await fetchAllEncryptedFieldsForUser('user-1', 'note') + expect(result).toEqual([]) + }) + + it('throws ApiError on query error', async () => { + const terminalEq = vi.fn().mockResolvedValueOnce({ + data: null, + error: { message: 'Query error' }, + }) + buildChain(terminalEq) + + try { + await fetchAllEncryptedFieldsForUser('user-1', 'note') + 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-fields.ts b/src/shared/api/supabase-fields.ts index 451ec96..1e56d73 100644 --- a/src/shared/api/supabase-fields.ts +++ b/src/shared/api/supabase-fields.ts @@ -38,6 +38,25 @@ export async function fetchField(entryId: string, fieldName: FieldName): Promise return mapServerField(data) } +/** + * Fetch every encrypted-field row for one field across all of the user's + * entries. RLS scopes the result to the authenticated user's own rows. + */ +export async function fetchAllEncryptedFieldsForUser( + userId: string, + fieldName: FieldName, +): Promise { + const supabase = getSupabase() + const { data, error } = await supabase + .from(ENCRYPTED_FIELDS_TABLE) + .select('entry_id, field_name, ciphertext, ciphertext_iv, updated_at') + .eq('user_id', userId) + .eq('field_name', fieldName) + + if (error) throw wrapApiError(error) + return (data ?? []).map(mapServerField) +} + /** * Upsert an encrypted field for an entry. * Uses onConflict to handle the unique (entry_id, field_name) constraint. diff --git a/src/shared/api/supabase-keys.test.ts b/src/shared/api/supabase-keys.test.ts index 95d1398..01824ec 100644 --- a/src/shared/api/supabase-keys.test.ts +++ b/src/shared/api/supabase-keys.test.ts @@ -6,6 +6,7 @@ import { MASTER_KEYS_TABLE, FIELD_KEYS_TABLE, GET_LOGIN_SALTS_RPC, + ROTATE_FIELD_KEY_RPC, } from '@/shared/types/supabase-schema' // Mock Supabase client @@ -30,6 +31,7 @@ import { fetchFreshEnvelope, saveWrappedKey, updateMasterKeyEnvelope, + rotateFieldKeyRpc, } from '@/shared/api/supabase-keys' describe('fetchLoginSalts', () => { @@ -544,3 +546,77 @@ describe('updateMasterKeyEnvelope', () => { } }) }) + +describe('rotateFieldKeyRpc', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('calls the rotation RPC with a correctly-shaped JSONB payload', async () => { + mockRpc.mockResolvedValueOnce({ data: null, error: null }) + + await rotateFieldKeyRpc({ + fieldName: 'note', + newVersion: 2, + newWrappedFieldKey: 'aa'.repeat(48), + newFieldKeyIV: 'bb'.repeat(12), + reEncryptedFields: [ + { entryId: 'entry-1', ciphertext: 'cc'.repeat(16), ciphertextIV: 'dd'.repeat(12) }, + { entryId: 'entry-2', ciphertext: 'ee'.repeat(16), ciphertextIV: 'ff'.repeat(12) }, + ], + }) + + expect(mockRpc).toHaveBeenCalledWith(ROTATE_FIELD_KEY_RPC, { + p_payload: { + field_name: 'note', + new_version: 2, + new_wrapped_field_key: 'aa'.repeat(48), + new_field_key_iv: 'bb'.repeat(12), + re_encrypted_fields: [ + { entry_id: 'entry-1', ciphertext: 'cc'.repeat(16), ciphertext_iv: 'dd'.repeat(12) }, + { entry_id: 'entry-2', ciphertext: 'ee'.repeat(16), ciphertext_iv: 'ff'.repeat(12) }, + ], + }, + }) + }) + + it('passes an empty re_encrypted_fields array when there are no entries', async () => { + mockRpc.mockResolvedValueOnce({ data: null, error: null }) + + await rotateFieldKeyRpc({ + fieldName: 'note', + newVersion: 2, + newWrappedFieldKey: 'aa'.repeat(48), + newFieldKeyIV: 'bb'.repeat(12), + reEncryptedFields: [], + }) + + expect(mockRpc).toHaveBeenCalledWith(ROTATE_FIELD_KEY_RPC, { + p_payload: { + field_name: 'note', + new_version: 2, + new_wrapped_field_key: 'aa'.repeat(48), + new_field_key_iv: 'bb'.repeat(12), + re_encrypted_fields: [], + }, + }) + }) + + it('throws ApiError on RPC error', async () => { + mockRpc.mockResolvedValueOnce({ data: null, error: { message: 'RPC error' } }) + + try { + await rotateFieldKeyRpc({ + fieldName: 'note', + newVersion: 2, + newWrappedFieldKey: 'aa'.repeat(48), + newFieldKeyIV: 'bb'.repeat(12), + reEncryptedFields: [], + }) + 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 d7c2cf9..b906b47 100644 --- a/src/shared/api/supabase-keys.ts +++ b/src/shared/api/supabase-keys.ts @@ -8,12 +8,14 @@ import type { CachedVaultEnvelope, SaveWrappedKeyData, UpdateMasterKeyEnvelopeData, + RotateFieldKeyRpcInput, } from '@/shared/types/api.types' import { LOGIN_SALTS_TABLE, MASTER_KEYS_TABLE, FIELD_KEYS_TABLE, GET_LOGIN_SALTS_RPC, + ROTATE_FIELD_KEY_RPC, } from '@/shared/types/supabase-schema' export interface LoginSalts { @@ -148,3 +150,29 @@ export async function updateMasterKeyEnvelope(userId: string, data: UpdateMaster if (masterError) throw wrapApiError(masterError) } + +/** + * Atomically rotate a field key: inserts the new wrapped key version, + * replaces every entry's ciphertext for that field (re-encrypted client-side), + * and deletes all older wrapped-key versions — all in one transaction. + * The SECURITY DEFINER RPC reads the user id from auth.uid(), so it is + * not part of the payload (no impersonation surface). + */ +export async function rotateFieldKeyRpc(input: RotateFieldKeyRpcInput): Promise { + const supabase = getSupabase() + const { error } = await supabase.rpc(ROTATE_FIELD_KEY_RPC, { + p_payload: { + field_name: input.fieldName, + new_version: input.newVersion, + new_wrapped_field_key: input.newWrappedFieldKey, + new_field_key_iv: input.newFieldKeyIV, + re_encrypted_fields: input.reEncryptedFields.map((f) => ({ + entry_id: f.entryId, + ciphertext: f.ciphertext, + ciphertext_iv: f.ciphertextIV, + })), + }, + }) + + if (error) throw wrapApiError(error) +} diff --git a/src/shared/auth/auth-dialogs-store.ts b/src/shared/auth/auth-dialogs-store.ts index 58ccdaf..11159bc 100644 --- a/src/shared/auth/auth-dialogs-store.ts +++ b/src/shared/auth/auth-dialogs-store.ts @@ -1,5 +1,15 @@ -import { createDialogStore } from '@/shared/ui/create-dialog-store' +import { createDialogStore, createDialogStoreWithPayload } from '@/shared/ui/create-dialog-store' +import type { FieldName } from '@/shared/types/entities/field.types' export const useChangePasswordDialogStore = createDialogStore('ChangePasswordDialogStore') export const useRegenerateMnemonicDialogStore = createDialogStore('RegenerateMnemonicDialogStore') export const useVerifyMnemonicDialogStore = createDialogStore('VerifyMnemonicDialogStore') + +/** + * Payload for the field-key rotation dialog. `fieldName` is null for the + * rotate-all action; otherwise it names the single field being rotated. + */ +export type RotateFieldKeyPayload = { fieldName: FieldName | null } + +export const useRotateFieldKeyDialogStore = + createDialogStoreWithPayload('RotateFieldKeyDialogStore') diff --git a/src/shared/crypto/crypto-integration.test.ts b/src/shared/crypto/crypto-integration.test.ts index ca3fe8a..552affa 100644 --- a/src/shared/crypto/crypto-integration.test.ts +++ b/src/shared/crypto/crypto-integration.test.ts @@ -1,13 +1,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { encrypt, decrypt, importKey } from '@/shared/crypto/core/aes-gcm' import { generateMasterKey, rewrapMasterKey } from '@/shared/crypto/keys/master-key' -import { generateAndWrapFieldKeys, unwrapFieldKeys } from '@/shared/crypto/keys/field-keys' +import { generateAllFieldKeys, unwrapFieldKeys, generateFieldKey } from '@/shared/crypto/keys/field-keys' import { deriveKEK } from '@/shared/crypto/core/hkdf' import { deriveAuthCredentials, derivePasswordKey } from '@/shared/crypto/keys/split-kdf' import { wrapMasterKeyWithRecovery, unwrapMasterKeyWithRecovery } from '@/shared/crypto/keys/mnemonic' import { DecryptionError } from '@/shared/crypto/core/errors' -import { MASTER_KEY_PASSWORD_AAD } from '@/shared/types/crypto.types' -import { hexEncode } from '@/shared/crypto/core/crypto-utils' +import { FIELD_CONTENT_VERSION, MASTER_KEY_PASSWORD_AAD } from '@/shared/types/crypto.types' +import { hexEncode, hexDecode } from '@/shared/crypto/core/crypto-utils' +import { encryptField, decryptField } from '@/features/fields/model/field-crypto' import type { ServerFieldKey, ServerMasterKeyEnvelope } from '@/shared/types/api.types' // Mock Argon2id module — Web Worker won't run in jsdom @@ -67,7 +68,7 @@ async function setupRegistration() { const authCreds = await deriveAuthCredentials(PASSWORD, kdfSalt) const kekBytes = await deriveKEK(masterKey) const kek = await importKey(kekBytes) - const { cryptoFieldKeys, wrappedFieldKeys } = await generateAndWrapFieldKeys(kek) + const { cryptoFieldKeys, wrappedFieldKeys } = await generateAllFieldKeys(kek) const serverFieldKeys: ServerFieldKey[] = wrappedFieldKeys.map((w) => ({ fieldName: w.fieldName, version: w.version, @@ -388,6 +389,92 @@ describe('crypto integration', () => { } await expect(unwrapFieldKeys([tampered], kek)).rejects.toThrow(DecryptionError) }) + + it('generateFieldKey + encryptField/decryptField end-to-end: rotate a field key and round-trip all ciphertexts', async () => { + const { kek, cryptoFieldKeys, serverFieldKeys } = await setupRegistration() + vi.clearAllMocks() + + const oldNoteKey = cryptoFieldKeys.get('note')! + const noteVersion = serverFieldKeys.find((f) => f.fieldName === 'note')!.version + + // Encrypt original note content with encryptField (same path the service uses). + const entries = [ + { entryId: 'e1', plaintext: 'note one' }, + { entryId: 'e2', plaintext: 'note two' }, + ] + const currentCiphertexts = await Promise.all( + entries.map(async ({ entryId, plaintext }) => { + const encrypted = await encryptField(plaintext, oldNoteKey, 'note') + return { + entryId, + ciphertext: hexEncode(encrypted.ciphertext), + ciphertextIV: hexEncode(encrypted.ciphertextIV), + } + }), + ) + + // Generate a new field key (the rotation step). + const newVersion = noteVersion + 1 + const { cryptoKey: newNoteKey, wrappedFieldKey, fieldKeyIV } = await generateFieldKey(kek, 'note', newVersion) + + // Re-encrypt all ciphertexts with the new key. + const reEncryptedFields = await Promise.all( + currentCiphertexts.map(async ({ entryId, ciphertext, ciphertextIV }) => { + const plaintext = await decryptField( + { ciphertext: hexDecode(ciphertext), ciphertextIV: hexDecode(ciphertextIV) }, + oldNoteKey, + 'note', + ) + const newEncrypted = await encryptField(plaintext, newNoteKey, 'note') + return { + entryId, + ciphertext: hexEncode(newEncrypted.ciphertext), + ciphertextIV: hexEncode(newEncrypted.ciphertextIV), + } + }), + ) + + expect(newVersion).toBe(noteVersion + 1) + + // The new wrapped key unwraps with the same KEK + new-version wrap AAD. + await expect( + decrypt(wrappedFieldKey, kek, { iv: fieldKeyIV, aad: encodeAAD('note', newVersion) }), + ).resolves.toBeDefined() + + // Re-encrypted ciphertexts decrypt with the new key and match the originals. + for (const r of reEncryptedFields) { + const expected = entries.find((e) => e.entryId === r.entryId)!.plaintext + const decrypted = await decryptField( + { ciphertext: hexDecode(r.ciphertext), ciphertextIV: hexDecode(r.ciphertextIV) }, + newNoteKey, + 'note', + ) + expect(decrypted).toBe(expected) + } + + // The old note key can no longer decrypt the re-encrypted ciphertexts. + for (const r of reEncryptedFields) { + await expect( + decryptField( + { ciphertext: hexDecode(r.ciphertext), ciphertextIV: hexDecode(r.ciphertextIV) }, + oldNoteKey, + 'note', + ), + ).rejects.toThrow(DecryptionError) + } + + // Other fields are untouched: their keys still round-trip their own content. + const websiteKey = cryptoFieldKeys.get('website')! + const websiteAad = encodeAAD('website', FIELD_CONTENT_VERSION) + const wIv = generateIV() + const wCipher = await encrypt( + new TextEncoder().encode('website content') as Uint8Array, + websiteKey, + { iv: wIv, aad: websiteAad }, + ) + const wPt = await decrypt(wCipher, websiteKey, { iv: wIv, aad: websiteAad }) + expect(new TextDecoder().decode(wPt)).toBe('website content') + }) }) describe('performance', () => { diff --git a/src/shared/crypto/keys/field-keys.test.ts b/src/shared/crypto/keys/field-keys.test.ts index dd1b97b..84e2f29 100644 --- a/src/shared/crypto/keys/field-keys.test.ts +++ b/src/shared/crypto/keys/field-keys.test.ts @@ -1,14 +1,77 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { DecryptionError } from '@/shared/crypto/core/errors' -import { hexEncode } from '@/shared/crypto/core/crypto-utils' +import * as cryptoUtils from '@/shared/crypto/core/crypto-utils' +import { hexEncode, generateKey, generateIV, encodeAAD, zeroFill } from '@/shared/crypto/core/crypto-utils' import { generateMasterKey } from '@/shared/crypto/keys/master-key' import { deriveKEK } from '@/shared/crypto/core/hkdf' -import { importKey } from '@/shared/crypto/core/aes-gcm' -import { generateAndWrapFieldKeys, unwrapFieldKeys } from '@/shared/crypto/keys/field-keys' +import { importKey, encrypt } from '@/shared/crypto/core/aes-gcm' +import { generateFieldKey, generateAllFieldKeys, unwrapFieldKeys } from '@/shared/crypto/keys/field-keys' import type { ServerFieldKey } from '@/shared/types/api.types' describe('field-keys', () => { - describe('generateAndWrapFieldKeys', () => { + describe('generateFieldKey', () => { + async function setupKEK() { + const masterKey = generateMasterKey() + const kekBytes = await deriveKEK(masterKey) + const kek = await importKey(kekBytes) + return { kek } + } + + it('returns a CryptoKey and correctly wrapped key material', async () => { + const { kek } = await setupKEK() + const { cryptoKey, wrappedFieldKey, fieldKeyIV } = await generateFieldKey(kek, 'note', 1) + + expect(cryptoKey).toBeInstanceOf(CryptoKey) + expect(wrappedFieldKey.length).toBe(48) // 32 bytes + 16-byte GCM tag + expect(fieldKeyIV.length).toBe(12) + }) + + it('round-trips through wrap and unwrap', async () => { + const { kek } = await setupKEK() + const { cryptoKey, wrappedFieldKey, fieldKeyIV } = await generateFieldKey(kek, 'note', 1) + + const aad = encodeAAD('note', 1) + const unwrappedRaw = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: fieldKeyIV, additionalData: aad }, + kek, + wrappedFieldKey, + ) + const unwrappedKey = await importKey(new Uint8Array(unwrappedRaw)) + + // Both keys produce identical ciphertext for the same plaintext/IV. + const plaintext = new Uint8Array(32).fill(0x42) + const iv = new Uint8Array(12).fill(0x00) + const cipher1 = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, plaintext) + const cipher2 = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, unwrappedKey, plaintext) + expect(new Uint8Array(cipher1)).toEqual(new Uint8Array(cipher2)) + }) + + it('uses the provided version in the AAD (rollback protection)', async () => { + const { kek } = await setupKEK() + const { wrappedFieldKey, fieldKeyIV } = await generateFieldKey(kek, 'note', 3) + + // Unwrapping with a different version must fail. + const staleAad = encodeAAD('note', 2) + await expect( + crypto.subtle.decrypt({ name: 'AES-GCM', iv: fieldKeyIV, additionalData: staleAad }, kek, wrappedFieldKey), + ).rejects.toThrow() + }) + + it('zero-fills the raw key material', async () => { + const { kek } = await setupKEK() + const rawKey = new Uint8Array(32).fill(0xab) + const spy = vi.spyOn(cryptoUtils, 'generateKey').mockReturnValue(rawKey) + + try { + await generateFieldKey(kek, 'note', 1) + expect(Array.from(rawKey)).toEqual(new Array(32).fill(0)) + } finally { + spy.mockRestore() + } + }) + }) + + describe('generateAllFieldKeys', () => { async function setupKEK() { const masterKey = generateMasterKey() const kekBytes = await deriveKEK(masterKey) @@ -18,7 +81,7 @@ describe('field-keys', () => { it('returns cryptoFieldKeys and wrappedFieldKeys for all four fields', async () => { const { kek } = await setupKEK() - const { cryptoFieldKeys, wrappedFieldKeys } = await generateAndWrapFieldKeys(kek) + const { cryptoFieldKeys, wrappedFieldKeys } = await generateAllFieldKeys(kek) expect(cryptoFieldKeys.size).toBe(4) expect(wrappedFieldKeys).toHaveLength(4) @@ -34,7 +97,7 @@ describe('field-keys', () => { it('produces CryptoKey instances for each field', async () => { const { kek } = await setupKEK() - const { cryptoFieldKeys } = await generateAndWrapFieldKeys(kek) + const { cryptoFieldKeys } = await generateAllFieldKeys(kek) for (const key of cryptoFieldKeys.values()) { expect(key).toBeInstanceOf(CryptoKey) } @@ -42,7 +105,7 @@ describe('field-keys', () => { it('produces wrapped keys of correct size with version', async () => { const { kek } = await setupKEK() - const { wrappedFieldKeys } = await generateAndWrapFieldKeys(kek) + const { wrappedFieldKeys } = await generateAllFieldKeys(kek) for (const w of wrappedFieldKeys) { expect(w.wrappedFieldKey.length).toBe(48) // 32 bytes + 16 byte GCM tag expect(w.fieldKeyIV.length).toBe(12) @@ -52,7 +115,7 @@ describe('field-keys', () => { it('round-trips through wrap and unwrap', async () => { const { kek } = await setupKEK() - const { cryptoFieldKeys, wrappedFieldKeys } = await generateAndWrapFieldKeys(kek) + const { cryptoFieldKeys, wrappedFieldKeys } = await generateAllFieldKeys(kek) const serverFieldKeys: ServerFieldKey[] = wrappedFieldKeys.map((w) => ({ fieldName: w.fieldName, @@ -78,7 +141,7 @@ describe('field-keys', () => { it('throws DecryptionError when unwrapping with wrong KEK', async () => { const { kek } = await setupKEK() - const { wrappedFieldKeys } = await generateAndWrapFieldKeys(kek) + const { wrappedFieldKeys } = await generateAllFieldKeys(kek) const serverFieldKeys: ServerFieldKey[] = wrappedFieldKeys.map((w) => ({ fieldName: w.fieldName, version: w.version, @@ -94,7 +157,7 @@ describe('field-keys', () => { it('throws DecryptionError when unwrapping with wrong version (rollback protection)', async () => { const { kek } = await setupKEK() - const { wrappedFieldKeys } = await generateAndWrapFieldKeys(kek) + const { wrappedFieldKeys } = await generateAllFieldKeys(kek) const tampered = wrappedFieldKeys.map((w) => ({ ...w, @@ -115,5 +178,44 @@ describe('field-keys', () => { const unwrapped = await unwrapFieldKeys([], kek) expect(unwrapped.size).toBe(0) }) + + it('keeps the last key when two versions are present for a field (atomic-swap guard)', async () => { + // After an atomic rotation the server holds exactly one version per + // field. If a multi-version state were ever reintroduced, unwrapFieldKeys + // builds a Map keyed by fieldName so the last entry wins — this test + // pins that behavior as a regression guard. + const { kek } = await setupKEK() + + const v1Raw = generateKey() + const v1Crypto = await importKey(v1Raw) + const v1IV = generateIV() + const v1Wrapped = await encrypt(v1Raw, kek, { iv: v1IV, aad: encodeAAD('note', 1) }) + + const v2Raw = generateKey() + const v2Crypto = await importKey(v2Raw) + const v2IV = generateIV() + const v2Wrapped = await encrypt(v2Raw, kek, { iv: v2IV, aad: encodeAAD('note', 2) }) + zeroFill([v1Raw, v2Raw]) + + const serverFieldKeys: ServerFieldKey[] = [ + { fieldName: 'note', version: 1, wrappedFieldKey: hexEncode(v1Wrapped), fieldKeyIV: hexEncode(v1IV) }, + { fieldName: 'note', version: 2, wrappedFieldKey: hexEncode(v2Wrapped), fieldKeyIV: hexEncode(v2IV) }, + ] + + const unwrapped = await unwrapFieldKeys(serverFieldKeys, kek) + expect(unwrapped.size).toBe(1) + + const noteKey = unwrapped.get('note')! + const plaintext = new Uint8Array(32).fill(0x42) + const iv = new Uint8Array(12).fill(0x00) + + const cipherV2 = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, v2Crypto, plaintext) + const cipherMap = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, noteKey, plaintext) + // The map holds the v2 key (last entry wins) — not the v1 key. + expect(new Uint8Array(cipherMap)).toEqual(new Uint8Array(cipherV2)) + + const cipherV1 = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, v1Crypto, plaintext) + expect(new Uint8Array(cipherMap)).not.toEqual(new Uint8Array(cipherV1)) + }) }) }) diff --git a/src/shared/crypto/keys/field-keys.ts b/src/shared/crypto/keys/field-keys.ts index 124ae0c..1346723 100644 --- a/src/shared/crypto/keys/field-keys.ts +++ b/src/shared/crypto/keys/field-keys.ts @@ -2,7 +2,7 @@ * Field key operations: generation+wrapping and unwrapping. * * Each entry has four encrypted fields (title, note, website, email), each - * protected by its own 256-bit field key. `generateAndWrapFieldKeys` creates + * protected by its own 256-bit field key. `generateAllFieldKeys` creates * and wraps all four field keys in a single parallel pass; `unwrapFieldKeys` * decrypts them during vault unlock. */ @@ -15,15 +15,34 @@ import { FIELD_KEY_VERSION } from '@/shared/types/crypto.types' import type { ServerFieldKey } from '@/shared/types/api.types' import { FIELD_NAMES } from '@/shared/types/entities/field.types' +export type GeneratedFieldKey = { + cryptoKey: CryptoKey + wrappedFieldKey: Uint8Array + fieldKeyIV: Uint8Array +} + +/** + * Generate a single field key, import it as a CryptoKey, and wrap it with the + * KEK. Raw key bytes are zero-filled in a finally block. + */ +export async function generateFieldKey(kek: CryptoKey, fieldName: string, version: number): Promise { + const rawKey = generateKey() + try { + const cryptoKey = await importKey(rawKey) + const fieldKeyIV = generateIV() + const aad = encodeAAD(fieldName, version) + const wrappedFieldKey = await encrypt(rawKey, kek, { iv: fieldKeyIV, aad }) + return { cryptoKey, wrappedFieldKey, fieldKeyIV } + } finally { + zeroFill(rawKey) + } +} + /** * Generate all four field keys, import them as CryptoKeys, and wrap them * with the KEK — all in a single parallel pass. - * - * This combines what were previously three separate steps (generate → version - * → wrap) into one, avoiding multiple iterations over the field key arrays. - * Raw keys are zero-filled after wrapping. */ -export async function generateAndWrapFieldKeys( +export async function generateAllFieldKeys( kek: CryptoKey, ): Promise<{ cryptoFieldKeys: Map; wrappedFieldKeys: WrappedFieldKey[] }> { const cryptoFieldKeys = new Map() @@ -31,18 +50,9 @@ export async function generateAndWrapFieldKeys( await Promise.all( FIELD_NAMES.map(async (fieldName) => { - const rawKey = generateKey() - try { - const cryptoKey = await importKey(rawKey) - const fieldKeyIV = generateIV() - const aad = encodeAAD(fieldName, FIELD_KEY_VERSION) - const wrappedFieldKey = await encrypt(rawKey, kek, { iv: fieldKeyIV, aad }) - - cryptoFieldKeys.set(fieldName, cryptoKey) - wrappedFieldKeys.push({ fieldName, version: FIELD_KEY_VERSION, wrappedFieldKey, fieldKeyIV }) - } finally { - zeroFill(rawKey) - } + const { cryptoKey, wrappedFieldKey, fieldKeyIV } = await generateFieldKey(kek, fieldName, FIELD_KEY_VERSION) + cryptoFieldKeys.set(fieldName, cryptoKey) + wrappedFieldKeys.push({ fieldName, version: FIELD_KEY_VERSION, wrappedFieldKey, fieldKeyIV }) }), ) diff --git a/src/shared/crypto/vault/crypto-store.ts b/src/shared/crypto/vault/crypto-store.ts index d02a531..1c7031e 100644 --- a/src/shared/crypto/vault/crypto-store.ts +++ b/src/shared/crypto/vault/crypto-store.ts @@ -1,6 +1,6 @@ import { create } from 'zustand' import type { QueryClient } from '@tanstack/react-query' -import type { CachedVaultEnvelope } from '@/shared/types/api.types' +import type { CachedVaultEnvelope, ServerFieldKey } from '@/shared/types/api.types' import { queryKeys } from '@/shared/lib/query-keys' import { clearEchoMarkers } from '@/shared/realtime/realtime-echo' @@ -15,6 +15,8 @@ interface CryptoState { interface CryptoActions { markKeysLoaded: (fieldKeyNames: string[]) => void setCachedEnvelope: (envelope: CachedVaultEnvelope) => void + clearCachedEnvelope: () => void + updateCachedFieldKey: (fieldKey: ServerFieldKey) => void lockVault: () => void clearVault: () => void updateActivity: () => void @@ -50,6 +52,13 @@ const useCryptoStore = create()((set) => ({ lastActivity: Date.now(), }), setCachedEnvelope: (envelope) => set({ cachedEnvelope: envelope }), + clearCachedEnvelope: () => set({ cachedEnvelope: null }), + updateCachedFieldKey: (fieldKey) => + set((state) => { + if (!state.cachedEnvelope) return {} + const others = state.cachedEnvelope.fieldKeys.filter((k) => k.fieldName !== fieldKey.fieldName) + return { cachedEnvelope: { ...state.cachedEnvelope, fieldKeys: [...others, fieldKey] } } + }), lockVault: () => { set({ loadedFieldKeys: {}, diff --git a/src/shared/crypto/vault/key-vault.test.ts b/src/shared/crypto/vault/key-vault.test.ts index 4de1b45..b5fcd64 100644 --- a/src/shared/crypto/vault/key-vault.test.ts +++ b/src/shared/crypto/vault/key-vault.test.ts @@ -39,6 +39,7 @@ const cryptoStoreState = { lockVault: mockLockVault, clearVault: mockClearVault, setCachedEnvelope: mockSetEnvelope, + clearCachedEnvelope: vi.fn(), updateActivity: vi.fn(), } @@ -350,6 +351,26 @@ describe('syncFieldKeys', () => { }) }) + it('handles a post-atomic-swap server state: exactly one version per field', async () => { + // After an atomic rotation, the server has a single version per field + // (the old version is deleted in the same transaction). This is a regression + // guard against accidentally reintroducing a multi-version server state. + const postRotation: ServerFieldKey[] = [ + { fieldName: 'title', version: 2, wrappedFieldKey: 't2', fieldKeyIV: 'ti2' }, + { fieldName: 'note', version: 2, wrappedFieldKey: 'n2', fieldKeyIV: 'ni2' }, + { fieldName: 'website', version: 2, wrappedFieldKey: 'w2', fieldKeyIV: 'wi2' }, + { fieldName: 'email', version: 2, wrappedFieldKey: 'e2', fieldKeyIV: 'ei2' }, + ] + vi.mocked(fetchFieldKeys).mockResolvedValueOnce(postRotation) + vi.mocked(unwrapFieldKeys).mockResolvedValueOnce(makeUnwrappedKeys(['title', 'note', 'website', 'email'])) + + await keyVault.syncFieldKeys('user-1') + + expect(unwrapFieldKeys).toHaveBeenCalledWith(postRotation, FAKE_KEK) + expect(mockMarkKeysLoaded).toHaveBeenCalledWith(['title', 'note', 'website', 'email']) + expect(mockSetEnvelope).toHaveBeenCalledWith({ ...cachedEnvelope, fieldKeys: postRotation }) + }) + it('throws when KEK is not in vault (vault locked)', async () => { keyVault.zeroKeys() diff --git a/src/shared/i18n/locales/cs/settings.json b/src/shared/i18n/locales/cs/settings.json index 1c60367..9e47169 100644 --- a/src/shared/i18n/locales/cs/settings.json +++ b/src/shared/i18n/locales/cs/settings.json @@ -2,11 +2,10 @@ "title": "Nastavení", "security": { "title": "Zabezpečení", - "description": "Správa hesla a nastavení zabezpečení.", - "changePassword": "Změnit heslo", + "description": "Správa šifrovacích klíčů a seed fráze.", "seedPhrase": "Znovu vygenerovat seed frázi", "verifySeedPhrase": "Ověřit seed frázi", - "keyVersions": "Verze klíčů" + "keyManagement": "Správa klíčů" }, "preferences": { "title": "Předvolby", @@ -20,8 +19,33 @@ }, "account": { "title": "Účet", - "description": "Správa nastavení účtu.", + "description": "Správa účtu a přihlašovacích údajů.", "username": "Uživatelské jméno", + "changePassword": "Změnit heslo", "deleteAccount": "Smazat účet" + }, + "keyRotation": { + "field": { + "title": "Název", + "note": "Poznámka", + "website": "Webová stránka", + "email": "E-mail" + }, + "version": "v{{version}}", + "rotate": "Rotovat", + "rotateAll": "Rotovat všechny klíče polí", + "rotating": "Rotuji...", + "confirm": { + "title": "Rotovat klíč pole {{field}}?", + "body": "Toto znovu zašifruje data pole {{field}} napříč všemi záznamy. Tuto akci nelze vrátit zpět." + }, + "confirmAll": { + "title": "Rotovat všechny klíče polí?", + "body": "Toto znovu zašifruje všechna vaše data. Tuto akci nelze vrátit zpět." + }, + "success": "Klíč pole {{field}} rotován na v{{version}}", + "successAll": "Všechny klíče polí rotovány", + "locked": "Odemkněte trezor pro rotaci klíčů", + "progress": "Rotuji {{field}}... ({{done}}/{{total}})" } } diff --git a/src/shared/i18n/locales/cs/vault.json b/src/shared/i18n/locales/cs/vault.json index f8723ee..7db5df4 100644 --- a/src/shared/i18n/locales/cs/vault.json +++ b/src/shared/i18n/locales/cs/vault.json @@ -17,5 +17,11 @@ "wrongPassword": "Špatné heslo", "argon2Failed": "Odvození klíče selhalo. Zkuste to prosím znovu.", "unwrapFailed": "Selhalo dešifrování trezoru. Vaše data mohou být poškozena." + }, + "rotation": { + "failed": "Rotace selhala. Vaše data zůstala nezměněna.", + "networkError": "Chyba sítě — rotace nebyla provedena.", + "staleVault": "Dešifrování selhalo. Odemkněte trezor znovu a zkuste to znovu.", + "locked": "Trezor je uzamčen." } } diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index 436a043..433ba0c 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -2,11 +2,10 @@ "title": "Settings", "security": { "title": "Security", - "description": "Manage your password and security settings.", - "changePassword": "Change password", + "description": "Manage your encryption keys and seed phrase.", "seedPhrase": "Regenerate seed phrase", "verifySeedPhrase": "Verify seed phrase", - "keyVersions": "Key versions" + "keyManagement": "Key management" }, "preferences": { "title": "Preferences", @@ -20,8 +19,33 @@ }, "account": { "title": "Account", - "description": "Manage your account settings.", + "description": "Manage your account and login credentials.", "username": "Username", + "changePassword": "Change password", "deleteAccount": "Delete account" + }, + "keyRotation": { + "field": { + "title": "Title", + "note": "Note", + "website": "Website", + "email": "Email" + }, + "version": "v{{version}}", + "rotate": "Rotate", + "rotateAll": "Rotate all field keys", + "rotating": "Rotating...", + "confirm": { + "title": "Rotate {{field}} key?", + "body": "This re-encrypts your {{field}} data across all entries. This cannot be undone." + }, + "confirmAll": { + "title": "Rotate all field keys?", + "body": "This re-encrypts all of your data. This cannot be undone." + }, + "success": "{{field}} key rotated to v{{version}}", + "successAll": "All field keys rotated", + "locked": "Unlock the vault to rotate keys", + "progress": "Rotating {{field}}... ({{done}}/{{total}})" } } diff --git a/src/shared/i18n/locales/en/vault.json b/src/shared/i18n/locales/en/vault.json index 53fbb91..4f1b82c 100644 --- a/src/shared/i18n/locales/en/vault.json +++ b/src/shared/i18n/locales/en/vault.json @@ -17,5 +17,11 @@ "wrongPassword": "Wrong password", "argon2Failed": "Key derivation failed. Please try again.", "unwrapFailed": "Failed to decrypt your vault. Your data may be corrupted." + }, + "rotation": { + "failed": "Rotation failed. Your data is unchanged.", + "networkError": "Network error — rotation not applied.", + "staleVault": "Failed to decrypt. Re-unlock the vault and try again.", + "locked": "Vault is locked." } } diff --git a/src/shared/realtime/realtime-echo.test.ts b/src/shared/realtime/realtime-echo.test.ts index ca5c0ef..b37660f 100644 --- a/src/shared/realtime/realtime-echo.test.ts +++ b/src/shared/realtime/realtime-echo.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { markLocalSave, - isLocalEcho, + isLocalSaveEcho, clearEchoMarkers, scheduleRemoteUpdateClear, } from '@/shared/realtime/realtime-echo' @@ -16,43 +16,43 @@ describe('echo detection', () => { it('markLocalSave + isLocalEcho detects echo with matching timestamp', () => { markLocalSave(ENTRY_ID, 'note', '2026-01-01T00:00:00Z') - expect(isLocalEcho(ENTRY_ID, 'note', '2026-01-01T00:00:00Z')).toBe(true) + expect(isLocalSaveEcho(ENTRY_ID, 'note', '2026-01-01T00:00:00Z')).toBe(true) // After detection, marker is consumed - expect(isLocalEcho(ENTRY_ID, 'note', '2026-01-01T00:00:00Z')).toBe(false) + expect(isLocalSaveEcho(ENTRY_ID, 'note', '2026-01-01T00:00:00Z')).toBe(false) }) it('isLocalEcho returns false for different timestamp', () => { markLocalSave(ENTRY_ID, 'note', '2026-01-01T00:00:00Z') - expect(isLocalEcho(ENTRY_ID, 'note', '2026-01-01T00:00:01Z')).toBe(false) + expect(isLocalSaveEcho(ENTRY_ID, 'note', '2026-01-01T00:00:01Z')).toBe(false) }) it('isLocalEcho returns false for unknown entry/field', () => { - expect(isLocalEcho(ENTRY_ID, 'note', '2026-01-01T00:00:00Z')).toBe(false) + expect(isLocalSaveEcho(ENTRY_ID, 'note', '2026-01-01T00:00:00Z')).toBe(false) }) it('isLocalEcho returns false on mismatch and does not remove the marker', () => { markLocalSave(ENTRY_ID, 'note', '2026-01-01T00:00:00Z') // Mismatch — returns false, but does NOT remove the marker - expect(isLocalEcho(ENTRY_ID, 'note', 'different')).toBe(false) + expect(isLocalSaveEcho(ENTRY_ID, 'note', 'different')).toBe(false) // The original marker is still there, so a matching call succeeds - expect(isLocalEcho(ENTRY_ID, 'note', '2026-01-01T00:00:00Z')).toBe(true) + expect(isLocalSaveEcho(ENTRY_ID, 'note', '2026-01-01T00:00:00Z')).toBe(true) }) it('clearEchoMarkers removes all markers', () => { markLocalSave('e1', 'note', 'ts1') markLocalSave('e2', 'title', 'ts2') clearEchoMarkers() - expect(isLocalEcho('e1', 'note', 'ts1')).toBe(false) - expect(isLocalEcho('e2', 'title', 'ts2')).toBe(false) + expect(isLocalSaveEcho('e1', 'note', 'ts1')).toBe(false) + expect(isLocalSaveEcho('e2', 'title', 'ts2')).toBe(false) }) it('markLocalSave overwrites previous timestamp for same key', () => { markLocalSave(ENTRY_ID, 'note', 'ts-old') markLocalSave(ENTRY_ID, 'note', 'ts-new') // ts-old no longer matches (was overwritten) - expect(isLocalEcho(ENTRY_ID, 'note', 'ts-old')).toBe(false) + expect(isLocalSaveEcho(ENTRY_ID, 'note', 'ts-old')).toBe(false) // ts-new matches - expect(isLocalEcho(ENTRY_ID, 'note', 'ts-new')).toBe(true) + expect(isLocalSaveEcho(ENTRY_ID, 'note', 'ts-new')).toBe(true) }) }) @@ -130,7 +130,7 @@ describe('crypto store clears echo markers', () => { useCryptoStore.getState().lockVault() // After lockVault, echo marker is gone - expect(isLocalEcho(ENTRY_ID, 'note', 'ts1')).toBe(false) + expect(isLocalSaveEcho(ENTRY_ID, 'note', 'ts1')).toBe(false) }) it('clearVault clears echo markers', () => { @@ -139,6 +139,6 @@ describe('crypto store clears echo markers', () => { useCryptoStore.getState().clearVault() // After clearVault, echo marker is gone - expect(isLocalEcho(ENTRY_ID, 'note', 'ts1')).toBe(false) + expect(isLocalSaveEcho(ENTRY_ID, 'note', 'ts1')).toBe(false) }) }) diff --git a/src/shared/realtime/realtime-echo.ts b/src/shared/realtime/realtime-echo.ts index 7ec6ccd..a148e5c 100644 --- a/src/shared/realtime/realtime-echo.ts +++ b/src/shared/realtime/realtime-echo.ts @@ -5,6 +5,9 @@ import type { FieldName } from '@/shared/types/entities/field.types' /** Maps "entryId:fieldName" → updatedAt timestamp from local save. */ const localSaveTimestamps = new Map() +/** Maps fieldName → the wrapped-key version we just rotated to locally. */ +const localKeyRotations = new Map() + /** Timer IDs for auto-clearing 'remote-update' status. */ const remoteUpdateTimers = new Map>() @@ -21,7 +24,7 @@ export function markLocalSave(entryId: string, fieldName: FieldName, updatedAt: * Check whether a realtime event is an echo of our own save. * If the updatedAt matches, it's an echo — removes the entry and returns true. */ -export function isLocalEcho(entryId: string, fieldName: FieldName, updatedAt: string): boolean { +export function isLocalSaveEcho(entryId: string, fieldName: FieldName, updatedAt: string): boolean { const key = echoKey(entryId, fieldName) const localTs = localSaveTimestamps.get(key) if (localTs === updatedAt) { @@ -37,6 +40,7 @@ export function isLocalEcho(entryId: string, fieldName: FieldName, updatedAt: st /** Clear all echo markers and remote-update timers (for logout/vault lock). */ export function clearEchoMarkers(): void { localSaveTimestamps.clear() + localKeyRotations.clear() for (const timer of remoteUpdateTimers.values()) { clearTimeout(timer) } @@ -61,3 +65,21 @@ export function scheduleRemoteUpdateClear(entryId: string, fieldName: FieldName, }, 3000) remoteUpdateTimers.set(key, timer) } + +/** Record a local key rotation so the echo can be suppressed. Call before the rotation RPC. */ +export function markLocalKeyRotation(fieldName: FieldName, version: number): void { + localKeyRotations.set(fieldName, version) +} + +/** + * True if an `onKeyRotation` event matches a rotation we initiated locally. + * Consumes the marker on a match so it only suppresses the first matching echo. + */ +export function isLocalKeyRotationEcho(fieldName: FieldName, version: number): boolean { + const marked = localKeyRotations.get(fieldName) + if (marked === version) { + localKeyRotations.delete(fieldName) + return true + } + return false +} diff --git a/src/shared/realtime/realtime.types.ts b/src/shared/realtime/realtime.types.ts index 7342fef..315b4a1 100644 --- a/src/shared/realtime/realtime.types.ts +++ b/src/shared/realtime/realtime.types.ts @@ -1,4 +1,5 @@ import type { ServerEncryptedField } from '@/shared/types/api.types' +import type { FieldName } from '@/shared/types/entities/field.types' /** The kind of row change a realtime `entries` event represents. */ export type RealtimeEntryEventType = 'INSERT' | 'UPDATE' | 'DELETE' @@ -14,7 +15,7 @@ export interface RealtimeEntryChange { export interface RealtimeCallbacks { onFieldChange: (data: ServerEncryptedField) => void onEntryChange: (change: RealtimeEntryChange) => void - onKeyRotation: (fieldName: string, newVersion: number) => void + onKeyRotation: (fieldName: FieldName, newVersion: number) => void onError: (error: Error) => void } diff --git a/src/shared/realtime/supabase-realtime.ts b/src/shared/realtime/supabase-realtime.ts index e1544ea..a75773c 100644 --- a/src/shared/realtime/supabase-realtime.ts +++ b/src/shared/realtime/supabase-realtime.ts @@ -56,7 +56,7 @@ class SupabaseRealtimeAdapter implements IRealtimeAdapter { // row (UPDATE), never deleting one if (payload.eventType === 'DELETE') return const row = payload.new as FieldKeyRow - callbacks.onKeyRotation(row.field_name, row.version) + callbacks.onKeyRotation(row.field_name as FieldName, row.version) }) .subscribe((status, error) => { // Realtime is best-effort: surface errors but never reject the promise or block the UI. diff --git a/src/shared/types/api.types.ts b/src/shared/types/api.types.ts index e45c356..e29ec25 100644 --- a/src/shared/types/api.types.ts +++ b/src/shared/types/api.types.ts @@ -65,3 +65,19 @@ export interface RecoverAccountData { newWrappedMasterKey: string newMasterKeyIV: string } + +/** One re-encrypted ciphertext in the rotation RPC payload. */ +export interface ReEncryptedField { + entryId: string + ciphertext: string + ciphertextIV: string +} + +/** Inputs to the field-key rotation RPC. */ +export interface RotateFieldKeyRpcInput { + fieldName: FieldName + newVersion: number + newWrappedFieldKey: string // 96 hex chars + newFieldKeyIV: string // 24 hex chars + reEncryptedFields: ReEncryptedField[] +} diff --git a/src/shared/types/crypto.types.ts b/src/shared/types/crypto.types.ts index f1d4ce7..447f364 100644 --- a/src/shared/types/crypto.types.ts +++ b/src/shared/types/crypto.types.ts @@ -7,9 +7,12 @@ export const CRYPTO_SALT_LENGTH = 16 as const /** System-wide cryptographic iv length in bytes (96 bits). */ export const CRYPTO_IV_LENGTH = 12 as const -/** System-wide cryptographic field key version number. */ +/** Key version for field key wrapping — increments on each rotation. */ export const FIELD_KEY_VERSION = 1 as const +/** Content/cipher scheme version for field encryption — only changes if the encryption scheme changes. */ +export const FIELD_CONTENT_VERSION = 1 as const + /** AAD context strings for master key wrapping — prevent cross-context decryption. */ export const MASTER_KEY_PASSWORD_AAD = new TextEncoder().encode('master-key-password') export const MASTER_KEY_RECOVERY_AAD = new TextEncoder().encode('master-key-recovery') diff --git a/src/shared/types/supabase-schema.ts b/src/shared/types/supabase-schema.ts index f9d6d54..c24ab91 100644 --- a/src/shared/types/supabase-schema.ts +++ b/src/shared/types/supabase-schema.ts @@ -21,6 +21,9 @@ export const RECOVER_ACCOUNT_RPC = 'recover_account' /** RPC function name for saving recovery data (authenticated, bcrypt-hashes auth proof). */ export const SAVE_RECOVERY_DATA_RPC = 'save_recovery_data' +/** RPC function name for atomic field-key rotation (authenticated, SECURITY DEFINER). */ +export const ROTATE_FIELD_KEY_RPC = 'rotate_field_key' + /** Snake_case row delivered by Supabase for an `encrypted_fields` change. */ export interface EncryptedFieldRow { entry_id: string diff --git a/src/shared/ui/collapsible.tsx b/src/shared/ui/collapsible.tsx new file mode 100644 index 0000000..773d59e --- /dev/null +++ b/src/shared/ui/collapsible.tsx @@ -0,0 +1,27 @@ +import { Collapsible } from '@base-ui/react/collapsible' + +import { cn } from '@/shared/lib/utils' + +function CollapsibleRoot({ className, ...props }: Collapsible.Root.Props) { + return +} + +function CollapsibleTrigger({ className, ...props }: Collapsible.Trigger.Props) { + return +} + +function CollapsiblePanel({ className, keepMounted, ...props }: Collapsible.Panel.Props & { keepMounted?: boolean }) { + return ( + + ) +} + +export { CollapsibleRoot, CollapsibleTrigger, CollapsiblePanel } diff --git a/src/shared/ui/create-dialog-store.ts b/src/shared/ui/create-dialog-store.ts index 95f261c..a41af6c 100644 --- a/src/shared/ui/create-dialog-store.ts +++ b/src/shared/ui/create-dialog-store.ts @@ -23,3 +23,29 @@ export function createDialogStore(name: string) { ), ) } + +interface PayloadDialogStore { + isOpen: boolean + payload: TPayload | null + open: (payload: TPayload) => void + close: () => void +} + +/** + * Like createDialogStore, but `open(payload)` stashes a payload the dialog reads + * when it renders. Use for dialogs that need to know *what* they're acting on + * (e.g. which field to rotate). `close()` clears the payload. + */ +export function createDialogStoreWithPayload(name: string) { + return create>()( + devtools( + (set) => ({ + isOpen: false, + payload: null, + open: (payload: TPayload) => set({ isOpen: true, payload }, false, `${name}/open`), + close: () => set({ isOpen: false, payload: null }, false, `${name}/close`), + }), + { name }, + ), + ) +} diff --git a/src/shared/ui/sonner.tsx b/src/shared/ui/sonner.tsx index c3df49e..b9f30f4 100644 --- a/src/shared/ui/sonner.tsx +++ b/src/shared/ui/sonner.tsx @@ -9,6 +9,8 @@ const Toaster = ({ ...props }: ToasterProps) => { >'field_name')::public.field_name; + v_new_version := (p_payload->>'new_version')::INTEGER; + v_wrapped_key := p_payload->>'new_wrapped_field_key'; + v_key_iv := p_payload->>'new_field_key_iv'; + v_fields := COALESCE(p_payload->'re_encrypted_fields', '[]'::jsonb); + + IF v_new_version < 1 THEN + RAISE EXCEPTION 'Invalid version'; + END IF; + + -- 1. Insert the new wrapped field key (v_new). The UNIQUE(user_id, field_name, version) + -- constraint lets v_new coexist with v_old until step 3. + INSERT INTO public.field_keys (user_id, field_name, version, wrapped_field_key, field_key_iv) + VALUES (v_user_id, v_field_name, v_new_version, v_wrapped_key, v_key_iv); + + -- 2. Replace every entry's ciphertext for this field with the re-encrypted content. + -- user_id filter is defensive (SECURITY DEFINER bypasses RLS); the row must exist. + FOR v_row IN SELECT * FROM jsonb_array_elements(v_fields) LOOP + UPDATE public.encrypted_fields + SET ciphertext = v_row->>'ciphertext', + ciphertext_iv = v_row->>'ciphertext_iv', + updated_at = now() + WHERE entry_id = (v_row->>'entry_id')::uuid + AND field_name = v_field_name + AND user_id = v_user_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'Encrypted field not found for entry %', v_row->>'entry_id'; + END IF; + END LOOP; + + -- 3. Delete all older wrapped-key versions for this field. + DELETE FROM public.field_keys + WHERE user_id = v_user_id AND field_name = v_field_name AND version < v_new_version; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.rotate_field_key(JSONB) TO authenticated;