Release cookie-free Persimmons vault link 0.6.0

This commit is contained in:
matcha committed 2026-10-04 08:05:18 +00:00
1 parent 53c4bb50f4
commit 1aa3cffaf2
32 files changed
+1535 -624

No files matched your search

+3 -1
View File
@@ -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';
+3 -5
View File
@@ -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;
+6 -4
View File
@@ -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();
+34 -8
View File
@@ -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 -4
View File
@@ -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",
+4 -5
View File
@@ -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);
});
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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(){
+6 -4
View File
@@ -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'}});
});
+13
View File
@@ -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) => ({
+1
View File
@@ -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'),'');
});
+3 -5
View File
@@ -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.
-2
View File
@@ -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);