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
48 changes: 48 additions & 0 deletions apps/desktop/__tests__/totp-import.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';

import { parseTotpJsonExport } from '../src/shared/totp-import';

describe('parseTotpJsonExport', () => {
it('parses entries with uris, keeping custom digits/period', () => {
const parsed = parseTotpJsonExport(
JSON.stringify([
{
name: 'Coinbase',
secret: 'JBSWY3DPEHPK3PXP',
uri: 'otpauth://totp/Coinbase?secret=JBSWY3DPEHPK3PXP&digits=7&period=10',
},
{
name: 'GitHub',
secret: 'KRSXG5A=',
uri: 'otpauth://totp/GitHub:alice?secret=KRSXG5A&issuer=GitHub',
},
])
);
expect(parsed).toEqual([
{
name: 'Coinbase',
uri: 'otpauth://totp/Coinbase?secret=JBSWY3DPEHPK3PXP&digits=7&period=10',
},
{ name: 'GitHub', uri: 'otpauth://totp/GitHub:alice?secret=KRSXG5A&issuer=GitHub' },
]);
});

it('builds a default uri when only name and secret are present', () => {
const [entry] = parseTotpJsonExport(
JSON.stringify([{ name: 'Plain', secret: 'jbswy3dpehpk3pxp' }])
);
expect(entry.name).toBe('Plain');
expect(entry.uri).toBe('otpauth://totp/Plain?secret=JBSWY3DPEHPK3PXP');
});

it('rejects non-arrays, bad json and entries without a secret', () => {
expect(() => parseTotpJsonExport('{}')).toThrow('expected a JSON array');
expect(() => parseTotpJsonExport('nope')).toThrow('not a valid JSON file');
expect(() => parseTotpJsonExport(JSON.stringify([{ name: 'x' }]))).toThrow(
'has no uri or secret'
);
expect(() =>
parseTotpJsonExport(JSON.stringify([{ uri: 'otpauth://hotp/x?secret=JBSWY3DP' }]))
).toThrow('only totp is supported');
});
});
3 changes: 3 additions & 0 deletions apps/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ export const registerIpc = (service: VaultService): void => {
if (parsed.period !== 30) {
await vault.setField(item.id, 'period', 'text', String(parsed.period), false);
}
if (parsed.digits !== 6) {
await vault.setField(item.id, 'digits', 'text', String(parsed.digits), false);
}
service.scheduleSave();
return item;
});
Expand Down
12 changes: 10 additions & 2 deletions apps/desktop/src/main/vault-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,19 @@ export class VaultService {
return this.vault;
}

