diff --git a/extension/vault-worker.mjs b/extension/vault-worker.mjs new file mode 100644 index 0000000..7581293 --- /dev/null +++ b/extension/vault-worker.mjs @@ -0,0 +1,103 @@ +import {initialize, unlock, encryptItem, decryptItem} from './human-crypto.mjs'; +import {vaultRequest, VaultUnavailableError} from './human-api.mjs'; +import {AUTO_LOCK, policies, VaultSession} from './vault-session.mjs'; +const session = new VaultSession(); +// Only a boolean, after the caller has validated live ownership. Never expose keys. +export const vaultUnlocked = owner => session.owner === owner && !!session.key && !!session.get(owner); +// Internal worker callback only. Never register this as a message operation. +export async function useVaultCredential(owner, item, use) { + const key = session.get(owner), epoch = session.epoch; + if (!key) throw new Error(); + let account; + const check = () => { + if (session.epoch !== epoch || !session.get(owner)) throw new Error(); + }; + try { + account = await decryptItem(key, owner, item); + check(); + return await use(account, check); + } finally { + if (account) { account.username = ''; account.password = ''; } + account = null; + } +} +export async function vaultUsername(owner, item) { + const key = session.get(owner); + if (!key) return null; + let account = null; + try { + account = await decryptItem(key, owner, item); + return account.username; + } finally { + if (account) { + account.username = ''; + account.password = ''; + account = null; + } + } +} +let heartbeat, expiry; +function stop() { session.clear(); clearInterval(heartbeat); clearTimeout(expiry); heartbeat = null; } +function keepAlive() { + clearInterval(heartbeat); + if (session.policy !== 'close') expiry = setTimeout(stop, Math.max(0, session.deadline - Date.now())); + // Bounded Chrome API heartbeat; worker loss still fails closed. Never persist a key. + heartbeat = setInterval(() => { + if (!session.get(session.owner)) { stop(); return; } + chrome.runtime.getPlatformInfo().catch(stop); + }, 20000); +} +chrome.runtime.onConnect.addListener(port => { + if (port.name !== 'human-vault' || port.sender?.id !== chrome.runtime.id || + port.sender?.url !== chrome.runtime.getURL('popup.html')) { port.disconnect(); return; } + let closed = false, cancellation = 0, queue = Promise.resolve(); + port.onDisconnect.addListener(() => { closed = true; session.closed(); }); + const handle = async (message, generation) => { + if (closed) return; + let result; + const epoch = session.epoch; + const check = () => { if (closed || epoch !== session.epoch) throw new Error(); }; + try { + if (generation !== cancellation) throw new Error(); + if (message.op === 'lock') { stop(); result = true; } + else { + if (!await chrome.permissions.contains({origins: ['https://persimmons.app/*']})) throw new Error(); + const data = await vaultRequest('GET', message.owner); check(); + if (data.owner !== message.owner) throw new Error(); + if (message.op === 'status') result = !!session.get(message.owner); + else if (message.op === 'unlock') { + stop(); + const unlockEpoch = session.epoch; + const checkUnlock = () => { if (closed || session.epoch !== unlockEpoch) throw new Error(); }; + const policy = (await chrome.storage.local.get(AUTO_LOCK))[AUTO_LOCK] || 'close'; + if (!policies.includes(policy)) throw new Error(); + const storageId = `human-vault:https://persimmons.app:${message.owner}`; + let record = (await chrome.storage.local.get(storageId))[storageId]; checkUnlock(); + if (!record) { + if (data.items.length) throw new Error(); + record = await initialize(message.passphrase, message.owner); checkUnlock(); + await chrome.storage.local.set({[storageId]: record}); checkUnlock(); + } + const key = await unlock(message.passphrase, message.owner, record); checkUnlock(); + session.set(key, message.owner, policy); keepAlive(); result = true; + } else { + const key = session.get(message.owner); + if (!key) throw new Error(); + const activeEpoch = session.epoch; + if (message.op === 'encrypt') result = await encryptItem(key, message.owner, message.origin, message.account); + else if (message.op === 'decrypt') result = await decryptItem(key, message.owner, message.item); + else throw new Error(); + if (closed || session.epoch !== activeEpoch || !session.get(message.owner)) throw new Error(); + } + } + if (!closed) port.postMessage({id: message.id, ok: true, result}); + } catch (error) { stop(); if (!closed) port.postMessage({id: message.id, ok: false, unavailable: error instanceof VaultUnavailableError}); } + finally { message.passphrase = ''; message.account = null; result = null; } + }; + port.onMessage.addListener(message => { + if (message.op === 'lock') { cancellation++; stop(); port.postMessage({id: message.id, ok: true, result: true}); return; } + const generation = cancellation; + queue = queue.then(() => handle(message, generation)); + }); +}); +chrome.permissions.onRemoved.addListener(stop);