Archived
291 lines
12 KiB
JavaScript
291 lines
12 KiB
JavaScript
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);
|