From 865083e58bd64c0d4200dbdfe533c2ca9f955613 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Sun, 5 Jul 2026 10:22:20 +0200 Subject: [PATCH 1/8] feat: add atomic field-key rotation RPC and adapters --- src/shared/api/supabase-fields.ts | 19 +++++ src/shared/api/supabase-keys.ts | 28 +++++++ src/shared/types/api.types.ts | 16 ++++ src/shared/types/supabase-schema.ts | 3 + .../migrations/00007_key_rotation_rpc.sql | 80 +++++++++++++++++++ 5 files changed, 146 insertions(+) create mode 100644 supabase/migrations/00007_key_rotation_rpc.sql 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.ts b/src/shared/api/supabase-keys.ts index d7c2cf9..1b40d11 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 and re-encrypted ciphertexts server-side. + * The SECURITY DEFINER RPC inserts the new wrapped key version, replaces + * every entry's ciphertext for that field, and deletes the old version — + * all in one transaction. The user id is read from auth.uid() inside the + * function, 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/types/api.types.ts b/src/shared/types/api.types.ts index e45c356..44d6854 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/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/supabase/migrations/00007_key_rotation_rpc.sql b/supabase/migrations/00007_key_rotation_rpc.sql new file mode 100644 index 0000000..3a501fb --- /dev/null +++ b/supabase/migrations/00007_key_rotation_rpc.sql @@ -0,0 +1,80 @@ +-- ============================================ +-- Cipher Note: Key Rotation RPC Function +-- ============================================ +-- A single SECURITY DEFINER RPC that performs an atomic field-key rotation: +-- +-- 1. Insert the new wrapped field key version (v_new). +-- 2. Replace every entry's ciphertext for that field with re-encrypted content. +-- 3. Delete all older wrapped-key versions for that field. +-- +-- All three steps run in one transaction, so the server is either all-v_old or +-- all-v_new — never mixed. A failure at any step rolls the whole thing back, +-- leaving the vault in its pre-rotation state. The client never needs +-- version-fallback logic. +-- +-- Caller identity comes from auth.uid() inside the function (SECURITY DEFINER +-- bypasses RLS), so no user_id is passed — there is no impersonation surface. +-- The public.field_name enum cast rejects invalid field names at the type +-- boundary. NOT FOUND on an UPDATE raises → whole tx rolls back → no mixed state. +-- +-- The realtime publication already includes field_keys and encrypted_fields +-- (00002_rls_policies.sql:167-169), so INSERT/UPDATE/DELETE emit postgres_changes +-- events. The DELETE on field_keys is intentionally ignored by the receiver +-- (supabase-realtime.ts). + +CREATE OR REPLACE FUNCTION public.rotate_field_key(p_payload JSONB) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_user_id UUID := auth.uid(); + v_field_name public.field_name; + v_new_version INTEGER; + v_wrapped_key TEXT; + v_key_iv TEXT; + v_fields JSONB; + v_row JSONB; +BEGIN + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + v_field_name := (p_payload->>'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; \ No newline at end of file From 0ee3cedc49325c37975591ffab5aba9085632c76 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Sun, 5 Jul 2026 10:46:50 +0200 Subject: [PATCH 2/8] feat: add field-key rotation crypto, service, and error mapping --- .../model/key-rotation-error-messages.ts | 34 +++++++ .../fields/model/key-rotation-service.ts | 96 +++++++++++++++++++ src/shared/crypto/keys/key-rotation.ts | 88 +++++++++++++++++ src/shared/crypto/vault/crypto-store.ts | 23 ++++- 4 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 src/features/fields/model/key-rotation-error-messages.ts create mode 100644 src/features/fields/model/key-rotation-service.ts create mode 100644 src/shared/crypto/keys/key-rotation.ts 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.ts b/src/features/fields/model/key-rotation-service.ts new file mode 100644 index 0000000..65675c7 --- /dev/null +++ b/src/features/fields/model/key-rotation-service.ts @@ -0,0 +1,96 @@ +/** + * 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 { rotateFieldKeyCrypto } from '@/shared/crypto/keys/key-rotation' +import { FIELD_NAMES } from '@/shared/types/entities/field.types' +import type { FieldName } from '@/shared/types/entities/field.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 + +/** + * Highest wrapped-key version currently stored for a field, from the cached + * envelope. After an atomic swap there is exactly one version per field. + */ +function maxVersionForField(fieldName: FieldName): number { + const envelope = useCryptoStore.getState().cachedEnvelope + if (!envelope) throw new Error('No cached envelope — vault is locked') + const versions = envelope.fieldKeys.filter((k) => k.fieldName === fieldName).map((k) => k.version) + if (versions.length === 0) throw new Error(`No field key found for "${fieldName}"`) + return Math.max(...versions) +} + +/** + * 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') + const oldFieldKey = keyVault.getKey(fieldName) + if (!kek || !oldFieldKey) throw new Error('Vault is locked — cannot rotate') + + const currentVersion = maxVersionForField(fieldName) + const currentCiphertexts = await fetchAllEncryptedFieldsForUser(userId, fieldName) + + const result = await rotateFieldKeyCrypto({ + kek, + oldFieldKey, + fieldName, + currentVersion, + currentCiphertexts: currentCiphertexts.map((f) => ({ + entryId: f.entryId, + ciphertext: f.ciphertext, + ciphertextIv: f.ciphertextIV, + })), + }) + + await rotateFieldKeyRpc({ + fieldName, + newVersion: result.newVersion, + newWrappedFieldKey: result.newWrappedFieldKey, + newFieldKeyIv: result.newFieldKeyIv, + reEncryptedFields: result.reEncryptedFields, + }) + + // Local state update only. Store the new key and update the cached envelope. + keyVault.storeKey(fieldName, result.newCryptoKey) + useCryptoStore.getState().updateCachedFieldKey({ + fieldName, + newVersion: result.newVersion, + newWrappedFieldKey: result.newWrappedFieldKey, + newFieldKeyIv: result.newFieldKeyIv, + }) +} + +/** + * 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 { + await rotateFieldKey(userId, fieldName) + outcomes.push({ fieldName, ok: true, newVersion: maxVersionForField(fieldName) }) + } catch (error) { + outcomes.push({ fieldName, ok: false, error }) + } + } + return outcomes +} diff --git a/src/shared/crypto/keys/key-rotation.ts b/src/shared/crypto/keys/key-rotation.ts new file mode 100644 index 0000000..1fda1de --- /dev/null +++ b/src/shared/crypto/keys/key-rotation.ts @@ -0,0 +1,88 @@ +/** + * Atomic field-key rotation: pure crypto. + * + * Derives the next field-key version, wraps the new key with the KEK, and + * re-encrypts every entry's ciphertext for that field from the old field key + * to the new one. Pure — no network, no store, no side effects beyond crypto + * state. + */ + +import { encrypt, decrypt, importKey } from '@/shared/crypto/core/aes-gcm' +import { generateKey, generateIV, encodeAAD, hexEncode, hexDecode, zeroFill } from '@/shared/crypto/core/crypto-utils' +import { FIELD_KEY_VERSION } from '@/shared/types/crypto.types' + +export type ReEncryptedFieldResult = { + entryId: string + ciphertext: string // hex + ciphertextIv: string // hex +} + +export type RotationResult = { + newCryptoKey: CryptoKey + newVersion: number + newWrappedFieldKey: string // hex + newFieldKeyIv: string // hex + reEncryptedFields: ReEncryptedFieldResult[] +} + +export type RotateFieldKeyCryptoInput = { + kek: CryptoKey + oldFieldKey: CryptoKey + fieldName: string + currentVersion: number + currentCiphertexts: { entryId: string; ciphertext: string; ciphertextIv: string }[] +} + +/** + * Derive the next field-key version and re-encrypt every ciphertext with a + * freshly generated field key. + * + * Two distinct AADs are used: + * - Wrap AAD: `encodeAAD(fieldName, newVersion)` — binds the wrapped key to its + * version, matching `field-keys.ts` so the new key unwraps correctly. + * - Content AAD: `encodeAAD(fieldName, FIELD_KEY_VERSION)` — the constant + * scheme version, matching `field-crypto.ts`. The same AAD is used for the + * old decrypt and the new encrypt; it does NOT track the rotation version. + */ +export async function rotateFieldKeyCrypto(input: RotateFieldKeyCryptoInput): Promise { + const { kek, oldFieldKey, fieldName, currentVersion, currentCiphertexts } = input + const newVersion = currentVersion + 1 + const rawNewKey = generateKey() + try { + const newCryptoKey = await importKey(rawNewKey) + + // Wrap the new field key with the KEK. AAD binds the wrap to newVersion. + const fieldKeyIv = generateIV() + const wrapAad = encodeAAD(fieldName, newVersion) + const wrappedFieldKey = await encrypt(rawNewKey, kek, { iv: fieldKeyIv, aad: wrapAad }) + + // Content encryption uses the constant FIELD_KEY_VERSION AAD (not newVersion). + const contentAad = encodeAAD(fieldName, FIELD_KEY_VERSION) + + const reEncryptedFields = await Promise.all( + currentCiphertexts.map(async ({ entryId, ciphertext, ciphertextIv }) => { + const plaintext = await decrypt(hexDecode(ciphertext), oldFieldKey, { + iv: hexDecode(ciphertextIv), + aad: contentAad, + }) + const newIv = generateIV() + const newCipher = await encrypt(plaintext, newCryptoKey, { iv: newIv, aad: contentAad }) + return { + entryId, + ciphertext: hexEncode(newCipher), + ciphertextIv: hexEncode(newIv), + } + }), + ) + + return { + newCryptoKey, + newVersion, + newWrappedFieldKey: hexEncode(wrappedFieldKey), + newFieldKeyIv: hexEncode(fieldKeyIv), + reEncryptedFields, + } + } finally { + zeroFill(rawNewKey) + } +} diff --git a/src/shared/crypto/vault/crypto-store.ts b/src/shared/crypto/vault/crypto-store.ts index d02a531..9a4f6cf 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' @@ -12,9 +12,18 @@ interface CryptoState { cachedEnvelope: CachedVaultEnvelope | null } +/** Inputs to replace one field key in the cached envelope. */ +export interface UpdateCachedFieldKeyInput { + fieldName: string + newVersion: number + newWrappedFieldKey: string + newFieldKeyIv: string +} + interface CryptoActions { markKeysLoaded: (fieldKeyNames: string[]) => void setCachedEnvelope: (envelope: CachedVaultEnvelope) => void + updateCachedFieldKey: (input: UpdateCachedFieldKeyInput) => void lockVault: () => void clearVault: () => void updateActivity: () => void @@ -50,6 +59,18 @@ const useCryptoStore = create()((set) => ({ lastActivity: Date.now(), }), setCachedEnvelope: (envelope) => set({ cachedEnvelope: envelope }), + updateCachedFieldKey: (input) => + set((state) => { + if (!state.cachedEnvelope) return {} + const others = state.cachedEnvelope.fieldKeys.filter((k) => k.fieldName !== input.fieldName) + const rotated: ServerFieldKey = { + fieldName: input.fieldName, + version: input.newVersion, + wrappedFieldKey: input.newWrappedFieldKey, + fieldKeyIV: input.newFieldKeyIv, + } + return { cachedEnvelope: { ...state.cachedEnvelope, fieldKeys: [...others, rotated] } } + }), lockVault: () => { set({ loadedFieldKeys: {}, From 4c9fbc7fc025cf5d462b26f7f82b896e3210b1bb Mon Sep 17 00:00:00 2001 From: VitekHub Date: Sun, 5 Jul 2026 10:54:11 +0200 Subject: [PATCH 3/8] feat: add field-key rotation UI, dialog, and realtime echo --- src/app/layouts/ProtectedLayout.tsx | 2 + .../fields/model/key-rotation-service.ts | 6 + .../fields/model/use-realtime-sync.ts | 9 +- .../fields/ui/RotateFieldKeyDialog.tsx | 170 ++++++++++++++++++ .../settings/ui/KeyRotationSection.tsx | 71 ++++++++ src/features/settings/ui/SecuritySection.tsx | 3 +- src/features/settings/ui/SettingsPage.tsx | 2 + src/shared/auth/auth-dialogs-store.ts | 12 +- src/shared/i18n/locales/cs/settings.json | 25 +++ src/shared/i18n/locales/cs/vault.json | 6 + src/shared/i18n/locales/en/settings.json | 25 +++ src/shared/i18n/locales/en/vault.json | 6 + src/shared/realtime/realtime-echo.ts | 27 +++ src/shared/ui/create-dialog-store.ts | 26 +++ 14 files changed, 385 insertions(+), 5 deletions(-) create mode 100644 src/features/fields/ui/RotateFieldKeyDialog.tsx create mode 100644 src/features/settings/ui/KeyRotationSection.tsx 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/fields/model/key-rotation-service.ts b/src/features/fields/model/key-rotation-service.ts index 65675c7..6acdf5d 100644 --- a/src/features/fields/model/key-rotation-service.ts +++ b/src/features/fields/model/key-rotation-service.ts @@ -10,6 +10,7 @@ import { useCryptoStore } from '@/shared/crypto/vault/crypto-store' import { fetchAllEncryptedFieldsForUser } from '@/shared/api/supabase-fields' import { rotateFieldKeyRpc } from '@/shared/api/supabase-keys' import { rotateFieldKeyCrypto } from '@/shared/crypto/keys/key-rotation' +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' @@ -58,6 +59,11 @@ export async function rotateFieldKey(userId: string, fieldName: FieldName): Prom })), }) + // Mark this rotation as locally initiated so the realtime echo of our own + // write doesn't double-toast. Placed right before the RPC so the marker + // is set before the DB write can trigger the broadcast. + markLocalKeyRotation(fieldName, result.newVersion) + await rotateFieldKeyRpc({ fieldName, newVersion: result.newVersion, diff --git a/src/features/fields/model/use-realtime-sync.ts b/src/features/fields/model/use-realtime-sync.ts index a123b93..ed1cea9 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 { isLocalEcho, scheduleRemoteUpdateClear, isLocalKeyRotationEcho } from '@/shared/realtime/realtime-echo' import type { ServerEncryptedField } from '@/shared/types/api.types' import type { FieldName } from '@/shared/types/entities/field.types' @@ -83,11 +83,16 @@ function useRealtimeSync(): void { // Promise. Any unhandled rejection is caught here, not by the adapter void (async () => { const { t, queryClient } = cbRef.current + // Echo of our own rotation: skip the toast (we already toasted + // locally) but still sync + invalidate so the vault matches. + const isEcho = isLocalKeyRotationEcho(fieldName, newVersion) try { await keyVault.syncFieldKeys(userId) // Invalidate all field queries: any entry's field could be affected queryClient.invalidateQueries({ queryKey: queryKeys.field.all }) - toast.success(t('realtime.keyRotationApplied', { field: fieldName, version: newVersion })) + if (!isEcho) { + toast.success(t('realtime.keyRotationApplied', { field: fieldName, version: newVersion })) + } } catch (error) { if (error instanceof DecryptionError) { toast.error(t('realtime.keyRotationFailed')) diff --git a/src/features/fields/ui/RotateFieldKeyDialog.tsx b/src/features/fields/ui/RotateFieldKeyDialog.tsx new file mode 100644 index 0000000..4df151a --- /dev/null +++ b/src/features/fields/ui/RotateFieldKeyDialog.tsx @@ -0,0 +1,170 @@ +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 { useCryptoStore } from '@/shared/crypto/vault/crypto-store' +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', +} + +/** Highest wrapped-key version stored for a field, from the cached envelope. */ +function currentVersionFor(fieldName: FieldName): number { + const fieldKeys = useCryptoStore.getState().cachedEnvelope?.fieldKeys + const versions = (fieldKeys ?? []).filter((k) => k.fieldName === fieldName).map((k) => k.version) + return versions.length > 0 ? Math.max(...versions) : 1 +} + +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 { + await rotateFieldKey(userId, name) + queryClient.invalidateQueries({ queryKey: queryKeys.field.all }) + const newVersion = currentVersionFor(name) + 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 { + await rotateFieldKey(userId, name) + succeeded.push({ name, version: currentVersionFor(name) }) + } 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/KeyRotationSection.tsx b/src/features/settings/ui/KeyRotationSection.tsx new file mode 100644 index 0000000..74887b3 --- /dev/null +++ b/src/features/settings/ui/KeyRotationSection.tsx @@ -0,0 +1,71 @@ +import { Fragment } from 'react' +import { useTranslation } from 'react-i18next' + +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/shared/ui/card' +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 KeyRotationSection() { + 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.keyVersions')} + {t('keyRotation.description')} + + + {FIELD_NAMES.map((fieldName, i) => ( + + {i > 0 && } +
+ + {t(FIELD_LABEL_KEYS[fieldName])} + + {t('keyRotation.version', { version: versionFor(fieldKeys, fieldName) })} + + + +
+
+ ))} +
+ + + {isVaultLocked &&

{t('keyRotation.locked')}

} +
+
+ ) +} + +export { KeyRotationSection } diff --git a/src/features/settings/ui/SecuritySection.tsx b/src/features/settings/ui/SecuritySection.tsx index 0a7ce88..4181834 100644 --- a/src/features/settings/ui/SecuritySection.tsx +++ b/src/features/settings/ui/SecuritySection.tsx @@ -1,6 +1,6 @@ import { Fragment } from 'react' import { useTranslation } from 'react-i18next' -import { ChevronRight, KeyRound, ShieldCheck, ScanEye, Fingerprint, type LucideIcon } from 'lucide-react' +import { ChevronRight, KeyRound, ShieldCheck, ScanEye, type LucideIcon } from 'lucide-react' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/shared/ui/card' import { Separator } from '@/shared/ui/separator' @@ -26,7 +26,6 @@ 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 }) { diff --git a/src/features/settings/ui/SettingsPage.tsx b/src/features/settings/ui/SettingsPage.tsx index cef2432..5fa8850 100644 --- a/src/features/settings/ui/SettingsPage.tsx +++ b/src/features/settings/ui/SettingsPage.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next' import { SecuritySection } from '@/features/settings/ui/SecuritySection' +import { KeyRotationSection } from '@/features/settings/ui/KeyRotationSection' import { PreferencesSection } from '@/features/settings/ui/PreferencesSection' import { AccountSection } from '@/features/settings/ui/AccountSection' @@ -12,6 +13,7 @@ function SettingsPage() {

{t('title')}

+
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/i18n/locales/cs/settings.json b/src/shared/i18n/locales/cs/settings.json index 1c60367..16e927f 100644 --- a/src/shared/i18n/locales/cs/settings.json +++ b/src/shared/i18n/locales/cs/settings.json @@ -23,5 +23,30 @@ "description": "Správa nastavení účtu.", "username": "Uživatelské jméno", "deleteAccount": "Smazat účet" + }, + "keyRotation": { + "description": "Rotuje šifrovací klíč jednoho pole. Znovu zašifruje data tohoto pole napříč všemi záznamy.", + "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..3fee3be 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -23,5 +23,30 @@ "description": "Manage your account settings.", "username": "Username", "deleteAccount": "Delete account" + }, + "keyRotation": { + "description": "Rotate the encryption key for a single field. Re-encrypts that field's data across all entries.", + "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.ts b/src/shared/realtime/realtime-echo.ts index 7ec6ccd..538a467 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>() @@ -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,26 @@ export function scheduleRemoteUpdateClear(entryId: string, fieldName: FieldName, }, 3000) remoteUpdateTimers.set(key, timer) } + +/** + * Mark that we just rotated a field key locally to `version`. The realtime + * broadcast of our own rotation will bounce back as an `onKeyRotation` event; + * the receiver uses `isLocalKeyRotationEcho` to suppress the redundant toast + * while still syncing the vault. Call this right 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/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 }, + ), + ) +} From 4025ef9e9da42f760947de11ac880f52152c4551 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Sun, 5 Jul 2026 11:14:06 +0200 Subject: [PATCH 4/8] test: add field-key rotation tests --- .../auth/model/mnemonic-service.test.ts | 1 + .../model/key-rotation-error-messages.test.ts | 34 +++ .../fields/model/key-rotation-service.test.ts | 271 ++++++++++++++++++ .../fields/model/use-realtime-sync.test.ts | 30 +- .../fields/ui/RotateFieldKeyDialog.test.tsx | 215 ++++++++++++++ .../settings/ui/KeyRotationSection.test.tsx | 119 ++++++++ .../settings/ui/SecuritySection.test.tsx | 9 +- src/shared/api/supabase-fields.test.ts | 93 +++++- src/shared/api/supabase-keys.test.ts | 76 +++++ src/shared/crypto/crypto-integration.test.ts | 76 ++++- src/shared/crypto/keys/field-keys.test.ts | 43 ++- src/shared/crypto/keys/key-rotation.test.ts | 245 ++++++++++++++++ src/shared/crypto/vault/key-vault.test.ts | 20 ++ src/shared/realtime/realtime.types.ts | 3 +- src/shared/realtime/supabase-realtime.ts | 2 +- 15 files changed, 1225 insertions(+), 12 deletions(-) create mode 100644 src/features/fields/model/key-rotation-error-messages.test.ts create mode 100644 src/features/fields/model/key-rotation-service.test.ts create mode 100644 src/features/fields/ui/RotateFieldKeyDialog.test.tsx create mode 100644 src/features/settings/ui/KeyRotationSection.test.tsx create mode 100644 src/shared/crypto/keys/key-rotation.test.ts diff --git a/src/features/auth/model/mnemonic-service.test.ts b/src/features/auth/model/mnemonic-service.test.ts index 317fb4e..10862bb 100644 --- a/src/features/auth/model/mnemonic-service.test.ts +++ b/src/features/auth/model/mnemonic-service.test.ts @@ -203,6 +203,7 @@ describe('regenerateMnemonic', () => { lockVault: vi.fn(), clearVault: vi.fn(), updateActivity: vi.fn(), + updateCachedFieldKey: vi.fn(), } as ReturnType) await regenerateMnemonic('password') diff --git a/src/features/fields/model/key-rotation-error-messages.test.ts b/src/features/fields/model/key-rotation-error-messages.test.ts new file mode 100644 index 0000000..a298a4a --- /dev/null +++ b/src/features/fields/model/key-rotation-error-messages.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest' +import type { TFunction } from 'i18next' +import { DecryptionError } from '@/shared/crypto/core/errors' +import { AuthError, AuthErrorCode } from '@/shared/auth/auth-errors' +import { ApiError, ApiErrorCode } from '@/shared/api/api-errors' +import { getKeyRotationErrorMessage } from '@/features/fields/model/key-rotation-error-messages' + +const mockT = ((key: string) => key) as unknown as TFunction<'vault'> + +describe('getKeyRotationErrorMessage', () => { + it('maps DecryptionError to the stale-vault message', () => { + expect(getKeyRotationErrorMessage(new DecryptionError(), mockT)).toBe('rotation.staleVault') + }) + + it('maps ApiError(NETWORK_ERROR) to the network message', () => { + expect(getKeyRotationErrorMessage(new ApiError(ApiErrorCode.NETWORK_ERROR), mockT)).toBe('rotation.networkError') + }) + + it('maps ApiError(UNEXPECTED) to the "unchanged" message', () => { + expect(getKeyRotationErrorMessage(new ApiError(ApiErrorCode.UNEXPECTED), mockT)).toBe('rotation.failed') + }) + + it('maps AuthError(NETWORK_ERROR) to the network message', () => { + expect(getKeyRotationErrorMessage(new AuthError(AuthErrorCode.NETWORK_ERROR), mockT)).toBe('rotation.networkError') + }) + + it('maps a raw TypeError("Failed to fetch") (network bypass) to the network message', () => { + expect(getKeyRotationErrorMessage(new TypeError('Failed to fetch'), mockT)).toBe('rotation.networkError') + }) + + it('maps a generic locked-vault Error to the locked message', () => { + expect(getKeyRotationErrorMessage(new Error('Vault is locked — cannot rotate'), mockT)).toBe('rotation.locked') + }) +}) 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..9591ed3 --- /dev/null +++ b/src/features/fields/model/key-rotation-service.test.ts @@ -0,0 +1,271 @@ +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' + +const { + mockGetKey, + mockStoreKey, + mockFetchAll, + mockRotateRpc, + mockRotateCrypto, + 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>(), + mockRotateCrypto: vi.fn<(input: unknown) => 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/key-rotation', () => ({ + rotateFieldKeyCrypto: mockRotateCrypto, +})) + +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', + }, + ] +} + +describe('rotateFieldKey', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetKey.mockImplementation((id: string) => (id === 'kek' ? mockKek : mockOldKey)) + mockFetchAll.mockResolvedValue(twoServerFields()) + mockRotateRpc.mockResolvedValue(undefined) + mockRotateCrypto.mockImplementation((input: unknown) => { + const { currentVersion, currentCiphertexts } = input as { + currentVersion: number + currentCiphertexts: { entryId: string }[] + } + return Promise.resolve({ + newCryptoKey: mockNewKey, + newVersion: currentVersion + 1, + newWrappedFieldKey: 'ff'.repeat(48), + newFieldKeyIv: 'ee'.repeat(12), + reEncryptedFields: currentCiphertexts.map((c) => ({ + entryId: c.entryId, + ciphertext: 'cc'.repeat(16), + ciphertextIv: 'dd'.repeat(12), + })), + }) + }) + }) + + 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') + + // Pure crypto called with vault inputs + current version from the cached envelope. + expect(mockRotateCrypto).toHaveBeenCalledWith({ + kek: mockKek, + oldFieldKey: mockOldKey, + fieldName: 'note', + currentVersion: 1, + currentCiphertexts: [ + { entryId: 'entry-1', ciphertext: 'aa'.repeat(16), ciphertextIv: 'bb'.repeat(12) }, + { entryId: 'entry-2', ciphertext: 'cc'.repeat(16), ciphertextIv: 'dd'.repeat(12) }, + ], + }) + + // 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 the crypto result. + 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', + newVersion: 2, + newWrappedFieldKey: 'ff'.repeat(48), + newFieldKeyIv: '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(mockRotateCrypto).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(mockRotateCrypto).toHaveBeenCalledTimes(1) + expect(mockRotateRpc).toHaveBeenCalledTimes(1) + expect(mockStoreKey).not.toHaveBeenCalled() + expect(mockUpdateCachedFieldKey).not.toHaveBeenCalled() + }) + + it('still calls the RPC with an empty ciphertext list when there are no entries', async () => { + mockFetchAll.mockResolvedValueOnce([]) + + await rotateFieldKey(USER_ID, 'note') + + expect(mockRotateCrypto).toHaveBeenCalledWith(expect.objectContaining({ currentCiphertexts: [] })) + 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) + mockRotateCrypto.mockImplementation((input: unknown) => { + const { currentVersion } = input as { currentVersion: number } + return Promise.resolve({ + newCryptoKey: mockNewKey, + newVersion: currentVersion + 1, + newWrappedFieldKey: 'ff'.repeat(48), + newFieldKeyIv: 'ee'.repeat(12), + reEncryptedFields: [], + }) + }) + // Reflect successful rotations in the cached envelope so maxVersionForField + // reports the new version for the per-field outcome. + mockUpdateCachedFieldKey.mockImplementation((input: unknown) => { + const { fieldName, newVersion } = input as { fieldName: FieldName; newVersion: 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: newVersion }) + 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/use-realtime-sync.test.ts b/src/features/fields/model/use-realtime-sync.test.ts index ea693ee..3ba77d9 100644 --- a/src/features/fields/model/use-realtime-sync.test.ts +++ b/src/features/fields/model/use-realtime-sync.test.ts @@ -53,7 +53,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' @@ -270,6 +270,34 @@ describe('useRealtimeSync', () => { expect(ctx.toastSuccess.mock.calls[0][0]).toEqual(expect.any(String)) }) + it('suppresses the toast for a locally-initiated rotation echo but still syncs and invalidates', async () => { + markLocalKeyRotation('note', 2) + + renderHook(() => useRealtimeSync(), { wrapper: createWrapper(queryClient) }) + + callbacks().onKeyRotation('note', 2) + + await waitFor(() => { + expect(ctx.mockSyncFieldKeys).toHaveBeenCalledWith('user-123') + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.field.all }) + // No toast: the initiator already toasted locally. + 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 () => { ctx.mockSyncFieldKeys.mockRejectedValue(new 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..86f74f6 --- /dev/null +++ b/src/features/fields/ui/RotateFieldKeyDialog.test.tsx @@ -0,0 +1,215 @@ +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() { + mockRotateFieldKey.mockImplementation(async (_userId: string, fieldName: FieldName) => { + 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, + ), + }, + } + }) + }) +} + +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. + mockRotateFieldKey.mockImplementation(async (_userId: string, fieldName: FieldName) => { + if (fieldName === 'website') throw new ApiError(ApiErrorCode.NETWORK_ERROR) + 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, + ), + }, + } + }) + }) + + 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/settings/ui/KeyRotationSection.test.tsx b/src/features/settings/ui/KeyRotationSection.test.tsx new file mode 100644 index 0000000..118b050 --- /dev/null +++ b/src/features/settings/ui/KeyRotationSection.test.tsx @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen, act } 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 type { CachedVaultEnvelope } from '@/shared/types/api.types' +import { KeyRotationSection } from './KeyRotationSection' + +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 })), + } +} + +function setUnlocked(envelope: CachedVaultEnvelope) { + useCryptoStore.setState({ isVaultLocked: false, cachedEnvelope: envelope }) +} + +describe('KeyRotationSection', () => { + beforeEach(() => { + useRotateFieldKeyDialogStore.setState({ isOpen: false, payload: null }) + }) + + it('renders four field rows with versions read from the cached envelope', () => { + setUnlocked(envelopeWith({ note: 2 })) + + render() + + expect(screen.getByText('Title')).toBeInTheDocument() + expect(screen.getByText('Note')).toBeInTheDocument() + expect(screen.getByText('Website')).toBeInTheDocument() + expect(screen.getByText('Email')).toBeInTheDocument() + + // title, website, email at v1; note at v2. + expect(screen.getAllByText('v1')).toHaveLength(3) + expect(screen.getByText('v2')).toBeInTheDocument() + }) + + it('shows v1 for every field when the cached envelope has no field keys', () => { + useCryptoStore.setState({ isVaultLocked: false, cachedEnvelope: null }) + + render() + + expect(screen.getAllByText('v1')).toHaveLength(4) + }) + + it('disables all rotate buttons and shows the unlock hint when the vault is locked', () => { + useCryptoStore.setState({ isVaultLocked: true, cachedEnvelope: envelopeWith({}) }) + + render() + + for (const button of screen.getAllByRole('button', { name: 'Rotate' })) { + expect(button).toBeDisabled() + } + expect(screen.getByRole('button', { name: 'Rotate all field keys' })).toBeDisabled() + expect(screen.getByText('Unlock the vault to rotate keys')).toBeInTheDocument() + }) + + it('does not show the unlock hint when the vault is unlocked', () => { + setUnlocked(envelopeWith({})) + + render() + + expect(screen.queryByText('Unlock the vault to rotate keys')).not.toBeInTheDocument() + for (const button of screen.getAllByRole('button', { name: 'Rotate' })) { + expect(button).toBeEnabled() + } + }) + + it('opens the rotation dialog for a single field when clicking "Rotate"', async () => { + const user = userEvent.setup() + setUnlocked(envelopeWith({})) + + render() + + await user.click(screen.getAllByRole('button', { name: 'Rotate' })[1]!) // Note row + + expect(useRotateFieldKeyDialogStore.getState().isOpen).toBe(true) + expect(useRotateFieldKeyDialogStore.getState().payload).toEqual({ fieldName: 'note' }) + }) + + it('opens the rotate-all dialog when clicking "Rotate all field keys"', async () => { + const user = userEvent.setup() + setUnlocked(envelopeWith({})) + + render() + + await user.click(screen.getByRole('button', { name: 'Rotate all field keys' })) + + expect(useRotateFieldKeyDialogStore.getState().isOpen).toBe(true) + expect(useRotateFieldKeyDialogStore.getState().payload).toEqual({ fieldName: null }) + }) + + it('reflects an updated version when the cached envelope changes', () => { + setUnlocked(envelopeWith({ note: 1 })) + + render() + + expect(screen.queryByText('v2')).not.toBeInTheDocument() + expect(screen.getAllByText('v1')).toHaveLength(4) + + act(() => { + useCryptoStore.setState({ cachedEnvelope: envelopeWith({ note: 2 }) }) + }) + + expect(screen.getByText('v2')).toBeInTheDocument() + expect(screen.getAllByText('v1')).toHaveLength(3) + }) +}) diff --git a/src/features/settings/ui/SecuritySection.test.tsx b/src/features/settings/ui/SecuritySection.test.tsx index 9fe8ae0..b1bc98d 100644 --- a/src/features/settings/ui/SecuritySection.test.tsx +++ b/src/features/settings/ui/SecuritySection.test.tsx @@ -22,17 +22,18 @@ describe('SecuritySection', () => { expect(screen.getByText('Manage your password and security settings.')).toBeInTheDocument() }) - it('renders three action items', () => { + it('renders the security action items (key versions moved to KeyRotationSection)', () => { 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() + expect(screen.queryByText('Key versions')).not.toBeInTheDocument() }) - it('renders three separator dividers between action items', () => { + it('renders two separator dividers between action items', () => { render() const separators = screen.getAllByRole('separator') - expect(separators).toHaveLength(3) + expect(separators).toHaveLength(2) }) it('opens change password dialog when clicking "Change password"', async () => { 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-keys.test.ts b/src/shared/api/supabase-keys.test.ts index 95d1398..f8472ca 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/crypto/crypto-integration.test.ts b/src/shared/crypto/crypto-integration.test.ts index ca3fe8a..5de3f70 100644 --- a/src/shared/crypto/crypto-integration.test.ts +++ b/src/shared/crypto/crypto-integration.test.ts @@ -2,12 +2,13 @@ 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 { rotateFieldKeyCrypto } from '@/shared/crypto/keys/key-rotation' 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_KEY_VERSION, MASTER_KEY_PASSWORD_AAD } from '@/shared/types/crypto.types' +import { hexEncode, hexDecode } from '@/shared/crypto/core/crypto-utils' import type { ServerFieldKey, ServerMasterKeyEnvelope } from '@/shared/types/api.types' // Mock Argon2id module — Web Worker won't run in jsdom @@ -388,6 +389,77 @@ describe('crypto integration', () => { } await expect(unwrapFieldKeys([tampered], kek)).rejects.toThrow(DecryptionError) }) + + it('rotateFieldKeyCrypto end-to-end unwraps the new key with the same KEK and round-trips 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 the content AAD rotateFieldKeyCrypto expects. + const noteAad = encodeAAD('note', FIELD_KEY_VERSION) + const entries = [ + { entryId: 'e1', plaintext: 'note one' }, + { entryId: 'e2', plaintext: 'note two' }, + ] + const currentCiphertexts = await Promise.all( + entries.map(async ({ entryId, plaintext }) => { + const iv = generateIV() + const cipher = await encrypt(new TextEncoder().encode(plaintext) as Uint8Array, oldNoteKey, { + iv, + aad: noteAad, + }) + return { entryId, ciphertext: hexEncode(cipher), ciphertextIv: hexEncode(iv) } + }), + ) + + const result = await rotateFieldKeyCrypto({ + kek, + oldFieldKey: oldNoteKey, + fieldName: 'note', + currentVersion: noteVersion, + currentCiphertexts, + }) + + expect(result.newVersion).toBe(noteVersion + 1) + + // The new wrapped key unwraps with the same KEK + new-version wrap AAD. + const unwrappedRaw = await decrypt(hexDecode(result.newWrappedFieldKey), kek, { + iv: hexDecode(result.newFieldKeyIv), + aad: encodeAAD('note', result.newVersion), + }) + const unwrappedKey = await importKey(unwrappedRaw) + + // Re-encrypted ciphertexts decrypt with the new key and match the originals. + for (const r of result.reEncryptedFields) { + const expected = entries.find((e) => e.entryId === r.entryId)!.plaintext + const pt = await decrypt(hexDecode(r.ciphertext), unwrappedKey, { + iv: hexDecode(r.ciphertextIv), + aad: noteAad, + }) + expect(new TextDecoder().decode(pt)).toBe(expected) + } + + // The old note key can no longer decrypt the re-encrypted ciphertexts. + for (const r of result.reEncryptedFields) { + await expect( + decrypt(hexDecode(r.ciphertext), oldNoteKey, { iv: hexDecode(r.ciphertextIv), aad: noteAad }), + ).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_KEY_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..4a0466e 100644 --- a/src/shared/crypto/keys/field-keys.test.ts +++ b/src/shared/crypto/keys/field-keys.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect } from 'vitest' import { DecryptionError } from '@/shared/crypto/core/errors' -import { hexEncode } 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 { importKey, encrypt } from '@/shared/crypto/core/aes-gcm' import { generateAndWrapFieldKeys, unwrapFieldKeys } from '@/shared/crypto/keys/field-keys' import type { ServerFieldKey } from '@/shared/types/api.types' @@ -115,5 +115,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/key-rotation.test.ts b/src/shared/crypto/keys/key-rotation.test.ts new file mode 100644 index 0000000..644452c --- /dev/null +++ b/src/shared/crypto/keys/key-rotation.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, vi } from 'vitest' +import * as cryptoUtils from '@/shared/crypto/core/crypto-utils' +import { DecryptionError } from '@/shared/crypto/core/errors' +import { encrypt, decrypt, importKey } from '@/shared/crypto/core/aes-gcm' +import { generateMasterKey } from '@/shared/crypto/keys/master-key' +import { deriveKEK } from '@/shared/crypto/core/hkdf' +import { rotateFieldKeyCrypto } from '@/shared/crypto/keys/key-rotation' +import { FIELD_KEY_VERSION } from '@/shared/types/crypto.types' +import type { FieldName } from '@/shared/types/entities/field.types' + +const FIELD_NAME: FieldName = 'note' + +async function setupKek() { + const masterKey = generateMasterKey() + const kekBytes = await deriveKEK(masterKey) + const kek = await importKey(kekBytes) + return { kek } +} + +async function makeOldFieldKey() { + const rawKey = cryptoUtils.generateKey() + const fieldKey = await importKey(rawKey) + return { fieldKey, rawKey } +} + +function toBytes(text: string): Uint8Array { + return new TextEncoder().encode(text) as Uint8Array +} + +async function encryptContent(plaintext: string, fieldKey: CryptoKey) { + const contentAad = cryptoUtils.encodeAAD(FIELD_NAME, FIELD_KEY_VERSION) + const iv = cryptoUtils.generateIV() + const ciphertext = await encrypt(toBytes(plaintext), fieldKey, { iv, aad: contentAad }) + return { ciphertext: cryptoUtils.hexEncode(ciphertext), ciphertextIv: cryptoUtils.hexEncode(iv) } +} + +async function decryptContent(ciphertext: string, ciphertextIv: string, fieldKey: CryptoKey) { + const contentAad = cryptoUtils.encodeAAD(FIELD_NAME, FIELD_KEY_VERSION) + const plaintext = await decrypt(cryptoUtils.hexDecode(ciphertext), fieldKey, { + iv: cryptoUtils.hexDecode(ciphertextIv), + aad: contentAad, + }) + return new TextDecoder().decode(plaintext) +} + +describe('key-rotation', () => { + describe('rotateFieldKeyCrypto', () => { + it('produces v2 wrapped key and re-encrypted ciphertexts from v1 input', async () => { + const { kek } = await setupKek() + const { fieldKey: oldFieldKey } = await makeOldFieldKey() + + const originals = [ + { entryId: 'entry-1', plaintext: 'first secret note' }, + { entryId: 'entry-2', plaintext: 'second secret note' }, + ] + const currentCiphertexts = await Promise.all( + originals.map(async ({ entryId, plaintext }) => { + const enc = await encryptContent(plaintext, oldFieldKey) + return { entryId, ...enc } + }), + ) + + const result = await rotateFieldKeyCrypto({ + kek, + oldFieldKey, + fieldName: FIELD_NAME, + currentVersion: 1, + currentCiphertexts, + }) + + expect(result.newVersion).toBe(2) + expect(result.reEncryptedFields).toHaveLength(2) + + // Wrapped key = 32 bytes plaintext + 16-byte GCM tag → 48 bytes → 96 hex chars. + // IV = 12 bytes → 24 hex chars. + expect(result.newWrappedFieldKey).toMatch(/^[0-9a-f]{96}$/) + expect(result.newFieldKeyIv).toMatch(/^[0-9a-f]{24}$/) + + for (const r of result.reEncryptedFields) { + expect(r.entryId).toBe(originals.find((o) => o.entryId === r.entryId)!.entryId) + expect(r.ciphertext).toMatch(/^[0-9a-f]+$/) + expect(r.ciphertextIv).toMatch(/^[0-9a-f]{24}$/) + expect(r.ciphertext).not.toBe(currentCiphertexts.find((c) => c.entryId === r.entryId)!.ciphertext) + } + }) + + it('re-encrypted ciphertexts decrypt with the new field key and match the originals', async () => { + const { kek } = await setupKek() + const { fieldKey: oldFieldKey } = await makeOldFieldKey() + + const originals = [ + { entryId: 'entry-1', plaintext: 'first secret note' }, + { entryId: 'entry-2', plaintext: 'second secret note' }, + ] + const currentCiphertexts = await Promise.all( + originals.map(async ({ entryId, plaintext }) => { + const enc = await encryptContent(plaintext, oldFieldKey) + return { entryId, ...enc } + }), + ) + + const result = await rotateFieldKeyCrypto({ + kek, + oldFieldKey, + fieldName: FIELD_NAME, + currentVersion: 1, + currentCiphertexts, + }) + + for (const r of result.reEncryptedFields) { + const expected = originals.find((o) => o.entryId === r.entryId)!.plaintext + await expect(decryptContent(r.ciphertext, r.ciphertextIv, result.newCryptoKey)).resolves.toBe(expected) + } + }) + + it('the old field key fails to decrypt re-encrypted ciphertexts', async () => { + const { kek } = await setupKek() + const { fieldKey: oldFieldKey } = await makeOldFieldKey() + + const originals = [{ entryId: 'entry-1', plaintext: 'first secret note' }] + const currentCiphertexts = await Promise.all( + originals.map(async ({ entryId, plaintext }) => { + const enc = await encryptContent(plaintext, oldFieldKey) + return { entryId, ...enc } + }), + ) + + const result = await rotateFieldKeyCrypto({ + kek, + oldFieldKey, + fieldName: FIELD_NAME, + currentVersion: 1, + currentCiphertexts, + }) + + for (const r of result.reEncryptedFields) { + await expect(decryptContent(r.ciphertext, r.ciphertextIv, oldFieldKey)).rejects.toThrow(DecryptionError) + } + }) + + it('the new wrapped key unwraps with the KEK to the new field key', async () => { + const { kek } = await setupKek() + const { fieldKey: oldFieldKey } = await makeOldFieldKey() + + const currentCiphertexts = [{ entryId: 'entry-1', ...(await encryptContent('first secret note', oldFieldKey)) }] + + const result = await rotateFieldKeyCrypto({ + kek, + oldFieldKey, + fieldName: FIELD_NAME, + currentVersion: 1, + currentCiphertexts, + }) + + const wrapAad = cryptoUtils.encodeAAD(FIELD_NAME, result.newVersion) + const unwrappedRaw = await decrypt(cryptoUtils.hexDecode(result.newWrappedFieldKey), kek, { + iv: cryptoUtils.hexDecode(result.newFieldKeyIv), + aad: wrapAad, + }) + const unwrappedKey = await importKey(unwrappedRaw) + + // The unwrapped key decrypts the re-encrypted ciphertext to the original plaintext, + // proving the wrapped key and the in-memory newCryptoKey are the same key. + const r = result.reEncryptedFields[0]! + await expect(decryptContent(r.ciphertext, r.ciphertextIv, unwrappedKey)).resolves.toBe('first secret note') + }) + + it('fails to unwrap the new wrapped key with the old version AAD (rollback protection)', async () => { + const { kek } = await setupKek() + const { fieldKey: oldFieldKey } = await makeOldFieldKey() + + const currentCiphertexts = [{ entryId: 'entry-1', ...(await encryptContent('first secret note', oldFieldKey)) }] + + const result = await rotateFieldKeyCrypto({ + kek, + oldFieldKey, + fieldName: FIELD_NAME, + currentVersion: 1, + currentCiphertexts, + }) + + // Wrapped with encodeAAD(FIELD_NAME, 2); unwrapping with v1 AAD must fail. + const staleWrapAad = cryptoUtils.encodeAAD(FIELD_NAME, 1) + await expect( + decrypt(cryptoUtils.hexDecode(result.newWrappedFieldKey), kek, { + iv: cryptoUtils.hexDecode(result.newFieldKeyIv), + aad: staleWrapAad, + }), + ).rejects.toThrow(DecryptionError) + }) + + it('sets the new version to currentVersion + 1', async () => { + const { kek } = await setupKek() + const { fieldKey: oldFieldKey } = await makeOldFieldKey() + + const result = await rotateFieldKeyCrypto({ + kek, + oldFieldKey, + fieldName: FIELD_NAME, + currentVersion: 7, + currentCiphertexts: [], + }) + + expect(result.newVersion).toBe(8) + }) + + it('returns an empty re-encrypted fields array when there are no entries', async () => { + const { kek } = await setupKek() + const { fieldKey: oldFieldKey } = await makeOldFieldKey() + + const result = await rotateFieldKeyCrypto({ + kek, + oldFieldKey, + fieldName: FIELD_NAME, + currentVersion: 1, + currentCiphertexts: [], + }) + + expect(result.reEncryptedFields).toEqual([]) + expect(result.newWrappedFieldKey).toMatch(/^[0-9a-f]{96}$/) + }) + + it('zero-fills the raw new field key material', async () => { + const { kek } = await setupKek() + const { fieldKey: oldFieldKey } = await makeOldFieldKey() + + const rawNewKey = new Uint8Array(32).fill(0xab) + const generateKeySpy = vi.spyOn(cryptoUtils, 'generateKey').mockReturnValue(rawNewKey) + + try { + await rotateFieldKeyCrypto({ + kek, + oldFieldKey, + fieldName: FIELD_NAME, + currentVersion: 1, + currentCiphertexts: [], + }) + + expect(Array.from(rawNewKey)).toEqual(new Array(32).fill(0)) + } finally { + generateKeySpy.mockRestore() + } + }) + }) +}) diff --git a/src/shared/crypto/vault/key-vault.test.ts b/src/shared/crypto/vault/key-vault.test.ts index 4de1b45..8658b68 100644 --- a/src/shared/crypto/vault/key-vault.test.ts +++ b/src/shared/crypto/vault/key-vault.test.ts @@ -350,6 +350,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/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. From 2d4bf02fb0fb87db54fd615645b55e65c9e41b06 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Mon, 6 Jul 2026 15:28:13 +0200 Subject: [PATCH 5/8] feat: restructure settings page, move change password to Account, merge Key Rotation into Security as collapsible --- .../model/key-rotation-error-messages.test.ts | 34 ----- .../settings/ui/AccountSection.test.tsx | 30 ++++- src/features/settings/ui/AccountSection.tsx | 21 ++-- .../ui/KeyManagementSubsection.test.tsx | 114 +++++++++++++++++ ...ection.tsx => KeyManagementSubsection.tsx} | 34 ++--- .../settings/ui/KeyRotationSection.test.tsx | 119 ------------------ .../settings/ui/SecuritySection.test.tsx | 40 ++---- src/features/settings/ui/SecuritySection.tsx | 47 ++----- .../settings/ui/SettingsItem.test.tsx | 36 ++++++ src/features/settings/ui/SettingsItem.tsx | 45 +++++++ .../settings/ui/SettingsPage.test.tsx | 11 +- src/features/settings/ui/SettingsPage.tsx | 10 +- src/shared/i18n/locales/cs/settings.json | 9 +- src/shared/i18n/locales/en/settings.json | 9 +- src/shared/ui/collapsible.tsx | 27 ++++ supabase/migrations/00002_rls_policies.sql | 2 +- supabase/migrations/00003_functions.sql | 2 +- supabase/migrations/00005_login_salts_rpc.sql | 2 +- supabase/migrations/00006_recovery_rpc.sql | 2 +- .../migrations/00007_key_rotation_rpc.sql | 2 +- 20 files changed, 325 insertions(+), 271 deletions(-) delete mode 100644 src/features/fields/model/key-rotation-error-messages.test.ts create mode 100644 src/features/settings/ui/KeyManagementSubsection.test.tsx rename src/features/settings/ui/{KeyRotationSection.tsx => KeyManagementSubsection.tsx} (68%) delete mode 100644 src/features/settings/ui/KeyRotationSection.test.tsx create mode 100644 src/features/settings/ui/SettingsItem.test.tsx create mode 100644 src/features/settings/ui/SettingsItem.tsx create mode 100644 src/shared/ui/collapsible.tsx diff --git a/src/features/fields/model/key-rotation-error-messages.test.ts b/src/features/fields/model/key-rotation-error-messages.test.ts deleted file mode 100644 index a298a4a..0000000 --- a/src/features/fields/model/key-rotation-error-messages.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, it, expect } from 'vitest' -import type { TFunction } from 'i18next' -import { DecryptionError } from '@/shared/crypto/core/errors' -import { AuthError, AuthErrorCode } from '@/shared/auth/auth-errors' -import { ApiError, ApiErrorCode } from '@/shared/api/api-errors' -import { getKeyRotationErrorMessage } from '@/features/fields/model/key-rotation-error-messages' - -const mockT = ((key: string) => key) as unknown as TFunction<'vault'> - -describe('getKeyRotationErrorMessage', () => { - it('maps DecryptionError to the stale-vault message', () => { - expect(getKeyRotationErrorMessage(new DecryptionError(), mockT)).toBe('rotation.staleVault') - }) - - it('maps ApiError(NETWORK_ERROR) to the network message', () => { - expect(getKeyRotationErrorMessage(new ApiError(ApiErrorCode.NETWORK_ERROR), mockT)).toBe('rotation.networkError') - }) - - it('maps ApiError(UNEXPECTED) to the "unchanged" message', () => { - expect(getKeyRotationErrorMessage(new ApiError(ApiErrorCode.UNEXPECTED), mockT)).toBe('rotation.failed') - }) - - it('maps AuthError(NETWORK_ERROR) to the network message', () => { - expect(getKeyRotationErrorMessage(new AuthError(AuthErrorCode.NETWORK_ERROR), mockT)).toBe('rotation.networkError') - }) - - it('maps a raw TypeError("Failed to fetch") (network bypass) to the network message', () => { - expect(getKeyRotationErrorMessage(new TypeError('Failed to fetch'), mockT)).toBe('rotation.networkError') - }) - - it('maps a generic locked-vault Error to the locked message', () => { - expect(getKeyRotationErrorMessage(new Error('Vault is locked — cannot rotate'), mockT)).toBe('rotation.locked') - }) -}) 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/KeyRotationSection.tsx b/src/features/settings/ui/KeyManagementSubsection.tsx similarity index 68% rename from src/features/settings/ui/KeyRotationSection.tsx rename to src/features/settings/ui/KeyManagementSubsection.tsx index 74887b3..26e158f 100644 --- a/src/features/settings/ui/KeyRotationSection.tsx +++ b/src/features/settings/ui/KeyManagementSubsection.tsx @@ -1,7 +1,8 @@ import { Fragment } from 'react' import { useTranslation } from 'react-i18next' +import { ChevronRight, KeyRound } from 'lucide-react' -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/shared/ui/card' +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' @@ -23,19 +24,23 @@ function versionFor(fieldKeys: { fieldName: string; version: number }[] | undefi return versions.length > 0 ? Math.max(...versions) : 1 } -function KeyRotationSection() { +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.keyVersions')} - {t('keyRotation.description')} - - + + + + + {t('security.keyManagement')} + + + + + {FIELD_NAMES.map((fieldName, i) => ( {i > 0 && } @@ -52,20 +57,19 @@ function KeyRotationSection() { ))} - - + - {isVaultLocked &&

