From 9f9858aab7404c429cffea5a12fdfa7ea6dab97c Mon Sep 17 00:00:00 2001 From: VitekHub Date: Tue, 7 Jul 2026 18:45:25 +0200 Subject: [PATCH 1/4] feat: add vault visibility lock, and extract vault settings store --- src/app/layouts/ProtectedLayout.tsx | 2 + .../auth/ui/ChangePasswordDialog.test.tsx | 2 +- src/features/auth/ui/ChangePasswordDialog.tsx | 2 +- .../auth/ui/RegenerateMnemonicDialog.test.tsx | 2 +- .../auth/ui/RegenerateMnemonicDialog.tsx | 2 +- .../auth/ui/VerifyMnemonicDialog.test.tsx | 2 +- src/features/auth/ui/VerifyMnemonicDialog.tsx | 2 +- .../fields/ui/RotateFieldKeyDialog.test.tsx | 2 +- .../fields/ui/RotateFieldKeyDialog.tsx | 2 +- .../settings/ui/AccountSection.test.tsx | 2 +- src/features/settings/ui/AccountSection.tsx | 2 +- .../ui/KeyManagementSubsection.test.tsx | 2 +- .../settings/ui/KeyManagementSubsection.tsx | 2 +- .../settings/ui/SecuritySection.test.tsx | 47 ++++- src/features/settings/ui/SecuritySection.tsx | 59 ++++++- .../vault/model/use-vault-timeout.test.ts | 9 +- src/features/vault/model/use-vault-timeout.ts | 10 +- .../model/use-vault-visibility-lock.test.ts | 97 ++++++++++ .../vault/model/use-vault-visibility-lock.ts | 31 ++++ .../vault/model/vault-dialog-store.ts | 2 +- src/shared/i18n/locales/cs/settings.json | 5 +- src/shared/i18n/locales/cs/vault.json | 3 +- src/shared/i18n/locales/en/settings.json | 5 +- src/shared/i18n/locales/en/vault.json | 3 +- .../{ui => stores}/create-dialog-store.ts | 0 .../dialogs-store.ts} | 2 +- .../stores/vault-settings-store.test.ts | 34 ++++ src/shared/stores/vault-settings-store.ts | 39 +++++ src/shared/ui/select.tsx | 165 ++++++++++++++++++ src/test/setup.ts | 5 + 30 files changed, 513 insertions(+), 29 deletions(-) create mode 100644 src/features/vault/model/use-vault-visibility-lock.test.ts create mode 100644 src/features/vault/model/use-vault-visibility-lock.ts rename src/shared/{ui => stores}/create-dialog-store.ts (100%) rename src/shared/{auth/auth-dialogs-store.ts => stores/dialogs-store.ts} (95%) create mode 100644 src/shared/stores/vault-settings-store.test.ts create mode 100644 src/shared/stores/vault-settings-store.ts create mode 100644 src/shared/ui/select.tsx diff --git a/src/app/layouts/ProtectedLayout.tsx b/src/app/layouts/ProtectedLayout.tsx index 496af9b..7549070 100644 --- a/src/app/layouts/ProtectedLayout.tsx +++ b/src/app/layouts/ProtectedLayout.tsx @@ -17,6 +17,7 @@ import { OfflineBanner } from '@/shared/ui/OfflineBanner' import { useLayoutStore } from './layout-store' import { useResizable } from '@/shared/lib/use-resizable' import { useVaultTimeout } from '@/features/vault/model/use-vault-timeout' +import { useVaultVisibilityLock } from '@/features/vault/model/use-vault-visibility-lock' import { logoutUser } from '@/features/auth/model/auth-service' import { useRealtimeSync } from '@/features/fields/model/use-realtime-sync' import { useNavigationBlocker } from '@/features/fields/model/use-navigation-blocker' @@ -50,6 +51,7 @@ function AuthenticatedLayout() { }) useVaultTimeout() + useVaultVisibilityLock() useRealtimeSync() useBlocker({ shouldBlockFn: () => !navigator.onLine, diff --git a/src/features/auth/ui/ChangePasswordDialog.test.tsx b/src/features/auth/ui/ChangePasswordDialog.test.tsx index a2a7d12..861c1ed 100644 --- a/src/features/auth/ui/ChangePasswordDialog.test.tsx +++ b/src/features/auth/ui/ChangePasswordDialog.test.tsx @@ -9,7 +9,7 @@ vi.mock('@/features/auth/model/auth-service', () => ({ import { ChangePasswordDialog } from './ChangePasswordDialog' import { changeUserPassword } from '@/features/auth/model/auth-service' -import { useChangePasswordDialogStore } from '@/shared/auth/auth-dialogs-store' +import { useChangePasswordDialogStore } from '@/shared/stores/dialogs-store' const mockChangeUserPassword = vi.mocked(changeUserPassword) diff --git a/src/features/auth/ui/ChangePasswordDialog.tsx b/src/features/auth/ui/ChangePasswordDialog.tsx index 112b3a9..86f4cc1 100644 --- a/src/features/auth/ui/ChangePasswordDialog.tsx +++ b/src/features/auth/ui/ChangePasswordDialog.tsx @@ -11,7 +11,7 @@ import { PasswordStrength } from '@/features/auth/ui/PasswordStrength' import { changePasswordSchema, type ChangePasswordFormData } from '@/features/auth/model/change-password-schema' import { changeUserPassword } from '@/features/auth/model/auth-service' import { getChangePasswordErrorMessage } from '@/features/auth/model/change-password-error-messages' -import { useChangePasswordDialogStore } from '@/shared/auth/auth-dialogs-store' +import { useChangePasswordDialogStore } from '@/shared/stores/dialogs-store' function ChangePasswordDialog() { const { t } = useTranslation('auth') diff --git a/src/features/auth/ui/RegenerateMnemonicDialog.test.tsx b/src/features/auth/ui/RegenerateMnemonicDialog.test.tsx index a2ac239..b3d45a6 100644 --- a/src/features/auth/ui/RegenerateMnemonicDialog.test.tsx +++ b/src/features/auth/ui/RegenerateMnemonicDialog.test.tsx @@ -14,7 +14,7 @@ vi.mock('@/features/auth/model/mnemonic-service', () => ({ import { RegenerateMnemonicDialog } from './RegenerateMnemonicDialog' import { regenerateMnemonic } from '@/features/auth/model/mnemonic-service' -import { useRegenerateMnemonicDialogStore } from '@/shared/auth/auth-dialogs-store' +import { useRegenerateMnemonicDialogStore } from '@/shared/stores/dialogs-store' const mockRegenerateMnemonic = vi.mocked(regenerateMnemonic) diff --git a/src/features/auth/ui/RegenerateMnemonicDialog.tsx b/src/features/auth/ui/RegenerateMnemonicDialog.tsx index 90bf55c..50c3901 100644 --- a/src/features/auth/ui/RegenerateMnemonicDialog.tsx +++ b/src/features/auth/ui/RegenerateMnemonicDialog.tsx @@ -4,7 +4,7 @@ import { toast } from 'sonner' import { PasswordConfirmDialog } from '@/shared/ui/PasswordConfirmDialog' import { MnemonicDialog } from '@/features/auth/ui/MnemonicDialog' -import { useRegenerateMnemonicDialogStore } from '@/shared/auth/auth-dialogs-store' +import { useRegenerateMnemonicDialogStore } from '@/shared/stores/dialogs-store' import { regenerateMnemonic } from '@/features/auth/model/mnemonic-service' import { getRegenerateMnemonicErrorMessage } from '@/features/auth/model/recovery-error-messages' diff --git a/src/features/auth/ui/VerifyMnemonicDialog.test.tsx b/src/features/auth/ui/VerifyMnemonicDialog.test.tsx index dc15894..e9935a2 100644 --- a/src/features/auth/ui/VerifyMnemonicDialog.test.tsx +++ b/src/features/auth/ui/VerifyMnemonicDialog.test.tsx @@ -42,7 +42,7 @@ vi.mock('@/shared/crypto/keys/mnemonic', () => ({ import { VerifyMnemonicDialog } from './VerifyMnemonicDialog' import { verifyMnemonic } from '@/features/auth/model/mnemonic-service' -import { useVerifyMnemonicDialogStore } from '@/shared/auth/auth-dialogs-store' +import { useVerifyMnemonicDialogStore } from '@/shared/stores/dialogs-store' import { toast } from 'sonner' const mockVerifyMnemonic = vi.mocked(verifyMnemonic) diff --git a/src/features/auth/ui/VerifyMnemonicDialog.tsx b/src/features/auth/ui/VerifyMnemonicDialog.tsx index 52ed72c..24a719c 100644 --- a/src/features/auth/ui/VerifyMnemonicDialog.tsx +++ b/src/features/auth/ui/VerifyMnemonicDialog.tsx @@ -5,7 +5,7 @@ import { toast } from 'sonner' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/shared/ui/dialog' import { Button } from '@/shared/ui/button' import { MnemonicInput } from '@/features/auth/ui/MnemonicInput' -import { useVerifyMnemonicDialogStore } from '@/shared/auth/auth-dialogs-store' +import { useVerifyMnemonicDialogStore } from '@/shared/stores/dialogs-store' import { verifyMnemonic } from '@/features/auth/model/mnemonic-service' import { getRecoveryErrorMessage } from '@/features/auth/model/recovery-error-messages' diff --git a/src/features/fields/ui/RotateFieldKeyDialog.test.tsx b/src/features/fields/ui/RotateFieldKeyDialog.test.tsx index 4faec9d..c1c7f88 100644 --- a/src/features/fields/ui/RotateFieldKeyDialog.test.tsx +++ b/src/features/fields/ui/RotateFieldKeyDialog.test.tsx @@ -24,7 +24,7 @@ vi.mock('@/features/fields/model/key-rotation-service', () => ({ import { RotateFieldKeyDialog } from './RotateFieldKeyDialog' import { toast } from 'sonner' -import { useRotateFieldKeyDialogStore } from '@/shared/auth/auth-dialogs-store' +import { useRotateFieldKeyDialogStore } from '@/shared/stores/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' diff --git a/src/features/fields/ui/RotateFieldKeyDialog.tsx b/src/features/fields/ui/RotateFieldKeyDialog.tsx index cd4f619..89a40d2 100644 --- a/src/features/fields/ui/RotateFieldKeyDialog.tsx +++ b/src/features/fields/ui/RotateFieldKeyDialog.tsx @@ -14,7 +14,7 @@ import { AlertDialogTitle, } from '@/shared/ui/alert-dialog' import { Spinner } from '@/shared/ui/Spinner' -import { useRotateFieldKeyDialogStore } from '@/shared/auth/auth-dialogs-store' +import { useRotateFieldKeyDialogStore } from '@/shared/stores/dialogs-store' import { useRequiredUserId } from '@/shared/auth/use-current-user' import { queryKeys } from '@/shared/lib/query-keys' import { FIELD_NAMES } from '@/shared/types/entities/field.types' diff --git a/src/features/settings/ui/AccountSection.test.tsx b/src/features/settings/ui/AccountSection.test.tsx index 87ee421..e3be778 100644 --- a/src/features/settings/ui/AccountSection.test.tsx +++ b/src/features/settings/ui/AccountSection.test.tsx @@ -2,7 +2,7 @@ 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 { useChangePasswordDialogStore } from '@/shared/stores/dialogs-store' import { AccountSection } from './AccountSection' diff --git a/src/features/settings/ui/AccountSection.tsx b/src/features/settings/ui/AccountSection.tsx index 1433d54..6f433cc 100644 --- a/src/features/settings/ui/AccountSection.tsx +++ b/src/features/settings/ui/AccountSection.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next' import { KeyRound, Trash2, User } from 'lucide-react' import { useCurrentUser } from '@/shared/auth/use-current-user' -import { useChangePasswordDialogStore } from '@/shared/auth/auth-dialogs-store' +import { useChangePasswordDialogStore } from '@/shared/stores/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' diff --git a/src/features/settings/ui/KeyManagementSubsection.test.tsx b/src/features/settings/ui/KeyManagementSubsection.test.tsx index 34bca7f..c62ad65 100644 --- a/src/features/settings/ui/KeyManagementSubsection.test.tsx +++ b/src/features/settings/ui/KeyManagementSubsection.test.tsx @@ -3,7 +3,7 @@ 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 { useRotateFieldKeyDialogStore } from '@/shared/stores/dialogs-store' import { KeyManagementSubsection } from './KeyManagementSubsection' const unlockedState = { diff --git a/src/features/settings/ui/KeyManagementSubsection.tsx b/src/features/settings/ui/KeyManagementSubsection.tsx index 26e158f..49f3dce 100644 --- a/src/features/settings/ui/KeyManagementSubsection.tsx +++ b/src/features/settings/ui/KeyManagementSubsection.tsx @@ -6,7 +6,7 @@ import { CollapsibleRoot, CollapsibleTrigger, CollapsiblePanel } from '@/shared/ 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 { useRotateFieldKeyDialogStore } from '@/shared/stores/dialogs-store' import { FIELD_NAMES } from '@/shared/types/entities/field.types' import type { FieldName } from '@/shared/types/entities/field.types' diff --git a/src/features/settings/ui/SecuritySection.test.tsx b/src/features/settings/ui/SecuritySection.test.tsx index 5b66d52..3d9f40f 100644 --- a/src/features/settings/ui/SecuritySection.test.tsx +++ b/src/features/settings/ui/SecuritySection.test.tsx @@ -2,8 +2,9 @@ import { describe, it, expect, beforeEach } from 'vitest' import { render, screen } from '@/test/utils' import userEvent from '@testing-library/user-event' -import { useRegenerateMnemonicDialogStore, useVerifyMnemonicDialogStore } from '@/shared/auth/auth-dialogs-store' +import { useRegenerateMnemonicDialogStore, useVerifyMnemonicDialogStore } from '@/shared/stores/dialogs-store' import { useCryptoStore } from '@/shared/crypto/vault/crypto-store' +import { useVaultSettingsStore, DEFAULT_VAULT_TIMEOUT_MS } from '@/shared/stores/vault-settings-store' import { SecuritySection } from './SecuritySection' describe('SecuritySection', () => { @@ -11,6 +12,7 @@ describe('SecuritySection', () => { useRegenerateMnemonicDialogStore.setState({ isOpen: false }) useVerifyMnemonicDialogStore.setState({ isOpen: false }) useCryptoStore.setState({ isVaultLocked: true, cachedEnvelope: null, loadedFieldKeys: {} }) + useVaultSettingsStore.setState({ vaultTimeoutMs: DEFAULT_VAULT_TIMEOUT_MS, lockOnTabHidden: false }) }) it('renders section title and description', () => { @@ -60,4 +62,47 @@ describe('SecuritySection', () => { expect(useVerifyMnemonicDialogStore.getState().isOpen).toBe(true) }) + + it('renders the auto-lock select with timeout options', async () => { + const user = userEvent.setup() + render() + + const select = screen.getByRole('combobox', { name: 'Auto-lock vault' }) + expect(select).toBeInTheDocument() + expect(select).toHaveTextContent('15 min') + + await user.click(select) + expect(screen.getAllByRole('option')).toHaveLength(5) + }) + + it('updates vaultTimeoutMs when the auto-lock selection changes', async () => { + const user = userEvent.setup() + render() + + const select = screen.getByRole('combobox', { name: 'Auto-lock vault' }) + await user.click(select) + await user.click(screen.getByRole('option', { name: '5 min' })) + + expect(useVaultSettingsStore.getState().vaultTimeoutMs).toBe(5 * 60 * 1000) + }) + + it('renders the tab-lock checkbox unchecked by default', () => { + render() + + const checkbox = screen.getByRole('checkbox') + expect(checkbox).not.toBeChecked() + }) + + it('toggles lockOnTabHidden when the checkbox is clicked', async () => { + const user = userEvent.setup() + render() + + const checkbox = screen.getByRole('checkbox') + await user.click(checkbox) + + expect(useVaultSettingsStore.getState().lockOnTabHidden).toBe(true) + + await user.click(checkbox) + expect(useVaultSettingsStore.getState().lockOnTabHidden).toBe(false) + }) }) diff --git a/src/features/settings/ui/SecuritySection.tsx b/src/features/settings/ui/SecuritySection.tsx index 83ebdfe..b8823a3 100644 --- a/src/features/settings/ui/SecuritySection.tsx +++ b/src/features/settings/ui/SecuritySection.tsx @@ -1,11 +1,14 @@ import { Fragment } from 'react' import { useTranslation } from 'react-i18next' -import { ShieldCheck, ScanEye, type LucideIcon } from 'lucide-react' +import { ShieldCheck, ScanEye, Timer, EyeOff, type LucideIcon } from 'lucide-react' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/shared/ui/card' import { Separator } from '@/shared/ui/separator' -import { useRegenerateMnemonicDialogStore, useVerifyMnemonicDialogStore } from '@/shared/auth/auth-dialogs-store' +import { useRegenerateMnemonicDialogStore, useVerifyMnemonicDialogStore } from '@/shared/stores/dialogs-store' import { SettingsItem } from '@/features/settings/ui/SettingsItem' +import { Checkbox } from '@/shared/ui/checkbox' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' +import { useVaultSettingsStore } from '@/shared/stores/vault-settings-store' import { KeyManagementSubsection } from '@/features/settings/ui/KeyManagementSubsection' const ITEMS: { icon: LucideIcon; labelKey: string; onClick?: () => void }[] = [ @@ -21,8 +24,25 @@ const ITEMS: { icon: LucideIcon; labelKey: string; onClick?: () => void }[] = [ }, ] +const AUTO_LOCK_OPTIONS = [ + { value: 5 * 60 * 1000, minutes: 5 }, + { value: 10 * 60 * 1000, minutes: 10 }, + { value: 15 * 60 * 1000, minutes: 15 }, + { value: 30 * 60 * 1000, minutes: 30 }, + { value: 60 * 60 * 1000, minutes: 60 }, +] as const + function SecuritySection() { const { t } = useTranslation('settings') + const vaultTimeoutMs = useVaultSettingsStore((s) => s.vaultTimeoutMs) + const setVaultTimeoutMs = useVaultSettingsStore((s) => s.setVaultTimeoutMs) + const lockOnTabHidden = useVaultSettingsStore((s) => s.lockOnTabHidden) + const setLockOnTabHidden = useVaultSettingsStore((s) => s.setLockOnTabHidden) + + const autoLockItems = AUTO_LOCK_OPTIONS.map((opt) => ({ + value: opt.value, + label: t('security.minutesShort', { count: opt.minutes }), + })) return ( @@ -39,6 +59,41 @@ function SecuritySection() { ))} + + + +
+ + + {t('security.autoLock')} + + +
+ + + +
) diff --git a/src/features/vault/model/use-vault-timeout.test.ts b/src/features/vault/model/use-vault-timeout.test.ts index 7046056..47a09bc 100644 --- a/src/features/vault/model/use-vault-timeout.test.ts +++ b/src/features/vault/model/use-vault-timeout.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { act } from 'react' import { useCryptoStore } from '@/shared/crypto/vault/crypto-store' -import { DEFAULT_VAULT_TIMEOUT_MS } from './use-vault-timeout' +import { useVaultSettingsStore, DEFAULT_VAULT_TIMEOUT_MS } from '@/shared/stores/vault-settings-store' const toastWarning = vi.hoisted(() => vi.fn<(msg: string, options?: unknown) => string | number>()) @@ -24,12 +24,14 @@ describe('useVaultTimeout', () => { beforeEach(() => { vi.useFakeTimers() useCryptoStore.setState({ isVaultLocked: true }) + useVaultSettingsStore.setState({ vaultTimeoutMs: DEFAULT_VAULT_TIMEOUT_MS, lockOnTabHidden: false }) vi.clearAllMocks() }) afterEach(() => { vi.useRealTimers() useCryptoStore.setState({ isVaultLocked: true }) + useVaultSettingsStore.setState({ vaultTimeoutMs: DEFAULT_VAULT_TIMEOUT_MS, lockOnTabHidden: false }) }) it('does not start timer when vault is locked', () => { @@ -135,10 +137,11 @@ describe('useVaultTimeout', () => { expect(keyVault.lockVault).not.toHaveBeenCalled() }) - it('supports custom timeout', () => { + it('supports custom timeout via store', () => { const customTimeout = 5000 useCryptoStore.setState({ isVaultLocked: false }) - renderHook(() => useVaultTimeout(customTimeout)) + useVaultSettingsStore.setState({ vaultTimeoutMs: customTimeout }) + renderHook(() => useVaultTimeout()) vi.advanceTimersByTime(customTimeout) diff --git a/src/features/vault/model/use-vault-timeout.ts b/src/features/vault/model/use-vault-timeout.ts index de5fa63..bf2f70b 100644 --- a/src/features/vault/model/use-vault-timeout.ts +++ b/src/features/vault/model/use-vault-timeout.ts @@ -4,13 +4,13 @@ import { toast } from 'sonner' import { useCryptoStore } from '@/shared/crypto/vault/crypto-store' import { keyVault } from '@/shared/crypto/vault/key-vault' - -export const DEFAULT_VAULT_TIMEOUT_MS = 15 * 60 * 1000 +import { useVaultSettingsStore } from '@/shared/stores/vault-settings-store' const ACTIVITY_EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll'] as const -export function useVaultTimeout(timeoutMs: number = DEFAULT_VAULT_TIMEOUT_MS): void { +export function useVaultTimeout(): void { const isVaultLocked = useCryptoStore((s) => s.isVaultLocked) + const vaultTimeoutMs = useVaultSettingsStore((s) => s.vaultTimeoutMs) const { t } = useTranslation('vault') const timeoutRef = useRef | null>(null) @@ -30,7 +30,7 @@ export function useVaultTimeout(timeoutMs: number = DEFAULT_VAULT_TIMEOUT_MS): v timeoutRef.current = setTimeout(() => { keyVault.lockVault() toast.warning(t('inactivityLocked')) - }, timeoutMs) + }, vaultTimeoutMs) } resetTimeout() @@ -50,5 +50,5 @@ export function useVaultTimeout(timeoutMs: number = DEFAULT_VAULT_TIMEOUT_MS): v document.removeEventListener(event, handler) } } - }, [isVaultLocked, timeoutMs, t]) + }, [isVaultLocked, vaultTimeoutMs, t]) } diff --git a/src/features/vault/model/use-vault-visibility-lock.test.ts b/src/features/vault/model/use-vault-visibility-lock.test.ts new file mode 100644 index 0000000..b35ede6 --- /dev/null +++ b/src/features/vault/model/use-vault-visibility-lock.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +import { useCryptoStore } from '@/shared/crypto/vault/crypto-store' +import { useVaultSettingsStore } from '@/shared/stores/vault-settings-store' + +const toastWarning = vi.hoisted(() => vi.fn<(msg: string, options?: unknown) => string | number>()) + +vi.mock('sonner', () => ({ + toast: { warning: toastWarning }, +})) + +vi.mock('@/shared/crypto/vault/key-vault', () => ({ + keyVault: { + lockVault: vi.fn<() => void>(), + }, +})) + +import { keyVault } from '@/shared/crypto/vault/key-vault' +import { useVaultVisibilityLock } from './use-vault-visibility-lock' +import { renderHook } from '@/test/utils' + +function setVisibilityState(state: 'visible' | 'hidden') { + Object.defineProperty(document, 'visibilityState', { value: state, configurable: true }) +} + +describe('useVaultVisibilityLock', () => { + beforeEach(() => { + useCryptoStore.setState({ isVaultLocked: true }) + useVaultSettingsStore.setState({ lockOnTabHidden: false }) + setVisibilityState('visible') + vi.clearAllMocks() + }) + + afterEach(() => { + useCryptoStore.setState({ isVaultLocked: true }) + useVaultSettingsStore.setState({ lockOnTabHidden: false }) + setVisibilityState('visible') + }) + + it('does nothing when disabled', () => { + useCryptoStore.setState({ isVaultLocked: false }) + renderHook(() => useVaultVisibilityLock()) + + setVisibilityState('hidden') + document.dispatchEvent(new Event('visibilitychange')) + + expect(keyVault.lockVault).not.toHaveBeenCalled() + }) + + it('locks the vault when document becomes hidden and vault is unlocked', () => { + useCryptoStore.setState({ isVaultLocked: false }) + useVaultSettingsStore.setState({ lockOnTabHidden: true }) + renderHook(() => useVaultVisibilityLock()) + + setVisibilityState('hidden') + document.dispatchEvent(new Event('visibilitychange')) + + expect(keyVault.lockVault).toHaveBeenCalledTimes(1) + expect(toastWarning).toHaveBeenCalledTimes(1) + expect(toastWarning.mock.calls[0][0]).toBe('Vault locked due to tab switch.') + }) + + it('does not lock when the vault is already locked', () => { + useCryptoStore.setState({ isVaultLocked: true }) + useVaultSettingsStore.setState({ lockOnTabHidden: true }) + renderHook(() => useVaultVisibilityLock()) + + setVisibilityState('hidden') + document.dispatchEvent(new Event('visibilitychange')) + + expect(keyVault.lockVault).not.toHaveBeenCalled() + }) + + it('does not lock when document becomes visible', () => { + useCryptoStore.setState({ isVaultLocked: false }) + useVaultSettingsStore.setState({ lockOnTabHidden: true }) + renderHook(() => useVaultVisibilityLock()) + + setVisibilityState('visible') + document.dispatchEvent(new Event('visibilitychange')) + + expect(keyVault.lockVault).not.toHaveBeenCalled() + }) + + it('removes the listener on unmount', () => { + useCryptoStore.setState({ isVaultLocked: false }) + useVaultSettingsStore.setState({ lockOnTabHidden: true }) + const { unmount } = renderHook(() => useVaultVisibilityLock()) + + unmount() + + setVisibilityState('hidden') + document.dispatchEvent(new Event('visibilitychange')) + + expect(keyVault.lockVault).not.toHaveBeenCalled() + }) +}) diff --git a/src/features/vault/model/use-vault-visibility-lock.ts b/src/features/vault/model/use-vault-visibility-lock.ts new file mode 100644 index 0000000..1b11f22 --- /dev/null +++ b/src/features/vault/model/use-vault-visibility-lock.ts @@ -0,0 +1,31 @@ +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +import { useCryptoStore } from '@/shared/crypto/vault/crypto-store' +import { keyVault } from '@/shared/crypto/vault/key-vault' +import { useVaultSettingsStore } from '@/shared/stores/vault-settings-store' + +/** + * Locks the vault when the document becomes hidden (tab switch, minimize, + * screen lock) if the user has enabled the setting. No-op when the vault is + * already locked, so it never fires redundantly with the inactivity timer. + */ +export function useVaultVisibilityLock(): void { + const enabled = useVaultSettingsStore((s) => s.lockOnTabHidden) + const { t } = useTranslation('vault') + + useEffect(() => { + if (!enabled) return + + function handleVisibilityChange() { + if (document.visibilityState === 'hidden' && !useCryptoStore.getState().isVaultLocked) { + keyVault.lockVault() + toast.warning(t('tabSwitchLocked')) + } + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + return () => document.removeEventListener('visibilitychange', handleVisibilityChange) + }, [enabled, t]) +} diff --git a/src/features/vault/model/vault-dialog-store.ts b/src/features/vault/model/vault-dialog-store.ts index 5417a52..7adb556 100644 --- a/src/features/vault/model/vault-dialog-store.ts +++ b/src/features/vault/model/vault-dialog-store.ts @@ -1,3 +1,3 @@ -import { createDialogStore } from '@/shared/ui/create-dialog-store' +import { createDialogStore } from '@/shared/stores/create-dialog-store' export const useVaultDialogStore = createDialogStore('VaultDialogStore') diff --git a/src/shared/i18n/locales/cs/settings.json b/src/shared/i18n/locales/cs/settings.json index 9e47169..6a3acba 100644 --- a/src/shared/i18n/locales/cs/settings.json +++ b/src/shared/i18n/locales/cs/settings.json @@ -5,7 +5,10 @@ "description": "Správa šifrovacích klíčů a seed fráze.", "seedPhrase": "Znovu vygenerovat seed frázi", "verifySeedPhrase": "Ověřit seed frázi", - "keyManagement": "Správa klíčů" + "keyManagement": "Správa klíčů", + "autoLock": "Automatické uzamčení trezoru", + "lockOnTabHidden": "Uzamknout při přepnutí karty", + "minutesShort": "{{count}} min" }, "preferences": { "title": "Předvolby", diff --git a/src/shared/i18n/locales/cs/vault.json b/src/shared/i18n/locales/cs/vault.json index 6c1b71c..e19b5a1 100644 --- a/src/shared/i18n/locales/cs/vault.json +++ b/src/shared/i18n/locales/cs/vault.json @@ -24,5 +24,6 @@ "staleVault": "Dešifrování selhalo. Odemkněte trezor znovu a zkuste to znovu.", "locked": "Trezor je uzamčen." }, - "inactivityLocked": "Trezor byl uzamčen kvůli nečinnosti." + "inactivityLocked": "Trezor byl uzamčen kvůli nečinnosti.", + "tabSwitchLocked": "Trezor byl uzamčen kvůli přepnutí karty." } diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index 433ba0c..2b6d593 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -5,7 +5,10 @@ "description": "Manage your encryption keys and seed phrase.", "seedPhrase": "Regenerate seed phrase", "verifySeedPhrase": "Verify seed phrase", - "keyManagement": "Key management" + "keyManagement": "Key management", + "autoLock": "Auto-lock vault", + "lockOnTabHidden": "Lock when switching tabs", + "minutesShort": "{{count}} min" }, "preferences": { "title": "Preferences", diff --git a/src/shared/i18n/locales/en/vault.json b/src/shared/i18n/locales/en/vault.json index a1a910a..4d283e3 100644 --- a/src/shared/i18n/locales/en/vault.json +++ b/src/shared/i18n/locales/en/vault.json @@ -24,5 +24,6 @@ "staleVault": "Failed to decrypt. Re-unlock the vault and try again.", "locked": "Vault is locked." }, - "inactivityLocked": "Vault locked due to inactivity." + "inactivityLocked": "Vault locked due to inactivity.", + "tabSwitchLocked": "Vault locked due to tab switch." } diff --git a/src/shared/ui/create-dialog-store.ts b/src/shared/stores/create-dialog-store.ts similarity index 100% rename from src/shared/ui/create-dialog-store.ts rename to src/shared/stores/create-dialog-store.ts diff --git a/src/shared/auth/auth-dialogs-store.ts b/src/shared/stores/dialogs-store.ts similarity index 95% rename from src/shared/auth/auth-dialogs-store.ts rename to src/shared/stores/dialogs-store.ts index 11159bc..9a57be8 100644 --- a/src/shared/auth/auth-dialogs-store.ts +++ b/src/shared/stores/dialogs-store.ts @@ -1,4 +1,4 @@ -import { createDialogStore, createDialogStoreWithPayload } from '@/shared/ui/create-dialog-store' +import { createDialogStore, createDialogStoreWithPayload } from '@/shared/stores/create-dialog-store' import type { FieldName } from '@/shared/types/entities/field.types' export const useChangePasswordDialogStore = createDialogStore('ChangePasswordDialogStore') diff --git a/src/shared/stores/vault-settings-store.test.ts b/src/shared/stores/vault-settings-store.test.ts new file mode 100644 index 0000000..10ea0e7 --- /dev/null +++ b/src/shared/stores/vault-settings-store.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, beforeEach } from 'vitest' + +import { useVaultSettingsStore, DEFAULT_VAULT_TIMEOUT_MS } from './vault-settings-store' + +describe('vault-settings-store', () => { + beforeEach(() => { + useVaultSettingsStore.setState({ + vaultTimeoutMs: DEFAULT_VAULT_TIMEOUT_MS, + lockOnTabHidden: false, + }) + }) + + it('initializes with default values', () => { + const state = useVaultSettingsStore.getState() + expect(state.vaultTimeoutMs).toBe(DEFAULT_VAULT_TIMEOUT_MS) + expect(state.lockOnTabHidden).toBe(false) + }) + + it('setVaultTimeoutMs updates vaultTimeoutMs', () => { + useVaultSettingsStore.getState().setVaultTimeoutMs(5 * 60 * 1000) + expect(useVaultSettingsStore.getState().vaultTimeoutMs).toBe(5 * 60 * 1000) + + useVaultSettingsStore.getState().setVaultTimeoutMs(60 * 60 * 1000) + expect(useVaultSettingsStore.getState().vaultTimeoutMs).toBe(60 * 60 * 1000) + }) + + it('setLockOnTabHidden updates lockOnTabHidden', () => { + useVaultSettingsStore.getState().setLockOnTabHidden(true) + expect(useVaultSettingsStore.getState().lockOnTabHidden).toBe(true) + + useVaultSettingsStore.getState().setLockOnTabHidden(false) + expect(useVaultSettingsStore.getState().lockOnTabHidden).toBe(false) + }) +}) diff --git a/src/shared/stores/vault-settings-store.ts b/src/shared/stores/vault-settings-store.ts new file mode 100644 index 0000000..08e5a8a --- /dev/null +++ b/src/shared/stores/vault-settings-store.ts @@ -0,0 +1,39 @@ +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' + +/** Default inactivity auto-lock timeout (15 minutes). */ +const DEFAULT_VAULT_TIMEOUT_MS = 15 * 60 * 1000 + +interface VaultSettingsState { + vaultTimeoutMs: number + lockOnTabHidden: boolean +} + +interface VaultSettingsActions { + setVaultTimeoutMs: (ms: number) => void + setLockOnTabHidden: (enabled: boolean) => void +} + +const useVaultSettingsStore = create()( + devtools( + persist( + (set) => ({ + vaultTimeoutMs: DEFAULT_VAULT_TIMEOUT_MS, + lockOnTabHidden: false, + setVaultTimeoutMs: (ms) => set({ vaultTimeoutMs: ms }, false, 'vaultSettings/setVaultTimeoutMs'), + setLockOnTabHidden: (enabled) => set({ lockOnTabHidden: enabled }, false, 'vaultSettings/setLockOnTabHidden'), + }), + { + name: 'cipher-note-vault-settings', + partialize: (state) => ({ + vaultTimeoutMs: state.vaultTimeoutMs, + lockOnTabHidden: state.lockOnTabHidden, + }), + }, + ), + { name: 'VaultSettingsStore' }, + ), +) + +export { useVaultSettingsStore, DEFAULT_VAULT_TIMEOUT_MS } +export type { VaultSettingsState, VaultSettingsActions } diff --git a/src/shared/ui/select.tsx b/src/shared/ui/select.tsx new file mode 100644 index 0000000..6289531 --- /dev/null +++ b/src/shared/ui/select.tsx @@ -0,0 +1,165 @@ +import * as React from 'react' +import { Select as SelectPrimitive } from '@base-ui/react/select' + +import { cn } from '@/shared/lib/utils' +import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from 'lucide-react' + +const Select = SelectPrimitive.Root + +function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) { + return +} + +function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { + return ( + + ) +} + +function SelectTrigger({ + className, + size = 'default', + children, + ...props +}: SelectPrimitive.Trigger.Props & { + size?: 'sm' | 'default' +}) { + return ( + + {children} + } /> + + ) +} + +function SelectContent({ + className, + children, + side = 'bottom', + sideOffset = 4, + align = 'center', + alignOffset = 0, + alignItemWithTrigger = true, + ...props +}: SelectPrimitive.Popup.Props & + Pick) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) { + return ( + + ) +} + +function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) { + return ( + + + {children} + + } + > + + + + ) +} + +function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) { + return ( + + ) +} + +function SelectScrollUpButton({ className, ...props }: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ className, ...props }: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/src/test/setup.ts b/src/test/setup.ts index 1090436..64944af 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -18,6 +18,7 @@ import vaultCs from '@/shared/i18n/locales/cs/vault.json' import { useAuthStore } from '@/features/auth/model/auth-store' import { useCryptoStore } from '@/shared/crypto/vault/crypto-store' import { useLayoutStore } from '@/app/layouts/layout-store' +import { useVaultSettingsStore, DEFAULT_VAULT_TIMEOUT_MS } from '@/shared/stores/vault-settings-store' import { useSyncStatusStore } from '@/features/fields/model/sync-status-store' afterEach(() => { @@ -38,6 +39,10 @@ afterEach(() => { activeField: null, sidebarWidth: 240, }) + useVaultSettingsStore.setState({ + vaultTimeoutMs: DEFAULT_VAULT_TIMEOUT_MS, + lockOnTabHidden: false, + }) useSyncStatusStore.getState().resetAll() }) From a2ce817ccf43d8cd7b8a298130296b5f66c049f5 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Tue, 7 Jul 2026 18:50:52 +0200 Subject: [PATCH 2/4] feat: harden security - add Referrer-Policy meta tag - zero-fill sensitive buffers - validate mnemonics early - add input validation tests --- index.html | 1 + .../auth/model/mnemonic-service.test.ts | 25 ++++- src/features/auth/model/mnemonic-service.ts | 16 ++- .../auth/model/security-validation.test.ts | 102 ++++++++++++++++++ .../fields/model/field-crypto.test.ts | 21 +++- src/features/fields/model/field-crypto.ts | 6 +- src/shared/crypto/core/argon2id.test.ts | 19 ++++ src/shared/crypto/core/argon2id.worker.ts | 4 + src/shared/crypto/keys/field-keys.test.ts | 26 +++++ src/shared/crypto/keys/field-keys.ts | 8 +- src/shared/crypto/keys/mnemonic.test.ts | 15 ++- src/shared/crypto/keys/mnemonic.ts | 6 +- 12 files changed, 238 insertions(+), 11 deletions(-) create mode 100644 src/features/auth/model/security-validation.test.ts diff --git a/index.html b/index.html index 9e0fedd..e11ff39 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,7 @@ + ', + 'user name', // space + 'user@name', // special char + 'user/name', + 'user;drop', + 'user.name', + 'user-name', + 'a'.repeat(33), // overlength + 'ab', // underlength + '', + ] + + for (const username of maliciousUsernames) { + it(`rejects ${JSON.stringify(username)}`, () => { + expect(USERNAME_PATTERN.test(username)).toBe(false) + }) + } + + const validUsernames = ['abc', 'user_123', 'ABC_def', 'a'.repeat(32)] + for (const username of validUsernames) { + it(`accepts ${JSON.stringify(username)}`, () => { + expect(USERNAME_PATTERN.test(username)).toBe(true) + }) + } + }) + + describe('registerSchema username', () => { + const validPasswords = { password: 'validPassword1', confirmPassword: 'validPassword1' } + + it('rejects SQL-injection-style username', async () => { + const result = await registerSchema.safeParseAsync({ username: "' OR 1=1--", ...validPasswords }) + expect(result.success).toBe(false) + }) + + it('rejects XSS payload in username', async () => { + const result = await registerSchema.safeParseAsync({ + username: '', + ...validPasswords, + }) + expect(result.success).toBe(false) + }) + + it('rejects overlength username (>32)', async () => { + const result = await registerSchema.safeParseAsync({ username: 'a'.repeat(33), ...validPasswords }) + expect(result.success).toBe(false) + }) + + it('rejects underlength username (<3)', async () => { + const result = await registerSchema.safeParseAsync({ username: 'ab', ...validPasswords }) + expect(result.success).toBe(false) + }) + + it('rejects username with spaces', async () => { + const result = await registerSchema.safeParseAsync({ username: 'user name', ...validPasswords }) + expect(result.success).toBe(false) + }) + }) + + describe('registerSchema password', () => { + it(`rejects password shorter than ${PASSWORD_MIN_LENGTH} characters`, async () => { + const result = await registerSchema.safeParseAsync({ + username: 'validuser', + password: 'short', + confirmPassword: 'short', + }) + expect(result.success).toBe(false) + }) + }) + + describe('loginSchema username', () => { + it('rejects SQL-injection-style username at login', async () => { + const result = await loginSchema.safeParseAsync({ username: "' OR 1=1--", password: 'anything' }) + expect(result.success).toBe(false) + }) + + it('rejects XSS payload in username at login', async () => { + const result = await loginSchema.safeParseAsync({ username: '', password: 'anything' }) + expect(result.success).toBe(false) + }) + }) +}) diff --git a/src/features/fields/model/field-crypto.test.ts b/src/features/fields/model/field-crypto.test.ts index 41a7aaf..4d26d3b 100644 --- a/src/features/fields/model/field-crypto.test.ts +++ b/src/features/fields/model/field-crypto.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { importKey } from '@/shared/crypto/core/aes-gcm' +import * as cryptoUtils from '@/shared/crypto/core/crypto-utils' import { DecryptionError } from '@/shared/crypto/core/errors' import { encryptField, decryptField, toSaveFieldData, toEncryptedFieldData } from '@/features/fields/model/field-crypto' import { FIELD_NAMES } from '@/shared/types/entities/field.types' @@ -64,6 +65,24 @@ describe('encryptField + decryptField', () => { // Attempt to decrypt as a different field — AAD won't match await expect(decryptField(encrypted, key, 'website')).rejects.toThrow(DecryptionError) }) + + it('zero-fills the decrypted plaintext bytes after decoding', async () => { + const key = await generateKey() + const encrypted = await encryptField('secret note', key, 'note') + + const zeroFillSpy = vi.spyOn(cryptoUtils, 'zeroFill') + try { + await decryptField(encrypted, key, 'note') + + // decryptField decodes plaintextBytes to a string, then zero-fills the bytes. + expect(zeroFillSpy).toHaveBeenCalledOnce() + const buf = zeroFillSpy.mock.calls[0]![0] as Uint8Array + expect(buf.length).toBe('secret note'.length) + expect(Array.from(buf).every((b) => b === 0)).toBe(true) + } finally { + zeroFillSpy.mockRestore() + } + }) }) describe('toSaveFieldData + toEncryptedFieldData', () => { diff --git a/src/features/fields/model/field-crypto.ts b/src/features/fields/model/field-crypto.ts index b4a4e20..51d3992 100644 --- a/src/features/fields/model/field-crypto.ts +++ b/src/features/fields/model/field-crypto.ts @@ -1,5 +1,5 @@ import { decrypt, encrypt } from '@/shared/crypto/core/aes-gcm' -import { encodeAAD, generateIV, hexDecode, hexEncode } from '@/shared/crypto/core/crypto-utils' +import { encodeAAD, generateIV, hexDecode, hexEncode, zeroFill } from '@/shared/crypto/core/crypto-utils' import { FIELD_CONTENT_VERSION } from '@/shared/types/crypto.types' import type { EncryptedFieldData } from '@/shared/types/crypto.types' import type { FieldName } from '@/shared/types/entities/field.types' @@ -36,7 +36,9 @@ export async function decryptField( ): Promise { const aad = encodeAAD(fieldName, FIELD_CONTENT_VERSION) const plaintextBytes = await decrypt(encryptedData.ciphertext, fieldKey, { iv: encryptedData.ciphertextIV, aad }) - return new TextDecoder().decode(plaintextBytes) + const text = new TextDecoder().decode(plaintextBytes) + zeroFill(plaintextBytes) + return text } /** Convert internal binary EncryptedFieldData to hex-string SaveFieldData for the API. */ diff --git a/src/shared/crypto/core/argon2id.test.ts b/src/shared/crypto/core/argon2id.test.ts index 30ccfbd..6964556 100644 --- a/src/shared/crypto/core/argon2id.test.ts +++ b/src/shared/crypto/core/argon2id.test.ts @@ -314,6 +314,25 @@ describe('argon2id.worker — onmessage handler', () => { expect(response.hash.byteLength).toBe(32) }) + it('zero-fills the worker-side hash after posting the result', async () => { + await import('@/shared/crypto/core/argon2id.worker') + + const handler = self.onmessage as (event: MessageEvent) => Promise + const salt = new Uint8Array(16).fill(1) + + await handler( + new MessageEvent('message', { + data: { type: 'derive', id: 42, password: 'pw', salt, params: DEFAULT_ARGON2_PARAMS }, + }), + ) + + // postMessage structured-clones the buffer, so zeroing the worker-side + // copy does not affect the message already delivered. The recorded + // response.hash is the same reference the worker zero-filled. + const response = postMessageSpy.mock.calls[0][0] + expect(Array.from(response.hash as Uint8Array)).toEqual(new Array(32).fill(0)) + }) + it('sends error back when derivation fails', async () => { mockArgon2Module.hash.mockRejectedValue(new Error('WASM load failed')) diff --git a/src/shared/crypto/core/argon2id.worker.ts b/src/shared/crypto/core/argon2id.worker.ts index 6ea45a6..e236bb3 100644 --- a/src/shared/crypto/core/argon2id.worker.ts +++ b/src/shared/crypto/core/argon2id.worker.ts @@ -1,3 +1,4 @@ +import { zeroFill } from '@/shared/crypto/core/crypto-utils' import type { Argon2Params } from '@/shared/types/crypto.types' import type { Argon2DeriveRequest, Argon2DeriveResult, Argon2DeriveError } from '@/shared/types/argon2-worker.types' @@ -53,6 +54,9 @@ self.onmessage = async (event: MessageEvent) => { const hash = await computeArgon2id(password, salt, params) const response: Argon2DeriveResult = { type: 'result', id, hash } self.postMessage(response) + // postMessage structured-clones the buffer, so zeroing the worker-side + // copy does not affect the message already delivered to the main thread. + zeroFill(hash) } catch (err) { const response: Argon2DeriveError = { type: 'error', diff --git a/src/shared/crypto/keys/field-keys.test.ts b/src/shared/crypto/keys/field-keys.test.ts index 84e2f29..3e3292f 100644 --- a/src/shared/crypto/keys/field-keys.test.ts +++ b/src/shared/crypto/keys/field-keys.test.ts @@ -179,6 +179,32 @@ describe('field-keys', () => { expect(unwrapped.size).toBe(0) }) + it('zero-fills each unwrapped raw field key after importing it', async () => { + const { kek } = await setupKEK() + const { wrappedFieldKeys } = await generateAllFieldKeys(kek) + const serverFieldKeys: ServerFieldKey[] = wrappedFieldKeys.map((w) => ({ + fieldName: w.fieldName, + version: w.version, + wrappedFieldKey: hexEncode(w.wrappedFieldKey), + fieldKeyIV: hexEncode(w.fieldKeyIV), + })) + + const zeroFillSpy = vi.spyOn(cryptoUtils, 'zeroFill') + try { + await unwrapFieldKeys(serverFieldKeys, kek) + + // One zeroFill per field (4 fields), each on a 32-byte raw key buffer. + const calls = zeroFillSpy.mock.calls.map(([buf]) => buf as Uint8Array) + expect(calls).toHaveLength(4) + for (const buf of calls) { + expect(buf.length).toBe(32) + expect(Array.from(buf).every((b) => b === 0)).toBe(true) + } + } finally { + zeroFillSpy.mockRestore() + } + }) + 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 diff --git a/src/shared/crypto/keys/field-keys.ts b/src/shared/crypto/keys/field-keys.ts index 1346723..0a6aac2 100644 --- a/src/shared/crypto/keys/field-keys.ts +++ b/src/shared/crypto/keys/field-keys.ts @@ -76,8 +76,12 @@ export async function unwrapFieldKeys(fieldKeys: ServerFieldKey[], kek: CryptoKe const aad = encodeAAD(fieldName, version) const iv = hexDecode(fieldKeyIV) const unwrappedKey = await decrypt(hexDecode(wrappedFieldKey), kek, { iv, aad }) - const key = await importKey(unwrappedKey) - return [fieldName, key] as [string, CryptoKey] + try { + const key = await importKey(unwrappedKey) + return [fieldName, key] as [string, CryptoKey] + } finally { + zeroFill(unwrappedKey) + } }), ) diff --git a/src/shared/crypto/keys/mnemonic.test.ts b/src/shared/crypto/keys/mnemonic.test.ts index 9f0bce1..3408da3 100644 --- a/src/shared/crypto/keys/mnemonic.test.ts +++ b/src/shared/crypto/keys/mnemonic.test.ts @@ -88,12 +88,25 @@ describe('mnemonic', () => { it('truncates the 64-byte seed to 32 bytes', async () => { const fullSeed = new Uint8Array(64).fill(0xab) + const expected = fullSeed.slice(0, 32) const { mnemonicToSeedSync } = await import('@scure/bip39') vi.mocked(mnemonicToSeedSync).mockReturnValueOnce(fullSeed) const seed = await mnemonicToSeed(MOCK_VALID_MNEMONIC) - expect(seed).toEqual(fullSeed.slice(0, 32)) + expect(seed).toEqual(expected) + }) + + it('zero-fills the 64-byte full seed after taking the 32-byte slice', async () => { + const fullSeed = new Uint8Array(64).fill(0xab) + const { mnemonicToSeedSync } = await import('@scure/bip39') + vi.mocked(mnemonicToSeedSync).mockReturnValueOnce(fullSeed) + + await mnemonicToSeed(MOCK_VALID_MNEMONIC) + + // The slice is a separate buffer (returned to the caller); the source + // 64-byte array is zeroed in the finally block. + expect(Array.from(fullSeed)).toEqual(new Array(64).fill(0)) }) it('throws MnemonicError for an invalid mnemonic', async () => { diff --git a/src/shared/crypto/keys/mnemonic.ts b/src/shared/crypto/keys/mnemonic.ts index 9247cd3..0fbe75b 100644 --- a/src/shared/crypto/keys/mnemonic.ts +++ b/src/shared/crypto/keys/mnemonic.ts @@ -77,7 +77,11 @@ export async function mnemonicToSeed(mnemonic: string): Promise + try { + return fullSeed.slice(0, CRYPTO_KEY_LENGTH) as Uint8Array + } finally { + zeroFill(fullSeed) + } } /** From 6613d668c239afa3bb69d8523a46250099b8cf6a Mon Sep 17 00:00:00 2001 From: VitekHub Date: Tue, 7 Jul 2026 18:55:37 +0200 Subject: [PATCH 3/4] feat: polish settings layout and segmented control responsiveness --- .../settings/ui/PreferencesSection.test.tsx | 4 +-- .../settings/ui/SecuritySection.test.tsx | 2 +- src/features/settings/ui/SecuritySection.tsx | 34 ++++++++++--------- src/features/settings/ui/SettingsPage.tsx | 4 +-- src/shared/i18n/locales/cs/settings.json | 2 +- src/shared/i18n/locales/en/settings.json | 2 +- src/shared/ui/SegmentedControl.tsx | 2 +- src/shared/ui/nav/LanguageSwitcher.test.tsx | 10 +++--- 8 files changed, 31 insertions(+), 29 deletions(-) diff --git a/src/features/settings/ui/PreferencesSection.test.tsx b/src/features/settings/ui/PreferencesSection.test.tsx index 2948369..fd17555 100644 --- a/src/features/settings/ui/PreferencesSection.test.tsx +++ b/src/features/settings/ui/PreferencesSection.test.tsx @@ -17,7 +17,7 @@ describe('PreferencesSection', () => { it('renders language switcher buttons', () => { render() - expect(screen.getByRole('tab', { name: 'English' })).toBeInTheDocument() - expect(screen.getByRole('tab', { name: 'Čeština' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: /English/ })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: /Čeština/ })).toBeInTheDocument() }) }) diff --git a/src/features/settings/ui/SecuritySection.test.tsx b/src/features/settings/ui/SecuritySection.test.tsx index 3d9f40f..ccf522f 100644 --- a/src/features/settings/ui/SecuritySection.test.tsx +++ b/src/features/settings/ui/SecuritySection.test.tsx @@ -18,7 +18,7 @@ describe('SecuritySection', () => { it('renders section title and description', () => { render() expect(screen.getByText('Security')).toBeInTheDocument() - expect(screen.getByText('Manage your encryption keys and seed phrase.')).toBeInTheDocument() + expect(screen.getByText('Manage vault, encryption keys, and seed phrase.')).toBeInTheDocument() }) it('renders the seed phrase action items', () => { diff --git a/src/features/settings/ui/SecuritySection.tsx b/src/features/settings/ui/SecuritySection.tsx index b8823a3..5314ee5 100644 --- a/src/features/settings/ui/SecuritySection.tsx +++ b/src/features/settings/ui/SecuritySection.tsx @@ -12,16 +12,16 @@ import { useVaultSettingsStore } from '@/shared/stores/vault-settings-store' import { KeyManagementSubsection } from '@/features/settings/ui/KeyManagementSubsection' const ITEMS: { icon: LucideIcon; labelKey: string; onClick?: () => void }[] = [ - { - icon: ShieldCheck, - labelKey: 'security.seedPhrase', - onClick: () => useRegenerateMnemonicDialogStore.getState().open(), - }, { icon: ScanEye, labelKey: 'security.verifySeedPhrase', onClick: () => useVerifyMnemonicDialogStore.getState().open(), }, + { + icon: ShieldCheck, + labelKey: 'security.seedPhrase', + onClick: () => useRegenerateMnemonicDialogStore.getState().open(), + }, ] const AUTO_LOCK_OPTIONS = [ @@ -51,17 +51,6 @@ function SecuritySection() { {t('security.description')} - {ITEMS.map((item, i) => ( - - {i > 0 && } - - - ))} - - - - -
@@ -94,6 +83,19 @@ function SecuritySection() { aria-label={t('security.lockOnTabHidden')} /> + + + + {ITEMS.map((item, i) => ( + + {i > 0 && } + + + ))} + + + + ) diff --git a/src/features/settings/ui/SettingsPage.tsx b/src/features/settings/ui/SettingsPage.tsx index f47b8ee..42f6ab9 100644 --- a/src/features/settings/ui/SettingsPage.tsx +++ b/src/features/settings/ui/SettingsPage.tsx @@ -8,9 +8,9 @@ function SettingsPage() { const { t } = useTranslation('settings') return ( -
+

{t('title')}

-
+
diff --git a/src/shared/i18n/locales/cs/settings.json b/src/shared/i18n/locales/cs/settings.json index 6a3acba..3ce0770 100644 --- a/src/shared/i18n/locales/cs/settings.json +++ b/src/shared/i18n/locales/cs/settings.json @@ -2,7 +2,7 @@ "title": "Nastavení", "security": { "title": "Zabezpečení", - "description": "Správa šifrovacích klíčů a seed fráze.", + "description": "Správa trezoru, šifrovacích klíčů a seed fráze.", "seedPhrase": "Znovu vygenerovat seed frázi", "verifySeedPhrase": "Ověřit seed frázi", "keyManagement": "Správa klíčů", diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index 2b6d593..759dfba 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -2,7 +2,7 @@ "title": "Settings", "security": { "title": "Security", - "description": "Manage your encryption keys and seed phrase.", + "description": "Manage vault, encryption keys, and seed phrase.", "seedPhrase": "Regenerate seed phrase", "verifySeedPhrase": "Verify seed phrase", "keyManagement": "Key management", diff --git a/src/shared/ui/SegmentedControl.tsx b/src/shared/ui/SegmentedControl.tsx index 304d59a..187c791 100644 --- a/src/shared/ui/SegmentedControl.tsx +++ b/src/shared/ui/SegmentedControl.tsx @@ -30,7 +30,7 @@ function SegmentedControl({ items, value, onChange, ariaLabel }: SegmentedContro role="tab" aria-pressed={value === item.value} > - {item.icon} + {item.icon ? item.icon : {item.value.toUpperCase()}} {item.label} ))} diff --git a/src/shared/ui/nav/LanguageSwitcher.test.tsx b/src/shared/ui/nav/LanguageSwitcher.test.tsx index 48c47ac..249cf4e 100644 --- a/src/shared/ui/nav/LanguageSwitcher.test.tsx +++ b/src/shared/ui/nav/LanguageSwitcher.test.tsx @@ -17,8 +17,8 @@ describe('LanguageSwitcher', () => { it('renders full variant with language names', () => { render() - expect(screen.getByRole('tab', { name: 'English' })).toBeInTheDocument() - expect(screen.getByRole('tab', { name: 'Čeština' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: /English/ })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: /Čeština/ })).toBeInTheDocument() }) it('defaults to compact variant', () => { @@ -36,14 +36,14 @@ describe('LanguageSwitcher', () => { it('switches language when clicked in full variant', async () => { const user = userEvent.setup() render() - await user.click(screen.getByRole('tab', { name: 'Čeština' })) + await user.click(screen.getByRole('tab', { name: /Čeština/ })) expect(i18next.language.startsWith('cs')).toBe(true) }) it('marks active language with aria-pressed in full variant', () => { render() - const enButton = screen.getByRole('tab', { name: 'English' }) - const csButton = screen.getByRole('tab', { name: 'Čeština' }) + const enButton = screen.getByRole('tab', { name: /English/ }) + const csButton = screen.getByRole('tab', { name: /Čeština/ }) expect(enButton).toHaveAttribute('aria-pressed', 'true') expect(csButton).toHaveAttribute('aria-pressed', 'false') }) From 7cac191d944a46679383110c5d946800acaa13e6 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Wed, 8 Jul 2026 14:22:18 +0200 Subject: [PATCH 4/4] feat: persist store versions and fix auto-lock select --- CLAUDE.md | 1 + docs/implementation-plan/09-phase-9-polish.md | 9 ++-- docs/implementation-plan/README.md | 2 +- src/app/layouts/layout-store.ts | 1 + src/features/settings/ui/SecuritySection.tsx | 41 +++++++++++-------- src/shared/stores/vault-settings-store.ts | 1 + 6 files changed, 31 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0e0aff8..837a2cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -192,6 +192,7 @@ See `docs/implementation-plan/README.md` for the full 36-step plan. - Step 32 (Key Rotation + UI) — complete - Step 33 (Mobile Responsive Refinements) — complete - Step 34 (Loading States, Error Boundaries, Toast Notifications) — complete +- Step 35 (Security Hardening) — complete ### Implementation Notes diff --git a/docs/implementation-plan/09-phase-9-polish.md b/docs/implementation-plan/09-phase-9-polish.md index e957c96..8a5b556 100644 --- a/docs/implementation-plan/09-phase-9-polish.md +++ b/docs/implementation-plan/09-phase-9-polish.md @@ -55,7 +55,7 @@ --- -## Step 35 — Security Hardening +## Step 35 — Security Hardening ✅ **Goal:** Defense-in-depth security measures for an E2EE app. @@ -64,11 +64,8 @@ - Zero-fill all key material when vault is locked (already in crypto-store) - Zero-fill key material after use in crypto functions (best effort — JS GC is not guaranteed) - Avoid `string` for sensitive data where possible (use `Uint8Array`) -- CSP headers (in `vite.config.ts` or deployment config): - - `Content-Security-Policy`: no inline scripts, no eval, strict origins - - `X-Content-Type-Options: nosniff` - - `X-Frame-Options: DENY` - - `Referrer-Policy: no-referrer` +- Referrer-Policy added as `` tag in `index.html` + - Remaining security headers (Content-Security-Policy, X-Content-Type-Options, X-Frame-Options) are deployment config — not set at build time - Supabase RLS audit: - Verify every table has RLS enabled - Verify policies enforce `user_id = auth.uid()` (using the internal Supabase email mapping) diff --git a/docs/implementation-plan/README.md b/docs/implementation-plan/README.md index 7ff038a..deff71a 100644 --- a/docs/implementation-plan/README.md +++ b/docs/implementation-plan/README.md @@ -72,7 +72,7 @@ This is the implementation plan for Cipher Note, an end-to-end encrypted note-ta - [x] Step 32 — Key Rotation + UI - [x] Step 33 — Mobile Responsive Refinements - [x] Step 34 — Loading States, Error Boundaries, Toast Notifications -- [ ] Step 35 — Security Hardening +- [x] Step 35 — Security Hardening - [ ] Step 36 — E2E Tests (Playwright) --- diff --git a/src/app/layouts/layout-store.ts b/src/app/layouts/layout-store.ts index f6cff84..fe7f214 100644 --- a/src/app/layouts/layout-store.ts +++ b/src/app/layouts/layout-store.ts @@ -28,6 +28,7 @@ const useLayoutStore = create()( }), { name: 'cipher-note-layout', + version: 0, partialize: (state) => ({ sidebarOpen: state.sidebarOpen, sidebarWidth: state.sidebarWidth, diff --git a/src/features/settings/ui/SecuritySection.tsx b/src/features/settings/ui/SecuritySection.tsx index 5314ee5..9e6b4a0 100644 --- a/src/features/settings/ui/SecuritySection.tsx +++ b/src/features/settings/ui/SecuritySection.tsx @@ -1,4 +1,4 @@ -import { Fragment } from 'react' +import { Fragment, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { ShieldCheck, ScanEye, Timer, EyeOff, type LucideIcon } from 'lucide-react' @@ -24,13 +24,7 @@ const ITEMS: { icon: LucideIcon; labelKey: string; onClick?: () => void }[] = [ }, ] -const AUTO_LOCK_OPTIONS = [ - { value: 5 * 60 * 1000, minutes: 5 }, - { value: 10 * 60 * 1000, minutes: 10 }, - { value: 15 * 60 * 1000, minutes: 15 }, - { value: 30 * 60 * 1000, minutes: 30 }, - { value: 60 * 60 * 1000, minutes: 60 }, -] as const +const AUTO_LOCK_MINUTES = [5, 10, 15, 30, 60] as const function SecuritySection() { const { t } = useTranslation('settings') @@ -39,10 +33,16 @@ function SecuritySection() { const lockOnTabHidden = useVaultSettingsStore((s) => s.lockOnTabHidden) const setLockOnTabHidden = useVaultSettingsStore((s) => s.setLockOnTabHidden) - const autoLockItems = AUTO_LOCK_OPTIONS.map((opt) => ({ - value: opt.value, - label: t('security.minutesShort', { count: opt.minutes }), - })) + // Base UI SelectItem children are portal-mounted and not in the DOM while the + // popup is closed, so SelectValue uses `items` to resolve the selected label. + const autoLockItems = useMemo( + () => + AUTO_LOCK_MINUTES.map((minutes) => ({ + value: String(minutes * 60 * 1000), + label: t('security.minutesShort', { count: minutes }), + })), + [t], + ) return ( @@ -56,14 +56,21 @@ function SecuritySection() { {t('security.autoLock')} - { + const ms = Number(v) + if (Number.isFinite(ms) && ms > 0) setVaultTimeoutMs(ms) + }} + items={autoLockItems} + > + - {AUTO_LOCK_OPTIONS.map((opt) => ( - - {t('security.minutesShort', { count: opt.minutes })} + {AUTO_LOCK_MINUTES.map((minutes) => ( + + {t('security.minutesShort', { count: minutes })} ))} diff --git a/src/shared/stores/vault-settings-store.ts b/src/shared/stores/vault-settings-store.ts index 08e5a8a..2fe2325 100644 --- a/src/shared/stores/vault-settings-store.ts +++ b/src/shared/stores/vault-settings-store.ts @@ -25,6 +25,7 @@ const useVaultSettingsStore = create( }), { name: 'cipher-note-vault-settings', + version: 0, partialize: (state) => ({ vaultTimeoutMs: state.vaultTimeoutMs, lockOnTabHidden: state.lockOnTabHidden,