From 0e92a80b20e6165d9518bc323cb1729be074ec0e Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Wed, 14 Oct 2026 00:22:00 -0700 Subject: [PATCH] port: from the old factory (tea-mcp/src/persimmons-extension), as it was, 2026-10-14 --- README.md | 294 ++++++++++++++++++++++++++++++++++++ connection-state.mjs | 4 + connection.mjs | 77 ++++++++++ core.mjs | 49 ++++++ human-api.mjs | 61 ++++++++ human-crypto.mjs | 84 +++++++++++ human-popup.mjs | 290 +++++++++++++++++++++++++++++++++++ icons/orange-128.png | Bin 0 -> 8911 bytes icons/orange-16.png | Bin 0 -> 651 bytes icons/orange-32.png | Bin 0 -> 1461 bytes icons/orange-48.png | Bin 0 -> 2463 bytes icons/orange.svg | 7 + identity.mjs | 2 + juice.mjs | 149 ++++++++++++++++++ manifest.json | 44 ++++++ popup.css | 35 +++++ popup.html | 43 ++++++ popup.mjs | 145 ++++++++++++++++++ tests/connection.test.mjs | 21 +++ tests/core.test.mjs | 72 +++++++++ tests/homepage.test.mjs | 61 ++++++++ tests/human-crypto.test.mjs | 59 ++++++++ tests/human-popup.test.mjs | 54 +++++++ tests/juice.test.mjs | 79 ++++++++++ tests/popup.test.mjs | 100 ++++++++++++ tests/session.test.mjs | 28 ++++ tests/worker.test.mjs | 58 +++++++ vault-session.mjs | 17 +++ vault-worker.mjs | 101 +++++++++++++ version.mjs | 10 ++ 30 files changed, 1944 insertions(+) create mode 100644 README.md create mode 100644 connection-state.mjs create mode 100644 connection.mjs create mode 100644 core.mjs create mode 100644 human-api.mjs create mode 100644 human-crypto.mjs create mode 100644 human-popup.mjs create mode 100644 icons/orange-128.png create mode 100644 icons/orange-16.png create mode 100644 icons/orange-32.png create mode 100644 icons/orange-48.png create mode 100644 icons/orange.svg create mode 100644 identity.mjs create mode 100644 juice.mjs create mode 100644 manifest.json create mode 100644 popup.css create mode 100644 popup.html create mode 100644 popup.mjs create mode 100644 tests/connection.test.mjs create mode 100644 tests/core.test.mjs create mode 100644 tests/homepage.test.mjs create mode 100644 tests/human-crypto.test.mjs create mode 100644 tests/human-popup.test.mjs create mode 100644 tests/juice.test.mjs create mode 100644 tests/popup.test.mjs create mode 100644 tests/session.test.mjs create mode 100644 tests/worker.test.mjs create mode 100644 vault-session.mjs create mode 100644 vault-worker.mjs create mode 100644 version.mjs diff --git a/README.md b/README.md new file mode 100644 index 0000000..c2c607c --- /dev/null +++ b/README.md @@ -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. diff --git a/connection-state.mjs b/connection-state.mjs new file mode 100644 index 0000000..69c6814 --- /dev/null +++ b/connection-state.mjs @@ -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'; diff --git a/connection.mjs b/connection.mjs new file mode 100644 index 0000000..b3934ce --- /dev/null +++ b/connection.mjs @@ -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; +}); diff --git a/core.mjs b/core.mjs new file mode 100644 index 0000000..1494f05 --- /dev/null +++ b/core.mjs @@ -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.'); + } +} diff --git a/human-api.mjs b/human-api.mjs new file mode 100644 index 0000000..b1c9601 --- /dev/null +++ b/human-api.mjs @@ -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; } +} diff --git a/human-crypto.mjs b/human-crypto.mjs new file mode 100644 index 0000000..a2ad338 --- /dev/null +++ b/human-crypto.mjs @@ -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; } +} diff --git a/human-popup.mjs b/human-popup.mjs new file mode 100644 index 0000000..a03980d --- /dev/null +++ b/human-popup.mjs @@ -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); diff --git a/icons/orange-128.png b/icons/orange-128.png new file mode 100644 index 0000000000000000000000000000000000000000..c973836edfd4dc28d795910bc08bd235cec927c4 GIT binary patch literal 8911 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 P00000NkvXXu0mjfmi)Q| literal 0 HcmV?d00001 diff --git a/icons/orange-48.png b/icons/orange-48.png new file mode 100644 index 0000000000000000000000000000000000000000..8569a7cd25223679b712aef7c14666f597a3a235 GIT binary patch literal 2463 zcmV;Q31Ie#P)wOWh5tBPb{k&Fe}F zV2+gerD2J~{QS^;>I!7+%i<{@eIByy)&*GCT1}ta;){|gq zu0?Tdfv5sYz7`cCna5tnVx3r}`>c7Y+|apV6gh(`xun$R$L{LexgY}P9^JrwjM`#v zYHsG-2cnFG0j3P-I3uA#5}vdYi;3I0`*{mS%5e}&nz@2tuHd$@ANGB9E}S)EU~0z| zJ%y-vjKQ=Tutw8lWWh-WMgF=2MeGKOAP7*M57Z=-CMMWjc#et0hVlI{ptX$!k%%-# z<>b>3lYD2b)TRtP~bGr2=X0Yf`V5fs(TQLdFrh`}A$y z%jyz{2ye0v@sG9#FZnK&ij!B{N}Dars?<#|TWCGmZ}24CoP5f4nzu3~0|EI!Tc9pV zl)Any0p9#Ya5uAc$=y_ZoF8SvqIUzRuC+{}M||28Am_tiD=av2VEx$_H_s)#AB2jg zlqv-CkTkFR!n_3BJ+BAy55+7n4fsL}#20^xcNGO>`8^kyS}JUU`Qn6_DnW`GiN(@@ z&y#@E0wIaO1CXw(B#+5Mn?Y zgRJB%kxFNwR5>23Q3wPU$;*%X2&-vv?=Cbs!-pGxzcqve&_p3&~YZWL_#3^kRx9au7HFGQ5ZgeMcY6NLMS97po*W0#k3)(|*dlYl~91T@HeK%rF9 zr7LW&VxRx^8=_%b540H6#jCKPECE!xQKif*EN&#=(Sh~fBV}&ImpbOO0K36SiqJV3 z_clu0AnxpOL9IbgoKCw?$@@Ol`c#M_y(Uw4sW=Dt!%a9Ri6L_P#9;Z@VK^sqZXZtC z29KOj$cMg)K3%o6%R|mhs;&j?7A)QP5l4|19b1sjl%&n_5dwVdYN_x6+qVgYpEF(e`1vvOao zOMn;c=e++lTf|(KnH)I`Gx%)0 z-U5|i!|RfjW{T4j&sO5C>V;MgzCOt}dQKZh{x)Sw)v_A3DHG)sCEb9^8Zhv^ zi`I_FynK0e-~XZ03V^h%>~n~k{g+CCq(JDlk3 zeVl9e#r?BbptMo*?SRip5LhD2K7oaV#Biz9%Gy6$6X(3|g~oo=yOF)%Vu30>9+dl{ea%?MMZ`Ji1rNTraPAcfanzI*EBxiHR4z>HIO^loO{&qe0v zB$azx0O<#pLeoAztOMW-LX>L@!dRe+m~dHVTA|vs_74+7^Wm#Yz>MMByEid`y{!4Z zQ1fXI%{5MsPJuz>gE(U-2cj#7`N(W);37 z@!cp;aK=@c=2MHHP0N`j&C zWdx;Ji72y3W&z?~Jk>K%L_5~}?(BkohFvg$a2&sN`G8K~I?h&`*ey6t)N%&b$+BCD zni^$GOz_htxJbRpFMywC8h>BAd+H_lfaAOn_%O!>|DQcBcmnSO00960XJ5rN00006 dNklpj! literal 0 HcmV?d00001 diff --git a/icons/orange.svg b/icons/orange.svg new file mode 100644 index 0000000..aa07a7e --- /dev/null +++ b/icons/orange.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/identity.mjs b/identity.mjs new file mode 100644 index 0000000..cc4ad97 --- /dev/null +++ b/identity.mjs @@ -0,0 +1,2 @@ +// Derived from manifest.key; keep in sync with the server exact Origin. +export const EXTENSION_ID = 'kiolgjoohhgdohcafgelhikpgkbmckbg'; diff --git a/juice.mjs b/juice.mjs new file mode 100644 index 0000000..ce0a4b8 --- /dev/null +++ b/juice.mjs @@ -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.'}; + } +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..d41dffc --- /dev/null +++ b/manifest.json @@ -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/*" + ] +} diff --git a/popup.css b/popup.css new file mode 100644 index 0000000..590c952 --- /dev/null +++ b/popup.css @@ -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; } diff --git a/popup.html b/popup.html new file mode 100644 index 0000000..e226a83 --- /dev/null +++ b/popup.html @@ -0,0 +1,43 @@ + + +Persimmons + +

