commit 0e92a80b20e6165d9518bc323cb1729be074ec0e Author: teahubxyz Date: Wed Oct 14 00:22:00 2026 -0700 port: from the old factory (tea-mcp/src/persimmons-extension), as it was, 2026-10-14 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 0000000..c973836 Binary files /dev/null and b/icons/orange-128.png differ diff --git a/icons/orange-16.png b/icons/orange-16.png new file mode 100644 index 0000000..9495004 Binary files /dev/null and b/icons/orange-16.png differ diff --git a/icons/orange-32.png b/icons/orange-32.png new file mode 100644 index 0000000..00cc355 Binary files /dev/null and b/icons/orange-32.png differ diff --git a/icons/orange-48.png b/icons/orange-48.png new file mode 100644 index 0000000..8569a7c Binary files /dev/null and b/icons/orange-48.png differ 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, /