async totpEntry(itemId: string, period = 30): Promise<TotpEntry> {
async totpEntry(itemId: string): Promise<TotpEntry> {
const vault = this.current();
const item = await vault.getItem(itemId);
if (!item) throw new Error('item not found');
const code = await vault.totpCode(itemId, { period });
const fields = await vault.listFields(itemId);
const numericField = async (name: string, fallback: number): Promise<number> => {
if (!fields.some((field) => field.name === name)) return fallback;
const value = Number(await vault.revealField(itemId, name));
return Number.isInteger(value) && value > 0 ? value : fallback;
};
const period = await numericField('period', 30);
const digits = await numericField('digits', 6);
const code = await vault.totpCode(itemId, { period, digits });
const now = Math.floor(Date.now() / 1000);
return { item, code, period, remaining: period - (now % period) };
}
Expand Down
52 changes: 47 additions & 5 deletions apps/desktop/src/renderer/src/screens/TotpScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,20 @@ import {
import { Input } from '@constructive-io/ui/input';
import { Label } from '@constructive-io/ui/label';
import { Progress } from '@constructive-io/ui/progress';
import { Copy, Import, Plus } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { Copy, FileUp, Import, Plus } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';

import type { TotpEntry } from '../../../shared/api';
import { parseTotpJsonExport } from '../../../shared/totp-import';
import { copyWithTimeout, dcrypt } from '../lib/ipc';

export const TotpScreen = () => {
const [entries, setEntries] = useState<TotpEntry[]>([]);
const [showImport, setShowImport] = useState(false);
const [uri, setUri] = useState('');
const [busy, setBusy] = useState(false);
const fileInput = useRef<HTMLInputElement>(null);

const refresh = useCallback(async () => {
try {
Expand Down Expand Up @@ -60,6 +62,31 @@ export const TotpScreen = () => {
}
};

const importJsonFile = async (file: File) => {
setBusy(true);
try {
const parsed = parseTotpJsonExport(await file.text());
let imported = 0;
const failures: string[] = [];
for (const entry of parsed) {
try {
await dcrypt.totp.importUri(entry.uri);
imported += 1;
} catch (err) {
failures.push(`${entry.name}: ${err instanceof Error ? err.message : String(err)}`);
}
}
if (imported) toast.success(`Imported ${imported} code${imported === 1 ? '' : 's'}`);
for (const failure of failures) toast.error(failure);
await refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
if (fileInput.current) fileInput.current.value = '';
}
};

return (
<div className="flex h-full flex-col gap-4 overflow-y-auto p-6">
<div className="flex items-center justify-between">
Expand All @@ -69,9 +96,24 @@ export const TotpScreen = () => {
Codes are generated locally by the vault database.
</p>
</div>
<Button variant="outline" onClick={() => setShowImport(true)}>
<Import className="size-4" /> Import otpauth URI
</Button>
<div className="flex gap-2">
<input
ref={fileInput}
type="file"
accept=".json,application/json"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void importJsonFile(file);
}}
/>
<Button variant="outline" disabled={busy} onClick={() => fileInput.current?.click()}>
<FileUp className="size-4" /> {busy ? 'Importing…' : 'Import JSON file'}
</Button>
<Button variant="outline" onClick={() => setShowImport(true)}>
<Import className="size-4" /> Import otpauth URI
</Button>
</div>
</div>

<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
Expand Down
49 changes: 49 additions & 0 deletions apps/desktop/src/shared/totp-import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { formatOtpauthUri, parseOtpauthUri } from './otpauth';

/**
* One entry of an authenticator JSON export:
* `[{ "name": "...", "secret": "...", "uri": "otpauth://totp/..." }, ...]`.
* The URI is authoritative when present (it carries digits/period/algorithm);
* name and secret alone are enough for a default 6-digit/30s entry.
*/
export interface TotpJsonEntry {
name?: string;
secret?: string;
uri?: string;
}

/** Parses a JSON export into otpauth URIs, one per entry. Throws on malformed files. */
export const parseTotpJsonExport = (json: string): { name: string; uri: string }[] => {
let data: unknown;
try {
data = JSON.parse(json);
} catch {
throw new Error('not a valid JSON file');
}
if (!Array.isArray(data)) {
throw new Error('expected a JSON array of { name, secret, uri } entries');
}
return data.map((raw, index) => {
if (typeof raw !== 'object' || raw === null) {
throw new Error(`entry ${index + 1} is not an object`);
}
const entry = raw as TotpJsonEntry;
if (typeof entry.uri === 'string' && entry.uri.length) {
const params = parseOtpauthUri(entry.uri);
const name = typeof entry.name === 'string' && entry.name.length ? entry.name : params.label;
return { name, uri: entry.uri };
}
if (typeof entry.secret === 'string' && entry.secret.length) {
const name = typeof entry.name === 'string' && entry.name.length ? entry.name : `Entry ${index + 1}`;
const uri = formatOtpauthUri({
label: name,
secret: entry.secret.toUpperCase().replace(/\s+/g, ''),
period: 30,
digits: 6,
algorithm: 'SHA1',
});
return { name, uri };
}
throw new Error(`entry ${index + 1} ("${entry.name ?? 'unnamed'}") has no uri or secret`);
});
};
11 changes: 10 additions & 1 deletion packages/cli/src/commands/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,16 @@ const totp = async (argv: ParsedArgs, prompter: Inquirerer): Promise<void> => {
const vault = await openVault(newArgv, prompter);
try {
const item = await findItem(vault, first);
const code = await vault.totpCode(item.id);
const fields = await vault.listFields(item.id);
const numeric = async (name: string, fallback: number): Promise<number> => {
if (!fields.some((field) => field.name === name)) return fallback;
const value = Number(await vault.revealField(item.id, name));
return Number.isInteger(value) && value > 0 ? value : fallback;
};
const code = await vault.totpCode(item.id, {
period: await numeric('period', 30),
digits: await numeric('digits', 6),
});
emit(newArgv, { code }, () => code);
} finally {
await vault.lock();
Expand Down
Loading