Persimmons

β†—
+
Current siteChecking…
+
+

My vault

Not connected
+ + +
+ + +
+ + + + + + +
+ + + diff --git a/popup.mjs b/popup.mjs new file mode 100644 index 0000000..5061744 --- /dev/null +++ b/popup.mjs @@ -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); +}); diff --git a/tests/connection.test.mjs b/tests/connection.test.mjs new file mode 100644 index 0000000..eb6fbbd --- /dev/null +++ b/tests/connection.test.mjs @@ -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); +}); diff --git a/tests/core.test.mjs b/tests/core.test.mjs new file mode 100644 index 0000000..8066e78 --- /dev/null +++ b/tests/core.test.mjs @@ -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`)); +}); diff --git a/tests/homepage.test.mjs b/tests/homepage.test.mjs new file mode 100644 index 0000000..fa4173c --- /dev/null +++ b/tests/homepage.test.mjs @@ -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); +}); diff --git a/tests/human-crypto.test.mjs b/tests/human-crypto.test.mjs new file mode 100644 index 0000000..6c80e77 --- /dev/null +++ b/tests/human-crypto.test.mjs @@ -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/ + ); +}); diff --git a/tests/human-popup.test.mjs b/tests/human-popup.test.mjs new file mode 100644 index 0000000..558eaea --- /dev/null +++ b/tests/human-popup.test.mjs @@ -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, /