Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/extension-wallet/src/router/__tests__/router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,8 @@ describe('extension transaction history filters', () => {
/>
);

expect(screen.getByText('No transactions match this filter.')).toBeInTheDocument();
expect(screen.getByText('No received transactions')).toBeInTheDocument();
expect(screen.getByText('Incoming payments will appear here.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Reset filter' })).toBeInTheDocument();
});
});
116 changes: 115 additions & 1 deletion apps/extension-wallet/src/screens/Settings/SecuritySettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Button, Input } from '@ancore/ui-kit';
import {
VaultExportError,
revealVaultSecret,
verifyVaultPassword,
type VaultExportKind,
} from '../../security/vault-export';
import { useTransferPolicy } from '../../hooks/useTransferPolicy';
Expand All @@ -19,6 +20,14 @@ type SecurityView =
| 'transfer-limits'
| 'active-sessions';

type SensitiveToggle = {
label: string;
description: string;
currentValue: boolean;
nextValue: boolean;
onConfirm: (value: boolean) => void;
} | null;

interface SecuritySettingsProps {
autoLockTimeout: number;
onAutoLockChange: (minutes: number) => void;
Expand Down Expand Up @@ -521,6 +530,7 @@ export function SecuritySettings({
onBack,
}: SecuritySettingsProps) {
const [view, setView] = React.useState<SecurityView>('menu');
const [sensitiveToggle, setSensitiveToggle] = React.useState<SensitiveToggle>(null);

const titles: Record<SecurityView, string> = {
menu: 'Security',
Expand All @@ -537,6 +547,16 @@ export function SecuritySettings({
else setView('menu');
}

function handleSensitiveToggleRequest(nextValue: boolean) {
setSensitiveToggle({
label: 'Require password for exports',
description: 'Re-enter your password to change this sensitive setting.',
currentValue: requirePasswordForSensitiveActions,
nextValue,
onConfirm: onRequirePasswordForSensitiveActionsChange,
});
}

return (
<div className="flex flex-col min-h-screen bg-background">
<ScreenHeader title={titles[view]} onBack={handleBack} />
Expand All @@ -546,7 +566,7 @@ export function SecuritySettings({
autoLockTimeout={autoLockTimeout}
onNavigate={setView}
requirePasswordForSensitiveActions={requirePasswordForSensitiveActions}
onRequirePasswordForSensitiveActionsChange={onRequirePasswordForSensitiveActionsChange}
onRequirePasswordForSensitiveActionsChange={handleSensitiveToggleRequest}
/>
)}
{view === 'change-password' && <ChangePasswordView onDone={() => setView('menu')} />}
Expand Down Expand Up @@ -579,6 +599,100 @@ export function SecuritySettings({
/>
)}
{view === 'active-sessions' && <ActiveSessionsView onDone={() => setView('menu')} />}
{sensitiveToggle && (
<SensitiveSettingConfirmDialog
label={sensitiveToggle.label}
description={sensitiveToggle.description}
nextValue={sensitiveToggle.nextValue}
onCancel={() => setSensitiveToggle(null)}
onConfirm={(value) => {
sensitiveToggle.onConfirm(value);
setSensitiveToggle(null);
}}
/>
)}
</div>
);
}

function SensitiveSettingConfirmDialog({
label,
description,
nextValue,
onCancel,
onConfirm,
}: {
label: string;
description: string;
nextValue: boolean;
onCancel: () => void;
onConfirm: (value: boolean) => void;
}) {
const [password, setPassword] = React.useState('');
const [error, setError] = React.useState('');
const [isSubmitting, setIsSubmitting] = React.useState(false);

async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');

if (!password) {
setError('Enter your password.');
return;
}

setIsSubmitting(true);
try {
const verified = await verifyVaultPassword(password);
if (!verified) {
setError('Incorrect password.');
return;
}

onConfirm(nextValue);
setPassword('');
} catch {
setError('Unable to verify password.');
} finally {
setIsSubmitting(false);
}
}

return (
<div className="absolute inset-0 z-10 flex items-end bg-background/80 backdrop-blur-sm sm:items-center sm:justify-center">
<div className="w-full rounded-t-2xl border border-border bg-card p-4 sm:max-w-md sm:rounded-2xl">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex items-start gap-3 rounded-xl border border-destructive/20 bg-destructive/5 p-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div>
<p className="text-sm font-semibold">{label}</p>
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Confirm Password
</label>
<Input
type="password"
placeholder="Enter password to continue"
value={password}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setPassword(event.target.value)
}
/>
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex gap-2">
<Button type="button" variant="outline" className="flex-1" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" className="flex-1" disabled={isSubmitting}>
{isSubmitting ? 'Verifying…' : 'Confirm'}
</Button>
</div>
</form>
</div>
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import { SettingsScreen } from '../SettingsScreen';
import { NetworkSettings } from '../NetworkSettings';
import { SecuritySettings } from '../SecuritySettings';
import { AboutScreen } from '../AboutScreen';
import { revealVaultSecret, VaultExportError } from '../../../security/vault-export';
import {
revealVaultSecret,
verifyVaultPassword,
VaultExportError,
} from '../../../security/vault-export';
import { SettingsGroup, SettingItem } from '../../../components/SettingsGroup';

vi.mock('../../../security/vault-export', () => ({
Expand All @@ -28,6 +32,7 @@ vi.mock('../../../security/vault-export', () => ({
revealVaultSecret: vi.fn(async ({ kind }: { kind: 'privateKey' | 'mnemonic' }) =>
kind === 'privateKey' ? 'STESTPRIVATEKEY' : 'word '.repeat(12).trim()
),
verifyVaultPassword: vi.fn(async () => true),
}));

function renderSettingsScreen() {
Expand Down Expand Up @@ -191,6 +196,7 @@ describe('SecuritySettings', () => {
vi.mocked(revealVaultSecret).mockImplementation(async ({ kind }) =>
kind === 'privateKey' ? 'STESTPRIVATEKEY' : 'word '.repeat(12).trim()
);
vi.mocked(verifyVaultPassword).mockResolvedValue(true);
});

it('renders security menu items', () => {
Expand Down Expand Up @@ -251,9 +257,39 @@ describe('SecuritySettings', () => {
/>
);
await userEvent.click(screen.getByText('Require password for exports'));
expect(
screen.getByText(/re-enter your password to change this sensitive setting/i)
).toBeInTheDocument();

await userEvent.type(screen.getByPlaceholderText(/enter password to continue/i), 'mypassword');
await userEvent.click(screen.getByRole('button', { name: /confirm/i }));

expect(verifyVaultPassword).toHaveBeenCalledWith('mypassword');
expect(onRequirePasswordForSensitiveActionsChange).toHaveBeenCalledWith(false);
});

it('shows error when sensitive toggle password is wrong', async () => {
vi.mocked(verifyVaultPassword).mockResolvedValueOnce(false);

const onRequirePasswordForSensitiveActionsChange = vi.fn();
render(
<SecuritySettings
{...defaultProps}
onRequirePasswordForSensitiveActionsChange={onRequirePasswordForSensitiveActionsChange}
/>
);

await userEvent.click(screen.getByText('Require password for exports'));
await userEvent.type(
screen.getByPlaceholderText(/enter password to continue/i),
'wrong-password'
);
await userEvent.click(screen.getByRole('button', { name: /confirm/i }));

expect(screen.getByText('Incorrect password.')).toBeInTheDocument();
expect(onRequirePasswordForSensitiveActionsChange).not.toHaveBeenCalled();
});

it('shows export mnemonic warning', async () => {
render(<SecuritySettings {...defaultProps} />);
await userEvent.click(screen.getByText('Export Recovery Phrase'));
Expand Down
43 changes: 32 additions & 11 deletions apps/extension-wallet/src/security/__tests__/vault-export.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { webcrypto } from 'node:crypto';
import { beforeEach, describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { encryptSecretKey } from '@ancore/crypto';
import { SecureStorageManager, type StorageAdapter } from '@ancore/core-sdk';

Expand All @@ -11,6 +11,21 @@
} from '../vault-export';
import { getSharedStorageManager } from '../storage-manager';

vi.mock('@ancore/core-sdk', async () => {
const actual = await vi.importActual<typeof import('@ancore/core-sdk')>('@ancore/core-sdk');
let adapter: StorageAdapter | null = null;

return {
...actual,
createStorageAdapter: () => {
if (!adapter) {
adapter = new MockStorageAdapter();
}
return adapter;
},
};
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (!globalThis.crypto?.subtle) {
Object.defineProperty(globalThis, 'crypto', {
value: webcrypto,
Expand Down Expand Up @@ -53,12 +68,18 @@
privateKey: string;
mnemonic?: string;
encryptedMnemonic?: Awaited<ReturnType<typeof encryptSecretKey>>;
}
},
options: {
lockAfterSave?: boolean;
} = {}
): Promise<SecureStorageManager> {
const { lockAfterSave = true } = options;
const manager = new SecureStorageManager(storage);
await manager.unlock(PASSWORD);
await manager.saveAccount(account);
manager.lock();
if (lockAfterSave) {
manager.lock();
}
return manager;
}

Expand All @@ -81,8 +102,6 @@
it('reveals a private key after password verification', async () => {
const manager = await seedVaultAccount(storage, { privateKey: PRIVATE_KEY });

await manager.unlock(PASSWORD);

await expect(
revealVaultSecret({
kind: 'privateKey',
Expand All @@ -90,16 +109,18 @@
requirePassword: true,
storageManager: manager,
})
).resolves.toBe(PRIVATE_KEY);

Check failure on line 112 in apps/extension-wallet/src/security/__tests__/vault-export.test.ts

View workflow job for this annotation

GitHub Actions / Apps — Lint & Test

src/security/__tests__/vault-export.test.ts > vault-export > reveals a private key after password verification

AssertionError: promise rejected "VaultExportError: Incorrect password." instead of resolving ❯ src/security/__tests__/vault-export.test.ts:112:5 Caused by: Error: Incorrect password. ❯ ensureStorageAccess src/security/vault-export.ts:57:13 ❯ Module.revealVaultSecret src/security/vault-export.ts:75:3 ❯ src/security/__tests__/vault-export.test.ts:105:5
});

it('reveals a stored mnemonic when secure storage is already unlocked', async () => {
const manager = await seedVaultAccount(storage, {
privateKey: PRIVATE_KEY,
mnemonic: MNEMONIC,
});

await manager.unlock(PASSWORD);
const manager = await seedVaultAccount(
storage,
{
privateKey: PRIVATE_KEY,
mnemonic: MNEMONIC,
},
{ lockAfterSave: false }
);

await expect(
revealVaultSecret({
Expand All @@ -108,7 +129,7 @@
requirePassword: false,
storageManager: manager,
})
).resolves.toBe(MNEMONIC);

Check failure on line 132 in apps/extension-wallet/src/security/__tests__/vault-export.test.ts

View workflow job for this annotation

GitHub Actions / Apps — Lint & Test

src/security/__tests__/vault-export.test.ts > vault-export > reveals a stored mnemonic when secure storage is already unlocked

AssertionError: promise rejected "VaultExportError: No wallet found." instead of resolving ❯ src/security/__tests__/vault-export.test.ts:132:5 Caused by: Error: No wallet found. ❯ Module.revealVaultSecret src/security/vault-export.ts:79:11 ❯ src/security/__tests__/vault-export.test.ts:125:5
});

it('decrypts an encrypted mnemonic with @ancore/crypto after password verification', async () => {
Expand All @@ -125,7 +146,7 @@
requirePassword: true,
storageManager: manager,
})
).resolves.toBe(MNEMONIC);

Check failure on line 149 in apps/extension-wallet/src/security/__tests__/vault-export.test.ts

View workflow job for this annotation

GitHub Actions / Apps — Lint & Test

src/security/__tests__/vault-export.test.ts > vault-export > decrypts an encrypted mnemonic with @ancore/crypto after password verification

AssertionError: promise rejected "VaultExportError: Incorrect password." instead of resolving ❯ src/security/__tests__/vault-export.test.ts:149:5 Caused by: Error: Incorrect password. ❯ ensureStorageAccess src/security/vault-export.ts:57:13 ❯ Module.revealVaultSecret src/security/vault-export.ts:75:3 ❯ src/security/__tests__/vault-export.test.ts:142:5
});

it('rejects incorrect passwords without exposing secret material', async () => {
Expand Down
Loading
Loading