diff --git a/extension/src/popup/components/types/totp.ts b/extension/src/popup/components/types/totp.ts index a794376..5649596 100644 --- a/extension/src/popup/components/types/totp.ts +++ b/extension/src/popup/components/types/totp.ts @@ -125,7 +125,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise // Copy the currently displayed rotating code. const codeEl = document.getElementById('totp-code'); const code = codeEl?.textContent?.trim(); - if (code && code !== '……' && code !== '·····' && code !== '······') { + if (code && code !== '·····' && code !== '······') { try { await navigator.clipboard.writeText(code); } catch { /* swallow */ } } break; diff --git a/extension/src/service-worker/router/__tests__/router.test.ts b/extension/src/service-worker/router/__tests__/router.test.ts index 79cdb5a..fa49416 100644 --- a/extension/src/service-worker/router/__tests__/router.test.ts +++ b/extension/src/service-worker/router/__tests__/router.test.ts @@ -522,3 +522,135 @@ describe('capture_save_login', () => { expect(vault.encryptAndWriteItem).not.toHaveBeenCalled(); }); }); + +// --- get_totp covers both Login.totp and Totp.config --- + +describe('get_totp handler covers both Login.totp and Totp.config', () => { + function primeUnlocked(state: RouterState): void { + vi.mocked(session.getCurrent).mockReturnValue({ free: () => {} } as never); + state.gitHost = {} as never; + } + + function withTotpWasm(state: RouterState): void { + state.wasm = { + ...state.wasm, + totp_compute: (_json: string, _now: bigint) => ({ + code: '123456', + expires_at: 1_700_000_030, + }), + }; + } + + beforeEach(() => { + vi.mocked(session.getCurrent).mockReset(); + vi.mocked(vault.fetchAndDecryptItem).mockReset(); + }); + + it('returns a code for an item with core.type === "totp"', async () => { + const state = makeState(); + primeUnlocked(state); + withTotpWasm(state); + const totpItem: Item = { + id: 'totp0000000000aa', + title: 'GitHub TOTP', + type: 'totp', + tags: [], + favorite: false, + created: 0, + modified: 0, + core: { + type: 'totp', + config: { + secret: [0x48, 0x65, 0x6c, 0x6c, 0x6f], // "Hello" in bytes + algorithm: 'sha1', + digits: 6, + period_seconds: 30, + kind: 'totp', + }, + issuer: 'GitHub', + }, + sections: [], + attachments: [], + field_history: {}, + }; + vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(totpItem); + + const res = await route( + { type: 'get_totp', id: 'totp0000000000aa' }, + state, + makePopupSender(), + ); + expect(res.ok).toBe(true); + if (res.ok) { + const d = res.data as { code: string; expires_at: number }; + expect(d.code).toMatch(/^\d{6}$/); + expect(d.expires_at).toBeGreaterThan(0); + } + }); + + it('still returns a code for Login items with a totp subfield', async () => { + const state = makeState(); + primeUnlocked(state); + withTotpWasm(state); + const loginItem: Item = { + id: 'login0000000000a', + title: 'Example', + type: 'login', + tags: [], + favorite: false, + created: 0, + modified: 0, + core: { + type: 'login', + username: 'alice', + password: 'hunter2', + url: 'https://example.com', + totp: { + secret: [0x48, 0x65, 0x6c, 0x6c, 0x6f], + algorithm: 'sha1', + digits: 6, + period_seconds: 30, + kind: 'totp', + }, + }, + sections: [], + attachments: [], + field_history: {}, + }; + vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(loginItem); + + const res = await route( + { type: 'get_totp', id: 'login0000000000a' }, + state, + makePopupSender(), + ); + expect(res.ok).toBe(true); + }); + + it('rejects items without any TOTP config', async () => { + const state = makeState(); + primeUnlocked(state); + withTotpWasm(state); + const identityItem: Item = { + id: 'id0000000000aaaa', + title: 'Identity', + type: 'identity', + tags: [], + favorite: false, + created: 0, + modified: 0, + core: { type: 'identity' }, + sections: [], + attachments: [], + field_history: {}, + }; + vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(identityItem); + + const res = await route( + { type: 'get_totp', id: 'id0000000000aaaa' }, + state, + makePopupSender(), + ); + expect(res).toEqual({ ok: false, error: 'no_totp' }); + }); +}); diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index b71f9d0..14b89cc 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -4,7 +4,7 @@ /// via sender.url === popup.html (or setup.html for save_setup). import type { PopupMessage, Response } from '../../shared/messages'; -import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings } from '../../shared/types'; +import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings, TotpConfig } from '../../shared/types'; import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types'; import type { GitHost } from '../git-host'; import { createGitHost, base64ToUint8Array } from '../git-host'; @@ -107,11 +107,18 @@ export async function handle( const handle = session.getCurrent(); if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id); - if (item.core.type !== 'login' || !item.core.totp) { - return { ok: false, error: 'no_totp' }; + // Resolve the TotpConfig from whichever carrier the item type uses. + // Login items hold TOTP as an optional subfield on LoginCore; the standalone + // Totp item type carries it as TotpCore.config (required). + let cfg: TotpConfig | null = null; + if (item.core.type === 'login' && item.core.totp) { + cfg = item.core.totp; + } else if (item.core.type === 'totp') { + cfg = item.core.config; } + if (!cfg) return { ok: false, error: 'no_totp' }; const now = Math.floor(Date.now() / 1000); - const code = state.wasm.totp_compute(JSON.stringify(item.core.totp), BigInt(now)); + const code = state.wasm.totp_compute(JSON.stringify(cfg), BigInt(now)); return { ok: true, data: { code: code.code, expires_at: code.expires_at } }; }