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:
@@ -125,7 +125,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
||||
// 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;
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user