This repository has been archived on 2026-10-14. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
extension/human-popup.mjs
T

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);