fix(ext): get_totp handles Totp items, not just Login

Critical bug caught in T8 code review: the SW's get_totp handler
gated on core.type === 'login' and referenced core.totp, so the
standalone Totp item type (which lands in T8 with core.type === 'totp'
and core.config) had its detail-view ticker silently rejected with
'no_totp' every second. Ticker swallowed the error; rotating code
display stayed at placeholder forever.

Fix: extend the handler to resolve TotpConfig from either carrier:
- Login items: item.core.totp (optional subfield)
- Totp items:  item.core.config (required)

Also:
- Add 3 router tests covering both paths + the empty case
- Remove stale '……' placeholder check in types/totp.ts's \`t\`
  keyboard shortcut (dead code — the placeholder is '·····' or
  '······', never horizontal ellipses)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-23 23:04:27 -04:00
parent 673981379e
commit 3c0b4c1589
3 changed files with 144 additions and 5 deletions

View File

@@ -125,7 +125,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
// Copy the currently displayed rotating code. // Copy the currently displayed rotating code.
const codeEl = document.getElementById('totp-code'); const codeEl = document.getElementById('totp-code');
const code = codeEl?.textContent?.trim(); const code = codeEl?.textContent?.trim();
if (code && code !== '……' && code !== '·····' && code !== '······') { if (code && code !== '·····' && code !== '······') {
try { await navigator.clipboard.writeText(code); } catch { /* swallow */ } try { await navigator.clipboard.writeText(code); } catch { /* swallow */ }
} }
break; break;

View File

@@ -522,3 +522,135 @@ describe('capture_save_login', () => {
expect(vault.encryptAndWriteItem).not.toHaveBeenCalled(); 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' });
});
});

View File

@@ -4,7 +4,7 @@
/// via sender.url === popup.html (or setup.html for save_setup). /// via sender.url === popup.html (or setup.html for save_setup).
import type { PopupMessage, Response } from '../../shared/messages'; 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 { DEFAULT_DEVICE_SETTINGS } from '../../shared/types';
import type { GitHost } from '../git-host'; import type { GitHost } from '../git-host';
import { createGitHost, base64ToUint8Array } from '../git-host'; import { createGitHost, base64ToUint8Array } from '../git-host';
@@ -107,11 +107,18 @@ export async function handle(
const handle = session.getCurrent(); const handle = session.getCurrent();
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id); const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
if (item.core.type !== 'login' || !item.core.totp) { // Resolve the TotpConfig from whichever carrier the item type uses.
return { ok: false, error: 'no_totp' }; // 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 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 } }; return { ok: true, data: { code: code.code, expires_at: code.expires_at } };
} }