feat(ext/sw): rewire flat handler onto typed-item vault + SessionHandle
This commit is contained in:
@@ -1,33 +1,18 @@
|
|||||||
/// Background script entry point for the relicario browser extension.
|
/// Background script entry point for the relicario browser extension.
|
||||||
///
|
///
|
||||||
/// In Chrome this runs as a service worker (MV3). In Firefox this runs
|
/// Transitional slice-3 shape: keeps the flat onMessage listener but uses
|
||||||
/// as a persistent background script. WASM loading adapts automatically.
|
/// the new typed-item vault + SessionHandle. The router split lands in
|
||||||
///
|
/// slice 4.
|
||||||
/// Loads the WASM module, manages vault state (master key, manifest, git host),
|
|
||||||
/// and routes all messages from the popup and content scripts.
|
|
||||||
|
|
||||||
import type { Request, Response } from '../shared/messages';
|
import type { Request, Response } from '../shared/messages';
|
||||||
import type { Manifest, VaultConfig, SetupState, RelicarioSettings } from '../shared/types';
|
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings } from '../shared/types';
|
||||||
import { DEFAULT_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 } from './git-host';
|
import { createGitHost, base64ToUint8Array } from './git-host';
|
||||||
import { base64ToUint8Array } from './git-host';
|
|
||||||
import * as vault from './vault';
|
import * as vault from './vault';
|
||||||
|
import * as session from './session';
|
||||||
|
|
||||||
// --- State held in memory (cleared on lock or service worker restart) ---
|
// --- WASM module load ---
|
||||||
|
|
||||||
let masterKey: Uint8Array | null = null;
|
|
||||||
let manifest: Manifest | null = null;
|
|
||||||
let gitHost: GitHost | null = null;
|
|
||||||
let wasmReady = false;
|
|
||||||
// Cache TOTP secrets by entry ID to avoid re-fetching the entry every second
|
|
||||||
const totpSecretCache: Map<string, string> = new Map();
|
|
||||||
|
|
||||||
// --- WASM initialization ---
|
|
||||||
|
|
||||||
// Chrome MV3 uses service workers which do NOT support dynamic import().
|
|
||||||
// Firefox MV3 uses background scripts which DO support dynamic import().
|
|
||||||
// We detect the environment at runtime and use the appropriate loading strategy.
|
|
||||||
|
|
||||||
// @ts-ignore TS2307 — resolved by webpack alias / copy
|
// @ts-ignore TS2307 — resolved by webpack alias / copy
|
||||||
import initDefault, { initSync } from '../../wasm/relicario_wasm.js';
|
import initDefault, { initSync } from '../../wasm/relicario_wasm.js';
|
||||||
@@ -46,23 +31,26 @@ async function initWasm(): Promise<WasmModule> {
|
|||||||
&& self instanceof (SWGlobalScope as unknown as typeof EventTarget);
|
&& self instanceof (SWGlobalScope as unknown as typeof EventTarget);
|
||||||
|
|
||||||
if (isServiceWorker) {
|
if (isServiceWorker) {
|
||||||
// Chrome: fetch WASM binary and instantiate synchronously
|
|
||||||
const wasmResponse = await fetch(chrome.runtime.getURL('relicario_wasm_bg.wasm'));
|
const wasmResponse = await fetch(chrome.runtime.getURL('relicario_wasm_bg.wasm'));
|
||||||
const wasmBytes = await wasmResponse.arrayBuffer();
|
const wasmBytes = await wasmResponse.arrayBuffer();
|
||||||
initSync({ module: new WebAssembly.Module(wasmBytes) });
|
initSync({ module: new WebAssembly.Module(wasmBytes) });
|
||||||
} else {
|
} else {
|
||||||
// Firefox: background script — async init works
|
|
||||||
const wasmUrl = chrome.runtime.getURL('relicario_wasm_bg.wasm');
|
const wasmUrl = chrome.runtime.getURL('relicario_wasm_bg.wasm');
|
||||||
await initDefault(wasmUrl);
|
await initDefault(wasmUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
vault.setWasm(wasmBindings);
|
vault.setWasm(wasmBindings);
|
||||||
wasm = wasmBindings;
|
wasm = wasmBindings;
|
||||||
wasmReady = true;
|
|
||||||
return wasm;
|
return wasm;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Storage helpers ---
|
// --- In-memory vault state (cleared on lock or SW restart) ---
|
||||||
|
|
||||||
|
let manifest: Manifest | null = null;
|
||||||
|
let gitHost: GitHost | null = null;
|
||||||
|
const totpConfigCache: Map<ItemId, unknown> = new Map();
|
||||||
|
|
||||||
|
// --- chrome.storage.local helpers ---
|
||||||
|
|
||||||
async function loadConfig(): Promise<VaultConfig | null> {
|
async function loadConfig(): Promise<VaultConfig | null> {
|
||||||
const result = await chrome.storage.local.get('vaultConfig');
|
const result = await chrome.storage.local.get('vaultConfig');
|
||||||
@@ -77,21 +65,15 @@ async function loadImageBase64(): Promise<string | null> {
|
|||||||
async function loadSetupState(): Promise<SetupState> {
|
async function loadSetupState(): Promise<SetupState> {
|
||||||
const config = await loadConfig();
|
const config = await loadConfig();
|
||||||
const imageBase64 = await loadImageBase64();
|
const imageBase64 = await loadImageBase64();
|
||||||
return {
|
return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null };
|
||||||
config,
|
|
||||||
imageBase64,
|
|
||||||
isConfigured: config !== null && imageBase64 !== null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Settings & blacklist helpers ---
|
async function loadSettings(): Promise<DeviceSettings> {
|
||||||
|
|
||||||
async function loadSettings(): Promise<RelicarioSettings> {
|
|
||||||
const result = await chrome.storage.local.get('relicarioSettings');
|
const result = await chrome.storage.local.get('relicarioSettings');
|
||||||
return (result.relicarioSettings as RelicarioSettings) ?? { ...DEFAULT_SETTINGS };
|
return (result.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSettings(settings: RelicarioSettings): Promise<void> {
|
async function saveSettings(settings: DeviceSettings): Promise<void> {
|
||||||
await chrome.storage.local.set({ relicarioSettings: settings });
|
await chrome.storage.local.set({ relicarioSettings: settings });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,204 +93,109 @@ function ensureGitHost(config: VaultConfig): GitHost {
|
|||||||
return gitHost;
|
return gitHost;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Message handler ---
|
// --- Message handler (flat; router split in slice 4) ---
|
||||||
|
|
||||||
chrome.runtime.onMessage.addListener(
|
chrome.runtime.onMessage.addListener(
|
||||||
(request: Request, _sender: chrome.runtime.MessageSender, sendResponse: (response: Response) => void) => {
|
(request: Request, _sender: chrome.runtime.MessageSender, sendResponse: (response: Response) => void) => {
|
||||||
handleMessage(request)
|
handleMessage(request)
|
||||||
.then(sendResponse)
|
.then(sendResponse)
|
||||||
.catch((err: Error) => sendResponse({ ok: false, error: err.message }));
|
.catch((err: Error) => sendResponse({ ok: false, error: err.message }));
|
||||||
// Return true to indicate async response.
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
async function handleMessage(req: Request): Promise<Response> {
|
async function handleMessage(req: Request): Promise<Response> {
|
||||||
switch (req.type) {
|
switch (req.type) {
|
||||||
// --- Auth ---
|
|
||||||
|
|
||||||
case 'is_unlocked':
|
case 'is_unlocked':
|
||||||
return { ok: true, data: { unlocked: masterKey !== null } };
|
return { ok: true, data: { unlocked: session.getCurrent() !== null } };
|
||||||
|
|
||||||
case 'unlock': {
|
case 'unlock': {
|
||||||
const w = await initWasm();
|
const w = await initWasm();
|
||||||
const config = await loadConfig();
|
const config = await loadConfig();
|
||||||
if (!config) return { ok: false, error: 'Extension not configured. Run setup first.' };
|
if (!config) return { ok: false, error: 'Extension not configured. Run setup first.' };
|
||||||
|
|
||||||
const imageB64 = await loadImageBase64();
|
const imageB64 = await loadImageBase64();
|
||||||
if (!imageB64) return { ok: false, error: 'Reference image not set. Run setup first.' };
|
if (!imageB64) return { ok: false, error: 'Reference image not set. Run setup first.' };
|
||||||
|
|
||||||
const imageBytes = base64ToUint8Array(imageB64);
|
const imageBytes = base64ToUint8Array(imageB64);
|
||||||
const imageSecret = w.extract_image_secret(imageBytes);
|
|
||||||
|
|
||||||
const git = ensureGitHost(config);
|
const git = ensureGitHost(config);
|
||||||
const meta = await vault.fetchVaultMeta(git);
|
const meta = await vault.fetchVaultMeta(git);
|
||||||
|
|
||||||
const key = w.derive_master_key(
|
const handle = w.unlock(req.passphrase, imageBytes, meta.salt, meta.paramsJson);
|
||||||
req.passphrase,
|
session.setCurrent(handle);
|
||||||
new Uint8Array(imageSecret),
|
// Clear passphrase from scope best-effort.
|
||||||
meta.salt,
|
// (JS strings are immutable; the message object goes out of scope after return.)
|
||||||
meta.paramsJson,
|
(req as { passphrase: string }).passphrase = '';
|
||||||
);
|
|
||||||
masterKey = new Uint8Array(key);
|
|
||||||
|
|
||||||
// Verify the key works by decrypting the manifest.
|
|
||||||
manifest = await vault.fetchAndDecryptManifest(git, masterKey);
|
|
||||||
|
|
||||||
|
manifest = await vault.fetchAndDecryptManifest(git, handle);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'lock':
|
case 'lock':
|
||||||
masterKey = null;
|
session.clearCurrent();
|
||||||
manifest = null;
|
manifest = null;
|
||||||
totpSecretCache.clear();
|
totpConfigCache.clear();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|
||||||
// --- Entries ---
|
case 'list_items': {
|
||||||
|
if (!manifest) return { ok: false, error: 'vault_locked' };
|
||||||
case 'list_entries': {
|
const items = vault.listItems(manifest, req.group);
|
||||||
if (!manifest) return { ok: false, error: 'Vault is locked' };
|
return { ok: true, data: { items } };
|
||||||
const entries = vault.listEntries(manifest, req.group);
|
|
||||||
return { ok: true, data: { entries } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'get_entry': {
|
case 'get_item': {
|
||||||
if (!masterKey || !gitHost) return { ok: false, error: 'Vault is locked' };
|
const handle = session.getCurrent();
|
||||||
const entry = await vault.fetchAndDecryptEntry(gitHost, masterKey, req.id);
|
if (!handle || !gitHost) return { ok: false, error: 'vault_locked' };
|
||||||
return { ok: true, data: { entry } };
|
const item = await vault.fetchAndDecryptItem(gitHost, handle, req.id);
|
||||||
|
return { ok: true, data: { item } };
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'search_entries': {
|
case 'add_item': {
|
||||||
if (!manifest) return { ok: false, error: 'Vault is locked' };
|
const handle = session.getCurrent();
|
||||||
const entries = vault.searchEntries(manifest, req.query);
|
if (!handle || !gitHost || !manifest) return { ok: false, error: 'vault_locked' };
|
||||||
return { ok: true, data: { entries } };
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'add_entry': {
|
|
||||||
if (!masterKey || !gitHost || !manifest) {
|
|
||||||
return { ok: false, error: 'Vault is locked' };
|
|
||||||
}
|
|
||||||
const w = await initWasm();
|
const w = await initWasm();
|
||||||
const id = w.generate_entry_id();
|
const id = w.new_item_id();
|
||||||
|
const item: Item = { ...req.item, id };
|
||||||
await vault.encryptAndWriteEntry(
|
|
||||||
gitHost, masterKey, id, req.entry,
|
|
||||||
`add: ${req.entry.name}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
manifest.entries[id] = {
|
|
||||||
name: req.entry.name,
|
|
||||||
url: req.entry.url,
|
|
||||||
username: req.entry.username,
|
|
||||||
group: req.entry.group,
|
|
||||||
updated_at: req.entry.updated_at,
|
|
||||||
};
|
|
||||||
|
|
||||||
await vault.encryptAndWriteManifest(
|
|
||||||
gitHost, masterKey, manifest,
|
|
||||||
`manifest: add ${req.entry.name}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
await vault.encryptAndWriteItem(gitHost, handle, id, item, `add: ${item.title}`);
|
||||||
|
manifest.items[id] = itemToManifestEntry(item);
|
||||||
|
await vault.encryptAndWriteManifest(gitHost, handle, manifest, `manifest: add ${item.title}`);
|
||||||
return { ok: true, data: { id } };
|
return { ok: true, data: { id } };
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'update_entry': {
|
case 'update_item': {
|
||||||
if (!masterKey || !gitHost || !manifest) {
|
const handle = session.getCurrent();
|
||||||
return { ok: false, error: 'Vault is locked' };
|
if (!handle || !gitHost || !manifest) return { ok: false, error: 'vault_locked' };
|
||||||
}
|
await vault.encryptAndWriteItem(gitHost, handle, req.id, req.item, `update: ${req.item.title}`);
|
||||||
|
manifest.items[req.id] = itemToManifestEntry(req.item);
|
||||||
await vault.encryptAndWriteEntry(
|
await vault.encryptAndWriteManifest(gitHost, handle, manifest, `manifest: update ${req.item.title}`);
|
||||||
gitHost, masterKey, req.id, req.entry,
|
totpConfigCache.delete(req.id);
|
||||||
`update: ${req.entry.name}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
manifest.entries[req.id] = {
|
|
||||||
name: req.entry.name,
|
|
||||||
url: req.entry.url,
|
|
||||||
username: req.entry.username,
|
|
||||||
group: req.entry.group,
|
|
||||||
updated_at: req.entry.updated_at,
|
|
||||||
};
|
|
||||||
|
|
||||||
await vault.encryptAndWriteManifest(
|
|
||||||
gitHost, masterKey, manifest,
|
|
||||||
`manifest: update ${req.entry.name}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'delete_entry': {
|
case 'delete_item': {
|
||||||
if (!masterKey || !gitHost || !manifest) {
|
const handle = session.getCurrent();
|
||||||
return { ok: false, error: 'Vault is locked' };
|
if (!handle || !gitHost || !manifest) return { ok: false, error: 'vault_locked' };
|
||||||
}
|
const entry = manifest.items[req.id];
|
||||||
|
if (!entry) return { ok: false, error: 'item_not_found' };
|
||||||
const name = manifest.entries[req.id]?.name ?? req.id;
|
// Soft-delete: fetch the item, set trashed_at, write it back.
|
||||||
await gitHost.deleteFile(`entries/${req.id}.enc`, `delete: ${name}`);
|
const item = await vault.fetchAndDecryptItem(gitHost, handle, req.id);
|
||||||
|
|
||||||
delete manifest.entries[req.id];
|
|
||||||
|
|
||||||
await vault.encryptAndWriteManifest(
|
|
||||||
gitHost, masterKey, manifest,
|
|
||||||
`manifest: delete ${name}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- TOTP ---
|
|
||||||
|
|
||||||
case 'get_totp': {
|
|
||||||
if (!masterKey || !gitHost) return { ok: false, error: 'Vault is locked' };
|
|
||||||
const w = await initWasm();
|
|
||||||
|
|
||||||
// Use cached TOTP secret to avoid re-fetching the entry every second
|
|
||||||
let totpSecret = totpSecretCache.get(req.id);
|
|
||||||
if (!totpSecret) {
|
|
||||||
const entry = await vault.fetchAndDecryptEntry(gitHost, masterKey, req.id);
|
|
||||||
if (!entry.totp_secret) return { ok: false, error: 'No TOTP secret for this entry' };
|
|
||||||
totpSecret = entry.totp_secret;
|
|
||||||
totpSecretCache.set(req.id, totpSecret);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const code = w.generate_totp(totpSecret, BigInt(now));
|
const updated: Item = { ...item, trashed_at: now, modified: now };
|
||||||
const remaining = 30 - (now % 30);
|
await vault.encryptAndWriteItem(gitHost, handle, req.id, updated, `trash: ${entry.title}`);
|
||||||
|
manifest.items[req.id] = { ...entry, trashed_at: now, modified: now };
|
||||||
return { ok: true, data: { code, remaining_seconds: remaining } };
|
await vault.encryptAndWriteManifest(gitHost, handle, manifest, `manifest: trash ${entry.title}`);
|
||||||
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Autofill ---
|
|
||||||
|
|
||||||
case 'get_autofill_candidates': {
|
|
||||||
if (!manifest) return { ok: false, error: 'Vault is locked' };
|
|
||||||
const candidates = vault.findByUrl(manifest, req.url);
|
|
||||||
return { ok: true, data: { candidates } };
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'get_credentials': {
|
|
||||||
if (!masterKey || !gitHost) return { ok: false, error: 'Vault is locked' };
|
|
||||||
const entry = await vault.fetchAndDecryptEntry(gitHost, masterKey, req.id);
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
data: { username: entry.username ?? '', password: entry.password },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Sync ---
|
|
||||||
|
|
||||||
case 'sync': {
|
case 'sync': {
|
||||||
if (!masterKey || !gitHost) return { ok: false, error: 'Vault is locked' };
|
const handle = session.getCurrent();
|
||||||
// Re-fetch the manifest from the remote to pick up changes from other devices.
|
if (!handle || !gitHost) return { ok: false, error: 'vault_locked' };
|
||||||
manifest = await vault.fetchAndDecryptManifest(gitHost, masterKey);
|
manifest = await vault.fetchAndDecryptManifest(gitHost, handle);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Setup ---
|
|
||||||
|
|
||||||
case 'get_setup_state': {
|
case 'get_setup_state': {
|
||||||
const state = await loadSetupState();
|
return { ok: true, data: await loadSetupState() };
|
||||||
return { ok: true, data: state };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'save_setup': {
|
case 'save_setup': {
|
||||||
@@ -316,53 +203,32 @@ async function handleMessage(req: Request): Promise<Response> {
|
|||||||
vaultConfig: req.config,
|
vaultConfig: req.config,
|
||||||
imageBase64: req.imageBase64,
|
imageBase64: req.imageBase64,
|
||||||
});
|
});
|
||||||
// Reset git host so it picks up new config on next use.
|
|
||||||
gitHost = null;
|
gitHost = null;
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Password generation ---
|
case 'rate_passphrase': {
|
||||||
|
const w = await initWasm();
|
||||||
|
return { ok: true, data: w.rate_passphrase(req.passphrase) };
|
||||||
|
}
|
||||||
|
|
||||||
case 'generate_password': {
|
case 'generate_password': {
|
||||||
const w = await initWasm();
|
const w = await initWasm();
|
||||||
const password = w.generate_password(req.length);
|
const password = w.generate_password(JSON.stringify(req.request));
|
||||||
return { ok: true, data: { password } };
|
return { ok: true, data: { password } };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Content script fill (forwarded to active tab) ---
|
case 'get_settings':
|
||||||
|
return { ok: true, data: { settings: await loadSettings() } };
|
||||||
case 'fill_credentials': {
|
|
||||||
// This is actually sent TO the content script, not FROM it.
|
|
||||||
// The popup sends this to the service worker, which forwards it.
|
|
||||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
||||||
if (tab?.id) {
|
|
||||||
await chrome.tabs.sendMessage(tab.id, {
|
|
||||||
type: 'fill_credentials',
|
|
||||||
username: req.username,
|
|
||||||
password: req.password,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Settings & blacklist ---
|
|
||||||
|
|
||||||
case 'get_settings': {
|
|
||||||
const settings = await loadSettings();
|
|
||||||
return { ok: true, data: { settings } };
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'update_settings': {
|
case 'update_settings': {
|
||||||
const current = await loadSettings();
|
const current = await loadSettings();
|
||||||
const updated = { ...current, ...req.settings };
|
await saveSettings({ ...current, ...req.settings });
|
||||||
await saveSettings(updated);
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'get_blacklist': {
|
case 'get_blacklist':
|
||||||
const blacklist = await loadBlacklist();
|
return { ok: true, data: { blacklist: await loadBlacklist() } };
|
||||||
return { ok: true, data: { blacklist } };
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'remove_blacklist': {
|
case 'remove_blacklist': {
|
||||||
const bl = await loadBlacklist();
|
const bl = await loadBlacklist();
|
||||||
@@ -370,72 +236,41 @@ async function handleMessage(req: Request): Promise<Response> {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'blacklist_site': {
|
// Slice 4 / 5 will wire these up properly (currently placeholders so the build passes):
|
||||||
const bl2 = await loadBlacklist();
|
case 'get_totp':
|
||||||
if (!bl2.includes(req.hostname)) {
|
case 'fill_credentials':
|
||||||
bl2.push(req.hostname);
|
case 'ack_autofill_origin':
|
||||||
await saveBlacklist(bl2);
|
case 'get_autofill_candidates':
|
||||||
}
|
case 'get_credentials':
|
||||||
return { ok: true };
|
case 'check_credential':
|
||||||
}
|
case 'blacklist_site':
|
||||||
|
return { ok: false, error: 'not_implemented_yet' };
|
||||||
|
|
||||||
// --- Credential capture ---
|
default: {
|
||||||
|
const exhaustive: never = req;
|
||||||
case 'check_credential': {
|
return { ok: false, error: `unknown_message_type: ${(exhaustive as { type: string }).type}` };
|
||||||
// Skip if vault locked
|
|
||||||
if (!masterKey || !gitHost || !manifest) {
|
|
||||||
return { ok: true, data: { action: 'skip' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if capture disabled
|
|
||||||
const captureSettings = await loadSettings();
|
|
||||||
if (!captureSettings.captureEnabled) {
|
|
||||||
return { ok: true, data: { action: 'skip' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if hostname blacklisted
|
|
||||||
let checkHostname: string;
|
|
||||||
try {
|
|
||||||
checkHostname = new URL(req.url).hostname;
|
|
||||||
} catch {
|
|
||||||
return { ok: true, data: { action: 'skip' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const captureBlacklist = await loadBlacklist();
|
|
||||||
if (captureBlacklist.includes(checkHostname)) {
|
|
||||||
return { ok: true, data: { action: 'skip' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search manifest by hostname
|
|
||||||
const candidates = vault.findByUrl(manifest, req.url);
|
|
||||||
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
return { ok: true, data: { action: 'save' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for matching username
|
|
||||||
for (const [entryId, entry] of candidates) {
|
|
||||||
if (entry.username === req.username) {
|
|
||||||
// Same hostname + username — compare passwords
|
|
||||||
try {
|
|
||||||
const fullEntry = await vault.fetchAndDecryptEntry(gitHost, masterKey, entryId);
|
|
||||||
if (fullEntry.password === req.password) {
|
|
||||||
return { ok: true, data: { action: 'skip' } };
|
|
||||||
} else {
|
|
||||||
return { ok: true, data: { action: 'update', entryId, entryName: entry.name } };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// If we can't decrypt, skip rather than error
|
|
||||||
return { ok: true, data: { action: 'skip' } };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same hostname, different username — new account
|
function itemToManifestEntry(item: Item) {
|
||||||
return { ok: true, data: { action: 'save' } };
|
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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
function safeHostname(url: string): string | undefined {
|
||||||
return { ok: false, error: `Unknown message type: ${(req as { type: string }).type}` };
|
try { return new URL(url).hostname; } catch { return undefined; }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user