diff --git a/extension/src/service-worker/router/__tests__/router.test.ts b/extension/src/service-worker/router/__tests__/router.test.ts index f4b4cf3..117610f 100644 --- a/extension/src/service-worker/router/__tests__/router.test.ts +++ b/extension/src/service-worker/router/__tests__/router.test.ts @@ -1000,3 +1000,41 @@ describe('list_groups', () => { expect(resp.data).toEqual({ groups: [] }); }); }); + +// --- preview_totp_from_secret --- + +describe('preview_totp_from_secret', () => { + let originalChrome: any; + beforeEach(() => { originalChrome = (globalThis as any).chrome; }); + afterEach(() => { (globalThis as any).chrome = originalChrome; }); + + it('returns code for valid base32', async () => { + const state = makeState(); + state.wasm = { + totp_compute: vi.fn().mockReturnValue({ code: '123456', expires_at: 9_999_999_999 }), + }; + const resp = await route( + { type: 'preview_totp_from_secret', secret_b32: 'JBSWY3DPEHPK3PXP' } as any, + state, makePopupSender(), + ); + expect(resp.ok).toBe(true); + expect(resp.data).toEqual({ code: '123456', expires_at: 9_999_999_999 }); + // Verify a transient TotpConfig was passed (sha1, 6 digits, 30s) + const cfgArg = JSON.parse(state.wasm.totp_compute.mock.calls[0][0]); + expect(cfgArg.algorithm).toBe('sha1'); + expect(cfgArg.digits).toBe(6); + expect(cfgArg.period_seconds).toBe(30); + }); + + it('rejects invalid base32', async () => { + const state = makeState(); + state.wasm = { totp_compute: vi.fn() }; + const resp = await route( + { type: 'preview_totp_from_secret', secret_b32: 'too-short!!!' } as any, + state, makePopupSender(), + ); + expect(resp.ok).toBe(false); + expect(resp.error).toMatch(/invalid/i); + expect(state.wasm.totp_compute).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index 55842db..9b62f3b 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -6,6 +6,7 @@ import type { PopupMessage, Response } from '../../shared/messages'; import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings, TotpConfig, AttachmentRef } from '../../shared/types'; import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types'; +import { base32Decode } from '../../shared/base32'; import type { GitHost } from '../git-host'; import { createGitHost, base64ToUint8Array } from '../git-host'; import * as vault from '../vault'; @@ -169,6 +170,29 @@ export async function handle( return { ok: true, data: { groups: Array.from(set).sort() } }; } + case 'preview_totp_from_secret': { + const cleaned = msg.secret_b32.toUpperCase().replace(/\s+/g, '').replace(/=+$/, ''); + if (cleaned.length < 16 || !/^[A-Z2-7]+$/.test(cleaned)) { + return { ok: false, error: 'invalid base32 secret' }; + } + let secretBytes: Uint8Array; + try { + secretBytes = base32Decode(cleaned); + } catch (e) { + return { ok: false, error: `invalid base32: ${e instanceof Error ? e.message : String(e)}` }; + } + const cfg = { + secret: Array.from(secretBytes), + algorithm: 'sha1', + digits: 6, + period_seconds: 30, + kind: 'totp', + }; + const now = Math.floor(Date.now() / 1000); + const result = state.wasm.totp_compute(JSON.stringify(cfg), BigInt(now)); + return { ok: true, data: { code: result.code, expires_at: result.expires_at } }; + } + case 'generate_password': { const password = state.wasm.generate_password(JSON.stringify(msg.request)); return { ok: true, data: { password } }; diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index e68de37..7408124 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -59,7 +59,8 @@ export type PopupMessage = newRemote: { hostType: 'gitea' | 'github'; hostUrl: string; repoPath: string; apiToken: string }; } | { type: 'parse_lastpass_csv'; bytes: ArrayBuffer } - | { type: 'import_lastpass_commit'; items: Item[] }; + | { type: 'import_lastpass_commit'; items: Item[] } + | { type: 'preview_totp_from_secret'; secret_b32: string }; // --- Messages a content script may send --- @@ -166,6 +167,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet = new Set([ 'get_session_config', 'update_session_config', 'export_backup', 'restore_backup', 'parse_lastpass_csv', 'import_lastpass_commit', + 'preview_totp_from_secret', ] as PopupMessage['type'][]); export interface ExportBackupResponse extends Extract {