Archived
port: from the old factory (tea-mcp/src/persimmons-extension), as it was, 2026-10-14
This commit is contained in:
commit
0e92a80b20
30 files changed
+1944
No files matched your search
@@ -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.
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// All secret cryptography stays in the extension. Server stores opaque blobs.
|
||||
const utf8 = new TextEncoder();
|
||||
const decode = new TextDecoder('utf-8', {fatal: true});
|
||||
const b64 = value => btoa(String.fromCharCode(...new Uint8Array(value)));
|
||||
const unb64 = value => Uint8Array.from(atob(value), c => c.charCodeAt(0));
|
||||
const random = size => crypto.getRandomValues(new Uint8Array(size));
|
||||
const ITERATIONS = 600000;
|
||||
|
||||
export function isHttpsOrigin(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'https:' && url.origin === value && !url.username && !url.password;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
const wrapAAD = owner => utf8.encode(JSON.stringify(['persimmons-wrap', 1, owner]));
|
||||
const itemAAD = (owner, item) => utf8.encode(JSON.stringify(['persimmons-item', item.v, owner, item.id, item.origin]));
|
||||
|
||||
async function wrappingKey(passphrase, salt) {
|
||||
let bytes = utf8.encode(passphrase);
|
||||
try {
|
||||
const material = await crypto.subtle.importKey('raw', bytes, 'PBKDF2', false, ['deriveKey']);
|
||||
return await crypto.subtle.deriveKey({name: 'PBKDF2', hash: 'SHA-256', salt, iterations: ITERATIONS},
|
||||
material, {name: 'AES-GCM', length: 256}, false, ['wrapKey', 'unwrapKey']);
|
||||
} finally { bytes.fill(0); bytes = null; }
|
||||
}
|
||||
|
||||
export async function initialize(passphrase, owner) {
|
||||
if (passphrase.length < 12 || passphrase.length > 1024) throw new Error('Use a passphrase of 12–1024 characters.');
|
||||
const salt = random(16), iv = random(12);
|
||||
let generated = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
|
||||
let wrapping = await wrappingKey(passphrase, salt);
|
||||
try {
|
||||
const wrapped = await crypto.subtle.wrapKey('raw', generated, wrapping,
|
||||
{name: 'AES-GCM', iv, additionalData: wrapAAD(owner), tagLength: 128});
|
||||
return {v: 1, kdf: 'PBKDF2', hash: 'SHA-256', iterations: ITERATIONS,
|
||||
salt: b64(salt), iv: b64(iv), wrappedKey: b64(wrapped)};
|
||||
} finally { generated = null; wrapping = null; passphrase = ''; }
|
||||
}
|
||||
|
||||
export async function unlock(passphrase, owner, record) {
|
||||
let wrapping;
|
||||
try {
|
||||
if (!record || record.v !== 1 || record.kdf !== 'PBKDF2' || record.hash !== 'SHA-256' ||
|
||||
record.iterations !== ITERATIONS || unb64(record.salt).length !== 16 ||
|
||||
unb64(record.iv).length !== 12 || unb64(record.wrappedKey).length !== 48 || passphrase.length > 1024) throw new Error();
|
||||
wrapping = await wrappingKey(passphrase, unb64(record.salt));
|
||||
return await crypto.subtle.unwrapKey('raw', unb64(record.wrappedKey), wrapping,
|
||||
{name: 'AES-GCM', iv: unb64(record.iv), additionalData: wrapAAD(owner), tagLength: 128},
|
||||
{name: 'AES-GCM', length: 256}, false, ['encrypt', 'decrypt']);
|
||||
} catch { throw new Error('Unlock failed. Check passphrase and local vault data.'); }
|
||||
finally { wrapping = null; passphrase = ''; }
|
||||
}
|
||||
|
||||
export function safeMetadata(item) {
|
||||
if (!item || item.v !== 1 || !/^[0-9a-f]{32}$/.test(item.id) || !isHttpsOrigin(item.origin)) throw new Error('Invalid encrypted item.');
|
||||
if (item.username !== undefined && (typeof item.username !== 'string' || !item.username || item.username.length > 512)) throw new Error('Invalid encrypted item.');
|
||||
return {v: 1, id: item.id, origin: item.origin, ...(item.username ? {username: item.username} : {})};
|
||||
}
|
||||
|
||||
export async function encryptItem(key, owner, origin, account, id = crypto.randomUUID().replaceAll('-', '')) {
|
||||
const item = safeMetadata({v: 1, id, origin});
|
||||
if (typeof account.username !== 'string' || typeof account.password !== 'string' || !account.username || !account.password) throw new Error('Enter username and password.');
|
||||
let plain = utf8.encode(JSON.stringify({username: account.username, password: account.password}));
|
||||
try {
|
||||
if (plain.length > 12000) throw new Error('Account is too large.');
|
||||
const iv = random(12);
|
||||
const ciphertext = await crypto.subtle.encrypt({name: 'AES-GCM', iv, additionalData: itemAAD(owner, item), tagLength: 128}, key, plain);
|
||||
return {...item, username: account.username, iv: b64(iv), ciphertext: b64(ciphertext)};
|
||||
} finally { plain.fill(0); plain = null; }
|
||||
}
|
||||
|
||||
export async function decryptItem(key, owner, item) {
|
||||
let plain;
|
||||
try {
|
||||
safeMetadata(item);
|
||||
if (unb64(item.iv).length !== 12 || item.ciphertext.length > 22000) throw new Error();
|
||||
plain = new Uint8Array(await crypto.subtle.decrypt({name: 'AES-GCM', iv: unb64(item.iv), additionalData: itemAAD(owner, item), tagLength: 128}, key, unb64(item.ciphertext)));
|
||||
const account = JSON.parse(decode.decode(plain));
|
||||
if (typeof account.username !== 'string' || typeof account.password !== 'string' || Object.keys(account).sort().join() !== 'password,username') throw new Error();
|
||||
return account;
|
||||
} catch { throw new Error('Encrypted item failed authentication.'); }
|
||||
finally { plain?.fill(0); plain = null; }
|
||||
}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
import {CONNECTION, VAULT_CAPABILITY} from './connection-state.mjs';
|
||||
import {safeMetadata, isHttpsOrigin} from './human-crypto.mjs';
|
||||
import {vaultRequest, fillAccount, captureAccount, VaultUnavailableError} from './human-api.mjs';
|
||||
import {AUTO_LOCK, policies} from './vault-session.mjs';
|
||||
import {updateState} from './version.mjs';
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const port = chrome.runtime.connect({name: 'human-vault'});
|
||||
let sequence = 0, owner = null, target = null, epoch = 0, busy = false, key = false, currentOrigin = null;
|
||||
let items = [];
|
||||
const pending = new Map();
|
||||
|
||||
function session(op, data = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++sequence;
|
||||
const timer = setTimeout(() => { pending.delete(id); reject(new Error()); }, 20000);
|
||||
pending.set(id, {resolve, reject, timer});
|
||||
port.postMessage({id, op, owner, ...data});
|
||||
});
|
||||
}
|
||||
|
||||
port.onMessage.addListener(message => {
|
||||
const task = pending.get(message.id); if (!task) return;
|
||||
clearTimeout(task.timer); pending.delete(message.id);
|
||||
message.ok ? task.resolve(message.result) : task.reject(message.unavailable ? new VaultUnavailableError() : new Error());
|
||||
});
|
||||
|
||||
const status = text => {
|
||||
$('human-status').textContent = text;
|
||||
$('human-status').hidden = !text;
|
||||
};
|
||||
|
||||
function controls() {
|
||||
const inventoryMode = currentOrigin === 'https://persimmons.app';
|
||||
const visibleItems = inventoryMode ? items : items.filter(item => item.origin === target?.origin);
|
||||
for (const id of ['human-connect', 'human-unlock', 'human-capture', 'human-fill', 'human-delete']) $(id).disabled = busy;
|
||||
$('auto-lock').disabled = busy;
|
||||
$('human-unlock').disabled ||= !owner || key;
|
||||
$('human-capture').disabled ||= !key || !target;
|
||||
$('human-fill').disabled ||= !key || !target || !$('human-items').value;
|
||||
$('human-delete').disabled ||= !key || !$('human-items').value;
|
||||
$('human-state').textContent = !owner ? 'Not connected' : key ? 'Unlocked' : 'Locked';
|
||||
$('human-connect-panel').hidden = !!owner;
|
||||
$('human-unlock-panel').hidden = !owner || key;
|
||||
$('human-actions').hidden = !key;
|
||||
$('human-capture').hidden = !target;
|
||||
$('human-accounts').hidden = !key || !visibleItems.length;
|
||||
$('settings-panel').hidden = !owner || !key;
|
||||
$('auto-lock-panel').hidden = !owner || !key;
|
||||
$('recovery').hidden = !owner || !key;
|
||||
$('updates').hidden = !owner || !$('updates').dataset.available;
|
||||
}
|
||||
|
||||
function clearPassphrase() { $('human-passphrase').value = ''; }
|
||||
|
||||
function lock() {
|
||||
session('lock').catch(() => {});
|
||||
epoch++; key = false; clearPassphrase(); status(''); controls();
|
||||
}
|
||||
|
||||
async function run(work, preservePassphrase = false) {
|
||||
if (busy) return;
|
||||
busy = true; controls();
|
||||
const current = epoch;
|
||||
const check = () => { if (epoch !== current) throw new Error('Locked.'); };
|
||||
try { await work(check); }
|
||||
catch (error) {
|
||||
if (epoch === current) {
|
||||
key = false; session('lock').catch(() => {});
|
||||
if (error instanceof VaultUnavailableError) {
|
||||
items = [];
|
||||
status('Vault temporarily unavailable. Check Persimmons sign-in and retry.');
|
||||
} else {
|
||||
status(preservePassphrase && owner ? 'Unlock failed. Check your passphrase.' : 'Nothing to save here yet.');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
const retry = preservePassphrase && !key && owner ? $('human-passphrase').value : '';
|
||||
clearPassphrase(); $('human-passphrase').value = retry; busy = false; controls();
|
||||
}
|
||||
}
|
||||
|
||||
async function activeTarget() {
|
||||
const [tab] = await chrome.tabs.query({active: true, currentWindow: true});
|
||||
const url = new URL(tab.url);
|
||||
if (!isHttpsOrigin(url.origin) || url.origin === 'https://persimmons.app') throw new Error();
|
||||
return {id: tab.id, origin: url.origin};
|
||||
}
|
||||
|
||||
async function refresh(check) {
|
||||
const data = await vaultRequest('GET', owner); check();
|
||||
items = data.items.map(safeMetadata);
|
||||
const inventoryMode = currentOrigin === 'https://persimmons.app';
|
||||
const visibleItems = inventoryMode ? items : items.filter(item => item.origin === target?.origin);
|
||||
$('human-items').replaceChildren(new Option(inventoryMode ? 'Choose a saved Persim' : 'Choose this site\'s Persim', ''));
|
||||
for (const item of visibleItems) {
|
||||
let label = item.username ? item.username + ' — ' + item.origin : item.origin;
|
||||
let account = null;
|
||||
if (!item.username && key) {
|
||||
try {
|
||||
const full = await vaultRequest('GET', owner, item.id); check();
|
||||
account = await session('decrypt', {item: full.item}); check();
|
||||
if (account?.username) {
|
||||
label = account.username + ' — ' + item.origin;
|
||||
await vaultRequest('PUT', owner, item.id, {...full.item, username: account.username}); check();
|
||||
item.username = account.username;
|
||||
}
|
||||
} finally {
|
||||
if (account) { account.username = ''; account.password = ''; account = null; }
|
||||
}
|
||||
}
|
||||
$('human-items').add(new Option(label, item.id));
|
||||
}
|
||||
}
|
||||
|
||||
async function linkCapability(check) {
|
||||
await chrome.storage.local.setAccessLevel({accessLevel: 'TRUSTED_CONTEXTS'}); check();
|
||||
const [tab] = await chrome.tabs.query({active: true, currentWindow: true}); check();
|
||||
if (!tab?.id || new URL(tab.url).origin !== 'https://persimmons.app') throw new VaultUnavailableError();
|
||||
const [execution] = await chrome.scripting.executeScript({
|
||||
target: {tabId: tab.id, frameIds: [0]}, world: 'ISOLATED',
|
||||
func: async () => {
|
||||
try {
|
||||
const response = await fetch('/api/human-vault/link', {
|
||||
method: 'POST', credentials: 'same-origin', cache: 'no-store',
|
||||
headers: {Accept: 'application/json', 'X-Requested-With': 'Persimmons'}
|
||||
});
|
||||
return response.ok ? await response.json() : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
}); check();
|
||||
const linked = execution?.result;
|
||||
if (!/^[a-z0-9][a-z0-9_.-]{0,99}$/.test(linked?.owner) ||
|
||||
!/^[A-Za-z0-9_-]{40,128}$/.test(linked?.capability) ||
|
||||
!Number.isSafeInteger(linked?.expires_at)) throw new VaultUnavailableError();
|
||||
await chrome.storage.local.set({
|
||||
[VAULT_CAPABILITY]: {owner: linked.owner, capability: linked.capability, expires_at: linked.expires_at},
|
||||
[CONNECTION]: {owner: linked.owner}
|
||||
}); check();
|
||||
return linked.owner;
|
||||
}
|
||||
|
||||
async function connect(check, expectedOwner) {
|
||||
await chrome.storage.local.setAccessLevel({accessLevel: 'TRUSTED_CONTEXTS'});
|
||||
const data = await vaultRequest('GET', expectedOwner); check();
|
||||
owner = data.owner;
|
||||
const storageId = `human-vault:https://persimmons.app:${owner}`;
|
||||
const initialized = !!(await chrome.storage.local.get(storageId))[storageId]; check();
|
||||
$('human-unlock').textContent = initialized ? 'Unlock vault' : 'Create vault';
|
||||
$('human-passphrase').placeholder = initialized ? 'Vault passphrase' : 'Choose at least 12 characters';
|
||||
try { target = await activeTarget(); } catch { target = null; }
|
||||
await refresh(check);
|
||||
await chrome.storage.local.set({[CONNECTION]: {owner}}); check();
|
||||
key = !!(await session('status')); check();
|
||||
if (key) await refresh(check);
|
||||
checkUpdates();
|
||||
status('');
|
||||
}
|
||||
|
||||
$('human-connect').onclick = () => {
|
||||
if (busy) return;
|
||||
lock(); owner = null; items = []; target = null; $('human-items').replaceChildren();
|
||||
run(async check => { const linkedOwner = await linkCapability(check); await connect(check, linkedOwner); });
|
||||
};
|
||||
|
||||
$('human-unlock-panel').onsubmit = event => {
|
||||
event.preventDefault();
|
||||
return run(async check => {
|
||||
let passphrase = $('human-passphrase').value;
|
||||
try {
|
||||
key = !!(await session('unlock', {passphrase})); check();
|
||||
await refresh(check);
|
||||
$('human-unlock').textContent = 'Unlock vault';
|
||||
$('human-passphrase').placeholder = 'Vault passphrase';
|
||||
status('');
|
||||
} finally { passphrase = ''; }
|
||||
}, true);
|
||||
};
|
||||
|
||||
$('human-capture').onclick = () => run(async check => {
|
||||
let account = null;
|
||||
try {
|
||||
const current = await activeTarget(); check();
|
||||
const result = await chrome.scripting.executeScript({
|
||||
target: {tabId: current.id, allFrames: true}, world: 'ISOLATED',
|
||||
func: captureAccount, args: [current.origin]
|
||||
});
|
||||
check();
|
||||
account = result.map(entry => entry.result).find(value => value?.username && value?.password) || null;
|
||||
if (!account) { status('No filled login found. Let your browser autofill the login form, then try again.'); return; }
|
||||
const item = await session('encrypt', {origin: current.origin, account}); check();
|
||||
account.username = ''; account.password = '';
|
||||
await vaultRequest('POST', owner, undefined, item); check();
|
||||
target = current; await refresh(check); $('human-items').value = item.id;
|
||||
status('Saved to your vault.');
|
||||
} finally { if (account) { account.username = ''; account.password = ''; } }
|
||||
});
|
||||
|
||||
$('human-fill').onclick = () => run(async check => {
|
||||
let account = null;
|
||||
try {
|
||||
const id = $('human-items').value;
|
||||
const current = await activeTarget(); check();
|
||||
if (!key || !target || current.id !== target.id || current.origin !== target.origin) throw new Error();
|
||||
const {item} = await vaultRequest('GET', owner, id); check();
|
||||
const metadata = safeMetadata(item);
|
||||
if (metadata.id !== id || metadata.origin !== current.origin) throw new Error();
|
||||
account = await session('decrypt', {item}); check();
|
||||
const result = await chrome.scripting.executeScript({
|
||||
target: {tabId: current.id, frameIds: [0]}, world: 'ISOLATED',
|
||||
func: fillAccount, args: [current.origin, account]
|
||||
});
|
||||
check(); if (!result[0]?.result) throw new Error();
|
||||
status('');
|
||||
} finally { if (account) { account.username = ''; account.password = ''; } }
|
||||
});
|
||||
|
||||
$('human-delete').onclick = () => run(async check => {
|
||||
if (!key || !await session('status')) throw new Error(); check();
|
||||
await vaultRequest('DELETE', owner, $('human-items').value); check();
|
||||
await refresh(check); status('');
|
||||
});
|
||||
|
||||
$('human-lock').onclick = lock;
|
||||
$('human-items').onchange = controls;
|
||||
$('human-signin').onclick = () => chrome.tabs.create({url: 'https://persimmons.app/'});
|
||||
$('updates').onclick = () => chrome.tabs.create({url: 'https://persimmons.app/'});
|
||||
window.addEventListener('pagehide', () => { epoch++; key = false; clearPassphrase(); port.disconnect(); });
|
||||
|
||||
port.onDisconnect.addListener(() => {
|
||||
key = false; epoch++; clearPassphrase(); controls();
|
||||
for (const task of pending.values()) { clearTimeout(task.timer); task.reject(new Error()); }
|
||||
pending.clear();
|
||||
});
|
||||
|
||||
controls();
|
||||
|
||||
run(async check => {
|
||||
try {
|
||||
const [tab] = await chrome.tabs.query({active: true, currentWindow: true}); check();
|
||||
const url = new URL(tab.url);
|
||||
currentOrigin = url.protocol === 'https:' ? url.origin : null;
|
||||
$('origin').textContent = currentOrigin || 'Open an HTTPS website';
|
||||
} catch { $('origin').textContent = 'Open an HTTPS website'; }
|
||||
const policy = (await chrome.storage.local.get(AUTO_LOCK))[AUTO_LOCK];
|
||||
$('auto-lock').value = policies.includes(policy) ? policy : 'close';
|
||||
const state = await chrome.storage.local.get([CONNECTION, VAULT_CAPABILITY]); check();
|
||||
const hint = state[CONNECTION], capability = state[VAULT_CAPABILITY];
|
||||
if (hint?.owner && capability?.owner === hint.owner && capability?.capability) {
|
||||
await connect(check, hint.owner); return;
|
||||
}
|
||||
status('Link vault to continue.');
|
||||
});
|
||||
|
||||
$('auto-lock').onchange = () => run(async check => {
|
||||
const policy = $('auto-lock').value;
|
||||
if (!policies.includes(policy)) throw new Error();
|
||||
await session('lock'); key = false; check();
|
||||
await chrome.storage.local.set({[AUTO_LOCK]: policy}); check();
|
||||
status('');
|
||||
});
|
||||
|
||||
function checkUpdates() {
|
||||
fetch('https://persimmons.app/assets/version.json', {cache: 'no-store'})
|
||||
.then(response => response.ok ? response.json() : null)
|
||||
.then(latest => {
|
||||
$('updates').dataset.available =
|
||||
updateState(chrome.runtime.getManifest().version, latest?.version) === 'Update available' ? 'yes' : '';
|
||||
controls();
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
setInterval(async () => {
|
||||
if (!key || busy) return;
|
||||
const current = epoch;
|
||||
try {
|
||||
const active = await session('status');
|
||||
if (epoch !== current || busy) return;
|
||||
if (!active) { epoch++; key = false; clearPassphrase(); controls(); }
|
||||
} catch (error) {
|
||||
if (epoch !== current) return;
|
||||
if (error instanceof VaultUnavailableError) {
|
||||
epoch++; key = false; items = []; target = null; clearPassphrase();
|
||||
status('Vault temporarily unavailable. Check Persimmons sign-in and retry.');
|
||||
controls();
|
||||
} else {
|
||||
lock();
|
||||
}
|
||||
}
|
||||
}, 10000);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 651 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<defs><radialGradient id="fruit" cx="35%" cy="28%" r="75%"><stop stop-color="#ffb448"/><stop offset=".65" stop-color="#f47b20"/><stop offset="1" stop-color="#db5c0d"/></radialGradient></defs>
|
||||
<path d="M64 31C36 22 9 44 10 73c1 28 22 47 54 47s53-19 54-47c1-29-26-51-54-42Z" fill="url(#fruit)"/>
|
||||
<path d="M64 33c-1-9 0-16 4-23" fill="none" stroke="#70502b" stroke-width="7" stroke-linecap="round"/>
|
||||
<path d="M65 28C43 30 33 20 30 9c22-4 38 4 35 19Z" fill="#4b813a"/>
|
||||
<path d="M38 61c-8 7-11 14-10 22" fill="none" stroke="#ffd091" stroke-width="7" stroke-linecap="round" opacity=".7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 663 B |
@@ -0,0 +1,2 @@
|
||||
// Derived from manifest.key; keep in sync with the server exact Origin.
|
||||
export const EXTENSION_ID = 'kiolgjoohhgdohcafgelhikpgkbmckbg';
|
||||
@@ -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.'};
|
||||
}
|
||||
}
|
||||
@@ -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/*"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
:root { color-scheme: light; font: 13px/1.5 ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #241a14; background: #fffdfa; }
|
||||
* { box-sizing: border-box; }
|
||||
body { width: 360px; margin: 0; padding: 16px; }
|
||||
header, .row { display: flex; align-items: center; gap: 8px; }
|
||||
header { margin-bottom: 18px; }
|
||||
header img { flex: none; }
|
||||
header a { margin-left: auto; font-size: 21px; text-decoration: none; }
|
||||
h1 { font-size: 22px; letter-spacing: -.04em; margin: 0; line-height: 1.15; }
|
||||
h2 { font-size: 16px; margin: 0; }
|
||||
p { margin: 10px 0; overflow-wrap: anywhere; }
|
||||
a { color: #b8530d; }
|
||||
.origin { margin-bottom: 14px; overflow-wrap: anywhere; }
|
||||
.eyebrow { display: block; color: #78695f; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.origin strong { font-weight: 500; }
|
||||
.card { background: #fff; border: 1px solid #eadfd6; border-radius: 14px; padding: 14px; margin: 12px 0; }
|
||||
.badge { margin-left: auto; color: #78695f; background: #fff5eb; border-radius: 20px; padding: 3px 8px; font-size: 11px; }
|
||||
label { display: block; margin: 10px 0; font-size: 12px; }
|
||||
input, select { width: 100%; padding: 9px 10px; margin-top: 4px; border: 1px solid #eadfd6; border-radius: 9px; font: inherit; color: #241a14; background: #fff; }
|
||||
button { border: 1px solid #eadfd6; border-radius: 10px; background: #fffdfa; padding: 9px 11px; margin: 4px 0; cursor: pointer; color: #241a14; font: inherit; font-weight: 600; }
|
||||
button:hover { border-color: #d9c7ba; }
|
||||
button.primary { background: #f47b20; border-color: #f47b20; color: #fff; }
|
||||
button.primary:hover { background: #da6410; }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
button.danger { color: #a23b2b; }
|
||||
.wide { width: 100%; }
|
||||
:focus-visible { outline: 2px solid #b8530d; outline-offset: 2px; }
|
||||
.hint, #human-status { color: #78695f; font-size: 11px; }
|
||||
summary { cursor: pointer; font-weight: 600; padding: 5px 0; }
|
||||
[hidden] { display: none !important; }
|
||||
.vault-actions { gap: 8px; margin-top: 10px; }
|
||||
.vault-actions button { flex: 1; }
|
||||
.link-button { border: 0; background: none; color: #b8530d; text-decoration: underline; padding: 6px 0; font-weight: 500; }
|
||||
.updates { border-top: 1px solid #eadfd6; padding-top: 6px; margin-top: 8px; }
|
||||
.recovery { border-top: 1px solid #eadfd6; padding-top: 10px; }
|
||||
.sr-only { position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0; }
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Persimmons</title><link rel="stylesheet" href="popup.css"></head>
|
||||
<body>
|
||||
<header><img src="icons/orange-48.png" width="36" height="36" alt=""><h1>Persimmons</h1><a href="https://persimmons.app/" target="_blank" rel="noreferrer" aria-label="Open Persimmons website">↗</a></header>
|
||||
<div class="origin"><span class="eyebrow">Current site</span><strong id="origin">Checking…</strong></div>
|
||||
<section class="card" aria-labelledby="human-heading">
|
||||
<div class="row"><h2 id="human-heading">My vault</h2><span id="human-state" class="badge">Not connected</span></div>
|
||||
<p id="human-status" role="status" aria-live="polite" hidden></p>
|
||||
|
||||
<div id="human-connect-panel">
|
||||
<button id="human-connect" class="primary wide">Link vault</button>
|
||||
<button type="button" id="human-signin" class="link-button wide">Sign in / create account</button>
|
||||
</div>
|
||||
|
||||
<form id="human-unlock-panel" hidden>
|
||||
<label class="sr-only" for="human-passphrase">Vault passphrase</label>
|
||||
<input id="human-passphrase" type="password" autocomplete="off" maxlength="1024" placeholder="Vault passphrase">
|
||||
<button type="submit" id="human-unlock" class="primary wide" disabled>Unlock vault</button>
|
||||
</form>
|
||||
|
||||
<div id="human-actions" hidden>
|
||||
<p class="hint">Let your browser autofill this site's login, then save it to your vault.</p>
|
||||
<div class="row vault-actions">
|
||||
<button type="button" id="human-capture" class="primary">Save autofilled login</button>
|
||||
<button type="button" id="human-lock">Lock vault</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="human-accounts" hidden>
|
||||
<label>Saved Persims <select id="human-items"></select></label>
|
||||
<div class="row"><button id="human-fill" class="primary" disabled>Fill</button><button id="human-delete" class="danger" disabled>Delete</button></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details id="settings-panel" class="updates" hidden><summary>Settings</summary>
|
||||
<div id="auto-lock-panel" hidden>
|
||||
<label>Auto-lock <select id="auto-lock"><option value="close">On popup close</option><option value="5">After 5 minutes</option><option value="15">After 15 minutes</option><option value="60">After 60 minutes</option></select></label>
|
||||
</div>
|
||||
<p id="recovery" class="hint recovery" hidden>No recovery or key sync in v0.</p>
|
||||
<button type="button" id="updates" class="link-button" hidden>Update available</button>
|
||||
</details>
|
||||
<script type="module" src="human-popup.mjs"></script></body></html>
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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`));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import {captureAccount, fillAccount} from '../human-api.mjs';
|
||||
|
||||
test('popup is only the human vault v0 surface', async () => {
|
||||
const html = await readFile(new URL('../popup.html', import.meta.url), 'utf8');
|
||||
assert.match(html, /My vault/);
|
||||
assert.match(html, /Save autofilled login/);
|
||||
assert.match(html, /browser autofill this site's login/);
|
||||
assert.match(html, /Lock vault/);
|
||||
assert.match(html, /Settings/);
|
||||
assert.doesNotMatch(html, /id="juice-panel"/);
|
||||
assert.doesNotMatch(html, /id="human-username"/);
|
||||
assert.doesNotMatch(html, /id="human-password"/);
|
||||
assert.doesNotMatch(html, /<iframe/);
|
||||
assert.ok(!html.includes('src="popup.mjs"'));
|
||||
});
|
||||
|
||||
test('linking mints a capability without a host permission prompt', async () => {
|
||||
const source = await readFile(new URL('../human-popup.mjs', import.meta.url), 'utf8');
|
||||
const manifest = JSON.parse(await readFile(new URL('../manifest.json', import.meta.url)));
|
||||
assert.match(source, /world: 'ISOLATED'/);
|
||||
assert.match(source, /fetch\('\/api\/human-vault\/link'/);
|
||||
assert.match(source, /\[VAULT_CAPABILITY\]/);
|
||||
assert.doesNotMatch(source, /chrome\.permissions\.request/);
|
||||
assert.equal(manifest.optional_host_permissions, undefined);
|
||||
assert.deepEqual(manifest.host_permissions, ['https://teahub.xyz/*']);
|
||||
});
|
||||
|
||||
test('capture and fill require one clear filled same-origin login form', () => {
|
||||
const old = {location:globalThis.location,document:globalThis.document,window:globalThis.window,HTMLInputElement:globalThis.HTMLInputElement,Event:globalThis.Event};
|
||||
const makeInput = (type,value) => ({
|
||||
type,value,disabled:false,readOnly:false,form:null,
|
||||
getClientRects:()=>[{}],dispatchEvent(){}
|
||||
});
|
||||
const user=makeInput('email','person@example.com'), password=makeInput('password','secret');
|
||||
const form={action:'https://example.com/login',querySelectorAll:()=>[user,password]};
|
||||
user.form=form;password.form=form;
|
||||
globalThis.location={protocol:'https:',origin:'https://example.com',href:'https://example.com/login'};
|
||||
globalThis.window={top:null};globalThis.window.top=globalThis.window;
|
||||
globalThis.document={querySelectorAll:selector=>selector==='input[type="password"]'?[password]:[user,password]};
|
||||
globalThis.HTMLInputElement=function(){};
|
||||
globalThis.HTMLInputElement.prototype={};
|
||||
Object.defineProperty(globalThis.HTMLInputElement.prototype,'value',{set(v){this._set=v},configurable:true});
|
||||
globalThis.Event=class { constructor(type,opts){this.type=type;this.opts=opts} };
|
||||
try {
|
||||
assert.deepEqual(captureAccount('https://example.com'),{username:'person@example.com',password:'secret'});
|
||||
assert.equal(captureAccount('https://other.example'),null);
|
||||
assert.equal(fillAccount('https://example.com',{username:'u',password:'p'}),true);
|
||||
} finally {
|
||||
Object.assign(globalThis,old);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {runJuice, giteaToken, checkedScopes} from '../juice.mjs';
|
||||
const sourceId = 'a'.repeat(32), juiceId = 'b'.repeat(32), oldId = 'c'.repeat(32);
|
||||
const sender = {origin:'https://persimmons.app',url:'https://persimmons.app/',frameId:0};
|
||||
const message = {type:'persimmons.juice.v1',action:'create',owner:'pg-2',source_id:sourceId,target:{id:1,name:'PackPack'},policy:{scopes:['read:repository']}};
|
||||
const raw = 'RAW-SOURCE-NEVER-PAGE', pat = 'd'.repeat(40);
|
||||
function fixture() {
|
||||
const calls = [], provider = [], account = {username:'source-fixture',password:raw};
|
||||
const deps = {
|
||||
id:()=>juiceId, live:async()=>{}, unlocked:async()=>{},
|
||||
source:async()=>({id:sourceId,origin:'https://teahub.xyz',ciphertext:'opaque'}),
|
||||
use:async(owner,item,fn)=>{try{return await fn(account,()=>{});}finally{account.password='';}},
|
||||
request:async(op,owner,data)=>{calls.push({op,data});return {items:[{id:oldId,source_id:sourceId,source_origin:'https://teahub.xyz',source_username:'source-fixture',scopes:['read:repository'],status:'active'}]};},
|
||||
provider:async(origin,account,name,scopes)=>{provider.push({origin,name,scopes});return scopes?pat:true;}
|
||||
};
|
||||
return {deps,calls,provider,account};
|
||||
}
|
||||
test('create sends only the derived PAT to write-only backend, never to page or metadata',async()=>{
|
||||
const f=fixture();const result=await runJuice(message,sender,f.deps);
|
||||
assert.deepEqual(result,{ok:true,id:juiceId,status:'active'});
|
||||
assert.doesNotMatch(JSON.stringify(result),new RegExp(raw+'|'+pat));
|
||||
assert.deepEqual(f.calls.map(x=>x.op),['prepare','write']);
|
||||
assert.equal(f.calls[1].data.pat,pat);
|
||||
assert.doesNotMatch(JSON.stringify(f.calls),new RegExp(raw));
|
||||
assert.doesNotMatch(JSON.stringify(f.calls[0]),new RegExp(pat));
|
||||
assert.equal(f.account.password,'');
|
||||
});
|
||||
test('top frame, origin, owner, unlock and scope gates reject before provider mutation',async()=>{
|
||||
for(const change of [{origin:'https://evil.invalid'},{url:'https://evil.invalid'},{frameId:1},{url:'invalid'}]){
|
||||
const f=fixture();assert.equal(await runJuice(message,{...sender,...change},f.deps),null);assert.equal(f.calls.length,0);
|
||||
}
|
||||
for(const gate of ['live','unlocked']){
|
||||
const f=fixture();f.deps[gate]=async()=>{throw new Error(raw)};
|
||||
assert.equal((await runJuice(message,sender,f.deps)).ok,false);assert.equal(f.provider.length,0);
|
||||
}
|
||||
for(const scopes of [[],['all'],['write:repository'],['read:issue','write:issue'],['read:user'],['read:repository','read:repository']]) assert.throws(()=>checkedScopes(scopes));
|
||||
const f=fixture();f.deps.source=async()=>({origin:'https://evil.invalid'});
|
||||
assert.equal((await runJuice(message,sender,f.deps)).message,'Juice adapter not available yet.');assert.equal(f.calls.length,0);
|
||||
});
|
||||
test('failed or uncertain write/create compensates by deterministic source token name',async()=>{
|
||||
for(const failure of ['write','provider']) {
|
||||
const f=fixture();const request=f.deps.request,provider=f.deps.provider;
|
||||
f.deps.request=async(op,...args)=>{if(op==='write'&&failure==='write')throw new Error(pat);return request(op,...args)};
|
||||
f.deps.provider=async(...args)=>{if(args[3]&&failure==='provider'){await provider(...args);throw new Error(raw)}return provider(...args)};
|
||||
const result=await runJuice(message,sender,f.deps);
|
||||
assert.equal(result.ok,false);assert.equal(result.status,'revoked');
|
||||
assert.equal(f.provider.at(-1).name,'persimmons-juice-'+juiceId);assert.equal(f.provider.at(-1).scopes,undefined);
|
||||
assert.equal(f.calls.at(-1).data.provider_status,'revoked');assert.doesNotMatch(JSON.stringify(result),new RegExp(raw+'|'+pat));
|
||||
}
|
||||
});
|
||||
test('unconfirmed rollback is explicit ANDON; rotation revokes old only after replacement stored',async()=>{
|
||||
const f=fixture(),request=f.deps.request;
|
||||
f.deps.request=async(op,...args)=>{if(op==='write')throw new Error();return request(op,...args)};
|
||||
f.deps.provider=async(o,a,n,s)=>{if(!s)throw new Error();return pat};
|
||||
const failed=await runJuice(message,sender,f.deps);
|
||||
assert.equal(failed.status,'partial-failure');assert.match(failed.message,/ANDON/);
|
||||
const r=fixture();const result=await runJuice({type:message.type,action:'rotate',owner:message.owner,target:message.target,id:oldId},sender,r.deps);
|
||||
assert.equal(result.ok,true);assert.deepEqual(r.calls.map(c=>c.op),['list','prepare','write','remove','cleanup']);
|
||||
assert.equal(r.provider.at(-1).name,'persimmons-juice-'+oldId);
|
||||
});
|
||||
test('locked revoke still removes target, records provider partial revoke truthfully',async()=>{
|
||||
const f=fixture();f.deps.unlocked=async()=>{throw new Error()};
|
||||
const result=await runJuice({type:message.type,action:'revoke',owner:message.owner,target:message.target,id:oldId},sender,f.deps);
|
||||
assert.equal(result.status,'partial-failure');assert.match(result.message,/ANDON/);
|
||||
assert.deepEqual(f.calls.map(c=>c.op),['list','remove','cleanup']);assert.equal(f.provider.length,0);
|
||||
assert.equal(f.calls.at(-1).data.provider_status,'unknown');
|
||||
});
|
||||
test('Gitea requests are pinned Basic auth, no cookies/redirects; responses do not escape',async()=>{
|
||||
const account={username:'fixture',password:raw};
|
||||
for(const origin of ['https://teahub.xyz']){
|
||||
const value=await giteaToken(origin,account,'persimmons-juice-'+juiceId,['read:repository'],async(url,opts)=>{
|
||||
assert.equal(url,origin+'/api/v1/users/fixture/tokens');assert.equal(opts.credentials,'omit');assert.equal(opts.redirect,'error');
|
||||
assert.equal(Buffer.from(opts.headers.Authorization.slice(6),'base64').toString(),'fixture:'+raw);
|
||||
return Response.json({name:'persimmons-juice-'+juiceId,sha1:pat,scopes:['read:repository']},{status:201});
|
||||
});assert.equal(value,pat);
|
||||
}
|
||||
let called=false;await assert.rejects(giteaToken('https://evil.invalid',account,'persimmons-juice-'+juiceId,['read:repository'],async()=>{called=true}));assert.equal(called,false);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
test('popup never reads cookies, saves explicit tokens, lists and revokes Juice in My vault', async () => {
|
||||
class Element {
|
||||
constructor(text = '', value = '') { this.textContent = text; this.value = value; this.children = []; this.disabled = false; }
|
||||
addEventListener(name, listener) { this[name] = listener; }
|
||||
append(...items) { this.children.push(...items); }
|
||||
add(item) { this.children.push(item); }
|
||||
replaceChildren(...items) { this.children = items; this.value = ''; }
|
||||
querySelectorAll() { return this.children.flatMap(c => c.children || []); }
|
||||
}
|
||||
const ids = ['juice-panel', 'origin', 'auth', 'connect', 'save', 'squeeze', 'status', 'vault-status',
|
||||
'juice-status', 'sources', 'juices', 'name', 'minutes', 'token', 'token-label', 'save-form', 'subject'];
|
||||
const nodes = Object.fromEntries(ids.map(id => [id, new Element()]));
|
||||
nodes.minutes.value = '60'; nodes.name.value = 'Site session';
|
||||
nodes.subject.value = 'Draft assistant';
|
||||
const grants = new Set();
|
||||
const permissionRequests = []; const calls = [];
|
||||
let grantNext = false; let cookieReads = 0; let juiceAvailable = false;
|
||||
let sourceExists = false;
|
||||
const publicItem = {id: 'ABC123', name: 'SITE_SESSION', vault_kind: 'user'};
|
||||
globalThis.document = {getElementById: id => nodes[id],
|
||||
querySelectorAll: () => [{value: 'content:read'}, {value: 'content:draft-write'}],
|
||||
createElement: () => new Element(), createTextNode: text => new Element(text)};
|
||||
globalThis.Option = Element;
|
||||
globalThis.window = {addEventListener() {}};
|
||||
globalThis.chrome = {
|
||||
tabs: {query: async () => [{url: 'https://wp.phly.cloud/wp-admin/'}]},
|
||||
permissions: {
|
||||
contains: async ({origins}) => origins.every(origin => grants.has(origin)),
|
||||
request: async ({origins}) => {
|
||||
permissionRequests.push(origins);
|
||||
if (grantNext) origins.forEach(origin => grants.add(origin));
|
||||
return grantNext;
|
||||
}
|
||||
},
|
||||
cookies: {getAll: async () => { cookieReads++; throw new Error('Cookie API must never be used'); }}
|
||||
};
|
||||
globalThis.fetch = async (url, options) => {
|
||||
const path = url.split('/persimmons/')[1];
|
||||
const body = options.body ? JSON.parse(options.body) : undefined;
|
||||
calls.push({path, body});
|
||||
let data; let status = 200;
|
||||
if (path === 'list') data = {connections: sourceExists ? [publicItem] : []};
|
||||
else if (path === 'create') { sourceExists = true; data = publicItem; }
|
||||
else if (!juiceAvailable) { data = {error: 'SECRET_SERVER_FIXTURE'}; status = 404; }
|
||||
else if (path === 'juice/list') data = {juice: [{id: 'JUICE123', vault_kind: 'user', status: 'active'}]};
|
||||
else data = {ok: true};
|
||||
return new Response(JSON.stringify(data), {status, headers: {'content-type': 'application/json'}});
|
||||
};
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 100 && nodes.connect.disabled; i++) await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(nodes.connect.disabled, false, 'operation completed');
|
||||
};
|
||||
await import('../popup.mjs');
|
||||
assert.equal(nodes.origin.textContent, 'https://wp.phly.cloud');
|
||||
assert.equal(cookieReads, 0); assert.equal(calls.length, 0);
|
||||
nodes.connect.onclick(); await settle();
|
||||
assert.equal(calls.length, 0); assert.match(nodes.status.textContent, /not granted/);
|
||||
grantNext = true;
|
||||
nodes.connect.onclick(); await settle();
|
||||
assert.deepEqual(calls.map(c => c.path), ['list', 'juice/list']);
|
||||
assert.equal(cookieReads, 0); assert.equal(nodes.save.disabled, false);
|
||||
nodes.token.value = 'SYNTHETIC_TOKEN';
|
||||
nodes['save-form'].onsubmit({preventDefault() {}}); await settle();
|
||||
const saved = calls.find(c => c.path === 'create').body;
|
||||
assert.equal(saved.vault_kind, 'user');
|
||||
assert.equal(saved.account, 'https://wp.phly.cloud');
|
||||
assert.equal(saved.credential, 'SYNTHETIC_TOKEN');
|
||||
assert.equal(saved.credential_type, 'api-token');
|
||||
assert.deepEqual(saved.scopes, ['content:read', 'content:draft-write']);
|
||||
assert.equal(saved.expires, undefined);
|
||||
assert.equal(nodes.sources.value, 'ABC123');
|
||||
assert.equal(nodes.token.value, ''); assert.equal(cookieReads, 0);
|
||||
juiceAvailable = true;
|
||||
nodes.squeeze.onclick(); await settle();
|
||||
assert.equal(calls.find(c => c.path === 'juice/create').body.source_id, 'ABC123');
|
||||
assert.equal(calls.find(c => c.path === 'juice/create').body.subject, 'Draft assistant');
|
||||
assert.ok(Math.abs(Date.parse(calls.find(c => c.path === 'juice/create').body.expires_at) - Date.now() - 3600000) < 5000);
|
||||
const revoke = nodes.juices.children[0].children[1];
|
||||
revoke.onclick(); await settle();
|
||||
assert.deepEqual(calls.find(c => c.path === 'juice/revoke').body, {id: 'JUICE123', vault_kind: 'user'});
|
||||
const flatten = node => [node.textContent, ...node.children.map(flatten)].join(' ');
|
||||
assert.doesNotMatch(Object.values(nodes).map(flatten).join(' '), /SECRET_|SYNTHETIC_TOKEN/);
|
||||
calls.length = 0;
|
||||
await import('../popup.mjs?reopened');
|
||||
assert.equal(calls.length, 0, 'reopening does not fetch secondary Juice');
|
||||
nodes['juice-panel'].open = true; nodes['juice-panel'].hidden = false;
|
||||
nodes['juice-panel'].toggle(); await settle();
|
||||
assert.deepEqual(calls.map(c => c.path), ['list', 'juice/list']);
|
||||
assert.equal(cookieReads, 0);
|
||||
assert.deepEqual(permissionRequests[0], ['https://teahub.xyz/*']);
|
||||
// A failed save clears the manually entered token too.
|
||||
globalThis.fetch = async () => { throw new Error('SYNTHETIC_TOKEN'); };
|
||||
nodes.token.value = 'SYNTHETIC_TOKEN';
|
||||
nodes['save-form'].onsubmit({preventDefault() {}}); await settle();
|
||||
assert.equal(nodes.token.value, '');
|
||||
assert.doesNotMatch(nodes.status.textContent, /SYNTHETIC_TOKEN/);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {VaultSession} from '../vault-session.mjs';
|
||||
import {updateState} from '../version.mjs';
|
||||
|
||||
test('vault session fails closed and honors bounded policies', () => {
|
||||
let now = 1000;
|
||||
const session = new VaultSession(() => now);
|
||||
const key = {extractable:false};
|
||||
session.set(key, 'owner-a', '5');
|
||||
assert.equal(session.get('owner-a'), key);
|
||||
assert.equal(session.get('owner-b'), null);
|
||||
session.set(key, 'owner-a', '5');
|
||||
now += 5 * 60000;
|
||||
assert.equal(session.get('owner-a'), null);
|
||||
session.set(key, 'owner-a', 'close');
|
||||
session.closed();
|
||||
assert.equal(session.get('owner-a'), null);
|
||||
});
|
||||
|
||||
test('version UI only says something when an update is actually available', () => {
|
||||
assert.equal(updateState('0.4.4','0.4.4'),'');
|
||||
assert.equal(updateState('0.4.5','0.4.4'),'');
|
||||
assert.equal(updateState('0.4.4','0.4.5'),'Update available');
|
||||
assert.equal(updateState('0.5.9','0.6.0'),'Update available');
|
||||
assert.equal(updateState('0.4.4','0.4.4.0'),'');
|
||||
assert.equal(updateState('bogus','0.4.5'),'');
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {AUTO_LOCK} from '../vault-session.mjs';
|
||||
import {VAULT_CAPABILITY} from '../connection-state.mjs';
|
||||
|
||||
test('worker keys stay private; trusted popup only; close, timer, auth loss and lock races fail closed', async () => {
|
||||
let connect, valid = true;
|
||||
const stored = {[VAULT_CAPABILITY]:{owner:'owner',capability:'A'.repeat(43)}}, timers = [];
|
||||
globalThis.setInterval = () => 1; globalThis.clearInterval = () => {};
|
||||
globalThis.setTimeout = callback => { timers.push(callback); return timers.length; };
|
||||
globalThis.clearTimeout = () => {};
|
||||
globalThis.chrome = {
|
||||
runtime: {id:'fixture', getURL:path=>'chrome-extension://fixture/'+path,
|
||||
getPlatformInfo:async()=>({}), onConnect:{addListener:fn=>connect=fn}},
|
||||
storage:{local:{get:async key=>({[key]:stored[key]}),set:async data=>Object.assign(stored,data)}}
|
||||
};
|
||||
globalThis.fetch = async()=>new Response(JSON.stringify({owner:valid?'owner':'other',items:[]}),{headers:{'content-type':'application/json'}});
|
||||
const {vaultUnlocked} = await import('../vault-worker.mjs');
|
||||
function popup(url='chrome-extension://fixture/popup.html') {
|
||||
let listener, close, disconnected = false, sequence = 0;
|
||||
const replies = new Map();
|
||||
connect({name:'human-vault',sender:{id:'fixture',url},disconnect:()=>disconnected=true,
|
||||
onMessage:{addListener:fn=>listener=fn},onDisconnect:{addListener:fn=>close=fn},
|
||||
postMessage:message=>{replies.get(message.id)?.(message);replies.delete(message.id)}});
|
||||
return {get disconnected(){return disconnected}, close:()=>close(),
|
||||
send:(op,data={})=>new Promise(resolve=>{const id=++sequence;replies.set(id,resolve);listener({id,op,owner:'owner',...data})})};
|
||||
}
|
||||
assert.equal(popup('https://persimmons.app/').disconnected,true);
|
||||
assert.equal(popup('chrome-extension://fixture/other.html').disconnected,true);
|
||||
let port = popup();
|
||||
const unlock = () => port.send('unlock',{passphrase:'synthetic test passphrase'});
|
||||
const firstUnlock = unlock();
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(vaultUnlocked('owner'),false,'status during unlock does not cancel it');
|
||||
assert.equal((await firstUnlock).result,true);
|
||||
assert.equal(vaultUnlocked('owner'),true);
|
||||
assert.equal(vaultUnlocked('other'),false);
|
||||
assert.equal(vaultUnlocked('owner'),true,'another owner status cannot lock the session');
|
||||
const record=stored['human-vault:https://persimmons.app:owner'];
|
||||
assert.ok(record.wrappedKey); assert.equal(Object.values(stored).some(x=>x instanceof CryptoKey),false);
|
||||
assert.doesNotMatch(JSON.stringify(stored),/synthetic test passphrase/);
|
||||
port.close(); port=popup();assert.equal((await port.send('status')).result,false);
|
||||
stored[AUTO_LOCK]='5';assert.equal((await unlock()).result,true);
|
||||
port.close();port=popup();assert.equal((await port.send('status')).result,true);
|
||||
const encrypted=await port.send('encrypt',{origin:'https://wp.phly.cloud',account:{username:'fixture',password:'synthetic'}});
|
||||
assert.equal(encrypted.ok,true);assert.ok(encrypted.result.ciphertext);
|
||||
assert.deepEqual((await port.send('decrypt',{item:encrypted.result})).result,{username:'fixture',password:'synthetic'});
|
||||
timers.at(-1)();assert.equal((await port.send('status')).result,false);
|
||||
assert.equal((await unlock()).result,true);
|
||||
valid=false;assert.equal((await port.send('status')).ok,false);valid=true;
|
||||
assert.equal((await port.send('status')).result,false);
|
||||
assert.equal((await port.send('unlock',{passphrase:'wrong'})).ok,false);
|
||||
const pending=unlock();await port.send('lock');assert.equal((await pending).ok,false);
|
||||
// Close invalidates any pending unlock and never installs its key.
|
||||
unlock();port.close();port=popup();
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal((await port.send('status')).result,false);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
// Memory only. Nothing here is serialized to storage or exposed externally.
|
||||
export const AUTO_LOCK = 'human-auto-lock';
|
||||
export const policies = ['close', '5', '15', '60'];
|
||||
export class VaultSession {
|
||||
constructor(now = Date.now) { this.now = now; this.clear(); }
|
||||
clear() { this.key = null; this.owner = null; this.deadline = 0; this.policy = 'close'; this.epoch = (this.epoch || 0) + 1; }
|
||||
set(key, owner, policy) {
|
||||
if (!policies.includes(policy) || key.extractable !== false) throw new Error('Invalid session');
|
||||
this.clear(); this.key = key; this.owner = owner; this.policy = policy;
|
||||
this.deadline = policy === 'close' ? Infinity : this.now() + Number(policy) * 60000;
|
||||
}
|
||||
get(owner) {
|
||||
if (this.now() >= this.deadline || owner !== this.owner) this.clear();
|
||||
return this.key;
|
||||
}
|
||||
closed() { if (this.policy === 'close') this.clear(); }
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {initialize, unlock, encryptItem, decryptItem} from './human-crypto.mjs';
|
||||
import {vaultRequest, VaultUnavailableError} from './human-api.mjs';
|
||||
import {AUTO_LOCK, policies, VaultSession} from './vault-session.mjs';
|
||||
const session = new VaultSession();
|
||||
// Only a boolean, after the caller has validated live ownership. Never expose keys.
|
||||
export const vaultUnlocked = owner => session.owner === owner && !!session.key && !!session.get(owner);
|
||||
// Internal worker callback only. Never register this as a message operation.
|
||||
export async function useVaultCredential(owner, item, use) {
|
||||
const key = session.get(owner), epoch = session.epoch;
|
||||
if (!key) throw new Error();
|
||||
let account;
|
||||
const check = () => {
|
||||
if (session.epoch !== epoch || !session.get(owner)) throw new Error();
|
||||
};
|
||||
try {
|
||||
account = await decryptItem(key, owner, item);
|
||||
check();
|
||||
return await use(account, check);
|
||||
} finally {
|
||||
if (account) { account.username = ''; account.password = ''; }
|
||||
account = null;
|
||||
}
|
||||
}
|
||||
export async function vaultUsername(owner, item) {
|
||||
const key = session.get(owner);
|
||||
if (!key) return null;
|
||||
let account = null;
|
||||
try {
|
||||
account = await decryptItem(key, owner, item);
|
||||
return account.username;
|
||||
} finally {
|
||||
if (account) {
|
||||
account.username = '';
|
||||
account.password = '';
|
||||
account = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
let heartbeat, expiry;
|
||||
function stop() { session.clear(); clearInterval(heartbeat); clearTimeout(expiry); heartbeat = null; }
|
||||
function keepAlive() {
|
||||
clearInterval(heartbeat);
|
||||
if (session.policy !== 'close') expiry = setTimeout(stop, Math.max(0, session.deadline - Date.now()));
|
||||
// Bounded Chrome API heartbeat; worker loss still fails closed. Never persist a key.
|
||||
heartbeat = setInterval(() => {
|
||||
if (!session.get(session.owner)) { stop(); return; }
|
||||
chrome.runtime.getPlatformInfo().catch(stop);
|
||||
}, 20000);
|
||||
}
|
||||
chrome.runtime.onConnect.addListener(port => {
|
||||
if (port.name !== 'human-vault' || port.sender?.id !== chrome.runtime.id ||
|
||||
port.sender?.url !== chrome.runtime.getURL('popup.html')) { port.disconnect(); return; }
|
||||
let closed = false, cancellation = 0, queue = Promise.resolve();
|
||||
port.onDisconnect.addListener(() => { closed = true; session.closed(); });
|
||||
const handle = async (message, generation) => {
|
||||
if (closed) return;
|
||||
let result;
|
||||
const epoch = session.epoch;
|
||||
const check = () => { if (closed || epoch !== session.epoch) throw new Error(); };
|
||||
try {
|
||||
if (generation !== cancellation) throw new Error();
|
||||
if (message.op === 'lock') { stop(); result = true; }
|
||||
else {
|
||||
const data = await vaultRequest('GET', message.owner); check();
|
||||
if (data.owner !== message.owner) throw new Error();
|
||||
if (message.op === 'status') result = !!session.get(message.owner);
|
||||
else if (message.op === 'unlock') {
|
||||
stop();
|
||||
const unlockEpoch = session.epoch;
|
||||
const checkUnlock = () => { if (closed || session.epoch !== unlockEpoch) throw new Error(); };
|
||||
const policy = (await chrome.storage.local.get(AUTO_LOCK))[AUTO_LOCK] || 'close';
|
||||
if (!policies.includes(policy)) throw new Error();
|
||||
const storageId = `human-vault:https://persimmons.app:${message.owner}`;
|
||||
let record = (await chrome.storage.local.get(storageId))[storageId]; checkUnlock();
|
||||
if (!record) {
|
||||
if (data.items.length) throw new Error();
|
||||
record = await initialize(message.passphrase, message.owner); checkUnlock();
|
||||
await chrome.storage.local.set({[storageId]: record}); checkUnlock();
|
||||
}
|
||||
const key = await unlock(message.passphrase, message.owner, record); checkUnlock();
|
||||
session.set(key, message.owner, policy); keepAlive(); result = true;
|
||||
} else {
|
||||
const key = session.get(message.owner);
|
||||
if (!key) throw new Error();
|
||||
const activeEpoch = session.epoch;
|
||||
if (message.op === 'encrypt') result = await encryptItem(key, message.owner, message.origin, message.account);
|
||||
else if (message.op === 'decrypt') result = await decryptItem(key, message.owner, message.item);
|
||||
else throw new Error();
|
||||
if (closed || session.epoch !== activeEpoch || !session.get(message.owner)) throw new Error();
|
||||
}
|
||||
}
|
||||
if (!closed) port.postMessage({id: message.id, ok: true, result});
|
||||
} catch (error) { stop(); if (!closed) port.postMessage({id: message.id, ok: false, unavailable: error instanceof VaultUnavailableError}); }
|
||||
finally { message.passphrase = ''; message.account = null; result = null; }
|
||||
};
|
||||
port.onMessage.addListener(message => {
|
||||
if (message.op === 'lock') { cancellation++; stop(); port.postMessage({id: message.id, ok: true, result: true}); return; }
|
||||
const generation = cancellation;
|
||||
queue = queue.then(() => handle(message, generation));
|
||||
});
|
||||
});
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
export function updateState(installed, latest) {
|
||||
const parse = value => typeof value === 'string' && /^\d+(\.\d+){0,3}$/.test(value) ? value.split('.').map(Number) : null;
|
||||
const a = parse(installed), b = parse(latest);
|
||||
if (!a || !b) return '';
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if ((a[i] || 0) < (b[i] || 0)) return 'Update available';
|
||||
if ((a[i] || 0) > (b[i] || 0)) return '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
Reference in new issue
Block a user