Release cookie-free Persimmons vault link 0.6.0
This commit is contained in:
1 parent
53c4bb50f4
commit
1aa3cffaf2
32 files changed
+1535
-624
No files matched your search
@@ -1,2 +1,4 @@
|
||||
// Only non-secret UX metadata; never a session, passphrase or unwrapped key.
|
||||
// CONNECTION is non-secret UX metadata. VAULT_CAPABILITY is an owner-scoped,
|
||||
// revocable opaque bearer kept in extension-local trusted storage only.
|
||||
export const CONNECTION = 'human-connection:https://persimmons.app';
|
||||
export const VAULT_CAPABILITY = 'human-capability:https://persimmons.app';
|
||||
@@ -14,8 +14,7 @@ export async function connectionStatus(message, sender) {
|
||||
const result = {connected: false, version: chrome.runtime.getManifest().version};
|
||||
if (!/^[a-z0-9][a-z0-9_.-]{0,99}$/.test(message.owner)) return result;
|
||||
const hint = (await chrome.storage.local.get(CONNECTION))[CONNECTION];
|
||||
if (hint?.owner !== message.owner ||
|
||||
!await chrome.permissions.contains({origins: ['https://persimmons.app/*']})) return result;
|
||||
if (hint?.owner !== message.owner) return result;
|
||||
try {
|
||||
const data = await vaultRequest('GET', hint.owner);
|
||||
const storageId = `human-vault:https://persimmons.app:${hint.owner}`;
|
||||
@@ -30,8 +29,7 @@ export async function vaultInventory(message, sender) {
|
||||
if (!trustedPage(message, sender, 'persimmons.inventory.v1')) return null;
|
||||
if (!/^[a-z0-9][a-z0-9_.-]{0,99}$/.test(message.owner)) return null;
|
||||
const hint = (await chrome.storage.local.get(CONNECTION))[CONNECTION];
|
||||
if (hint?.owner !== message.owner || !vaultUnlocked(message.owner) ||
|
||||
!await chrome.permissions.contains({origins: ['https://persimmons.app/*']})) return null;
|
||||
if (hint?.owner !== message.owner || !vaultUnlocked(message.owner)) return null;
|
||||
try {
|
||||
const data = await vaultRequest('GET', hint.owner);
|
||||
const items = [];
|
||||
@@ -50,7 +48,7 @@ export async function vaultInventory(message, sender) {
|
||||
|
||||
async function liveJuice(owner) {
|
||||
const hint = (await chrome.storage.local.get(CONNECTION))[CONNECTION];
|
||||
if (hint?.owner !== owner || !await chrome.permissions.contains({origins: ['https://persimmons.app/*']})) throw new Error();
|
||||
if (hint?.owner !== owner) throw new Error();
|
||||
await vaultRequest('GET', owner);
|
||||
}
|
||||
let juiceBusy = false;
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import {VAULT_CAPABILITY} from './connection-state.mjs';
|
||||
export class VaultUnavailableError extends Error {}
|
||||
const BASE = 'https://teahub.xyz/tea/site/chat/persimmons';
|
||||
export async function vaultRequest(method = 'GET', owner, id, body, fetcher = fetch) {
|
||||
if (!['GET', 'POST', 'PUT', 'DELETE'].includes(method) || (id !== undefined && !/^[0-9a-f]{32}$/.test(id))) throw new Error('Invalid vault operation.');
|
||||
// Chromium omits Origin on permitted extension GETs. POST reads carry its
|
||||
// genuine Origin so the server retains the pinned-caller check.
|
||||
try {
|
||||
const stored = (await chrome.storage.local.get(VAULT_CAPABILITY))[VAULT_CAPABILITY];
|
||||
const capability = stored?.owner === owner && typeof stored?.capability === 'string' ? stored.capability : null;
|
||||
if (!capability) throw new Error();
|
||||
const response = await fetcher(`${BASE}/human-vault/items${id ? '/' + id : ''}${method === 'GET' ? '/read' : ''}`, {
|
||||
method: method === 'GET' ? 'POST' : method, credentials: 'include', cache: 'no-store', redirect: 'error', signal: AbortSignal.timeout(15000),
|
||||
headers: {Accept: 'application/json', ...(owner ? {'X-Vault-Owner': owner} : {}), ...(body ? {'Content-Type': 'application/json'} : {})},
|
||||
method: method === 'GET' ? 'POST' : method, credentials: 'omit', cache: 'no-store', redirect: 'error', signal: AbortSignal.timeout(15000),
|
||||
headers: {Accept: 'application/json', 'X-Vault-Capability': capability, ...(owner ? {'X-Vault-Owner': owner} : {}), ...(body ? {'Content-Type': 'application/json'} : {})},
|
||||
...(body ? {body: JSON.stringify(body)} : {})});
|
||||
if (!response.ok || !(response.headers.get('content-type') || '').includes('application/json')) throw new Error();
|
||||
const data = await response.json();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {CONNECTION} from './connection-state.mjs';
|
||||
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';
|
||||
@@ -114,6 +114,34 @@ async function refresh(check) {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -133,9 +161,8 @@ async function connect(check, expectedOwner) {
|
||||
|
||||
$('human-connect').onclick = () => {
|
||||
if (busy) return;
|
||||
const grant = chrome.permissions.request({origins: ['https://persimmons.app/*']});
|
||||
lock(); owner = null; items = []; target = null; $('human-items').replaceChildren();
|
||||
run(async check => { if (!await grant) throw new Error(); await connect(check); });
|
||||
run(async check => { const linkedOwner = await linkCapability(check); await connect(check, linkedOwner); });
|
||||
};
|
||||
|
||||
$('human-unlock-panel').onsubmit = event => {
|
||||
@@ -219,13 +246,12 @@ run(async check => {
|
||||
} 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 hint = (await chrome.storage.local.get(CONNECTION))[CONNECTION]; check();
|
||||
const permitted = await chrome.permissions.contains({origins: ['https://persimmons.app/*']}); check();
|
||||
if (hint?.owner) {
|
||||
if (!permitted) { status('Link vault to continue.'); return; }
|
||||
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;
|
||||
}
|
||||
if (permitted) await connect(check);
|
||||
status('Link vault to continue.');
|
||||
});
|
||||
|
||||
$('auto-lock').onchange = () => run(async check => {
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Persimmons",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"description": "Encrypted human vault with one-click save from browser-autofilled HTTPS login forms and explicit fill.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"scripting",
|
||||
"storage"
|
||||
],
|
||||
"optional_host_permissions": [
|
||||
"https://persimmons.app/*"
|
||||
],
|
||||
"incognito": "not_allowed",
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {CONNECTION} from '../connection-state.mjs';
|
||||
import {CONNECTION, VAULT_CAPABILITY} from '../connection-state.mjs';
|
||||
|
||||
test('page handshake is exact-origin, read-only, owner-bound and live-validated', async () => {
|
||||
let grant=true, hint={owner:'test-owner'}, calls=0, valid=true;
|
||||
let hint={owner:'test-owner'}, calls=0, valid=true;
|
||||
globalThis.chrome={runtime:{getManifest:()=>({version:'0.3.4'}),onMessageExternal:{addListener(){}},onConnect:{addListener(){}} },
|
||||
storage:{local:{get:async()=>({[CONNECTION]:hint})}},permissions:{onRemoved:{addListener(){}},contains:async()=>grant}};
|
||||
storage:{local:{get:async()=>({[CONNECTION]:hint,[VAULT_CAPABILITY]:{owner:'test-owner',capability:'A'.repeat(43)}})}}};
|
||||
globalThis.fetch=async()=>{calls++;return new Response(JSON.stringify({owner:valid?'test-owner':'other-owner',items:[]}),{headers:{'content-type':'application/json'}})};
|
||||
const {connectionStatus}=await import('../connection.mjs');
|
||||
const message={type:'persimmons.connection.v1',owner:'test-owner'};
|
||||
@@ -16,7 +16,6 @@ test('page handshake is exact-origin, read-only, owner-bound and live-validated'
|
||||
assert.deepEqual(await connectionStatus({type:'persimmons.connection.v1'},sender),{connected:false,version:'0.3.4'});
|
||||
assert.equal(calls,1);
|
||||
assert.equal((await connectionStatus({...message,owner:'other-owner'},sender)).connected,false);
|
||||
grant=false; assert.equal((await connectionStatus(message,sender)).connected,false); assert.equal(calls,1);
|
||||
grant=true; valid=false; assert.equal((await connectionStatus(message,sender)).connected,false);
|
||||
valid=false; assert.equal((await connectionStatus(message,sender)).connected,false);
|
||||
hint=null; assert.equal((await connectionStatus(message,sender)).connected,false);
|
||||
});
|
||||
@@ -54,7 +54,7 @@ test('legacy token module still has no persistence or injection; MV3 exposure is
|
||||
assert.equal(manifest.manifest_version, 3);
|
||||
assert.deepEqual(manifest.permissions, ['activeTab', 'scripting', 'storage']);
|
||||
assert.deepEqual(manifest.host_permissions, ['https://teahub.xyz/*']);
|
||||
assert.deepEqual(manifest.optional_host_permissions, ['https://persimmons.app/*']);
|
||||
assert.equal(manifest.optional_host_permissions, undefined);
|
||||
assert.equal(manifest.content_scripts, undefined);
|
||||
assert.deepEqual(manifest.background, {service_worker:'connection.mjs',type:'module'});
|
||||
assert.equal(manifest.web_accessible_resources, undefined);
|
||||
|
||||
@@ -31,7 +31,7 @@ test('homepage derives one onboarding state and never renders up-to-date copy',
|
||||
status:url==='/api/session'&&!signedIn?401:200,headers:{'content-type':'application/json'}
|
||||
});
|
||||
|
||||
const source=(await readFile(new URL('../../persimmons/web/app.js',import.meta.url),'utf8'))
|
||||
const source=(await readFile(new URL('../../backend/web/app.js',import.meta.url),'utf8'))
|
||||
.replace('./version.mjs',new URL('../version.mjs',import.meta.url).href);
|
||||
let sequence=0;
|
||||
async function render(){
|
||||
|
||||
@@ -5,14 +5,14 @@ import {readFile} from 'node:fs/promises';
|
||||
import {initialize, unlock, encryptItem, decryptItem, isHttpsOrigin} from '../human-crypto.mjs';
|
||||
import {EXTENSION_ID} from '../identity.mjs';
|
||||
import {vaultRequest} from '../human-api.mjs';
|
||||
import {VAULT_CAPABILITY} from '../connection-state.mjs';
|
||||
|
||||
test('deterministic unpacked ID agrees with the exact server Origin', async () => {
|
||||
test('deterministic unpacked ID stays pinned', async () => {
|
||||
const manifest = JSON.parse(await readFile(new URL('../manifest.json', import.meta.url)));
|
||||
const derived = createHash('sha256').update(Buffer.from(manifest.key, 'base64')).digest('hex').slice(0, 32)
|
||||
.replace(/[0-9a-f]/g, c => String.fromCharCode(97 + parseInt(c, 16)));
|
||||
assert.equal(derived, EXTENSION_ID);
|
||||
const server = await readFile(new URL('../../tea_mcp/human_vault.py', import.meta.url), 'utf8');
|
||||
assert.ok(server.includes(`EXTENSION_ORIGIN = "chrome-extension://${derived}"`));
|
||||
assert.equal(derived, 'kiolgjoohhgdohcafgelhikpgkbmckbg');
|
||||
});
|
||||
|
||||
test('exact HTTPS origins are data while extension origin remains the caller boundary', () => {
|
||||
@@ -42,10 +42,12 @@ test('local wrapping and authenticated ciphertext work for ordinary HTTPS sites'
|
||||
});
|
||||
|
||||
test('vault HTTP has no query or keys; session failures never echo bodies', async () => {
|
||||
globalThis.chrome={storage:{local:{get:async()=>({[VAULT_CAPABILITY]:{owner:'fixture',capability:'A'.repeat(43)}})}}};
|
||||
await vaultRequest('GET', 'fixture', '1'.repeat(32), undefined, async (url, options) => {
|
||||
assert.equal(url, 'https://teahub.xyz/tea/site/chat/persimmons/human-vault/items/' + '1'.repeat(32) + '/read');
|
||||
assert.equal(options.method, 'POST');
|
||||
assert.equal(options.credentials, 'include');
|
||||
assert.equal(options.credentials, 'omit');
|
||||
assert.equal(options.headers['X-Vault-Capability'], 'A'.repeat(43));
|
||||
assert.equal(options.headers['X-Vault-Owner'], 'fixture');
|
||||
return new Response(JSON.stringify({owner: 'fixture'}), {headers: {'Content-Type': 'application/json'}});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,19 @@ test('popup is only the human vault v0 surface', async () => {
|
||||
assert.ok(!html.includes('src="popup.mjs"'));
|
||||
});
|
||||
|
||||
test('linking mints an owner capability in the active signed-in page without a host prompt', async () => {
|
||||
const source = await readFile(new URL('../human-popup.mjs', import.meta.url), 'utf8');
|
||||
const manifest = JSON.parse(await readFile(new URL('../manifest.json', import.meta.url)));
|
||||
assert.match(source, /world: 'ISOLATED'/);
|
||||
assert.match(source, /fetch\('\/api\/human-vault\/link'/);
|
||||
assert.match(source, /'X-Requested-With': 'Persimmons'/);
|
||||
assert.match(source, /\[VAULT_CAPABILITY\]/);
|
||||
assert.doesNotMatch(source, /chrome\.permissions\.request/);
|
||||
assert.doesNotMatch(source, /credentials: 'include'/);
|
||||
assert.equal(manifest.optional_host_permissions, undefined);
|
||||
assert.deepEqual(manifest.host_permissions, ['https://teahub.xyz/*']);
|
||||
});
|
||||
|
||||
test('capture and fill require one clear filled same-origin login form', () => {
|
||||
const old = {location:globalThis.location,document:globalThis.document,window:globalThis.window,HTMLInputElement:globalThis.HTMLInputElement,Event:globalThis.Event};
|
||||
const makeInput = (type,value) => ({
|
||||
|
||||
@@ -22,6 +22,7 @@ test('version UI only says something when an update is actually available', () =
|
||||
assert.equal(updateState('0.4.4','0.4.4'),'');
|
||||
assert.equal(updateState('0.4.5','0.4.4'),'');
|
||||
assert.equal(updateState('0.4.4','0.4.5'),'Update available');
|
||||
assert.equal(updateState('0.5.9','0.6.0'),'Update available');
|
||||
assert.equal(updateState('0.4.4','0.4.4.0'),'');
|
||||
assert.equal(updateState('bogus','0.4.5'),'');
|
||||
});
|
||||
@@ -1,17 +1,17 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {AUTO_LOCK} from '../vault-session.mjs';
|
||||
import {VAULT_CAPABILITY} from '../connection-state.mjs';
|
||||
|
||||
test('worker keys stay private; trusted popup only; close, timer, auth loss and lock races fail closed', async () => {
|
||||
let connect, removed, valid = true, grant = true;
|
||||
const stored = {}, timers = [];
|
||||
let connect, valid = true;
|
||||
const stored = {[VAULT_CAPABILITY]:{owner:'owner',capability:'A'.repeat(43)}}, timers = [];
|
||||
globalThis.setInterval = () => 1; globalThis.clearInterval = () => {};
|
||||
globalThis.setTimeout = callback => { timers.push(callback); return timers.length; };
|
||||
globalThis.clearTimeout = () => {};
|
||||
globalThis.chrome = {
|
||||
runtime: {id:'fixture', getURL:path=>'chrome-extension://fixture/'+path,
|
||||
getPlatformInfo:async()=>({}), onConnect:{addListener:fn=>connect=fn}},
|
||||
permissions:{contains:async()=>grant,onRemoved:{addListener:fn=>removed=fn}},
|
||||
storage:{local:{get:async key=>({[key]:stored[key]}),set:async data=>Object.assign(stored,data)}}
|
||||
};
|
||||
globalThis.fetch = async()=>new Response(JSON.stringify({owner:valid?'owner':'other',items:[]}),{headers:{'content-type':'application/json'}});
|
||||
@@ -49,8 +49,6 @@ test('worker keys stay private; trusted popup only; close, timer, auth loss and
|
||||
assert.equal((await unlock()).result,true);
|
||||
valid=false;assert.equal((await port.send('status')).ok,false);valid=true;
|
||||
assert.equal((await port.send('status')).result,false);
|
||||
assert.equal((await unlock()).result,true);removed();assert.equal((await port.send('status')).result,false);
|
||||
grant=false;assert.equal((await unlock()).ok,false);grant=true;
|
||||
assert.equal((await port.send('unlock',{passphrase:'wrong'})).ok,false);
|
||||
const pending=unlock();await port.send('lock');assert.equal((await pending).ok,false);
|
||||
// Close invalidates any pending unlock and never installs its key.
|
||||
|
||||
@@ -61,7 +61,6 @@ chrome.runtime.onConnect.addListener(port => {
|
||||
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);
|
||||
@@ -100,4 +99,3 @@ chrome.runtime.onConnect.addListener(port => {
|
||||
queue = queue.then(() => handle(message, generation));
|
||||
});
|
||||
});
|
||||
chrome.permissions.onRemoved.addListener(stop);
|
||||
Reference in new issue
Block a user