Skip to content
Merged
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: 4 additions & 0 deletions apps/desktop/__tests__/step-up.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ describe('stepUpKind', () => {
expect(stepUpKind('STEP_UP_REQUIRED_FRESH_AUTH')).toBe('fresh_auth');
});

it('treats the bare code as a password demand', () => {
expect(stepUpKind('GraphQL Error: STEP_UP_REQUIRED')).toBe('password');
});

it('does not fire on any other failure', () => {
expect(stepUpKind('no GraphQL endpoint at http://x/graphql')).toBeNull();
expect(stepUpKind('invalid email or password')).toBeNull();
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,14 @@ export const registerIpc = (service: VaultService): void => {
await accounts().revokeApiKey(assertString(itemId), proof(stepUp));
service.scheduleSave();
});
handle(CHANNELS.accountsLinkTotp, async (accountItemId: string, totpItemId: string) => {
await accounts().linkTotp(assertString(accountItemId), assertString(totpItemId));
service.scheduleSave();
});
handle(CHANNELS.accountsUnlinkTotp, async (accountItemId: string) => {
await accounts().unlinkTotp(assertString(accountItemId));
service.scheduleSave();
});

// ─── audit ───
handle(CHANNELS.auditLog, (itemId?: string) => service.current().auditLog(itemId));
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ const api: DcryptApi & {
invoke(CHANNELS.accountsCreateKey, accountItemId, request, stepUp),
revealKey: (itemId) => invoke(CHANNELS.accountsRevealKey, itemId),
revokeKey: (itemId, stepUp) => invoke(CHANNELS.accountsRevokeKey, itemId, stepUp),
linkTotp: (accountItemId, totpItemId) =>
invoke(CHANNELS.accountsLinkTotp, accountItemId, totpItemId),
unlinkTotp: (accountItemId) => invoke(CHANNELS.accountsUnlinkTotp, accountItemId),
},
audit: {
log: (itemId) => invoke(CHANNELS.auditLog, itemId),
Expand Down
98 changes: 92 additions & 6 deletions apps/desktop/src/renderer/src/screens/AccountsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,25 @@ import {
import { Input } from '@constructive-io/ui/input';
import { Label } from '@constructive-io/ui/label';
import { Separator } from '@constructive-io/ui/separator';
import { Copy, KeyRound, LogIn, LogOut, Plus, Trash2, UserPlus } from 'lucide-react';
import {
Copy,
KeyRound,
LogIn,
LogOut,
Plus,
Timer,
Trash2,
UserPlus,
} from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';

import type { AccountRecord, ApiKeyRecord, StepUpProof } from '../../../shared/api';
import type {
AccountRecord,
ApiKeyRecord,
StepUpProof,
TotpEntry,
} from '../../../shared/api';
import {
StepUpKind,
stepUpKind,
Expand Down Expand Up @@ -60,14 +74,19 @@ export const AccountsScreen = () => {
const [held, setHeld] = useState<HeldRequest | null>(null);
const [proofValue, setProofValue] = useState('');

const [codes, setCodes] = useState<TotpEntry[]>([]);
const [linkFor, setLinkFor] = useState<AccountRecord | null>(null);

const refresh = useCallback(async () => {
try {
const [nextAccounts, nextKeys] = await Promise.all([
const [nextAccounts, nextKeys, nextCodes] = await Promise.all([
dcrypt.accounts.list(),
dcrypt.accounts.keys(),
dcrypt.totp.list(),
]);
setAccounts(nextAccounts);
setKeys(nextKeys);
setCodes(nextCodes);
} catch {
// vault locked mid-refresh
}
Expand Down Expand Up @@ -230,8 +249,33 @@ export const AccountsScreen = () => {
>
<Trash2 className="size-4" /> Forget
</Button>
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() =>
account.totpItemId
? run(async () => {
await dcrypt.accounts.unlinkTotp(account.itemId);
return 'Code unlinked';
})
: setLinkFor(account)
}
>
<Timer className="size-4" />
{account.totpItemId ? 'Unlink code' : 'Link a code'}
</Button>
</div>

{account.totpItemId && (
<p className="text-xs text-muted-foreground">
MFA challenges are answered with{' '}
{codes.find((entry) => entry.item.id === account.totpItemId)?.item
.title ?? 'a code in this vault'}
, so you are not asked for one.
</p>
)}

{accountKeys.length > 0 && <Separator />}
{accountKeys.map((key) => (
<div key={key.itemId} className="flex items-center gap-2 text-sm">
Expand Down Expand Up @@ -371,6 +415,50 @@ export const AccountsScreen = () => {
</DialogContent>
</Dialog>

<Dialog open={linkFor !== null} onOpenChange={(open) => !open && setLinkFor(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Link a one-time code</DialogTitle>
<DialogDescription>
When the server asks this account for MFA, dcrypt answers with the code
you pick here. Only the item is remembered — the seed stays where it is.
</DialogDescription>
</DialogHeader>
<DialogPanel className="flex flex-col gap-2">
{codes.length === 0 && (
<p className="text-sm text-muted-foreground">
This vault holds no one-time codes yet.
</p>
)}
{codes.map((entry) => (
<Button
key={entry.item.id}
variant="outline"
className="justify-between"
disabled={busy}
onClick={() => {
const account = linkFor;
if (!account) return;
setLinkFor(null);
void run(async () => {
await dcrypt.accounts.linkTotp(account.itemId, entry.item.id);
return `${entry.item.title} will answer MFA for ${account.email}`;
});
}}
>
<span>{entry.item.title}</span>
<Timer className="size-4 text-muted-foreground" />
</Button>
))}
</DialogPanel>
<DialogFooter>
<Button variant="outline" disabled={busy} onClick={() => setLinkFor(null)}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

<Dialog open={held !== null} onOpenChange={(open) => !open && setHeld(null)}>
<DialogContent>
<DialogHeader>
Expand Down Expand Up @@ -404,9 +492,7 @@ export const AccountsScreen = () => {
</Button>
<Button
disabled={busy || !held || !proofValue}
onClick={() =>
held && run(held.work, stepUpProof(held.kind, proofValue))
}
onClick={() => held && run(held.work, stepUpProof(held.kind, proofValue))}
>
{busy ? 'Verifying…' : 'Confirm and continue'}
</Button>
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/shared/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ export interface DcryptApi {
/** Reads the secret back out of the vault, on demand only. */
revealKey(itemId: string): Promise<string>;
revokeKey(itemId: string, stepUp?: StepUpProof): Promise<void>;
/** Point an account at a vault code that then answers its MFA step-ups. */
linkTotp(accountItemId: string, totpItemId: string): Promise<void>;
unlinkTotp(accountItemId: string): Promise<void>;
};
audit: {
log(itemId?: string): Promise<AuditEntry[]>;
Expand Down Expand Up @@ -228,6 +231,8 @@ export const CHANNELS = {
accountsCreateKey: 'accounts:create-key',
accountsRevealKey: 'accounts:reveal-key',
accountsRevokeKey: 'accounts:revoke-key',
accountsLinkTotp: 'accounts:link-totp',
accountsUnlinkTotp: 'accounts:unlink-totp',
auditLog: 'audit:log',
wbCreateWallet: 'workbench:create-wallet',
wbDeriveAccounts: 'workbench:derive-accounts',
Expand Down
10 changes: 6 additions & 4 deletions apps/desktop/src/shared/step-up.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ export type StepUpKind = 'password' | 'mfa' | 'fresh_auth';

/**
* An IPC rejection reaches the renderer as a string, so the only thing left to
* key off is the server's own `STEP_UP_REQUIRED_*` code, which survives being
* wrapped by Electron and by our own error types.
* key off is the server's own `STEP_UP_REQUIRED*` code, which survives being
* wrapped by Electron and by our own error types. The bare form, raised when a
* session has no recent password verification, asks for a password.
*/
export const stepUpKind = (message: string): StepUpKind | null => {
const found = /STEP_UP_REQUIRED_(PASSWORD|MFA|FRESH_AUTH)/.exec(message);
return found ? (found[1].toLowerCase() as StepUpKind) : null;
const found = /STEP_UP_REQUIRED(?:_(PASSWORD|MFA|FRESH_AUTH))?/.exec(message);
if (!found) return null;
return (found[1]?.toLowerCase() as StepUpKind) ?? 'password';
};

export const stepUpPrompt = (
Expand Down
108 changes: 107 additions & 1 deletion packages/accounts/__tests__/accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
CreateApiKeyOptions,
hasExpired,
StepUpKind,
stepUpKind,
StepUpRequiredError,
} from '../src';

Expand Down Expand Up @@ -61,7 +62,7 @@ class FakeServer {
},
verifyTotp: async (code: string) => {
this.calls.push({ operation: 'verifyTotp', token });
if (code !== '123456') {
if (!/^\d{6}$/.test(code)) {
throw new AuthError('verifyTotp', 'the code was not accepted');
}
this.demandStepUp = null;
Expand Down Expand Up @@ -120,6 +121,9 @@ beforeEach(async () => {
for (const item of await vault.listItems({ kind: 'api_key' })) {
await vault.deleteItemForever(item.id);
}
for (const item of await vault.listItems({ kind: 'totp' })) {
await vault.deleteItemForever(item.id);
}
server = new FakeServer();
accounts = new AccountManager(vault, { createClient: server.factory });
});
Expand Down Expand Up @@ -363,6 +367,108 @@ describe('step-up', () => {
});
});

describe('stepUpKind', () => {
it('reads the factor the server named', () => {
expect(stepUpKind('STEP_UP_REQUIRED_PASSWORD')).toBe('password');
expect(stepUpKind('STEP_UP_REQUIRED_MFA')).toBe('mfa');
expect(stepUpKind('STEP_UP_REQUIRED_FRESH_AUTH')).toBe('fresh_auth');
});

it('treats the bare code as a password demand', () => {
expect(stepUpKind('GraphQL Error: STEP_UP_REQUIRED')).toBe('password');
});

it('stays out of the way of every other failure', () => {
expect(stepUpKind('invalid email or password')).toBeNull();
});
});

describe('linked one-time codes', () => {
const signIn = () =>
accounts.signIn({
endpoint: ENDPOINT,
email: 'dev@example.com',
password: 'hunter22',
});

/** A vault code item, the same shape the app's importer writes. */
const addCode = async (title: string): Promise<string> => {
const item = await vault.createItem('totp', title);
await vault.setField(item.id, 'seed', 'totp_seed', 'JBSWY3DPEHPK3PXP');
return item.id;
};

it('answers an MFA demand from the linked item, unprompted', async () => {
const account = await signIn();
await accounts.linkTotp(account.itemId, await addCode('Constructive dev'));
expect(await accounts.stepUpCode(account.itemId)).toMatch(/^\d{6}$/);

server.demandStepUp = 'mfa';
const key = await accounts.createApiKey(account.itemId, { name: 'ci' });

expect(key.name).toBe('ci');
expect(server.calls.map((call) => call.operation)).toEqual([
'signIn',
'createApiKey',
'verifyTotp',
'createApiKey',
]);
});

it('still asks for the password when that is what was demanded', async () => {
const account = await signIn();
await accounts.linkTotp(account.itemId, await addCode('Constructive dev'));
server.demandStepUp = 'password';

await expect(accounts.createApiKey(account.itemId, { name: 'ci' })).rejects.toThrow(
StepUpRequiredError
);
expect(server.calls.some((call) => call.operation === 'verifyTotp')).toBe(false);
});

it('stores the item id, not a copy of the seed', async () => {
const account = await signIn();
const totpItemId = await addCode('Constructive dev');
await accounts.linkTotp(account.itemId, totpItemId);

const fields = await vault.listFields(account.itemId);
const link = fields.find((field) => field.name === 'totp_item_id');
expect(link).toBeDefined();
expect(await vault.revealField(account.itemId, 'totp_item_id')).toBe(totpItemId);
expect(fields.some((field) => field.purpose === 'totp_seed')).toBe(false);
expect((await accounts.getAccount(account.itemId)).totpItemId).toBe(totpItemId);
});

it('refuses an item that carries no seed', async () => {
const account = await signIn();
const empty = await vault.createItem('totp', 'nothing in here');

await expect(accounts.linkTotp(account.itemId, empty.id)).rejects.toThrow(
'carries no code seed'
);
});

it('has no code to offer until something is linked', async () => {
const account = await signIn();
expect(await accounts.stepUpCode(account.itemId)).toBeNull();

const totpItemId = await addCode('Constructive dev');
await accounts.linkTotp(account.itemId, totpItemId);
await accounts.unlinkTotp(account.itemId);
expect(await accounts.stepUpCode(account.itemId)).toBeNull();
});

it('forgets a link whose code has left the vault', async () => {
const account = await signIn();
const totpItemId = await addCode('Constructive dev');
await accounts.linkTotp(account.itemId, totpItemId);
await vault.deleteItemForever(totpItemId);

expect(await accounts.stepUpCode(account.itemId)).toBeNull();
expect((await accounts.getAccount(account.itemId)).totpItemId).toBeNull();
});
});

describe('hasExpired', () => {
const now = new Date('2026-01-01T00:00:00.000Z');

Expand Down
8 changes: 6 additions & 2 deletions packages/accounts/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,16 +113,20 @@ const explain = (message: string, endpoint: string): string => {
return `no GraphQL endpoint at ${endpoint} — ${suffix}`;
};

const STEP_UP = /STEP_UP_REQUIRED_(PASSWORD|MFA|FRESH_AUTH)/;
const STEP_UP = /STEP_UP_REQUIRED(?:_(PASSWORD|MFA|FRESH_AUTH))?/;

/**
* Which factor a message is asking for, or null. Exported because a step-up
* error arrives at a UI as plain text once it has crossed a process boundary,
* and the server's own code is the only trustworthy thing to key off.
*
* The bare `STEP_UP_REQUIRED` raised by the generated guard means the session
* has no recent password verification, so it asks for a password.
*/
export const stepUpKind = (message: string): StepUpKind | null => {
const found = STEP_UP.exec(message);
return found ? (found[1].toLowerCase() as StepUpKind) : null;
if (!found) return null;
return (found[1]?.toLowerCase() as StepUpKind) ?? 'password';
};

const rethrow = (operation: string, endpoint: string, error: unknown): never => {
Expand Down
Loading
Loading