port: from the old factory (tea-mcp/src/persimmons-extension), as it was, 2026-10-14

This commit is contained in:
teahubxyz committed 2026-10-14 00:22:00 -07:00
commit 0e92a80b20
30 files changed
+1944

No files matched your search

+294
View File
@@ -0,0 +1,294 @@
# Current dedicated human-vault taste test
Version 0.3.6 sends human-account requests only to
`https://teahub.xyz/tea/site/chat/persimmons/human-vault/items`. Sign in to
shared Teahub, then connect from the product surface at `https://persimmons.app`.
List/item reads use empty POST `/read` routes so Chromium sends its genuine
extension Origin; the pinned server allowlist is unchanged. **Connect Persimmons** requests only the exact Persimmons host permission. The pinned extension
ID remains `kiolgjoohhgdohcafgelhikpgkbmckbg`.
Human vault keys are namespaced by Persimmons origin and account. Existing Tea
ciphertext/local keys are not migrated or overwritten. The separate **Actions
tokens / Juice** controls retain their existing explicit Tea permission and
session flow. They do not use the new human vault.
Download the extension ZIP from the Persimmons homepage, extract it, and load
`persimmons-extension/` through Chromium Developer mode. Full steps and deployment
receipt: [dedicated host](../docs/persimmons-host-receipt.md).
The historical v0 description below applies to the separate Actions/Juice controls.
# Persimmons Chromium extension v0
Standalone MV3; load this `persimmons-extension/` directory using Developer mode →
Load unpacked at `chrome://extensions`. No build or dependency install is needed.
## Use
1. Sign in to `https://teahub.xyz` in the same browser profile. Visit an HTTPS site
such as `https://wp.phly.cloud/wp-admin/` and click the Persimmons toolbar icon.
2. Review **Current origin** and **Authentication unknown**. The capture signal is
only the current origin. No cookies, page content, passwords or passkeys are read.
3. **Connect Teahub / refresh** requests only `https://teahub.xyz/*` permission.
Your browser sends its existing Teahub session; no service identity is substituted.
4. To **Save authority to My vault**, explicitly paste a provider API token / PAT
into the masked field and enter a public source name. The existing create API
requires a credential value; origin metadata alone cannot create authority.
The field clears on success, failure and popup close. Refresh after ambiguous saves.
5. Under **Agent access / Permissions**, review requested scopes (read + draft-write
by default), expiry (60 minutes by default), choose a source and enter a public
agent/workflow subject. **Squeeze scoped Juice** persists policy metadata.
6. **Revoke** changes that Juice policy's status without deleting its source token.
**Open Persimmons** opens the existing hosted vault UI.
Juice v0 is **policy metadata only**. It does not enforce downstream scopes or
expiry, perform provider calls, attenuate tokens, or revoke provider sessions.
Public names, subjects and notes must contain no secrets. Provider privileges and
validity remain unchanged until revoked at the provider.
## Existing API and storage
No backend, vault, CORS or MCP changes. Fixed base:
`https://teahub.xyz/tea/site/chat/persimmons`.
| Action | Existing route |
| --- | --- |
| List sources | GET `/list` (popup projects My vault only) |
| Save explicit token | POST `/create` with `vault_kind: "user"` |
| List Juice | GET `/juice/list` |
| Squeeze | POST `/juice/create` with source, subject, scopes, expiry, My vault |
| Revoke Juice | POST `/juice/revoke` with ID and My vault |
| Agent read | MCP `persimmons_juice_list(repository?, source_id?)` |
Requests include browser credentials, reject redirects, bypass cache, have a
15-second timeout, and cap encoded bodies at 24 KiB (tokens at 16 KiB).
POST uses the existing browser client's JSON-as-text/plain contract. Errors are
fixed messages; response bodies are never dumped. Only public metadata is rendered.
The existing Gitea Actions **secret** stores the token; Actions **variables** store
connection and Juice metadata. Those variables are not secret storage. The extension
adds no encryption: at-rest protection is delegated to the existing Gitea secret
store and its operator configuration, not independently audited here. This is not
end-to-end encryption; Teahub receives plaintext over HTTPS and Gitea/authorized
Actions retain their existing access. There is no reveal endpoint or new vault.
The legacy Actions/Juice module has no storage persistence or content script; the human-vault module and status worker are described below.
Tokens exist briefly in JS/network memory; memory erasure is not guaranteed.
## Threat limits
Only `activeTab` and optional Teahub host permission are requested. The current tab
URL is reduced to an origin; paths, query strings and fragments are not saved.
Authentication is deliberately unknown, not inferred from cookies or page text.
The extension never scrapes passwords or exports passkey private keys.
A compromised browser, extension, Teahub host, or authorized Gitea/Actions operator
can still misuse a submitted token. Host grants are hostname-based and include all
ports. Browser SameSite/origin policy can block session transport; failure stays
closed and no cookie is manually injected. Incognito is disabled. Closing the popup
can interrupt operations; refresh before retrying. Revoking Juice does not revoke
the token, sign out the browser or enforce downstream permissions.
Chrome documents [activeTab access](https://developer.chrome.com/docs/extensions/develop/concepts/activeTab)
and [extension host permissions](https://developer.chrome.com/docs/extensions/develop/concepts/network-requests).
## Verification
From repository root:
```sh
node --test persimmons-extension/tests/*.test.mjs
XDG_DATA_HOME=/tmp/persimmons-test-data python -m unittest tests.test_persimmons tests.test_persimmons_web tests.test_persimmons_vault_model tests.test_persimmons_juice tests.test_persimmons_juice_http
```
Focused tests exercise permission denial, token save and clearing on failure,
origin minimization, safe failures, scoped Juice/revoke and startup refresh.
The registered MCP Juice tool is exercised with a generated synthetic sentinel:
it never reads a secret route or returns a source credential, including an injected
extra credential field in stored Juice metadata. Existing vault isolation/lifecycle
checks remain in place. These are not downstream enforcement tests.
For a signed-in live taste test, use a disposable provider token: follow Use above,
reopen to check persistence, call `persimmons_juice_list`, revoke Juice and verify
its revoked status. Deny Teahub permission, log out and simulate offline responses;
expect safe failures. Never put token values or cookie data in screenshots/logs.
Next: authenticated disposable-token acceptance; a caller-authorized executor
that enforces Juice policy; provider-native OAuth/token rotation and revocation.
## 0.3.3 taste-test UX
The packaged orange icons cover Chromium 16/32/48/128 sizes. Keep the same loaded
folder/profile and reload after extracting the updated ZIP; the pinned ID is
unchanged. Connect → Unlock → **Save an account for this origin** reveals manual
username/password entry only on the accepted HTTPS origins. **Encrypt and save**
is the sole capture action; typing, navigating, and signing in never capture.
Persimmons' own login stays outside the accepted human-vault origin allowlist.
Actions/Juice remains available under a collapsed section with its existing
separate Teahub permission and auth flow.
The homepage checks the actual account session, hides redundant sign-in when
signed in, and labels its ZIP link **Get / update**. It does not infer installation
or unlock state. Account forms request autocomplete off and retain real masked
password inputs, validation and clearing; browsers can override that hint and
still offer save/autofill. No browser-wide password-manager setting is changed.
References: [browser autocomplete limits](https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/Turning_off_form_autocompletion),
[Chromium icon packaging](https://developer.chrome.com/docs/extensions/develop/ui/configure-icons).
## 0.3.4 connection, controlled acceptance and updates
Connect previously lived only in the popup's `owner` variable; closing the popup
lost it. Now `chrome.storage.local` retains only `{owner}` in a dedicated connection
hint, in addition to the existing passphrase-wrapped key. Every reopen verifies
permission and the live same-owner server session before showing Connected/Locked.
A changed account requires explicit Connect; offline/expired sessions fail closed.
Passphrases, plaintext credentials and unwrapped keys are never persisted.
The only new background capability is a read-only external status message from
the top-level exact `https://persimmons.app` origin. It compares the page's owner,
the saved connection hint and the live server owner, returning only a connected
boolean and package version. There is no page command to unlock, fill, reload,
read credentials or retrieve keys, and no unlocked signal. The homepage checks on
load, focus and Refresh. Old extensions produce an unavailable/update message.
The existing exact server extension ID and two-origin human-vault allowlist remain.
Actions tokens / Juice is now a small collapsed divider, with token authority,
scope/expiry and passkey details under separate disclosures. Orange assets and
hero/footer source are unchanged.
### Stable-folder update loop
Use the homepage or popup **Update extension** link. Save
`https://persimmons.app/assets/update-extension.py` once and run:
```sh
python3 update-extension.py "/path/to/already-loaded/persimmons-extension"
```
Close the popup first. The Python 3 helper downloads the dedicated host's HTTPS
ZIP, refuses redirects/downgrades, validates bounded archive paths and the pinned
manifest key/ID, then replaces files in that same folder (manifest last). It does
not access browser storage, run downloaded commands, install a native host or
open a local network listener. This uses TLS release trust; the public manifest
key is an identity check, not an independent package signature. If interrupted,
rerun before Reload. Existing unrelated folder files are retained.
Then use **Reload** at `chrome://extensions`, or, from 0.3.4 onward, popup **Update
extension → Reload installed files**. Retain the same browser profile and do not
uninstall or clear storage. The first update from 0.3.3 uses Chrome's Reload.
Future rounds reuse the command and folder, without manual download/unzip/select.
Python must already be installed (`py` on Windows). Manual in-place ZIP replacement
remains a fallback. The helper was exercised on Linux; macOS/Windows are not yet
browser-tested.
An unpacked extension cannot write its installation directory with its extension
APIs; `runtime.reload()` rereads installed files and does not download them.
`requestUpdateCheck()` is for packaged updates; setting an update URL does not
turn a loaded unpacked directory into a Web Store installation. See Chrome's
[local development/reload instructions](https://developer.chrome.com/docs/extensions/get-started/tutorial/hello-world)
and [update lifecycle](https://developer.chrome.com/docs/extensions/develop/concepts/extensions-update-lifecycle).
### Shortest automatic-update distribution path (not published)
With explicit approval, use a Chrome Web Store developer account, prepare listing
and privacy/data-use disclosures plus reviewer instructions, and upload a versioned
package. **Private** with named Google-account testers is the narrow taste-test
choice; **Unlisted** allows anyone with the link. Both require review. Approved
store-installed packages receive Chrome-managed updates, with later versioned ZIPs
submitted through the same listing. See [distribution settings](https://developer.chrome.com/docs/webstore/cws-dashboard-distribution)
and [updating a listing](https://developer.chrome.com/docs/webstore/update).
Identity is a pre-publication gate: inspect the assigned store public key/ID and
verify it equals `kiolgjoohhgdohcafgelhikpgkbmckbg`. Do not assume that including an
arbitrary manifest public key forces the Store to use it. Establish whether the
original signing identity can be retained through the supported upload process;
no private signing key was sought or exported in this round. If the Store assigns
a different ID, stop for an explicitly reviewed identity/key-storage migration;
do not widen the server caller allowlist or tell testers to uninstall their
existing vault. See [Chrome's key/ID guidance](https://developer.chrome.com/docs/extensions/reference/manifest/key).
### Retest 0.3.4
1. Update the same folder and Reload; sign in at persimmons.app.
2. Open `https://wp.phly.cloud/wp-login.php`, click 🍊, Connect once and accept the
exact Persimmons permission. Initialize/unlock with your vault passphrase.
3. Expand Save an account, type a disposable username/password and Encrypt and save.
4. Close the popup, refresh the login page, reopen 🍊: Connected/Locked should return
automatically, with empty passphrase and Fill disabled. Unlock, select the saved
account, Fill current page. Both fields should fill; nothing submits.
5. Delete that account. Refresh persimmons.app: Extension connected, with no claim
that the vault is unlocked. Sign out: reconnect fails safely; sign back into the
same account to restore connection. A different account requires explicit Connect.
Automated acceptance now uses the real controlled WordPress login form, including
actual popup destruction/reopen, both page refreshes, wrong-passphrase rejection,
metadata-only storage inventory, explicit fill with zero POST submissions, deletion,
and signed-out rejection. It uses disposable values with no provider authority.
## Next: selective CSV import (not shipped)
Keep the import hook absent in 0.3.5. A separate change should add **Import CSV**
under Settings, enabled only for a live-connected, unlocked vault. Accept one
explicitly selected Chrome/Brave password export via a local file input; never
read browser password stores or request filesystem/password-manager authority.
Parse quoted CSV locally with bounded file/row/field sizes and required
`url,username,password` columns. Preview only origin and username, unchecked by
default; reject unsupported origins using the existing crypto allowlist, invalid
URLs and passkey records. Do not expand the allowlist as part of import.
Deduplicate selected records by exact origin and username against decrypted
existing records in popup memory; skip duplicates, never overwrite silently.
Encrypt each selected record immediately with the existing `encryptItem`, then
send only its ciphertext envelope through `vaultRequest`. Stop on lock, owner
change or session loss; report per-record success without logging credentials.
Clear file contents, preview and plaintext references on completion/lock/close.
Show “Delete the exported CSV after importing” at selection and completion.
Tests must cover quoted fields, malformed/oversized input, selective import,
duplicates, interrupted partial imports and ciphertext-only network requests.
Passkeys remain relationship metadata; never import private material.
## 0.3.6 state hierarchy and auto-lock
Connect uses the existing Persimmons browser session, never an account password
entered into the popup. Sign-in supports browser username/password autofill and
returns an already signed-in browser to the homepage. The vault passphrase is a
separate, locally chosen encryption secret; account sign-in cannot recover it.
Before connection, only Connect and the sign-in fallback are shown. Connected but
locked shows Unlock and collapsed auto-lock settings. Unlocked supported origins
show saved accounts and explicit Save. Actions/Juice stays collapsed and is hidden
until unlock on Teahub; its independent auth/server boundary is unchanged. The homepage
prominently shows Extension connected, hides Get whenever the extension responds,
and offers Update only when its numeric version is older than the built artifact.
A failed version check never invents an available update.
Auto-lock defaults to **On popup close**. Optional **5 / 15 / 60 minutes** are fixed
maximum sessions starting at unlock, not sliding inactivity timers. Changing the
setting locks immediately. A non-exportable AES CryptoKey lives only in the
extension service worker's memory; no plaintext passphrase, vault key, or account
is stored in local/session/sync storage. Existing local passphrase-wrapped key
records retain their format. No vault key is synced, exported, or sent to a server.
A bounded 20-second extension API heartbeat keeps the worker available across
popup closes; a deadline timer and checks on every operation enforce expiry.
Restart, reload, permission removal, failed live owner/session validation, or
unexpected worker shutdown fail closed (and can lock earlier). The popup checks
status every ten seconds without clearing fields unless the session has ended.
Each crypto operation revalidates the live owner. Internal ports accept only this
extension's exact popup URL; the website's separate read-only handshake returns
installation/version/connection only, never keys, accounts, or unlock state.
Tradeoff: anyone controlling the same unlocked browser profile can use Fill during
the selected window. Choose popup-close locking on shared machines, or Lock vault
before leaving. Browser-restart-only unlock is intentionally not offered: all
retained sessions have a maximum sixty-minute deadline. There is no recovery when
the passphrase or original profile is lost.
Retest: update the existing folder with the helper at
`https://persimmons.app/assets/update-extension.py`, then Reload in
`chrome://extensions` (keep the same profile/folder; do not uninstall). Open
`https://persimmons.app/` → sign in if needed → 🍊 → Connect. Confirm the homepage
says Extension connected / Up to date and has no Get or Update CTA. On
`https://wp.phly.cloud/wp-login.php`, unlock → Save for this site → enter disposable credentials → Save.
Close/reopen with the default: unlock → select account → Fill (no submit). Choose
Settings → Auto-lock settings → After 5 minutes → unlock → close → reopen: still unlocked.
Lock explicitly or wait five minutes: Fill is blocked. Unlock → select → Delete.
+4
View File
@@ -0,0 +1,4 @@
// 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';
+77
View File
@@ -0,0 +1,77 @@
import {runJuice, juiceRequest, giteaToken} from './juice.mjs';
import {vaultUnlocked, vaultUsername, useVaultCredential} from './vault-worker.mjs';
import {CONNECTION} from './connection-state.mjs';
import {vaultRequest} from './human-api.mjs';
function trustedPage(message, sender, type) {
return sender.origin === 'https://persimmons.app' &&
new URL(sender.url).origin === sender.origin && sender.frameId === 0 &&
message?.type === type;
}
export async function connectionStatus(message, sender) {
if (!trustedPage(message, sender, 'persimmons.connection.v1')) return null;
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 && hint.owner !== message.owner) return result;
try {
const data = await vaultRequest('GET', message.owner);
if (!hint?.owner) await chrome.storage.local.set({[CONNECTION]: {owner: message.owner}});
const storageId = `human-vault:https://persimmons.app:${message.owner}`;
result.connected = true;
result.initialized = !!(await chrome.storage.local.get(storageId))[storageId];
result.unlocked = vaultUnlocked(message.owner);
result.empty = data.items.length === 0;
} catch {}
return result;
}
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)) return null;
try {
const data = await vaultRequest('GET', hint.owner);
const items = [];
for (const metadata of data.items) {
const full = await vaultRequest('GET', hint.owner, metadata.id);
const username = await vaultUsername(hint.owner, full.item);
if (typeof username === 'string' && username) {
items.push({id: metadata.id, origin: metadata.origin, username});
}
}
return {items};
} catch {
return null;
}
}
async function liveJuice(owner) {
const hint = (await chrome.storage.local.get(CONNECTION))[CONNECTION];
if (hint?.owner !== owner) throw new Error();
await vaultRequest('GET', owner);
}
let juiceBusy = false;
export async function juiceMessage(message, sender) {
if (juiceBusy) return {ok: false, message: 'A Juice operation is already running.'};
juiceBusy = true;
try {
return await runJuice(message, sender, {
live: liveJuice,
unlocked: async owner => {
if (!vaultUnlocked(owner) || !(await chrome.storage.local.get(`human-vault:https://persimmons.app:${owner}`))[`human-vault:https://persimmons.app:${owner}`]) throw new Error();
},
source: async (owner, id) => (await vaultRequest('GET', owner, id)).item,
use: useVaultCredential, request: juiceRequest, provider: giteaToken,
id: () => crypto.randomUUID().replaceAll('-', '')
});
} finally { juiceBusy = false; }
}
// External responses contain only status, operation IDs and safe username metadata.
chrome.runtime.onMessageExternal.addListener((message, sender, reply) => {
const work = message?.type === 'persimmons.juice.v1' ? juiceMessage(message, sender) : message?.type === 'persimmons.inventory.v1' ? vaultInventory(message, sender) : connectionStatus(message, sender);
work.then(reply, () => reply(null));
return true;
});
+49
View File
@@ -0,0 +1,49 @@
export const BASE = 'https://teahub.xyz/tea/site/chat/persimmons';
export const SCOPES = ['content:read', 'content:draft-write', 'manage:users', 'billing', 'destructive'];
export const DEFAULT_SCOPES = SCOPES.slice(0, 2);
const bytes = value => new TextEncoder().encode(value).length;
export function pageTarget(url) {
const parsed = new URL(url);
if (parsed.protocol !== 'https:' || parsed.username || parsed.password) throw new Error('Use an HTTPS website tab.');
return {origin: parsed.origin};
}
export function metadata({origin, name, scopes}) {
if (!name.trim() || name.length > 100) throw new Error('Enter a public name of at most 100 characters.');
if (!Array.isArray(scopes) || scopes.some(s => !SCOPES.includes(s))) throw new Error('Invalid scopes.');
return {name: name.trim(), provider: new URL(origin).hostname, account: origin, vault_kind: 'user',
credential_type: 'api-token', scopes,
notes: 'Persimmons extension v0. Requested scopes are metadata; provider authority is unchanged.'};
}
export function publicSource(item) {
if (!item || item.vault_kind !== 'user' || typeof item.id !== 'string' ||
!/^[A-Za-z0-9_-]{1,100}$/.test(item.id) || typeof item.name !== 'string' || item.name.length > 200) {
throw new Error('Unexpected public source response. Refresh My vault.');
}
return {id: item.id, name: item.name};
}
export async function request(path, body, fetcher = fetch) {
if (!['list', 'create', 'juice/list', 'juice/create', 'juice/revoke'].includes(path)) throw new Error('Invalid operation.');
const encoded = body === undefined ? undefined : JSON.stringify(body);
if (encoded && bytes(encoded) > 24 * 1024) throw new Error('Request exceeds 24 KiB; nothing sent.');
const options = {credentials: 'include', cache: 'no-store', redirect: 'error',
signal: AbortSignal.timeout(15000), headers: {Accept: 'application/json'}};
if (encoded !== undefined) Object.assign(options, {method: 'POST', body: encoded,
headers: {...options.headers, 'Content-Type': 'text/plain;charset=UTF-8'}});
try {
const response = await fetcher(`${BASE}/${path}`, options);
if (response.status === 401 || response.status === 403) throw new Error('Sign in to teahub.xyz and retry; access may be denied.');
if (!response.ok) throw new Error(path.startsWith('juice/') ? 'Juice unavailable; no capability confirmed.' : 'My vault request failed; refresh before retrying.');
if (!(response.headers.get('content-type') || '').includes('application/json')) throw new Error('Service unavailable; expected JSON.');
const data = await response.json();
if (!data || typeof data !== 'object' || data.error || data.ok === false) throw new Error('Service did not confirm the operation.');
return data;
} catch (error) {
// Only our fixed messages leave this boundary, never server bodies or network errors.
const safe = ['Sign in to teahub.xyz and retry; access may be denied.', 'Juice unavailable; no capability confirmed.',
'My vault request failed; refresh before retrying.', 'Service unavailable; expected JSON.', 'Service did not confirm the operation.'];
throw new Error(safe.includes(error.message) ? error.message : 'Service unreachable or timed out; refresh before retrying.');
}
}
+61
View File
@@ -0,0 +1,61 @@
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.');
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: '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();
if (!/^[a-z0-9][a-z0-9_.-]{0,99}$/.test(data.owner) || (owner && data.owner !== owner) || data.error) throw new Error();
return data;
} catch { throw new VaultUnavailableError('Vault unavailable or session changed. Sign in to Persimmons and reconnect.'); }
}
// Serialized into the isolated main-frame world only after an explicit Fill click.
// Check origin again inside the actual target document to close navigation races.
export function fillAccount(expectedOrigin, account) {
try {
if (location.protocol !== 'https:' || location.origin !== expectedOrigin || window.top !== window) return false;
const visible = input => !input.disabled && !input.readOnly && input.getClientRects().length > 0;
const passwords = [...document.querySelectorAll('input[type="password"]')].filter(visible);
if (passwords.length !== 1) return false;
const password = passwords[0];
const form = password.form;
if (!form || new URL(form.action || location.href, location.href).origin !== expectedOrigin) return false;
const users = [...form.querySelectorAll('input')].filter(input =>
['text', 'email'].includes(input.type) && visible(input));
if (users.length !== 1) return false;
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
setter.call(users[0], account.username); setter.call(password, account.password);
for (const input of [users[0], password]) {
input.dispatchEvent(new Event('input', {bubbles: true}));
input.dispatchEvent(new Event('change', {bubbles: true}));
}
return true;
} finally { account.username = ''; account.password = ''; account = null; }
}
// Serialized into the isolated main-frame world only after an explicit Capture click.
export function captureAccount(expectedOrigin) {
try {
if (location.protocol !== 'https:' || location.origin !== expectedOrigin) return null;
const visible = input => !input.disabled && !input.readOnly && input.getClientRects().length > 0;
const passwords = [...document.querySelectorAll('input[type="password"]')].filter(input => visible(input) && input.value);
if (passwords.length !== 1) return null;
const password = passwords[0];
const form = password.form;
if (!form || new URL(form.action || location.href, location.href).origin !== expectedOrigin) return null;
const users = [...form.querySelectorAll('input')].filter(input =>
['text', 'email'].includes(input.type) && visible(input) && input.value);
if (users.length !== 1) return null;
return {username: users[0].value, password: password.value};
} catch { return null; }
}
+84
View File
@@ -0,0 +1,84 @@
// All secret cryptography stays in the extension. Server stores opaque blobs.
const utf8 = new TextEncoder();
const decode = new TextDecoder('utf-8', {fatal: true});
const b64 = value => btoa(String.fromCharCode(...new Uint8Array(value)));
const unb64 = value => Uint8Array.from(atob(value), c => c.charCodeAt(0));
const random = size => crypto.getRandomValues(new Uint8Array(size));
const ITERATIONS = 600000;
export function isHttpsOrigin(value) {
try {
const url = new URL(value);
return url.protocol === 'https:' && url.origin === value && !url.username && !url.password;
} catch { return false; }
}
const wrapAAD = owner => utf8.encode(JSON.stringify(['persimmons-wrap', 1, owner]));
const itemAAD = (owner, item) => utf8.encode(JSON.stringify(['persimmons-item', item.v, owner, item.id, item.origin]));
async function wrappingKey(passphrase, salt) {
let bytes = utf8.encode(passphrase);
try {
const material = await crypto.subtle.importKey('raw', bytes, 'PBKDF2', false, ['deriveKey']);
return await crypto.subtle.deriveKey({name: 'PBKDF2', hash: 'SHA-256', salt, iterations: ITERATIONS},
material, {name: 'AES-GCM', length: 256}, false, ['wrapKey', 'unwrapKey']);
} finally { bytes.fill(0); bytes = null; }
}
export async function initialize(passphrase, owner) {
if (passphrase.length < 12 || passphrase.length > 1024) throw new Error('Use a passphrase of 12–1024 characters.');
const salt = random(16), iv = random(12);
let generated = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
let wrapping = await wrappingKey(passphrase, salt);
try {
const wrapped = await crypto.subtle.wrapKey('raw', generated, wrapping,
{name: 'AES-GCM', iv, additionalData: wrapAAD(owner), tagLength: 128});
return {v: 1, kdf: 'PBKDF2', hash: 'SHA-256', iterations: ITERATIONS,
salt: b64(salt), iv: b64(iv), wrappedKey: b64(wrapped)};
} finally { generated = null; wrapping = null; passphrase = ''; }
}
export async function unlock(passphrase, owner, record) {
let wrapping;
try {
if (!record || record.v !== 1 || record.kdf !== 'PBKDF2' || record.hash !== 'SHA-256' ||
record.iterations !== ITERATIONS || unb64(record.salt).length !== 16 ||
unb64(record.iv).length !== 12 || unb64(record.wrappedKey).length !== 48 || passphrase.length > 1024) throw new Error();
wrapping = await wrappingKey(passphrase, unb64(record.salt));
return await crypto.subtle.unwrapKey('raw', unb64(record.wrappedKey), wrapping,
{name: 'AES-GCM', iv: unb64(record.iv), additionalData: wrapAAD(owner), tagLength: 128},
{name: 'AES-GCM', length: 256}, false, ['encrypt', 'decrypt']);
} catch { throw new Error('Unlock failed. Check passphrase and local vault data.'); }
finally { wrapping = null; passphrase = ''; }
}
export function safeMetadata(item) {
if (!item || item.v !== 1 || !/^[0-9a-f]{32}$/.test(item.id) || !isHttpsOrigin(item.origin)) throw new Error('Invalid encrypted item.');
if (item.username !== undefined && (typeof item.username !== 'string' || !item.username || item.username.length > 512)) throw new Error('Invalid encrypted item.');
return {v: 1, id: item.id, origin: item.origin, ...(item.username ? {username: item.username} : {})};
}
export async function encryptItem(key, owner, origin, account, id = crypto.randomUUID().replaceAll('-', '')) {
const item = safeMetadata({v: 1, id, origin});
if (typeof account.username !== 'string' || typeof account.password !== 'string' || !account.username || !account.password) throw new Error('Enter username and password.');
let plain = utf8.encode(JSON.stringify({username: account.username, password: account.password}));
try {
if (plain.length > 12000) throw new Error('Account is too large.');
const iv = random(12);
const ciphertext = await crypto.subtle.encrypt({name: 'AES-GCM', iv, additionalData: itemAAD(owner, item), tagLength: 128}, key, plain);
return {...item, username: account.username, iv: b64(iv), ciphertext: b64(ciphertext)};
} finally { plain.fill(0); plain = null; }
}
export async function decryptItem(key, owner, item) {
let plain;
try {
safeMetadata(item);
if (unb64(item.iv).length !== 12 || item.ciphertext.length > 22000) throw new Error();
plain = new Uint8Array(await crypto.subtle.decrypt({name: 'AES-GCM', iv: unb64(item.iv), additionalData: itemAAD(owner, item), tagLength: 128}, key, unb64(item.ciphertext)));
const account = JSON.parse(decode.decode(plain));
if (typeof account.username !== 'string' || typeof account.password !== 'string' || Object.keys(account).sort().join() !== 'password,username') throw new Error();
return account;
} catch { throw new Error('Encrypted item failed authentication.'); }
finally { plain?.fill(0); plain = null; }
}
+290
View File
@@ -0,0 +1,290 @@
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);
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 651 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs><radialGradient id="fruit" cx="35%" cy="28%" r="75%"><stop stop-color="#ffb448"/><stop offset=".65" stop-color="#f47b20"/><stop offset="1" stop-color="#db5c0d"/></radialGradient></defs>
<path d="M64 31C36 22 9 44 10 73c1 28 22 47 54 47s53-19 54-47c1-29-26-51-54-42Z" fill="url(#fruit)"/>
<path d="M64 33c-1-9 0-16 4-23" fill="none" stroke="#70502b" stroke-width="7" stroke-linecap="round"/>
<path d="M65 28C43 30 33 20 30 9c22-4 38 4 35 19Z" fill="#4b813a"/>
<path d="M38 61c-8 7-11 14-10 22" fill="none" stroke="#ffd091" stroke-width="7" stroke-linecap="round" opacity=".7"/>
</svg>

After

Width:  |  Height:  |  Size: 663 B

+2
View File
@@ -0,0 +1,2 @@
// Derived from manifest.key; keep in sync with the server exact Origin.
export const EXTENSION_ID = 'kiolgjoohhgdohcafgelhikpgkbmckbg';
+149
View File
@@ -0,0 +1,149 @@
// Gitea v1.27.3: API-class scopes, not repository restrictions. Worker only.
export const JUICE_ORIGINS = ['https://teahub.xyz'];
export const JUICE_SCOPES = ['read:repository', 'read:issue', 'write:issue'];
const idPattern = /^[0-9a-f]{32}$/;
const usernamePattern = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
export function trustedJuice(message, sender) {
try {
return sender.origin === 'https://persimmons.app' && new URL(sender.url).origin === sender.origin &&
sender.frameId === 0 && message?.type === 'persimmons.juice.v1' &&
['create', 'rotate', 'revoke'].includes(message.action) &&
/^[a-z0-9][a-z0-9_.-]{0,99}$/.test(message.owner) &&
message.target && Object.keys(message.target).sort().join() === 'id,name' &&
Number.isSafeInteger(message.target.id) && message.target.id > 0 &&
/^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/.test(message.target.name) &&
Object.keys(message).sort().join() === (message.action === 'create' ?
'action,owner,policy,source_id,target,type' : 'action,id,owner,target,type');
} catch { return false; }
}
export function checkedScopes(value) {
if (!Array.isArray(value) || !value.length || value.length > 3 ||
value.some(s => !JUICE_SCOPES.includes(s)) || new Set(value).size !== value.length ||
(value.includes('read:issue') && value.includes('write:issue'))) throw new Error();
return [...value].sort();
}
export async function juiceRequest(operation, owner, data, fetcher = fetch) {
const response = await fetcher('https://persimmons.app/api/juice/' + operation, {
method: 'POST', credentials: 'include', cache: 'no-store', redirect: 'error', signal: AbortSignal.timeout(20000),
headers: {'Content-Type': 'application/json', 'X-Vault-Owner': owner}, body: JSON.stringify(data)
});
if (!response.ok) throw new Error();
const result = await response.json();
if (result.owner !== owner) throw new Error();
return result;
}
export async function giteaToken(origin, account, name, scopes, fetcher = fetch) {
if (!JUICE_ORIGINS.includes(origin) || !usernamePattern.test(account.username) ||
!/^persimmons-juice-[0-9a-f]{32}$/.test(name)) throw new Error();
const bytes = new TextEncoder().encode(account.username + ':' + account.password);
let auth;
try {
auth = 'Basic ' + btoa(String.fromCharCode(...bytes));
const response = await fetcher(origin + '/api/v1/users/' + encodeURIComponent(account.username) + '/tokens' +
(scopes ? '' : '/' + name), {
method: scopes ? 'POST' : 'DELETE', credentials: 'omit', cache: 'no-store', redirect: 'error',
signal: AbortSignal.timeout(15000), headers: {'Content-Type': 'application/json', Authorization: auth},
...(scopes ? {body: JSON.stringify({name, scopes: checkedScopes(scopes)})} : {})
});
if (!scopes) {
if (response.status !== 204 && response.status !== 404) throw new Error();
return true;
}
if (response.status !== 201) throw new Error();
const token = await response.json();
if (!/^[0-9a-f]{40}$/.test(token.sha1) || token.name !== name ||
checkedScopes(token.scopes).join() !== checkedScopes(scopes).join()) throw new Error();
return token.sha1;
} finally { bytes.fill(0); auth = ''; }
}
// Dependencies keep crypto and Chrome state out of the adapter's test surface.
// No error object, provider response or credential can be returned to the page.
export async function runJuice(message, sender, deps) {
if (!trustedJuice(message, sender)) return null;
const {owner, target, action} = message;
const request = (op, data) => deps.request(op, owner, {target, ...data});
let newId, source, previous, prepared = false, attempted = false;
try {
await deps.live(owner);
if (action !== 'create') {
if (!idPattern.test(message.id)) throw new Error();
previous = (await request('list', {})).items.find(i => i.id === message.id);
if (!previous) throw new Error();
}
if (action === 'revoke') {
// Target removal does not depend on source availability or unlock state.
await request('remove', {id: previous.id});
let confirmed = false;
try {
await deps.unlocked(owner);
source = await deps.source(owner, previous.source_id);
if (source.origin !== previous.source_origin) throw new Error();
await deps.use(owner, source, async (account, check) => {
if (account.username !== previous.source_username) throw new Error();
await deps.live(owner); check();
await deps.provider(source.origin, account, 'persimmons-juice-' + previous.id);
confirmed = true;
});
} catch {}
await request('cleanup', {id: previous.id, provider_status: confirmed ? 'revoked' : 'unknown'});
return {ok: confirmed, id: previous.id, status: confirmed ? 'revoked' : 'partial-failure',
message: confirmed ? 'Revoked.' : 'ANDON: Target secret removed; provider revocation is unconfirmed. Unlock the source and retry Revoke.'};
}
await deps.unlocked(owner);
const sourceId = previous?.source_id || message.source_id;
if (!idPattern.test(sourceId)) throw new Error();
const scopes = checkedScopes(previous?.scopes || message.policy?.scopes);
if (action === 'create' && Object.keys(message.policy).join() !== 'scopes') throw new Error();
if (previous && previous.status !== 'active') throw new Error();
source = await deps.source(owner, sourceId);
if (!JUICE_ORIGINS.includes(source.origin)) return {ok: false, message: 'Juice adapter not available yet.'};
if (previous && previous.source_origin !== source.origin) throw new Error();
newId = deps.id();
return await deps.use(owner, source, async (account, check) => {
if (!usernamePattern.test(account.username) || (previous && account.username !== previous.source_username)) throw new Error();
let pat = '';
try {
await deps.live(owner); check();
await request('prepare', {id: newId, source_id: sourceId, source_username: account.username,
scopes, replaces: previous?.id || null});
prepared = true;
await deps.live(owner); check();
attempted = true;
pat = await deps.provider(source.origin, account, 'persimmons-juice-' + newId, scopes);
await deps.live(owner); check();
await request('write', {id: newId, pat});
pat = '';
} catch {
pat = '';
let confirmed = !attempted;
if (attempted) {
try { check(); await deps.provider(source.origin, account, 'persimmons-juice-' + newId); confirmed = true; } catch {}
}
let targetClean = !prepared;
if (prepared) {
try { await request('cleanup', {id: newId, provider_status: confirmed ? 'revoked' : 'unknown'}); targetClean = true; } catch {}
}
return {ok: false, id: newId, status: confirmed && targetClean ? 'revoked' : 'partial-failure',
message: confirmed && targetClean ? 'Juice failed; new token and target secret cleaned up.' :
'ANDON: Juice failed; cleanup is unconfirmed. Inspect the pending Juice card and retry Revoke.'};
} finally { pat = ''; }
if (previous) {
let confirmed = false;
try {
await request('remove', {id: previous.id});
await deps.live(owner); check();
await deps.provider(source.origin, account, 'persimmons-juice-' + previous.id);
confirmed = true;
await request('cleanup', {id: previous.id, provider_status: 'revoked'});
} catch {
return {ok: false, id: newId, status: 'partial-failure',
message: 'ANDON: Replacement stored; old token cleanup is ' + (confirmed ? 'not recorded' : 'unconfirmed') + '. Retry Revoke on the old card.'};
}
}
return {ok: true, id: newId, status: 'active'};
});
} catch {
return {ok: false, status: 'unavailable', message: 'Juice unavailable. Check connection, owner, source and unlocked vault.'};
}
}
+44
View File
@@ -0,0 +1,44 @@
{
"manifest_version": 3,
"name": "Persimmons",
"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"
],
"incognito": "not_allowed",
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/orange-16.png",
"32": "icons/orange-32.png",
"48": "icons/orange-48.png",
"128": "icons/orange-128.png"
},
"default_title": "Persimmons"
},
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'none'; connect-src https://teahub.xyz https://persimmons.app"
},
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtqUSMc+fqoFTmXlXSWtG5ISzr2VirLmNH2vD96SlJ7WmHg738SNvL3vFsuTi13dJNudptW4su/VRz0mcCYBlLPn573BkHoTgcSAR1R60t9ojSqurTGsqU7EyudQZlw7+eZeF1oWM7cZa3R3APHc+T4ZghYoTQNI2v52XREVPmpTw4zSFCH9ImnCZ2UhYkuBspuBc+EvIaxU9Es6u7/tLp7zKVEjGB7xLI7pUrY1z/S1Sk3XOKfKao7MZfIVnQGk1WD4YyzQb/FDM0dsG9dCLiLSPHNFKuqw0oSUNHLTDkZ64qXNdtunszg2wH4kbIqBV08/ZZbyu63jVKcODxpPXtwIDAQAB",
"icons": {
"16": "icons/orange-16.png",
"32": "icons/orange-32.png",
"48": "icons/orange-48.png",
"128": "icons/orange-128.png"
},
"background": {
"service_worker": "connection.mjs",
"type": "module"
},
"externally_connectable": {
"matches": [
"https://persimmons.app/*"
]
},
"host_permissions": [
"https://teahub.xyz/*"
]
}
+35
View File
@@ -0,0 +1,35 @@
:root { color-scheme: light; font: 13px/1.5 ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #241a14; background: #fffdfa; }
* { box-sizing: border-box; }
body { width: 360px; margin: 0; padding: 16px; }
header, .row { display: flex; align-items: center; gap: 8px; }
header { margin-bottom: 18px; }
header img { flex: none; }
header a { margin-left: auto; font-size: 21px; text-decoration: none; }
h1 { font-size: 22px; letter-spacing: -.04em; margin: 0; line-height: 1.15; }
h2 { font-size: 16px; margin: 0; }
p { margin: 10px 0; overflow-wrap: anywhere; }
a { color: #b8530d; }
.origin { margin-bottom: 14px; overflow-wrap: anywhere; }
.eyebrow { display: block; color: #78695f; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
.origin strong { font-weight: 500; }
.card { background: #fff; border: 1px solid #eadfd6; border-radius: 14px; padding: 14px; margin: 12px 0; }
.badge { margin-left: auto; color: #78695f; background: #fff5eb; border-radius: 20px; padding: 3px 8px; font-size: 11px; }
label { display: block; margin: 10px 0; font-size: 12px; }
input, select { width: 100%; padding: 9px 10px; margin-top: 4px; border: 1px solid #eadfd6; border-radius: 9px; font: inherit; color: #241a14; background: #fff; }
button { border: 1px solid #eadfd6; border-radius: 10px; background: #fffdfa; padding: 9px 11px; margin: 4px 0; cursor: pointer; color: #241a14; font: inherit; font-weight: 600; }
button:hover { border-color: #d9c7ba; }
button.primary { background: #f47b20; border-color: #f47b20; color: #fff; }
button.primary:hover { background: #da6410; }
button:disabled { opacity: .5; cursor: default; }
button.danger { color: #a23b2b; }
.wide { width: 100%; }
:focus-visible { outline: 2px solid #b8530d; outline-offset: 2px; }
.hint, #human-status { color: #78695f; font-size: 11px; }
summary { cursor: pointer; font-weight: 600; padding: 5px 0; }
[hidden] { display: none !important; }
.vault-actions { gap: 8px; margin-top: 10px; }
.vault-actions button { flex: 1; }
.link-button { border: 0; background: none; color: #b8530d; text-decoration: underline; padding: 6px 0; font-weight: 500; }
.updates { border-top: 1px solid #eadfd6; padding-top: 6px; margin-top: 8px; }
.recovery { border-top: 1px solid #eadfd6; padding-top: 10px; }
.sr-only { position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0; }
+43
View File
@@ -0,0 +1,43 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Persimmons</title><link rel="stylesheet" href="popup.css"></head>
<body>
<header><img src="icons/orange-48.png" width="36" height="36" alt=""><h1>Persimmons</h1><a href="https://persimmons.app/" target="_blank" rel="noreferrer" aria-label="Open Persimmons website">↗</a></header>
<div class="origin"><span class="eyebrow">Current site</span><strong id="origin">Checking…</strong></div>
<section class="card" aria-labelledby="human-heading">
<div class="row"><h2 id="human-heading">My vault</h2><span id="human-state" class="badge">Not connected</span></div>
<p id="human-status" role="status" aria-live="polite" hidden></p>
<div id="human-connect-panel">
<button id="human-connect" class="primary wide">Link vault</button>
<button type="button" id="human-signin" class="link-button wide">Sign in / create account</button>
</div>
<form id="human-unlock-panel" hidden>
<label class="sr-only" for="human-passphrase">Vault passphrase</label>
<input id="human-passphrase" type="password" autocomplete="off" maxlength="1024" placeholder="Vault passphrase">
<button type="submit" id="human-unlock" class="primary wide" disabled>Unlock vault</button>
</form>
<div id="human-actions" hidden>
<p class="hint">Let your browser autofill this site's login, then save it to your vault.</p>
<div class="row vault-actions">
<button type="button" id="human-capture" class="primary">Save autofilled login</button>
<button type="button" id="human-lock">Lock vault</button>
</div>
</div>
<div id="human-accounts" hidden>
<label>Saved Persims <select id="human-items"></select></label>
<div class="row"><button id="human-fill" class="primary" disabled>Fill</button><button id="human-delete" class="danger" disabled>Delete</button></div>
</div>
</section>
<details id="settings-panel" class="updates" hidden><summary>Settings</summary>
<div id="auto-lock-panel" hidden>
<label>Auto-lock <select id="auto-lock"><option value="close">On popup close</option><option value="5">After 5 minutes</option><option value="15">After 15 minutes</option><option value="60">After 60 minutes</option></select></label>
</div>
<p id="recovery" class="hint recovery" hidden>No recovery or key sync in v0.</p>
<button type="button" id="updates" class="link-button" hidden>Update available</button>
</details>
<script type="module" src="human-popup.mjs"></script></body></html>
+145
View File
@@ -0,0 +1,145 @@
import {pageTarget, metadata, publicSource, request} from './core.mjs';
const $ = id => document.getElementById(id);
let target;
let connected = false;
let busy = false;
const allowed = pattern => chrome.permissions.contains({origins: [pattern]});
const scopes = () => [...document.querySelectorAll('input[name=scope]:checked')].map(input => input.value);
const settings = () => metadata({origin: target.origin, name: $('name').value,
scopes: scopes(), minutes: Number($('minutes').value)});
function controls() {
$('connect').disabled = busy;
$('save').disabled = busy || !target || !connected;
$('squeeze').disabled = busy || !connected || !$('sources').value;
for (const button of $('juices').querySelectorAll('button')) button.disabled = busy;
}
async function run(work) {
if (busy) return;
busy = true; controls(); $('status').textContent = '';
try { await work(); }
catch { $('status').textContent = 'Operation failed safely. Refresh before retrying.'; }
finally { busy = false; controls(); }
}
async function api(path, body) {
try { return await request(path, body); }
catch (error) { $('status').textContent = error.message; return null; }
}
async function listJuice() {
$('juices').replaceChildren();
const data = await api('juice/list');
if (!data || !Array.isArray(data.juice)) {
$('juice-status').textContent = 'Juice unavailable. Saved sources remain in My vault.';
return;
}
$('juice-status').textContent = 'Juice · My vault';
for (const item of data.juice.slice(0, 100)) {
if (item.vault_kind !== 'user' || typeof item.id !== 'string' || !/^[A-Za-z0-9_-]{1,100}$/.test(item.id)) continue;
const id = item.id;
const row = document.createElement('li');
if (!['active', 'revoked'].includes(item.status)) continue;
const status = item.status;
row.append(document.createTextNode(`Juice ${id} · ${status} `));
if (status === 'revoked') { $('juices').append(row); continue; }
const button = document.createElement('button');
button.textContent = 'Revoke';
button.onclick = () => run(async () => {
const result = await api('juice/revoke', {id, vault_kind: 'user'});
if (result) {
await listJuice();
$('status').textContent = 'Juice revoke request accepted. Source credentials remain in My vault.';
}
});
row.append(button); $('juices').append(row);
}
}
async function refresh() {
connected = false;
$('sources').replaceChildren(new Option('Choose a saved source', ''));
$('juices').replaceChildren();
if (!await allowed('https://teahub.xyz/*')) {
$('vault-status').textContent = 'Connect Teahub to list My vault.';
return;
}
const data = await api('list');
if (!data || !Array.isArray(data.connections)) {
$('vault-status').textContent = 'My vault unavailable. Sign in to Teahub and refresh.';
return;
}
connected = true;
for (const item of data.connections.slice(0, 200)) {
if (item.vault_kind !== 'user') continue;
try {
const source = publicSource(item);
$('sources').add(new Option(source.name, source.id));
} catch { /* Ignore malformed public metadata. */ }
}
$('vault-status').textContent = 'Connected · My vault';
await listJuice();
}
$('connect').onclick = () => {
// Request directly in the gesture, before any async work.
const grant = chrome.permissions.request({origins: ['https://teahub.xyz/*']});
run(async () => {
if (await grant) await refresh();
else $('status').textContent = 'Teahub permission was not granted.';
});
};
$('sources').onchange = controls;
$('save-form').onsubmit = event => {
event.preventDefault();
run(async () => {
let credential = '';
try {
const meta = settings();
if (!connected) return;
credential = $('token').value;
$('token').value = '';
if (!credential || new TextEncoder().encode(credential).length > 16384) {
$('status').textContent = 'Provide a credential of at most 16 KiB.'; return;
}
const result = await api('create', {...meta, credential});
credential = '';
if (!result) return;
const source = publicSource(result);
await refresh();
$('sources').value = source.id;
$('status').textContent = 'Saved to My vault. Only public source metadata is retained.';
} finally { credential = ''; $('token').value = ''; }
});
};
$('squeeze').onclick = () => run(async () => {
const source_id = $('sources').value;
const minutes = Number($('minutes').value);
const subject = $('subject').value.trim();
if (!source_id || !subject || subject.length > 200 || !scopes().length || !Number.isInteger(minutes) || minutes < 1 || minutes > 60) {
$('status').textContent = 'Choose a source, enter a public subject, select at least one scope and use 1–60 minutes.';
return;
}
const result = await api('juice/create', {source_id, subject, vault_kind: 'user', scopes: scopes(),
expires_at: new Date(Date.now() + minutes * 60000).toISOString()});
if (result) {
await listJuice();
$('status').textContent = 'Juice policy saved. Downstream scope and expiry enforcement is not yet available.';
}
});
window.addEventListener('pagehide', () => { $('token').value = ''; });
await run(async () => {
try {
const [tab] = await chrome.tabs.query({active: true, currentWindow: true});
target = pageTarget(tab.url);
$('origin').textContent = target.origin;
} catch { $('origin').textContent = 'Use an HTTPS website tab.'; }
// Juice requests begin only when its secondary disclosure is opened.
});
$('juice-panel').addEventListener('toggle', () => {
if ($('juice-panel').open && !$('juice-panel').hidden) run(refresh);
});
+21
View File
@@ -0,0 +1,21 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {CONNECTION, VAULT_CAPABILITY} from '../connection-state.mjs';
test('page handshake is exact-origin, read-only, owner-bound and live-validated', async () => {
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,[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'};
const sender={origin:'https://persimmons.app',url:'https://persimmons.app/',frameId:0};
assert.deepEqual(await connectionStatus(message,sender),{connected:true,version:'0.3.4',initialized:false,unlocked:false,empty:true});
for(const bad of [{...sender,origin:'https://evil.example'},{...sender,url:'https://evil.example/'},{...sender,frameId:1}]) assert.equal(await connectionStatus(message,bad),null);
assert.equal(await connectionStatus({...message,type:'unlock'},sender),null);
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);
valid=false; assert.equal((await connectionStatus(message,sender)).connected,false);
hint=null; assert.equal((await connectionStatus(message,sender)).connected,false);
});
+72
View File
@@ -0,0 +1,72 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFile, readdir} from 'node:fs/promises';
import {pageTarget, metadata, publicSource, request, DEFAULT_SCOPES, BASE} from '../core.mjs';
const target = pageTarget('https://wp.phly.cloud/wp-admin/');
const response = (data, status = 200, type = 'application/json') => new Response(JSON.stringify(data), {status, headers: {'content-type': type}});
test('HTTPS origin only; no path/query exposed as origin', () => {
assert.equal(target.origin, 'https://wp.phly.cloud');
assert.deepEqual(pageTarget('https://wp.phly.cloud/private?token=example#fragment'), {origin: 'https://wp.phly.cloud'});
for (const url of ['http://example.org', 'chrome://extensions', 'file:///tmp/a', 'https://user:pass@example.org']) assert.throws(() => pageTarget(url));
});
test('My vault metadata does not pretend to attenuate provider authority', () => {
const meta = metadata({origin: target.origin, name: 'Site session', scopes: DEFAULT_SCOPES, minutes: 60}, 0);
assert.equal(meta.vault_kind, 'user');
assert.equal(meta.expires, undefined);
assert.deepEqual(meta.scopes, ['content:read', 'content:draft-write']);
assert.equal(meta.credential_type, 'api-token');
assert.throws(() => metadata({...meta, origin: target.origin, scopes: ['admin'], minutes: 60}));
});
test('public source projection retains id/name only, rejects repository context', () => {
assert.deepEqual(publicSource({id: 'ABC123', name: 'Site', vault_kind: 'user', credential: 'SENSITIVE_FIXTURE'}), {id: 'ABC123', name: 'Site'});
assert.throws(() => publicSource({id: 'ABC123', name: 'Site', vault_kind: 'repository'}));
});
test('exact create route, browser authority and safe request settings', async () => {
await request('create', {vault_kind: 'user', credential: 'SENSITIVE_FIXTURE'}, async (url, options) => {
assert.equal(url, `${BASE}/create`);
assert.equal(options.credentials, 'include');
assert.equal(options.redirect, 'error');
assert.equal(options.cache, 'no-store');
assert.equal(options.method, 'POST');
assert.equal(JSON.parse(options.body).vault_kind, 'user');
return response({id: 'ABC123', name: 'Site', vault_kind: 'user'});
});
for (const path of ['juice/create', 'juice/revoke']) await request(path, {vault_kind: 'user'}, async url => {
assert.equal(url, `${BASE}/${path}`); return response({ok: true});
});
});
test('missing Juice, rejected auth, redirects/HTML, echoed secrets and timeouts are safe', async () => {
for (const [status, expected] of [[404, /Juice unavailable/], [503, /Juice unavailable/], [401, /Sign in/], [403, /Sign in/]]) {
await assert.rejects(request('juice/create', {}, async () => response({error: 'SENSITIVE_FIXTURE'}, status)), expected);
}
await assert.rejects(request('list', undefined, async () => response('SENSITIVE_FIXTURE', 200, 'text/html')), /expected JSON/);
await assert.rejects(request('create', {}, async () => response({error: 'SENSITIVE_FIXTURE'})), /did not confirm/);
await assert.rejects(request('create', {}, async () => { throw new Error('SENSITIVE_FIXTURE'); }), /unreachable or timed out/);
let called = false;
await assert.rejects(request('create', {credential: '\\'.repeat(16384)}, async () => { called = true; }), /24 KiB/);
assert.equal(called, false);
});
test('legacy token module still has no persistence or injection; MV3 exposure is bounded', async () => {
const root = new URL('../', import.meta.url);
const manifest = JSON.parse(await readFile(new URL('manifest.json', root), 'utf8'));
assert.equal(manifest.manifest_version, 3);
assert.deepEqual(manifest.permissions, ['activeTab', 'scripting', 'storage']);
assert.deepEqual(manifest.host_permissions, ['https://teahub.xyz/*']);
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);
assert.deepEqual(manifest.externally_connectable, {matches:['https://persimmons.app/*']});
for (const name of await readdir(root)) {
if (!/\.(mjs|html)$/.test(name)) continue;
const source = await readFile(new URL(name, root), 'utf8');
assert.doesNotMatch(source, /console\.|chrome\.cookies|localStorage|sessionStorage|indexedDB|innerHTML|eval\(/);
if (['core.mjs', 'popup.mjs'].includes(name)) assert.doesNotMatch(source, /chrome\.scripting|chrome\.storage/);
}
const html = await readFile(new URL('popup.html', root), 'utf8');
assert.match(html, /human-popup\.mjs/);
assert.doesNotMatch(html, /juice-panel|value="content:read"|value="content:draft-write"/);
for (const scope of ['manage:users', 'billing', 'destructive']) assert.doesNotMatch(html, new RegExp(`value="${scope}" checked`));
});
+61
View File
@@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
test('homepage derives one onboarding state and never renders up-to-date copy', async () => {
const nodes = new Map();
const node = id => {
if (!nodes.has(id)) nodes.set(id,{
hidden:false,textContent:'',innerHTML:'',open:false,
addEventListener(){},append(){},appendChild(){},replaceChildren(){},style:{}
});
return nodes.get(id);
};
globalThis.document={
hidden:false,
getElementById:node,
createElement:tag=>({
tagName:tag.toUpperCase(),hidden:false,textContent:'',innerHTML:'',style:{},open:false,
setAttribute(){},append(){},appendChild(){},replaceChildren(){},showModal(){this.open=true},close(){this.open=false},
addEventListener(){},querySelector(){return {value:'',disabled:false}}
}),
body:{append(){}}
};
globalThis.window={addEventListener(){}};
globalThis.setInterval=()=>0;
let installed={connected:true,initialized:true,unlocked:true,empty:true,version:'0.4.4'};
let signedIn=true, latest='0.4.4';
globalThis.chrome={runtime:{sendMessage:(id,message,callback)=>callback(installed),lastError:null}};
globalThis.fetch=async url=>new Response(JSON.stringify(url==='/api/session'?{owner:'fixture'}:{version:latest}),{
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'))
.replace('./version.mjs',new URL('../version.mjs',import.meta.url).href);
let sequence=0;
async function render(){
await import('data:text/javascript,'+encodeURIComponent(source+'\n//'+sequence++));
await new Promise(resolve=>setImmediate(resolve));
}
await render();
assert.equal(node('extensionState').textContent,'🔓');
assert.equal(node('updateExtension').hidden,true);
assert.ok(![...nodes.values()].some(n=>n.textContent==='Up to date'));
installed={connected:true,initialized:true,unlocked:false,version:'0.4.4'}; await render();
assert.equal(node('extensionState').textContent,'🔒');
installed={connected:false,version:'0.4.3'}; await render();
assert.equal(node('onboardingHelp').textContent,'Open 🍊 and choose Link vault.');
assert.equal(node('updateExtension').hidden,false);
signedIn=false; await render();
assert.equal(node('signInLink').hidden,false);
assert.equal(node('createAccount').hidden,false);
assert.equal(node('updateExtension').hidden,true);
signedIn=true; installed=null; await render();
assert.equal(node('getExtension').hidden,false);
});
+59
View File
@@ -0,0 +1,59 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {createHash} from 'node:crypto';
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 () => {
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}"`));
});
test('exact HTTPS origins are data while extension origin remains the caller boundary', () => {
for (const origin of ['https://example.com','https://accounts.example.com','https://example.com:8443']) assert.equal(isHttpsOrigin(origin),true);
for (const origin of ['http://example.com','https://example.com/path','https://user@example.com','not-a-url']) assert.equal(isHttpsOrigin(origin),false);
});
test('local wrapping and authenticated ciphertext work for ordinary HTTPS sites', async () => {
const passphrase = 'disposable test passphrase';
const owner = 'fixture';
const record = await initialize(passphrase, owner);
assert.ok(!JSON.stringify(record).includes(passphrase));
const key = await unlock(passphrase, owner, record);
assert.equal(key.extractable, false);
await assert.rejects(crypto.subtle.exportKey('raw', key));
await assert.rejects(unlock('wrong passphrase', owner, record), /Unlock failed/);
const account = {username: 'DISPOSABLE_USER_SENTINEL', password: 'DISPOSABLE_PASSWORD_SENTINEL'};
const item = await encryptItem(key, owner, 'https://example.com', account);
assert.deepEqual(await decryptItem(key, owner, item), account);
assert.equal(item.username, account.username);
assert.ok(!JSON.stringify(item).includes(account.password));
const updated = await encryptItem(key, owner, item.origin, account, item.id);
assert.notEqual(updated.iv, item.iv);
assert.notEqual(updated.ciphertext, item.ciphertext);
for (const bad of [{...item,id:'0'.repeat(32)},{...item,origin:'https://other.example'},{...item,v:2}])
await assert.rejects(decryptItem(key, owner, bad), /failed authentication/);
});
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, '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'}});
});
for (const status of [401,403,500]) await assert.rejects(
vaultRequest('GET', undefined, undefined, undefined, async()=>new Response('PRIVATE_SENTINEL',{status})),
/Vault unavailable/
);
});
+54
View File
@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import {captureAccount, fillAccount} from '../human-api.mjs';
test('popup is only the human vault v0 surface', async () => {
const html = await readFile(new URL('../popup.html', import.meta.url), 'utf8');
assert.match(html, /My vault/);
assert.match(html, /Save autofilled login/);
assert.match(html, /browser autofill this site's login/);
assert.match(html, /Lock vault/);
assert.match(html, /Settings/);
assert.doesNotMatch(html, /id="juice-panel"/);
assert.doesNotMatch(html, /id="human-username"/);
assert.doesNotMatch(html, /id="human-password"/);
assert.doesNotMatch(html, /<iframe/);
assert.ok(!html.includes('src="popup.mjs"'));
});
test('linking mints a capability without a host permission 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, /\[VAULT_CAPABILITY\]/);
assert.doesNotMatch(source, /chrome\.permissions\.request/);
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) => ({
type,value,disabled:false,readOnly:false,form:null,
getClientRects:()=>[{}],dispatchEvent(){}
});
const user=makeInput('email','person@example.com'), password=makeInput('password','secret');
const form={action:'https://example.com/login',querySelectorAll:()=>[user,password]};
user.form=form;password.form=form;
globalThis.location={protocol:'https:',origin:'https://example.com',href:'https://example.com/login'};
globalThis.window={top:null};globalThis.window.top=globalThis.window;
globalThis.document={querySelectorAll:selector=>selector==='input[type="password"]'?[password]:[user,password]};
globalThis.HTMLInputElement=function(){};
globalThis.HTMLInputElement.prototype={};
Object.defineProperty(globalThis.HTMLInputElement.prototype,'value',{set(v){this._set=v},configurable:true});
globalThis.Event=class { constructor(type,opts){this.type=type;this.opts=opts} };
try {
assert.deepEqual(captureAccount('https://example.com'),{username:'person@example.com',password:'secret'});
assert.equal(captureAccount('https://other.example'),null);
assert.equal(fillAccount('https://example.com',{username:'u',password:'p'}),true);
} finally {
Object.assign(globalThis,old);
}
});
+79
View File
@@ -0,0 +1,79 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {runJuice, giteaToken, checkedScopes} from '../juice.mjs';
const sourceId = 'a'.repeat(32), juiceId = 'b'.repeat(32), oldId = 'c'.repeat(32);
const sender = {origin:'https://persimmons.app',url:'https://persimmons.app/',frameId:0};
const message = {type:'persimmons.juice.v1',action:'create',owner:'pg-2',source_id:sourceId,target:{id:1,name:'PackPack'},policy:{scopes:['read:repository']}};
const raw = 'RAW-SOURCE-NEVER-PAGE', pat = 'd'.repeat(40);
function fixture() {
const calls = [], provider = [], account = {username:'source-fixture',password:raw};
const deps = {
id:()=>juiceId, live:async()=>{}, unlocked:async()=>{},
source:async()=>({id:sourceId,origin:'https://teahub.xyz',ciphertext:'opaque'}),
use:async(owner,item,fn)=>{try{return await fn(account,()=>{});}finally{account.password='';}},
request:async(op,owner,data)=>{calls.push({op,data});return {items:[{id:oldId,source_id:sourceId,source_origin:'https://teahub.xyz',source_username:'source-fixture',scopes:['read:repository'],status:'active'}]};},
provider:async(origin,account,name,scopes)=>{provider.push({origin,name,scopes});return scopes?pat:true;}
};
return {deps,calls,provider,account};
}
test('create sends only the derived PAT to write-only backend, never to page or metadata',async()=>{
const f=fixture();const result=await runJuice(message,sender,f.deps);
assert.deepEqual(result,{ok:true,id:juiceId,status:'active'});
assert.doesNotMatch(JSON.stringify(result),new RegExp(raw+'|'+pat));
assert.deepEqual(f.calls.map(x=>x.op),['prepare','write']);
assert.equal(f.calls[1].data.pat,pat);
assert.doesNotMatch(JSON.stringify(f.calls),new RegExp(raw));
assert.doesNotMatch(JSON.stringify(f.calls[0]),new RegExp(pat));
assert.equal(f.account.password,'');
});
test('top frame, origin, owner, unlock and scope gates reject before provider mutation',async()=>{
for(const change of [{origin:'https://evil.invalid'},{url:'https://evil.invalid'},{frameId:1},{url:'invalid'}]){
const f=fixture();assert.equal(await runJuice(message,{...sender,...change},f.deps),null);assert.equal(f.calls.length,0);
}
for(const gate of ['live','unlocked']){
const f=fixture();f.deps[gate]=async()=>{throw new Error(raw)};
assert.equal((await runJuice(message,sender,f.deps)).ok,false);assert.equal(f.provider.length,0);
}
for(const scopes of [[],['all'],['write:repository'],['read:issue','write:issue'],['read:user'],['read:repository','read:repository']]) assert.throws(()=>checkedScopes(scopes));
const f=fixture();f.deps.source=async()=>({origin:'https://evil.invalid'});
assert.equal((await runJuice(message,sender,f.deps)).message,'Juice adapter not available yet.');assert.equal(f.calls.length,0);
});
test('failed or uncertain write/create compensates by deterministic source token name',async()=>{
for(const failure of ['write','provider']) {
const f=fixture();const request=f.deps.request,provider=f.deps.provider;
f.deps.request=async(op,...args)=>{if(op==='write'&&failure==='write')throw new Error(pat);return request(op,...args)};
f.deps.provider=async(...args)=>{if(args[3]&&failure==='provider'){await provider(...args);throw new Error(raw)}return provider(...args)};
const result=await runJuice(message,sender,f.deps);
assert.equal(result.ok,false);assert.equal(result.status,'revoked');
assert.equal(f.provider.at(-1).name,'persimmons-juice-'+juiceId);assert.equal(f.provider.at(-1).scopes,undefined);
assert.equal(f.calls.at(-1).data.provider_status,'revoked');assert.doesNotMatch(JSON.stringify(result),new RegExp(raw+'|'+pat));
}
});
test('unconfirmed rollback is explicit ANDON; rotation revokes old only after replacement stored',async()=>{
const f=fixture(),request=f.deps.request;
f.deps.request=async(op,...args)=>{if(op==='write')throw new Error();return request(op,...args)};
f.deps.provider=async(o,a,n,s)=>{if(!s)throw new Error();return pat};
const failed=await runJuice(message,sender,f.deps);
assert.equal(failed.status,'partial-failure');assert.match(failed.message,/ANDON/);
const r=fixture();const result=await runJuice({type:message.type,action:'rotate',owner:message.owner,target:message.target,id:oldId},sender,r.deps);
assert.equal(result.ok,true);assert.deepEqual(r.calls.map(c=>c.op),['list','prepare','write','remove','cleanup']);
assert.equal(r.provider.at(-1).name,'persimmons-juice-'+oldId);
});
test('locked revoke still removes target, records provider partial revoke truthfully',async()=>{
const f=fixture();f.deps.unlocked=async()=>{throw new Error()};
const result=await runJuice({type:message.type,action:'revoke',owner:message.owner,target:message.target,id:oldId},sender,f.deps);
assert.equal(result.status,'partial-failure');assert.match(result.message,/ANDON/);
assert.deepEqual(f.calls.map(c=>c.op),['list','remove','cleanup']);assert.equal(f.provider.length,0);
assert.equal(f.calls.at(-1).data.provider_status,'unknown');
});
test('Gitea requests are pinned Basic auth, no cookies/redirects; responses do not escape',async()=>{
const account={username:'fixture',password:raw};
for(const origin of ['https://teahub.xyz']){
const value=await giteaToken(origin,account,'persimmons-juice-'+juiceId,['read:repository'],async(url,opts)=>{
assert.equal(url,origin+'/api/v1/users/fixture/tokens');assert.equal(opts.credentials,'omit');assert.equal(opts.redirect,'error');
assert.equal(Buffer.from(opts.headers.Authorization.slice(6),'base64').toString(),'fixture:'+raw);
return Response.json({name:'persimmons-juice-'+juiceId,sha1:pat,scopes:['read:repository']},{status:201});
});assert.equal(value,pat);
}
let called=false;await assert.rejects(giteaToken('https://evil.invalid',account,'persimmons-juice-'+juiceId,['read:repository'],async()=>{called=true}));assert.equal(called,false);
});
+100
View File
@@ -0,0 +1,100 @@
import test from 'node:test';
import assert from 'node:assert/strict';
test('popup never reads cookies, saves explicit tokens, lists and revokes Juice in My vault', async () => {
class Element {
constructor(text = '', value = '') { this.textContent = text; this.value = value; this.children = []; this.disabled = false; }
addEventListener(name, listener) { this[name] = listener; }
append(...items) { this.children.push(...items); }
add(item) { this.children.push(item); }
replaceChildren(...items) { this.children = items; this.value = ''; }
querySelectorAll() { return this.children.flatMap(c => c.children || []); }
}
const ids = ['juice-panel', 'origin', 'auth', 'connect', 'save', 'squeeze', 'status', 'vault-status',
'juice-status', 'sources', 'juices', 'name', 'minutes', 'token', 'token-label', 'save-form', 'subject'];
const nodes = Object.fromEntries(ids.map(id => [id, new Element()]));
nodes.minutes.value = '60'; nodes.name.value = 'Site session';
nodes.subject.value = 'Draft assistant';
const grants = new Set();
const permissionRequests = []; const calls = [];
let grantNext = false; let cookieReads = 0; let juiceAvailable = false;
let sourceExists = false;
const publicItem = {id: 'ABC123', name: 'SITE_SESSION', vault_kind: 'user'};
globalThis.document = {getElementById: id => nodes[id],
querySelectorAll: () => [{value: 'content:read'}, {value: 'content:draft-write'}],
createElement: () => new Element(), createTextNode: text => new Element(text)};
globalThis.Option = Element;
globalThis.window = {addEventListener() {}};
globalThis.chrome = {
tabs: {query: async () => [{url: 'https://wp.phly.cloud/wp-admin/'}]},
permissions: {
contains: async ({origins}) => origins.every(origin => grants.has(origin)),
request: async ({origins}) => {
permissionRequests.push(origins);
if (grantNext) origins.forEach(origin => grants.add(origin));
return grantNext;
}
},
cookies: {getAll: async () => { cookieReads++; throw new Error('Cookie API must never be used'); }}
};
globalThis.fetch = async (url, options) => {
const path = url.split('/persimmons/')[1];
const body = options.body ? JSON.parse(options.body) : undefined;
calls.push({path, body});
let data; let status = 200;
if (path === 'list') data = {connections: sourceExists ? [publicItem] : []};
else if (path === 'create') { sourceExists = true; data = publicItem; }
else if (!juiceAvailable) { data = {error: 'SECRET_SERVER_FIXTURE'}; status = 404; }
else if (path === 'juice/list') data = {juice: [{id: 'JUICE123', vault_kind: 'user', status: 'active'}]};
else data = {ok: true};
return new Response(JSON.stringify(data), {status, headers: {'content-type': 'application/json'}});
};
const settle = async () => {
for (let i = 0; i < 100 && nodes.connect.disabled; i++) await new Promise(resolve => setImmediate(resolve));
assert.equal(nodes.connect.disabled, false, 'operation completed');
};
await import('../popup.mjs');
assert.equal(nodes.origin.textContent, 'https://wp.phly.cloud');
assert.equal(cookieReads, 0); assert.equal(calls.length, 0);
nodes.connect.onclick(); await settle();
assert.equal(calls.length, 0); assert.match(nodes.status.textContent, /not granted/);
grantNext = true;
nodes.connect.onclick(); await settle();
assert.deepEqual(calls.map(c => c.path), ['list', 'juice/list']);
assert.equal(cookieReads, 0); assert.equal(nodes.save.disabled, false);
nodes.token.value = 'SYNTHETIC_TOKEN';
nodes['save-form'].onsubmit({preventDefault() {}}); await settle();
const saved = calls.find(c => c.path === 'create').body;
assert.equal(saved.vault_kind, 'user');
assert.equal(saved.account, 'https://wp.phly.cloud');
assert.equal(saved.credential, 'SYNTHETIC_TOKEN');
assert.equal(saved.credential_type, 'api-token');
assert.deepEqual(saved.scopes, ['content:read', 'content:draft-write']);
assert.equal(saved.expires, undefined);
assert.equal(nodes.sources.value, 'ABC123');
assert.equal(nodes.token.value, ''); assert.equal(cookieReads, 0);
juiceAvailable = true;
nodes.squeeze.onclick(); await settle();
assert.equal(calls.find(c => c.path === 'juice/create').body.source_id, 'ABC123');
assert.equal(calls.find(c => c.path === 'juice/create').body.subject, 'Draft assistant');
assert.ok(Math.abs(Date.parse(calls.find(c => c.path === 'juice/create').body.expires_at) - Date.now() - 3600000) < 5000);
const revoke = nodes.juices.children[0].children[1];
revoke.onclick(); await settle();
assert.deepEqual(calls.find(c => c.path === 'juice/revoke').body, {id: 'JUICE123', vault_kind: 'user'});
const flatten = node => [node.textContent, ...node.children.map(flatten)].join(' ');
assert.doesNotMatch(Object.values(nodes).map(flatten).join(' '), /SECRET_|SYNTHETIC_TOKEN/);
calls.length = 0;
await import('../popup.mjs?reopened');
assert.equal(calls.length, 0, 'reopening does not fetch secondary Juice');
nodes['juice-panel'].open = true; nodes['juice-panel'].hidden = false;
nodes['juice-panel'].toggle(); await settle();
assert.deepEqual(calls.map(c => c.path), ['list', 'juice/list']);
assert.equal(cookieReads, 0);
assert.deepEqual(permissionRequests[0], ['https://teahub.xyz/*']);
// A failed save clears the manually entered token too.
globalThis.fetch = async () => { throw new Error('SYNTHETIC_TOKEN'); };
nodes.token.value = 'SYNTHETIC_TOKEN';
nodes['save-form'].onsubmit({preventDefault() {}}); await settle();
assert.equal(nodes.token.value, '');
assert.doesNotMatch(nodes.status.textContent, /SYNTHETIC_TOKEN/);
});
+28
View File
@@ -0,0 +1,28 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {VaultSession} from '../vault-session.mjs';
import {updateState} from '../version.mjs';
test('vault session fails closed and honors bounded policies', () => {
let now = 1000;
const session = new VaultSession(() => now);
const key = {extractable:false};
session.set(key, 'owner-a', '5');
assert.equal(session.get('owner-a'), key);
assert.equal(session.get('owner-b'), null);
session.set(key, 'owner-a', '5');
now += 5 * 60000;
assert.equal(session.get('owner-a'), null);
session.set(key, 'owner-a', 'close');
session.closed();
assert.equal(session.get('owner-a'), null);
});
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'),'');
});
+58
View File
@@ -0,0 +1,58 @@
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, 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}},
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'}});
const {vaultUnlocked} = await import('../vault-worker.mjs');
function popup(url='chrome-extension://fixture/popup.html') {
let listener, close, disconnected = false, sequence = 0;
const replies = new Map();
connect({name:'human-vault',sender:{id:'fixture',url},disconnect:()=>disconnected=true,
onMessage:{addListener:fn=>listener=fn},onDisconnect:{addListener:fn=>close=fn},
postMessage:message=>{replies.get(message.id)?.(message);replies.delete(message.id)}});
return {get disconnected(){return disconnected}, close:()=>close(),
send:(op,data={})=>new Promise(resolve=>{const id=++sequence;replies.set(id,resolve);listener({id,op,owner:'owner',...data})})};
}
assert.equal(popup('https://persimmons.app/').disconnected,true);
assert.equal(popup('chrome-extension://fixture/other.html').disconnected,true);
let port = popup();
const unlock = () => port.send('unlock',{passphrase:'synthetic test passphrase'});
const firstUnlock = unlock();
await new Promise(resolve=>setImmediate(resolve));
assert.equal(vaultUnlocked('owner'),false,'status during unlock does not cancel it');
assert.equal((await firstUnlock).result,true);
assert.equal(vaultUnlocked('owner'),true);
assert.equal(vaultUnlocked('other'),false);
assert.equal(vaultUnlocked('owner'),true,'another owner status cannot lock the session');
const record=stored['human-vault:https://persimmons.app:owner'];
assert.ok(record.wrappedKey); assert.equal(Object.values(stored).some(x=>x instanceof CryptoKey),false);
assert.doesNotMatch(JSON.stringify(stored),/synthetic test passphrase/);
port.close(); port=popup();assert.equal((await port.send('status')).result,false);
stored[AUTO_LOCK]='5';assert.equal((await unlock()).result,true);
port.close();port=popup();assert.equal((await port.send('status')).result,true);
const encrypted=await port.send('encrypt',{origin:'https://wp.phly.cloud',account:{username:'fixture',password:'synthetic'}});
assert.equal(encrypted.ok,true);assert.ok(encrypted.result.ciphertext);
assert.deepEqual((await port.send('decrypt',{item:encrypted.result})).result,{username:'fixture',password:'synthetic'});
timers.at(-1)();assert.equal((await port.send('status')).result,false);
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 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.
unlock();port.close();port=popup();
await new Promise(resolve=>setImmediate(resolve));
assert.equal((await port.send('status')).result,false);
});
+17
View File
@@ -0,0 +1,17 @@
// Memory only. Nothing here is serialized to storage or exposed externally.
export const AUTO_LOCK = 'human-auto-lock';
export const policies = ['close', '5', '15', '60'];
export class VaultSession {
constructor(now = Date.now) { this.now = now; this.clear(); }
clear() { this.key = null; this.owner = null; this.deadline = 0; this.policy = 'close'; this.epoch = (this.epoch || 0) + 1; }
set(key, owner, policy) {
if (!policies.includes(policy) || key.extractable !== false) throw new Error('Invalid session');
this.clear(); this.key = key; this.owner = owner; this.policy = policy;
this.deadline = policy === 'close' ? Infinity : this.now() + Number(policy) * 60000;
}
get(owner) {
if (this.now() >= this.deadline || owner !== this.owner) this.clear();
return this.key;
}
closed() { if (this.policy === 'close') this.clear(); }
}
+101
View File
@@ -0,0 +1,101 @@
import {initialize, unlock, encryptItem, decryptItem} from './human-crypto.mjs';
import {vaultRequest, VaultUnavailableError} from './human-api.mjs';
import {AUTO_LOCK, policies, VaultSession} from './vault-session.mjs';
const session = new VaultSession();
// Only a boolean, after the caller has validated live ownership. Never expose keys.
export const vaultUnlocked = owner => session.owner === owner && !!session.key && !!session.get(owner);
// Internal worker callback only. Never register this as a message operation.
export async function useVaultCredential(owner, item, use) {
const key = session.get(owner), epoch = session.epoch;
if (!key) throw new Error();
let account;
const check = () => {
if (session.epoch !== epoch || !session.get(owner)) throw new Error();
};
try {
account = await decryptItem(key, owner, item);
check();
return await use(account, check);
} finally {
if (account) { account.username = ''; account.password = ''; }
account = null;
}
}
export async function vaultUsername(owner, item) {
const key = session.get(owner);
if (!key) return null;
let account = null;
try {
account = await decryptItem(key, owner, item);
return account.username;
} finally {
if (account) {
account.username = '';
account.password = '';
account = null;
}
}
}
let heartbeat, expiry;
function stop() { session.clear(); clearInterval(heartbeat); clearTimeout(expiry); heartbeat = null; }
function keepAlive() {
clearInterval(heartbeat);
if (session.policy !== 'close') expiry = setTimeout(stop, Math.max(0, session.deadline - Date.now()));
// Bounded Chrome API heartbeat; worker loss still fails closed. Never persist a key.
heartbeat = setInterval(() => {
if (!session.get(session.owner)) { stop(); return; }
chrome.runtime.getPlatformInfo().catch(stop);
}, 20000);
}
chrome.runtime.onConnect.addListener(port => {
if (port.name !== 'human-vault' || port.sender?.id !== chrome.runtime.id ||
port.sender?.url !== chrome.runtime.getURL('popup.html')) { port.disconnect(); return; }
let closed = false, cancellation = 0, queue = Promise.resolve();
port.onDisconnect.addListener(() => { closed = true; session.closed(); });
const handle = async (message, generation) => {
if (closed) return;
let result;
const epoch = session.epoch;
const check = () => { if (closed || epoch !== session.epoch) throw new Error(); };
try {
if (generation !== cancellation) throw new Error();
if (message.op === 'lock') { stop(); result = true; }
else {
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);
else if (message.op === 'unlock') {
stop();
const unlockEpoch = session.epoch;
const checkUnlock = () => { if (closed || session.epoch !== unlockEpoch) throw new Error(); };
const policy = (await chrome.storage.local.get(AUTO_LOCK))[AUTO_LOCK] || 'close';
if (!policies.includes(policy)) throw new Error();
const storageId = `human-vault:https://persimmons.app:${message.owner}`;
let record = (await chrome.storage.local.get(storageId))[storageId]; checkUnlock();
if (!record) {
if (data.items.length) throw new Error();
record = await initialize(message.passphrase, message.owner); checkUnlock();
await chrome.storage.local.set({[storageId]: record}); checkUnlock();
}
const key = await unlock(message.passphrase, message.owner, record); checkUnlock();
session.set(key, message.owner, policy); keepAlive(); result = true;
} else {
const key = session.get(message.owner);
if (!key) throw new Error();
const activeEpoch = session.epoch;
if (message.op === 'encrypt') result = await encryptItem(key, message.owner, message.origin, message.account);
else if (message.op === 'decrypt') result = await decryptItem(key, message.owner, message.item);
else throw new Error();
if (closed || session.epoch !== activeEpoch || !session.get(message.owner)) throw new Error();
}
}
if (!closed) port.postMessage({id: message.id, ok: true, result});
} catch (error) { stop(); if (!closed) port.postMessage({id: message.id, ok: false, unavailable: error instanceof VaultUnavailableError}); }
finally { message.passphrase = ''; message.account = null; result = null; }
};
port.onMessage.addListener(message => {
if (message.op === 'lock') { cancellation++; stop(); port.postMessage({id: message.id, ok: true, result: true}); return; }
const generation = cancellation;
queue = queue.then(() => handle(message, generation));
});
});
+10
View File
@@ -0,0 +1,10 @@
export function updateState(installed, latest) {
const parse = value => typeof value === 'string' && /^\d+(\.\d+){0,3}$/.test(value) ? value.split('.').map(Number) : null;
const a = parse(installed), b = parse(latest);
if (!a || !b) return '';
for (let i = 0; i < 4; i++) {
if ((a[i] || 0) < (b[i] || 0)) return 'Update available';
if ((a[i] || 0) > (b[i] || 0)) return '';
}
return '';
}