import {CONNECTION, VAULT_CAPABILITY} from './connection-state.mjs'; import {safeMetadata, isHttpsOrigin} from './human-crypto.mjs'; import {vaultRequest, fillAccount, captureAccount, VaultUnavailableError} from './human-api.mjs'; import {AUTO_LOCK, policies} from './vault-session.mjs'; import {updateState} from './version.mjs'; const $ = id => document.getElementById(id); const port = chrome.runtime.connect({name: 'human-vault'}); let sequence = 0, owner = null, target = null, epoch = 0, busy = false, key = false, currentOrigin = null; let items = []; const pending = new Map(); function session(op, data = {}) { return new Promise((resolve, reject) => { const id = ++sequence; const timer = setTimeout(() => { pending.delete(id); reject(new Error()); }, 20000); pending.set(id, {resolve, reject, timer}); port.postMessage({id, op, owner, ...data}); }); } port.onMessage.addListener(message => { const task = pending.get(message.id); if (!task) return; clearTimeout(task.timer); pending.delete(message.id); message.ok ? task.resolve(message.result) : task.reject(message.unavailable ? new VaultUnavailableError() : new Error()); }); const status = text => { $('human-status').textContent = text; $('human-status').hidden = !text; }; function controls() { const inventoryMode = currentOrigin === 'https://persimmons.app'; const visibleItems = inventoryMode ? items : items.filter(item => item.origin === target?.origin); for (const id of ['human-connect', 'human-unlock', 'human-capture', 'human-fill', 'human-delete']) $(id).disabled = busy; $('auto-lock').disabled = busy; $('human-unlock').disabled ||= !owner || key; $('human-capture').disabled ||= !key || !target; $('human-fill').disabled ||= !key || !target || !$('human-items').value; $('human-delete').disabled ||= !key || !$('human-items').value; $('human-state').textContent = !owner ? 'Not connected' : key ? 'Unlocked' : 'Locked'; $('human-connect-panel').hidden = !!owner; $('human-unlock-panel').hidden = !owner || key; $('human-actions').hidden = !key; $('human-capture').hidden = !target; $('human-accounts').hidden = !key || !visibleItems.length; $('settings-panel').hidden = !owner || !key; $('auto-lock-panel').hidden = !owner || !key; $('recovery').hidden = !owner || !key; $('updates').hidden = !owner || !$('updates').dataset.available; } function clearPassphrase() { $('human-passphrase').value = ''; } function lock() { session('lock').catch(() => {}); epoch++; key = false; clearPassphrase(); status(''); controls(); } async function run(work, preservePassphrase = false) { if (busy) return; busy = true; controls(); const current = epoch; const check = () => { if (epoch !== current) throw new Error('Locked.'); }; try { await work(check); } catch (error) { if (epoch === current) { key = false; session('lock').catch(() => {}); if (error instanceof VaultUnavailableError) { items = []; status('Vault temporarily unavailable. Check Persimmons sign-in and retry.'); } else { status(preservePassphrase && owner ? 'Unlock failed. Check your passphrase.' : 'Nothing to save here yet.'); } } } finally { const retry = preservePassphrase && !key && owner ? $('human-passphrase').value : ''; clearPassphrase(); $('human-passphrase').value = retry; busy = false; controls(); } } async function activeTarget() { const [tab] = await chrome.tabs.query({active: true, currentWindow: true}); const url = new URL(tab.url); if (!isHttpsOrigin(url.origin) || url.origin === 'https://persimmons.app') throw new Error(); return {id: tab.id, origin: url.origin}; } async function refresh(check) { const data = await vaultRequest('GET', owner); check(); items = data.items.map(safeMetadata); const inventoryMode = currentOrigin === 'https://persimmons.app'; const visibleItems = inventoryMode ? items : items.filter(item => item.origin === target?.origin); $('human-items').replaceChildren(new Option(inventoryMode ? 'Choose a saved Persim' : 'Choose this site\'s Persim', '')); for (const item of visibleItems) { let label = item.username ? item.username + ' — ' + item.origin : item.origin; let account = null; if (!item.username && key) { try { const full = await vaultRequest('GET', owner, item.id); check(); account = await session('decrypt', {item: full.item}); check(); if (account?.username) { label = account.username + ' — ' + item.origin; await vaultRequest('PUT', owner, item.id, {...full.item, username: account.username}); check(); item.username = account.username; } } finally { if (account) { account.username = ''; account.password = ''; account = null; } } } $('human-items').add(new Option(label, item.id)); } } async function linkCapability(check) { await chrome.storage.local.setAccessLevel({accessLevel: 'TRUSTED_CONTEXTS'}); check(); const [tab] = await chrome.tabs.query({active: true, currentWindow: true}); check(); if (!tab?.id || new URL(tab.url).origin !== 'https://persimmons.app') throw new VaultUnavailableError(); const [execution] = await chrome.scripting.executeScript({ target: {tabId: tab.id, frameIds: [0]}, world: 'ISOLATED', func: async () => { try { const response = await fetch('/api/human-vault/link', { method: 'POST', credentials: 'same-origin', cache: 'no-store', headers: {Accept: 'application/json', 'X-Requested-With': 'Persimmons'} }); return response.ok ? await response.json() : null; } catch { return null; } } }); check(); const linked = execution?.result; if (!/^[a-z0-9][a-z0-9_.-]{0,99}$/.test(linked?.owner) || !/^[A-Za-z0-9_-]{40,128}$/.test(linked?.capability) || !Number.isSafeInteger(linked?.expires_at)) throw new VaultUnavailableError(); await chrome.storage.local.set({ [VAULT_CAPABILITY]: {owner: linked.owner, capability: linked.capability, expires_at: linked.expires_at}, [CONNECTION]: {owner: linked.owner} }); check(); return linked.owner; } async function connect(check, expectedOwner) { await chrome.storage.local.setAccessLevel({accessLevel: 'TRUSTED_CONTEXTS'}); const data = await vaultRequest('GET', expectedOwner); check(); owner = data.owner; const storageId = `human-vault:https://persimmons.app:${owner}`; const initialized = !!(await chrome.storage.local.get(storageId))[storageId]; check(); $('human-unlock').textContent = initialized ? 'Unlock vault' : 'Create vault'; $('human-passphrase').placeholder = initialized ? 'Vault passphrase' : 'Choose at least 12 characters'; try { target = await activeTarget(); } catch { target = null; } await refresh(check); await chrome.storage.local.set({[CONNECTION]: {owner}}); check(); key = !!(await session('status')); check(); if (key) await refresh(check); checkUpdates(); status(''); } $('human-connect').onclick = () => { if (busy) return; lock(); owner = null; items = []; target = null; $('human-items').replaceChildren(); run(async check => { const linkedOwner = await linkCapability(check); await connect(check, linkedOwner); }); }; $('human-unlock-panel').onsubmit = event => { event.preventDefault(); return run(async check => { let passphrase = $('human-passphrase').value; try { key = !!(await session('unlock', {passphrase})); check(); await refresh(check); $('human-unlock').textContent = 'Unlock vault'; $('human-passphrase').placeholder = 'Vault passphrase'; status(''); } finally { passphrase = ''; } }, true); }; $('human-capture').onclick = () => run(async check => { let account = null; try { const current = await activeTarget(); check(); const result = await chrome.scripting.executeScript({ target: {tabId: current.id, allFrames: true}, world: 'ISOLATED', func: captureAccount, args: [current.origin] }); check(); account = result.map(entry => entry.result).find(value => value?.username && value?.password) || null; if (!account) { status('No filled login found. Let your browser autofill the login form, then try again.'); return; } const item = await session('encrypt', {origin: current.origin, account}); check(); account.username = ''; account.password = ''; await vaultRequest('POST', owner, undefined, item); check(); target = current; await refresh(check); $('human-items').value = item.id; status('Saved to your vault.'); } finally { if (account) { account.username = ''; account.password = ''; } } }); $('human-fill').onclick = () => run(async check => { let account = null; try { const id = $('human-items').value; const current = await activeTarget(); check(); if (!key || !target || current.id !== target.id || current.origin !== target.origin) throw new Error(); const {item} = await vaultRequest('GET', owner, id); check(); const metadata = safeMetadata(item); if (metadata.id !== id || metadata.origin !== current.origin) throw new Error(); account = await session('decrypt', {item}); check(); const result = await chrome.scripting.executeScript({ target: {tabId: current.id, frameIds: [0]}, world: 'ISOLATED', func: fillAccount, args: [current.origin, account] }); check(); if (!result[0]?.result) throw new Error(); status(''); } finally { if (account) { account.username = ''; account.password = ''; } } }); $('human-delete').onclick = () => run(async check => { if (!key || !await session('status')) throw new Error(); check(); await vaultRequest('DELETE', owner, $('human-items').value); check(); await refresh(check); status(''); }); $('human-lock').onclick = lock; $('human-items').onchange = controls; $('human-signin').onclick = () => chrome.tabs.create({url: 'https://persimmons.app/'}); $('updates').onclick = () => chrome.tabs.create({url: 'https://persimmons.app/'}); window.addEventListener('pagehide', () => { epoch++; key = false; clearPassphrase(); port.disconnect(); }); port.onDisconnect.addListener(() => { key = false; epoch++; clearPassphrase(); controls(); for (const task of pending.values()) { clearTimeout(task.timer); task.reject(new Error()); } pending.clear(); }); controls(); run(async check => { try { const [tab] = await chrome.tabs.query({active: true, currentWindow: true}); check(); const url = new URL(tab.url); currentOrigin = url.protocol === 'https:' ? url.origin : null; $('origin').textContent = currentOrigin || 'Open an HTTPS website'; } catch { $('origin').textContent = 'Open an HTTPS website'; } const policy = (await chrome.storage.local.get(AUTO_LOCK))[AUTO_LOCK]; $('auto-lock').value = policies.includes(policy) ? policy : 'close'; const state = await chrome.storage.local.get([CONNECTION, VAULT_CAPABILITY]); check(); const hint = state[CONNECTION], capability = state[VAULT_CAPABILITY]; if (hint?.owner && capability?.owner === hint.owner && capability?.capability) { await connect(check, hint.owner); return; } status('Link vault to continue.'); }); $('auto-lock').onchange = () => run(async check => { const policy = $('auto-lock').value; if (!policies.includes(policy)) throw new Error(); await session('lock'); key = false; check(); await chrome.storage.local.set({[AUTO_LOCK]: policy}); check(); status(''); }); function checkUpdates() { fetch('https://persimmons.app/assets/version.json', {cache: 'no-store'}) .then(response => response.ok ? response.json() : null) .then(latest => { $('updates').dataset.available = updateState(chrome.runtime.getManifest().version, latest?.version) === 'Update available' ? 'yes' : ''; controls(); }).catch(() => {}); } setInterval(async () => { if (!key || busy) return; const current = epoch; try { const active = await session('status'); if (epoch !== current || busy) return; if (!active) { epoch++; key = false; clearPassphrase(); controls(); } } catch (error) { if (epoch !== current) return; if (error instanceof VaultUnavailableError) { epoch++; key = false; items = []; target = null; clearPassphrase(); status('Vault temporarily unavailable. Check Persimmons sign-in and retry.'); controls(); } else { lock(); } } }, 10000);