{t('keyRotation.locked')}

} -
-
+ {isVaultLocked &&

{t('keyRotation.locked')}

} + + ) } -export { KeyRotationSection } +export { KeyManagementSubsection } diff --git a/src/features/settings/ui/KeyRotationSection.test.tsx b/src/features/settings/ui/KeyRotationSection.test.tsx deleted file mode 100644 index 118b050..0000000 --- a/src/features/settings/ui/KeyRotationSection.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { render, screen, act } 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 type { CachedVaultEnvelope } from '@/shared/types/api.types' -import { KeyRotationSection } from './KeyRotationSection' - -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 })), - } -} - -function setUnlocked(envelope: CachedVaultEnvelope) { - useCryptoStore.setState({ isVaultLocked: false, cachedEnvelope: envelope }) -} - -describe('KeyRotationSection', () => { - beforeEach(() => { - useRotateFieldKeyDialogStore.setState({ isOpen: false, payload: null }) - }) - - it('renders four field rows with versions read from the cached envelope', () => { - setUnlocked(envelopeWith({ note: 2 })) - - render() - - expect(screen.getByText('Title')).toBeInTheDocument() - expect(screen.getByText('Note')).toBeInTheDocument() - expect(screen.getByText('Website')).toBeInTheDocument() - expect(screen.getByText('Email')).toBeInTheDocument() - - // title, website, email at v1; note at v2. - expect(screen.getAllByText('v1')).toHaveLength(3) - expect(screen.getByText('v2')).toBeInTheDocument() - }) - - it('shows v1 for every field when the cached envelope has no field keys', () => { - useCryptoStore.setState({ isVaultLocked: false, cachedEnvelope: null }) - - render() - - expect(screen.getAllByText('v1')).toHaveLength(4) - }) - - it('disables all rotate buttons and shows the unlock hint when the vault is locked', () => { - useCryptoStore.setState({ isVaultLocked: true, cachedEnvelope: envelopeWith({}) }) - - render() - - for (const button of screen.getAllByRole('button', { name: 'Rotate' })) { - expect(button).toBeDisabled() - } - expect(screen.getByRole('button', { name: 'Rotate all field keys' })).toBeDisabled() - expect(screen.getByText('Unlock the vault to rotate keys')).toBeInTheDocument() - }) - - it('does not show the unlock hint when the vault is unlocked', () => { - setUnlocked(envelopeWith({})) - - render() - - expect(screen.queryByText('Unlock the vault to rotate keys')).not.toBeInTheDocument() - for (const button of screen.getAllByRole('button', { name: 'Rotate' })) { - expect(button).toBeEnabled() - } - }) - - it('opens the rotation dialog for a single field when clicking "Rotate"', async () => { - const user = userEvent.setup() - setUnlocked(envelopeWith({})) - - render() - - await user.click(screen.getAllByRole('button', { name: 'Rotate' })[1]!) // Note row - - expect(useRotateFieldKeyDialogStore.getState().isOpen).toBe(true) - expect(useRotateFieldKeyDialogStore.getState().payload).toEqual({ fieldName: 'note' }) - }) - - it('opens the rotate-all dialog when clicking "Rotate all field keys"', async () => { - const user = userEvent.setup() - setUnlocked(envelopeWith({})) - - render() - - await user.click(screen.getByRole('button', { name: 'Rotate all field keys' })) - - expect(useRotateFieldKeyDialogStore.getState().isOpen).toBe(true) - expect(useRotateFieldKeyDialogStore.getState().payload).toEqual({ fieldName: null }) - }) - - it('reflects an updated version when the cached envelope changes', () => { - setUnlocked(envelopeWith({ note: 1 })) - - render() - - expect(screen.queryByText('v2')).not.toBeInTheDocument() - expect(screen.getAllByText('v1')).toHaveLength(4) - - act(() => { - useCryptoStore.setState({ cachedEnvelope: envelopeWith({ note: 2 }) }) - }) - - expect(screen.getByText('v2')).toBeInTheDocument() - expect(screen.getAllByText('v1')).toHaveLength(3) - }) -}) diff --git a/src/features/settings/ui/SecuritySection.test.tsx b/src/features/settings/ui/SecuritySection.test.tsx index b1bc98d..5b66d52 100644 --- a/src/features/settings/ui/SecuritySection.test.tsx +++ b/src/features/settings/ui/SecuritySection.test.tsx @@ -2,59 +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 the security action items (key versions moved to KeyRotationSection)', () => { + it('renders the seed phrase action items', () => { render() - expect(screen.getByText('Change password')).toBeInTheDocument() expect(screen.getByText('Regenerate seed phrase')).toBeInTheDocument() expect(screen.getByText('Verify seed phrase')).toBeInTheDocument() - expect(screen.queryByText('Key versions')).not.toBeInTheDocument() }) - it('renders two separator dividers between action items', () => { + it('renders Key management trigger', () => { render() - const separators = screen.getAllByRole('separator') - expect(separators).toHaveLength(2) + 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 4181834..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, 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', @@ -28,34 +21,6 @@ const ITEMS: { icon: LucideIcon; labelKey: string; onClick?: () => void }[] = [ }, ] -function SecurityItem({ icon: Icon, label, onClick }: { icon: LucideIcon; label: string; onClick?: () => void }) { - if (onClick) { - return ( - - ) - } - - return ( -
- - - {label} - - -
- ) -} - function SecuritySection() { const { t } = useTranslation('settings') @@ -69,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 5fa8850..f47b8ee 100644 --- a/src/features/settings/ui/SettingsPage.tsx +++ b/src/features/settings/ui/SettingsPage.tsx @@ -1,9 +1,8 @@ import { useTranslation } from 'react-i18next' -import { SecuritySection } from '@/features/settings/ui/SecuritySection' -import { KeyRotationSection } from '@/features/settings/ui/KeyRotationSection' -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') @@ -12,10 +11,9 @@ function SettingsPage() {

{t('title')}

- - - + +
) diff --git a/src/shared/i18n/locales/cs/settings.json b/src/shared/i18n/locales/cs/settings.json index 16e927f..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,12 +19,12 @@ }, "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": { - "description": "Rotuje šifrovací klíč jednoho pole. Znovu zašifruje data tohoto pole napříč všemi záznamy.", "field": { "title": "Název", "note": "Poznámka", diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index 3fee3be..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,12 +19,12 @@ }, "account": { "title": "Account", - "description": "Manage your account settings.", + "description": "Manage your account and login credentials.", "username": "Username", + "changePassword": "Change password", "deleteAccount": "Delete account" }, "keyRotation": { - "description": "Rotate the encryption key for a single field. Re-encrypts that field's data across all entries.", "field": { "title": "Title", "note": "Note", 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/supabase/migrations/00002_rls_policies.sql b/supabase/migrations/00002_rls_policies.sql index a4bf956..74088d1 100644 --- a/supabase/migrations/00002_rls_policies.sql +++ b/supabase/migrations/00002_rls_policies.sql @@ -166,4 +166,4 @@ CREATE POLICY "Users can update own recovery keys" -- ============================================ ALTER PUBLICATION supabase_realtime ADD TABLE public.encrypted_fields; ALTER PUBLICATION supabase_realtime ADD TABLE public.entries; -ALTER PUBLICATION supabase_realtime ADD TABLE public.field_keys; \ No newline at end of file +ALTER PUBLICATION supabase_realtime ADD TABLE public.field_keys; diff --git a/supabase/migrations/00003_functions.sql b/supabase/migrations/00003_functions.sql index 7b79f97..63380b9 100644 --- a/supabase/migrations/00003_functions.sql +++ b/supabase/migrations/00003_functions.sql @@ -103,4 +103,4 @@ CREATE TRIGGER update_recovery_keys_updated_at -- ============================================ REVOKE ALL ON FUNCTION public.handle_new_user() FROM anon, authenticated; REVOKE ALL ON FUNCTION public.get_current_user_id() FROM anon, authenticated; -REVOKE ALL ON FUNCTION public.update_updated_at_column() FROM anon, authenticated; \ No newline at end of file +REVOKE ALL ON FUNCTION public.update_updated_at_column() FROM anon, authenticated; diff --git a/supabase/migrations/00005_login_salts_rpc.sql b/supabase/migrations/00005_login_salts_rpc.sql index abe727d..f8a47cd 100644 --- a/supabase/migrations/00005_login_salts_rpc.sql +++ b/supabase/migrations/00005_login_salts_rpc.sql @@ -46,4 +46,4 @@ END; $$; GRANT EXECUTE ON FUNCTION public.get_login_salts(TEXT) TO anon; -GRANT EXECUTE ON FUNCTION public.get_login_salts(TEXT) TO authenticated; \ No newline at end of file +GRANT EXECUTE ON FUNCTION public.get_login_salts(TEXT) TO authenticated; diff --git a/supabase/migrations/00006_recovery_rpc.sql b/supabase/migrations/00006_recovery_rpc.sql index 00cfd11..69f564d 100644 --- a/supabase/migrations/00006_recovery_rpc.sql +++ b/supabase/migrations/00006_recovery_rpc.sql @@ -186,4 +186,4 @@ BEGIN END; $$; -GRANT EXECUTE ON FUNCTION public.save_recovery_data(UUID, TEXT, TEXT, TEXT, TEXT) TO authenticated; \ No newline at end of file +GRANT EXECUTE ON FUNCTION public.save_recovery_data(UUID, TEXT, TEXT, TEXT, TEXT) TO authenticated; diff --git a/supabase/migrations/00007_key_rotation_rpc.sql b/supabase/migrations/00007_key_rotation_rpc.sql index 3a501fb..f3d5764 100644 --- a/supabase/migrations/00007_key_rotation_rpc.sql +++ b/supabase/migrations/00007_key_rotation_rpc.sql @@ -77,4 +77,4 @@ BEGIN END; $$; -GRANT EXECUTE ON FUNCTION public.rotate_field_key(JSONB) TO authenticated; \ No newline at end of file +GRANT EXECUTE ON FUNCTION public.rotate_field_key(JSONB) TO authenticated; From 83f8222a7ef724c827b5e7c659df155e083e69db Mon Sep 17 00:00:00 2001 From: VitekHub Date: Mon, 6 Jul 2026 17:38:01 +0200 Subject: [PATCH 6/8] refactor: decompose key rotation into composable primitives, split key/content version constants --- .../auth/model/registration-crypto.ts | 4 +- src/features/fields/model/field-crypto.ts | 10 +- .../fields/model/key-rotation-service.test.ts | 111 ++++---- .../fields/model/key-rotation-service.ts | 93 ++++--- .../fields/ui/RotateFieldKeyDialog.test.tsx | 23 +- .../fields/ui/RotateFieldKeyDialog.tsx | 15 +- src/shared/api/supabase-keys.test.ts | 10 +- src/shared/api/supabase-keys.ts | 14 +- src/shared/crypto/crypto-integration.test.ts | 85 +++--- src/shared/crypto/keys/field-keys.test.ts | 81 +++++- src/shared/crypto/keys/field-keys.ts | 46 ++-- src/shared/crypto/keys/key-rotation.test.ts | 245 ------------------ src/shared/crypto/keys/key-rotation.ts | 88 ------- src/shared/crypto/vault/crypto-store.ts | 22 +- src/shared/types/api.types.ts | 4 +- src/shared/types/crypto.types.ts | 5 +- 16 files changed, 298 insertions(+), 558 deletions(-) delete mode 100644 src/shared/crypto/keys/key-rotation.test.ts delete mode 100644 src/shared/crypto/keys/key-rotation.ts 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-service.test.ts b/src/features/fields/model/key-rotation-service.test.ts index 9591ed3..3141fba 100644 --- a/src/features/fields/model/key-rotation-service.test.ts +++ b/src/features/fields/model/key-rotation-service.test.ts @@ -1,13 +1,17 @@ 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, - mockRotateCrypto, + mockGenerateFieldKey, + mockDecryptField, + mockEncryptField, mockMarkLocal, mockUpdateCachedFieldKey, mockEnvelope, @@ -19,7 +23,9 @@ const { mockStoreKey: vi.fn<(id: string, key: CryptoKey) => void>(), mockFetchAll: vi.fn<(userId: string, fieldName: FieldName) => Promise>(), mockRotateRpc: vi.fn<(input: unknown) => Promise>(), - mockRotateCrypto: 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: { @@ -59,8 +65,13 @@ vi.mock('@/shared/api/supabase-keys', () => ({ rotateFieldKeyRpc: mockRotateRpc, })) -vi.mock('@/shared/crypto/keys/key-rotation', () => ({ - rotateFieldKeyCrypto: mockRotateCrypto, +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', () => ({ @@ -90,29 +101,28 @@ function twoServerFields(): ServerEncryptedField[] { ] } +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) - mockRotateCrypto.mockImplementation((input: unknown) => { - const { currentVersion, currentCiphertexts } = input as { - currentVersion: number - currentCiphertexts: { entryId: string }[] - } - return Promise.resolve({ - newCryptoKey: mockNewKey, - newVersion: currentVersion + 1, - newWrappedFieldKey: 'ff'.repeat(48), - newFieldKeyIv: 'ee'.repeat(12), - reEncryptedFields: currentCiphertexts.map((c) => ({ - entryId: c.entryId, - ciphertext: 'cc'.repeat(16), - ciphertextIv: 'dd'.repeat(12), - })), - }) - }) + setupCryptoMocks() }) it('happy path: fetches ciphertexts, rotates, calls RPC, updates vault and cache', async () => { @@ -125,31 +135,26 @@ describe('rotateFieldKey', () => { // All ciphertexts for the field fetched across entries. expect(mockFetchAll).toHaveBeenCalledWith(USER_ID, 'note') - // Pure crypto called with vault inputs + current version from the cached envelope. - expect(mockRotateCrypto).toHaveBeenCalledWith({ - kek: mockKek, - oldFieldKey: mockOldKey, - fieldName: 'note', - currentVersion: 1, - currentCiphertexts: [ - { entryId: 'entry-1', ciphertext: 'aa'.repeat(16), ciphertextIv: 'bb'.repeat(12) }, - { entryId: 'entry-2', ciphertext: 'cc'.repeat(16), ciphertextIv: 'dd'.repeat(12) }, - ], - }) + // 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 the crypto result. + // Atomic server swap called with hex-encoded crypto results. expect(mockRotateRpc).toHaveBeenCalledWith({ fieldName: 'note', newVersion: 2, newWrappedFieldKey: 'ff'.repeat(48), - newFieldKeyIv: 'ee'.repeat(12), + 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) }, + { entryId: 'entry-1', ciphertext: 'cc'.repeat(16), ciphertextIV: 'dd'.repeat(12) }, + { entryId: 'entry-2', ciphertext: 'cc'.repeat(16), ciphertextIV: 'dd'.repeat(12) }, ], }) @@ -157,9 +162,9 @@ describe('rotateFieldKey', () => { expect(mockStoreKey).toHaveBeenCalledWith('note', mockNewKey) expect(mockUpdateCachedFieldKey).toHaveBeenCalledWith({ fieldName: 'note', - newVersion: 2, - newWrappedFieldKey: 'ff'.repeat(48), - newFieldKeyIv: 'ee'.repeat(12), + version: 2, + wrappedFieldKey: 'ff'.repeat(48), + fieldKeyIV: 'ee'.repeat(12), }) }) @@ -169,7 +174,7 @@ describe('rotateFieldKey', () => { await expect(rotateFieldKey(USER_ID, 'note')).rejects.toThrow('Vault is locked — cannot rotate') expect(mockFetchAll).not.toHaveBeenCalled() - expect(mockRotateCrypto).not.toHaveBeenCalled() + expect(mockGenerateFieldKey).not.toHaveBeenCalled() expect(mockRotateRpc).not.toHaveBeenCalled() expect(mockStoreKey).not.toHaveBeenCalled() expect(mockUpdateCachedFieldKey).not.toHaveBeenCalled() @@ -181,18 +186,19 @@ describe('rotateFieldKey', () => { 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(mockRotateCrypto).toHaveBeenCalledTimes(1) + expect(mockGenerateFieldKey).toHaveBeenCalledTimes(1) expect(mockRotateRpc).toHaveBeenCalledTimes(1) expect(mockStoreKey).not.toHaveBeenCalled() expect(mockUpdateCachedFieldKey).not.toHaveBeenCalled() }) - it('still calls the RPC with an empty ciphertext list when there are no entries', async () => { + 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(mockRotateCrypto).toHaveBeenCalledWith(expect.objectContaining({ currentCiphertexts: [] })) + expect(mockDecryptField).not.toHaveBeenCalled() + expect(mockEncryptField).not.toHaveBeenCalled() expect(mockRotateRpc).toHaveBeenCalledWith(expect.objectContaining({ reEncryptedFields: [] })) expect(mockStoreKey).toHaveBeenCalledWith('note', mockNewKey) expect(mockUpdateCachedFieldKey).toHaveBeenCalledTimes(1) @@ -205,23 +211,14 @@ describe('rotateAllFields', () => { mockGetKey.mockImplementation((id: string) => (id === 'kek' ? mockKek : mockOldKey)) mockFetchAll.mockResolvedValue([]) mockRotateRpc.mockResolvedValue(undefined) - mockRotateCrypto.mockImplementation((input: unknown) => { - const { currentVersion } = input as { currentVersion: number } - return Promise.resolve({ - newCryptoKey: mockNewKey, - newVersion: currentVersion + 1, - newWrappedFieldKey: 'ff'.repeat(48), - newFieldKeyIv: 'ee'.repeat(12), - reEncryptedFields: [], - }) - }) - // Reflect successful rotations in the cached envelope so maxVersionForField - // reports the new version for the per-field outcome. + 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, newVersion } = input as { fieldName: FieldName; newVersion: number } + 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: newVersion }) + if (rotated) others.push({ ...rotated, version }) mockEnvelope.fieldKeys = others }) }) diff --git a/src/features/fields/model/key-rotation-service.ts b/src/features/fields/model/key-rotation-service.ts index 6acdf5d..e556968 100644 --- a/src/features/fields/model/key-rotation-service.ts +++ b/src/features/fields/model/key-rotation-service.ts @@ -9,10 +9,13 @@ 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 { rotateFieldKeyCrypto } from '@/shared/crypto/keys/key-rotation' 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 } @@ -22,16 +25,39 @@ export type RotationFailure = { fieldName: FieldName; ok: false; error: unknown export type RotationOutcome = RotationSuccess | RotationFailure -/** - * Highest wrapped-key version currently stored for a field, from the cached - * envelope. After an atomic swap there is exactly one version per field. - */ -function maxVersionForField(fieldName: FieldName): number { +/** 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 versions = envelope.fieldKeys.filter((k) => k.fieldName === fieldName).map((k) => k.version) - if (versions.length === 0) throw new Error(`No field key found for "${fieldName}"`) - return Math.max(...versions) + 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), + } + }), + ) } /** @@ -39,47 +65,38 @@ function maxVersionForField(fieldName: FieldName): number { * 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 { +export async function rotateFieldKey(userId: string, fieldName: FieldName): Promise { const kek = keyVault.getKey('kek') - const oldFieldKey = keyVault.getKey(fieldName) - if (!kek || !oldFieldKey) throw new Error('Vault is locked — cannot rotate') + if (!kek || !keyVault.getKey(fieldName)) throw new Error('Vault is locked — cannot rotate') - const currentVersion = maxVersionForField(fieldName) const currentCiphertexts = await fetchAllEncryptedFieldsForUser(userId, fieldName) - const result = await rotateFieldKeyCrypto({ - kek, - oldFieldKey, - fieldName, - currentVersion, - currentCiphertexts: currentCiphertexts.map((f) => ({ - entryId: f.entryId, - ciphertext: f.ciphertext, - ciphertextIv: f.ciphertextIV, - })), - }) + // --- 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) - // Mark this rotation as locally initiated so the realtime echo of our own - // write doesn't double-toast. Placed right before the RPC so the marker - // is set before the DB write can trigger the broadcast. - markLocalKeyRotation(fieldName, result.newVersion) + markLocalKeyRotation(fieldName, newVersion) + // --- Server commit --- await rotateFieldKeyRpc({ fieldName, - newVersion: result.newVersion, - newWrappedFieldKey: result.newWrappedFieldKey, - newFieldKeyIv: result.newFieldKeyIv, - reEncryptedFields: result.reEncryptedFields, + newVersion, + newWrappedFieldKey: hexEncode(wrappedFieldKey), + newFieldKeyIV: hexEncode(fieldKeyIV), + reEncryptedFields, }) // Local state update only. Store the new key and update the cached envelope. - keyVault.storeKey(fieldName, result.newCryptoKey) + keyVault.storeKey(fieldName, newFieldKey) useCryptoStore.getState().updateCachedFieldKey({ fieldName, - newVersion: result.newVersion, - newWrappedFieldKey: result.newWrappedFieldKey, - newFieldKeyIv: result.newFieldKeyIv, + version: newVersion, + wrappedFieldKey: hexEncode(wrappedFieldKey), + fieldKeyIV: hexEncode(fieldKeyIV), }) + + return newVersion } /** @@ -92,8 +109,8 @@ export async function rotateAllFields(userId: string): Promise ({ mockInvalidateQueries: vi.fn(), - mockRotateFieldKey: vi.fn<(userId: string, fieldName: FieldName) => Promise>(), + mockRotateFieldKey: vi.fn<(userId: string, fieldName: FieldName) => Promise>(), })) vi.mock('@tanstack/react-query', async () => { @@ -48,8 +48,11 @@ function envelopeWith(versions: Partial> = {}): CachedVau } /** Mock rotation that simulates the service bumping the cached-envelope version. */ -function rotatingMock() { +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 { @@ -61,6 +64,7 @@ function rotatingMock() { }, } }) + return newVersion }) } @@ -144,20 +148,7 @@ describe('RotateFieldKeyDialog', () => { useRotateFieldKeyDialogStore.setState({ isOpen: true, payload: { fieldName: null } }) // 3rd field (website) fails; first two and the 4th succeed. - mockRotateFieldKey.mockImplementation(async (_userId: string, fieldName: FieldName) => { - if (fieldName === 'website') throw new ApiError(ApiErrorCode.NETWORK_ERROR) - 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, - ), - }, - } - }) - }) + rotatingMock(new Set(['website'])) render() diff --git a/src/features/fields/ui/RotateFieldKeyDialog.tsx b/src/features/fields/ui/RotateFieldKeyDialog.tsx index 4df151a..db044d8 100644 --- a/src/features/fields/ui/RotateFieldKeyDialog.tsx +++ b/src/features/fields/ui/RotateFieldKeyDialog.tsx @@ -15,7 +15,6 @@ import { } from '@/shared/ui/alert-dialog' import { useRotateFieldKeyDialogStore } from '@/shared/auth/auth-dialogs-store' import { useRequiredUserId } from '@/shared/auth/use-current-user' -import { useCryptoStore } from '@/shared/crypto/vault/crypto-store' 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' @@ -30,13 +29,6 @@ const FIELD_LABEL_KEYS: Record = { email: 'keyRotation.field.email', } -/** Highest wrapped-key version stored for a field, from the cached envelope. */ -function currentVersionFor(fieldName: FieldName): number { - const fieldKeys = useCryptoStore.getState().cachedEnvelope?.fieldKeys - const versions = (fieldKeys ?? []).filter((k) => k.fieldName === fieldName).map((k) => k.version) - return versions.length > 0 ? Math.max(...versions) : 1 -} - type Progress = { field: FieldName; done: number; total: number } | null function RotateFieldKeyDialog() { @@ -70,9 +62,8 @@ function RotateFieldKeyDialog() { async function handleConfirmSingle(name: FieldName) { setIsSubmitting(true) try { - await rotateFieldKey(userId, name) + const newVersion = await rotateFieldKey(userId, name) queryClient.invalidateQueries({ queryKey: queryKeys.field.all }) - const newVersion = currentVersionFor(name) toast.success(t('keyRotation.success', { field: t(FIELD_LABEL_KEYS[name]), version: newVersion })) closeDialog() } catch (error) { @@ -92,8 +83,8 @@ function RotateFieldKeyDialog() { for (const name of FIELD_NAMES) { setProgress({ field: name, done, total }) try { - await rotateFieldKey(userId, name) - succeeded.push({ name, version: currentVersionFor(name) }) + const newVersion = await rotateFieldKey(userId, name) + succeeded.push({ name, version: newVersion }) } catch (error) { failed.push({ name, error }) } diff --git a/src/shared/api/supabase-keys.test.ts b/src/shared/api/supabase-keys.test.ts index f8472ca..01824ec 100644 --- a/src/shared/api/supabase-keys.test.ts +++ b/src/shared/api/supabase-keys.test.ts @@ -559,10 +559,10 @@ describe('rotateFieldKeyRpc', () => { fieldName: 'note', newVersion: 2, newWrappedFieldKey: 'aa'.repeat(48), - newFieldKeyIv: 'bb'.repeat(12), + 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) }, + { entryId: 'entry-1', ciphertext: 'cc'.repeat(16), ciphertextIV: 'dd'.repeat(12) }, + { entryId: 'entry-2', ciphertext: 'ee'.repeat(16), ciphertextIV: 'ff'.repeat(12) }, ], }) @@ -587,7 +587,7 @@ describe('rotateFieldKeyRpc', () => { fieldName: 'note', newVersion: 2, newWrappedFieldKey: 'aa'.repeat(48), - newFieldKeyIv: 'bb'.repeat(12), + newFieldKeyIV: 'bb'.repeat(12), reEncryptedFields: [], }) @@ -610,7 +610,7 @@ describe('rotateFieldKeyRpc', () => { fieldName: 'note', newVersion: 2, newWrappedFieldKey: 'aa'.repeat(48), - newFieldKeyIv: 'bb'.repeat(12), + newFieldKeyIV: 'bb'.repeat(12), reEncryptedFields: [], }) expect.unreachable('should have thrown') diff --git a/src/shared/api/supabase-keys.ts b/src/shared/api/supabase-keys.ts index 1b40d11..b906b47 100644 --- a/src/shared/api/supabase-keys.ts +++ b/src/shared/api/supabase-keys.ts @@ -152,11 +152,11 @@ export async function updateMasterKeyEnvelope(userId: string, data: UpdateMaster } /** - * Atomically rotate a field key and re-encrypted ciphertexts server-side. - * The SECURITY DEFINER RPC inserts the new wrapped key version, replaces - * every entry's ciphertext for that field, and deletes the old version — - * all in one transaction. The user id is read from auth.uid() inside the - * function, so it is not part of the payload (no impersonation surface). + * 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() @@ -165,11 +165,11 @@ export async function rotateFieldKeyRpc(input: RotateFieldKeyRpcInput): Promise< field_name: input.fieldName, new_version: input.newVersion, new_wrapped_field_key: input.newWrappedFieldKey, - new_field_key_iv: input.newFieldKeyIv, + new_field_key_iv: input.newFieldKeyIV, re_encrypted_fields: input.reEncryptedFields.map((f) => ({ entry_id: f.entryId, ciphertext: f.ciphertext, - ciphertext_iv: f.ciphertextIv, + ciphertext_iv: f.ciphertextIV, })), }, }) diff --git a/src/shared/crypto/crypto-integration.test.ts b/src/shared/crypto/crypto-integration.test.ts index 5de3f70..552affa 100644 --- a/src/shared/crypto/crypto-integration.test.ts +++ b/src/shared/crypto/crypto-integration.test.ts @@ -1,14 +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 { rotateFieldKeyCrypto } from '@/shared/crypto/keys/key-rotation' +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 { FIELD_KEY_VERSION, MASTER_KEY_PASSWORD_AAD } from '@/shared/types/crypto.types' +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 @@ -68,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, @@ -390,67 +390,82 @@ describe('crypto integration', () => { await expect(unwrapFieldKeys([tampered], kek)).rejects.toThrow(DecryptionError) }) - it('rotateFieldKeyCrypto end-to-end unwraps the new key with the same KEK and round-trips all ciphertexts', async () => { + 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 the content AAD rotateFieldKeyCrypto expects. - const noteAad = encodeAAD('note', FIELD_KEY_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 iv = generateIV() - const cipher = await encrypt(new TextEncoder().encode(plaintext) as Uint8Array, oldNoteKey, { - iv, - aad: noteAad, - }) - return { entryId, ciphertext: hexEncode(cipher), ciphertextIv: hexEncode(iv) } + const encrypted = await encryptField(plaintext, oldNoteKey, 'note') + return { + entryId, + ciphertext: hexEncode(encrypted.ciphertext), + ciphertextIV: hexEncode(encrypted.ciphertextIV), + } }), ) - const result = await rotateFieldKeyCrypto({ - kek, - oldFieldKey: oldNoteKey, - fieldName: 'note', - currentVersion: noteVersion, - currentCiphertexts, - }) + // 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(result.newVersion).toBe(noteVersion + 1) + expect(newVersion).toBe(noteVersion + 1) // The new wrapped key unwraps with the same KEK + new-version wrap AAD. - const unwrappedRaw = await decrypt(hexDecode(result.newWrappedFieldKey), kek, { - iv: hexDecode(result.newFieldKeyIv), - aad: encodeAAD('note', result.newVersion), - }) - const unwrappedKey = await importKey(unwrappedRaw) + 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 result.reEncryptedFields) { + for (const r of reEncryptedFields) { const expected = entries.find((e) => e.entryId === r.entryId)!.plaintext - const pt = await decrypt(hexDecode(r.ciphertext), unwrappedKey, { - iv: hexDecode(r.ciphertextIv), - aad: noteAad, - }) - expect(new TextDecoder().decode(pt)).toBe(expected) + 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 result.reEncryptedFields) { + for (const r of reEncryptedFields) { await expect( - decrypt(hexDecode(r.ciphertext), oldNoteKey, { iv: hexDecode(r.ciphertextIv), aad: noteAad }), + 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_KEY_VERSION) + const websiteAad = encodeAAD('website', FIELD_CONTENT_VERSION) const wIv = generateIV() const wCipher = await encrypt( new TextEncoder().encode('website content') as Uint8Array, diff --git a/src/shared/crypto/keys/field-keys.test.ts b/src/shared/crypto/keys/field-keys.test.ts index 4a0466e..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 * 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, encrypt } from '@/shared/crypto/core/aes-gcm' -import { generateAndWrapFieldKeys, unwrapFieldKeys } from '@/shared/crypto/keys/field-keys' +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, 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/keys/key-rotation.test.ts b/src/shared/crypto/keys/key-rotation.test.ts deleted file mode 100644 index 644452c..0000000 --- a/src/shared/crypto/keys/key-rotation.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import * as cryptoUtils from '@/shared/crypto/core/crypto-utils' -import { DecryptionError } from '@/shared/crypto/core/errors' -import { encrypt, decrypt, importKey } from '@/shared/crypto/core/aes-gcm' -import { generateMasterKey } from '@/shared/crypto/keys/master-key' -import { deriveKEK } from '@/shared/crypto/core/hkdf' -import { rotateFieldKeyCrypto } from '@/shared/crypto/keys/key-rotation' -import { FIELD_KEY_VERSION } from '@/shared/types/crypto.types' -import type { FieldName } from '@/shared/types/entities/field.types' - -const FIELD_NAME: FieldName = 'note' - -async function setupKek() { - const masterKey = generateMasterKey() - const kekBytes = await deriveKEK(masterKey) - const kek = await importKey(kekBytes) - return { kek } -} - -async function makeOldFieldKey() { - const rawKey = cryptoUtils.generateKey() - const fieldKey = await importKey(rawKey) - return { fieldKey, rawKey } -} - -function toBytes(text: string): Uint8Array { - return new TextEncoder().encode(text) as Uint8Array -} - -async function encryptContent(plaintext: string, fieldKey: CryptoKey) { - const contentAad = cryptoUtils.encodeAAD(FIELD_NAME, FIELD_KEY_VERSION) - const iv = cryptoUtils.generateIV() - const ciphertext = await encrypt(toBytes(plaintext), fieldKey, { iv, aad: contentAad }) - return { ciphertext: cryptoUtils.hexEncode(ciphertext), ciphertextIv: cryptoUtils.hexEncode(iv) } -} - -async function decryptContent(ciphertext: string, ciphertextIv: string, fieldKey: CryptoKey) { - const contentAad = cryptoUtils.encodeAAD(FIELD_NAME, FIELD_KEY_VERSION) - const plaintext = await decrypt(cryptoUtils.hexDecode(ciphertext), fieldKey, { - iv: cryptoUtils.hexDecode(ciphertextIv), - aad: contentAad, - }) - return new TextDecoder().decode(plaintext) -} - -describe('key-rotation', () => { - describe('rotateFieldKeyCrypto', () => { - it('produces v2 wrapped key and re-encrypted ciphertexts from v1 input', async () => { - const { kek } = await setupKek() - const { fieldKey: oldFieldKey } = await makeOldFieldKey() - - const originals = [ - { entryId: 'entry-1', plaintext: 'first secret note' }, - { entryId: 'entry-2', plaintext: 'second secret note' }, - ] - const currentCiphertexts = await Promise.all( - originals.map(async ({ entryId, plaintext }) => { - const enc = await encryptContent(plaintext, oldFieldKey) - return { entryId, ...enc } - }), - ) - - const result = await rotateFieldKeyCrypto({ - kek, - oldFieldKey, - fieldName: FIELD_NAME, - currentVersion: 1, - currentCiphertexts, - }) - - expect(result.newVersion).toBe(2) - expect(result.reEncryptedFields).toHaveLength(2) - - // Wrapped key = 32 bytes plaintext + 16-byte GCM tag → 48 bytes → 96 hex chars. - // IV = 12 bytes → 24 hex chars. - expect(result.newWrappedFieldKey).toMatch(/^[0-9a-f]{96}$/) - expect(result.newFieldKeyIv).toMatch(/^[0-9a-f]{24}$/) - - for (const r of result.reEncryptedFields) { - expect(r.entryId).toBe(originals.find((o) => o.entryId === r.entryId)!.entryId) - expect(r.ciphertext).toMatch(/^[0-9a-f]+$/) - expect(r.ciphertextIv).toMatch(/^[0-9a-f]{24}$/) - expect(r.ciphertext).not.toBe(currentCiphertexts.find((c) => c.entryId === r.entryId)!.ciphertext) - } - }) - - it('re-encrypted ciphertexts decrypt with the new field key and match the originals', async () => { - const { kek } = await setupKek() - const { fieldKey: oldFieldKey } = await makeOldFieldKey() - - const originals = [ - { entryId: 'entry-1', plaintext: 'first secret note' }, - { entryId: 'entry-2', plaintext: 'second secret note' }, - ] - const currentCiphertexts = await Promise.all( - originals.map(async ({ entryId, plaintext }) => { - const enc = await encryptContent(plaintext, oldFieldKey) - return { entryId, ...enc } - }), - ) - - const result = await rotateFieldKeyCrypto({ - kek, - oldFieldKey, - fieldName: FIELD_NAME, - currentVersion: 1, - currentCiphertexts, - }) - - for (const r of result.reEncryptedFields) { - const expected = originals.find((o) => o.entryId === r.entryId)!.plaintext - await expect(decryptContent(r.ciphertext, r.ciphertextIv, result.newCryptoKey)).resolves.toBe(expected) - } - }) - - it('the old field key fails to decrypt re-encrypted ciphertexts', async () => { - const { kek } = await setupKek() - const { fieldKey: oldFieldKey } = await makeOldFieldKey() - - const originals = [{ entryId: 'entry-1', plaintext: 'first secret note' }] - const currentCiphertexts = await Promise.all( - originals.map(async ({ entryId, plaintext }) => { - const enc = await encryptContent(plaintext, oldFieldKey) - return { entryId, ...enc } - }), - ) - - const result = await rotateFieldKeyCrypto({ - kek, - oldFieldKey, - fieldName: FIELD_NAME, - currentVersion: 1, - currentCiphertexts, - }) - - for (const r of result.reEncryptedFields) { - await expect(decryptContent(r.ciphertext, r.ciphertextIv, oldFieldKey)).rejects.toThrow(DecryptionError) - } - }) - - it('the new wrapped key unwraps with the KEK to the new field key', async () => { - const { kek } = await setupKek() - const { fieldKey: oldFieldKey } = await makeOldFieldKey() - - const currentCiphertexts = [{ entryId: 'entry-1', ...(await encryptContent('first secret note', oldFieldKey)) }] - - const result = await rotateFieldKeyCrypto({ - kek, - oldFieldKey, - fieldName: FIELD_NAME, - currentVersion: 1, - currentCiphertexts, - }) - - const wrapAad = cryptoUtils.encodeAAD(FIELD_NAME, result.newVersion) - const unwrappedRaw = await decrypt(cryptoUtils.hexDecode(result.newWrappedFieldKey), kek, { - iv: cryptoUtils.hexDecode(result.newFieldKeyIv), - aad: wrapAad, - }) - const unwrappedKey = await importKey(unwrappedRaw) - - // The unwrapped key decrypts the re-encrypted ciphertext to the original plaintext, - // proving the wrapped key and the in-memory newCryptoKey are the same key. - const r = result.reEncryptedFields[0]! - await expect(decryptContent(r.ciphertext, r.ciphertextIv, unwrappedKey)).resolves.toBe('first secret note') - }) - - it('fails to unwrap the new wrapped key with the old version AAD (rollback protection)', async () => { - const { kek } = await setupKek() - const { fieldKey: oldFieldKey } = await makeOldFieldKey() - - const currentCiphertexts = [{ entryId: 'entry-1', ...(await encryptContent('first secret note', oldFieldKey)) }] - - const result = await rotateFieldKeyCrypto({ - kek, - oldFieldKey, - fieldName: FIELD_NAME, - currentVersion: 1, - currentCiphertexts, - }) - - // Wrapped with encodeAAD(FIELD_NAME, 2); unwrapping with v1 AAD must fail. - const staleWrapAad = cryptoUtils.encodeAAD(FIELD_NAME, 1) - await expect( - decrypt(cryptoUtils.hexDecode(result.newWrappedFieldKey), kek, { - iv: cryptoUtils.hexDecode(result.newFieldKeyIv), - aad: staleWrapAad, - }), - ).rejects.toThrow(DecryptionError) - }) - - it('sets the new version to currentVersion + 1', async () => { - const { kek } = await setupKek() - const { fieldKey: oldFieldKey } = await makeOldFieldKey() - - const result = await rotateFieldKeyCrypto({ - kek, - oldFieldKey, - fieldName: FIELD_NAME, - currentVersion: 7, - currentCiphertexts: [], - }) - - expect(result.newVersion).toBe(8) - }) - - it('returns an empty re-encrypted fields array when there are no entries', async () => { - const { kek } = await setupKek() - const { fieldKey: oldFieldKey } = await makeOldFieldKey() - - const result = await rotateFieldKeyCrypto({ - kek, - oldFieldKey, - fieldName: FIELD_NAME, - currentVersion: 1, - currentCiphertexts: [], - }) - - expect(result.reEncryptedFields).toEqual([]) - expect(result.newWrappedFieldKey).toMatch(/^[0-9a-f]{96}$/) - }) - - it('zero-fills the raw new field key material', async () => { - const { kek } = await setupKek() - const { fieldKey: oldFieldKey } = await makeOldFieldKey() - - const rawNewKey = new Uint8Array(32).fill(0xab) - const generateKeySpy = vi.spyOn(cryptoUtils, 'generateKey').mockReturnValue(rawNewKey) - - try { - await rotateFieldKeyCrypto({ - kek, - oldFieldKey, - fieldName: FIELD_NAME, - currentVersion: 1, - currentCiphertexts: [], - }) - - expect(Array.from(rawNewKey)).toEqual(new Array(32).fill(0)) - } finally { - generateKeySpy.mockRestore() - } - }) - }) -}) diff --git a/src/shared/crypto/keys/key-rotation.ts b/src/shared/crypto/keys/key-rotation.ts deleted file mode 100644 index 1fda1de..0000000 --- a/src/shared/crypto/keys/key-rotation.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Atomic field-key rotation: pure crypto. - * - * Derives the next field-key version, wraps the new key with the KEK, and - * re-encrypts every entry's ciphertext for that field from the old field key - * to the new one. Pure — no network, no store, no side effects beyond crypto - * state. - */ - -import { encrypt, decrypt, importKey } from '@/shared/crypto/core/aes-gcm' -import { generateKey, generateIV, encodeAAD, hexEncode, hexDecode, zeroFill } from '@/shared/crypto/core/crypto-utils' -import { FIELD_KEY_VERSION } from '@/shared/types/crypto.types' - -export type ReEncryptedFieldResult = { - entryId: string - ciphertext: string // hex - ciphertextIv: string // hex -} - -export type RotationResult = { - newCryptoKey: CryptoKey - newVersion: number - newWrappedFieldKey: string // hex - newFieldKeyIv: string // hex - reEncryptedFields: ReEncryptedFieldResult[] -} - -export type RotateFieldKeyCryptoInput = { - kek: CryptoKey - oldFieldKey: CryptoKey - fieldName: string - currentVersion: number - currentCiphertexts: { entryId: string; ciphertext: string; ciphertextIv: string }[] -} - -/** - * Derive the next field-key version and re-encrypt every ciphertext with a - * freshly generated field key. - * - * Two distinct AADs are used: - * - Wrap AAD: `encodeAAD(fieldName, newVersion)` — binds the wrapped key to its - * version, matching `field-keys.ts` so the new key unwraps correctly. - * - Content AAD: `encodeAAD(fieldName, FIELD_KEY_VERSION)` — the constant - * scheme version, matching `field-crypto.ts`. The same AAD is used for the - * old decrypt and the new encrypt; it does NOT track the rotation version. - */ -export async function rotateFieldKeyCrypto(input: RotateFieldKeyCryptoInput): Promise { - const { kek, oldFieldKey, fieldName, currentVersion, currentCiphertexts } = input - const newVersion = currentVersion + 1 - const rawNewKey = generateKey() - try { - const newCryptoKey = await importKey(rawNewKey) - - // Wrap the new field key with the KEK. AAD binds the wrap to newVersion. - const fieldKeyIv = generateIV() - const wrapAad = encodeAAD(fieldName, newVersion) - const wrappedFieldKey = await encrypt(rawNewKey, kek, { iv: fieldKeyIv, aad: wrapAad }) - - // Content encryption uses the constant FIELD_KEY_VERSION AAD (not newVersion). - const contentAad = encodeAAD(fieldName, FIELD_KEY_VERSION) - - const reEncryptedFields = await Promise.all( - currentCiphertexts.map(async ({ entryId, ciphertext, ciphertextIv }) => { - const plaintext = await decrypt(hexDecode(ciphertext), oldFieldKey, { - iv: hexDecode(ciphertextIv), - aad: contentAad, - }) - const newIv = generateIV() - const newCipher = await encrypt(plaintext, newCryptoKey, { iv: newIv, aad: contentAad }) - return { - entryId, - ciphertext: hexEncode(newCipher), - ciphertextIv: hexEncode(newIv), - } - }), - ) - - return { - newCryptoKey, - newVersion, - newWrappedFieldKey: hexEncode(wrappedFieldKey), - newFieldKeyIv: hexEncode(fieldKeyIv), - reEncryptedFields, - } - } finally { - zeroFill(rawNewKey) - } -} diff --git a/src/shared/crypto/vault/crypto-store.ts b/src/shared/crypto/vault/crypto-store.ts index 9a4f6cf..df71150 100644 --- a/src/shared/crypto/vault/crypto-store.ts +++ b/src/shared/crypto/vault/crypto-store.ts @@ -12,18 +12,10 @@ interface CryptoState { cachedEnvelope: CachedVaultEnvelope | null } -/** Inputs to replace one field key in the cached envelope. */ -export interface UpdateCachedFieldKeyInput { - fieldName: string - newVersion: number - newWrappedFieldKey: string - newFieldKeyIv: string -} - interface CryptoActions { markKeysLoaded: (fieldKeyNames: string[]) => void setCachedEnvelope: (envelope: CachedVaultEnvelope) => void - updateCachedFieldKey: (input: UpdateCachedFieldKeyInput) => void + updateCachedFieldKey: (fieldKey: ServerFieldKey) => void lockVault: () => void clearVault: () => void updateActivity: () => void @@ -59,17 +51,11 @@ const useCryptoStore = create()((set) => ({ lastActivity: Date.now(), }), setCachedEnvelope: (envelope) => set({ cachedEnvelope: envelope }), - updateCachedFieldKey: (input) => + updateCachedFieldKey: (fieldKey) => set((state) => { if (!state.cachedEnvelope) return {} - const others = state.cachedEnvelope.fieldKeys.filter((k) => k.fieldName !== input.fieldName) - const rotated: ServerFieldKey = { - fieldName: input.fieldName, - version: input.newVersion, - wrappedFieldKey: input.newWrappedFieldKey, - fieldKeyIV: input.newFieldKeyIv, - } - return { cachedEnvelope: { ...state.cachedEnvelope, fieldKeys: [...others, rotated] } } + const others = state.cachedEnvelope.fieldKeys.filter((k) => k.fieldName !== fieldKey.fieldName) + return { cachedEnvelope: { ...state.cachedEnvelope, fieldKeys: [...others, fieldKey] } } }), lockVault: () => { set({ diff --git a/src/shared/types/api.types.ts b/src/shared/types/api.types.ts index 44d6854..e29ec25 100644 --- a/src/shared/types/api.types.ts +++ b/src/shared/types/api.types.ts @@ -70,7 +70,7 @@ export interface RecoverAccountData { export interface ReEncryptedField { entryId: string ciphertext: string - ciphertextIv: string + ciphertextIV: string } /** Inputs to the field-key rotation RPC. */ @@ -78,6 +78,6 @@ export interface RotateFieldKeyRpcInput { fieldName: FieldName newVersion: number newWrappedFieldKey: string // 96 hex chars - newFieldKeyIv: string // 24 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') From b39dbb4c5b8b57eeaa0b06677d7c02f5304b8ac6 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Mon, 6 Jul 2026 18:09:46 +0200 Subject: [PATCH 7/8] fix: clear cached envelope on key rotation while vault is locked --- src/features/auth/model/auth-service.test.ts | 1 + src/features/auth/model/mnemonic-service.test.ts | 1 + src/features/fields/model/use-realtime-sync.test.ts | 9 +++++++-- src/features/fields/model/use-realtime-sync.ts | 11 +++++++---- src/shared/crypto/vault/crypto-store.ts | 2 ++ src/shared/crypto/vault/key-vault.test.ts | 1 + src/shared/ui/sonner.tsx | 2 ++ 7 files changed, 21 insertions(+), 6 deletions(-) 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 10862bb..cf9fc40 100644 --- a/src/features/auth/model/mnemonic-service.test.ts +++ b/src/features/auth/model/mnemonic-service.test.ts @@ -199,6 +199,7 @@ describe('regenerateMnemonic', () => { lastActivity: 0, cachedEnvelope: null, setCachedEnvelope: mockSetCachedEnvelope, + clearCachedEnvelope: vi.fn(), markKeysLoaded: vi.fn(), lockVault: vi.fn(), clearVault: vi.fn(), diff --git a/src/features/fields/model/use-realtime-sync.test.ts b/src/features/fields/model/use-realtime-sync.test.ts index 3ba77d9..a58aa6f 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, } }) @@ -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,8 @@ 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('suppresses the toast for a locally-initiated rotation echo but still syncs and invalidates', async () => { diff --git a/src/features/fields/model/use-realtime-sync.ts b/src/features/fields/model/use-realtime-sync.ts index ed1cea9..7205598 100644 --- a/src/features/fields/model/use-realtime-sync.ts +++ b/src/features/fields/model/use-realtime-sync.ts @@ -74,10 +74,13 @@ 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 + // 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 diff --git a/src/shared/crypto/vault/crypto-store.ts b/src/shared/crypto/vault/crypto-store.ts index df71150..1c7031e 100644 --- a/src/shared/crypto/vault/crypto-store.ts +++ b/src/shared/crypto/vault/crypto-store.ts @@ -15,6 +15,7 @@ interface CryptoState { interface CryptoActions { markKeysLoaded: (fieldKeyNames: string[]) => void setCachedEnvelope: (envelope: CachedVaultEnvelope) => void + clearCachedEnvelope: () => void updateCachedFieldKey: (fieldKey: ServerFieldKey) => void lockVault: () => void clearVault: () => void @@ -51,6 +52,7 @@ const useCryptoStore = create()((set) => ({ lastActivity: Date.now(), }), setCachedEnvelope: (envelope) => set({ cachedEnvelope: envelope }), + clearCachedEnvelope: () => set({ cachedEnvelope: null }), updateCachedFieldKey: (fieldKey) => set((state) => { if (!state.cachedEnvelope) return {} diff --git a/src/shared/crypto/vault/key-vault.test.ts b/src/shared/crypto/vault/key-vault.test.ts index 8658b68..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(), } 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) => { Date: Mon, 6 Jul 2026 22:06:09 +0200 Subject: [PATCH 8/8] =?UTF-8?q?refactor:=20rename=20isLocalEcho=20?= =?UTF-8?q?=E2=86=92=20isLocalSaveEcho,=20skip=20key=20rotation=20echo=20e?= =?UTF-8?q?ntirely?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fields/model/use-realtime-sync.test.ts | 8 +++--- .../fields/model/use-realtime-sync.ts | 19 ++++++-------- src/shared/realtime/realtime-echo.test.ts | 26 +++++++++---------- src/shared/realtime/realtime-echo.ts | 9 ++----- 4 files changed, 27 insertions(+), 35 deletions(-) diff --git a/src/features/fields/model/use-realtime-sync.test.ts b/src/features/fields/model/use-realtime-sync.test.ts index a58aa6f..73ece5c 100644 --- a/src/features/fields/model/use-realtime-sync.test.ts +++ b/src/features/fields/model/use-realtime-sync.test.ts @@ -275,18 +275,18 @@ describe('useRealtimeSync', () => { expect(ctx.mockClearCachedEnvelope).not.toHaveBeenCalled() }) - it('suppresses the toast for a locally-initiated rotation echo but still syncs and invalidates', async () => { + 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).toHaveBeenCalledWith('user-123') + expect(ctx.mockSyncFieldKeys).not.toHaveBeenCalled() }) - expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.field.all }) - // No toast: the initiator already toasted locally. + expect(invalidateSpy).not.toHaveBeenCalled() expect(ctx.toastSuccess).not.toHaveBeenCalled() expect(ctx.toastError).not.toHaveBeenCalled() }) diff --git a/src/features/fields/model/use-realtime-sync.ts b/src/features/fields/model/use-realtime-sync.ts index 7205598..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, isLocalKeyRotationEcho } 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,6 +74,9 @@ function useRealtimeSync(): void { } }, onKeyRotation: (fieldName, newVersion) => { + // 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). @@ -85,17 +88,11 @@ function useRealtimeSync(): void { // 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 - // Echo of our own rotation: skip the toast (we already toasted - // locally) but still sync + invalidate so the vault matches. - const isEcho = isLocalKeyRotationEcho(fieldName, newVersion) + 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 }) - if (!isEcho) { - toast.success(t('realtime.keyRotationApplied', { field: fieldName, version: newVersion })) - } + // 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) { toast.error(t('realtime.keyRotationFailed')) 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 538a467..a148e5c 100644 --- a/src/shared/realtime/realtime-echo.ts +++ b/src/shared/realtime/realtime-echo.ts @@ -24,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) { @@ -66,12 +66,7 @@ export function scheduleRemoteUpdateClear(entryId: string, fieldName: FieldName, remoteUpdateTimers.set(key, timer) } -/** - * Mark that we just rotated a field key locally to `version`. The realtime - * broadcast of our own rotation will bounce back as an `onKeyRotation` event; - * the receiver uses `isLocalKeyRotationEcho` to suppress the redundant toast - * while still syncing the vault. Call this right before the rotation RPC. - */ +/** 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) }