ext(sw): add preview_totp_from_secret popup handler
This commit is contained in:
@@ -1000,3 +1000,41 @@ describe('list_groups', () => {
|
|||||||
expect(resp.data).toEqual({ 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import type { PopupMessage, Response } from '../../shared/messages';
|
import type { PopupMessage, Response } from '../../shared/messages';
|
||||||
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings, TotpConfig, AttachmentRef } from '../../shared/types';
|
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings, TotpConfig, AttachmentRef } from '../../shared/types';
|
||||||
import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types';
|
import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types';
|
||||||
|
import { base32Decode } from '../../shared/base32';
|
||||||
import type { GitHost } from '../git-host';
|
import type { GitHost } from '../git-host';
|
||||||
import { createGitHost, base64ToUint8Array } from '../git-host';
|
import { createGitHost, base64ToUint8Array } from '../git-host';
|
||||||
import * as vault from '../vault';
|
import * as vault from '../vault';
|
||||||
@@ -169,6 +170,29 @@ export async function handle(
|
|||||||
return { ok: true, data: { groups: Array.from(set).sort() } };
|
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': {
|
case 'generate_password': {
|
||||||
const password = state.wasm.generate_password(JSON.stringify(msg.request));
|
const password = state.wasm.generate_password(JSON.stringify(msg.request));
|
||||||
return { ok: true, data: { password } };
|
return { ok: true, data: { password } };
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ export type PopupMessage =
|
|||||||
newRemote: { hostType: 'gitea' | 'github'; hostUrl: string; repoPath: string; apiToken: string };
|
newRemote: { hostType: 'gitea' | 'github'; hostUrl: string; repoPath: string; apiToken: string };
|
||||||
}
|
}
|
||||||
| { type: 'parse_lastpass_csv'; bytes: ArrayBuffer }
|
| { 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 ---
|
// --- Messages a content script may send ---
|
||||||
|
|
||||||
@@ -166,6 +167,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
|||||||
'get_session_config', 'update_session_config',
|
'get_session_config', 'update_session_config',
|
||||||
'export_backup', 'restore_backup',
|
'export_backup', 'restore_backup',
|
||||||
'parse_lastpass_csv', 'import_lastpass_commit',
|
'parse_lastpass_csv', 'import_lastpass_commit',
|
||||||
|
'preview_totp_from_secret',
|
||||||
] as PopupMessage['type'][]);
|
] as PopupMessage['type'][]);
|
||||||
|
|
||||||
export interface ExportBackupResponse extends Extract<Response, { ok: true }> {
|
export interface ExportBackupResponse extends Extract<Response, { ok: true }> {
|
||||||
|
|||||||
Reference in New Issue
Block a user