parse_lastpass_csv is a pure pass-through to the WASM bridge. import_lastpass_commit re-mints each item's ID via state.wasm.new_item_id() (same pattern as add_item), encrypts and writes per-item via git.writeFile, then writes the manifest last. Per-item commits + a final manifest commit — extension GitHost has no atomic-batch API, so the single-commit semantics the CLI provides aren't replicable here. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
655 lines
25 KiB
TypeScript
655 lines
25 KiB
TypeScript
/// Popup-callable message handlers.
|
|
///
|
|
/// Every export here assumes the router has already verified sender identity
|
|
/// 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, TotpConfig, AttachmentRef } from '../../shared/types';
|
|
import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types';
|
|
import type { GitHost } from '../git-host';
|
|
import { createGitHost, base64ToUint8Array } from '../git-host';
|
|
import * as vault from '../vault';
|
|
import * as session from '../session';
|
|
import * as devices from '../devices';
|
|
import * as sessionTimer from '../session-timer';
|
|
|
|
// --- Shared ambient state owned by the SW module ---
|
|
//
|
|
// The router keeps these on a single `state` object and injects it into the
|
|
// handler so testing can mock them without reaching for globals.
|
|
|
|
export interface PopupState {
|
|
manifest: Manifest | null;
|
|
gitHost: GitHost | null;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
wasm: any;
|
|
}
|
|
|
|
export async function handle(
|
|
msg: PopupMessage,
|
|
state: PopupState,
|
|
sender: chrome.runtime.MessageSender,
|
|
): Promise<Response> {
|
|
void sender; // unused in most branches; retained for symmetry with content-callable
|
|
switch (msg.type) {
|
|
case 'is_unlocked':
|
|
return { ok: true, data: { unlocked: session.getCurrent() !== null } };
|
|
|
|
case 'unlock': {
|
|
const w = state.wasm;
|
|
const config = await loadConfig();
|
|
if (!config) return { ok: false, error: 'Extension not configured. Run setup first.' };
|
|
const imageB64 = await loadImageBase64();
|
|
if (!imageB64) return { ok: false, error: 'Reference image not set. Run setup first.' };
|
|
|
|
const imageBytes = base64ToUint8Array(imageB64);
|
|
if (!state.gitHost) state.gitHost = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
|
|
const meta = await vault.fetchVaultMeta(state.gitHost);
|
|
|
|
const handle = w.unlock(msg.passphrase, imageBytes, meta.salt, meta.paramsJson);
|
|
session.setCurrent(handle);
|
|
(msg as { passphrase: string }).passphrase = '';
|
|
|
|
state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle);
|
|
return { ok: true };
|
|
}
|
|
|
|
case 'lock':
|
|
session.clearCurrent();
|
|
state.manifest = null;
|
|
return { ok: true };
|
|
|
|
case 'list_items': {
|
|
if (!state.manifest) return { ok: false, error: 'vault_locked' };
|
|
return { ok: true, data: { items: vault.listItems(state.manifest, msg.group) } };
|
|
}
|
|
|
|
case 'get_item': {
|
|
const handle = session.getCurrent();
|
|
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
|
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
|
|
return { ok: true, data: { item } };
|
|
}
|
|
|
|
case 'add_item': {
|
|
const handle = session.getCurrent();
|
|
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
|
const id = state.wasm.new_item_id();
|
|
const item: Item = { ...msg.item, id };
|
|
await vault.encryptAndWriteItem(state.gitHost, handle, id, item, `add: ${item.title}`);
|
|
state.manifest.items[id] = itemToManifestEntry(item);
|
|
await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: add ${item.title}`);
|
|
return { ok: true, data: { id } };
|
|
}
|
|
|
|
case 'update_item': {
|
|
const handle = session.getCurrent();
|
|
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
|
await vault.encryptAndWriteItem(state.gitHost, handle, msg.id, msg.item, `update: ${msg.item.title}`);
|
|
state.manifest.items[msg.id] = itemToManifestEntry(msg.item);
|
|
await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: update ${msg.item.title}`);
|
|
return { ok: true };
|
|
}
|
|
|
|
case 'delete_item': {
|
|
const handle = session.getCurrent();
|
|
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
|
const entry = state.manifest.items[msg.id];
|
|
if (!entry) return { ok: false, error: 'item_not_found' };
|
|
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const updated: Item = { ...item, trashed_at: now, modified: now };
|
|
await vault.encryptAndWriteItem(state.gitHost, handle, msg.id, updated, `trash: ${entry.title}`);
|
|
state.manifest.items[msg.id] = { ...entry, trashed_at: now, modified: now };
|
|
await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: trash ${entry.title}`);
|
|
return { ok: true };
|
|
}
|
|
|
|
case 'get_totp': {
|
|
const handle = session.getCurrent();
|
|
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
|
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
|
|
// 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(cfg), BigInt(now));
|
|
return { ok: true, data: { code: code.code, expires_at: code.expires_at } };
|
|
}
|
|
|
|
case 'sync': {
|
|
const handle = session.getCurrent();
|
|
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
|
state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle);
|
|
return { ok: true };
|
|
}
|
|
|
|
case 'get_setup_state':
|
|
return { ok: true, data: await loadSetupState() };
|
|
|
|
case 'save_setup': {
|
|
await chrome.storage.local.set({
|
|
vaultConfig: msg.config,
|
|
imageBase64: msg.imageBase64,
|
|
});
|
|
state.gitHost = null;
|
|
return { ok: true };
|
|
}
|
|
|
|
case 'rate_passphrase':
|
|
return { ok: true, data: state.wasm.rate_passphrase(msg.passphrase) };
|
|
|
|
case 'generate_password': {
|
|
const password = state.wasm.generate_password(JSON.stringify(msg.request));
|
|
return { ok: true, data: { password } };
|
|
}
|
|
|
|
case 'generate_passphrase': {
|
|
const passphrase = state.wasm.generate_passphrase(JSON.stringify(msg.request));
|
|
return { ok: true, data: { passphrase } };
|
|
}
|
|
|
|
case 'fill_credentials':
|
|
return handleFillCredentials(msg, state);
|
|
|
|
case 'ack_autofill_origin': {
|
|
const handle = session.getCurrent();
|
|
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
|
const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
|
|
const acks = { ...(settings.autofill_origin_acks ?? {}), [msg.hostname]: Math.floor(Date.now() / 1000) };
|
|
const updated = { ...settings, autofill_origin_acks: acks };
|
|
await vault.encryptAndWriteSettings(state.gitHost, handle, updated, `settings: ack origin ${msg.hostname}`);
|
|
return { ok: true };
|
|
}
|
|
|
|
case 'get_settings':
|
|
return { ok: true, data: { settings: await loadDeviceSettings() } };
|
|
|
|
case 'update_settings': {
|
|
const current = await loadDeviceSettings();
|
|
await saveDeviceSettings({ ...current, ...msg.settings });
|
|
return { ok: true };
|
|
}
|
|
|
|
case 'get_vault_settings': {
|
|
const handle = session.getCurrent();
|
|
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
|
const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
|
|
return { ok: true, data: { settings } };
|
|
}
|
|
|
|
case 'update_vault_settings': {
|
|
const handle = session.getCurrent();
|
|
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
|
await vault.encryptAndWriteSettings(
|
|
state.gitHost, handle, msg.settings,
|
|
'settings: update vault-level config',
|
|
);
|
|
return { ok: true };
|
|
}
|
|
|
|
case 'get_blacklist':
|
|
return { ok: true, data: { blacklist: await loadBlacklist() } };
|
|
|
|
case 'remove_blacklist': {
|
|
const bl = await loadBlacklist();
|
|
await saveBlacklist(bl.filter((h) => h !== msg.hostname));
|
|
return { ok: true };
|
|
}
|
|
|
|
case 'upload_attachment': {
|
|
try {
|
|
const handle = session.getCurrent();
|
|
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
|
|
|
// Look up the per-attachment size cap from vault settings (if any).
|
|
let maxBytes = BigInt(Number.MAX_SAFE_INTEGER);
|
|
try {
|
|
const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
|
|
const cap = settings.attachment_caps?.per_attachment_max_bytes;
|
|
if (cap !== undefined) maxBytes = BigInt(cap);
|
|
} catch {
|
|
// If settings are unavailable, proceed uncapped.
|
|
}
|
|
|
|
const plaintext = new Uint8Array(msg.bytes);
|
|
// Cap enforcement layering:
|
|
// - per_attachment_max_bytes: enforced here via WASM (defense-in-depth)
|
|
// - per_item_max_count, per_vault_*_cap_bytes: enforced client-side in
|
|
// the popup (Task 7's attachments-disclosure component does this).
|
|
const encrypted = state.wasm.attachment_encrypt(handle, plaintext, maxBytes);
|
|
// encrypted: EncryptedAttachment — exposes .aid (string) and .bytes (Uint8Array)
|
|
const aid: string = encrypted.aid;
|
|
const encBytes: Uint8Array = encrypted.bytes;
|
|
|
|
// Duplicate-id check: same content hash → same id → no-op re-upload.
|
|
let existingRef: AttachmentRef | undefined;
|
|
try {
|
|
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.itemId);
|
|
existingRef = item.attachments.find((a) => a.id === aid);
|
|
} catch {
|
|
// Item not yet persisted or not found — will fail at addAttachmentToItem below.
|
|
}
|
|
if (existingRef) {
|
|
return { ok: true, data: { attachment: existingRef } };
|
|
}
|
|
|
|
// Store the encrypted blob.
|
|
const blobPath = `attachments/${aid}.bin`;
|
|
await state.gitHost.putBlob(blobPath, encBytes, `attach: ${msg.filename}`);
|
|
|
|
// Build the AttachmentRef and append it to the item + manifest.
|
|
const ref: AttachmentRef = {
|
|
id: aid,
|
|
filename: msg.filename,
|
|
mime_type: msg.mimeType,
|
|
size: plaintext.length,
|
|
created: Math.floor(Date.now() / 1000),
|
|
};
|
|
await vault.addAttachmentToItem(state.gitHost, handle, msg.itemId, ref);
|
|
|
|
return { ok: true, data: { attachment: ref } };
|
|
} catch (e) {
|
|
console.error('[relicario] upload_attachment failed', e);
|
|
return { ok: false, error: 'upload_failed' };
|
|
}
|
|
}
|
|
|
|
case 'download_attachment': {
|
|
try {
|
|
const handle = session.getCurrent();
|
|
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
|
|
|
// Fetch the item to recover the filename + mime type from the AttachmentRef.
|
|
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.itemId);
|
|
const ref = item.attachments.find((a) => a.id === msg.attachmentId);
|
|
if (!ref) return { ok: false, error: 'attachment_not_found' };
|
|
|
|
const blobPath = `attachments/${ref.id}.bin`;
|
|
const encBytes = await state.gitHost.getBlob(blobPath);
|
|
const decrypted = state.wasm.attachment_decrypt(handle, encBytes);
|
|
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
bytes: decrypted.buffer.slice(
|
|
decrypted.byteOffset,
|
|
decrypted.byteOffset + decrypted.byteLength,
|
|
) as ArrayBuffer,
|
|
filename: ref.filename,
|
|
mimeType: ref.mime_type,
|
|
},
|
|
};
|
|
} catch (e) {
|
|
console.error('[relicario] download_attachment failed', e);
|
|
return { ok: false, error: 'download_failed' };
|
|
}
|
|
}
|
|
|
|
case 'list_devices': {
|
|
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
|
const list = await devices.readDevices(state.gitHost);
|
|
return { ok: true, data: { devices: list } };
|
|
}
|
|
|
|
case 'add_device': {
|
|
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
|
const device = {
|
|
name: msg.name,
|
|
public_key: msg.public_key,
|
|
added_at: Math.floor(Date.now() / 1000),
|
|
};
|
|
await devices.addDevice(state.gitHost, device);
|
|
return { ok: true };
|
|
}
|
|
|
|
case 'register_this_device': {
|
|
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
|
const keypair = JSON.parse(state.wasm.generate_device_keypair()) as {
|
|
public_key_hex: string;
|
|
private_key_base64: string;
|
|
};
|
|
await chrome.storage.local.set({
|
|
device_name: msg.name,
|
|
device_private_key: keypair.private_key_base64,
|
|
});
|
|
await devices.addDevice(state.gitHost, {
|
|
name: msg.name,
|
|
public_key: keypair.public_key_hex,
|
|
added_at: Math.floor(Date.now() / 1000),
|
|
});
|
|
return { ok: true };
|
|
}
|
|
|
|
case 'revoke_device': {
|
|
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
|
await devices.revokeDevice(state.gitHost, msg.name);
|
|
return { ok: true };
|
|
}
|
|
|
|
case 'list_trashed': {
|
|
if (!state.manifest) return { ok: false, error: 'vault_locked' };
|
|
const items = vault.listTrashed(state.manifest);
|
|
return { ok: true, data: { items } };
|
|
}
|
|
|
|
case 'restore_item': {
|
|
const handle = session.getCurrent();
|
|
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
|
await vault.restoreItem(state.gitHost, handle, state.manifest, msg.id);
|
|
return { ok: true };
|
|
}
|
|
|
|
case 'purge_item': {
|
|
const handle = session.getCurrent();
|
|
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
|
await vault.purgeItem(state.gitHost, msg.id, state.manifest);
|
|
await vault.encryptAndWriteManifest(
|
|
state.gitHost, handle, state.manifest,
|
|
`manifest: purge ${state.manifest.items[msg.id]?.title ?? msg.id}`,
|
|
);
|
|
return { ok: true };
|
|
}
|
|
|
|
case 'purge_all_trash': {
|
|
const handle = session.getCurrent();
|
|
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
|
const result = await vault.purgeAllTrash(state.gitHost, handle, state.manifest);
|
|
return { ok: true, data: result };
|
|
}
|
|
|
|
case 'get_field_history': {
|
|
const handle = session.getCurrent();
|
|
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
|
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
|
|
const history = state.wasm.get_field_history(JSON.stringify(item));
|
|
return { ok: true, data: { history } };
|
|
}
|
|
|
|
case 'get_session_config':
|
|
return { ok: true, data: { config: sessionTimer.getConfig() } };
|
|
|
|
case 'update_session_config': {
|
|
sessionTimer.setConfig(msg.config);
|
|
sessionTimer.resetTimer();
|
|
await chrome.storage.local.set({ session_timeout: msg.config });
|
|
return { ok: true };
|
|
}
|
|
|
|
case 'export_backup': {
|
|
if (!state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
|
|
|
try {
|
|
const blob = await vault.fetchVaultStateForBackup(state.gitHost, state.manifest);
|
|
|
|
let reference_jpg: string | null = null;
|
|
if (msg.includeImage) {
|
|
const stored = await chrome.storage.local.get('imageBase64');
|
|
const b64 = stored.imageBase64 as string | undefined;
|
|
if (!b64) return { ok: false, error: 'no reference image stored locally' };
|
|
reference_jpg = b64;
|
|
}
|
|
|
|
const inputJson = JSON.stringify({
|
|
salt: blob.salt_b64,
|
|
params_json: blob.params_json,
|
|
devices_json: blob.devices_json,
|
|
manifest_enc: blob.manifest_enc_b64,
|
|
settings_enc: blob.settings_enc_b64,
|
|
items: blob.items.map(i => ({ id: i.id, ciphertext: i.ciphertext_b64 })),
|
|
attachments: blob.attachments.map(a => ({
|
|
item_id: a.item_id, attachment_id: a.attachment_id, ciphertext: a.ciphertext_b64
|
|
})),
|
|
reference_jpg,
|
|
git_archive: null, // Extension never bundles git history.
|
|
});
|
|
|
|
const bytes: Uint8Array = state.wasm.pack_backup_json(inputJson, msg.passphrase);
|
|
return { ok: true, data: { bytes: bytes.buffer } };
|
|
} catch (e) {
|
|
return { ok: false, error: (e as Error).message };
|
|
}
|
|
}
|
|
|
|
case 'restore_backup': {
|
|
try {
|
|
const bytes = new Uint8Array(msg.bytes);
|
|
const outJson: string = state.wasm.unpack_backup_json(bytes, msg.passphrase);
|
|
const out = JSON.parse(outJson) as {
|
|
salt: string;
|
|
params_json: string;
|
|
devices_json: string;
|
|
manifest_enc: string;
|
|
settings_enc: string;
|
|
items: Array<{ id: string; ciphertext: string }>;
|
|
attachments: Array<{ item_id: string; attachment_id: string; ciphertext: string }>;
|
|
reference_jpg: string | null;
|
|
};
|
|
|
|
// Build a GitHost for the new remote.
|
|
const newHost = createGitHost(
|
|
msg.newRemote.hostType,
|
|
msg.newRemote.hostUrl,
|
|
msg.newRemote.repoPath,
|
|
msg.newRemote.apiToken,
|
|
);
|
|
|
|
// Refuse if the remote already has a vault.
|
|
try {
|
|
const meta = await vault.fetchVaultMeta(newHost);
|
|
if (meta.salt && meta.paramsJson) {
|
|
return { ok: false, error: 'remote already contains a relicario vault' };
|
|
}
|
|
} catch {
|
|
// No vault present — expected for a fresh remote.
|
|
}
|
|
|
|
// Write the layout via writeFileCreateOnly. Refuses to clobber.
|
|
const b64 = (s: string) => Uint8Array.from(atob(s), c => c.charCodeAt(0));
|
|
await newHost.writeFileCreateOnly('.relicario/salt', b64(out.salt), 'restore: salt');
|
|
await newHost.writeFileCreateOnly('.relicario/params.json', new TextEncoder().encode(out.params_json), 'restore: params.json');
|
|
await newHost.writeFileCreateOnly('.relicario/devices.json', new TextEncoder().encode(out.devices_json), 'restore: devices.json');
|
|
await newHost.writeFileCreateOnly('manifest.enc', b64(out.manifest_enc), 'restore: manifest.enc');
|
|
await newHost.writeFileCreateOnly('settings.enc', b64(out.settings_enc), 'restore: settings.enc');
|
|
|
|
for (const it of out.items) {
|
|
await newHost.writeFileCreateOnly(
|
|
`items/${it.id}.enc`, b64(it.ciphertext), `restore: item ${it.id}`,
|
|
);
|
|
}
|
|
// Translate canonical envelope keys (<item_id>/<aid>) back to the
|
|
// extension's flat layout (attachments/<aid>.bin). The aid is
|
|
// already content-addressed and globally unique; the item_id segment
|
|
// is recorded only in the manifest's attachment_summaries.
|
|
for (const a of out.attachments) {
|
|
await newHost.writeFileCreateOnly(
|
|
`attachments/${a.attachment_id}.bin`,
|
|
b64(a.ciphertext),
|
|
`restore: attachment ${a.attachment_id}`,
|
|
);
|
|
}
|
|
|
|
// Update local config so subsequent unlocks work.
|
|
const cfg = {
|
|
hostType: msg.newRemote.hostType,
|
|
hostUrl: msg.newRemote.hostUrl,
|
|
repoPath: msg.newRemote.repoPath,
|
|
apiToken: msg.newRemote.apiToken,
|
|
};
|
|
const storageUpdate: Record<string, unknown> = { vaultConfig: cfg };
|
|
if (out.reference_jpg) {
|
|
storageUpdate.imageBase64 = out.reference_jpg;
|
|
}
|
|
await chrome.storage.local.set(storageUpdate);
|
|
|
|
// Make sure the SW's gitHost cache picks up the new config.
|
|
state.gitHost = newHost;
|
|
state.manifest = null; // user must unlock to populate
|
|
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
summary: {
|
|
itemCount: out.items.length,
|
|
attachmentCount: out.attachments.length,
|
|
hasImage: out.reference_jpg != null,
|
|
},
|
|
},
|
|
};
|
|
} catch (e) {
|
|
return { ok: false, error: (e as Error).message };
|
|
}
|
|
}
|
|
|
|
case 'parse_lastpass_csv': {
|
|
try {
|
|
const json: string = state.wasm.parse_lastpass_csv_json(new Uint8Array(msg.bytes));
|
|
const parsed = JSON.parse(json) as {
|
|
items: Item[];
|
|
warnings: Array<{ row: number; title?: string; message: string }>;
|
|
};
|
|
return { ok: true, data: parsed };
|
|
} catch (e) {
|
|
return { ok: false, error: (e as Error).message };
|
|
}
|
|
}
|
|
|
|
case 'import_lastpass_commit': {
|
|
const handle = session.getCurrent();
|
|
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
|
if (msg.items.length === 0) return { ok: false, error: 'no items to import' };
|
|
|
|
try {
|
|
const total = msg.items.length;
|
|
for (let i = 0; i < msg.items.length; i++) {
|
|
const item = msg.items[i];
|
|
// Items arrive with IDs already minted by the WASM bridge. We
|
|
// overwrite that with a fresh extension-generated ID so the SW
|
|
// remains the single ID-issuance authority for new items in the
|
|
// remote — same pattern as `add_item`.
|
|
const id = state.wasm.new_item_id();
|
|
const reIdItem: Item = { ...item, id };
|
|
|
|
await vault.encryptAndWriteItem(
|
|
state.gitHost, handle, id, reIdItem,
|
|
`import: ${reIdItem.title} (${i + 1}/${total})`,
|
|
);
|
|
state.manifest.items[id] = itemToManifestEntry(reIdItem);
|
|
}
|
|
|
|
await vault.encryptAndWriteManifest(
|
|
state.gitHost, handle, state.manifest,
|
|
`manifest: import ${total} items from LastPass`,
|
|
);
|
|
return { ok: true, data: { summary: { itemCount: total } } };
|
|
} catch (e) {
|
|
return { ok: false, error: (e as Error).message };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- fill_credentials with captured-tab verification (audit M5) ---
|
|
|
|
async function handleFillCredentials(
|
|
msg: Extract<PopupMessage, { type: 'fill_credentials' }>,
|
|
state: PopupState,
|
|
): Promise<Response> {
|
|
const handle = session.getCurrent();
|
|
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
|
|
|
let tab: chrome.tabs.Tab;
|
|
try { tab = await chrome.tabs.get(msg.capturedTabId); }
|
|
catch { return { ok: false, error: 'captured_tab_gone' }; }
|
|
|
|
const currentHost = safeHostname(tab.url ?? '');
|
|
const capturedHost = safeHostname(msg.capturedUrl);
|
|
if (!currentHost || !capturedHost || currentHost !== capturedHost) {
|
|
return { ok: false, error: 'tab_navigated' };
|
|
}
|
|
|
|
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
|
|
if (item.core.type !== 'login') return { ok: false, error: 'not_a_login' };
|
|
const itemHost = safeHostname(item.core.url ?? '');
|
|
if (!itemHost || itemHost !== currentHost) return { ok: false, error: 'origin_mismatch' };
|
|
|
|
// Pass the hostname the SW validated. The content script re-verifies
|
|
// against location.href before filling — if the tab navigated between
|
|
// our chrome.tabs.get check above and the sendMessage delivery below,
|
|
// fill.ts rejects with 'origin_changed'.
|
|
await chrome.tabs.sendMessage(msg.capturedTabId, {
|
|
type: 'fill_credentials',
|
|
username: item.core.username ?? '',
|
|
password: item.core.password ?? '',
|
|
expectedHost: currentHost,
|
|
});
|
|
return { ok: true };
|
|
}
|
|
|
|
// --- chrome.storage.local helpers (module-scoped so all handlers share) ---
|
|
|
|
async function loadConfig(): Promise<VaultConfig | null> {
|
|
const r = await chrome.storage.local.get('vaultConfig');
|
|
return (r.vaultConfig as VaultConfig) ?? null;
|
|
}
|
|
|
|
async function loadImageBase64(): Promise<string | null> {
|
|
const r = await chrome.storage.local.get('imageBase64');
|
|
return (r.imageBase64 as string) ?? null;
|
|
}
|
|
|
|
async function loadSetupState(): Promise<SetupState> {
|
|
const config = await loadConfig();
|
|
const imageBase64 = await loadImageBase64();
|
|
return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null };
|
|
}
|
|
|
|
async function loadDeviceSettings(): Promise<DeviceSettings> {
|
|
const r = await chrome.storage.local.get('relicarioSettings');
|
|
return (r.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS };
|
|
}
|
|
|
|
async function saveDeviceSettings(s: DeviceSettings): Promise<void> {
|
|
await chrome.storage.local.set({ relicarioSettings: s });
|
|
}
|
|
|
|
async function loadBlacklist(): Promise<string[]> {
|
|
const r = await chrome.storage.local.get('captureBlacklist');
|
|
return (r.captureBlacklist as string[]) ?? [];
|
|
}
|
|
|
|
async function saveBlacklist(list: string[]): Promise<void> {
|
|
await chrome.storage.local.set({ captureBlacklist: list });
|
|
}
|
|
|
|
// --- Manifest entry derivation (duplicated from index; kept here to keep handler self-contained) ---
|
|
|
|
function itemToManifestEntry(item: Item) {
|
|
return {
|
|
id: item.id,
|
|
type: item.type,
|
|
title: item.title,
|
|
tags: item.tags,
|
|
favorite: item.favorite,
|
|
group: item.group,
|
|
icon_hint: (item.core.type === 'login' && item.core.url)
|
|
? safeHostname(item.core.url) : undefined,
|
|
modified: item.modified,
|
|
trashed_at: item.trashed_at,
|
|
attachment_summaries: item.attachments.map((a) => ({
|
|
id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function safeHostname(url: string): string | undefined {
|
|
try { return new URL(url).hostname; } catch { return undefined; }
|
|
}
|