openvibers/openvibe.extensions Commits
Add Locker passwords to Companion with explicit exact-origin fill (#18)
Changes
@@@ -5,3 +5,4 @@ dist/.env.**.pem*.key++artifacts/@@@ -75,8 +75,8 @@ the Companion never rewrites links or adds referral tags.| | ||---|---|--| **Permissions** | `activeTab`, `identity`, `scripting`, `storage`, and `sidePanel` in the Chrome build (Firefox's `sidebar_action` needs no permission of its own). The host permissions are four OpenVibe origins: `https://openvibe.coupons/*` (the coupon API), `https://openvibe.network/*` (the Dock's state), `https://openvibe.codes/*` (the Dock's agent view and its four controls) and `https://openvibe.chat/*` (the Dock's Chats tab). `identity` starts the Dock's one-click OAuth sign-in (`chrome.identity.launchWebAuthFlow` / Firefox's `browser.identity`). `scripting` is used for four buttons: "Try these codes on this page" injects the packaged `lib/autoapply.js`, "Save its rating to OpenVibe Reviews" injects `lib/rating.js`, and "Keep on my Rent board" and "Check the rent" inject `lib/listing.js`, into the active tab (the access `activeTab` gives after you press the button), in its top frame and the extension's isolated world. |--| **Not requested** | `<all_urls>`, `tabs`, `cookies`, `webRequest`, `history`, optional permissions, content scripts, a background worker, web-accessible resources, `externally_connectable`. |++| **Permissions** | `activeTab`, `identity`, `scripting`, `storage`, `alarms`, `clipboardWrite`, and Chrome-only `offscreen` and `sidePanel` in the Chrome build (Firefox's `sidebar_action` needs no permission of its own). The host permissions are five OpenVibe origins: `https://openvibe.coupons/*` (the coupon API), `https://openvibe.network/*` (the Dock's state), `https://openvibe.codes/*` (the Dock's agent view and its four controls) and `https://openvibe.chat/*` (the Dock's Chats tab), plus `https://openvibe.locker/*` (encrypted password logins). `identity` starts the Dock's one-click OAuth sign-in (`chrome.identity.launchWebAuthFlow` / Firefox's `browser.identity`). `scripting` is used for four buttons: "Try these codes on this page" injects the packaged `lib/autoapply.js`, "Save its rating to OpenVibe Reviews" injects `lib/rating.js`, and "Keep on my Rent board" and "Check the rent" inject `lib/listing.js`, into the active tab (the access `activeTab` gives after you press the button), in its top frame and the extension's isolated world. |++| **Not requested** | `<all_urls>`, `tabs`, `cookies`, `webRequest`, `history`, optional permissions, content scripts, web-accessible resources, `externally_connectable`. || **Read from the page** | The active tab's **URL and title**, when you open the popup (that's what `activeTab` grants). The page's content only when you press "Try these codes on this page": `lib/autoapply.js` finds the coupon box, its Apply button and the order total, types the codes in, and returns to the popup which codes changed the total. It makes no request, reads no cookies or storage, and sends nothing anywhere; `test/manifest.test.js` scans it for that. On a review site, "Save its rating to OpenVibe Reviews" runs `lib/rating.js`, which only reads: the page's name, its rating, the scale, how many ratings, and the city from its structured data. "Keep on my Rent board" and "Check the rent" run `lib/listing.js`, which only reads: the price and its currency, the city and region (never the street) and the bedrooms, from the structured data, else the page's price tag. Browser pages, files, IP addresses and local names are "not a site", and nothing is sent or offered for them. || **Sent by the extension** | The Coupons part sends to `https://openvibe.coupons` only, and only the **hostname** (`lib/host.js` drops the path, query, fragment, port and credentials first): `GET /api/v1/merchants/resolve?host=`, the merchant id (`GET /api/v1/merchants/:id/coupons`), and, when you report, the code id plus `worked` or `failed` with an optional reason. Every request goes with `credentials: 'omit'` (no cookies), `referrerPolicy: 'no-referrer'` and `redirect: 'error'`. The Dock sends the person's own bearer to `https://openvibe.network` (its state, `/api/v1/me/dock`), `https://openvibe.chat` (Chats), and, when the Agents tab needs it, `https://openvibe.codes` (the agent view and its controls). || **Hand-offs (only when you press the button)** | "Keep on my Rent board", "Check the rent", "Watch this page", "Compare delivery apps" and "Save its rating to OpenVibe Reviews" open **a new tab you see** on `https://openvibe.rent/board`, `https://openvibe.rent/fair`, `https://openvibe.watch/watches/new`, `https://openvibe.food/delivery` or `https://openvibe.reviews/save`, with the page's address and title (and, for Reviews, the rating read from it; for Rent, the price, city and bedrooms read from it) in that tab's address. `lib/handoff.js` removes the credentials, the fragment and tracking parameters (`utm_*`, `fbclid`, `gclid`, …) first. The extension makes no request for it, nothing opens on its own, and nothing is saved until you save it on that site. `platform.tabs.open()` refuses any other origin, and the permission audit allows only `https://openvibe.<tld>` hand-off origins. |@@@ -170,7 +170,7 @@ npm test| Requirement | Test ||---|---|--| The manifest permission audit fails if anything widens: extra permissions, `<all_urls>` or broader hosts, content scripts, a background worker, web-accessible resources, `externally_connectable`, optional permissions, `unsafe-eval`, remote script or connect sources, any style source beyond `'unsafe-inline'`, any image beyond `'self' blob:`, any frame beyond Live, MV2. The first-party origins agree across config, host permissions and CSP. Pages have no inline or remote script. The code never uses cookies, scripting, dynamic code, HTML injection or `storage.sync`, and only `lib/api.js`, `lib/network-api.js`, `lib/auth.js` and the bundled Dock fetch. | `test/manifest.test.js` |++| The manifest permission audit fails if anything widens: extra permissions, `<all_urls>` or broader hosts, content scripts, web-accessible resources, `externally_connectable`, optional permissions, `unsafe-eval`, remote script or connect sources, any style source beyond `'unsafe-inline'`, any image beyond `'self' blob:`, any frame beyond Live, MV2. The first-party origins agree across config, host permissions and CSP. Pages have no inline or remote script. The code never uses cookies, scripting, dynamic code, HTML injection or `storage.sync`, and only `lib/api.js`, `lib/network-api.js`, `lib/auth.js` and the bundled Dock fetch. | `test/manifest.test.js` || The side panel in a real headless Chrome: the unpacked extension opens `panel.html` on the Agents tab with a stubbed Network/Chat/Codes, and the agent view draws its stream frame, line, meta and Pause/Resume, Curtain now, Mute/Unmute and End, with its styles applied and no CSP refusal. Skipped when no Chrome is installed. | `test/panel.test.js` (`scripts/check-panel.js`) || API client against a stub: routes; no `Authorization` without a token; never cookies, referrers or redirects; errors as codes (revoked, scope, 429, 503); timeouts; refused input. | `test/api-client.test.js` || The Coupons part never sends page content or URLs beyond the hostname. The popup controller runs on checkout URLs full of personal data, and every request the stub receives is inspected. Non-shop pages send nothing. Only `tabs.query` and `storage.local` are touched, and only `cpx_` tokens are stored there. | `test/privacy.test.js` |@@@ -255,3 +255,45 @@ shows "Allow access to openvibe.coupons".---Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).++++## Locker passwords++++The popup unlocks Locker with its passphrase or recovery kit. A passkey belongs to++openvibe.locker, so the popup explains this and offers **Open Locker**. Unlock keys++live only in `storage.session` (or background memory where unavailable), never local++or sync storage. Lock now, account changes, browser restart and 15 idle minutes++clear them. Worker suspension does not reset the deadline.++++Saved logins match the current origin exactly, including scheme, hostname and port.++**Fill** writes visible username/password inputs in the active tab's top frame;++**Fill and sign in** also submits. Neither runs on page load. Ambiguous forms with++more than one password input are refused. The injected function checks the origin++again immediately before writing each input. Copy password/code clears the clipboard++30 seconds after the latest copy, even when the popup closes; it does not read the++clipboard and may clear something copied elsewhere during those 30 seconds.++++**Save this login** encrypts `{version:1,site,username,password,totp,notes}` using the++same SDK envelope and private payload framing as Locker. Site and login details++stay encrypted on the server. TOTP codes (including otpauth links) are computed++locally. No password telemetry or breach lookup runs in Companion.++++Deployment requires Network's `locker.login.manage` scope and++`GET /api/v1/me/keys/companion`, then Locker's `/api/v1/companion` routes. Existing++OAuth grants need **Sign in to Companion** again to grant the new scope. The scope++allows only own wrapped-key reads and own encrypted-login reads/creates, with live++Network revocation checks. It grants no key-write, general-file or agent blind-fill++capability. Chrome uses an offscreen clipboard document; Firefox uses its++background event page with an alarm to clear even after suspension. Only the Locker host permission is added.++++Bundled crypto: `browser/shared/ov-locker-core.js` is the unchanged SDK 0.101.0++browser bundle from Shared 4.156.0. `locker-payload.js` and `locker-passwords.js`++come from Locker commit `9c2744e`; keep their framing/TOTP semantics compatible.++Run `npm test -- locker`, `npm run pack:stores`, and `npm run lint:firefox`.++++Optional browser verification: install Playwright outside the package (or set++`PLAYWRIGHT_MODULE` to an existing installation), install its Chromium build, and run++`node test/locker-browser.js`. It checks trusted popup clicks, 12 responsive/theme++screenshots and actual clipboard clearing with the packaged worker and no popup open.++++Firefox MV3 background pages are [non-persistent](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Background_scripts), so a clipboard alarm backs up its in-memory timer.++Chrome uses the documented [offscreen clipboard context](https://developer.chrome.com/docs/extensions/reference/api/offscreen).@@@ -18,6 +18,13 @@ import './lib/mask.js';import './lib/mask-service.js';import './lib/mail-api.js';import './lib/mask-router.js';++import './shared/ov-locker-core.js';++import './lib/locker-passwords.js';++import './lib/locker-payload.js';++import './lib/locker-api.js';++import './lib/locker-fill.js';++import './lib/locker-clipboard.js';++import './lib/locker-vault.js';const ext = self.browser || self.chrome;const config = self.OVConfig;@@@ -48,10 +55,38 @@ function isOwnPage(sender) {const router = self.OVMaskRouter.createRouter({ rules: self.OVMask, service: service, isOwnPage: isOwnPage });++const lockerApi = self.OVLockerApi.create({ origin: config.LOCKER_ORIGIN, fetch: self.fetch.bind(self), getToken: () => auth.accessToken() });++const vault = self.OVLockerVault.create({++ core: self.OVLockerCore, payload: self.LockerPayload, passwords: self.LockerPasswords, api: lockerApi,++ storage: platform.storage, tabs: platform.tabs, fill: platform.scripting.fillLogin,++ clipboard: platform.clipboard,++ account: async () => { const a = await auth.account(); return a?.sub || (await auth.status()).mode; },++ scheduleLock: (when) => ext.alarms.create('locker-lock', { when }), cancelLock: () => ext.alarms.clear('locker-lock'),++});++ext.alarms.onAlarm.addListener((alarm) => {++ if (alarm.name === 'locker-lock') vault.state().catch(() => vault.lock());++ if (alarm.name === 'locker-clipboard-clear') platform.clipboard.clear().catch(() => {});++});++// Restore the deadline if a browser dropped an alarm while suspending the worker.++vault.state().then((saved) => { if (saved) return ext.alarms.create('locker-lock', { when: saved.expires }); }).catch(() => vault.lock());++ext.runtime.onStartup.addListener(() => { vault.lock(); });++ext.storage.onChanged.addListener((changes, area) => {++ if (area === 'local' && (changes[config.DOCK_ACCOUNT_KEY] || changes[config.NETWORK_TOKEN_KEY])) vault.lock();++});++ext.runtime.onMessage.addListener(function (message, sender, sendResponse) {-- router.handle(message, sender).then(++ if (message?.target === 'locker-clipboard') return false;++ if (message?.type === 'locker:signin') {++ if (sender?.url !== ext.runtime.getURL('popup.html') || sender.tab || message.clicked !== true) { sendResponse({ ok: false }); return false; }++ vault.lock().then(() => auth.signIn()).then(() => sendResponse({ ok: true }), () => sendResponse({ ok: false }));++ return true;++ }++ const operation = message?.type?.startsWith('locker:')++ ? vault.handle(message, sender, ext.runtime.getURL('popup.html'))++ : router.handle(message, sender);++ operation.then(function (answer) { sendResponse(answer || { ok: false }); },-- function () { sendResponse({ ok: false, message: 'Something went wrong. Try again later.' }); }++ function (error) { sendResponse({ ok: false, message: message?.type?.startsWith('locker:') ? (error.message.startsWith('That passphrase') || /^(Unlock |Sign in |Use your |The active tab |This login |Enter a username)/.test(error.message) ? error.message : 'Locker could not complete that action. Unlock again and retry.') : 'Something went wrong. Try again later.' }); });return true;});nnew file mode 100644@@@ -0,0 +1,3 @@++<!DOCTYPE html>++<html lang="en"><head><meta charset="utf-8"><title>Companion clipboard</title></head>++<body><textarea id="clipboard" aria-label="Clipboard transfer"></textarea><script src="lib/locker-clipboard.js"></script><script src="clipboard.js"></script></body></html>nnew file mode 100644@@@ -0,0 +1,16 @@++'use strict';++// This offscreen document stays alive after the popup closes; it never reads the clipboard.++const clipboard = OVLockerClipboard.create({ write(value) {++ const field = document.getElementById('clipboard');++ // A nonempty selection ensures Chrome fires copy even when clearing to the empty string.++ field.value = value || ' '; field.select();++ const copy = (event) => { event.clipboardData.setData('text/plain', value); event.preventDefault(); };++ document.addEventListener('copy', copy, { once: true });++ try { if (!document.execCommand('copy')) throw new Error('Clipboard unavailable'); }++ finally { document.removeEventListener('copy', copy); field.value = ''; }++} });++chrome.runtime.onMessage.addListener((message, sender, reply) => {++ if (message?.target !== 'locker-clipboard' || sender.id !== chrome.runtime.id || sender.url !== chrome.runtime.getURL('background.js')) return;++ (message.clear ? clipboard.clear() : clipboard.write(message.value)).then(() => reply({ ok: true }), () => reply({ ok: false }));++ return true;++});@@@ -34,6 +34,7 @@CHAT_ORIGIN: 'https://openvibe.chat',// MAIL_ORIGIN is OpenVibe.Mail's API, where the person's masks live (GET/POST /api/v1/masks). The// Companion asks it for one mask per site, and never for anything else.++ LOCKER_ORIGIN: 'https://openvibe.locker',MAIL_ORIGIN: 'https://openvibe.email',// CODES_ORIGIN is OpenVibe.Codes' API: the Dock's agent view (GET /api/v1/me/agent-view and the// four controls), and the still Codes serves when nothing is live.@@@ -43,7 +44,7 @@// identity.getRedirectURL() is not one of these refuses to sign in rather than send an// unregistered redirect.OAUTH_CLIENT_ID: 'companion',-- OAUTH_SCOPES: Object.freeze(['dock', 'activity', 'chat.read', 'agent.view', 'mail.mask.manage']),++ OAUTH_SCOPES: Object.freeze(['dock', 'activity', 'chat.read', 'agent.view', 'mail.mask.manage', 'locker.login.manage']),OAUTH_REDIRECT_URIS: Object.freeze(['https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/','https://bb6593a68b1dcfbb7acd9650f04a169f4c5b57a0.extensions.allizom.org/',nnew file mode 100644@@@ -0,0 +1,32 @@++(function (root, factory) {++ const api = factory();++ if (typeof module === 'object' && module.exports) module.exports = api; else root.OVLockerApi = api;++})(globalThis, function () {++ 'use strict';++ function create({ fetch: request, getToken, origin }) {++ async function call(path, body) {++ const token = await getToken();++ const r = await request(origin + '/api/v1/companion' + path, {++ method: body ? 'POST' : 'GET', credentials: 'omit', referrerPolicy: 'no-referrer', redirect: 'error',++ headers: { Authorization: 'Bearer ' + token, ...(body ? { 'Content-Type': 'application/octet-stream' } : {}) },++ ...(body ? { body: new Blob([body], { type: 'application/octet-stream' }) } : {}), signal: AbortSignal.timeout(15000),++ });++ if (!r.ok) throw new Error(r.status === 401 || r.status === 403 ? 'Sign in again to allow Companion to use Locker.' : 'Locker could not complete this request.');++ return r;++ }++ return {++ keys: async () => (await call('/keys')).json(),++ async list() {++ const items = []; let before = '';++ do {++ const page = await (await call('/logins' + (before ? '?before=' + encodeURIComponent(before) : ''))).json();++ items.push(...page.items); before = page.next_cursor;++ } while (before);++ return items;++ },++ content: async (id) => new Uint8Array(await (await call('/logins/' + encodeURIComponent(id) + '/content')).arrayBuffer()),++ save: async (bytes) => (await call('/logins', bytes)).json(),++ };++ }++ return { create };++});nnew file mode 100644@@@ -0,0 +1,22 @@++(function (root, factory) {++ const api = factory();++ if (typeof module === 'object' && module.exports) module.exports = api; else root.OVLockerClipboard = api;++})(globalThis, function () {++ 'use strict';++ function create({ write, later = setTimeout, cancel = clearTimeout }) {++ let timer, queue = Promise.resolve();++ function clear() { queue = queue.catch(() => {}).then(() => write('')); return queue; }++ return {++ write(value) {++ queue = queue.catch(() => {}).then(async () => {++ await write(value);++ cancel(timer);++ timer = later(() => { clear().catch(() => { timer = later(() => clear().catch(() => {}), 1000); }); }, 30000);++ });++ return queue;++ },++ clear,++ };++ }++ return { create };++});nnew file mode 100644@@@ -0,0 +1,43 @@++(function (root, factory) {++ const api = factory();++ if (typeof module === 'object' && module.exports) module.exports = api; else root.OVLockerFill = api;++})(globalThis, function () {++ 'use strict';++ // Self-contained: Chrome serializes this function into the isolated world of frame 0.++ function fill(login, submit, clicked) {++ if (clicked !== true || window !== window.top || location.origin !== login.site) return { ok: false };++ function visible(input) {++ for (let ancestor = input; ancestor; ancestor = ancestor.parentElement) {++ const style = getComputedStyle(ancestor);++ if (style.display === 'none' || style.visibility !== 'visible' || Number(style.opacity) === 0) return false;++ }++ const style = getComputedStyle(input);++ return !input.disabled && !input.readOnly && input.type !== 'hidden' && style.display !== 'none' && style.visibility === 'visible'++ && Number(style.opacity) !== 0 && Array.from(input.getClientRects()).some((rect) => rect.width > 0 && rect.height > 0) && !input.closest('[inert]');++ }++ const passwords = Array.from(document.querySelectorAll('input[type="password"]')).filter(visible);++ if (passwords.length !== 1) return { ok: false }; // Ambiguous signup / change-password forms need the person.++ const password = passwords[0];++ const fields = Array.from((password.form || document).querySelectorAll('input')).filter(visible);++ const textFields = fields.filter((el) => el.type === 'text');++ const username = fields.find((el) => el.autocomplete === 'username')++ || fields.find((el) => el.type === 'email')++ || fields.find((el) => el.type === 'text' && /user|login|email/i.test(el.name + ' ' + el.id))++ || (password.form && textFields.length === 1 ? textFields[0] : null);++ if (!username || (submit && !password.form)) return { ok: false };++ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;++ for (const [input, value] of [[username, login.username], [password, login.password]]) {++ // Page event handlers may navigate, remove a field, or replace the form between writes.++ if (window !== window.top || location.origin !== login.site || !input.isConnected || !visible(input)) return { ok: false };++ setter.call(input, value);++ input.dispatchEvent(new Event('input', { bubbles: true }));++ input.dispatchEvent(new Event('change', { bubbles: true }));++ }++ if (submit) {++ if (location.origin !== login.site || window !== window.top || !password.isConnected) return { ok: false };++ HTMLFormElement.prototype.requestSubmit.call(password.form);++ }++ return { ok: true };++ }++ return { fill };++});nnew file mode 100644@@@ -0,0 +1,93 @@++(function (root) {++ 'use strict';++ function mount({ container, ask, platform }) {++ const make = (tag, text, cls) => { const node = document.createElement(tag); if (text) node.textContent = text; if (cls) node.className = cls; return node; };++ let context, busy = false, tick, lastActivity = 0, isUnlocked = false;++ const note = make('p', '', 'note muted'); note.setAttribute('role', 'status');++ const body = make('div');++ const heading = make('div', '', 'locker-heading');++ heading.append(make('h2', 'Passwords'), make('span', 'Locker', 'status'));++ container.append(heading, body, note);++ function button(label, action, cls = 'btn') {++ const b = make('button', label, cls); b.type = 'button';++ b.addEventListener('click', (e) => { if (e.isTrusted) run(b, action); }); return b;++ }++ async function run(b, action) {++ if (busy) return; busy = true; b.disabled = true; note.textContent = '';++ try { await action(); } catch (e) { note.textContent = e.message; }++ finally { busy = false; b.disabled = false; }++ }++ async function request(action, fields = {}) {++ const r = await ask({ type: 'locker:' + action, ...context, ...fields });++ if (!r?.ok) throw new Error(r?.message || 'Could not complete that action. Check that the page has one visible login form.');++ return r;++ }++ function field(form, label, type, name) {++ const wrap = make('label', label, 'locker-field'); const input = make('input');++ input.name = name; input.type = type; input.autocomplete = 'off'; input.spellcheck = false;++ wrap.append(input); form.append(wrap); return input;++ }++ for (const event of ['pointerdown', 'keydown']) container.addEventListener(event, (e) => {++ if (!e.isTrusted || !isUnlocked || Date.now() - lastActivity < 60000) return;++ lastActivity = Date.now(); ask({ type: 'locker:activity', clicked: true });++ });++ function locked() {++ isUnlocked = false;++ clearInterval(tick); body.replaceChildren();++ body.append(make('p', 'Unlock to use a saved login on this site.', 'muted'));++ const form = make('form', '', 'locker-form');++ const modeLabel = make('label', 'Unlock with', 'locker-field'); const mode = make('select');++ for (const [value, label] of [['passphrase', 'Locker passphrase'], ['recovery', 'Recovery kit']]) { const opt = make('option', label); opt.value = value; mode.append(opt); }++ modeLabel.append(mode); form.append(modeLabel);++ const secret = field(form, 'Passphrase or recovery words', 'password', 'secret'); secret.required = true;++ const submit = make('button', 'Unlock Locker', 'btn primary'); submit.type = 'submit'; form.append(submit);++ form.addEventListener('submit', (e) => {++ e.preventDefault(); if (!e.isTrusted) return;++ const value = secret.value; secret.value = '';++ run(submit, async () => { await request('unlock', { clicked: true, mode: mode.value, secret: value }); await unlocked(); });++ });++ body.append(form, make('p', 'Your passkey belongs to openvibe.locker and cannot unlock here. Use your recovery kit or open Locker.', 'note muted'));++ const open = make('a', 'Open Locker'); open.href = 'https://openvibe.locker/passwords'; open.target = '_blank'; open.rel = 'noopener'; body.append(open);++ body.append(button('Sign in to Companion', async () => { const r = await ask({ type: 'locker:signin', clicked: true }); if (!r?.ok) throw new Error('Sign-in could not finish. Try again.'); note.textContent = 'Signed in. Unlock with your Locker passphrase or recovery kit.'; }));++ }++ async function unlocked() {++ isUnlocked = true; lastActivity = Date.now();++ body.replaceChildren(); clearInterval(tick);++ const controls = make('div', '', 'locker-heading');++ controls.append(make('p', 'Locks after 15 minutes idle', 'note muted'), button('Lock now', async () => { await request('lock', { clicked: true }); locked(); }));++ body.append(controls);++ if (!context) { body.append(make('p', 'Open a website to fill or save a login.', 'muted')); return; }++ body.append(make('p', context.site, 'locker-origin'));++ const list = make('div'); body.append(list);++ const data = await request('list');++ if (!data.items.length) list.append(make('p', 'No saved logins for this exact site.', 'muted'));++ for (const item of data.items) {++ const card = make('article', '', 'locker-login'); card.append(make('h3', item.username || 'No username'));++ const actions = make('div', '', 'row');++ const act = (label, action, primary) => button(label, async () => {++ await request(action, { id: item.id, clicked: true });++ note.textContent = action === 'password' || action === 'totp' ? 'Copied. Clipboard clears in 30 seconds.' : action === 'submit' ? 'Filled and submitted.' : 'Filled.';++ }, primary ? 'btn primary' : 'btn');++ actions.append(act('Fill', 'fill', true), act('Fill and sign in', 'submit'), act('Copy password', 'password'));++ if (item.hasTotp) actions.append(act('Copy code', 'totp')); card.append(actions); list.append(card);++ }++ const details = make('details', '', 'locker-save'); details.append(make('summary', 'Save this login'));++ const form = make('form', '', 'locker-form');++ const username = field(form, 'Username', 'text', 'username'); const password = field(form, 'Password', 'password', 'password'); password.required = true;++ const totp = field(form, 'TOTP secret or otpauth link (optional)', 'password', 'totp');++ const save = make('button', 'Save encrypted login', 'btn primary'); save.type = 'submit'; form.append(save);++ form.addEventListener('submit', (e) => {++ e.preventDefault(); if (!e.isTrusted) return;++ const values = { username: username.value, password: password.value, totp: totp.value, clicked: true }; form.reset();++ run(save, async () => { await request('save', values); await unlocked(); note.textContent = 'Saved to Locker.'; });++ }); details.append(form); body.append(details);++ tick = setInterval(async () => { const s = await ask({ type: 'locker:status' }); if (!s?.ok || s.locked) locked(); }, 5000);++ }++ (async () => {++ const tab = await platform.tabs.active();++ try { const u = new URL(tab.url); if (['http:', 'https:'].includes(u.protocol)) context = { tabId: tab.id, site: u.origin }; } catch { /* internal tab */ }++ const state = await request('status'); if (state.locked) locked(); else await unlocked();++ })().catch(() => { locked(); note.textContent = 'Unlock Locker to continue.'; });++ }++ root.OVLockerPanel = { mount };++})(globalThis);nnew file mode 100644@@@ -0,0 +1,44 @@++(function (root) {++ 'use strict';++ function parseCsv(text) {++ const rows = []; let row = [], field = '', quoted = false;++ for (let i = 0; i < text.length; i++) { const c = text[i]; if (quoted) { if (c === '"' && text[i + 1] === '"') { field += '"'; i++; } else if (c === '"') quoted = false; else field += c; }++ else if (c === '"') quoted = true; else if (c === ',') { row.push(field); field = ''; } else if (c === '\n' || c === '\r') { if (c === '\r' && text[i + 1] === '\n') i++; row.push(field); rows.push(row); row = []; field = ''; } else field += c; }++ if (quoted) throw new Error('CSV has an unclosed quoted field.'); if (field || row.length) { row.push(field); rows.push(row); } return rows.filter((r) => r.some(Boolean));++ }++ function normalizeOrigin(value) { const url = new URL(value); if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('Use a valid website URL.'); return url.origin; }++ async function breachRange(password) {++ const digest = new Uint8Array(await crypto.subtle.digest('SHA-1', new TextEncoder().encode(password)));++ const hash = [...digest].map((b) => b.toString(16).padStart(2, '0')).join('').toUpperCase();++ return { prefix: hash.slice(0, 5), suffix: hash.slice(5) };++ }++ // A bare Base32 secret, or an otpauth://totp/ link as authenticator QR codes and most exports carry it.++ function totpParams(value) {++ const raw = String(value || '').trim();++ if (!/^otpauth:\/\//i.test(raw)) return { secret: raw, algorithm: 'SHA-1', digits: 6, period: 30 };++ const url = new URL(raw); const p = url.searchParams;++ if (url.hostname.toLowerCase() !== 'totp') throw new Error('Only time-based (TOTP) codes are supported.');++ const algorithm = { SHA1: 'SHA-1', SHA256: 'SHA-256', SHA512: 'SHA-512' }[(p.get('algorithm') || 'SHA1').toUpperCase().replace('-', '')];++ const digits = Number(p.get('digits') || 6), period = Number(p.get('period') || 30);++ if (!algorithm || ![6, 7, 8].includes(digits) || !Number.isInteger(period) || period < 1 || period > 300) throw new Error('This TOTP link uses settings Locker does not support.');++ return { secret: p.get('secret') || '', algorithm, digits, period };++ }++ // A site from an import file: a bare host gets https://; anything that is not a web origin is skipped (null).++ function importOrigin(value) {++ const raw = String(value || '').trim(); if (!raw) return null;++ try { return normalizeOrigin(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`); } catch { return null; }++ }++ async function totp(value, now = Date.now()) {++ const { secret, algorithm, digits, period } = totpParams(value);++ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; let bits = '', bytes = [];++ for (const c of secret.toUpperCase().replace(/[\s=-]/g, '')) { const v = alphabet.indexOf(c); if (v < 0) throw new Error('The TOTP secret is not valid Base32.'); bits += v.toString(2).padStart(5, '0'); }++ for (let i = 0; i + 8 <= bits.length; i += 8) bytes.push(parseInt(bits.slice(i, i + 8), 2));++ if (!bytes.length) throw new Error('The TOTP secret is empty.');++ const counter = BigInt(Math.floor(now / (period * 1000))); const msg = new Uint8Array(8); new DataView(msg.buffer).setBigUint64(0, counter);++ const key = await crypto.subtle.importKey('raw', new Uint8Array(bytes), { name: 'HMAC', hash: algorithm }, false, ['sign']); const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, msg));++ const off = mac.at(-1) & 15; const code = ((mac[off] & 127) << 24 | mac[off + 1] << 16 | mac[off + 2] << 8 | mac[off + 3]) % 10 ** digits; return String(code).padStart(digits, '0');++ }++ const api = { parseCsv, normalizeOrigin, importOrigin, breachRange, totpParams, totp };++ if (typeof module !== 'undefined') module.exports = api;++ else root.LockerPasswords = api;++})(globalThis);nnew file mode 100644@@@ -0,0 +1,65 @@++/* The private file description is part of the plaintext given to the existing Locker SDK. */++(function (root) {++ 'use strict';++ const encoder = new TextEncoder();++ function prefix(meta) {++ const json = encoder.encode(JSON.stringify({ version: 1, ...meta }));++ if (json.length > 65536) throw new Error('The file description is too long.');++ const bytes = new Uint8Array(4 + json.length);++ new DataView(bytes.buffer).setUint32(0, json.length);++ bytes.set(json, 4);++ return bytes;++ }++ function pack(meta, stream) {++ const first = prefix(meta);++ let sent = false, reader;++ return new ReadableStream({++ async pull(controller) {++ if (!sent) { sent = true; controller.enqueue(first); return; }++ reader ||= stream.getReader();++ const chunk = await reader.read();++ if (chunk.done) { reader.releaseLock(); controller.close(); } else controller.enqueue(chunk.value);++ },++ async cancel(reason) { if (reader) await reader.cancel(reason); else await stream.cancel(reason); },++ });++ }++ async function unpack(stream) {++ const reader = stream.getReader();++ let buffer = new Uint8Array(0);++ async function take(n) {++ while (buffer.length < n) {++ const part = await reader.read();++ if (part.done) throw new Error('The locked file is incomplete.');++ const next = new Uint8Array(buffer.length + part.value.length); next.set(buffer); next.set(part.value, buffer.length); buffer = next;++ }++ const out = buffer.slice(0, n); buffer = buffer.slice(n); return out;++ }++ try {++ const len = new DataView((await take(4)).buffer).getUint32(0);++ if (len < 2 || len > 65536) throw new Error('The locked file description is invalid.');++ const meta = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(await take(len)));++ if (meta.version !== 1 || typeof meta.name !== 'string' || meta.name.length > 1024 || typeof meta.type !== 'string'++ || !Number.isSafeInteger(meta.size) || meta.size < 0) throw new Error('The locked file description is invalid.');++ let received = 0;++ const content = new ReadableStream({++ async pull(controller) {++ const part = buffer.length ? { value: buffer, done: false } : await reader.read(); buffer = new Uint8Array(0);++ if (part.done) {++ reader.releaseLock();++ if (received !== meta.size) throw new Error('The locked file size does not match.');++ controller.close();++ } else {++ received += part.value.length;++ if (received > meta.size) throw new Error('The locked file is longer than expected.');++ controller.enqueue(part.value);++ }++ },++ cancel: (reason) => reader.cancel(reason),++ });++ return { meta, stream: content };++ } catch (e) { await reader.cancel(e).catch(() => {}); throw e; }++ }++ const api = { prefix, pack, unpack };++ if (typeof module !== 'undefined') module.exports = api;++ else root.LockerPayload = api;++})(globalThis);nnew file mode 100644@@@ -0,0 +1,116 @@++(function (root, factory) {++ const api = factory();++ if (typeof module === 'object' && module.exports) module.exports = api; else root.OVLockerVault = api;++})(globalThis, function () {++ 'use strict';++ const IDLE = 15 * 60 * 1000, KEY = 'lockerUnlock';++ function origin(url) {++ try { const u = new URL(url); return ['https:', 'http:'].includes(u.protocol) && !u.username && !u.password ? u.origin : null; } catch { return null; }++ }++ function create({ core, payload, passwords, api, storage, scheduleLock, cancelLock, tabs, fill, clipboard, account, now = Date.now }) {++ let generation = 0, mutations = Promise.resolve();++ function persist(saved, version) {++ mutations = mutations.catch(() => {}).then(async () => {++ if (version !== generation) throw new Error('Locker locked. Unlock again.');++ await storage.setSession({ [KEY]: saved }); await scheduleLock(saved.expires);++ });++ return mutations;++ }++ async function lock() {++ generation++;++ mutations = mutations.catch(() => {}).then(async () => { await storage.removeSession([KEY]); await cancelLock(); });++ await mutations; return { locked: true };++ }++ async function state(touch = false) {++ const version = generation;++ const saved = await storage.getSession(KEY);++ if (!saved) return null;++ if (version !== generation) return null;++ if (now() >= saved.expires || saved.account !== await account()) { await lock(); return null; }++ if (touch) { saved.expires = now() + IDLE; await persist(saved, version); }++ return saved;++ }++ async function active(message) {++ const tab = await tabs.active();++ const site = origin(tab.url);++ if (!site || tab.id !== message.tabId || site !== message.site) throw new Error('The active tab changed. Open Companion again.');++ return tab;++ }++ async function unlocked() {++ const version = generation;++ const s = await state(true);++ if (!s) throw new Error('Unlock your Locker first.');++ return { ...s, person: await core.importPersonKeys(s.privateKeys), generation: version };++ }++ async function still(s) {++ if (generation !== s.generation || !(await state()) || s.account !== await account()) throw new Error('Locker locked. Unlock again.');++ }++ async function read(id, s) {++ const bytes = await api.content(id);++ if (bytes.byteLength > 2 * 1024 ** 2) throw new Error('This login is too large.');++ const plain = await core.open(bytes, { privateKey: s.person.ecdhPrivateKey, subject: { type: 'user', id: s.subject } });++ const opened = await payload.unpack(new Blob([plain]).stream());++ const login = JSON.parse(await new Response(opened.stream).text());++ if (login.version !== 1 || origin(login.site) !== login.site || typeof login.username !== 'string' || typeof login.password !== 'string') throw new Error('Invalid saved login.');++ return login;++ }++ async function handle(m, sender, popupUrl) {++ // Web pages, iframes, panel, options and offscreen document cannot invoke password operations.++ if (sender?.url !== popupUrl || sender?.tab || !m || !m.type.startsWith('locker:')) return { ok: false };++ const action = m.type.slice(7);++ if (action === 'status') return { ok: true, locked: !(await state()) };++ if (m.clicked !== true && action !== 'list') return { ok: false };++ if (action === 'activity') return { ok: true, locked: !(await state(true)) };++ if (action === 'lock') return { ok: true, ...await lock() };++ if (action === 'unlock') {++ await lock(); const g = generation;++ const owner = await account();++ const keys = await api.keys();++ const mode = m.mode === 'recovery' ? 'recovery' : 'passphrase';++ const backup = keys.backups?.[mode];++ if (!backup?.envelope) throw new Error('Use your recovery kit, or open Locker to set up a passphrase. Passkeys belong to openvibe.locker.');++ const secret = mode === 'recovery' ? String(m.secret).trim().toLowerCase().split(/\s+/).join(' ') : m.secret;++ const bytes = Uint8Array.from(atob(backup.envelope), (c) => c.charCodeAt(0));++ let privateKeys;++ try {++ privateKeys = JSON.parse(new TextDecoder().decode(await core.open(bytes, { password: secret })));++ await core.importPersonKeys(privateKeys);++ } catch { throw new Error('That passphrase or recovery kit could not unlock Locker.'); }++ if (g !== generation || owner !== await account()) throw new Error('Sign-in changed. Try again.');++ const expires = now() + IDLE;++ await persist({ privateKeys, subject: keys.subject, account: owner, expires }, g);++ return { ok: true, locked: false };++ }++ await active(m);++ const s = await unlocked();++ if (action === 'list') {++ const items = [];++ for (const item of await api.list()) {++ const login = await read(item.id, s);++ if (login.site === m.site) items.push({ id: item.id, username: login.username, hasTotp: !!login.totp });++ }++ await still(s); await active(m);++ return { ok: true, items };++ }++ if (action === 'save') {++ if (typeof m.username !== 'string' || typeof m.password !== 'string' || !m.password || m.password.length > 16384 || m.username.length > 4096) throw new Error('Enter a username and password.');++ if (m.totp) await passwords.totp(m.totp);++ const login = { version: 1, site: m.site, username: m.username, password: m.password, totp: m.totp || '', notes: '' };++ const body = new Blob([JSON.stringify(login)]);++ const packed = new Uint8Array(await new Response(payload.pack({ name: 'login.json', type: 'application/json', size: body.size }, body.stream())).arrayBuffer());++ const sealed = await core.seal(packed, { lock: 'people', keys: [{ subject: { type: 'user', id: s.subject }, publicKey: s.person.ecdhPublic }] });++ await still(s); await active(m); await api.save(sealed.bytes);++ return { ok: true };++ }++ if (!['fill', 'submit', 'password', 'totp'].includes(action)) return { ok: false };++ const login = await read(m.id, s);++ if (login.site !== m.site) throw new Error('This login belongs to a different origin.');++ await still(s); const tab = await active(m);++ if (action === 'fill' || action === 'submit') return await fill(tab.id, { site: login.site, username: login.username, password: login.password }, action === 'submit');++ await clipboard.write(action === 'totp' ? await passwords.totp(login.totp) : login.password);++ return { ok: true };++ }++ return { handle, lock, state };++ }++ return { create, origin, IDLE, KEY };++});@@@ -52,7 +52,34 @@await call(ext.tabs.create, ext.tabs, [{ url: url }]);},},++ clipboard: {++ async clear() {++ if (this.local) return this.local.clear();++ if (!ext.offscreen) return navigator.clipboard.writeText('');++ },++ async write(value) {++ if (!ext.offscreen) {++ // Firefox may unload its event page: the alarm wakes it to clear the clipboard.++ this.local ||= self.OVLockerClipboard.create({ write: (text) => navigator.clipboard.writeText(text) });++ await this.local.write(value);++ await ext.alarms.create('locker-clipboard-clear', { when: Date.now() + 30000 });++ return;++ }++ if (!await ext.offscreen.hasDocument()) await ext.offscreen.createDocument({++ url: 'clipboard.html', reasons: ['CLIPBOARD'], justification: 'Copy a saved password or TOTP code and clear it after 30 seconds.',++ });++ const result = await ext.runtime.sendMessage({ target: 'locker-clipboard', value });++ if (!result?.ok) throw new Error('Clipboard unavailable.');++ },++ },scripting: {++ async fillLogin(tabId, login, submit) {++ const out = await call(ext.scripting.executeScript, ext.scripting, [{++ target: { tabId, allFrames: false }, world: 'ISOLATED',++ func: self.OVLockerFill.fill, args: [login, submit === true, true],++ }]);++ return out?.[0]?.result || { ok: false };++ },async tryCodes(tabId, codes) {if (!ext.scripting || !Number.isInteger(tabId)) throw new TypeError('this browser cannot run the code tester');const list = (Array.isArray(codes) ? codes : []).slice(0, config.MAX_TRIES).map(function (c) { return { id: String(c.id || ''), code: String(c.code || '') }; });@@@ -37,16 +37,20 @@"activeTab","identity","scripting",-- "storage"++ "storage",++ "alarms",++ "clipboardWrite",++ "offscreen"],"host_permissions": ["https://openvibe.coupons/*","https://openvibe.network/*","https://openvibe.codes/*",-- "https://openvibe.chat/*"++ "https://openvibe.chat/*",++ "https://openvibe.locker/*"],"content_security_policy": {-- "extension_pages": "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob:; connect-src https://openvibe.coupons https://openvibe.network https://openvibe.chat https://openvibe.codes https://openvibe.email; frame-src https://openvibe.live; object-src 'none'; base-uri 'none'"++ "extension_pages": "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob:; connect-src https://openvibe.coupons https://openvibe.network https://openvibe.chat https://openvibe.codes https://openvibe.email https://openvibe.locker; frame-src https://openvibe.live; object-src 'none'; base-uri 'none'"},"minimum_chrome_version": "114","browser_specific_settings": {@@@ -1,7 +1,7 @@:root { color-scheme: light dark; --fg: #1d2433; --muted: #5d6b82; --bg: #ffffff; --card: #f4f6fa; --line: #d6dce6; --accent: #1d4ed8; --accent-strong: #2563eb; --on-accent-strong: #ffffff; --code: #b45309; --ok: #15803d; --bad: #b91c1c; }@media (prefers-color-scheme: dark) { :root { --fg: #e6edf7; --muted: #9aa8bf; --bg: #0f1522; --card: #172033; --line: #2a3650; --accent: #60a5fa; --accent-strong: #2563eb; --on-accent-strong: #ffffff; --code: #f59e0b; --ok: #22c55e; --bad: #f87171; } }* { box-sizing: border-box; }--body { margin: 0; width: 360px; max-height: 560px; overflow-y: auto; background: var(--bg); color: var(--fg); font: 13px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }++body { margin: 0; width: 360px; max-width: 100vw; max-height: 560px; overflow-y: auto; background: var(--bg); color: var(--fg); font: 13px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }header, main, footer { padding: 10px 12px; }header { border-bottom: 1px solid var(--line); }h1 { font-size: 14px; margin: 0; }@@@ -33,3 +33,18 @@ body.options input { font: inherit; padding: 4px 6px; max-width: 100%; }.try ol { margin: 6px 0 2px; padding-left: 18px; }.try li.worked { color: var(--ok); }.try li.failed { color: var(--bad); }++++.locker { padding: 12px; border-bottom: 1px solid var(--line); }++.locker-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; }++.locker-heading h2 { margin: 0; }++.locker-form { display: grid; gap: 10px; margin: 12px 0; }++.locker-field { display: grid; gap: 4px; font-size: 12px; font-weight: 600; }++.locker-field input, .locker-field select { width: 100%; min-width: 0; min-height: 44px; padding: 8px; border: 1px solid var(--line); border-radius: 7px; background: var(--bg); color: var(--fg); font: inherit; }++.locker .btn { min-height: 44px; }++.locker a { display: inline-block; margin-right: 12px; padding-block: 8px; }++.locker-origin { overflow-wrap: anywhere; font-size: 12px; color: var(--muted); }++.locker-login { margin: 10px 0; padding: 12px; border-radius: 9px; background: var(--card); border: 1px solid var(--line); }++.locker-login h3 { margin: 0 0 10px; font-size: 13px; overflow-wrap: anywhere; }++.locker-save { margin-top: 12px; border-top: 1px solid var(--line); padding-top: 10px; }++.locker-save summary { cursor: pointer; padding: 8px 0; font-weight: 600; }++.locker input:focus-visible, .locker select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }@@@ -10,6 +10,7 @@<h1>OpenVibe Companion</h1><p id="site" class="muted"></p></header>++<section id="locker" class="locker" aria-label="Locker passwords"></section><section id="masks" class="masks" aria-label="Masks for this site" hidden><div id="masks-body"></div></section>@@@ -38,6 +39,7 @@<script src="lib/controller.js"></script><script src="lib/mask.js"></script><script src="lib/mask-panel.js"></script>++<script src="lib/locker-panel.js"></script><script src="popup.js"></script></body></html>@@@ -29,6 +29,8 @@});}++ self.OVLockerPanel.mount({ container: document.getElementById('locker'), ask: askWorker, platform });++function el(tag, attrs) {const node = document.createElement(tag);const props = attrs || {};nnew file mode 100644@@@ -0,0 +1,1715 @@++/* Generated by scripts/build-locker-core.js from openvibe-sdk 0.101.0 (src/locker). Do not edit: change the SDK,++ * release it, bump the pin, and run the script. The SDK's files, unchanged but for the names of require and module:++ * bytes.js 80a5a560c683f489++ * errors.js e73f94b7711452e7++ * format.js f7793a59fdb67ad9++ * index.js eee174e3a87df494++ * inspect.js 2f28ff0da8c96a4e++ * keys.js fbe20e7949924f2b++ * padme.js e0dd4b08e0d0f24f++ * recovery.js 2829d07a02561674++ * seal.js ff6ad448082c636c++ * server.js 6177fc986b2b705d++ * words.js 8f4f51abdadfbfd1++ */++(function (root) {++ 'use strict';++ if (!root || root.OVLockerCore) return;++ const defs = {++ "bytes": function (__ovLockerModule, exports, __ovLockerReq) {++'use strict';++/**++ * Byte helpers for the Locker envelope. Framing only, no cryptography: every value is a++ * Uint8Array, so the same code runs in Node and in the browser.++ */++++const encoder = new TextEncoder();++const decoder = new TextDecoder();++++/** The UTF-8 bytes of a string. */++function utf8(text) { return encoder.encode(text); }++++/** The string of UTF-8 bytes. */++function fromUtf8(bytes) { return decoder.decode(bytes); }++++/** One buffer of all the parts, in order. */++function concat(...parts) {++ let total = 0;++ for (const part of parts) total += part.length;++ const out = new Uint8Array(total);++ let at = 0;++ for (const part of parts) { out.set(part, at); at += part.length; }++ return out;++}++++/** Standard base64. btoa/atob are in every browser and in Node. */++function base64(bytes) {++ let binary = '';++ for (const byte of bytes) binary += String.fromCharCode(byte);++ return btoa(binary);++}++++/** The bytes of a standard base64 string. */++function fromBase64(text) {++ const binary = atob(text);++ const out = new Uint8Array(binary.length);++ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);++ return out;++}++++/** base64url: the same alphabet without the padding, for keys that travel in a URL. */++function base64url(bytes) { return base64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }++++/** The bytes of a base64url string. */++function fromBase64url(text) {++ const padded = text.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (text.length % 4)) % 4);++ return fromBase64(padded);++}++++/** A number as four bytes, big endian. */++function u32be(value) {++ const out = new Uint8Array(4);++ new DataView(out.buffer).setUint32(0, value >>> 0, false);++ return out;++}++++/** The number four bytes at `at` hold, big endian. */++function readU32be(bytes, at) { return new DataView(bytes.buffer, bytes.byteOffset + at, 4).getUint32(0, false); }++++/** A number as eight bytes, big endian. */++function u64be(value) {++ const out = new Uint8Array(8);++ new DataView(out.buffer).setBigUint64(0, BigInt(value), false);++ return out;++}++++/** The number eight bytes at `at` hold, big endian. */++function readU64be(bytes, at) { return new DataView(bytes.buffer, bytes.byteOffset + at, 8).getBigUint64(0, false); }++++/**++ * The canonical JSON of a value: object keys sorted, no whitespace. Two sides that hold the++ * same value therefore hold the same bytes, which is what a signature or an associated data++ * needs. (JSON.stringify would keep the order the object was built in, which is not a promise++ * we can make across two implementations.)++ */++function canonicalJson(value) {++ if (value === null || typeof value !== 'object') return JSON.stringify(value);++ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;++ const keys = Object.keys(value).filter(key => value[key] !== undefined).sort();++ return `{${keys.map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;++}++++__ovLockerModule.exports = {++ utf8, fromUtf8, concat, base64, fromBase64, base64url, fromBase64url,++ u32be, readU32be, u64be, readU64be, canonicalJson,++};++++ },++ "errors": function (__ovLockerModule, exports, __ovLockerReq) {++'use strict';++/**++ * The one error Locker throws. The code says what went wrong, so a caller can tell a wrong++ * password from a truncated download from bytes that are not an envelope at all.++ *++ * The codes: locker.invalid (the caller asked for something impossible), locker.bad_envelope++ * (the bytes are not an envelope we wrote), locker.truncated (the bytes stop early),++ * locker.wrong_key (the key is not the one), locker.no_key (no key was given), locker.no_slot++ * (there is no slot for this key), locker.size_mismatch (the size does not add up) and++ * locker.no_crypto (the runtime has no WebCrypto).++ */++class LockerError extends Error {++ constructor(code, message) {++ super(message);++ this.name = 'LockerError';++ this.code = code;++ }++}++++/** A Locker error, or a plain Error with the same shape. */++function fail(code, message) { throw new LockerError(code, message); }++++__ovLockerModule.exports = { LockerError, fail };++++ },++ "format": function (__ovLockerModule, exports, __ovLockerReq) {++'use strict';++/**++ * The Locker envelope on the wire (locker.envelope@1). This file is the format and nothing++ * else: the numbers, the framing and the associated data. The cryptography lives in keys.js++ * and seal.js; the rules for a service that only reads the header live in server.js.++ *++ * 0 4 8 8 + header length++ * +------+------+--------------------------+--------------------------+++ * | OVLK | len | header JSON (UTF-8) | chunk frames |++ * +------+------+--------------------------+--------------------------+++ *++ * each chunk frame:++ * 0 4 5 17++ * +------+------+--------------------------+--------------------------+++ * | len | flags| nonce (12 bytes) | ciphertext + 16-byte tag |++ * +------+------+--------------------------+--------------------------+++ */++const { utf8, fromUtf8, concat, base64, fromBase64, u32be, readU32be, canonicalJson } = __ovLockerReq('./bytes');++const { fail } = __ovLockerReq('./errors');++const { padme } = __ovLockerReq('./padme');++++/** The four bytes that say "this is a Locker envelope". */++const MAGIC = 'OVLK';++/** The format this library reads and writes. */++const VERSION = 'locker.envelope.v1';++/** The only suite in this version: AES-256-GCM, a 32-byte key. */++const SUITE = 'AES-256-GCM';++/** The chunk size in bytes: 1 MiB. */++const CHUNK_SIZE = 1048576;++/** The GCM tag is appended to the ciphertext. */++const TAG_SIZE = 16;++/** The GCM nonce is 12 bytes. */++const NONCE_SIZE = 12;++/** The first half of every nonce: eight random bytes, in the header. */++const NONCE_BASE_SIZE = 8;++/** The padded plaintext starts with the true length, as eight bytes. */++const LENGTH_PREFIX = 8;++/**++ * The smallest padded plaintext we write. A short note still costs 256 bytes: below that the++ * padded length would say too much about the real one (Padmé's grid is coarse down there), and++ * 256 bytes is nothing next to a chunk.++ */++const MIN_PADDED_SIZE = 256;++/** A header is small. A bigger one is not a header we wrote, and a service must not read it. */++const MAX_HEADER_BYTES = 65536;++/** The bit that marks the last chunk of an envelope. */++const FINAL_FLAG = 0x01;++++/**++ * The header fields every chunk authenticates. The slots are not in here, on purpose: adding a++ * member to a people-locked item re-wraps the content key without re-encrypting the content, so++ * the core cannot change either. Each slot is authenticated by its own tag instead.++ */++function coreOf(header) {++ return {++ version: header.version,++ suite: header.suite,++ chunk_size: header.chunk_size,++ padded_size: header.padded_size,++ nonce_base: header.nonce_base,++ lock: header.lock,++ };++}++++/** The canonical JSON of the core: the same bytes on both sides, whatever order the header was written in. */++function coreBytes(header) { return utf8(canonicalJson(coreOf(header))); }++++/** The associated data of one chunk: the core, the chunk counter and the flags. */++function chunkAad(header, counter, flags) {++ return concat(coreBytes(header), u32be(counter), Uint8Array.of(flags));++}++++/** The header as the bytes that go on the wire. */++function encodeHeader(header) { return utf8(canonicalJson(header)); }++++/** The header's JSON bytes, as they were written. */++function decodeHeader(headerBytes) {++ let header;++ try { header = JSON.parse(fromUtf8(headerBytes)); }++ catch { fail('locker.bad_envelope', 'The envelope header is not JSON'); }++ return header;++}++++/** The whole envelope: the magic, the header and the chunks. */++function encodeEnvelope(headerBytes, chunks) {++ return concat(utf8(MAGIC), u32be(headerBytes.length), headerBytes, chunks);++}++++/**++ * Reads the header out of envelope bytes. It stops at the header: the chunks are not touched,++ * so this is safe to do on bytes that are not an envelope at all.++ */++function readHeader(bytes) {++ if (!bytes || bytes.length < 8) fail('locker.bad_envelope', 'The envelope is too short to hold a header');++ const magic = fromUtf8(bytes.slice(0, 4));++ if (magic !== MAGIC) fail('locker.bad_envelope', 'These bytes are not a Locker envelope');++ const headerLength = readU32be(bytes, 4);++ if (headerLength === 0 || headerLength > MAX_HEADER_BYTES) {++ fail('locker.bad_envelope', `The envelope header is ${headerLength} bytes, which is not one we write`);++ }++ if (bytes.length < 8 + headerLength) fail('locker.truncated', 'The envelope ends inside its header');++ const headerBytes = bytes.slice(8, 8 + headerLength);++ return { header: decodeHeader(headerBytes), headerBytes, chunkOffset: 8 + headerLength };++}++++/**++ * How many chunks a padded plaintext of this size takes. Every chunk but the last is a whole++ * chunk; the last holds what is left of it.++ */++function chunkCount(padded) { return Math.max(1, Math.ceil(padded / CHUNK_SIZE)); }++++/** How many bytes the last chunk of a padded plaintext of this size holds: more than zero. */++function lastChunkSize(padded) { return padded - (chunkCount(padded) - 1) * CHUNK_SIZE; }++++/**++ * The padded size of a content of this size: the length prefix plus the content, padded with++ * Padmé up to the next size on its grid, and never below the floor.++ */++function paddedSize(size) { return Math.max(MIN_PADDED_SIZE, padme(LENGTH_PREFIX + size)); }++++/** The nonce of a chunk: the header's base, then the counter. No nonce repeats inside an envelope. */++function chunkNonce(nonceBase, counter) {++ const nonce = new Uint8Array(NONCE_SIZE);++ nonce.set(nonceBase, 0);++ new DataView(nonce.buffer).setUint32(8, counter >>> 0, false);++ return nonce;++}++++/** One chunk frame: its length, its flags, its nonce and the ciphertext with the tag. */++function encodeChunk(counter, flags, nonce, ciphertext) {++ return concat(u32be(ciphertext.length), Uint8Array.of(flags), nonce, ciphertext);++}++++/** The header's nonce base as bytes. */++function nonceBaseBytes(header) {++ const bytes = fromBase64(header.nonce_base);++ if (bytes.length !== NONCE_BASE_SIZE) fail('locker.bad_envelope', 'The envelope nonce base is not eight bytes');++ return bytes;++}++++__ovLockerModule.exports = {++ MAGIC, VERSION, SUITE, CHUNK_SIZE, TAG_SIZE, NONCE_SIZE, NONCE_BASE_SIZE, LENGTH_PREFIX,++ MIN_PADDED_SIZE, MAX_HEADER_BYTES, FINAL_FLAG,++ coreOf, coreBytes, chunkAad, encodeHeader, decodeHeader, encodeEnvelope, readHeader,++ chunkCount, lastChunkSize, paddedSize, chunkNonce, encodeChunk, nonceBaseBytes,++};++++ },++ "index": function (__ovLockerModule, exports, __ovLockerReq) {++'use strict';++/**++ * openvibe-sdk/locker: end-to-end encryption for anything on OpenVibe.++ *++ * One format, one library, two runtimes: Node and the browser, WebCrypto only. A locked item is++ * stored and served as ciphertext; the keys never reach our servers.++ *++ * const { seal, open, generatePersonKeys } = locker; // openvibe-sdk/locker++ * const person = await generatePersonKeys();++ * const sealed = await seal('a note', { lock: 'people', keys: [{ subject, publicKey: person.ecdhPublic }] });++ * const note = await open(sealed, { privateKey: person.ecdh.privateKey, subject });++ */++const format = __ovLockerReq('./format');++const keys = __ovLockerReq('./keys');++const seal = __ovLockerReq('./seal');++const { padme } = __ovLockerReq('./padme');++const { inspect } = __ovLockerReq('./inspect');++const { recoveryWords, isRecoveryPhrase, RECOVERY_WORDS } = __ovLockerReq('./recovery');++const { WORDS } = __ovLockerReq('./words');++const { LockerError } = __ovLockerReq('./errors');++++__ovLockerModule.exports = {++ // The format.++ VERSION: format.VERSION,++ SUITE: format.SUITE,++ CHUNK_SIZE: format.CHUNK_SIZE,++ MIN_PADDED_SIZE: format.MIN_PADDED_SIZE,++ MAX_HEADER_BYTES: format.MAX_HEADER_BYTES,++ PASSWORD_ITERATIONS: keys.PASSWORD_ITERATIONS,++ PASSWORD_ITERATIONS_MIN: keys.PASSWORD_ITERATIONS_MIN,++ PASSWORD_ITERATIONS_MAX: keys.PASSWORD_ITERATIONS_MAX,++++ // The content.++ seal: seal.seal,++ open: seal.open,++ sealStream: seal.sealStream,++ openStream: seal.openStream,++ rewrap: seal.rewrap,++++ // What an envelope says about itself, without a key.++ inspect,++++ // The padding: the padded size of a content of a given size.++ padme,++++ // The recovery phrase: words to write down, and a check on a typed one.++ recoveryWords,++ isRecoveryPhrase,++ RECOVERY_WORDS,++ WORDS,++++ // A person's keys, and the eight words that say which keys they are.++ generatePersonKeys: keys.generatePersonKeys,++ exportPersonKeys: keys.exportPersonKeys,++ exportPrivateKeys: keys.exportPrivateKeys,++ importPersonKeys: keys.importPersonKeys,++ verifyPersonKeys: keys.verifyPersonKeys,++ fingerprintOf: keys.fingerprintOf,++ fingerprintWords: keys.fingerprintWords,++ jwkThumbprint: keys.jwkThumbprint,++++ // The key slots: the content key, wrapped for one person or for one password.++ wrapFor: keys.wrapFor,++ wrapForPassword: keys.wrapForPassword,++ unwrapPeopleSlot: keys.unwrapPeopleSlot,++ unwrapPasswordSlot: keys.unwrapPasswordSlot,++++ LockerError,++};++++ },++ "inspect": function (__ovLockerModule, exports, __ovLockerReq) {++'use strict';++/**++ * inspect: what an envelope says about itself, without a key and without decrypting anything.++ *++ * A service, or a page that shows a locked item, often needs to know the lock kind, how big++ * the item is, who it is for and how strong the password is — before it has any key at all.++ * This reads the header and nothing else: no chunk is touched and no key is used.++ *++ * The header holds no content, only wrapped keys, so this says nothing the server does not++ * already see.++ */++const { envelopeBytes, readCore } = __ovLockerReq('./seal');++const { readHeader } = __ovLockerReq('./format');++++/**++ * What an envelope says about itself.++ *++ * { version, lock, padded_size, recipients, iterations }++ *++ * `recipients` holds the thumbprint of each recipient's key for a people lock (the same++ * strings the slots carry), and `iterations` the PBKDF2 count for a password lock. The other++ * field is empty for the other lock kinds.++ */++function inspect(bytes) {++ const data = envelopeBytes(bytes);++ const { header } = readHeader(data);++ readCore(header);++ const out = {++ version: header.version,++ lock: header.lock,++ padded_size: header.padded_size,++ recipients: [],++ iterations: null,++ };++ if (header.lock === 'people') {++ out.recipients = (header.slots.people || []).map(slot => slot.recipient_key);++ } else if (header.lock === 'password' && header.slots.password) {++ out.iterations = header.slots.password.iterations;++ }++ return out;++}++++__ovLockerModule.exports = { inspect };++++ },++ "keys": function (__ovLockerModule, exports, __ovLockerReq) {++'use strict';++/**++ * The keys: a person's two key pairs, the content key, and the two ways of wrapping the content++ * key for someone else (their public key, or a password).++ *++ * WebCrypto only: globalThis.crypto.subtle. No other crypto library, nothing hand-rolled: the++ * ECDH, the ECDSA, the HKDF, the PBKDF2 and the AES-GCM are all the browser's own.++ */++const { utf8, concat, base64, base64url, fromBase64, canonicalJson } = __ovLockerReq('./bytes');++const { fail } = __ovLockerReq('./errors');++const { WORDS } = __ovLockerReq('./words');++++/** The suite of the key pairs: P-256, which every browser's WebCrypto has. */++const CURVE = 'P-256';++/** How the content key is wrapped for a person. */++const PEOPLE_ALGORITHM = 'ECDH-P256/HKDF-SHA256/AES-256-GCM';++/** How the content key is wrapped for a password. */++const PASSWORD_ALGORITHM = 'PBKDF2-SHA256/AES-256-GCM';++/** The PBKDF2 floor for this version. The count may go up; it may not go down. */++const PASSWORD_ITERATIONS = 600000;++/** The smallest PBKDF2 count we accept, on both sides: the floor above. */++const PASSWORD_ITERATIONS_MIN = 600000;++/**++ * The largest PBKDF2 count we accept. The count comes from the header, so a hostile header could++ * ask for a derivation that takes hours; the ceiling keeps a browser honest.++ */++const PASSWORD_ITERATIONS_MAX = 10000000;++/** The content key is 32 bytes: AES-256. */++const KEY_BYTES = 32;++/** The salt of a people slot (HKDF) and of a password slot (PBKDF2). */++const SALT_BYTES = 16;++++/** WebCrypto, or a clear error when the runtime has none. */++function subtle() {++ const crypto = globalThis.crypto;++ if (!crypto || !crypto.subtle) {++ fail('locker.no_crypto', 'This runtime has no WebCrypto (globalThis.crypto.subtle)');++ }++ return crypto.subtle;++}++++/** Random bytes from the platform. */++function platformRandom(length) {++ const bytes = new Uint8Array(length);++ globalThis.crypto.getRandomValues(bytes);++ return bytes;++}++++/** The SHA-256 of some bytes. */++async function sha256(bytes) {++ return new Uint8Array(await subtle().digest('SHA-256', bytes));++}++++/** The bytes of a public JWK, as they go on the wire: no key_ops, no ext. */++function publicJwk(jwk) {++ return { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };++}++++/** The message a person signs: both public keys, canonically. */++function fingerprintMessage(ecdhPublic, signingPublic) {++ return utf8(canonicalJson({ ecdh: publicJwk(ecdhPublic), signing: publicJwk(signingPublic) }));++}++++/**++ * The eight words of a fingerprint: the first eight bytes of SHA-256, eight bits each, in two++ * groups of four. Sixty-four bits, not thirty-two: a server that swapped in its own key would++ * need about 2^32 key generations to land on the same four words, which is not "verify a++ * person"; with eight words it would need 2^64.++ *++ * The two groups are joined with a middle dot, not a hyphen: a hyphen reads as one compound++ * word ("plateau yew-apple petal"), and the groups are meant to be read apart.++ */++function fingerprintWords(hash) {++ const words = [];++ for (let i = 0; i < 8; i++) words.push(WORDS[hash[i]]);++ return `${words.slice(0, 4).join(' ')} · ${words.slice(4).join(' ')}`;++}++++/**++ * The RFC 7638 thumbprint of a public JWK: SHA-256 over the canonical JSON of its four++ * required members, base64url. It names one key and nothing else, so a slot can be bound to++ * the key of the person it is for.++ */++async function jwkThumbprint(jwk) {++ const canonical = canonicalJson({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });++ return base64url(await sha256(utf8(canonical)));++}++++/** The fingerprint of a pair of public keys. */++async function fingerprintOf(ecdhPublic, signingPublic) {++ return fingerprintWords(await sha256(fingerprintMessage(ecdhPublic, signingPublic)));++}++++/**++ * A person's two key pairs: an ECDH pair that receives content keys, and an ECDSA pair that signs++ * the fingerprint. The signing key signs both public keys, so the pair is self-certifying: a++ * reader can check that the holder owns the signing key, and the fingerprint changes whenever++ * either key changes.++ */++async function generatePersonKeys() {++ const s = subtle();++ const ecdh = await s.generateKey({ name: 'ECDH', namedCurve: CURVE }, true, ['deriveBits']);++ const signing = await s.generateKey({ name: 'ECDSA', namedCurve: CURVE }, true, ['sign', 'verify']);++ const ecdhPublic = publicJwk(await s.exportKey('jwk', ecdh.publicKey));++ const signingPublic = publicJwk(await s.exportKey('jwk', signing.publicKey));++ const message = fingerprintMessage(ecdhPublic, signingPublic);++ const signature = new Uint8Array(await s.sign({ name: 'ECDSA', hash: 'SHA-256' }, signing.privateKey, message));++ return {++ ecdh: { publicKey: ecdh.publicKey, privateKey: ecdh.privateKey },++ signing: { publicKey: signing.publicKey, privateKey: signing.privateKey },++ ecdhPublic,++ signingPublic,++ fingerprint: fingerprintWords(await sha256(message)),++ fingerprintSignature: signature,++ };++}++++/** The public half of a person's keys, as it is published: locker.public-keys@1. */++async function exportPersonKeys(person) {++ const ecdhPublic = publicJwk(person.ecdhPublic || await subtle().exportKey('jwk', person.ecdh.publicKey));++ const signingPublic = publicJwk(person.signingPublic || await subtle().exportKey('jwk', person.signing.publicKey));++ const message = fingerprintMessage(ecdhPublic, signingPublic);++ const signature = person.fingerprintSignature++ || new Uint8Array(await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, (person.signing?.privateKey || person.signingPrivateKey), message));++ return {++ ecdh: ecdhPublic,++ signing: signingPublic,++ fingerprint: fingerprintWords(await sha256(message)),++ fingerprint_signature: base64(signature),++ };++}++++/**++ * The private half of a person's keys, as it is stored: the two private keys as JWKs.++ */++async function exportPrivateKeys(person) {++ const ecdh = await subtle().exportKey('jwk', person.ecdh?.privateKey || person.ecdhPrivateKey);++ const signing = await subtle().exportKey('jwk', person.signing?.privateKey || person.signingPrivateKey);++ return { ecdh, signing };++}++++/**++ * A person's keys, ready to use: the private keys as CryptoKeys and the public keys as JWKs.++ * The private keys may be JWKs (as they are stored) or CryptoKeys (as they are in memory).++ */++async function importPersonKeys(data) {++ const s = subtle();++ const ecdhPrivate = data.ecdhPrivateKey++ || await s.importKey('jwk', data.ecdh, { name: 'ECDH', namedCurve: CURVE }, true, ['deriveBits']);++ const signingPrivate = data.signingPrivateKey++ || await s.importKey('jwk', data.signing, { name: 'ECDSA', namedCurve: CURVE }, true, ['sign']);++ return {++ ecdhPrivateKey: ecdhPrivate,++ signingPrivateKey: signingPrivate,++ ecdhPublic: data.ecdh ? publicJwk(data.ecdh) : publicJwk(await s.exportKey('jwk', ecdhPrivate)),++ signingPublic: data.signing ? publicJwk(data.signing) : publicJwk(await s.exportKey('jwk', signingPrivate)),++ };++}++++/**++ * Checks a published set of keys: the signature must verify with the signing key, and the four++ * words must be the fingerprint of the two keys. Returns false instead of throwing, so a caller++ * can show a warning rather than an error.++ */++async function verifyPersonKeys(data) {++ try {++ const s = subtle();++ const message = fingerprintMessage(data.ecdh, data.signing);++ const signature = fromBase64(data.fingerprint_signature);++ const key = await s.importKey('jwk', data.signing, { name: 'ECDSA', namedCurve: CURVE }, true, ['verify']);++ const ok = await s.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, signature, message);++ if (!ok) return false;++ return fingerprintWords(await sha256(message)) === data.fingerprint;++ } catch {++ return false;++ }++}++++/** The content key as an AES-GCM CryptoKey. */++async function importContentKey(bytes, usages) {++ if (!bytes || bytes.length !== KEY_BYTES) fail('locker.invalid', 'The content key is 32 bytes');++ return subtle().importKey('raw', bytes, { name: 'AES-GCM', length: 256 }, false, usages);++}++++/** The wrapping key for a person: the ECDH secret, put through HKDF-SHA-256. */++async function deriveWrappingKey(sharedSecret, salt, info) {++ const s = subtle();++ const material = await s.importKey('raw', sharedSecret, 'HKDF', false, ['deriveKey']);++ return s.deriveKey(++ { name: 'HKDF', hash: 'SHA-256', salt, info },++ material,++ { name: 'AES-GCM', length: 256 },++ false,++ ['encrypt', 'decrypt'],++ );++}++++/** The wrapping key for a password: PBKDF2-SHA-256, with the parameters the header names. */++async function derivePasswordKey(password, salt, iterations) {++ if (typeof password !== 'string' || !password.length) fail('locker.invalid', 'A password lock needs a password');++ // The count comes from the header, so it is checked before it is used: a hostile header must++ // not be able to make a reader spend minutes (or hours) on the derivation.++ if (!Number.isInteger(iterations) || iterations < PASSWORD_ITERATIONS_MIN || iterations > PASSWORD_ITERATIONS_MAX) {++ fail('locker.bad_envelope', `The iteration count is not between ${PASSWORD_ITERATIONS_MIN} and ${PASSWORD_ITERATIONS_MAX}`);++ }++ const s = subtle();++ const material = await s.importKey('raw', utf8(password), 'PBKDF2', false, ['deriveKey']);++ return s.deriveKey(++ { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },++ material,++ { name: 'AES-GCM', length: 256 },++ false,++ ['encrypt', 'decrypt'],++ );++}++++/**++ * The associated data of a people slot: it binds the slot to one person, to that person's key++ * and to one envelope.++ */++function peopleSlotAad(subject, ephemeralPublic, salt, recipientKey) {++ const id = typeof subject === 'string' ? subject : `${subject.type}:${subject.id}`;++ return utf8(`openvibe-locker people-slot v1|${id}|${recipientKey}|${canonicalJson(publicJwk(ephemeralPublic))}|${base64(salt)}`);++}++++/** The associated data of a password slot: it binds the slot to its own parameters. */++function passwordSlotAad(iterations, salt) {++ return utf8(`openvibe-locker password-slot v1|${iterations}|${base64(salt)}`);++}++++/** A public key as WebCrypto wants it. */++async function importPublicKey(jwk) {++ try {++ return await subtle().importKey('jwk', publicJwk(jwk), { name: 'ECDH', namedCurve: CURVE }, true, []);++ } catch {++ fail('locker.invalid', 'The public key is not a P-256 key');++ }++}++++/**++ * The envelope's one ephemeral ECDH key pair. It may be given (the shared test vectors need the++ * same bytes every time), or made fresh.++ */++async function resolveEphemeral(ephemeral) {++ const s = subtle();++ if (!ephemeral) {++ const pair = await s.generateKey({ name: 'ECDH', namedCurve: CURVE }, true, ['deriveBits']);++ return { privateKey: pair.privateKey, publicKey: pair.publicKey };++ }++ if (ephemeral.privateKey) return ephemeral;++ const privateKey = await s.importKey('jwk', ephemeral.private, { name: 'ECDH', namedCurve: CURVE }, true, ['deriveBits']);++ return { privateKey };++}++++/**++ * Wraps the content key for one person: the content key, sealed for their public key.++ * The envelope has one ephemeral ECDH key pair; the shared secret with each person goes through++ * HKDF-SHA-256, and the slot's tag covers the person, their key, the ephemeral key and the++ * salt, so a slot cannot be moved to another person or replayed from another envelope.++ */++async function wrapFor(contentKey, publicKey, { subject, salt, random = platformRandom, ephemeral } = {}) {++ const s = subtle();++ if (!subject) fail('locker.invalid', 'A people slot needs the subject it is for');++ const theirs = await importPublicKey(publicKey);++ const pair = await resolveEphemeral(ephemeral);++ const ephemeralPublic = publicJwk(await s.exportKey('jwk', pair.publicKey || pair.privateKey));++ // The slot names the key it is for, so an opener can check it before deriving anything.++ const recipientKey = await jwkThumbprint(publicJwk(publicKey));++ const shared = new Uint8Array(await s.deriveBits({ name: 'ECDH', public: theirs }, pair.privateKey, 256));++ const slotSalt = salt || random(SALT_BYTES);++ const key = await deriveWrappingKey(shared, slotSalt, utf8(`openvibe-locker people-slot v1|${subject.type}:${subject.id}|${recipientKey}`));++ const nonce = random(12);++ const sealed = new Uint8Array(await s.encrypt(++ { name: 'AES-GCM', iv: nonce, additionalData: peopleSlotAad(subject, ephemeralPublic, slotSalt, recipientKey) },++ key,++ contentKey,++ ));++ return {++ subject,++ algorithm: PEOPLE_ALGORITHM,++ recipient_key: recipientKey,++ ephemeral_public_key: ephemeralPublic,++ salt: base64(slotSalt),++ nonce: base64(nonce),++ wrapped_key: base64(sealed),++ };++}++++/** The content key out of a people slot: the recipient's private key and the slot. */++async function unwrapPeopleSlot(slot, privateKey) {++ const s = subtle();++ // The slot names the key it was made for. If it is not this key, the slot is not this++ // person's, and nothing is derived: the check comes before the work.++ if (!slot.recipient_key) fail('locker.bad_envelope', 'The slot does not name the key it is for');++ const theirPublic = publicJwk(await s.exportKey('jwk', privateKey));++ const recipientKey = await jwkThumbprint(theirPublic);++ if (slot.recipient_key !== recipientKey) {++ fail('locker.wrong_key', 'This slot is for another key');++ }++ const theirs = await s.importKey('jwk', publicJwk(slot.ephemeral_public_key), { name: 'ECDH', namedCurve: CURVE }, true, []);++ const shared = new Uint8Array(await s.deriveBits({ name: 'ECDH', public: theirs }, privateKey, 256));++ const salt = fromBase64(slot.salt);++ const key = await deriveWrappingKey(shared, salt, utf8(`openvibe-locker people-slot v1|${slot.subject.type}:${slot.subject.id}|${recipientKey}`));++ try {++ const plain = new Uint8Array(await s.decrypt(++ { name: 'AES-GCM', iv: fromBase64(slot.nonce), additionalData: peopleSlotAad(slot.subject, slot.ephemeral_public_key, salt, recipientKey) },++ key,++ fromBase64(slot.wrapped_key),++ ));++ if (plain.length !== KEY_BYTES) fail('locker.wrong_key', 'The slot does not hold a content key');++ return plain;++ } catch (err) {++ if (err && err.code) throw err;++ fail('locker.wrong_key', 'This key does not open that slot');++ }++}++++/** Wraps the content key with a password: PBKDF2-SHA-256, then AES-256-GCM. */++async function wrapForPassword(contentKey, password, { salt, iterations = PASSWORD_ITERATIONS, random = platformRandom } = {}) {++ const s = subtle();++ const slotSalt = salt || random(SALT_BYTES);++ const key = await derivePasswordKey(password, slotSalt, iterations);++ const nonce = random(12);++ const sealed = new Uint8Array(await s.encrypt(++ { name: 'AES-GCM', iv: nonce, additionalData: passwordSlotAad(iterations, slotSalt) },++ key,++ contentKey,++ ));++ return {++ algorithm: PASSWORD_ALGORITHM,++ kdf: 'PBKDF2-SHA-256',++ iterations,++ salt: base64(slotSalt),++ nonce: base64(nonce),++ wrapped_key: base64(sealed),++ };++}++++/** The content key out of a password slot. The parameters come from the slot, never from here. */++async function unwrapPasswordSlot(slot, password) {++ const s = subtle();++ const salt = fromBase64(slot.salt);++ const key = await derivePasswordKey(password, salt, slot.iterations);++ try {++ const plain = new Uint8Array(await s.decrypt(++ { name: 'AES-GCM', iv: fromBase64(slot.nonce), additionalData: passwordSlotAad(slot.iterations, salt) },++ key,++ fromBase64(slot.wrapped_key),++ ));++ if (plain.length !== KEY_BYTES) fail('locker.wrong_key', 'The slot does not hold a content key');++ return plain;++ } catch (err) {++ if (err && err.code) throw err;++ fail('locker.wrong_key', 'That password does not open this item');++ }++}++++__ovLockerModule.exports = {++ CURVE, PEOPLE_ALGORITHM, PASSWORD_ALGORITHM, PASSWORD_ITERATIONS,++ PASSWORD_ITERATIONS_MIN, PASSWORD_ITERATIONS_MAX, KEY_BYTES, SALT_BYTES,++ subtle, platformRandom, sha256, publicJwk, fingerprintMessage, fingerprintWords, fingerprintOf,++ jwkThumbprint, generatePersonKeys, exportPersonKeys, exportPrivateKeys, importPersonKeys,++ verifyPersonKeys, importContentKey, deriveWrappingKey, derivePasswordKey, resolveEphemeral,++ peopleSlotAad, passwordSlotAad, wrapFor, unwrapPeopleSlot, wrapForPassword, unwrapPasswordSlot,++};++++ },++ "padme": function (__ovLockerModule, exports, __ovLockerReq) {++'use strict';++/**++ * Padmé padding (Nikitin et al., "Reducing Metadata Leakage from Encrypted Files and Network++ * Traffic", PoPETs 2019).++ *++ * The point of padding a locked item: the server must not learn how big it is. Rounding the size++ * up to a whole 1 MiB chunk says too much — a 200-byte note becomes a 1 MiB object, and every++ * item under a megabyte looks the same, which is a leak of its own.++ *++ * Padmé rounds the length up to the next size on a grid that gets finer as the length grows:++ *++ * E = floor(log2(n)) the band the length is in++ * S = floor(log2(E)) + 1 how many steps the band has++ * d = 2^(E - S) the step of the band++ * P(n) = d * ceil(n / d) the padded length++ *++ * The padded length is one of a small number of values per band, and the bands double, so the++ * number of possible padded lengths up to n grows like log(n): knowing the padded length says++ * O(log log n) bits about the real one. The expansion stays under 12% (the worst case is a++ * length just above a band boundary, 129 bytes padded to 144).++ *++ * The arithmetic is exact above 2^31: there are no 32-bit bitwise operators here, only++ * comparisons and division by a power of two, both exact on the doubles we have.++ */++++/** The largest e with 2^e <= size: floor(log2(size)), without a logarithm. */++function exponentOf(size) {++ let e = 0;++ let power = 1;++ while (power * 2 <= size) { power *= 2; e++; }++ return e;++}++++/**++ * The padded length of a content of this size: the smallest length on Padmé's grid that is not++ * smaller than it. Zero for zero, which the caller handles.++ */++function padme(size) {++ if (!Number.isInteger(size) || size <= 0) return 0;++ if (size === 1) return 1;++ const band = exponentOf(size);++ const steps = exponentOf(band) + 1;++ const step = Math.pow(2, band - steps);++ // Dividing by a power of two is exact, so the ceiling is the ceiling and not nearly it.++ return Math.ceil(size / step) * step;++}++++__ovLockerModule.exports = { padme, exponentOf };++++ },++ "recovery": function (__ovLockerModule, exports, __ovLockerReq) {++'use strict';++/**++ * The recovery phrase: the words a person writes down.++ *++ * A phrase of n words from the same 256-word list the fingerprint uses, one byte per word from++ * the platform's CSPRNG (crypto.getRandomValues). Eight bits per word, so the usual 24 words++ * carry 192 bits: enough for a key nobody else has, and short enough to write on paper.++ *++ * There is no checksum in the phrase: the words are the secret, and a phrase that is typed++ * wrong simply does not open anything. isRecoveryPhrase only says whether every word is one of++ * the 256, so a UI can tell a typo from a word that was never in the list.++ */++const { fail } = __ovLockerReq('./errors');++const { platformRandom } = __ovLockerReq('./keys');++const { WORDS } = __ovLockerReq('./words');++++/** How many words a recovery phrase has, unless the caller says otherwise. */++const RECOVERY_WORDS = 24;++++/**++ * A recovery phrase: n words from the list, one byte each.++ *++ * recoveryWords() // 24 words, 192 bits++ * recoveryWords(12) // 12 words, 96 bits++ */++function recoveryWords(count = RECOVERY_WORDS, { random = platformRandom } = {}) {++ if (!Number.isInteger(count) || count < 1 || count > WORDS.length) {++ fail('locker.invalid', `A recovery phrase is one to ${WORDS.length} words`);++ }++ // One byte per word: the list has 256 words, so a byte picks one of them exactly.++ const bytes = random(count);++ const words = [];++ for (const byte of bytes) words.push(WORDS[byte]);++ return words.join(' ');++}++++/**++ * Whether a typed phrase is made of words from the list. It says nothing about the order or++ * about whether the phrase is the right one: only that every word could have come from us.++ */++function isRecoveryPhrase(phrase) {++ if (typeof phrase !== 'string') return false;++ const words = phrase.trim().split(/\s+/).filter(Boolean);++ if (!words.length) return false;++ return words.every(word => WORDS.includes(word.toLowerCase()));++}++++__ovLockerModule.exports = { RECOVERY_WORDS, recoveryWords, isRecoveryPhrase };++++ },++ "seal": function (__ovLockerModule, exports, __ovLockerReq) {++'use strict';++/**++ * seal and open: the content, the three locks, and the streams.++ *++ * The envelope is written once and read once. Every chunk is sealed with AES-256-GCM under the++ * content key, with the header's core, the chunk counter and the flags as associated data, so a++ * chunk cannot be cut, reordered or truncated without the open failing.++ */++const {++ utf8, fromUtf8, concat, base64, base64url, fromBase64url, u32be, readU32be, u64be, readU64be,++} = __ovLockerReq('./bytes');++const { fail } = __ovLockerReq('./errors');++const {++ MAGIC, VERSION, SUITE, CHUNK_SIZE, TAG_SIZE, NONCE_BASE_SIZE, LENGTH_PREFIX, MIN_PADDED_SIZE,++ FINAL_FLAG, coreOf, chunkAad, encodeHeader, encodeEnvelope, readHeader, chunkCount,++ lastChunkSize, paddedSize, chunkNonce, encodeChunk, nonceBaseBytes,++} = __ovLockerReq('./format');++const {++ KEY_BYTES, PASSWORD_ITERATIONS, subtle, platformRandom, importContentKey, publicJwk,++ jwkThumbprint, wrapFor, unwrapPeopleSlot, wrapForPassword, unwrapPasswordSlot, resolveEphemeral,++} = __ovLockerReq('./keys');++++/** The content as bytes: a string is UTF-8, anything else is taken as it is. */++function toBytes(content) {++ if (typeof content === 'string') return utf8(content);++ if (content instanceof Uint8Array) return content;++ if (content instanceof ArrayBuffer) return new Uint8Array(content);++ if (ArrayBuffer.isView(content)) return new Uint8Array(content.buffer, content.byteOffset, content.byteLength);++ fail('locker.invalid', 'The content must be a string or bytes');++}++++/** The bytes of an envelope: what seal returns, or what the caller kept. */++function envelopeBytes(envelope) {++ if (envelope instanceof Uint8Array) return envelope;++ if (envelope && envelope.bytes instanceof Uint8Array) return envelope.bytes;++ fail('locker.invalid', 'That is not a Locker envelope');++}++++/** A source of bytes: an async iterable of Uint8Array. */++async function* once(bytes) { yield bytes; }++++/**++ * Collects bytes into blocks of a fixed size, without ever holding more than one block and the++ * part that has not been used yet. This is what keeps a stream seal in constant memory.++ */++class Chunker {++ constructor(size) { this.size = size; this.parts = []; this.length = 0; }++++ push(part) { this.parts.push(part); this.length += part.length; }++++ /** The next full block, or null when there is not enough yet. */++ take() {++ if (this.length < this.size) return null;++ const block = new Uint8Array(this.size);++ let at = 0;++ while (at < this.size) {++ const head = this.parts[0];++ const take = Math.min(head.length, this.size - at);++ block.set(head.subarray(0, take), at);++ at += take;++ if (take === head.length) this.parts.shift();++ else this.parts[0] = head.subarray(take);++ }++ this.length -= this.size;++ return block;++ }++++ /** What is left, as one block of exactly this many bytes: the bytes that are there, then zeros. */++ takeExactly(count) {++ if (this.length > count) fail('locker.size_mismatch', 'The content is longer than the size it was given');++ const block = new Uint8Array(count);++ let at = 0;++ for (const part of this.parts) { block.set(part, at); at += part.length; }++ this.parts = [];++ this.length = 0;++ return block;++ }++}++++/** The header, the content key and the nonce base: everything a seal needs before it writes. */++async function prepare(options) {++ const lock = options.lock;++ if (!['people', 'password', 'link'].includes(lock)) {++ fail('locker.invalid', 'A lock is people, password or link');++ }++ const random = options.random || platformRandom;++ const contentKey = options.contentKey ? toBytes(options.contentKey) : random(KEY_BYTES);++ if (contentKey.length !== KEY_BYTES) fail('locker.invalid', 'The content key is 32 bytes');++ const nonceBase = options.nonceBase ? toBytes(options.nonceBase) : random(NONCE_BASE_SIZE);++ if (nonceBase.length !== NONCE_BASE_SIZE) fail('locker.invalid', 'The nonce base is eight bytes');++ let slots;++ if (lock === 'people') {++ const keys = options.keys || [];++ if (!keys.length) fail('locker.invalid', 'A people lock needs at least one recipient');++ // One ephemeral key pair for the whole envelope: the shared secret differs per person.++ const pair = await resolveEphemeral(options.ephemeral);++ const people = [];++ for (const key of keys) {++ people.push(await wrapFor(contentKey, key.publicKey, {++ subject: key.subject, random, ephemeral: pair,++ }));++ }++ slots = { people };++ } else if (lock === 'password') {++ slots = {++ password: await wrapForPassword(contentKey, options.password, {++ salt: options.salt, iterations: options.iterations || PASSWORD_ITERATIONS, random,++ }),++ };++ } else {++ // A link lock stores no key: the content key travels in the part of the link after the #.++ slots = { link: {} };++ }++ return { contentKey, nonceBase, slots };++}++++/** The header of an envelope. */++function makeHeader(lock, slots, padded, nonceBase) {++ return {++ version: VERSION,++ suite: SUITE,++ chunk_size: CHUNK_SIZE,++ padded_size: padded,++ nonce_base: base64(nonceBase),++ lock,++ slots,++ };++}++++/** Seals one block of the padded plaintext. */++async function sealBlock(key, header, nonceBase, counter, block, isFinal) {++ const flags = isFinal ? FINAL_FLAG : 0;++ const nonce = chunkNonce(nonceBase, counter);++ const ciphertext = new Uint8Array(await subtle().encrypt(++ { name: 'AES-GCM', iv: nonce, additionalData: chunkAad(header, counter, flags) },++ key,++ block,++ ));++ return encodeChunk(counter, flags, nonce, ciphertext);++}++++/**++ * The envelope, piece by piece. The header comes first (it holds the padded size, so the size++ * has to be known before the first byte is written), then one frame per chunk.++ */++async function* sealPieces(source, options) {++ const size = options.size;++ const { contentKey, nonceBase, slots } = await prepare(options);++ const padded = paddedSize(size);++ const header = makeHeader(options.lock, slots, padded, nonceBase);++ const headerBytes = encodeHeader(header);++ yield { header, headerBytes, contentKey, bytes: concat(utf8(MAGIC), u32be(headerBytes.length), headerBytes) };++ const key = await importContentKey(contentKey, ['encrypt']);++ const total = chunkCount(padded);++ const chunker = new Chunker(CHUNK_SIZE);++ let counter = 0;++ // The padded plaintext starts with the true length, so the server cannot read the size.++ chunker.push(u64be(size));++ for await (const part of source) {++ chunker.push(part);++ // Every chunk but the last is a whole chunk.++ for (;;) {++ if (counter >= total - 1) break;++ const block = chunker.take();++ if (!block) break;++ yield { bytes: await sealBlock(key, header, nonceBase, counter, block, false) };++ counter++;++ }++ }++ // The content is done; the padding fills the rest of the padded plaintext. (A content that++ // is longer than the size it was given fails in takeExactly, below.)++ while (counter < total - 1) {++ const block = chunker.takeExactly(CHUNK_SIZE);++ yield { bytes: await sealBlock(key, header, nonceBase, counter, block, false) };++ counter++;++ }++ // The last chunk: what is left of the plaintext, then zeros up to the padded size.++ const tail = chunker.takeExactly(lastChunkSize(padded));++ yield { bytes: await sealBlock(key, header, nonceBase, counter, tail, true) };++}++++/**++ * Seals content: a string or bytes. The result holds the envelope's bytes and its header.++ *++ * seal('a note', { lock: 'password', password: 'correct horse' })++ * seal(bytes, { lock: 'people', keys: [{ subject, publicKey }] })++ * seal(bytes, { lock: 'link' }) // the key comes back as linkKey++ */++async function seal(content, options = {}) {++ const bytes = toBytes(content);++ const pieces = [];++ let header = null;++ let headerBytes = null;++ let contentKey = null;++ for await (const piece of sealPieces(once(bytes), { ...options, size: bytes.length })) {++ if (piece.header) { header = piece.header; headerBytes = piece.headerBytes; contentKey = piece.contentKey; }++ pieces.push(piece.bytes);++ }++ const envelope = {++ version: VERSION,++ header,++ headerBytes,++ bytes: concat(...pieces),++ chunkOffset: 8 + headerBytes.length,++ };++ if (options.lock === 'link') {++ // The link carries the content key; the envelope itself stores nothing.++ envelope.linkKey = base64url(contentKey);++ }++ return envelope;++}++++/** The header's core, as a reader needs it: the fields must be the ones we write. */++function readCore(header) {++ if (!header || typeof header !== 'object' || Array.isArray(header)) {++ fail('locker.bad_envelope', 'The envelope header is not an object');++ }++ if (header.version !== VERSION) fail('locker.bad_envelope', 'This envelope is another version');++ if (header.suite !== SUITE) fail('locker.bad_envelope', 'This envelope uses another suite');++ if (header.chunk_size !== CHUNK_SIZE) fail('locker.bad_envelope', 'This envelope uses another chunk size');++ if (!Number.isInteger(header.padded_size) || header.padded_size < MIN_PADDED_SIZE) {++ fail('locker.bad_envelope', 'The padded size is not a size we write');++ }++ if (!['people', 'password', 'link'].includes(header.lock)) {++ fail('locker.bad_envelope', 'The lock is not one we know');++ }++ if (!header.slots || typeof header.slots !== 'object') {++ fail('locker.bad_envelope', 'The envelope has no slots');++ }++ return header;++}++++/** The content key out of the header, with the credentials the reader has. */++async function openContentKey(header, credentials = {}) {++ // A caller that still holds the content key needs no private key: an agent that sealed the item++ // can open it, or wrap it for the people who stay, with the key it kept. A string is base64url,++ // as the link key travels.++ if (credentials.contentKey) {++ const given = typeof credentials.contentKey === 'string'++ ? fromBase64url(credentials.contentKey) : toBytes(credentials.contentKey);++ if (given.length !== KEY_BYTES) fail('locker.invalid', 'The content key is 32 bytes');++ return given;++ }++ if (header.lock === 'people') {++ // A person may be named by their key instead of their subject: the slot says which key++ // it is for, so the reader does not need to know who they are.++ if (credentials.person) {++ const slot = await slotForPerson(header, credentials.person);++ const privateKey = await importPrivateKey(privateKeyOf(credentials.person) || credentials.privateKey);++ if (!privateKey) fail('locker.no_key', 'A people lock needs the private key of a recipient');++ return unwrapPeopleSlot(slot, privateKey);++ }++ const slot = pickSlot(header, credentials.subject);++ const privateKey = await importPrivateKey(credentials.privateKey);++ if (!privateKey) fail('locker.no_key', 'A people lock needs the private key of a recipient');++ return unwrapPeopleSlot(slot, privateKey);++ }++ if (header.lock === 'password') {++ if (!credentials.password) fail('locker.no_key', 'A password lock needs the password');++ if (!header.slots.password) fail('locker.bad_envelope', 'The envelope has no password slot');++ return unwrapPasswordSlot(header.slots.password, credentials.password);++ }++ if (!credentials.key) fail('locker.no_key', 'A link lock needs the key from the link');++ const key = typeof credentials.key === 'string' ? fromBase64url(credentials.key) : toBytes(credentials.key);++ if (key.length !== KEY_BYTES) fail('locker.invalid', 'The link key is 32 bytes');++ return key;++}++++/** The slot of the person who is opening: theirs, or the only one there is. */++function pickSlot(header, subject) {++ const slots = (header.slots && header.slots.people) || [];++ if (!slots.length) fail('locker.bad_envelope', 'The envelope has no people slots');++ if (subject) {++ const id = typeof subject === 'string' ? subject : subject.id;++ const type = typeof subject === 'string' ? null : subject.type;++ const found = slots.find(slot => slot.subject.id === id && (type === null || slot.subject.type === type));++ if (!found) fail('locker.no_key', 'This envelope has no slot for that person');++ return found;++ }++ if (slots.length > 1) fail('locker.no_key', 'This envelope has several slots: say which person you are');++ return slots[0];++}++++/** The ECDH public key of a person, as a JWK. */++async function publicKeyOf(person) {++ if (!person || typeof person !== 'object') fail('locker.invalid', 'A people lock needs the person');++ if (person.ecdhPublic) return publicJwk(person.ecdhPublic);++ if (person.ecdh && person.ecdh.kty) return publicJwk(person.ecdh);++ if (person.kty === 'EC') return publicJwk(person);++ if (person.ecdh && person.ecdh.publicKey) {++ return publicJwk(await subtle().exportKey('jwk', person.ecdh.publicKey));++ }++ fail('locker.invalid', 'The person has no ECDH public key');++}++++/** The ECDH private key of a person, if they brought one. */++function privateKeyOf(person) {++ if (!person || typeof person !== 'object') return null;++ if (person.ecdh && person.ecdh.privateKey) return person.ecdh.privateKey;++ if (person.ecdhPrivateKey) return person.ecdhPrivateKey;++ if (person.privateKey) return person.privateKey;++ return null;++}++++/**++ * The slot that names this person's key. Every people slot carries the thumbprint of the key++ * it was made for, so the reader finds its own slot without knowing anybody's subject. An++ * envelope that has no slot for this key is not an envelope this person can open: that is++ * locker.no_slot, which says the key is not on the list (not that the key is wrong).++ */++async function slotForPerson(header, person) {++ const slots = (header.slots && header.slots.people) || [];++ if (!slots.length) fail('locker.bad_envelope', 'The envelope has no people slots');++ const thumbprint = await jwkThumbprint(await publicKeyOf(person));++ const found = slots.find(slot => slot.recipient_key === thumbprint);++ if (!found) fail('locker.no_slot', 'This envelope has no slot for that key');++ return found;++}++++/** A private key as WebCrypto wants it: a CryptoKey, or a JWK to import. */++async function importPrivateKey(key) {++ if (!key) return null;++ if (key.type === 'private' && key.algorithm && key.algorithm.name === 'ECDH') return key;++ if (key.kty === 'EC' && key.crv === 'P-256' && key.d) {++ try {++ return await subtle().importKey('jwk', key, { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);++ } catch {++ fail('locker.invalid', 'The private key is not a P-256 key');++ }++ }++ fail('locker.invalid', 'The private key must be an ECDH P-256 key');++}++++/** Reads exact byte counts out of an envelope that is already in memory. */++function arrayReader(bytes) {++ let at = 0;++ return {++ /** The next bytes, or null when the envelope is over. */++ async probe(count) {++ if (at >= bytes.length) return null;++ return this.take(count);++ },++ async take(count) {++ if (at + count > bytes.length) fail('locker.truncated', 'The envelope ends inside a chunk');++ const out = bytes.slice(at, at + count);++ at += count;++ return out;++ },++ };++}++++/** Reads exact byte counts out of a stream, keeping only what it must. */++function streamReader(iterator) {++ let buffer = new Uint8Array(0);++ let ended = false;++ async function fill(count) {++ while (buffer.length < count && !ended) {++ const next = await iterator.next();++ if (next.done) { ended = true; break; }++ buffer = buffer.length ? concat(buffer, next.value) : next.value;++ }++ return buffer.length;++ }++ return {++ /** The next bytes, or null when the stream is over. */++ async probe(count) {++ const have = await fill(count);++ if (have === 0) return null;++ return this.take(count);++ },++ async take(count) {++ const have = await fill(count);++ if (have < count) fail('locker.truncated', 'The envelope ends inside a chunk');++ const out = buffer.slice(0, count);++ buffer = buffer.slice(count);++ return out;++ },++ };++}++++/**++ * Opens the chunks: the plaintext, piece by piece. The reader may be an envelope in memory or a++ * stream; both are read the same way, so the same checks run on both.++ */++async function* openPieces(reader, header, key) {++ const expected = chunkCount(header.padded_size);++ const lastLength = lastChunkSize(header.padded_size);++ const nonceBase = nonceBaseBytes(header);++ let counter = 0;++ let length = null;++ let emitted = 0;++ for (;;) {++ const lengthBytes = await reader.probe(4);++ if (lengthBytes === null) {++ if (counter !== expected) fail('locker.truncated', 'The envelope ends before its last chunk');++ break;++ }++ if (counter >= expected) fail('locker.bad_envelope', 'The envelope has more chunks than its size says');++ const cipherLength = readU32be(lengthBytes, 0);++ // The length is checked before it is used: a header that claims a huge chunk must not++ // make the reader wait for it. Every chunk but the last is a whole chunk; the last holds++ // what is left of the padded size.++ const wantedLength = counter === expected - 1 ? lastLength : CHUNK_SIZE;++ if (cipherLength !== wantedLength + TAG_SIZE) {++ fail('locker.bad_envelope', 'A chunk is not the size the envelope says');++ }++ const flags = (await reader.take(1))[0];++ const nonce = await reader.take(12);++ const ciphertext = await reader.take(cipherLength);++ const isFinal = (flags & FINAL_FLAG) === FINAL_FLAG;++ if (isFinal !== (counter === expected - 1)) {++ fail('locker.bad_envelope', 'The envelope marks the wrong chunk as the last one');++ }++ const wanted = chunkNonce(nonceBase, counter);++ for (let i = 0; i < wanted.length; i++) {++ if (nonce[i] !== wanted[i]) fail('locker.bad_envelope', 'A chunk carries the wrong nonce');++ }++ let plain;++ try {++ plain = new Uint8Array(await subtle().decrypt(++ { name: 'AES-GCM', iv: nonce, additionalData: chunkAad(header, counter, flags) },++ key,++ ciphertext,++ ));++ } catch (err) {++ if (err && err.code) throw err;++ fail('locker.wrong_key', 'This envelope does not open with this key');++ }++ if (counter === 0) {++ if (plain.length < LENGTH_PREFIX) fail('locker.bad_envelope', 'The first chunk is too short');++ length = Number(readU64be(plain, 0));++ if (length > header.padded_size - LENGTH_PREFIX) {++ fail('locker.size_mismatch', 'The content is longer than the envelope says');++ }++ }++ const start = counter === 0 ? LENGTH_PREFIX : 0;++ const part = plain.subarray(start);++ const room = length - emitted;++ const out = part.length > room ? part.subarray(0, room) : part;++ emitted += out.length;++ counter++;++ if (out.length) yield out;++ }++ if (emitted !== length) fail('locker.size_mismatch', 'The envelope holds fewer bytes than it says');++}++++/**++ * Opens an envelope: the content as bytes.++ *++ * open(envelope, { privateKey, subject }) // a people lock, by subject++ * open(envelope, { person }) // a people lock, by key++ * open(envelope, { password }) // a password lock++ * open(envelope, { key }) // a link lock++ */++async function open(envelope, credentials = {}) {++ const bytes = envelopeBytes(envelope);++ const { header, chunkOffset } = readHeader(bytes);++ readCore(header);++ const contentKey = await openContentKey(header, credentials);++ const key = await importContentKey(contentKey, ['decrypt']);++ const reader = arrayReader(bytes.subarray(chunkOffset));++ const parts = [];++ for await (const part of openPieces(reader, header, key)) parts.push(part);++ return concat(...parts);++}++++/** A stream of the bytes of a WHATWG stream (or of any async iterable of bytes). */++async function* readChunks(stream) {++ if (stream && typeof stream.getReader === 'function') {++ const reader = stream.getReader();++ try {++ for (;;) {++ const { value, done } = await reader.read();++ if (done) return;++ yield value instanceof Uint8Array ? value : new Uint8Array(value);++ }++ } finally {++ reader.releaseLock();++ }++ return;++ }++ for await (const part of stream) yield part instanceof Uint8Array ? part : new Uint8Array(part);++}++++/**++ * Seals a stream: the envelope comes out piece by piece, and only one chunk is ever in memory.++ * The size of the content has to be known, because the padded size is in the header and the++ * header is written first.++ */++function sealStream(stream, options = {}) {++ if (typeof options.size !== 'number' || options.size < 0) {++ fail('locker.invalid', 'A stream seal needs the size of the content');++ }++ const iterator = sealPieces(readChunks(stream), { ...options, size: options.size })[Symbol.asyncIterator]();++ return new ReadableStream({++ async pull(controller) {++ const next = await iterator.next();++ if (next.done) controller.close();++ else controller.enqueue(next.value.bytes);++ },++ async cancel(reason) { if (iterator.return) await iterator.return(reason); },++ });++}++++/**++ * Opens a stream: the content comes out piece by piece, and only one chunk is ever in memory.++ *++ * The chunks are yielded as they are verified, so a reader sees the earlier ones before the last++ * one is reached. A stream that ends early throws at the end, when the missing chunk shows up:++ * discard what it yielded, because the content was never complete.++ */++function openStream(stream, credentials = {}) {++ const iterator = (async function* openStreamPieces() {++ const reader = streamReader(readChunks(stream)[Symbol.asyncIterator]());++ const head = await reader.probe(8);++ if (head === null) fail('locker.truncated', 'The envelope is too short to hold a header');++ if (fromUtf8(head.subarray(0, 4)) !== MAGIC) fail('locker.bad_envelope', 'These bytes are not a Locker envelope');++ const headerLength = readU32be(head, 4);++ if (headerLength === 0 || headerLength > 65536) {++ fail('locker.bad_envelope', `The envelope header is ${headerLength} bytes, which is not one we write`);++ }++ const headerBytes = await reader.take(headerLength);++ let header;++ try { header = JSON.parse(fromUtf8(headerBytes)); }++ catch { fail('locker.bad_envelope', 'The envelope header is not JSON'); }++ readCore(header);++ const contentKey = await openContentKey(header, credentials);++ const key = await importContentKey(contentKey, ['decrypt']);++ yield* openPieces(reader, header, key);++ })()[Symbol.asyncIterator]();++ return new ReadableStream({++ async pull(controller) {++ const next = await iterator.next();++ if (next.done) controller.close();++ else controller.enqueue(next.value);++ },++ async cancel(reason) { if (iterator.return) await iterator.return(reason); },++ });++}++++/**++ * Wraps the content key for other people, without touching the content: the envelope's header++ * changes, the chunks do not, because the slots are not part of what the chunks authenticate.++ * This is how a member is added to a people-locked item: the people who are already on it keep++ * their slots, and the new ones get one of their own.++ *++ * Removing a member wants a fresh content key, not just a slot that is gone: the person who is++ * removed may still hold the old key. So a removal opens the content and seals it again, with a++ * new key and a new nonce base, for the people who are left. Their public keys are needed for++ * that, so the caller gives them in `keys`.++ */++async function rewrap(envelope, {++ add = [], remove = [], keys = [], credentials = {}, random = platformRandom, ephemeral,++} = {}) {++ const bytes = envelopeBytes(envelope);++ const { header, chunkOffset } = readHeader(bytes);++ if (header.lock !== 'people') fail('locker.invalid', 'Only a people-locked envelope can be re-wrapped');++ readCore(header);++ const gone = new Set(remove.map(who => (typeof who === 'string' ? who : who.id)));++ const staying = (header.slots.people || []).filter(slot => !gone.has(slot.subject.id));++++ if (!gone.size) {++ // Adding: the content key and the chunks stay as they are.++ const contentKey = await openContentKey(header, credentials);++ const people = staying.slice();++ if (add.length) {++ const pair = await resolveEphemeral(ephemeral);++ for (const recipient of add) {++ people.push(await wrapFor(contentKey, recipient.publicKey, {++ subject: recipient.subject, ephemeral: pair, random,++ }));++ }++ }++ if (!people.length) fail('locker.invalid', 'A people-locked envelope needs at least one slot');++ const newHeader = { ...coreOf(header), slots: { people } };++ const headerBytes = encodeHeader(newHeader);++ return {++ version: VERSION,++ header: newHeader,++ headerBytes,++ bytes: encodeEnvelope(headerBytes, bytes.subarray(chunkOffset)),++ chunkOffset: 8 + headerBytes.length,++ };++ }++++ // Removing: the content is opened and sealed again under a new key, so the key the person++ // who is leaving still holds opens nothing.++ const contentKey = await openContentKey(header, credentials);++ const plain = await open(bytes, credentials);++ const recipients = [];++ for (const slot of staying) {++ const found = keys.find(key => (typeof key.subject === 'string' ? key.subject : key.subject.id) === slot.subject.id);++ if (!found) {++ fail('locker.invalid', `Removing a person needs the public key of ${slot.subject.id}, who stays`);++ }++ recipients.push({ subject: slot.subject, publicKey: found.publicKey });++ }++ for (const recipient of add) recipients.push({ subject: recipient.subject, publicKey: recipient.publicKey });++ if (!recipients.length) fail('locker.invalid', 'A people-locked envelope needs at least one slot');++ return seal(plain, { lock: 'people', keys: recipients, random, ephemeral });++}++++__ovLockerModule.exports = {++ seal, open, sealStream, openStream, rewrap,++ envelopeBytes, toBytes, openContentKey, readCore, prepare, makeHeader, Chunker,++ publicKeyOf, privateKeyOf, slotForPerson,++};++++ },++ "server": function (__ovLockerModule, exports, __ovLockerReq) {++'use strict';++/**++ * What a service needs to know about an envelope without opening it: the header's shape and the++ * envelope's size. Nothing here decrypts anything, and nothing here needs a key: a service may++ * hold the bytes of an item it can never read.++ *++ * A service that stores an envelope should:++ * - check it with inspectEnvelope() and refuse what does not pass;++ * - keep the header as it is (it holds no key material, only wrapped keys);++ * - skip indexing, previews, thumbnails, transcoding and content AI;++ * - show the lock badge, and serve the bytes only to the people canView allows.++ */++const { fromUtf8, fromBase64, readU32be } = __ovLockerReq('./bytes');++const {++ MAGIC, VERSION, SUITE, CHUNK_SIZE, TAG_SIZE, NONCE_SIZE, NONCE_BASE_SIZE, MIN_PADDED_SIZE,++ MAX_HEADER_BYTES, chunkCount, lastChunkSize,++} = __ovLockerReq('./format');++++/** The bytes of a base64 field, or null when it is not base64 of that many bytes. */++function bytesOf(text, length) {++ if (typeof text !== 'string' || !text.length) return null;++ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(text)) return null;++ let bytes;++ try { bytes = fromBase64(text); } catch { return null; }++ return bytes.length === length ? bytes : null;++}++++/** A subject: { type, id }, both there. */++function subjectOk(subject) {++ return !!subject && typeof subject === 'object' && typeof subject.type === 'string'++ && typeof subject.id === 'string' && subject.type.length > 0 && subject.id.length > 0;++}++++/** A public key as a JWK: P-256, with its two coordinates. */++function publicKeyOk(key) {++ return !!key && typeof key === 'object' && key.kty === 'EC' && key.crv === 'P-256'++ && typeof key.x === 'string' && /^[A-Za-z0-9_-]{43}$/.test(key.x)++ && typeof key.y === 'string' && /^[A-Za-z0-9_-]{43}$/.test(key.y);++}++++/** One people slot: whose it is, which key it is for, and the wrapped key with the bytes it must have. */++function peopleSlotOk(slot) {++ if (!slot || typeof slot !== 'object') return false;++ if (!subjectOk(slot.subject)) return false;++ if (slot.algorithm !== 'ECDH-P256/HKDF-SHA256/AES-256-GCM') return false;++ // The thumbprint of the recipient's key: the slot names the key it is for.++ if (typeof slot.recipient_key !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(slot.recipient_key)) return false;++ if (!publicKeyOk(slot.ephemeral_public_key)) return false;++ if (!bytesOf(slot.salt, 16)) return false;++ if (!bytesOf(slot.nonce, NONCE_SIZE)) return false;++ return !!bytesOf(slot.wrapped_key, 48);++}++++/** The password slot: the parameters and the wrapped key. */++function passwordSlotOk(slot) {++ if (!slot || typeof slot !== 'object') return false;++ if (slot.algorithm !== 'PBKDF2-SHA256/AES-256-GCM') return false;++ if (slot.kdf !== 'PBKDF2-SHA-256') return false;++ if (!Number.isInteger(slot.iterations) || slot.iterations < 600000 || slot.iterations > 10000000) return false;++ if (!bytesOf(slot.salt, 16)) return false;++ if (!bytesOf(slot.nonce, NONCE_SIZE)) return false;++ return !!bytesOf(slot.wrapped_key, 48);++}++++/**++ * The header's shape: what it says, and whether the slots match the lock it names. The problems++ * are returned as words, so a service can log them; nothing is thrown and nothing is decrypted.++ */++function checkHeader(header) {++ const errors = [];++ if (!header || typeof header !== 'object' || Array.isArray(header)) {++ return ['the header is not an object'];++ }++ if (header.version !== VERSION) errors.push(`the version is not ${VERSION}`);++ if (header.suite !== SUITE) errors.push(`the suite is not ${SUITE}`);++ if (header.chunk_size !== CHUNK_SIZE) errors.push(`the chunk size is not ${CHUNK_SIZE}`);++ if (!Number.isInteger(header.padded_size) || header.padded_size < MIN_PADDED_SIZE) {++ errors.push(`the padded size is not at least ${MIN_PADDED_SIZE} bytes`);++ }++ if (!bytesOf(header.nonce_base, NONCE_BASE_SIZE)) errors.push('the nonce base is not eight bytes');++ if (!['people', 'password', 'link'].includes(header.lock)) errors.push('the lock is not one we know');++ const slots = header.slots;++ if (!slots || typeof slots !== 'object' || Array.isArray(slots)) {++ errors.push('the header has no slots');++ return errors;++ }++ const kinds = Object.keys(slots);++ const wanted = header.lock === 'people' ? 'people' : header.lock;++ if (kinds.length !== 1 || kinds[0] !== wanted) {++ errors.push(`the slots do not match the ${header.lock} lock`);++ return errors;++ }++ if (wanted === 'people') {++ if (!Array.isArray(slots.people) || !slots.people.length || slots.people.length > 100) {++ errors.push('the people slots are not a list of one to a hundred');++ } else if (!slots.people.every(peopleSlotOk)) {++ errors.push('a people slot is not the shape it must be');++ }++ } else if (wanted === 'password') {++ if (!passwordSlotOk(slots.password)) errors.push('the password slot is not the shape it must be');++ } else if (Object.keys(slots.link).length) {++ errors.push('a link slot stores nothing, and this one does');++ }++ return errors;++}++++/**++ * Reads an envelope's header and checks its shape and its size. Nothing is decrypted and no key++ * is needed: this is what a service runs on bytes it may never be able to read.++ */++function inspectBytes(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}, totalBytes = bytes && bytes.length) {++ const errors = [];++ const data = bytes instanceof Uint8Array ? bytes : null;++ if (!data) return { valid: false, errors: ['the envelope is not bytes'], header: null };++ if (data.length < 8) return { valid: false, errors: ['the envelope is too short to hold a header'], header: null };++ if (fromUtf8(data.subarray(0, 4)) !== MAGIC) {++ return { valid: false, errors: ['these bytes are not a Locker envelope'], header: null };++ }++ const headerLength = readU32be(data, 4);++ if (headerLength === 0 || headerLength > maxHeaderBytes) {++ return { valid: false, errors: [`the envelope header is ${headerLength} bytes, which is not one we write`], header: null };++ }++ if (data.length < 8 + headerLength) {++ return { valid: false, errors: ['the envelope ends inside its header'], header: null };++ }++ let header = null;++ try { header = JSON.parse(fromUtf8(data.subarray(8, 8 + headerLength))); }++ catch { return { valid: false, errors: ['the envelope header is not JSON'], header: null }; }++ if (!header || typeof header !== 'object' || Array.isArray(header)) return { valid: false, errors: ['the header is not an object'], header: null };++ try { errors.push(...checkHeader(header)); } catch { errors.push('the slots are not an object'); }++ const chunkOffset = 8 + headerLength;++ let expectedBytes = null;++ if (Number.isInteger(header.padded_size) && header.padded_size >= MIN_PADDED_SIZE++ && header.chunk_size === CHUNK_SIZE) {++ const chunks = chunkCount(header.padded_size);++ // Every chunk: its length, its flags, its nonce, the ciphertext and the tag. Every chunk++ // but the last is a whole chunk; the last holds what is left of the padded size.++ const frame = 4 + 1 + NONCE_SIZE;++ const whole = (chunks - 1) * (frame + CHUNK_SIZE + TAG_SIZE);++ const last = frame + lastChunkSize(header.padded_size) + TAG_SIZE;++ expectedBytes = chunkOffset + whole + last;++ if (totalBytes < expectedBytes) errors.push('the envelope is shorter than its own header says');++ }++ return {++ valid: errors.length === 0,++ errors,++ header,++ headerLength,++ chunkOffset,++ lock: header.lock,++ paddedSize: header.padded_size,++ chunkCount: Number.isInteger(header.padded_size) ? chunkCount(header.padded_size) : null,++ expectedBytes,++ };++}++++function inspectEnvelope(bytes, options) { return inspectBytes(bytes, options); }++++/** A streamed upload: inspect just its bounded header against the ACTUAL stored byte count.++ * The caller must have counted and bounded the complete ciphertext first, not trust Content-Length.++ * No chunks are read or authenticated here; as with inspectEnvelope, only a client can do that.++ */++function inspectEnvelopePrefix(prefix, totalBytes, options) {++ const info = inspectBytes(prefix, options, totalBytes);++ if (!Number.isSafeInteger(totalBytes) || !Number.isSafeInteger(info.expectedBytes) || totalBytes !== info.expectedBytes) {++ info.errors.push('the stored byte count does not match the envelope');++ }++ if (info.header && !Number.isSafeInteger(info.header.padded_size)) info.errors.push('the padded size is not a safe integer');++ info.valid = info.errors.length === 0;++ return info;++}++__ovLockerModule.exports = { inspectEnvelope, inspectEnvelopePrefix, checkHeader, peopleSlotOk };++++ },++ "words": function (__ovLockerModule, exports, __ovLockerReq) {++'use strict';++/**++ * The 256 words of a fingerprint. A fingerprint is the first eight bytes of SHA-256 over a++ * person's two public keys, read as eight words in two groups of four: eight bits each, so the++ * words say which of the 256 they are. It is short enough to read out loud and compare once,++ * which is the whole point: a person checks that the eight words on the screen are the eight++ * words the other person sees.++ *++ * The order is part of the format: word number n is the nth word here. Changing the list would++ * change every fingerprint, so it is frozen with the version.++ */++const WORDS = [++ 'amber', 'anchor', 'apple', 'arrow', 'aspen', 'autumn', 'badge', 'bamboo',++ 'beacon', 'berry', 'birch', 'bison', 'blossom', 'breeze', 'brick', 'brook',++ 'bucket', 'bugle', 'cabin', 'cactus', 'candle', 'canoe', 'canyon', 'cedar',++ 'cellar', 'chalk', 'cherry', 'cider', 'cinder', 'citrus', 'clay', 'clover',++ 'cobalt', 'cocoa', 'comet', 'coral', 'cosmos', 'cotton', 'cove', 'crane',++ 'crater', 'crayon', 'cricket', 'crimson', 'crystal', 'cymbal', 'daisy', 'dapple',++ 'dawn', 'delta', 'desert', 'diamond', 'dolphin', 'domino', 'donkey', 'dragon',++ 'drift', 'drizzle', 'drum', 'dune', 'dusk', 'eagle', 'ember', 'emerald',++ 'estuary', 'fable', 'falcon', 'feather', 'fern', 'ferry', 'field', 'flame',++ 'flint', 'forest', 'fossil', 'fox', 'frost', 'galaxy', 'garden', 'garnet',++ 'gazelle', 'geyser', 'ginger', 'glacier', 'granite', 'grape', 'gravel', 'grove',++ 'guitar', 'haiku', 'harbor', 'hazel', 'heather', 'hollow', 'honey', 'horizon',++ 'ice', 'indigo', 'island', 'ivory', 'jasper', 'jungle', 'juniper', 'kayak',++ 'kernel', 'kestrel', 'lagoon', 'lantern', 'lattice', 'laurel', 'lemon', 'lichen',++ 'lilac', 'linen', 'lotus', 'lunar', 'magnet', 'mango', 'maple', 'marble',++ 'marsh', 'meadow', 'meteor', 'mint', 'mirror', 'mist', 'monsoon', 'moss',++ 'moth', 'mountain', 'nectar', 'needle', 'nest', 'nettle', 'night', 'oasis',++ 'ocean', 'olive', 'onyx', 'opal', 'orbit', 'orchard', 'orchid', 'osprey',++ 'otter', 'owl', 'oyster', 'palace', 'palm', 'panda', 'papaya', 'paper',++ 'peach', 'pearl', 'pebble', 'pecan', 'penguin', 'peony', 'pepper', 'petal',++ 'pine', 'plateau', 'plum', 'pollen', 'pond', 'poplar', 'poppy', 'prairie',++ 'prism', 'pumpkin', 'quail', 'quarry', 'quartz', 'rabbit', 'radish', 'rain',++ 'raven', 'reef', 'ridge', 'river', 'robin', 'rosemary', 'sage', 'salmon',++ 'sand', 'sapphire', 'satellite', 'scallop', 'shadow', 'shell', 'shore', 'shuttle',++ 'silk', 'silver', 'slate', 'snow', 'solar', 'sparrow', 'spruce', 'star',++ 'stone', 'storm', 'stream', 'sugar', 'summit', 'sunset', 'swan', 'thistle',++ 'thunder', 'tide', 'tiger', 'timber', 'topaz', 'trail', 'tulip', 'tundra',++ 'turtle', 'valley', 'vapor', 'velvet', 'vine', 'violet', 'walnut', 'wave',++ 'whale', 'wheat', 'willow', 'winter', 'wolf', 'wren', 'yacht', 'yarrow',++ 'yew', 'zinc', 'zephyr', 'almond', 'basil', 'cinnamon', 'cypress', 'damson',++ 'elder', 'fennel', 'garlic', 'hibiscus', 'iris', 'jasmine', 'linden', 'melon',++ 'nutmeg', 'sesame', 'sorrel', 'thyme', 'anise', 'chicory', 'dill', 'endive',++];++++__ovLockerModule.exports = { WORDS };++++ },++ };++ const cache = {};++ function req(name) {++ const key = String(name).replace(/^\.\//, '');++ if (!defs[key]) throw new Error('ov-locker-core: no module ' + name);++ if (!cache[key]) { const m = { exports: {} }; cache[key] = m; defs[key](m, m.exports, req); }++ return cache[key].exports;++ }++ root.OVLockerCore = Object.assign({}, req('index'), { SDK_VERSION: "0.101.0" });++})(typeof globalThis !== 'undefined' ? globalThis : this);@@@ -38,7 +38,7 @@ const path = require('path');const ALLOWED_KEYS = new Set(['manifest_version', 'name', 'short_name', 'version', 'description', 'homepage_url', 'author', 'icons','action', 'options_ui', 'permissions', 'host_permissions', 'content_security_policy', 'minimum_chrome_version', 'browser_specific_settings','side_panel', 'sidebar_action', 'key', 'background']);--const BASE_PERMISSIONS = ['activeTab', 'identity', 'scripting', 'storage'];++const BASE_PERMISSIONS = ['activeTab', 'alarms', 'clipboardWrite', 'identity', 'offscreen', 'scripting', 'storage'];const SIDE_PERMISSIONS = ['sidePanel', 'sidebarAction'];const PERMISSIONS = BASE_PERMISSIONS.concat(['sidePanel']);const BACKGROUND = { service_worker: 'background.js', type: 'module' };@@@ -119,16 +119,17 @@ function auditDir(dir) {const chat = (configSrc.match(/CHAT_ORIGIN:\s*'([^']+)'/) || [])[1];const codes = (configSrc.match(/CODES_ORIGIN:\s*'([^']+)'/) || [])[1];const mail = (configSrc.match(/MAIL_ORIGIN:\s*'([^']+)'/) || [])[1];++ const locker = (configSrc.match(/LOCKER_ORIGIN:\s*'([^']+)'/) || [])[1];const handoffs = ((configSrc.match(/HANDOFF_ORIGINS:\s*Object\.freeze\(\[([^\]]*)\]\)/) || [])[1] || '').split(',').map((x) => x.trim().replace(/^'|'$/g, '')).filter(Boolean);const problems = [];if (!origin) problems.push('lib/config.js has no API_ORIGIN');if (!handoffs.length) problems.push('lib/config.js has no HANDOFF_ORIGINS');for (const h of handoffs) if (!/^https:\/\/openvibe\.[a-z]+$/.test(h)) problems.push(`hand-off origin ${h} is not an https://openvibe.<tld> site`);-- const origins = [origin, network, chat, codes].filter(Boolean);-- for (const o of [network, chat, codes, mail].filter(Boolean)) if (!/^https:\/\/openvibe\.[a-z]+$/.test(o)) problems.push(`${o} is not an https://openvibe.<tld> site`);++ const origins = [origin, network, chat, codes, locker].filter(Boolean);++ for (const o of [network, chat, codes, mail, locker].filter(Boolean)) if (!/^https:\/\/openvibe\.[a-z]+$/.test(o)) problems.push(`${o} is not an https://openvibe.<tld> site`);const connectOrigins = origins.concat(mail ? [mail] : []);problems.push(...auditManifest(manifest, { apiOrigin: origin, origins, connectOrigins }));-- for (const page of ['popup.html', 'options.html', 'panel.html']) problems.push(...auditPage(fs.readFileSync(path.join(dir, page), 'utf8'), page));++ for (const page of ['popup.html', 'options.html', 'panel.html', 'clipboard.html']) problems.push(...auditPage(fs.readFileSync(path.join(dir, page), 'utf8'), page));return { manifest, origin, origins, connectOrigins, problems };}@@@ -85,7 +85,7 @@ function forTarget(files, target) {delete m.minimum_chrome_version;delete m.side_panel;delete m.key; // Chrome's public key is not a Firefox manifest field-- m.permissions = (m.permissions || []).filter((p) => p !== 'sidePanel');++ m.permissions = (m.permissions || []).filter((p) => p !== 'sidePanel' && p !== 'offscreen');// Firefox has no background service worker: it runs the same file as a background page.m.background = { scripts: ['background.js'], type: 'module' };} else throw new Error('target must be chrome or firefox');@@@ -6,20 +6,24 @@ reads, sends or stores, change this file, the README's "What it can see" table a## Single purpose (Chrome Web Store)--> Help with the page in the active tab, only when the person opens the Companion: coupon codes for the shop they are on (looked up on OpenVibe Coupons by hostname and tried in the page's coupon box on request), and one-press hand-offs of that page to OpenVibe services (Rent, Watch, Food, Reviews) in a tab the person sees.++> Help with the page in the active tab, only when the person opens the Companion: coupon codes for the shop they are on (looked up on OpenVibe Coupons by hostname and tried in the page's coupon box on request), saved Locker passwords filled only on request into the exact saved origin, and one-press hand-offs of that page to OpenVibe services (Rent, Watch, Food, Reviews) in a tab the person sees.## Permission justifications (Chrome Web Store)| Permission | Justification ||---|---|--| `activeTab` | When the person opens the popup, it reads the active tab's address and title, to look up coupon codes for that site by its hostname and to fill the hand-off buttons. No access to any tab before that click, and none to other tabs. |--| `scripting` | Four buttons inject a script packaged in the extension into the active tab's top frame, only after the press: "Try these codes on this page" runs `lib/autoapply.js` (finds the coupon box, types each code, reads whether the order total changed), "Save its rating to OpenVibe Reviews" runs `lib/rating.js` (reads the rating the page states), and "Keep on my Rent board" and "Check the rent" run `lib/listing.js` (reads the price, city and bedrooms a listing states). None makes a request or reads cookies or storage. |--| `storage` | Keeps the optional `cpx_` install token, and the Dock's sign-in: the OAuth access token in `storage.session` (memory) and the rotating refresh token in `storage.local` (never synced). A pasted 0.7.0 `networkToken` is kept only as that fallback. |--| `identity` | Starts the Dock's one-click sign-in (`launchWebAuthFlow`) at openvibe.network; the code returns to the browser's own extension redirect URL. No password is read by the extension. |++| `activeTab` | When the person opens the popup, it reads the active tab's address and title, to look up coupon codes for that site by its hostname and to fill the hand-off buttons. No access to any tab before that click, and none to other tabs. Locker compares this origin exactly to decrypted saved logins, without sending that origin to Locker. |++| `scripting` | Four buttons inject a script packaged in the extension into the active tab's top frame, only after the press: "Try these codes on this page" runs `lib/autoapply.js` (finds the coupon box, types each code, reads whether the order total changed), "Save its rating to OpenVibe Reviews" runs `lib/rating.js` (reads the rating the page states), and "Keep on my Rent board" and "Check the rent" run `lib/listing.js` (reads the price, city and bedrooms a listing states). Locker Fill / Fill and sign in also uses scripting to write visible inputs in frame 0, with an exact-origin recheck before typing. None makes a request or reads cookies or storage. |++| `storage` | Keeps the optional `cpx_` install token, and the Dock's sign-in: the OAuth access token in `storage.session` (memory) and the rotating refresh token in `storage.local` (never synced). Unlocked Locker private keys and their expiry live only in storage.session or memory. A pasted 0.7.0 `networkToken` is kept only as that fallback. |++| `identity` | Starts the Dock's one-click sign-in (`launchWebAuthFlow`) at openvibe.network; the code returns to the browser's own extension redirect URL. This flow never reads the Network password; the separate Locker form accepts a Locker passphrase or recovery kit locally. || Host permission `https://openvibe.coupons/*` | The Coupons API: look up a merchant by hostname, list its codes, and report whether a code worked. || Host permission `https://openvibe.network/*` | The Dock's state (`GET`/`PUT /api/v1/me/dock`) and its notifications, with the person's own token. || Host permission `https://openvibe.chat/*` | The Dock's Chats tab: the person's conversations and DMs (`GET /api/dm/conversations`), with the same token. || Host permission `https://openvibe.codes/*` | The Dock's agent view: the person's own desk stream, its still, and the Pause/Resume, Curtain now, Mute/Unmute and End controls (`/api/v1/me/agent-view`), with the same token. |++| `alarms` | Enforces the 15-minute idle lock when the service worker is asleep. |++| `clipboardWrite` | Copies a password or locally computed TOTP code only on request, then clears the clipboard after 30 seconds. Never reads the clipboard. |++| `offscreen` (Chrome only) | Keeps the clipboard-clearing document alive when the popup closes. Firefox uses its background document. |++| Host permission `https://openvibe.locker/*` | Reads wrapped keys and encrypted login items, and uploads locally sealed login items with the person's scoped token. No plaintext login or unlock secret is sent to Locker. || Remote code | **No.** All JavaScript is in the package; the CSP is `script-src 'self'` with no `eval`. The agent view's `https://openvibe.live/embed/agent/…` frame is the only remote frame (`frame-src`), and it is Live's own page, not script the extension runs. |## Data usage (Chrome Web Store "Privacy practices" tab)@@@ -30,7 +34,7 @@ Tick these data types, because each leaves the device in some case:|---|---|---|| Web history | **Yes** | The active tab's **hostname** goes to openvibe.coupons when the popup opens on a site. On a hand-off press, the page's address (credentials, fragment and tracking parameters removed) and title go to openvibe.rent, openvibe.watch, openvibe.food or openvibe.reviews in the address of a tab the person sees. || Website content | **Yes** | The Dock reads the person's own agent view (the task line, the worked time, the stream's state) from openvibe.codes and frames the stream from openvibe.live; it is their own content, not the page's. On "Save its rating to OpenVibe Reviews": the business name, rating, scale, count of ratings and city the page states, in the address of the openvibe.reviews tab. On "Compare delivery apps": the restaurant or store name taken from the page title, to openvibe.food. On "Keep on my Rent board" and "Check the rent": the price, its currency, the city and region (never the street) and the bedrooms the listing states, in the address of the openvibe.rent tab. |--| Authentication information | **Yes** | Only if the person connects the extension: the `cpx_` install token they pasted is sent to openvibe.coupons as `Authorization: Bearer` on its API calls. The Dock's one-click sign-in sends an OAuth access token to openvibe.network, openvibe.chat and openvibe.codes as `Authorization: Bearer` (scopes `dock`, `activity`, `chat.read`) and keeps a refresh token on this device. |++| Authentication information | **Yes** | Only if the person connects the extension: the `cpx_` install token they pasted is sent to openvibe.coupons as `Authorization: Bearer` on its API calls. The Dock's one-click sign-in sends an OAuth access token to openvibe.network, openvibe.chat and openvibe.codes as `Authorization: Bearer` (scopes `dock`, `activity`, `chat.read`) and keeps a refresh token on this device. The new locker.login.manage scope reaches openvibe.locker. Locker passwords, usernames, site origins and TOTP secrets are sealed locally before upload; only explicit Fill / Fill and sign in sends plaintext to the exact saved origin. Passphrases and recovery words stay in the extension. Copy sends the requested password/code to the local system clipboard, which clears after 30 seconds. || User activity | No | No clicks, keystrokes or scrolls are recorded. A report sends a code id, `worked`/`failed` and an optional reason the person chose, which is the person's own feedback. || Financial and payment information | No | `lib/autoapply.js` reads the order total in the page to tell which codes lowered it; the total stays in the popup and is never sent. || Personally identifiable information, health, personal communications, location | No | Not read. (The city sent with a rating is the business's, and with a listing the home's, from the page; never the person's.) |@@@ -65,7 +65,7 @@ function fetcher(responses) {assert.strictEqual(u.searchParams.get('client_id'), 'companion');assert.strictEqual(u.searchParams.get('redirect_uri'), CHROME);assert.strictEqual(u.searchParams.get('response_type'), 'code');-- assert.strictEqual(u.searchParams.get('scope'), 'dock activity chat.read agent.view mail.mask.manage');++ assert.strictEqual(u.searchParams.get('scope'), 'dock activity chat.read agent.view mail.mask.manage locker.login.manage');assert.strictEqual(u.searchParams.get('code_challenge_method'), 'S256');assert.match(state, /^[A-Za-z0-9_-]{43}$/);issuedChallenge = u.searchParams.get('code_challenge');nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++// Optional real-browser check. PLAYWRIGHT_MODULE may point at an installed Playwright module.++const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');++const assert = require('node:assert/strict');++const fs = require('node:fs'), path = require('node:path'), os = require('node:os');++const { sourceFiles, forTarget } = require('../scripts/pack');++const root = path.join(__dirname, '..');++const artifacts = process.env.LOCKER_SHOTS || path.join(root, 'artifacts', 'locker');++const pause = (ms) => new Promise((r) => setTimeout(r, ms));++(async () => {++ fs.mkdirSync(artifacts, { recursive: true });++ const browser = await chromium.launch({ headless: true });++ try {++ for (const width of [320, 390, 1280]) for (const scheme of ['light', 'dark']) {++ const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme });++ const errors = []; page.on('pageerror', (error) => errors.push(error.message));++ await page.setContent('<html><head></head><body><header><h1>OpenVibe Companion</h1></header><section id="locker" class="locker"></section></body></html>');++ await page.addStyleTag({ path: path.join(root, 'browser/popup.css') });++ await page.addScriptTag({ path: path.join(root, 'browser/lib/locker-panel.js') });++ await page.evaluate(() => {++ let locked = true; window.actions = [];++ OVLockerPanel.mount({ container: document.getElementById('locker'), platform: { tabs: { active: async () => ({ id: 7, url: 'https://example.test/signin' }) } },++ ask: async (m) => {++ window.actions.push(m.type);++ if (m.type === 'locker:status') return { ok: true, locked };++ if (m.type === 'locker:unlock') { locked = false; return { ok: true }; }++ if (m.type === 'locker:lock') { locked = true; return { ok: true }; }++ if (m.type === 'locker:list') return { ok: true, items: [{ id: 'fixture', username: '[email protected]', hasTotp: true }] };++ return { ok: true };++ } });++ });++ await page.getByRole('button', { name: 'Unlock Locker', exact: true }).waitFor();++ await page.screenshot({ path: path.join(artifacts, `locked-${width}-${scheme}.png`), fullPage: true });++ await page.locator('[name=secret]').fill('fixture passphrase');++ await page.getByRole('button', { name: 'Unlock Locker', exact: true }).click();++ await page.getByRole('button', { name: 'Fill', exact: true }).waitFor();++ assert.deepEqual(await page.evaluate(() => actions), ['locker:status', 'locker:unlock', 'locker:list']);++ await page.getByRole('button', { name: 'Fill', exact: true }).click();++ await page.getByRole('button', { name: 'Fill and sign in', exact: true }).click();++ await page.getByText('Save this login', { exact: true }).click();++ await page.locator('[name=username]').fill('[email protected]'); await page.locator('[name=password]').fill('fixture password');++ await page.screenshot({ path: path.join(artifacts, `unlocked-${width}-${scheme}.png`), fullPage: true });++ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth), width);++ assert.equal(await page.locator('#locker button').evaluateAll((nodes) => nodes.every((n) => n.getBoundingClientRect().height >= 44)), true);++ await page.getByRole('button', { name: 'Save encrypted login', exact: true }).click();++ await page.getByRole('button', { name: 'Lock now', exact: true }).click();++ assert.equal(await page.locator('[name=password]').count(), 0);++ assert.deepEqual(errors, []); await page.close();++ }++ } finally { await browser.close(); }++ console.log('Popup: 12 screenshots, 320/390/1280 px, both themes, no overflow or console errors, 44 px controls; trusted clicks exercised.');++++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'companion-locker-browser-'));++ const files = sourceFiles('https://openvibe.coupons'); forTarget(files, 'chrome');++ for (const [name, data] of files) { fs.mkdirSync(path.dirname(path.join(tmp, name)), { recursive: true }); fs.writeFileSync(path.join(tmp, name), data); }++ const context = await chromium.launchPersistentContext('', { headless: true, channel: 'chromium', args: [`--disable-extensions-except=${tmp}`, `--load-extension=${tmp}`] });++ try {++ const worker = context.serviceWorkers()[0] || await context.waitForEvent('serviceworker');++ await context.route('https://example.test/**', (route) => route.fulfill({ contentType: 'text/html', body: '<html><body>Clipboard test</body></html>' }));++ const page = await context.newPage(); await page.goto('https://example.test');++ await context.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: 'https://example.test' });++ await worker.evaluate(async () => { await OVPlatform.createPlatform(chrome, OVConfig).clipboard.write('clipboard test fixture'); });++ assert.equal(await page.evaluate(() => navigator.clipboard.readText()), 'clipboard test fixture');++ console.log('Packaged worker/offscreen copied successfully; checking 30-second clearing with no popup open.');++ await pause(31000);++ assert.equal(await page.evaluate(() => navigator.clipboard.readText()), '');++ assert.equal(await worker.evaluate(async () => (await chrome.storage.local.get('lockerUnlock')).lockerUnlock), undefined);++ console.log('Real clipboard cleared after 30 seconds with no popup open.');++ } finally { await context.close(); fs.rmSync(tmp, { recursive: true, force: true }); }++})().catch((error) => { console.error(error); process.exitCode = 1; });nnew file mode 100644@@@ -0,0 +1,156 @@++'use strict';++const test = require('node:test');++const assert = require('node:assert/strict');++const vm = require('node:vm');++const fs = require('node:fs');++require('../browser/shared/ov-locker-core');++const core = globalThis.OVLockerCore;++const payload = require('../browser/lib/locker-payload');++const passwords = require('../browser/lib/locker-passwords');++const { create, KEY, IDLE } = require('../browser/lib/locker-vault');++const { fill } = require('../browser/lib/locker-fill');++const SUBJECT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', POPUP = 'chrome-extension://test/popup.html';++++async function fixture() {++ let clock = 1000, active = { id: 1, url: 'https://example.test/login' }, account = 'owner';++ const memory = new Map(), ciphertexts = new Map(), writes = [], fills = [], clipboard = [];++ const person = await core.generatePersonKeys(); const priv = await core.exportPrivateKeys(person);++ const pass = 'test passphrase used only by fixtures';++ const recovery = 'test recovery words';++ const sealBackup = async (password) => Buffer.from((await core.seal(JSON.stringify(priv), { lock: 'password', password })).bytes).toString('base64');++ const backups = { passphrase: { envelope: await sealBackup(pass) }, recovery: { envelope: await sealBackup(recovery) } };++ const api = {++ keys: async () => ({ subject: SUBJECT, backups }), list: async () => [...ciphertexts.keys()].map((id) => ({ id })),++ content: async (id) => ciphertexts.get(id), save: async (bytes) => { ciphertexts.set(String(ciphertexts.size + 1), bytes); return { id: '1' }; },++ };++ const storage = { getSession: async (k) => memory.get(k), setSession: async (obj) => { writes.push(obj); for (const [k, v] of Object.entries(obj)) memory.set(k, v); }, removeSession: async (keys) => keys.forEach((k) => memory.delete(k)) };++ const options = { core, payload, passwords, api, storage, scheduleLock: async () => {}, cancelLock: async () => {}, tabs: { active: async () => active }, fill: async (...args) => { fills.push(args); return { ok: true }; }, clipboard: { write: async (v) => clipboard.push(v) }, account: async () => account, now: () => clock };++ const vault = create(options);++ const ask = (type, values = {}, sender = { url: POPUP }) => vault.handle({ type: 'locker:' + type, tabId: 1, site: 'https://example.test', ...values }, sender, POPUP);++ return { vault, ask, memory, fills, clipboard, api, options, writes, pass, recovery, ciphertexts,++ advance: (ms) => { clock += ms; }, active: (tab) => { active = tab; }, account: (id) => { account = id; } };++}++++test('passphrase/recovery unlock, sealed save/list, local TOTP and explicit-click fill/copy only', async () => {++ const f = await fixture();++ assert.deepEqual(await f.ask('unlock', { secret: f.pass }), { ok: false });++ assert.deepEqual(await f.ask('unlock', { clicked: true, secret: f.pass }, { url: 'https://example.test' }), { ok: false });++ await f.ask('unlock', { clicked: true, secret: f.pass });++ assert.equal((await f.ask('status')).locked, false);++ await f.ask('save', { clicked: true, username: 'alice', password: 'fixture password', totp: 'otpauth://totp/x?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ' });++ assert.ok(!Buffer.from(f.ciphertexts.get('1')).includes(Buffer.from('fixture password')));++ assert.deepEqual((await f.ask('list')).items, [{ id: '1', username: 'alice', hasTotp: true }]);++ assert.equal(f.fills.length, 0, 'unlock and list never fill');++ assert.deepEqual(await f.ask('fill', { id: '1' }), { ok: false });++ assert.deepEqual(await f.ask('fill', { id: '1', clicked: true }, { url: POPUP, tab: { id: 1 } }), { ok: false });++ await f.ask('fill', { id: '1', clicked: true }); assert.equal(f.fills[0][2], false);++ await f.ask('submit', { id: '1', clicked: true }); assert.equal(f.fills[1][2], true);++ await f.ask('password', { id: '1', clicked: true }); assert.equal(f.clipboard[0], 'fixture password');++ await f.ask('totp', { id: '1', clicked: true }); assert.match(f.clipboard[1], /^\d{6}$/);++ assert.ok(!JSON.stringify(f.writes).includes('fixture password'), 'passwords never go to storage');++ await f.ask('lock', { clicked: true }); assert.equal(f.memory.has(KEY), false);++ await assert.rejects(f.ask('unlock', { clicked: true, secret: 'wrong' }), /could not unlock/);++ await f.ask('unlock', { clicked: true, mode: 'recovery', secret: ' TEST recovery WORDS ' });++ assert.equal((await f.ask('status')).locked, false);++});++++test('idle expiry survives worker restart; polling does not keep it unlocked; browser restart and account change lock', async () => {++ const f = await fixture(); await f.ask('unlock', { clicked: true, secret: f.pass });++ f.advance(IDLE - 1); assert.equal((await f.ask('status')).locked, false);++ const restarted = create(f.options); f.advance(1); assert.equal(await restarted.state(), null); assert.equal(f.memory.has(KEY), false);++ await f.ask('unlock', { clicked: true, secret: f.pass });++ f.advance(IDLE - 1); await f.ask('activity', { clicked: true }); f.advance(IDLE - 1);++ assert.equal((await f.ask('status')).locked, false, 'trusted popup activity extends the deadline');++ f.memory.clear(); assert.equal((await f.ask('status')).locked, true, 'storage.session clears at browser restart');++ await f.ask('unlock', { clicked: true, secret: f.pass }); f.account('other'); assert.equal((await f.ask('status')).locked, true);++});++++test('saved origin never follows subdomains, protocol changes, another active tab or navigation', async () => {++ const f = await fixture(); await f.ask('unlock', { clicked: true, secret: f.pass });++ await f.ask('save', { clicked: true, username: 'alice', password: 'fixture password' });++ for (const site of ['https://sub.example.test', 'http://example.test', 'https://example.test:444']) {++ f.active({ id: 1, url: site });++ await assert.rejects(f.ask('fill', { id: '1', clicked: true }), /active tab changed/);++ await assert.rejects(f.ask('fill', { id: '1', site, clicked: true }), /different origin/);++ }++ f.active({ id: 2, url: 'https://example.test' }); await assert.rejects(f.ask('fill', { id: '1', clicked: true }), /active tab changed/);++ assert.equal(f.fills.length, 0);++});++++test('locking during a pending decrypt cancels the fill', async () => {++ const f = await fixture(); await f.ask('unlock', { clicked: true, secret: f.pass });++ await f.ask('save', { clicked: true, username: 'alice', password: 'fixture password' });++ let release, entered;++ const started = new Promise((r) => { entered = r; });++ f.api.content = () => { entered(); return new Promise((r) => { release = r; }); };++ const pending = f.ask('fill', { id: '1', clicked: true }); await started; await f.vault.lock(); release(f.ciphertexts.get('1'));++ await assert.rejects(pending, /locked/); assert.equal(f.fills.length, 0);++});++++function page({ site = 'https://example.test', frame = false, clicked = true, submit = false, hidden = false, transparentParent = false, navigate = false } = {}) {++ const calls = []; const form = { querySelectorAll: () => [username, password] };++ class Input { set value(v) { calls.push(v); } }++ class Form { requestSubmit() { calls.push('submit'); } }++ const input = (type, name) => Object.assign(new Input(), { type, name, id: name, autocomplete: name, form, parentElement: transparentParent ? { transparent: true } : null, isConnected: true, getClientRects: () => hidden ? [] : [{ width: 160, height: 40 }], closest: () => null, dispatchEvent: () => { if (navigate) ctx.location.origin = 'https://evil.test'; } });++ const username = input('text', 'username'), password = input('password', 'password');++ const win = {}; win.top = frame ? {} : win;++ const ctx = { window: win, location: { origin: site }, document: { querySelectorAll: () => [password] }, HTMLInputElement: Input, HTMLFormElement: Form, Event: class {}, getComputedStyle: (el) => ({ display: 'block', visibility: 'visible', opacity: el.transparent ? '0' : '1' }) };++ const result = vm.runInNewContext('(' + fill.toString() + ')(login, submit, clicked)', { ...ctx, login: { site: 'https://example.test', username: 'alice', password: 'fixture password' }, submit, clicked });++ return { result, calls };++}++test('injected function rechecks origin and top frame before typing and submits only on explicit request', () => {++ assert.deepEqual(page().calls, ['alice', 'fixture password']); assert.deepEqual(page({ submit: true }).calls, ['alice', 'fixture password', 'submit']);++ for (const opts of [{ site: 'https://sub.example.test' }, { site: 'http://example.test' }, { frame: true }, { clicked: false }, { hidden: true }, { transparentParent: true }]) assert.deepEqual(page(opts).calls, []);++ assert.deepEqual(page({ navigate: true }).calls, ['alice'], 'navigation from a username handler prevents password typing');++});++++test('clipboard clear is scheduled outside popup, replacement resets the deadline, and TOTP matches RFC vector', async () => {++ const { create } = require('../browser/lib/locker-clipboard'); let callback, delay, cancelled = 0; const writes = [];++ const c = create({ write: async (v) => writes.push(v), later: (cb, ms) => { callback = cb; delay = ms; return 1; }, cancel: () => { cancelled++; } });++ await c.write('fixture password'); assert.equal(delay, 30000); await c.write('123456'); assert.equal(cancelled, 2);++ callback(); await c.clear(); assert.equal(writes.at(-1), '');++ assert.equal(await passwords.totp('otpauth://totp/x?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ', 59000), '287082');++ const bg = fs.readFileSync('browser/background.js', 'utf8'); assert.match(bg, /onStartup.addListener\(\(\) => \{ vault.lock\(\)/);++});++++++test('platform injection targets only the active top frame in the isolated world', async () => {++ const { createPlatform } = require('../browser/lib/platform');++ const calls = []; globalThis.self = { OVLockerFill: { fill } };++ try {++ const p = createPlatform({ scripting: { executeScript: async (arg) => { calls.push(arg); return [{ result: { ok: true } }]; } } }, {});++ await p.scripting.fillLogin(42, { site: 'https://example.test', username: 'alice', password: 'fixture' }, false);++ assert.deepEqual(calls[0].target, { tabId: 42, allFrames: false });++ assert.equal(calls[0].world, 'ISOLATED'); assert.equal(calls[0].func, fill); assert.equal(calls[0].args[2], true);++ } finally { delete globalThis.self; }++});++++test('Locker HTTP client only sends the token and sealed bytes; failure bodies are not reflected', async () => {++ const { create } = require('../browser/lib/locker-api'); const requests = [];++ const client = create({ origin: 'https://openvibe.locker', getToken: async () => 'fixture-token', fetch: async (url, options) => {++ requests.push({ url, options }); return new Response(JSON.stringify({ id: 'fixture' }), { status: 201 });++ } });++ const sealed = (await core.seal('fixture password', { lock: 'password', password: 'fixture passphrase' })).bytes;++ await client.save(sealed);++ assert.equal(requests[0].url, 'https://openvibe.locker/api/v1/companion/logins');++ assert.equal(requests[0].options.credentials, 'omit'); assert.equal(requests[0].options.redirect, 'error');++ const wire = Buffer.from(await requests[0].options.body.arrayBuffer());++ assert.deepEqual(wire, Buffer.from(sealed)); assert.ok(!wire.includes(Buffer.from('fixture password')));++ const bad = create({ origin: 'https://openvibe.locker', getToken: async () => 'fixture-token', fetch: async () => new Response('sensitive remote error', { status: 500 }) });++ await assert.rejects(bad.keys(), (e) => !e.message.includes('sensitive') && /Locker/.test(e.message));++});++++++test('Firefox clipboard alarm survives event-page unloading without persisting plaintext', async () => {++ const { createPlatform } = require('../browser/lib/platform'); const writes = [], alarms = [];++ const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');++ Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { clipboard: { writeText: async (v) => writes.push(v) } } });++ globalThis.self = { OVLockerClipboard: { create: ({ write }) => ({ write, clear: () => write('') }) } };++ try {++ const ext = { alarms: { create: async (name, info) => alarms.push({ name, ...info }) } };++ const before = Date.now(); await createPlatform(ext, {}).clipboard.write('fixture');++ assert.equal(alarms[0].name, 'locker-clipboard-clear'); assert.ok(alarms[0].when >= before + 30000);++ // A fresh platform has no timer/value in memory. The alarm can still clear it.++ await createPlatform(ext, {}).clipboard.clear(); assert.deepEqual(writes, ['fixture', '']);++ } finally { delete globalThis.self; Object.defineProperty(globalThis, 'navigator', originalNavigator); }++});@@@ -11,7 +11,7 @@ const { check, done } = require('./helpers/harness');const DIR = path.join(__dirname, '..', 'browser');const ORIGIN = 'https://openvibe.coupons';--const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat', 'https://openvibe.codes'];++const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat', 'https://openvibe.codes', 'https://openvibe.locker'];(async () => {const { manifest, origin, problems } = auditDir(DIR);@@@ -19,8 +19,8 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:await check('the shipped manifest passes the audit: activeTab + identity + scripting + storage + sidePanel, four OpenVibe host permissions, MV3', async () => {assert.deepStrictEqual(problems, []);assert.strictEqual(origin, ORIGIN);-- assert.deepStrictEqual(manifest.permissions.slice().sort(), ['activeTab', 'identity', 'scripting', 'storage']);-- assert.deepStrictEqual(manifest.host_permissions, ['https://openvibe.coupons/*', 'https://openvibe.network/*', 'https://openvibe.codes/*', 'https://openvibe.chat/*']);++ assert.deepStrictEqual(manifest.permissions.slice().sort(), ['activeTab', 'alarms', 'clipboardWrite', 'identity', 'offscreen', 'scripting', 'storage']);++ assert.deepStrictEqual(manifest.host_permissions, ['https://openvibe.coupons/*', 'https://openvibe.network/*', 'https://openvibe.codes/*', 'https://openvibe.chat/*', 'https://openvibe.locker/*']);assert.strictEqual(manifest.sidebar_action.default_panel, 'panel.html', 'the Dock lives in the side panel');for (const k of ['content_scripts', 'web_accessible_resources', 'externally_connectable', 'optional_permissions', 'optional_host_permissions']) {assert.ok(!(k in manifest), `${k} must not be present`);@@@ -95,7 +95,7 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:const csp = manifest.content_security_policy.extension_pages;// OpenVibe.Mail is in the CSP's connect-src (the Companion reaches the masks with CORS,// the way the Coupons API lets a first-party extension in) but it is not a host permission.-- assert.match(csp, /connect-src https:\/\/openvibe\.coupons https:\/\/openvibe\.network https:\/\/openvibe\.chat https:\/\/openvibe\.codes https:\/\/openvibe\.email;/);++ assert.match(csp, /connect-src https:\/\/openvibe\.coupons https:\/\/openvibe\.network https:\/\/openvibe\.chat https:\/\/openvibe\.codes https:\/\/openvibe\.email https:\/\/openvibe\.locker;/);assert.match(csp, /style-src 'self' 'unsafe-inline';/);assert.match(csp, /img-src 'self' blob:;/);assert.match(csp, /frame-src https:\/\/openvibe\.live;/);@@@ -124,7 +124,7 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:const src = fs.readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"])\/\/.*$/gm, '$1');for (const re of forbidden) assert.doesNotMatch(src, re, `${path.relative(DIR, f)} uses ${re}`);const rel0 = path.relative(DIR, f);-- if (![path.join('background.js'), path.join('lib', 'mask-fill.js'), path.join('popup.js')].includes(rel0)) {++ if (![path.join('background.js'), path.join('lib', 'mask-fill.js'), path.join('popup.js'), path.join('lib', 'platform.js')].includes(rel0)) {assert.doesNotMatch(src, /sendMessage/, `${rel0} messages the extension`);}const rel = path.relative(DIR, f);@@@ -139,7 +139,7 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:assert.deepStrictEqual(injects, [path.join('lib', 'platform.js')], 'only the platform wrapper runs a script in a page');const plat = fs.readFileSync(path.join(DIR, 'lib', 'platform.js'), 'utf8');assert.deepStrictEqual([...plat.matchAll(/files:\s*\[([^\]]*)\]/g)].map((m) => m[1].trim()), ["'lib/autoapply.js'", "'lib/rating.js'", "'lib/mask.js', 'lib/mask-fill.js'", "'lib/listing.js'"], 'the files it injects');-- assert.doesNotMatch(plat, /allFrames|world:\s*['"]MAIN|tabId:(?!\s*tabId\b)/, 'the active tab only, its top frame, the isolated world');++ assert.doesNotMatch(plat, /allFrames:\s*true|world:\s*['"]MAIN|tabId:(?!\s*tabId\b)/, 'the active tab only, its top frame, the isolated world');// What runs in the page makes no request and reads no stored data.for (const file of ['autoapply.js', 'rating.js', 'listing.js']) {const src = fs.readFileSync(path.join(DIR, 'lib', file), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');@@@ -47,7 +47,7 @@ function unzip(buf) {assert.ok(files.has('manifest.json') && files.has('popup.html') && files.has('lib/api.js') && files.has('icons/icon-128.png'));// OpenVibe.Mail is in the CSP's connect-src (the Companion reaches the masks with CORS) but it is// not one of the four host permissions.-- assert.deepStrictEqual(auditManifest(JSON.parse(files.get('manifest.json')), { apiOrigin: 'https://openvibe.coupons', origins: ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat', 'https://openvibe.codes'], connectOrigins: ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat', 'https://openvibe.codes', 'https://openvibe.email'] }), []);++ assert.deepStrictEqual(auditManifest(JSON.parse(files.get('manifest.json')), { apiOrigin: 'https://openvibe.coupons', origins: ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat', 'https://openvibe.codes', 'https://openvibe.locker'], connectOrigins: ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat', 'https://openvibe.codes', 'https://openvibe.email', 'https://openvibe.locker'] }), []);});await check('the build is reproducible', async () => {@@@ -61,8 +61,8 @@ function unzip(buf) {assert.match(r.out, /-dev\.zip$/);const files = unzip(fs.readFileSync(r.out));const m = JSON.parse(files.get('manifest.json'));-- assert.deepStrictEqual(m.host_permissions, ['http://localhost:4850/*', 'https://openvibe.network/*', 'https://openvibe.codes/*', 'https://openvibe.chat/*']);-- assert.match(m.content_security_policy.extension_pages, /connect-src http:\/\/localhost:4850 https:\/\/openvibe\.network https:\/\/openvibe\.chat https:\/\/openvibe\.codes https:\/\/openvibe\.email;/);++ assert.deepStrictEqual(m.host_permissions, ['http://localhost:4850/*', 'https://openvibe.network/*', 'https://openvibe.codes/*', 'https://openvibe.chat/*', 'https://openvibe.locker/*']);++ assert.match(m.content_security_policy.extension_pages, /connect-src http:\/\/localhost:4850 https:\/\/openvibe\.network https:\/\/openvibe\.chat https:\/\/openvibe\.codes https:\/\/openvibe\.email https:\/\/openvibe\.locker;/);assert.match(m.name, /development/);assert.match(files.get('lib/config.js').toString(), /API_ORIGIN: 'http:\/\/localhost:4850'/);assert.doesNotMatch(files.get('manifest.json').toString() + files.get('lib/config.js').toString(), /openvibe\.coupons\//);@@@ -85,7 +85,7 @@ function unzip(buf) {} else {delete want.side_panel;delete want.key;-- want.permissions = want.permissions.filter((p) => p !== 'sidePanel');++ want.permissions = want.permissions.filter((p) => p !== 'sidePanel' && p !== 'offscreen');// Firefox has no background service worker: it runs the same file as a background page.want.background = { scripts: ['background.js'], type: 'module' };}
@@ -5,3 +5,4 @@ dist/
@@ -5,3 +5,4 @@ dist/
.env.*
.env.*
*.pem
*.pem
*.key
*.key
+artifacts/
@@ -75,8 +75,8 @@ the Companion never rewrites links or adds referral tags.
@@ -75,8 +75,8 @@ the Companion never rewrites links or adds referral tags.
| | |
| | |
|---|---|
|---|---|
-| **Permissions** | `activeTab`, `identity`, `scripting`, `storage`, and `sidePanel` in the Chrome build (Firefox's `sidebar_action` needs no permission of its own). The host permissions are four OpenVibe origins: `https://openvibe.coupons/*` (the coupon API), `https://openvibe.network/*` (the Dock's state), `https://openvibe.codes/*` (the Dock's agent view and its four controls) and `https://openvibe.chat/*` (the Dock's Chats tab). `identity` starts the Dock's one-click OAuth sign-in (`chrome.identity.launchWebAuthFlow` / Firefox's `browser.identity`). `scripting` is used for four buttons: "Try these codes on this page" injects the packaged `lib/autoapply.js`, "Save its rating to OpenVibe Reviews" injects `lib/rating.js`, and "Keep on my Rent board" and "Check the rent" inject `lib/listing.js`, into the active tab (the access `activeTab` gives after you press the button), in its top frame and the extension's isolated world. |
-| **Not requested** | `<all_urls>`, `tabs`, `cookies`, `webRequest`, `history`, optional permissions, content scripts, a background worker, web-accessible resources, `externally_connectable`. |
+| **Permissions** | `activeTab`, `identity`, `scripting`, `storage`, `alarms`, `clipboardWrite`, and Chrome-only `offscreen` and `sidePanel` in the Chrome build (Firefox's `sidebar_action` needs no permission of its own). The host permissions are five OpenVibe origins: `https://openvibe.coupons/*` (the coupon API), `https://openvibe.network/*` (the Dock's state), `https://openvibe.codes/*` (the Dock's agent view and its four controls) and `https://openvibe.chat/*` (the Dock's Chats tab), plus `https://openvibe.locker/*` (encrypted password logins). `identity` starts the Dock's one-click OAuth sign-in (`chrome.identity.launchWebAuthFlow` / Firefox's `browser.identity`). `scripting` is used for four buttons: "Try these codes on this page" injects the packaged `lib/autoapply.js`, "Save its rating to OpenVibe Reviews" injects `lib/rating.js`, and "Keep on my Rent board" and "Check the rent" inject `lib/listing.js`, into the active tab (the access `activeTab` gives after you press the button), in its top frame and the extension's isolated world. |
+| **Not requested** | `<all_urls>`, `tabs`, `cookies`, `webRequest`, `history`, optional permissions, content scripts, web-accessible resources, `externally_connectable`. |
| **Read from the page** | The active tab's **URL and title**, when you open the popup (that's what `activeTab` grants). The page's content only when you press "Try these codes on this page": `lib/autoapply.js` finds the coupon box, its Apply button and the order total, types the codes in, and returns to the popup which codes changed the total. It makes no request, reads no cookies or storage, and sends nothing anywhere; `test/manifest.test.js` scans it for that. On a review site, "Save its rating to OpenVibe Reviews" runs `lib/rating.js`, which only reads: the page's name, its rating, the scale, how many ratings, and the city from its structured data. "Keep on my Rent board" and "Check the rent" run `lib/listing.js`, which only reads: the price and its currency, the city and region (never the street) and the bedrooms, from the structured data, else the page's price tag. Browser pages, files, IP addresses and local names are "not a site", and nothing is sent or offered for them. |
| **Read from the page** | The active tab's **URL and title**, when you open the popup (that's what `activeTab` grants). The page's content only when you press "Try these codes on this page": `lib/autoapply.js` finds the coupon box, its Apply button and the order total, types the codes in, and returns to the popup which codes changed the total. It makes no request, reads no cookies or storage, and sends nothing anywhere; `test/manifest.test.js` scans it for that. On a review site, "Save its rating to OpenVibe Reviews" runs `lib/rating.js`, which only reads: the page's name, its rating, the scale, how many ratings, and the city from its structured data. "Keep on my Rent board" and "Check the rent" run `lib/listing.js`, which only reads: the price and its currency, the city and region (never the street) and the bedrooms, from the structured data, else the page's price tag. Browser pages, files, IP addresses and local names are "not a site", and nothing is sent or offered for them. |
| **Sent by the extension** | The Coupons part sends to `https://openvibe.coupons` only, and only the **hostname** (`lib/host.js` drops the path, query, fragment, port and credentials first): `GET /api/v1/merchants/resolve?host=`, the merchant id (`GET /api/v1/merchants/:id/coupons`), and, when you report, the code id plus `worked` or `failed` with an optional reason. Every request goes with `credentials: 'omit'` (no cookies), `referrerPolicy: 'no-referrer'` and `redirect: 'error'`. The Dock sends the person's own bearer to `https://openvibe.network` (its state, `/api/v1/me/dock`), `https://openvibe.chat` (Chats), and, when the Agents tab needs it, `https://openvibe.codes` (the agent view and its controls). |
| **Sent by the extension** | The Coupons part sends to `https://openvibe.coupons` only, and only the **hostname** (`lib/host.js` drops the path, query, fragment, port and credentials first): `GET /api/v1/merchants/resolve?host=`, the merchant id (`GET /api/v1/merchants/:id/coupons`), and, when you report, the code id plus `worked` or `failed` with an optional reason. Every request goes with `credentials: 'omit'` (no cookies), `referrerPolicy: 'no-referrer'` and `redirect: 'error'`. The Dock sends the person's own bearer to `https://openvibe.network` (its state, `/api/v1/me/dock`), `https://openvibe.chat` (Chats), and, when the Agents tab needs it, `https://openvibe.codes` (the agent view and its controls). |
| **Hand-offs (only when you press the button)** | "Keep on my Rent board", "Check the rent", "Watch this page", "Compare delivery apps" and "Save its rating to OpenVibe Reviews" open **a new tab you see** on `https://openvibe.rent/board`, `https://openvibe.rent/fair`, `https://openvibe.watch/watches/new`, `https://openvibe.food/delivery` or `https://openvibe.reviews/save`, with the page's address and title (and, for Reviews, the rating read from it; for Rent, the price, city and bedrooms read from it) in that tab's address. `lib/handoff.js` removes the credentials, the fragment and tracking parameters (`utm_*`, `fbclid`, `gclid`, …) first. The extension makes no request for it, nothing opens on its own, and nothing is saved until you save it on that site. `platform.tabs.open()` refuses any other origin, and the permission audit allows only `https://openvibe.<tld>` hand-off origins. |
| **Hand-offs (only when you press the button)** | "Keep on my Rent board", "Check the rent", "Watch this page", "Compare delivery apps" and "Save its rating to OpenVibe Reviews" open **a new tab you see** on `https://openvibe.rent/board`, `https://openvibe.rent/fair`, `https://openvibe.watch/watches/new`, `https://openvibe.food/delivery` or `https://openvibe.reviews/save`, with the page's address and title (and, for Reviews, the rating read from it; for Rent, the price, city and bedrooms read from it) in that tab's address. `lib/handoff.js` removes the credentials, the fragment and tracking parameters (`utm_*`, `fbclid`, `gclid`, …) first. The extension makes no request for it, nothing opens on its own, and nothing is saved until you save it on that site. `platform.tabs.open()` refuses any other origin, and the permission audit allows only `https://openvibe.<tld>` hand-off origins. |
@@ -170,7 +170,7 @@ npm test
@@ -170,7 +170,7 @@ npm test
| Requirement | Test |
| Requirement | Test |
|---|---|
|---|---|
-| The manifest permission audit fails if anything widens: extra permissions, `<all_urls>` or broader hosts, content scripts, a background worker, web-accessible resources, `externally_connectable`, optional permissions, `unsafe-eval`, remote script or connect sources, any style source beyond `'unsafe-inline'`, any image beyond `'self' blob:`, any frame beyond Live, MV2. The first-party origins agree across config, host permissions and CSP. Pages have no inline or remote script. The code never uses cookies, scripting, dynamic code, HTML injection or `storage.sync`, and only `lib/api.js`, `lib/network-api.js`, `lib/auth.js` and the bundled Dock fetch. | `test/manifest.test.js` |
+| The manifest permission audit fails if anything widens: extra permissions, `<all_urls>` or broader hosts, content scripts, web-accessible resources, `externally_connectable`, optional permissions, `unsafe-eval`, remote script or connect sources, any style source beyond `'unsafe-inline'`, any image beyond `'self' blob:`, any frame beyond Live, MV2. The first-party origins agree across config, host permissions and CSP. Pages have no inline or remote script. The code never uses cookies, scripting, dynamic code, HTML injection or `storage.sync`, and only `lib/api.js`, `lib/network-api.js`, `lib/auth.js` and the bundled Dock fetch. | `test/manifest.test.js` |
| The side panel in a real headless Chrome: the unpacked extension opens `panel.html` on the Agents tab with a stubbed Network/Chat/Codes, and the agent view draws its stream frame, line, meta and Pause/Resume, Curtain now, Mute/Unmute and End, with its styles applied and no CSP refusal. Skipped when no Chrome is installed. | `test/panel.test.js` (`scripts/check-panel.js`) |
| The side panel in a real headless Chrome: the unpacked extension opens `panel.html` on the Agents tab with a stubbed Network/Chat/Codes, and the agent view draws its stream frame, line, meta and Pause/Resume, Curtain now, Mute/Unmute and End, with its styles applied and no CSP refusal. Skipped when no Chrome is installed. | `test/panel.test.js` (`scripts/check-panel.js`) |
| API client against a stub: routes; no `Authorization` without a token; never cookies, referrers or redirects; errors as codes (revoked, scope, 429, 503); timeouts; refused input. | `test/api-client.test.js` |
| API client against a stub: routes; no `Authorization` without a token; never cookies, referrers or redirects; errors as codes (revoked, scope, 429, 503); timeouts; refused input. | `test/api-client.test.js` |
| The Coupons part never sends page content or URLs beyond the hostname. The popup controller runs on checkout URLs full of personal data, and every request the stub receives is inspected. Non-shop pages send nothing. Only `tabs.query` and `storage.local` are touched, and only `cpx_` tokens are stored there. | `test/privacy.test.js` |
| The Coupons part never sends page content or URLs beyond the hostname. The popup controller runs on checkout URLs full of personal data, and every request the stub receives is inspected. Non-shop pages send nothing. Only `tabs.query` and `storage.local` are touched, and only `cpx_` tokens are stored there. | `test/privacy.test.js` |
@@ -255,3 +255,45 @@ shows "Allow access to openvibe.coupons".
@@ -255,3 +255,45 @@ shows "Allow access to openvibe.coupons".
---
---
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
+
+## Locker passwords
+
+The popup unlocks Locker with its passphrase or recovery kit. A passkey belongs to
+openvibe.locker, so the popup explains this and offers **Open Locker**. Unlock keys
+live only in `storage.session` (or background memory where unavailable), never local
+or sync storage. Lock now, account changes, browser restart and 15 idle minutes
+clear them. Worker suspension does not reset the deadline.
+
+Saved logins match the current origin exactly, including scheme, hostname and port.
+**Fill** writes visible username/password inputs in the active tab's top frame;
+**Fill and sign in** also submits. Neither runs on page load. Ambiguous forms with
+more than one password input are refused. The injected function checks the origin
+again immediately before writing each input. Copy password/code clears the clipboard
+30 seconds after the latest copy, even when the popup closes; it does not read the
+clipboard and may clear something copied elsewhere during those 30 seconds.
+
+**Save this login** encrypts `{version:1,site,username,password,totp,notes}` using the
+same SDK envelope and private payload framing as Locker. Site and login details
+stay encrypted on the server. TOTP codes (including otpauth links) are computed
+locally. No password telemetry or breach lookup runs in Companion.
+
+Deployment requires Network's `locker.login.manage` scope and
+`GET /api/v1/me/keys/companion`, then Locker's `/api/v1/companion` routes. Existing
+OAuth grants need **Sign in to Companion** again to grant the new scope. The scope
+allows only own wrapped-key reads and own encrypted-login reads/creates, with live
+Network revocation checks. It grants no key-write, general-file or agent blind-fill
+capability. Chrome uses an offscreen clipboard document; Firefox uses its
+background event page with an alarm to clear even after suspension. Only the Locker host permission is added.
+
+Bundled crypto: `browser/shared/ov-locker-core.js` is the unchanged SDK 0.101.0
+browser bundle from Shared 4.156.0. `locker-payload.js` and `locker-passwords.js`
+come from Locker commit `9c2744e`; keep their framing/TOTP semantics compatible.
+Run `npm test -- locker`, `npm run pack:stores`, and `npm run lint:firefox`.
+
+Optional browser verification: install Playwright outside the package (or set
+`PLAYWRIGHT_MODULE` to an existing installation), install its Chromium build, and run
+`node test/locker-browser.js`. It checks trusted popup clicks, 12 responsive/theme
+screenshots and actual clipboard clearing with the packaged worker and no popup open.
+
+Firefox MV3 background pages are [non-persistent](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Background_scripts), so a clipboard alarm backs up its in-memory timer.
+Chrome uses the documented [offscreen clipboard context](https://developer.chrome.com/docs/extensions/reference/api/offscreen).
@@ -18,6 +18,13 @@ import './lib/mask.js';
@@ -18,6 +18,13 @@ import './lib/mask.js';
import './lib/mask-service.js';
import './lib/mask-service.js';
import './lib/mail-api.js';
import './lib/mail-api.js';
import './lib/mask-router.js';
import './lib/mask-router.js';
+import './shared/ov-locker-core.js';
+import './lib/locker-passwords.js';
+import './lib/locker-payload.js';
+import './lib/locker-api.js';
+import './lib/locker-fill.js';
+import './lib/locker-clipboard.js';
+import './lib/locker-vault.js';
const ext = self.browser || self.chrome;
const ext = self.browser || self.chrome;
const config = self.OVConfig;
const config = self.OVConfig;
@@ -48,10 +55,38 @@ function isOwnPage(sender) {
@@ -48,10 +55,38 @@ function isOwnPage(sender) {
const router = self.OVMaskRouter.createRouter({ rules: self.OVMask, service: service, isOwnPage: isOwnPage });
const router = self.OVMaskRouter.createRouter({ rules: self.OVMask, service: service, isOwnPage: isOwnPage });
+const lockerApi = self.OVLockerApi.create({ origin: config.LOCKER_ORIGIN, fetch: self.fetch.bind(self), getToken: () => auth.accessToken() });
+const vault = self.OVLockerVault.create({
+ core: self.OVLockerCore, payload: self.LockerPayload, passwords: self.LockerPasswords, api: lockerApi,
+ storage: platform.storage, tabs: platform.tabs, fill: platform.scripting.fillLogin,
+ clipboard: platform.clipboard,
+ account: async () => { const a = await auth.account(); return a?.sub || (await auth.status()).mode; },
+ scheduleLock: (when) => ext.alarms.create('locker-lock', { when }), cancelLock: () => ext.alarms.clear('locker-lock'),
+});
+ext.alarms.onAlarm.addListener((alarm) => {
+ if (alarm.name === 'locker-lock') vault.state().catch(() => vault.lock());
+ if (alarm.name === 'locker-clipboard-clear') platform.clipboard.clear().catch(() => {});
+});
+// Restore the deadline if a browser dropped an alarm while suspending the worker.
+vault.state().then((saved) => { if (saved) return ext.alarms.create('locker-lock', { when: saved.expires }); }).catch(() => vault.lock());
+ext.runtime.onStartup.addListener(() => { vault.lock(); });
+ext.storage.onChanged.addListener((changes, area) => {
+ if (area === 'local' && (changes[config.DOCK_ACCOUNT_KEY] || changes[config.NETWORK_TOKEN_KEY])) vault.lock();
+});
+
ext.runtime.onMessage.addListener(function (message, sender, sendResponse) {
ext.runtime.onMessage.addListener(function (message, sender, sendResponse) {
- router.handle(message, sender).then(
+ if (message?.target === 'locker-clipboard') return false;
+ if (message?.type === 'locker:signin') {
+ if (sender?.url !== ext.runtime.getURL('popup.html') || sender.tab || message.clicked !== true) { sendResponse({ ok: false }); return false; }
+ vault.lock().then(() => auth.signIn()).then(() => sendResponse({ ok: true }), () => sendResponse({ ok: false }));
+ return true;
+ }
+ const operation = message?.type?.startsWith('locker:')
+ ? vault.handle(message, sender, ext.runtime.getURL('popup.html'))
+ : router.handle(message, sender);
+ operation.then(
function (answer) { sendResponse(answer || { ok: false }); },
function (answer) { sendResponse(answer || { ok: false }); },
- function () { sendResponse({ ok: false, message: 'Something went wrong. Try again later.' }); }
+ function (error) { sendResponse({ ok: false, message: message?.type?.startsWith('locker:') ? (error.message.startsWith('That passphrase') || /^(Unlock |Sign in |Use your |The active tab |This login |Enter a username)/.test(error.message) ? error.message : 'Locker could not complete that action. Unlock again and retry.') : 'Something went wrong. Try again later.' }); }
);
);
return true;
return true;
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+<!DOCTYPE html>
+<html lang="en"><head><meta charset="utf-8"><title>Companion clipboard</title></head>
+<body><textarea id="clipboard" aria-label="Clipboard transfer"></textarea><script src="lib/locker-clipboard.js"></script><script src="clipboard.js"></script></body></html>
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+'use strict';
+// This offscreen document stays alive after the popup closes; it never reads the clipboard.
+const clipboard = OVLockerClipboard.create({ write(value) {
+ const field = document.getElementById('clipboard');
+ // A nonempty selection ensures Chrome fires copy even when clearing to the empty string.
+ field.value = value || ' '; field.select();
+ const copy = (event) => { event.clipboardData.setData('text/plain', value); event.preventDefault(); };
+ document.addEventListener('copy', copy, { once: true });
+ try { if (!document.execCommand('copy')) throw new Error('Clipboard unavailable'); }
+ finally { document.removeEventListener('copy', copy); field.value = ''; }
+} });
+chrome.runtime.onMessage.addListener((message, sender, reply) => {
+ if (message?.target !== 'locker-clipboard' || sender.id !== chrome.runtime.id || sender.url !== chrome.runtime.getURL('background.js')) return;
+ (message.clear ? clipboard.clear() : clipboard.write(message.value)).then(() => reply({ ok: true }), () => reply({ ok: false }));
+ return true;
+});
@@ -34,6 +34,7 @@
@@ -34,6 +34,7 @@
CHAT_ORIGIN: 'https://openvibe.chat',
CHAT_ORIGIN: 'https://openvibe.chat',
// MAIL_ORIGIN is OpenVibe.Mail's API, where the person's masks live (GET/POST /api/v1/masks). The
// MAIL_ORIGIN is OpenVibe.Mail's API, where the person's masks live (GET/POST /api/v1/masks). The
// Companion asks it for one mask per site, and never for anything else.
// Companion asks it for one mask per site, and never for anything else.
+ LOCKER_ORIGIN: 'https://openvibe.locker',
MAIL_ORIGIN: 'https://openvibe.email',
MAIL_ORIGIN: 'https://openvibe.email',
// CODES_ORIGIN is OpenVibe.Codes' API: the Dock's agent view (GET /api/v1/me/agent-view and the
// CODES_ORIGIN is OpenVibe.Codes' API: the Dock's agent view (GET /api/v1/me/agent-view and the
// four controls), and the still Codes serves when nothing is live.
// four controls), and the still Codes serves when nothing is live.
@@ -43,7 +44,7 @@
@@ -43,7 +44,7 @@
// identity.getRedirectURL() is not one of these refuses to sign in rather than send an
// identity.getRedirectURL() is not one of these refuses to sign in rather than send an
// unregistered redirect.
// unregistered redirect.
OAUTH_CLIENT_ID: 'companion',
OAUTH_CLIENT_ID: 'companion',
- OAUTH_SCOPES: Object.freeze(['dock', 'activity', 'chat.read', 'agent.view', 'mail.mask.manage']),
+ OAUTH_SCOPES: Object.freeze(['dock', 'activity', 'chat.read', 'agent.view', 'mail.mask.manage', 'locker.login.manage']),
OAUTH_REDIRECT_URIS: Object.freeze([
OAUTH_REDIRECT_URIS: Object.freeze([
'https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/',
'https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/',
'https://bb6593a68b1dcfbb7acd9650f04a169f4c5b57a0.extensions.allizom.org/',
'https://bb6593a68b1dcfbb7acd9650f04a169f4c5b57a0.extensions.allizom.org/',
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api; else root.OVLockerApi = api;
+})(globalThis, function () {
+ 'use strict';
+ function create({ fetch: request, getToken, origin }) {
+ async function call(path, body) {
+ const token = await getToken();
+ const r = await request(origin + '/api/v1/companion' + path, {
+ method: body ? 'POST' : 'GET', credentials: 'omit', referrerPolicy: 'no-referrer', redirect: 'error',
+ headers: { Authorization: 'Bearer ' + token, ...(body ? { 'Content-Type': 'application/octet-stream' } : {}) },
+ ...(body ? { body: new Blob([body], { type: 'application/octet-stream' }) } : {}), signal: AbortSignal.timeout(15000),
+ });
+ if (!r.ok) throw new Error(r.status === 401 || r.status === 403 ? 'Sign in again to allow Companion to use Locker.' : 'Locker could not complete this request.');
+ return r;
+ }
+ return {
+ keys: async () => (await call('/keys')).json(),
+ async list() {
+ const items = []; let before = '';
+ do {
+ const page = await (await call('/logins' + (before ? '?before=' + encodeURIComponent(before) : ''))).json();
+ items.push(...page.items); before = page.next_cursor;
+ } while (before);
+ return items;
+ },
+ content: async (id) => new Uint8Array(await (await call('/logins/' + encodeURIComponent(id) + '/content')).arrayBuffer()),
+ save: async (bytes) => (await call('/logins', bytes)).json(),
+ };
+ }
+ return { create };
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api; else root.OVLockerClipboard = api;
+})(globalThis, function () {
+ 'use strict';
+ function create({ write, later = setTimeout, cancel = clearTimeout }) {
+ let timer, queue = Promise.resolve();
+ function clear() { queue = queue.catch(() => {}).then(() => write('')); return queue; }
+ return {
+ write(value) {
+ queue = queue.catch(() => {}).then(async () => {
+ await write(value);
+ cancel(timer);
+ timer = later(() => { clear().catch(() => { timer = later(() => clear().catch(() => {}), 1000); }); }, 30000);
+ });
+ return queue;
+ },
+ clear,
+ };
+ }
+ return { create };
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api; else root.OVLockerFill = api;
+})(globalThis, function () {
+ 'use strict';
+ // Self-contained: Chrome serializes this function into the isolated world of frame 0.
+ function fill(login, submit, clicked) {
+ if (clicked !== true || window !== window.top || location.origin !== login.site) return { ok: false };
+ function visible(input) {
+ for (let ancestor = input; ancestor; ancestor = ancestor.parentElement) {
+ const style = getComputedStyle(ancestor);
+ if (style.display === 'none' || style.visibility !== 'visible' || Number(style.opacity) === 0) return false;
+ }
+ const style = getComputedStyle(input);
+ return !input.disabled && !input.readOnly && input.type !== 'hidden' && style.display !== 'none' && style.visibility === 'visible'
+ && Number(style.opacity) !== 0 && Array.from(input.getClientRects()).some((rect) => rect.width > 0 && rect.height > 0) && !input.closest('[inert]');
+ }
+ const passwords = Array.from(document.querySelectorAll('input[type="password"]')).filter(visible);
+ if (passwords.length !== 1) return { ok: false }; // Ambiguous signup / change-password forms need the person.
+ const password = passwords[0];
+ const fields = Array.from((password.form || document).querySelectorAll('input')).filter(visible);
+ const textFields = fields.filter((el) => el.type === 'text');
+ const username = fields.find((el) => el.autocomplete === 'username')
+ || fields.find((el) => el.type === 'email')
+ || fields.find((el) => el.type === 'text' && /user|login|email/i.test(el.name + ' ' + el.id))
+ || (password.form && textFields.length === 1 ? textFields[0] : null);
+ if (!username || (submit && !password.form)) return { ok: false };
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
+ for (const [input, value] of [[username, login.username], [password, login.password]]) {
+ // Page event handlers may navigate, remove a field, or replace the form between writes.
+ if (window !== window.top || location.origin !== login.site || !input.isConnected || !visible(input)) return { ok: false };
+ setter.call(input, value);
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+ if (submit) {
+ if (location.origin !== login.site || window !== window.top || !password.isConnected) return { ok: false };
+ HTMLFormElement.prototype.requestSubmit.call(password.form);
+ }
+ return { ok: true };
+ }
+ return { fill };
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+(function (root) {
+ 'use strict';
+ function mount({ container, ask, platform }) {
+ const make = (tag, text, cls) => { const node = document.createElement(tag); if (text) node.textContent = text; if (cls) node.className = cls; return node; };
+ let context, busy = false, tick, lastActivity = 0, isUnlocked = false;
+ const note = make('p', '', 'note muted'); note.setAttribute('role', 'status');
+ const body = make('div');
+ const heading = make('div', '', 'locker-heading');
+ heading.append(make('h2', 'Passwords'), make('span', 'Locker', 'status'));
+ container.append(heading, body, note);
+ function button(label, action, cls = 'btn') {
+ const b = make('button', label, cls); b.type = 'button';
+ b.addEventListener('click', (e) => { if (e.isTrusted) run(b, action); }); return b;
+ }
+ async function run(b, action) {
+ if (busy) return; busy = true; b.disabled = true; note.textContent = '';
+ try { await action(); } catch (e) { note.textContent = e.message; }
+ finally { busy = false; b.disabled = false; }
+ }
+ async function request(action, fields = {}) {
+ const r = await ask({ type: 'locker:' + action, ...context, ...fields });
+ if (!r?.ok) throw new Error(r?.message || 'Could not complete that action. Check that the page has one visible login form.');
+ return r;
+ }
+ function field(form, label, type, name) {
+ const wrap = make('label', label, 'locker-field'); const input = make('input');
+ input.name = name; input.type = type; input.autocomplete = 'off'; input.spellcheck = false;
+ wrap.append(input); form.append(wrap); return input;
+ }
+ for (const event of ['pointerdown', 'keydown']) container.addEventListener(event, (e) => {
+ if (!e.isTrusted || !isUnlocked || Date.now() - lastActivity < 60000) return;
+ lastActivity = Date.now(); ask({ type: 'locker:activity', clicked: true });
+ });
+ function locked() {
+ isUnlocked = false;
+ clearInterval(tick); body.replaceChildren();
+ body.append(make('p', 'Unlock to use a saved login on this site.', 'muted'));
+ const form = make('form', '', 'locker-form');
+ const modeLabel = make('label', 'Unlock with', 'locker-field'); const mode = make('select');
+ for (const [value, label] of [['passphrase', 'Locker passphrase'], ['recovery', 'Recovery kit']]) { const opt = make('option', label); opt.value = value; mode.append(opt); }
+ modeLabel.append(mode); form.append(modeLabel);
+ const secret = field(form, 'Passphrase or recovery words', 'password', 'secret'); secret.required = true;
+ const submit = make('button', 'Unlock Locker', 'btn primary'); submit.type = 'submit'; form.append(submit);
+ form.addEventListener('submit', (e) => {
+ e.preventDefault(); if (!e.isTrusted) return;
+ const value = secret.value; secret.value = '';
+ run(submit, async () => { await request('unlock', { clicked: true, mode: mode.value, secret: value }); await unlocked(); });
+ });
+ body.append(form, make('p', 'Your passkey belongs to openvibe.locker and cannot unlock here. Use your recovery kit or open Locker.', 'note muted'));
+ const open = make('a', 'Open Locker'); open.href = 'https://openvibe.locker/passwords'; open.target = '_blank'; open.rel = 'noopener'; body.append(open);
+ body.append(button('Sign in to Companion', async () => { const r = await ask({ type: 'locker:signin', clicked: true }); if (!r?.ok) throw new Error('Sign-in could not finish. Try again.'); note.textContent = 'Signed in. Unlock with your Locker passphrase or recovery kit.'; }));
+ }
+ async function unlocked() {
+ isUnlocked = true; lastActivity = Date.now();
+ body.replaceChildren(); clearInterval(tick);
+ const controls = make('div', '', 'locker-heading');
+ controls.append(make('p', 'Locks after 15 minutes idle', 'note muted'), button('Lock now', async () => { await request('lock', { clicked: true }); locked(); }));
+ body.append(controls);
+ if (!context) { body.append(make('p', 'Open a website to fill or save a login.', 'muted')); return; }
+ body.append(make('p', context.site, 'locker-origin'));
+ const list = make('div'); body.append(list);
+ const data = await request('list');
+ if (!data.items.length) list.append(make('p', 'No saved logins for this exact site.', 'muted'));
+ for (const item of data.items) {
+ const card = make('article', '', 'locker-login'); card.append(make('h3', item.username || 'No username'));
+ const actions = make('div', '', 'row');
+ const act = (label, action, primary) => button(label, async () => {
+ await request(action, { id: item.id, clicked: true });
+ note.textContent = action === 'password' || action === 'totp' ? 'Copied. Clipboard clears in 30 seconds.' : action === 'submit' ? 'Filled and submitted.' : 'Filled.';
+ }, primary ? 'btn primary' : 'btn');
+ actions.append(act('Fill', 'fill', true), act('Fill and sign in', 'submit'), act('Copy password', 'password'));
+ if (item.hasTotp) actions.append(act('Copy code', 'totp')); card.append(actions); list.append(card);
+ }
+ const details = make('details', '', 'locker-save'); details.append(make('summary', 'Save this login'));
+ const form = make('form', '', 'locker-form');
+ const username = field(form, 'Username', 'text', 'username'); const password = field(form, 'Password', 'password', 'password'); password.required = true;
+ const totp = field(form, 'TOTP secret or otpauth link (optional)', 'password', 'totp');
+ const save = make('button', 'Save encrypted login', 'btn primary'); save.type = 'submit'; form.append(save);
+ form.addEventListener('submit', (e) => {
+ e.preventDefault(); if (!e.isTrusted) return;
+ const values = { username: username.value, password: password.value, totp: totp.value, clicked: true }; form.reset();
+ run(save, async () => { await request('save', values); await unlocked(); note.textContent = 'Saved to Locker.'; });
+ }); details.append(form); body.append(details);
+ tick = setInterval(async () => { const s = await ask({ type: 'locker:status' }); if (!s?.ok || s.locked) locked(); }, 5000);
+ }
+ (async () => {
+ const tab = await platform.tabs.active();
+ try { const u = new URL(tab.url); if (['http:', 'https:'].includes(u.protocol)) context = { tabId: tab.id, site: u.origin }; } catch { /* internal tab */ }
+ const state = await request('status'); if (state.locked) locked(); else await unlocked();
+ })().catch(() => { locked(); note.textContent = 'Unlock Locker to continue.'; });
+ }
+ root.OVLockerPanel = { mount };
+})(globalThis);
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+(function (root) {
+ 'use strict';
+ function parseCsv(text) {
+ const rows = []; let row = [], field = '', quoted = false;
+ for (let i = 0; i < text.length; i++) { const c = text[i]; if (quoted) { if (c === '"' && text[i + 1] === '"') { field += '"'; i++; } else if (c === '"') quoted = false; else field += c; }
+ else if (c === '"') quoted = true; else if (c === ',') { row.push(field); field = ''; } else if (c === '\n' || c === '\r') { if (c === '\r' && text[i + 1] === '\n') i++; row.push(field); rows.push(row); row = []; field = ''; } else field += c; }
+ if (quoted) throw new Error('CSV has an unclosed quoted field.'); if (field || row.length) { row.push(field); rows.push(row); } return rows.filter((r) => r.some(Boolean));
+ }
+ function normalizeOrigin(value) { const url = new URL(value); if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('Use a valid website URL.'); return url.origin; }
+ async function breachRange(password) {
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-1', new TextEncoder().encode(password)));
+ const hash = [...digest].map((b) => b.toString(16).padStart(2, '0')).join('').toUpperCase();
+ return { prefix: hash.slice(0, 5), suffix: hash.slice(5) };
+ }
+ // A bare Base32 secret, or an otpauth://totp/ link as authenticator QR codes and most exports carry it.
+ function totpParams(value) {
+ const raw = String(value || '').trim();
+ if (!/^otpauth:\/\//i.test(raw)) return { secret: raw, algorithm: 'SHA-1', digits: 6, period: 30 };
+ const url = new URL(raw); const p = url.searchParams;
+ if (url.hostname.toLowerCase() !== 'totp') throw new Error('Only time-based (TOTP) codes are supported.');
+ const algorithm = { SHA1: 'SHA-1', SHA256: 'SHA-256', SHA512: 'SHA-512' }[(p.get('algorithm') || 'SHA1').toUpperCase().replace('-', '')];
+ const digits = Number(p.get('digits') || 6), period = Number(p.get('period') || 30);
+ if (!algorithm || ![6, 7, 8].includes(digits) || !Number.isInteger(period) || period < 1 || period > 300) throw new Error('This TOTP link uses settings Locker does not support.');
+ return { secret: p.get('secret') || '', algorithm, digits, period };
+ }
+ // A site from an import file: a bare host gets https://; anything that is not a web origin is skipped (null).
+ function importOrigin(value) {
+ const raw = String(value || '').trim(); if (!raw) return null;
+ try { return normalizeOrigin(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`); } catch { return null; }
+ }
+ async function totp(value, now = Date.now()) {
+ const { secret, algorithm, digits, period } = totpParams(value);
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; let bits = '', bytes = [];
+ for (const c of secret.toUpperCase().replace(/[\s=-]/g, '')) { const v = alphabet.indexOf(c); if (v < 0) throw new Error('The TOTP secret is not valid Base32.'); bits += v.toString(2).padStart(5, '0'); }
+ for (let i = 0; i + 8 <= bits.length; i += 8) bytes.push(parseInt(bits.slice(i, i + 8), 2));
+ if (!bytes.length) throw new Error('The TOTP secret is empty.');
+ const counter = BigInt(Math.floor(now / (period * 1000))); const msg = new Uint8Array(8); new DataView(msg.buffer).setBigUint64(0, counter);
+ const key = await crypto.subtle.importKey('raw', new Uint8Array(bytes), { name: 'HMAC', hash: algorithm }, false, ['sign']); const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, msg));
+ const off = mac.at(-1) & 15; const code = ((mac[off] & 127) << 24 | mac[off + 1] << 16 | mac[off + 2] << 8 | mac[off + 3]) % 10 ** digits; return String(code).padStart(digits, '0');
+ }
+ const api = { parseCsv, normalizeOrigin, importOrigin, breachRange, totpParams, totp };
+ if (typeof module !== 'undefined') module.exports = api;
+ else root.LockerPasswords = api;
+})(globalThis);
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
+/* The private file description is part of the plaintext given to the existing Locker SDK. */
+(function (root) {
+ 'use strict';
+ const encoder = new TextEncoder();
+ function prefix(meta) {
+ const json = encoder.encode(JSON.stringify({ version: 1, ...meta }));
+ if (json.length > 65536) throw new Error('The file description is too long.');
+ const bytes = new Uint8Array(4 + json.length);
+ new DataView(bytes.buffer).setUint32(0, json.length);
+ bytes.set(json, 4);
+ return bytes;
+ }
+ function pack(meta, stream) {
+ const first = prefix(meta);
+ let sent = false, reader;
+ return new ReadableStream({
+ async pull(controller) {
+ if (!sent) { sent = true; controller.enqueue(first); return; }
+ reader ||= stream.getReader();
+ const chunk = await reader.read();
+ if (chunk.done) { reader.releaseLock(); controller.close(); } else controller.enqueue(chunk.value);
+ },
+ async cancel(reason) { if (reader) await reader.cancel(reason); else await stream.cancel(reason); },
+ });
+ }
+ async function unpack(stream) {
+ const reader = stream.getReader();
+ let buffer = new Uint8Array(0);
+ async function take(n) {
+ while (buffer.length < n) {
+ const part = await reader.read();
+ if (part.done) throw new Error('The locked file is incomplete.');
+ const next = new Uint8Array(buffer.length + part.value.length); next.set(buffer); next.set(part.value, buffer.length); buffer = next;
+ }
+ const out = buffer.slice(0, n); buffer = buffer.slice(n); return out;
+ }
+ try {
+ const len = new DataView((await take(4)).buffer).getUint32(0);
+ if (len < 2 || len > 65536) throw new Error('The locked file description is invalid.');
+ const meta = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(await take(len)));
+ if (meta.version !== 1 || typeof meta.name !== 'string' || meta.name.length > 1024 || typeof meta.type !== 'string'
+ || !Number.isSafeInteger(meta.size) || meta.size < 0) throw new Error('The locked file description is invalid.');
+ let received = 0;
+ const content = new ReadableStream({
+ async pull(controller) {
+ const part = buffer.length ? { value: buffer, done: false } : await reader.read(); buffer = new Uint8Array(0);
+ if (part.done) {
+ reader.releaseLock();
+ if (received !== meta.size) throw new Error('The locked file size does not match.');
+ controller.close();
+ } else {
+ received += part.value.length;
+ if (received > meta.size) throw new Error('The locked file is longer than expected.');
+ controller.enqueue(part.value);
+ }
+ },
+ cancel: (reason) => reader.cancel(reason),
+ });
+ return { meta, stream: content };
+ } catch (e) { await reader.cancel(e).catch(() => {}); throw e; }
+ }
+ const api = { prefix, pack, unpack };
+ if (typeof module !== 'undefined') module.exports = api;
+ else root.LockerPayload = api;
+})(globalThis);
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -0,0 +1,116 @@
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api; else root.OVLockerVault = api;
+})(globalThis, function () {
+ 'use strict';
+ const IDLE = 15 * 60 * 1000, KEY = 'lockerUnlock';
+ function origin(url) {
+ try { const u = new URL(url); return ['https:', 'http:'].includes(u.protocol) && !u.username && !u.password ? u.origin : null; } catch { return null; }
+ }
+ function create({ core, payload, passwords, api, storage, scheduleLock, cancelLock, tabs, fill, clipboard, account, now = Date.now }) {
+ let generation = 0, mutations = Promise.resolve();
+ function persist(saved, version) {
+ mutations = mutations.catch(() => {}).then(async () => {
+ if (version !== generation) throw new Error('Locker locked. Unlock again.');
+ await storage.setSession({ [KEY]: saved }); await scheduleLock(saved.expires);
+ });
+ return mutations;
+ }
+ async function lock() {
+ generation++;
+ mutations = mutations.catch(() => {}).then(async () => { await storage.removeSession([KEY]); await cancelLock(); });
+ await mutations; return { locked: true };
+ }
+ async function state(touch = false) {
+ const version = generation;
+ const saved = await storage.getSession(KEY);
+ if (!saved) return null;
+ if (version !== generation) return null;
+ if (now() >= saved.expires || saved.account !== await account()) { await lock(); return null; }
+ if (touch) { saved.expires = now() + IDLE; await persist(saved, version); }
+ return saved;
+ }
+ async function active(message) {
+ const tab = await tabs.active();
+ const site = origin(tab.url);
+ if (!site || tab.id !== message.tabId || site !== message.site) throw new Error('The active tab changed. Open Companion again.');
+ return tab;
+ }
+ async function unlocked() {
+ const version = generation;
+ const s = await state(true);
+ if (!s) throw new Error('Unlock your Locker first.');
+ return { ...s, person: await core.importPersonKeys(s.privateKeys), generation: version };
+ }
+ async function still(s) {
+ if (generation !== s.generation || !(await state()) || s.account !== await account()) throw new Error('Locker locked. Unlock again.');
+ }
+ async function read(id, s) {
+ const bytes = await api.content(id);
+ if (bytes.byteLength > 2 * 1024 ** 2) throw new Error('This login is too large.');
+ const plain = await core.open(bytes, { privateKey: s.person.ecdhPrivateKey, subject: { type: 'user', id: s.subject } });
+ const opened = await payload.unpack(new Blob([plain]).stream());
+ const login = JSON.parse(await new Response(opened.stream).text());
+ if (login.version !== 1 || origin(login.site) !== login.site || typeof login.username !== 'string' || typeof login.password !== 'string') throw new Error('Invalid saved login.');
+ return login;
+ }
+ async function handle(m, sender, popupUrl) {
+ // Web pages, iframes, panel, options and offscreen document cannot invoke password operations.
+ if (sender?.url !== popupUrl || sender?.tab || !m || !m.type.startsWith('locker:')) return { ok: false };
+ const action = m.type.slice(7);
+ if (action === 'status') return { ok: true, locked: !(await state()) };
+ if (m.clicked !== true && action !== 'list') return { ok: false };
+ if (action === 'activity') return { ok: true, locked: !(await state(true)) };
+ if (action === 'lock') return { ok: true, ...await lock() };
+ if (action === 'unlock') {
+ await lock(); const g = generation;
+ const owner = await account();
+ const keys = await api.keys();
+ const mode = m.mode === 'recovery' ? 'recovery' : 'passphrase';
+ const backup = keys.backups?.[mode];
+ if (!backup?.envelope) throw new Error('Use your recovery kit, or open Locker to set up a passphrase. Passkeys belong to openvibe.locker.');
+ const secret = mode === 'recovery' ? String(m.secret).trim().toLowerCase().split(/\s+/).join(' ') : m.secret;
+ const bytes = Uint8Array.from(atob(backup.envelope), (c) => c.charCodeAt(0));
+ let privateKeys;
+ try {
+ privateKeys = JSON.parse(new TextDecoder().decode(await core.open(bytes, { password: secret })));
+ await core.importPersonKeys(privateKeys);
+ } catch { throw new Error('That passphrase or recovery kit could not unlock Locker.'); }
+ if (g !== generation || owner !== await account()) throw new Error('Sign-in changed. Try again.');
+ const expires = now() + IDLE;
+ await persist({ privateKeys, subject: keys.subject, account: owner, expires }, g);
+ return { ok: true, locked: false };
+ }
+ await active(m);
+ const s = await unlocked();
+ if (action === 'list') {
+ const items = [];
+ for (const item of await api.list()) {
+ const login = await read(item.id, s);
+ if (login.site === m.site) items.push({ id: item.id, username: login.username, hasTotp: !!login.totp });
+ }
+ await still(s); await active(m);
+ return { ok: true, items };
+ }
+ if (action === 'save') {
+ if (typeof m.username !== 'string' || typeof m.password !== 'string' || !m.password || m.password.length > 16384 || m.username.length > 4096) throw new Error('Enter a username and password.');
+ if (m.totp) await passwords.totp(m.totp);
+ const login = { version: 1, site: m.site, username: m.username, password: m.password, totp: m.totp || '', notes: '' };
+ const body = new Blob([JSON.stringify(login)]);
+ const packed = new Uint8Array(await new Response(payload.pack({ name: 'login.json', type: 'application/json', size: body.size }, body.stream())).arrayBuffer());
+ const sealed = await core.seal(packed, { lock: 'people', keys: [{ subject: { type: 'user', id: s.subject }, publicKey: s.person.ecdhPublic }] });
+ await still(s); await active(m); await api.save(sealed.bytes);
+ return { ok: true };
+ }
+ if (!['fill', 'submit', 'password', 'totp'].includes(action)) return { ok: false };
+ const login = await read(m.id, s);
+ if (login.site !== m.site) throw new Error('This login belongs to a different origin.');
+ await still(s); const tab = await active(m);
+ if (action === 'fill' || action === 'submit') return await fill(tab.id, { site: login.site, username: login.username, password: login.password }, action === 'submit');
+ await clipboard.write(action === 'totp' ? await passwords.totp(login.totp) : login.password);
+ return { ok: true };
+ }
+ return { handle, lock, state };
+ }
+ return { create, origin, IDLE, KEY };
+});
@@ -52,7 +52,34 @@
@@ -52,7 +52,34 @@
await call(ext.tabs.create, ext.tabs, [{ url: url }]);
await call(ext.tabs.create, ext.tabs, [{ url: url }]);
},
},
},
},
+ clipboard: {
+ async clear() {
+ if (this.local) return this.local.clear();
+ if (!ext.offscreen) return navigator.clipboard.writeText('');
+ },
+ async write(value) {
+ if (!ext.offscreen) {
+ // Firefox may unload its event page: the alarm wakes it to clear the clipboard.
+ this.local ||= self.OVLockerClipboard.create({ write: (text) => navigator.clipboard.writeText(text) });
+ await this.local.write(value);
+ await ext.alarms.create('locker-clipboard-clear', { when: Date.now() + 30000 });
+ return;
+ }
+ if (!await ext.offscreen.hasDocument()) await ext.offscreen.createDocument({
+ url: 'clipboard.html', reasons: ['CLIPBOARD'], justification: 'Copy a saved password or TOTP code and clear it after 30 seconds.',
+ });
+ const result = await ext.runtime.sendMessage({ target: 'locker-clipboard', value });
+ if (!result?.ok) throw new Error('Clipboard unavailable.');
+ },
+ },
scripting: {
scripting: {
+ async fillLogin(tabId, login, submit) {
+ const out = await call(ext.scripting.executeScript, ext.scripting, [{
+ target: { tabId, allFrames: false }, world: 'ISOLATED',
+ func: self.OVLockerFill.fill, args: [login, submit === true, true],
+ }]);
+ return out?.[0]?.result || { ok: false };
+ },
async tryCodes(tabId, codes) {
async tryCodes(tabId, codes) {
if (!ext.scripting || !Number.isInteger(tabId)) throw new TypeError('this browser cannot run the code tester');
if (!ext.scripting || !Number.isInteger(tabId)) throw new TypeError('this browser cannot run the code tester');
const list = (Array.isArray(codes) ? codes : []).slice(0, config.MAX_TRIES).map(function (c) { return { id: String(c.id || ''), code: String(c.code || '') }; });
const list = (Array.isArray(codes) ? codes : []).slice(0, config.MAX_TRIES).map(function (c) { return { id: String(c.id || ''), code: String(c.code || '') }; });
@@ -37,16 +37,20 @@
@@ -37,16 +37,20 @@
"activeTab",
"activeTab",
"identity",
"identity",
"scripting",
"scripting",
- "storage"
+ "storage",
+ "alarms",
+ "clipboardWrite",
+ "offscreen"
],
],
"host_permissions": [
"host_permissions": [
"https://openvibe.coupons/*",
"https://openvibe.coupons/*",
"https://openvibe.network/*",
"https://openvibe.network/*",
"https://openvibe.codes/*",
"https://openvibe.codes/*",
- "https://openvibe.chat/*"
+ "https://openvibe.chat/*",
+ "https://openvibe.locker/*"
],
],
"content_security_policy": {
"content_security_policy": {
- "extension_pages": "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob:; connect-src https://openvibe.coupons https://openvibe.network https://openvibe.chat https://openvibe.codes https://openvibe.email; frame-src https://openvibe.live; object-src 'none'; base-uri 'none'"
+ "extension_pages": "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob:; connect-src https://openvibe.coupons https://openvibe.network https://openvibe.chat https://openvibe.codes https://openvibe.email https://openvibe.locker; frame-src https://openvibe.live; object-src 'none'; base-uri 'none'"
},
},
"minimum_chrome_version": "114",
"minimum_chrome_version": "114",
"browser_specific_settings": {
"browser_specific_settings": {
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
:root { color-scheme: light dark; --fg: #1d2433; --muted: #5d6b82; --bg: #ffffff; --card: #f4f6fa; --line: #d6dce6; --accent: #1d4ed8; --accent-strong: #2563eb; --on-accent-strong: #ffffff; --code: #b45309; --ok: #15803d; --bad: #b91c1c; }
:root { color-scheme: light dark; --fg: #1d2433; --muted: #5d6b82; --bg: #ffffff; --card: #f4f6fa; --line: #d6dce6; --accent: #1d4ed8; --accent-strong: #2563eb; --on-accent-strong: #ffffff; --code: #b45309; --ok: #15803d; --bad: #b91c1c; }
@media (prefers-color-scheme: dark) { :root { --fg: #e6edf7; --muted: #9aa8bf; --bg: #0f1522; --card: #172033; --line: #2a3650; --accent: #60a5fa; --accent-strong: #2563eb; --on-accent-strong: #ffffff; --code: #f59e0b; --ok: #22c55e; --bad: #f87171; } }
@media (prefers-color-scheme: dark) { :root { --fg: #e6edf7; --muted: #9aa8bf; --bg: #0f1522; --card: #172033; --line: #2a3650; --accent: #60a5fa; --accent-strong: #2563eb; --on-accent-strong: #ffffff; --code: #f59e0b; --ok: #22c55e; --bad: #f87171; } }
* { box-sizing: border-box; }
* { box-sizing: border-box; }
-body { margin: 0; width: 360px; max-height: 560px; overflow-y: auto; background: var(--bg); color: var(--fg); font: 13px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
+body { margin: 0; width: 360px; max-width: 100vw; max-height: 560px; overflow-y: auto; background: var(--bg); color: var(--fg); font: 13px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
header, main, footer { padding: 10px 12px; }
header, main, footer { padding: 10px 12px; }
header { border-bottom: 1px solid var(--line); }
header { border-bottom: 1px solid var(--line); }
h1 { font-size: 14px; margin: 0; }
h1 { font-size: 14px; margin: 0; }
@@ -33,3 +33,18 @@ body.options input { font: inherit; padding: 4px 6px; max-width: 100%; }
@@ -33,3 +33,18 @@ body.options input { font: inherit; padding: 4px 6px; max-width: 100%; }
.try ol { margin: 6px 0 2px; padding-left: 18px; }
.try ol { margin: 6px 0 2px; padding-left: 18px; }
.try li.worked { color: var(--ok); }
.try li.worked { color: var(--ok); }
.try li.failed { color: var(--bad); }
.try li.failed { color: var(--bad); }
+
+.locker { padding: 12px; border-bottom: 1px solid var(--line); }
+.locker-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
+.locker-heading h2 { margin: 0; }
+.locker-form { display: grid; gap: 10px; margin: 12px 0; }
+.locker-field { display: grid; gap: 4px; font-size: 12px; font-weight: 600; }
+.locker-field input, .locker-field select { width: 100%; min-width: 0; min-height: 44px; padding: 8px; border: 1px solid var(--line); border-radius: 7px; background: var(--bg); color: var(--fg); font: inherit; }
+.locker .btn { min-height: 44px; }
+.locker a { display: inline-block; margin-right: 12px; padding-block: 8px; }
+.locker-origin { overflow-wrap: anywhere; font-size: 12px; color: var(--muted); }
+.locker-login { margin: 10px 0; padding: 12px; border-radius: 9px; background: var(--card); border: 1px solid var(--line); }
+.locker-login h3 { margin: 0 0 10px; font-size: 13px; overflow-wrap: anywhere; }
+.locker-save { margin-top: 12px; border-top: 1px solid var(--line); padding-top: 10px; }
+.locker-save summary { cursor: pointer; padding: 8px 0; font-weight: 600; }
+.locker input:focus-visible, .locker select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
<h1>OpenVibe Companion</h1>
<h1>OpenVibe Companion</h1>
<p id="site" class="muted"></p>
<p id="site" class="muted"></p>
</header>
</header>
+<section id="locker" class="locker" aria-label="Locker passwords"></section>
<section id="masks" class="masks" aria-label="Masks for this site" hidden>
<section id="masks" class="masks" aria-label="Masks for this site" hidden>
<div id="masks-body"></div>
<div id="masks-body"></div>
</section>
</section>
@@ -38,6 +39,7 @@
@@ -38,6 +39,7 @@
<script src="lib/controller.js"></script>
<script src="lib/controller.js"></script>
<script src="lib/mask.js"></script>
<script src="lib/mask.js"></script>
<script src="lib/mask-panel.js"></script>
<script src="lib/mask-panel.js"></script>
+<script src="lib/locker-panel.js"></script>
<script src="popup.js"></script>
<script src="popup.js"></script>
</body>
</body>
</html>
</html>
@@ -29,6 +29,8 @@
@@ -29,6 +29,8 @@
});
});
}
}
+ self.OVLockerPanel.mount({ container: document.getElementById('locker'), ask: askWorker, platform });
+
function el(tag, attrs) {
function el(tag, attrs) {
const node = document.createElement(tag);
const node = document.createElement(tag);
const props = attrs || {};
const props = attrs || {};
new file mode 100644
new file mode 100644
@@ -0,0 +1,1715 @@
@@ -0,0 +1,1715 @@
+/* Generated by scripts/build-locker-core.js from openvibe-sdk 0.101.0 (src/locker). Do not edit: change the SDK,
+ * release it, bump the pin, and run the script. The SDK's files, unchanged but for the names of require and module:
+ * bytes.js 80a5a560c683f489
+ * errors.js e73f94b7711452e7
+ * format.js f7793a59fdb67ad9
+ * index.js eee174e3a87df494
+ * inspect.js 2f28ff0da8c96a4e
+ * keys.js fbe20e7949924f2b
+ * padme.js e0dd4b08e0d0f24f
+ * recovery.js 2829d07a02561674
+ * seal.js ff6ad448082c636c
+ * server.js 6177fc986b2b705d
+ * words.js 8f4f51abdadfbfd1
+ */
+(function (root) {
+ 'use strict';
+ if (!root || root.OVLockerCore) return;
+ const defs = {
+ "bytes": function (__ovLockerModule, exports, __ovLockerReq) {
+'use strict';
+/**
+ * Byte helpers for the Locker envelope. Framing only, no cryptography: every value is a
+ * Uint8Array, so the same code runs in Node and in the browser.
+ */
+
+const encoder = new TextEncoder();
+const decoder = new TextDecoder();
+
+/** The UTF-8 bytes of a string. */
+function utf8(text) { return encoder.encode(text); }
+
+/** The string of UTF-8 bytes. */
+function fromUtf8(bytes) { return decoder.decode(bytes); }
+
+/** One buffer of all the parts, in order. */
+function concat(...parts) {
+ let total = 0;
+ for (const part of parts) total += part.length;
+ const out = new Uint8Array(total);
+ let at = 0;
+ for (const part of parts) { out.set(part, at); at += part.length; }
+ return out;
+}
+
+/** Standard base64. btoa/atob are in every browser and in Node. */
+function base64(bytes) {
+ let binary = '';
+ for (const byte of bytes) binary += String.fromCharCode(byte);
+ return btoa(binary);
+}
+
+/** The bytes of a standard base64 string. */
+function fromBase64(text) {
+ const binary = atob(text);
+ const out = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
+ return out;
+}
+
+/** base64url: the same alphabet without the padding, for keys that travel in a URL. */
+function base64url(bytes) { return base64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
+
+/** The bytes of a base64url string. */
+function fromBase64url(text) {
+ const padded = text.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (text.length % 4)) % 4);
+ return fromBase64(padded);
+}
+
+/** A number as four bytes, big endian. */
+function u32be(value) {
+ const out = new Uint8Array(4);
+ new DataView(out.buffer).setUint32(0, value >>> 0, false);
+ return out;
+}
+
+/** The number four bytes at `at` hold, big endian. */
+function readU32be(bytes, at) { return new DataView(bytes.buffer, bytes.byteOffset + at, 4).getUint32(0, false); }
+
+/** A number as eight bytes, big endian. */
+function u64be(value) {
+ const out = new Uint8Array(8);
+ new DataView(out.buffer).setBigUint64(0, BigInt(value), false);
+ return out;
+}
+
+/** The number eight bytes at `at` hold, big endian. */
+function readU64be(bytes, at) { return new DataView(bytes.buffer, bytes.byteOffset + at, 8).getBigUint64(0, false); }
+
+/**
+ * The canonical JSON of a value: object keys sorted, no whitespace. Two sides that hold the
+ * same value therefore hold the same bytes, which is what a signature or an associated data
+ * needs. (JSON.stringify would keep the order the object was built in, which is not a promise
+ * we can make across two implementations.)
+ */
+function canonicalJson(value) {
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
+ const keys = Object.keys(value).filter(key => value[key] !== undefined).sort();
+ return `{${keys.map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
+}
+
+__ovLockerModule.exports = {
+ utf8, fromUtf8, concat, base64, fromBase64, base64url, fromBase64url,
+ u32be, readU32be, u64be, readU64be, canonicalJson,
+};
+
+ },
+ "errors": function (__ovLockerModule, exports, __ovLockerReq) {
+'use strict';
+/**
+ * The one error Locker throws. The code says what went wrong, so a caller can tell a wrong
+ * password from a truncated download from bytes that are not an envelope at all.
+ *
+ * The codes: locker.invalid (the caller asked for something impossible), locker.bad_envelope
+ * (the bytes are not an envelope we wrote), locker.truncated (the bytes stop early),
+ * locker.wrong_key (the key is not the one), locker.no_key (no key was given), locker.no_slot
+ * (there is no slot for this key), locker.size_mismatch (the size does not add up) and
+ * locker.no_crypto (the runtime has no WebCrypto).
+ */
+class LockerError extends Error {
+ constructor(code, message) {
+ super(message);
+ this.name = 'LockerError';
+ this.code = code;
+ }
+}
+
+/** A Locker error, or a plain Error with the same shape. */
+function fail(code, message) { throw new LockerError(code, message); }
+
+__ovLockerModule.exports = { LockerError, fail };
+
+ },
+ "format": function (__ovLockerModule, exports, __ovLockerReq) {
+'use strict';
+/**
+ * The Locker envelope on the wire (locker.envelope@1). This file is the format and nothing
+ * else: the numbers, the framing and the associated data. The cryptography lives in keys.js
+ * and seal.js; the rules for a service that only reads the header live in server.js.
+ *
+ * 0 4 8 8 + header length
+ * +------+------+--------------------------+--------------------------+
+ * | OVLK | len | header JSON (UTF-8) | chunk frames |
+ * +------+------+--------------------------+--------------------------+
+ *
+ * each chunk frame:
+ * 0 4 5 17
+ * +------+------+--------------------------+--------------------------+
+ * | len | flags| nonce (12 bytes) | ciphertext + 16-byte tag |
+ * +------+------+--------------------------+--------------------------+
+ */
+const { utf8, fromUtf8, concat, base64, fromBase64, u32be, readU32be, canonicalJson } = __ovLockerReq('./bytes');
+const { fail } = __ovLockerReq('./errors');
+const { padme } = __ovLockerReq('./padme');
+
+/** The four bytes that say "this is a Locker envelope". */
+const MAGIC = 'OVLK';
+/** The format this library reads and writes. */
+const VERSION = 'locker.envelope.v1';
+/** The only suite in this version: AES-256-GCM, a 32-byte key. */
+const SUITE = 'AES-256-GCM';
+/** The chunk size in bytes: 1 MiB. */
+const CHUNK_SIZE = 1048576;
+/** The GCM tag is appended to the ciphertext. */
+const TAG_SIZE = 16;
+/** The GCM nonce is 12 bytes. */
+const NONCE_SIZE = 12;
+/** The first half of every nonce: eight random bytes, in the header. */
+const NONCE_BASE_SIZE = 8;
+/** The padded plaintext starts with the true length, as eight bytes. */
+const LENGTH_PREFIX = 8;
+/**
+ * The smallest padded plaintext we write. A short note still costs 256 bytes: below that the
+ * padded length would say too much about the real one (Padmé's grid is coarse down there), and
+ * 256 bytes is nothing next to a chunk.
+ */
+const MIN_PADDED_SIZE = 256;
+/** A header is small. A bigger one is not a header we wrote, and a service must not read it. */
+const MAX_HEADER_BYTES = 65536;
+/** The bit that marks the last chunk of an envelope. */
+const FINAL_FLAG = 0x01;
+
+/**
+ * The header fields every chunk authenticates. The slots are not in here, on purpose: adding a
+ * member to a people-locked item re-wraps the content key without re-encrypting the content, so
+ * the core cannot change either. Each slot is authenticated by its own tag instead.
+ */
+function coreOf(header) {
+ return {
+ version: header.version,
+ suite: header.suite,
+ chunk_size: header.chunk_size,
+ padded_size: header.padded_size,
+ nonce_base: header.nonce_base,
+ lock: header.lock,
+ };
+}
+
+/** The canonical JSON of the core: the same bytes on both sides, whatever order the header was written in. */
+function coreBytes(header) { return utf8(canonicalJson(coreOf(header))); }
+
+/** The associated data of one chunk: the core, the chunk counter and the flags. */
+function chunkAad(header, counter, flags) {
+ return concat(coreBytes(header), u32be(counter), Uint8Array.of(flags));
+}
+
+/** The header as the bytes that go on the wire. */
+function encodeHeader(header) { return utf8(canonicalJson(header)); }
+
+/** The header's JSON bytes, as they were written. */
+function decodeHeader(headerBytes) {
+ let header;
+ try { header = JSON.parse(fromUtf8(headerBytes)); }
+ catch { fail('locker.bad_envelope', 'The envelope header is not JSON'); }
+ return header;
+}
+
+/** The whole envelope: the magic, the header and the chunks. */
+function encodeEnvelope(headerBytes, chunks) {
+ return concat(utf8(MAGIC), u32be(headerBytes.length), headerBytes, chunks);
+}
+
+/**
+ * Reads the header out of envelope bytes. It stops at the header: the chunks are not touched,
+ * so this is safe to do on bytes that are not an envelope at all.
+ */
+function readHeader(bytes) {
+ if (!bytes || bytes.length < 8) fail('locker.bad_envelope', 'The envelope is too short to hold a header');
+ const magic = fromUtf8(bytes.slice(0, 4));
+ if (magic !== MAGIC) fail('locker.bad_envelope', 'These bytes are not a Locker envelope');
+ const headerLength = readU32be(bytes, 4);
+ if (headerLength === 0 || headerLength > MAX_HEADER_BYTES) {
+ fail('locker.bad_envelope', `The envelope header is ${headerLength} bytes, which is not one we write`);
+ }
+ if (bytes.length < 8 + headerLength) fail('locker.truncated', 'The envelope ends inside its header');
+ const headerBytes = bytes.slice(8, 8 + headerLength);
+ return { header: decodeHeader(headerBytes), headerBytes, chunkOffset: 8 + headerLength };
+}
+
+/**
+ * How many chunks a padded plaintext of this size takes. Every chunk but the last is a whole
+ * chunk; the last holds what is left of it.
+ */
+function chunkCount(padded) { return Math.max(1, Math.ceil(padded / CHUNK_SIZE)); }
+
+/** How many bytes the last chunk of a padded plaintext of this size holds: more than zero. */
+function lastChunkSize(padded) { return padded - (chunkCount(padded) - 1) * CHUNK_SIZE; }
+
+/**
+ * The padded size of a content of this size: the length prefix plus the content, padded with
+ * Padmé up to the next size on its grid, and never below the floor.
+ */
+function paddedSize(size) { return Math.max(MIN_PADDED_SIZE, padme(LENGTH_PREFIX + size)); }
+
+/** The nonce of a chunk: the header's base, then the counter. No nonce repeats inside an envelope. */
+function chunkNonce(nonceBase, counter) {
+ const nonce = new Uint8Array(NONCE_SIZE);
+ nonce.set(nonceBase, 0);
+ new DataView(nonce.buffer).setUint32(8, counter >>> 0, false);
+ return nonce;
+}
+
+/** One chunk frame: its length, its flags, its nonce and the ciphertext with the tag. */
+function encodeChunk(counter, flags, nonce, ciphertext) {
+ return concat(u32be(ciphertext.length), Uint8Array.of(flags), nonce, ciphertext);
+}
+
+/** The header's nonce base as bytes. */
+function nonceBaseBytes(header) {
+ const bytes = fromBase64(header.nonce_base);
+ if (bytes.length !== NONCE_BASE_SIZE) fail('locker.bad_envelope', 'The envelope nonce base is not eight bytes');
+ return bytes;
+}
+
+__ovLockerModule.exports = {
+ MAGIC, VERSION, SUITE, CHUNK_SIZE, TAG_SIZE, NONCE_SIZE, NONCE_BASE_SIZE, LENGTH_PREFIX,
+ MIN_PADDED_SIZE, MAX_HEADER_BYTES, FINAL_FLAG,
+ coreOf, coreBytes, chunkAad, encodeHeader, decodeHeader, encodeEnvelope, readHeader,
+ chunkCount, lastChunkSize, paddedSize, chunkNonce, encodeChunk, nonceBaseBytes,
+};
+
+ },
+ "index": function (__ovLockerModule, exports, __ovLockerReq) {
+'use strict';
+/**
+ * openvibe-sdk/locker: end-to-end encryption for anything on OpenVibe.
+ *
+ * One format, one library, two runtimes: Node and the browser, WebCrypto only. A locked item is
+ * stored and served as ciphertext; the keys never reach our servers.
+ *
+ * const { seal, open, generatePersonKeys } = locker; // openvibe-sdk/locker
+ * const person = await generatePersonKeys();
+ * const sealed = await seal('a note', { lock: 'people', keys: [{ subject, publicKey: person.ecdhPublic }] });
+ * const note = await open(sealed, { privateKey: person.ecdh.privateKey, subject });
+ */
+const format = __ovLockerReq('./format');
+const keys = __ovLockerReq('./keys');
+const seal = __ovLockerReq('./seal');
+const { padme } = __ovLockerReq('./padme');
+const { inspect } = __ovLockerReq('./inspect');
+const { recoveryWords, isRecoveryPhrase, RECOVERY_WORDS } = __ovLockerReq('./recovery');
+const { WORDS } = __ovLockerReq('./words');
+const { LockerError } = __ovLockerReq('./errors');
+
+__ovLockerModule.exports = {
+ // The format.
+ VERSION: format.VERSION,
+ SUITE: format.SUITE,
+ CHUNK_SIZE: format.CHUNK_SIZE,
+ MIN_PADDED_SIZE: format.MIN_PADDED_SIZE,
+ MAX_HEADER_BYTES: format.MAX_HEADER_BYTES,
+ PASSWORD_ITERATIONS: keys.PASSWORD_ITERATIONS,
+ PASSWORD_ITERATIONS_MIN: keys.PASSWORD_ITERATIONS_MIN,
+ PASSWORD_ITERATIONS_MAX: keys.PASSWORD_ITERATIONS_MAX,
+
+ // The content.
+ seal: seal.seal,
+ open: seal.open,
+ sealStream: seal.sealStream,
+ openStream: seal.openStream,
+ rewrap: seal.rewrap,
+
+ // What an envelope says about itself, without a key.
+ inspect,
+
+ // The padding: the padded size of a content of a given size.
+ padme,
+
+ // The recovery phrase: words to write down, and a check on a typed one.
+ recoveryWords,
+ isRecoveryPhrase,
+ RECOVERY_WORDS,
+ WORDS,
+
+ // A person's keys, and the eight words that say which keys they are.
+ generatePersonKeys: keys.generatePersonKeys,
+ exportPersonKeys: keys.exportPersonKeys,
+ exportPrivateKeys: keys.exportPrivateKeys,
+ importPersonKeys: keys.importPersonKeys,
+ verifyPersonKeys: keys.verifyPersonKeys,
+ fingerprintOf: keys.fingerprintOf,
+ fingerprintWords: keys.fingerprintWords,
+ jwkThumbprint: keys.jwkThumbprint,
+
+ // The key slots: the content key, wrapped for one person or for one password.
+ wrapFor: keys.wrapFor,
+ wrapForPassword: keys.wrapForPassword,
+ unwrapPeopleSlot: keys.unwrapPeopleSlot,
+ unwrapPasswordSlot: keys.unwrapPasswordSlot,
+
+ LockerError,
+};
+
+ },
+ "inspect": function (__ovLockerModule, exports, __ovLockerReq) {
+'use strict';
+/**
+ * inspect: what an envelope says about itself, without a key and without decrypting anything.
+ *
+ * A service, or a page that shows a locked item, often needs to know the lock kind, how big
+ * the item is, who it is for and how strong the password is — before it has any key at all.
+ * This reads the header and nothing else: no chunk is touched and no key is used.
+ *
+ * The header holds no content, only wrapped keys, so this says nothing the server does not
+ * already see.
+ */
+const { envelopeBytes, readCore } = __ovLockerReq('./seal');
+const { readHeader } = __ovLockerReq('./format');
+
+/**
+ * What an envelope says about itself.
+ *
+ * { version, lock, padded_size, recipients, iterations }
+ *
+ * `recipients` holds the thumbprint of each recipient's key for a people lock (the same
+ * strings the slots carry), and `iterations` the PBKDF2 count for a password lock. The other
+ * field is empty for the other lock kinds.
+ */
+function inspect(bytes) {
+ const data = envelopeBytes(bytes);
+ const { header } = readHeader(data);
+ readCore(header);
+ const out = {
+ version: header.version,
+ lock: header.lock,
+ padded_size: header.padded_size,
+ recipients: [],
+ iterations: null,
+ };
+ if (header.lock === 'people') {
+ out.recipients = (header.slots.people || []).map(slot => slot.recipient_key);
+ } else if (header.lock === 'password' && header.slots.password) {
+ out.iterations = header.slots.password.iterations;
+ }
+ return out;
+}
+
+__ovLockerModule.exports = { inspect };
+
+ },
+ "keys": function (__ovLockerModule, exports, __ovLockerReq) {
+'use strict';
+/**
+ * The keys: a person's two key pairs, the content key, and the two ways of wrapping the content
+ * key for someone else (their public key, or a password).
+ *
+ * WebCrypto only: globalThis.crypto.subtle. No other crypto library, nothing hand-rolled: the
+ * ECDH, the ECDSA, the HKDF, the PBKDF2 and the AES-GCM are all the browser's own.
+ */
+const { utf8, concat, base64, base64url, fromBase64, canonicalJson } = __ovLockerReq('./bytes');
+const { fail } = __ovLockerReq('./errors');
+const { WORDS } = __ovLockerReq('./words');
+
+/** The suite of the key pairs: P-256, which every browser's WebCrypto has. */
+const CURVE = 'P-256';
+/** How the content key is wrapped for a person. */
+const PEOPLE_ALGORITHM = 'ECDH-P256/HKDF-SHA256/AES-256-GCM';
+/** How the content key is wrapped for a password. */
+const PASSWORD_ALGORITHM = 'PBKDF2-SHA256/AES-256-GCM';
+/** The PBKDF2 floor for this version. The count may go up; it may not go down. */
+const PASSWORD_ITERATIONS = 600000;
+/** The smallest PBKDF2 count we accept, on both sides: the floor above. */
+const PASSWORD_ITERATIONS_MIN = 600000;
+/**
+ * The largest PBKDF2 count we accept. The count comes from the header, so a hostile header could
+ * ask for a derivation that takes hours; the ceiling keeps a browser honest.
+ */
+const PASSWORD_ITERATIONS_MAX = 10000000;
+/** The content key is 32 bytes: AES-256. */
+const KEY_BYTES = 32;
+/** The salt of a people slot (HKDF) and of a password slot (PBKDF2). */
+const SALT_BYTES = 16;
+
+/** WebCrypto, or a clear error when the runtime has none. */
+function subtle() {
+ const crypto = globalThis.crypto;
+ if (!crypto || !crypto.subtle) {
+ fail('locker.no_crypto', 'This runtime has no WebCrypto (globalThis.crypto.subtle)');
+ }
+ return crypto.subtle;
+}
+
+/** Random bytes from the platform. */
+function platformRandom(length) {
+ const bytes = new Uint8Array(length);
+ globalThis.crypto.getRandomValues(bytes);
+ return bytes;
+}
+
+/** The SHA-256 of some bytes. */
+async function sha256(bytes) {
+ return new Uint8Array(await subtle().digest('SHA-256', bytes));
+}
+
+/** The bytes of a public JWK, as they go on the wire: no key_ops, no ext. */
+function publicJwk(jwk) {
+ return { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
+}
+
+/** The message a person signs: both public keys, canonically. */
+function fingerprintMessage(ecdhPublic, signingPublic) {
+ return utf8(canonicalJson({ ecdh: publicJwk(ecdhPublic), signing: publicJwk(signingPublic) }));
+}
+
+/**
+ * The eight words of a fingerprint: the first eight bytes of SHA-256, eight bits each, in two
+ * groups of four. Sixty-four bits, not thirty-two: a server that swapped in its own key would
+ * need about 2^32 key generations to land on the same four words, which is not "verify a
+ * person"; with eight words it would need 2^64.
+ *
+ * The two groups are joined with a middle dot, not a hyphen: a hyphen reads as one compound
+ * word ("plateau yew-apple petal"), and the groups are meant to be read apart.
+ */
+function fingerprintWords(hash) {
+ const words = [];
+ for (let i = 0; i < 8; i++) words.push(WORDS[hash[i]]);
+ return `${words.slice(0, 4).join(' ')} · ${words.slice(4).join(' ')}`;
+}
+
+/**
+ * The RFC 7638 thumbprint of a public JWK: SHA-256 over the canonical JSON of its four
+ * required members, base64url. It names one key and nothing else, so a slot can be bound to
+ * the key of the person it is for.
+ */
+async function jwkThumbprint(jwk) {
+ const canonical = canonicalJson({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
+ return base64url(await sha256(utf8(canonical)));
+}
+
+/** The fingerprint of a pair of public keys. */
+async function fingerprintOf(ecdhPublic, signingPublic) {
+ return fingerprintWords(await sha256(fingerprintMessage(ecdhPublic, signingPublic)));
+}
+
+/**
+ * A person's two key pairs: an ECDH pair that receives content keys, and an ECDSA pair that signs
+ * the fingerprint. The signing key signs both public keys, so the pair is self-certifying: a
+ * reader can check that the holder owns the signing key, and the fingerprint changes whenever
+ * either key changes.
+ */
+async function generatePersonKeys() {
+ const s = subtle();
+ const ecdh = await s.generateKey({ name: 'ECDH', namedCurve: CURVE }, true, ['deriveBits']);
+ const signing = await s.generateKey({ name: 'ECDSA', namedCurve: CURVE }, true, ['sign', 'verify']);
+ const ecdhPublic = publicJwk(await s.exportKey('jwk', ecdh.publicKey));
+ const signingPublic = publicJwk(await s.exportKey('jwk', signing.publicKey));
+ const message = fingerprintMessage(ecdhPublic, signingPublic);
+ const signature = new Uint8Array(await s.sign({ name: 'ECDSA', hash: 'SHA-256' }, signing.privateKey, message));
+ return {
+ ecdh: { publicKey: ecdh.publicKey, privateKey: ecdh.privateKey },
+ signing: { publicKey: signing.publicKey, privateKey: signing.privateKey },
+ ecdhPublic,
+ signingPublic,
+ fingerprint: fingerprintWords(await sha256(message)),
+ fingerprintSignature: signature,
+ };
+}
+
+/** The public half of a person's keys, as it is published: locker.public-keys@1. */
+async function exportPersonKeys(person) {
+ const ecdhPublic = publicJwk(person.ecdhPublic || await subtle().exportKey('jwk', person.ecdh.publicKey));
+ const signingPublic = publicJwk(person.signingPublic || await subtle().exportKey('jwk', person.signing.publicKey));
+ const message = fingerprintMessage(ecdhPublic, signingPublic);
+ const signature = person.fingerprintSignature
+ || new Uint8Array(await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, (person.signing?.privateKey || person.signingPrivateKey), message));
+ return {
+ ecdh: ecdhPublic,
+ signing: signingPublic,
+ fingerprint: fingerprintWords(await sha256(message)),
+ fingerprint_signature: base64(signature),
+ };
+}
+
+/**
+ * The private half of a person's keys, as it is stored: the two private keys as JWKs.
+ */
+async function exportPrivateKeys(person) {
+ const ecdh = await subtle().exportKey('jwk', person.ecdh?.privateKey || person.ecdhPrivateKey);
+ const signing = await subtle().exportKey('jwk', person.signing?.privateKey || person.signingPrivateKey);
+ return { ecdh, signing };
+}
+
+/**
+ * A person's keys, ready to use: the private keys as CryptoKeys and the public keys as JWKs.
+ * The private keys may be JWKs (as they are stored) or CryptoKeys (as they are in memory).
+ */
+async function importPersonKeys(data) {
+ const s = subtle();
+ const ecdhPrivate = data.ecdhPrivateKey
+ || await s.importKey('jwk', data.ecdh, { name: 'ECDH', namedCurve: CURVE }, true, ['deriveBits']);
+ const signingPrivate = data.signingPrivateKey
+ || await s.importKey('jwk', data.signing, { name: 'ECDSA', namedCurve: CURVE }, true, ['sign']);
+ return {
+ ecdhPrivateKey: ecdhPrivate,
+ signingPrivateKey: signingPrivate,
+ ecdhPublic: data.ecdh ? publicJwk(data.ecdh) : publicJwk(await s.exportKey('jwk', ecdhPrivate)),
+ signingPublic: data.signing ? publicJwk(data.signing) : publicJwk(await s.exportKey('jwk', signingPrivate)),
+ };
+}
+
+/**
+ * Checks a published set of keys: the signature must verify with the signing key, and the four
+ * words must be the fingerprint of the two keys. Returns false instead of throwing, so a caller
+ * can show a warning rather than an error.
+ */
+async function verifyPersonKeys(data) {
+ try {
+ const s = subtle();
+ const message = fingerprintMessage(data.ecdh, data.signing);
+ const signature = fromBase64(data.fingerprint_signature);
+ const key = await s.importKey('jwk', data.signing, { name: 'ECDSA', namedCurve: CURVE }, true, ['verify']);
+ const ok = await s.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, signature, message);
+ if (!ok) return false;
+ return fingerprintWords(await sha256(message)) === data.fingerprint;
+ } catch {
+ return false;
+ }
+}
+
+/** The content key as an AES-GCM CryptoKey. */
+async function importContentKey(bytes, usages) {
+ if (!bytes || bytes.length !== KEY_BYTES) fail('locker.invalid', 'The content key is 32 bytes');
+ return subtle().importKey('raw', bytes, { name: 'AES-GCM', length: 256 }, false, usages);
+}
+
+/** The wrapping key for a person: the ECDH secret, put through HKDF-SHA-256. */
+async function deriveWrappingKey(sharedSecret, salt, info) {
+ const s = subtle();
+ const material = await s.importKey('raw', sharedSecret, 'HKDF', false, ['deriveKey']);
+ return s.deriveKey(
+ { name: 'HKDF', hash: 'SHA-256', salt, info },
+ material,
+ { name: 'AES-GCM', length: 256 },
+ false,
+ ['encrypt', 'decrypt'],
+ );
+}
+
+/** The wrapping key for a password: PBKDF2-SHA-256, with the parameters the header names. */
+async function derivePasswordKey(password, salt, iterations) {
+ if (typeof password !== 'string' || !password.length) fail('locker.invalid', 'A password lock needs a password');
+ // The count comes from the header, so it is checked before it is used: a hostile header must
+ // not be able to make a reader spend minutes (or hours) on the derivation.
+ if (!Number.isInteger(iterations) || iterations < PASSWORD_ITERATIONS_MIN || iterations > PASSWORD_ITERATIONS_MAX) {
+ fail('locker.bad_envelope', `The iteration count is not between ${PASSWORD_ITERATIONS_MIN} and ${PASSWORD_ITERATIONS_MAX}`);
+ }
+ const s = subtle();
+ const material = await s.importKey('raw', utf8(password), 'PBKDF2', false, ['deriveKey']);
+ return s.deriveKey(
+ { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
+ material,
+ { name: 'AES-GCM', length: 256 },
+ false,
+ ['encrypt', 'decrypt'],
+ );
+}
+
+/**
+ * The associated data of a people slot: it binds the slot to one person, to that person's key
+ * and to one envelope.
+ */
+function peopleSlotAad(subject, ephemeralPublic, salt, recipientKey) {
+ const id = typeof subject === 'string' ? subject : `${subject.type}:${subject.id}`;
+ return utf8(`openvibe-locker people-slot v1|${id}|${recipientKey}|${canonicalJson(publicJwk(ephemeralPublic))}|${base64(salt)}`);
+}
+
+/** The associated data of a password slot: it binds the slot to its own parameters. */
+function passwordSlotAad(iterations, salt) {
+ return utf8(`openvibe-locker password-slot v1|${iterations}|${base64(salt)}`);
+}
+
+/** A public key as WebCrypto wants it. */
+async function importPublicKey(jwk) {
+ try {
+ return await subtle().importKey('jwk', publicJwk(jwk), { name: 'ECDH', namedCurve: CURVE }, true, []);
+ } catch {
+ fail('locker.invalid', 'The public key is not a P-256 key');
+ }
+}
+
+/**
+ * The envelope's one ephemeral ECDH key pair. It may be given (the shared test vectors need the
+ * same bytes every time), or made fresh.
+ */
+async function resolveEphemeral(ephemeral) {
+ const s = subtle();
+ if (!ephemeral) {
+ const pair = await s.generateKey({ name: 'ECDH', namedCurve: CURVE }, true, ['deriveBits']);
+ return { privateKey: pair.privateKey, publicKey: pair.publicKey };
+ }
+ if (ephemeral.privateKey) return ephemeral;
+ const privateKey = await s.importKey('jwk', ephemeral.private, { name: 'ECDH', namedCurve: CURVE }, true, ['deriveBits']);
+ return { privateKey };
+}
+
+/**
+ * Wraps the content key for one person: the content key, sealed for their public key.
+ * The envelope has one ephemeral ECDH key pair; the shared secret with each person goes through
+ * HKDF-SHA-256, and the slot's tag covers the person, their key, the ephemeral key and the
+ * salt, so a slot cannot be moved to another person or replayed from another envelope.
+ */
+async function wrapFor(contentKey, publicKey, { subject, salt, random = platformRandom, ephemeral } = {}) {
+ const s = subtle();
+ if (!subject) fail('locker.invalid', 'A people slot needs the subject it is for');
+ const theirs = await importPublicKey(publicKey);
+ const pair = await resolveEphemeral(ephemeral);
+ const ephemeralPublic = publicJwk(await s.exportKey('jwk', pair.publicKey || pair.privateKey));
+ // The slot names the key it is for, so an opener can check it before deriving anything.
+ const recipientKey = await jwkThumbprint(publicJwk(publicKey));
+ const shared = new Uint8Array(await s.deriveBits({ name: 'ECDH', public: theirs }, pair.privateKey, 256));
+ const slotSalt = salt || random(SALT_BYTES);
+ const key = await deriveWrappingKey(shared, slotSalt, utf8(`openvibe-locker people-slot v1|${subject.type}:${subject.id}|${recipientKey}`));
+ const nonce = random(12);
+ const sealed = new Uint8Array(await s.encrypt(
+ { name: 'AES-GCM', iv: nonce, additionalData: peopleSlotAad(subject, ephemeralPublic, slotSalt, recipientKey) },
+ key,
+ contentKey,
+ ));
+ return {
+ subject,
+ algorithm: PEOPLE_ALGORITHM,
+ recipient_key: recipientKey,
+ ephemeral_public_key: ephemeralPublic,
+ salt: base64(slotSalt),
+ nonce: base64(nonce),
+ wrapped_key: base64(sealed),
+ };
+}
+
+/** The content key out of a people slot: the recipient's private key and the slot. */
+async function unwrapPeopleSlot(slot, privateKey) {
+ const s = subtle();
+ // The slot names the key it was made for. If it is not this key, the slot is not this
+ // person's, and nothing is derived: the check comes before the work.
+ if (!slot.recipient_key) fail('locker.bad_envelope', 'The slot does not name the key it is for');
+ const theirPublic = publicJwk(await s.exportKey('jwk', privateKey));
+ const recipientKey = await jwkThumbprint(theirPublic);
+ if (slot.recipient_key !== recipientKey) {
+ fail('locker.wrong_key', 'This slot is for another key');
+ }
+ const theirs = await s.importKey('jwk', publicJwk(slot.ephemeral_public_key), { name: 'ECDH', namedCurve: CURVE }, true, []);
+ const shared = new Uint8Array(await s.deriveBits({ name: 'ECDH', public: theirs }, privateKey, 256));
+ const salt = fromBase64(slot.salt);
+ const key = await deriveWrappingKey(shared, salt, utf8(`openvibe-locker people-slot v1|${slot.subject.type}:${slot.subject.id}|${recipientKey}`));
+ try {
+ const plain = new Uint8Array(await s.decrypt(
+ { name: 'AES-GCM', iv: fromBase64(slot.nonce), additionalData: peopleSlotAad(slot.subject, slot.ephemeral_public_key, salt, recipientKey) },
+ key,
+ fromBase64(slot.wrapped_key),
+ ));
+ if (plain.length !== KEY_BYTES) fail('locker.wrong_key', 'The slot does not hold a content key');
+ return plain;
+ } catch (err) {
+ if (err && err.code) throw err;
+ fail('locker.wrong_key', 'This key does not open that slot');
+ }
+}
+
+/** Wraps the content key with a password: PBKDF2-SHA-256, then AES-256-GCM. */
+async function wrapForPassword(contentKey, password, { salt, iterations = PASSWORD_ITERATIONS, random = platformRandom } = {}) {
+ const s = subtle();
+ const slotSalt = salt || random(SALT_BYTES);
+ const key = await derivePasswordKey(password, slotSalt, iterations);
+ const nonce = random(12);
+ const sealed = new Uint8Array(await s.encrypt(
+ { name: 'AES-GCM', iv: nonce, additionalData: passwordSlotAad(iterations, slotSalt) },
+ key,
+ contentKey,
+ ));
+ return {
+ algorithm: PASSWORD_ALGORITHM,
+ kdf: 'PBKDF2-SHA-256',
+ iterations,
+ salt: base64(slotSalt),
+ nonce: base64(nonce),
+ wrapped_key: base64(sealed),
+ };
+}
+
+/** The content key out of a password slot. The parameters come from the slot, never from here. */
+async function unwrapPasswordSlot(slot, password) {
+ const s = subtle();
+ const salt = fromBase64(slot.salt);
+ const key = await derivePasswordKey(password, salt, slot.iterations);
+ try {
+ const plain = new Uint8Array(await s.decrypt(
+ { name: 'AES-GCM', iv: fromBase64(slot.nonce), additionalData: passwordSlotAad(slot.iterations, salt) },
+ key,
+ fromBase64(slot.wrapped_key),
+ ));
+ if (plain.length !== KEY_BYTES) fail('locker.wrong_key', 'The slot does not hold a content key');
+ return plain;
+ } catch (err) {
+ if (err && err.code) throw err;
+ fail('locker.wrong_key', 'That password does not open this item');
+ }
+}
+
+__ovLockerModule.exports = {
+ CURVE, PEOPLE_ALGORITHM, PASSWORD_ALGORITHM, PASSWORD_ITERATIONS,
+ PASSWORD_ITERATIONS_MIN, PASSWORD_ITERATIONS_MAX, KEY_BYTES, SALT_BYTES,
+ subtle, platformRandom, sha256, publicJwk, fingerprintMessage, fingerprintWords, fingerprintOf,
+ jwkThumbprint, generatePersonKeys, exportPersonKeys, exportPrivateKeys, importPersonKeys,
+ verifyPersonKeys, importContentKey, deriveWrappingKey, derivePasswordKey, resolveEphemeral,
+ peopleSlotAad, passwordSlotAad, wrapFor, unwrapPeopleSlot, wrapForPassword, unwrapPasswordSlot,
+};
+
+ },
+ "padme": function (__ovLockerModule, exports, __ovLockerReq) {
+'use strict';
+/**
+ * Padmé padding (Nikitin et al., "Reducing Metadata Leakage from Encrypted Files and Network
+ * Traffic", PoPETs 2019).
+ *
+ * The point of padding a locked item: the server must not learn how big it is. Rounding the size
+ * up to a whole 1 MiB chunk says too much — a 200-byte note becomes a 1 MiB object, and every
+ * item under a megabyte looks the same, which is a leak of its own.
+ *
+ * Padmé rounds the length up to the next size on a grid that gets finer as the length grows:
+ *
+ * E = floor(log2(n)) the band the length is in
+ * S = floor(log2(E)) + 1 how many steps the band has
+ * d = 2^(E - S) the step of the band
+ * P(n) = d * ceil(n / d) the padded length
+ *
+ * The padded length is one of a small number of values per band, and the bands double, so the
+ * number of possible padded lengths up to n grows like log(n): knowing the padded length says
+ * O(log log n) bits about the real one. The expansion stays under 12% (the worst case is a
+ * length just above a band boundary, 129 bytes padded to 144).
+ *
+ * The arithmetic is exact above 2^31: there are no 32-bit bitwise operators here, only
+ * comparisons and division by a power of two, both exact on the doubles we have.
+ */
+
+/** The largest e with 2^e <= size: floor(log2(size)), without a logarithm. */
+function exponentOf(size) {
+ let e = 0;
+ let power = 1;
+ while (power * 2 <= size) { power *= 2; e++; }
+ return e;
+}
+
+/**
+ * The padded length of a content of this size: the smallest length on Padmé's grid that is not
+ * smaller than it. Zero for zero, which the caller handles.
+ */
+function padme(size) {
+ if (!Number.isInteger(size) || size <= 0) return 0;
+ if (size === 1) return 1;
+ const band = exponentOf(size);
+ const steps = exponentOf(band) + 1;
+ const step = Math.pow(2, band - steps);
+ // Dividing by a power of two is exact, so the ceiling is the ceiling and not nearly it.
+ return Math.ceil(size / step) * step;
+}
+
+__ovLockerModule.exports = { padme, exponentOf };
+
+ },
+ "recovery": function (__ovLockerModule, exports, __ovLockerReq) {
+'use strict';
+/**
+ * The recovery phrase: the words a person writes down.
+ *
+ * A phrase of n words from the same 256-word list the fingerprint uses, one byte per word from
+ * the platform's CSPRNG (crypto.getRandomValues). Eight bits per word, so the usual 24 words
+ * carry 192 bits: enough for a key nobody else has, and short enough to write on paper.
+ *
+ * There is no checksum in the phrase: the words are the secret, and a phrase that is typed
+ * wrong simply does not open anything. isRecoveryPhrase only says whether every word is one of
+ * the 256, so a UI can tell a typo from a word that was never in the list.
+ */
+const { fail } = __ovLockerReq('./errors');
+const { platformRandom } = __ovLockerReq('./keys');
+const { WORDS } = __ovLockerReq('./words');
+
+/** How many words a recovery phrase has, unless the caller says otherwise. */
+const RECOVERY_WORDS = 24;
+
+/**
+ * A recovery phrase: n words from the list, one byte each.
+ *
+ * recoveryWords() // 24 words, 192 bits
+ * recoveryWords(12) // 12 words, 96 bits
+ */
+function recoveryWords(count = RECOVERY_WORDS, { random = platformRandom } = {}) {
+ if (!Number.isInteger(count) || count < 1 || count > WORDS.length) {
+ fail('locker.invalid', `A recovery phrase is one to ${WORDS.length} words`);
+ }
+ // One byte per word: the list has 256 words, so a byte picks one of them exactly.
+ const bytes = random(count);
+ const words = [];
+ for (const byte of bytes) words.push(WORDS[byte]);
+ return words.join(' ');
+}
+
+/**
+ * Whether a typed phrase is made of words from the list. It says nothing about the order or
+ * about whether the phrase is the right one: only that every word could have come from us.
+ */
+function isRecoveryPhrase(phrase) {
+ if (typeof phrase !== 'string') return false;
+ const words = phrase.trim().split(/\s+/).filter(Boolean);
+ if (!words.length) return false;
+ return words.every(word => WORDS.includes(word.toLowerCase()));
+}
+
+__ovLockerModule.exports = { RECOVERY_WORDS, recoveryWords, isRecoveryPhrase };
+
+ },
+ "seal": function (__ovLockerModule, exports, __ovLockerReq) {
+'use strict';
+/**
+ * seal and open: the content, the three locks, and the streams.
+ *
+ * The envelope is written once and read once. Every chunk is sealed with AES-256-GCM under the
+ * content key, with the header's core, the chunk counter and the flags as associated data, so a
+ * chunk cannot be cut, reordered or truncated without the open failing.
+ */
+const {
+ utf8, fromUtf8, concat, base64, base64url, fromBase64url, u32be, readU32be, u64be, readU64be,
+} = __ovLockerReq('./bytes');
+const { fail } = __ovLockerReq('./errors');
+const {
+ MAGIC, VERSION, SUITE, CHUNK_SIZE, TAG_SIZE, NONCE_BASE_SIZE, LENGTH_PREFIX, MIN_PADDED_SIZE,
+ FINAL_FLAG, coreOf, chunkAad, encodeHeader, encodeEnvelope, readHeader, chunkCount,
+ lastChunkSize, paddedSize, chunkNonce, encodeChunk, nonceBaseBytes,
+} = __ovLockerReq('./format');
+const {
+ KEY_BYTES, PASSWORD_ITERATIONS, subtle, platformRandom, importContentKey, publicJwk,
+ jwkThumbprint, wrapFor, unwrapPeopleSlot, wrapForPassword, unwrapPasswordSlot, resolveEphemeral,
+} = __ovLockerReq('./keys');
+
+/** The content as bytes: a string is UTF-8, anything else is taken as it is. */
+function toBytes(content) {
+ if (typeof content === 'string') return utf8(content);
+ if (content instanceof Uint8Array) return content;
+ if (content instanceof ArrayBuffer) return new Uint8Array(content);
+ if (ArrayBuffer.isView(content)) return new Uint8Array(content.buffer, content.byteOffset, content.byteLength);
+ fail('locker.invalid', 'The content must be a string or bytes');
+}
+
+/** The bytes of an envelope: what seal returns, or what the caller kept. */
+function envelopeBytes(envelope) {
+ if (envelope instanceof Uint8Array) return envelope;
+ if (envelope && envelope.bytes instanceof Uint8Array) return envelope.bytes;
+ fail('locker.invalid', 'That is not a Locker envelope');
+}
+
+/** A source of bytes: an async iterable of Uint8Array. */
+async function* once(bytes) { yield bytes; }
+
+/**
+ * Collects bytes into blocks of a fixed size, without ever holding more than one block and the
+ * part that has not been used yet. This is what keeps a stream seal in constant memory.
+ */
+class Chunker {
+ constructor(size) { this.size = size; this.parts = []; this.length = 0; }
+
+ push(part) { this.parts.push(part); this.length += part.length; }
+
+ /** The next full block, or null when there is not enough yet. */
+ take() {
+ if (this.length < this.size) return null;
+ const block = new Uint8Array(this.size);
+ let at = 0;
+ while (at < this.size) {
+ const head = this.parts[0];
+ const take = Math.min(head.length, this.size - at);
+ block.set(head.subarray(0, take), at);
+ at += take;
+ if (take === head.length) this.parts.shift();
+ else this.parts[0] = head.subarray(take);
+ }
+ this.length -= this.size;
+ return block;
+ }
+
+ /** What is left, as one block of exactly this many bytes: the bytes that are there, then zeros. */
+ takeExactly(count) {
+ if (this.length > count) fail('locker.size_mismatch', 'The content is longer than the size it was given');
+ const block = new Uint8Array(count);
+ let at = 0;
+ for (const part of this.parts) { block.set(part, at); at += part.length; }
+ this.parts = [];
+ this.length = 0;
+ return block;
+ }
+}
+
+/** The header, the content key and the nonce base: everything a seal needs before it writes. */
+async function prepare(options) {
+ const lock = options.lock;
+ if (!['people', 'password', 'link'].includes(lock)) {
+ fail('locker.invalid', 'A lock is people, password or link');
+ }
+ const random = options.random || platformRandom;
+ const contentKey = options.contentKey ? toBytes(options.contentKey) : random(KEY_BYTES);
+ if (contentKey.length !== KEY_BYTES) fail('locker.invalid', 'The content key is 32 bytes');
+ const nonceBase = options.nonceBase ? toBytes(options.nonceBase) : random(NONCE_BASE_SIZE);
+ if (nonceBase.length !== NONCE_BASE_SIZE) fail('locker.invalid', 'The nonce base is eight bytes');
+ let slots;
+ if (lock === 'people') {
+ const keys = options.keys || [];
+ if (!keys.length) fail('locker.invalid', 'A people lock needs at least one recipient');
+ // One ephemeral key pair for the whole envelope: the shared secret differs per person.
+ const pair = await resolveEphemeral(options.ephemeral);
+ const people = [];
+ for (const key of keys) {
+ people.push(await wrapFor(contentKey, key.publicKey, {
+ subject: key.subject, random, ephemeral: pair,
+ }));
+ }
+ slots = { people };
+ } else if (lock === 'password') {
+ slots = {
+ password: await wrapForPassword(contentKey, options.password, {
+ salt: options.salt, iterations: options.iterations || PASSWORD_ITERATIONS, random,
+ }),
+ };
+ } else {
+ // A link lock stores no key: the content key travels in the part of the link after the #.
+ slots = { link: {} };
+ }
+ return { contentKey, nonceBase, slots };
+}
+
+/** The header of an envelope. */
+function makeHeader(lock, slots, padded, nonceBase) {
+ return {
+ version: VERSION,
+ suite: SUITE,
+ chunk_size: CHUNK_SIZE,
+ padded_size: padded,
+ nonce_base: base64(nonceBase),
+ lock,
+ slots,
+ };
+}
+
+/** Seals one block of the padded plaintext. */
+async function sealBlock(key, header, nonceBase, counter, block, isFinal) {
+ const flags = isFinal ? FINAL_FLAG : 0;
+ const nonce = chunkNonce(nonceBase, counter);
+ const ciphertext = new Uint8Array(await subtle().encrypt(
+ { name: 'AES-GCM', iv: nonce, additionalData: chunkAad(header, counter, flags) },
+ key,
+ block,
+ ));
+ return encodeChunk(counter, flags, nonce, ciphertext);
+}
+
+/**
+ * The envelope, piece by piece. The header comes first (it holds the padded size, so the size
+ * has to be known before the first byte is written), then one frame per chunk.
+ */
+async function* sealPieces(source, options) {
+ const size = options.size;
+ const { contentKey, nonceBase, slots } = await prepare(options);
+ const padded = paddedSize(size);
+ const header = makeHeader(options.lock, slots, padded, nonceBase);
+ const headerBytes = encodeHeader(header);
+ yield { header, headerBytes, contentKey, bytes: concat(utf8(MAGIC), u32be(headerBytes.length), headerBytes) };
+ const key = await importContentKey(contentKey, ['encrypt']);
+ const total = chunkCount(padded);
+ const chunker = new Chunker(CHUNK_SIZE);
+ let counter = 0;
+ // The padded plaintext starts with the true length, so the server cannot read the size.
+ chunker.push(u64be(size));
+ for await (const part of source) {
+ chunker.push(part);
+ // Every chunk but the last is a whole chunk.
+ for (;;) {
+ if (counter >= total - 1) break;
+ const block = chunker.take();
+ if (!block) break;
+ yield { bytes: await sealBlock(key, header, nonceBase, counter, block, false) };
+ counter++;
+ }
+ }
+ // The content is done; the padding fills the rest of the padded plaintext. (A content that
+ // is longer than the size it was given fails in takeExactly, below.)
+ while (counter < total - 1) {
+ const block = chunker.takeExactly(CHUNK_SIZE);
+ yield { bytes: await sealBlock(key, header, nonceBase, counter, block, false) };
+ counter++;
+ }
+ // The last chunk: what is left of the plaintext, then zeros up to the padded size.
+ const tail = chunker.takeExactly(lastChunkSize(padded));
+ yield { bytes: await sealBlock(key, header, nonceBase, counter, tail, true) };
+}
+
+/**
+ * Seals content: a string or bytes. The result holds the envelope's bytes and its header.
+ *
+ * seal('a note', { lock: 'password', password: 'correct horse' })
+ * seal(bytes, { lock: 'people', keys: [{ subject, publicKey }] })
+ * seal(bytes, { lock: 'link' }) // the key comes back as linkKey
+ */
+async function seal(content, options = {}) {
+ const bytes = toBytes(content);
+ const pieces = [];
+ let header = null;
+ let headerBytes = null;
+ let contentKey = null;
+ for await (const piece of sealPieces(once(bytes), { ...options, size: bytes.length })) {
+ if (piece.header) { header = piece.header; headerBytes = piece.headerBytes; contentKey = piece.contentKey; }
+ pieces.push(piece.bytes);
+ }
+ const envelope = {
+ version: VERSION,
+ header,
+ headerBytes,
+ bytes: concat(...pieces),
+ chunkOffset: 8 + headerBytes.length,
+ };
+ if (options.lock === 'link') {
+ // The link carries the content key; the envelope itself stores nothing.
+ envelope.linkKey = base64url(contentKey);
+ }
+ return envelope;
+}
+
+/** The header's core, as a reader needs it: the fields must be the ones we write. */
+function readCore(header) {
+ if (!header || typeof header !== 'object' || Array.isArray(header)) {
+ fail('locker.bad_envelope', 'The envelope header is not an object');
+ }
+ if (header.version !== VERSION) fail('locker.bad_envelope', 'This envelope is another version');
+ if (header.suite !== SUITE) fail('locker.bad_envelope', 'This envelope uses another suite');
+ if (header.chunk_size !== CHUNK_SIZE) fail('locker.bad_envelope', 'This envelope uses another chunk size');
+ if (!Number.isInteger(header.padded_size) || header.padded_size < MIN_PADDED_SIZE) {
+ fail('locker.bad_envelope', 'The padded size is not a size we write');
+ }
+ if (!['people', 'password', 'link'].includes(header.lock)) {
+ fail('locker.bad_envelope', 'The lock is not one we know');
+ }
+ if (!header.slots || typeof header.slots !== 'object') {
+ fail('locker.bad_envelope', 'The envelope has no slots');
+ }
+ return header;
+}
+
+/** The content key out of the header, with the credentials the reader has. */
+async function openContentKey(header, credentials = {}) {
+ // A caller that still holds the content key needs no private key: an agent that sealed the item
+ // can open it, or wrap it for the people who stay, with the key it kept. A string is base64url,
+ // as the link key travels.
+ if (credentials.contentKey) {
+ const given = typeof credentials.contentKey === 'string'
+ ? fromBase64url(credentials.contentKey) : toBytes(credentials.contentKey);
+ if (given.length !== KEY_BYTES) fail('locker.invalid', 'The content key is 32 bytes');
+ return given;
+ }
+ if (header.lock === 'people') {
+ // A person may be named by their key instead of their subject: the slot says which key
+ // it is for, so the reader does not need to know who they are.
+ if (credentials.person) {
+ const slot = await slotForPerson(header, credentials.person);
+ const privateKey = await importPrivateKey(privateKeyOf(credentials.person) || credentials.privateKey);
+ if (!privateKey) fail('locker.no_key', 'A people lock needs the private key of a recipient');
+ return unwrapPeopleSlot(slot, privateKey);
+ }
+ const slot = pickSlot(header, credentials.subject);
+ const privateKey = await importPrivateKey(credentials.privateKey);
+ if (!privateKey) fail('locker.no_key', 'A people lock needs the private key of a recipient');
+ return unwrapPeopleSlot(slot, privateKey);
+ }
+ if (header.lock === 'password') {
+ if (!credentials.password) fail('locker.no_key', 'A password lock needs the password');
+ if (!header.slots.password) fail('locker.bad_envelope', 'The envelope has no password slot');
+ return unwrapPasswordSlot(header.slots.password, credentials.password);
+ }
+ if (!credentials.key) fail('locker.no_key', 'A link lock needs the key from the link');
+ const key = typeof credentials.key === 'string' ? fromBase64url(credentials.key) : toBytes(credentials.key);
+ if (key.length !== KEY_BYTES) fail('locker.invalid', 'The link key is 32 bytes');
+ return key;
+}
+
+/** The slot of the person who is opening: theirs, or the only one there is. */
+function pickSlot(header, subject) {
+ const slots = (header.slots && header.slots.people) || [];
+ if (!slots.length) fail('locker.bad_envelope', 'The envelope has no people slots');
+ if (subject) {
+ const id = typeof subject === 'string' ? subject : subject.id;
+ const type = typeof subject === 'string' ? null : subject.type;
+ const found = slots.find(slot => slot.subject.id === id && (type === null || slot.subject.type === type));
+ if (!found) fail('locker.no_key', 'This envelope has no slot for that person');
+ return found;
+ }
+ if (slots.length > 1) fail('locker.no_key', 'This envelope has several slots: say which person you are');
+ return slots[0];
+}
+
+/** The ECDH public key of a person, as a JWK. */
+async function publicKeyOf(person) {
+ if (!person || typeof person !== 'object') fail('locker.invalid', 'A people lock needs the person');
+ if (person.ecdhPublic) return publicJwk(person.ecdhPublic);
+ if (person.ecdh && person.ecdh.kty) return publicJwk(person.ecdh);
+ if (person.kty === 'EC') return publicJwk(person);
+ if (person.ecdh && person.ecdh.publicKey) {
+ return publicJwk(await subtle().exportKey('jwk', person.ecdh.publicKey));
+ }
+ fail('locker.invalid', 'The person has no ECDH public key');
+}
+
+/** The ECDH private key of a person, if they brought one. */
+function privateKeyOf(person) {
+ if (!person || typeof person !== 'object') return null;
+ if (person.ecdh && person.ecdh.privateKey) return person.ecdh.privateKey;
+ if (person.ecdhPrivateKey) return person.ecdhPrivateKey;
+ if (person.privateKey) return person.privateKey;
+ return null;
+}
+
+/**
+ * The slot that names this person's key. Every people slot carries the thumbprint of the key
+ * it was made for, so the reader finds its own slot without knowing anybody's subject. An
+ * envelope that has no slot for this key is not an envelope this person can open: that is
+ * locker.no_slot, which says the key is not on the list (not that the key is wrong).
+ */
+async function slotForPerson(header, person) {
+ const slots = (header.slots && header.slots.people) || [];
+ if (!slots.length) fail('locker.bad_envelope', 'The envelope has no people slots');
+ const thumbprint = await jwkThumbprint(await publicKeyOf(person));
+ const found = slots.find(slot => slot.recipient_key === thumbprint);
+ if (!found) fail('locker.no_slot', 'This envelope has no slot for that key');
+ return found;
+}
+
+/** A private key as WebCrypto wants it: a CryptoKey, or a JWK to import. */
+async function importPrivateKey(key) {
+ if (!key) return null;
+ if (key.type === 'private' && key.algorithm && key.algorithm.name === 'ECDH') return key;
+ if (key.kty === 'EC' && key.crv === 'P-256' && key.d) {
+ try {
+ return await subtle().importKey('jwk', key, { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
+ } catch {
+ fail('locker.invalid', 'The private key is not a P-256 key');
+ }
+ }
+ fail('locker.invalid', 'The private key must be an ECDH P-256 key');
+}
+
+/** Reads exact byte counts out of an envelope that is already in memory. */
+function arrayReader(bytes) {
+ let at = 0;
+ return {
+ /** The next bytes, or null when the envelope is over. */
+ async probe(count) {
+ if (at >= bytes.length) return null;
+ return this.take(count);
+ },
+ async take(count) {
+ if (at + count > bytes.length) fail('locker.truncated', 'The envelope ends inside a chunk');
+ const out = bytes.slice(at, at + count);
+ at += count;
+ return out;
+ },
+ };
+}
+
+/** Reads exact byte counts out of a stream, keeping only what it must. */
+function streamReader(iterator) {
+ let buffer = new Uint8Array(0);
+ let ended = false;
+ async function fill(count) {
+ while (buffer.length < count && !ended) {
+ const next = await iterator.next();
+ if (next.done) { ended = true; break; }
+ buffer = buffer.length ? concat(buffer, next.value) : next.value;
+ }
+ return buffer.length;
+ }
+ return {
+ /** The next bytes, or null when the stream is over. */
+ async probe(count) {
+ const have = await fill(count);
+ if (have === 0) return null;
+ return this.take(count);
+ },
+ async take(count) {
+ const have = await fill(count);
+ if (have < count) fail('locker.truncated', 'The envelope ends inside a chunk');
+ const out = buffer.slice(0, count);
+ buffer = buffer.slice(count);
+ return out;
+ },
+ };
+}
+
+/**
+ * Opens the chunks: the plaintext, piece by piece. The reader may be an envelope in memory or a
+ * stream; both are read the same way, so the same checks run on both.
+ */
+async function* openPieces(reader, header, key) {
+ const expected = chunkCount(header.padded_size);
+ const lastLength = lastChunkSize(header.padded_size);
+ const nonceBase = nonceBaseBytes(header);
+ let counter = 0;
+ let length = null;
+ let emitted = 0;
+ for (;;) {
+ const lengthBytes = await reader.probe(4);
+ if (lengthBytes === null) {
+ if (counter !== expected) fail('locker.truncated', 'The envelope ends before its last chunk');
+ break;
+ }
+ if (counter >= expected) fail('locker.bad_envelope', 'The envelope has more chunks than its size says');
+ const cipherLength = readU32be(lengthBytes, 0);
+ // The length is checked before it is used: a header that claims a huge chunk must not
+ // make the reader wait for it. Every chunk but the last is a whole chunk; the last holds
+ // what is left of the padded size.
+ const wantedLength = counter === expected - 1 ? lastLength : CHUNK_SIZE;
+ if (cipherLength !== wantedLength + TAG_SIZE) {
+ fail('locker.bad_envelope', 'A chunk is not the size the envelope says');
+ }
+ const flags = (await reader.take(1))[0];
+ const nonce = await reader.take(12);
+ const ciphertext = await reader.take(cipherLength);
+ const isFinal = (flags & FINAL_FLAG) === FINAL_FLAG;
+ if (isFinal !== (counter === expected - 1)) {
+ fail('locker.bad_envelope', 'The envelope marks the wrong chunk as the last one');
+ }
+ const wanted = chunkNonce(nonceBase, counter);
+ for (let i = 0; i < wanted.length; i++) {
+ if (nonce[i] !== wanted[i]) fail('locker.bad_envelope', 'A chunk carries the wrong nonce');
+ }
+ let plain;
+ try {
+ plain = new Uint8Array(await subtle().decrypt(
+ { name: 'AES-GCM', iv: nonce, additionalData: chunkAad(header, counter, flags) },
+ key,
+ ciphertext,
+ ));
+ } catch (err) {
+ if (err && err.code) throw err;
+ fail('locker.wrong_key', 'This envelope does not open with this key');
+ }
+ if (counter === 0) {
+ if (plain.length < LENGTH_PREFIX) fail('locker.bad_envelope', 'The first chunk is too short');
+ length = Number(readU64be(plain, 0));
+ if (length > header.padded_size - LENGTH_PREFIX) {
+ fail('locker.size_mismatch', 'The content is longer than the envelope says');
+ }
+ }
+ const start = counter === 0 ? LENGTH_PREFIX : 0;
+ const part = plain.subarray(start);
+ const room = length - emitted;
+ const out = part.length > room ? part.subarray(0, room) : part;
+ emitted += out.length;
+ counter++;
+ if (out.length) yield out;
+ }
+ if (emitted !== length) fail('locker.size_mismatch', 'The envelope holds fewer bytes than it says');
+}
+
+/**
+ * Opens an envelope: the content as bytes.
+ *
+ * open(envelope, { privateKey, subject }) // a people lock, by subject
+ * open(envelope, { person }) // a people lock, by key
+ * open(envelope, { password }) // a password lock
+ * open(envelope, { key }) // a link lock
+ */
+async function open(envelope, credentials = {}) {
+ const bytes = envelopeBytes(envelope);
+ const { header, chunkOffset } = readHeader(bytes);
+ readCore(header);
+ const contentKey = await openContentKey(header, credentials);
+ const key = await importContentKey(contentKey, ['decrypt']);
+ const reader = arrayReader(bytes.subarray(chunkOffset));
+ const parts = [];
+ for await (const part of openPieces(reader, header, key)) parts.push(part);
+ return concat(...parts);
+}
+
+/** A stream of the bytes of a WHATWG stream (or of any async iterable of bytes). */
+async function* readChunks(stream) {
+ if (stream && typeof stream.getReader === 'function') {
+ const reader = stream.getReader();
+ try {
+ for (;;) {
+ const { value, done } = await reader.read();
+ if (done) return;
+ yield value instanceof Uint8Array ? value : new Uint8Array(value);
+ }
+ } finally {
+ reader.releaseLock();
+ }
+ return;
+ }
+ for await (const part of stream) yield part instanceof Uint8Array ? part : new Uint8Array(part);
+}
+
+/**
+ * Seals a stream: the envelope comes out piece by piece, and only one chunk is ever in memory.
+ * The size of the content has to be known, because the padded size is in the header and the
+ * header is written first.
+ */
+function sealStream(stream, options = {}) {
+ if (typeof options.size !== 'number' || options.size < 0) {
+ fail('locker.invalid', 'A stream seal needs the size of the content');
+ }
+ const iterator = sealPieces(readChunks(stream), { ...options, size: options.size })[Symbol.asyncIterator]();
+ return new ReadableStream({
+ async pull(controller) {
+ const next = await iterator.next();
+ if (next.done) controller.close();
+ else controller.enqueue(next.value.bytes);
+ },
+ async cancel(reason) { if (iterator.return) await iterator.return(reason); },
+ });
+}
+
+/**
+ * Opens a stream: the content comes out piece by piece, and only one chunk is ever in memory.
+ *
+ * The chunks are yielded as they are verified, so a reader sees the earlier ones before the last
+ * one is reached. A stream that ends early throws at the end, when the missing chunk shows up:
+ * discard what it yielded, because the content was never complete.
+ */
+function openStream(stream, credentials = {}) {
+ const iterator = (async function* openStreamPieces() {
+ const reader = streamReader(readChunks(stream)[Symbol.asyncIterator]());
+ const head = await reader.probe(8);
+ if (head === null) fail('locker.truncated', 'The envelope is too short to hold a header');
+ if (fromUtf8(head.subarray(0, 4)) !== MAGIC) fail('locker.bad_envelope', 'These bytes are not a Locker envelope');
+ const headerLength = readU32be(head, 4);
+ if (headerLength === 0 || headerLength > 65536) {
+ fail('locker.bad_envelope', `The envelope header is ${headerLength} bytes, which is not one we write`);
+ }
+ const headerBytes = await reader.take(headerLength);
+ let header;
+ try { header = JSON.parse(fromUtf8(headerBytes)); }
+ catch { fail('locker.bad_envelope', 'The envelope header is not JSON'); }
+ readCore(header);
+ const contentKey = await openContentKey(header, credentials);
+ const key = await importContentKey(contentKey, ['decrypt']);
+ yield* openPieces(reader, header, key);
+ })()[Symbol.asyncIterator]();
+ return new ReadableStream({
+ async pull(controller) {
+ const next = await iterator.next();
+ if (next.done) controller.close();
+ else controller.enqueue(next.value);
+ },
+ async cancel(reason) { if (iterator.return) await iterator.return(reason); },
+ });
+}
+
+/**
+ * Wraps the content key for other people, without touching the content: the envelope's header
+ * changes, the chunks do not, because the slots are not part of what the chunks authenticate.
+ * This is how a member is added to a people-locked item: the people who are already on it keep
+ * their slots, and the new ones get one of their own.
+ *
+ * Removing a member wants a fresh content key, not just a slot that is gone: the person who is
+ * removed may still hold the old key. So a removal opens the content and seals it again, with a
+ * new key and a new nonce base, for the people who are left. Their public keys are needed for
+ * that, so the caller gives them in `keys`.
+ */
+async function rewrap(envelope, {
+ add = [], remove = [], keys = [], credentials = {}, random = platformRandom, ephemeral,
+} = {}) {
+ const bytes = envelopeBytes(envelope);
+ const { header, chunkOffset } = readHeader(bytes);
+ if (header.lock !== 'people') fail('locker.invalid', 'Only a people-locked envelope can be re-wrapped');
+ readCore(header);
+ const gone = new Set(remove.map(who => (typeof who === 'string' ? who : who.id)));
+ const staying = (header.slots.people || []).filter(slot => !gone.has(slot.subject.id));
+
+ if (!gone.size) {
+ // Adding: the content key and the chunks stay as they are.
+ const contentKey = await openContentKey(header, credentials);
+ const people = staying.slice();
+ if (add.length) {
+ const pair = await resolveEphemeral(ephemeral);
+ for (const recipient of add) {
+ people.push(await wrapFor(contentKey, recipient.publicKey, {
+ subject: recipient.subject, ephemeral: pair, random,
+ }));
+ }
+ }
+ if (!people.length) fail('locker.invalid', 'A people-locked envelope needs at least one slot');
+ const newHeader = { ...coreOf(header), slots: { people } };
+ const headerBytes = encodeHeader(newHeader);
+ return {
+ version: VERSION,
+ header: newHeader,
+ headerBytes,
+ bytes: encodeEnvelope(headerBytes, bytes.subarray(chunkOffset)),
+ chunkOffset: 8 + headerBytes.length,
+ };
+ }
+
+ // Removing: the content is opened and sealed again under a new key, so the key the person
+ // who is leaving still holds opens nothing.
+ const contentKey = await openContentKey(header, credentials);
+ const plain = await open(bytes, credentials);
+ const recipients = [];
+ for (const slot of staying) {
+ const found = keys.find(key => (typeof key.subject === 'string' ? key.subject : key.subject.id) === slot.subject.id);
+ if (!found) {
+ fail('locker.invalid', `Removing a person needs the public key of ${slot.subject.id}, who stays`);
+ }
+ recipients.push({ subject: slot.subject, publicKey: found.publicKey });
+ }
+ for (const recipient of add) recipients.push({ subject: recipient.subject, publicKey: recipient.publicKey });
+ if (!recipients.length) fail('locker.invalid', 'A people-locked envelope needs at least one slot');
+ return seal(plain, { lock: 'people', keys: recipients, random, ephemeral });
+}
+
+__ovLockerModule.exports = {
+ seal, open, sealStream, openStream, rewrap,
+ envelopeBytes, toBytes, openContentKey, readCore, prepare, makeHeader, Chunker,
+ publicKeyOf, privateKeyOf, slotForPerson,
+};
+
+ },
+ "server": function (__ovLockerModule, exports, __ovLockerReq) {
+'use strict';
+/**
+ * What a service needs to know about an envelope without opening it: the header's shape and the
+ * envelope's size. Nothing here decrypts anything, and nothing here needs a key: a service may
+ * hold the bytes of an item it can never read.
+ *
+ * A service that stores an envelope should:
+ * - check it with inspectEnvelope() and refuse what does not pass;
+ * - keep the header as it is (it holds no key material, only wrapped keys);
+ * - skip indexing, previews, thumbnails, transcoding and content AI;
+ * - show the lock badge, and serve the bytes only to the people canView allows.
+ */
+const { fromUtf8, fromBase64, readU32be } = __ovLockerReq('./bytes');
+const {
+ MAGIC, VERSION, SUITE, CHUNK_SIZE, TAG_SIZE, NONCE_SIZE, NONCE_BASE_SIZE, MIN_PADDED_SIZE,
+ MAX_HEADER_BYTES, chunkCount, lastChunkSize,
+} = __ovLockerReq('./format');
+
+/** The bytes of a base64 field, or null when it is not base64 of that many bytes. */
+function bytesOf(text, length) {
+ if (typeof text !== 'string' || !text.length) return null;
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(text)) return null;
+ let bytes;
+ try { bytes = fromBase64(text); } catch { return null; }
+ return bytes.length === length ? bytes : null;
+}
+
+/** A subject: { type, id }, both there. */
+function subjectOk(subject) {
+ return !!subject && typeof subject === 'object' && typeof subject.type === 'string'
+ && typeof subject.id === 'string' && subject.type.length > 0 && subject.id.length > 0;
+}
+
+/** A public key as a JWK: P-256, with its two coordinates. */
+function publicKeyOk(key) {
+ return !!key && typeof key === 'object' && key.kty === 'EC' && key.crv === 'P-256'
+ && typeof key.x === 'string' && /^[A-Za-z0-9_-]{43}$/.test(key.x)
+ && typeof key.y === 'string' && /^[A-Za-z0-9_-]{43}$/.test(key.y);
+}
+
+/** One people slot: whose it is, which key it is for, and the wrapped key with the bytes it must have. */
+function peopleSlotOk(slot) {
+ if (!slot || typeof slot !== 'object') return false;
+ if (!subjectOk(slot.subject)) return false;
+ if (slot.algorithm !== 'ECDH-P256/HKDF-SHA256/AES-256-GCM') return false;
+ // The thumbprint of the recipient's key: the slot names the key it is for.
+ if (typeof slot.recipient_key !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(slot.recipient_key)) return false;
+ if (!publicKeyOk(slot.ephemeral_public_key)) return false;
+ if (!bytesOf(slot.salt, 16)) return false;
+ if (!bytesOf(slot.nonce, NONCE_SIZE)) return false;
+ return !!bytesOf(slot.wrapped_key, 48);
+}
+
+/** The password slot: the parameters and the wrapped key. */
+function passwordSlotOk(slot) {
+ if (!slot || typeof slot !== 'object') return false;
+ if (slot.algorithm !== 'PBKDF2-SHA256/AES-256-GCM') return false;
+ if (slot.kdf !== 'PBKDF2-SHA-256') return false;
+ if (!Number.isInteger(slot.iterations) || slot.iterations < 600000 || slot.iterations > 10000000) return false;
+ if (!bytesOf(slot.salt, 16)) return false;
+ if (!bytesOf(slot.nonce, NONCE_SIZE)) return false;
+ return !!bytesOf(slot.wrapped_key, 48);
+}
+
+/**
+ * The header's shape: what it says, and whether the slots match the lock it names. The problems
+ * are returned as words, so a service can log them; nothing is thrown and nothing is decrypted.
+ */
+function checkHeader(header) {
+ const errors = [];
+ if (!header || typeof header !== 'object' || Array.isArray(header)) {
+ return ['the header is not an object'];
+ }
+ if (header.version !== VERSION) errors.push(`the version is not ${VERSION}`);
+ if (header.suite !== SUITE) errors.push(`the suite is not ${SUITE}`);
+ if (header.chunk_size !== CHUNK_SIZE) errors.push(`the chunk size is not ${CHUNK_SIZE}`);
+ if (!Number.isInteger(header.padded_size) || header.padded_size < MIN_PADDED_SIZE) {
+ errors.push(`the padded size is not at least ${MIN_PADDED_SIZE} bytes`);
+ }
+ if (!bytesOf(header.nonce_base, NONCE_BASE_SIZE)) errors.push('the nonce base is not eight bytes');
+ if (!['people', 'password', 'link'].includes(header.lock)) errors.push('the lock is not one we know');
+ const slots = header.slots;
+ if (!slots || typeof slots !== 'object' || Array.isArray(slots)) {
+ errors.push('the header has no slots');
+ return errors;
+ }
+ const kinds = Object.keys(slots);
+ const wanted = header.lock === 'people' ? 'people' : header.lock;
+ if (kinds.length !== 1 || kinds[0] !== wanted) {
+ errors.push(`the slots do not match the ${header.lock} lock`);
+ return errors;
+ }
+ if (wanted === 'people') {
+ if (!Array.isArray(slots.people) || !slots.people.length || slots.people.length > 100) {
+ errors.push('the people slots are not a list of one to a hundred');
+ } else if (!slots.people.every(peopleSlotOk)) {
+ errors.push('a people slot is not the shape it must be');
+ }
+ } else if (wanted === 'password') {
+ if (!passwordSlotOk(slots.password)) errors.push('the password slot is not the shape it must be');
+ } else if (Object.keys(slots.link).length) {
+ errors.push('a link slot stores nothing, and this one does');
+ }
+ return errors;
+}
+
+/**
+ * Reads an envelope's header and checks its shape and its size. Nothing is decrypted and no key
+ * is needed: this is what a service runs on bytes it may never be able to read.
+ */
+function inspectBytes(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}, totalBytes = bytes && bytes.length) {
+ const errors = [];
+ const data = bytes instanceof Uint8Array ? bytes : null;
+ if (!data) return { valid: false, errors: ['the envelope is not bytes'], header: null };
+ if (data.length < 8) return { valid: false, errors: ['the envelope is too short to hold a header'], header: null };
+ if (fromUtf8(data.subarray(0, 4)) !== MAGIC) {
+ return { valid: false, errors: ['these bytes are not a Locker envelope'], header: null };
+ }
+ const headerLength = readU32be(data, 4);
+ if (headerLength === 0 || headerLength > maxHeaderBytes) {
+ return { valid: false, errors: [`the envelope header is ${headerLength} bytes, which is not one we write`], header: null };
+ }
+ if (data.length < 8 + headerLength) {
+ return { valid: false, errors: ['the envelope ends inside its header'], header: null };
+ }
+ let header = null;
+ try { header = JSON.parse(fromUtf8(data.subarray(8, 8 + headerLength))); }
+ catch { return { valid: false, errors: ['the envelope header is not JSON'], header: null }; }
+ if (!header || typeof header !== 'object' || Array.isArray(header)) return { valid: false, errors: ['the header is not an object'], header: null };
+ try { errors.push(...checkHeader(header)); } catch { errors.push('the slots are not an object'); }
+ const chunkOffset = 8 + headerLength;
+ let expectedBytes = null;
+ if (Number.isInteger(header.padded_size) && header.padded_size >= MIN_PADDED_SIZE
+ && header.chunk_size === CHUNK_SIZE) {
+ const chunks = chunkCount(header.padded_size);
+ // Every chunk: its length, its flags, its nonce, the ciphertext and the tag. Every chunk
+ // but the last is a whole chunk; the last holds what is left of the padded size.
+ const frame = 4 + 1 + NONCE_SIZE;
+ const whole = (chunks - 1) * (frame + CHUNK_SIZE + TAG_SIZE);
+ const last = frame + lastChunkSize(header.padded_size) + TAG_SIZE;
+ expectedBytes = chunkOffset + whole + last;
+ if (totalBytes < expectedBytes) errors.push('the envelope is shorter than its own header says');
+ }
+ return {
+ valid: errors.length === 0,
+ errors,
+ header,
+ headerLength,
+ chunkOffset,
+ lock: header.lock,
+ paddedSize: header.padded_size,
+ chunkCount: Number.isInteger(header.padded_size) ? chunkCount(header.padded_size) : null,
+ expectedBytes,
+ };
+}
+
+function inspectEnvelope(bytes, options) { return inspectBytes(bytes, options); }
+
+/** A streamed upload: inspect just its bounded header against the ACTUAL stored byte count.
+ * The caller must have counted and bounded the complete ciphertext first, not trust Content-Length.
+ * No chunks are read or authenticated here; as with inspectEnvelope, only a client can do that.
+ */
+function inspectEnvelopePrefix(prefix, totalBytes, options) {
+ const info = inspectBytes(prefix, options, totalBytes);
+ if (!Number.isSafeInteger(totalBytes) || !Number.isSafeInteger(info.expectedBytes) || totalBytes !== info.expectedBytes) {
+ info.errors.push('the stored byte count does not match the envelope');
+ }
+ if (info.header && !Number.isSafeInteger(info.header.padded_size)) info.errors.push('the padded size is not a safe integer');
+ info.valid = info.errors.length === 0;
+ return info;
+}
+__ovLockerModule.exports = { inspectEnvelope, inspectEnvelopePrefix, checkHeader, peopleSlotOk };
+
+ },
+ "words": function (__ovLockerModule, exports, __ovLockerReq) {
+'use strict';
+/**
+ * The 256 words of a fingerprint. A fingerprint is the first eight bytes of SHA-256 over a
+ * person's two public keys, read as eight words in two groups of four: eight bits each, so the
+ * words say which of the 256 they are. It is short enough to read out loud and compare once,
+ * which is the whole point: a person checks that the eight words on the screen are the eight
+ * words the other person sees.
+ *
+ * The order is part of the format: word number n is the nth word here. Changing the list would
+ * change every fingerprint, so it is frozen with the version.
+ */
+const WORDS = [
+ 'amber', 'anchor', 'apple', 'arrow', 'aspen', 'autumn', 'badge', 'bamboo',
+ 'beacon', 'berry', 'birch', 'bison', 'blossom', 'breeze', 'brick', 'brook',
+ 'bucket', 'bugle', 'cabin', 'cactus', 'candle', 'canoe', 'canyon', 'cedar',
+ 'cellar', 'chalk', 'cherry', 'cider', 'cinder', 'citrus', 'clay', 'clover',
+ 'cobalt', 'cocoa', 'comet', 'coral', 'cosmos', 'cotton', 'cove', 'crane',
+ 'crater', 'crayon', 'cricket', 'crimson', 'crystal', 'cymbal', 'daisy', 'dapple',
+ 'dawn', 'delta', 'desert', 'diamond', 'dolphin', 'domino', 'donkey', 'dragon',
+ 'drift', 'drizzle', 'drum', 'dune', 'dusk', 'eagle', 'ember', 'emerald',
+ 'estuary', 'fable', 'falcon', 'feather', 'fern', 'ferry', 'field', 'flame',
+ 'flint', 'forest', 'fossil', 'fox', 'frost', 'galaxy', 'garden', 'garnet',
+ 'gazelle', 'geyser', 'ginger', 'glacier', 'granite', 'grape', 'gravel', 'grove',
+ 'guitar', 'haiku', 'harbor', 'hazel', 'heather', 'hollow', 'honey', 'horizon',
+ 'ice', 'indigo', 'island', 'ivory', 'jasper', 'jungle', 'juniper', 'kayak',
+ 'kernel', 'kestrel', 'lagoon', 'lantern', 'lattice', 'laurel', 'lemon', 'lichen',
+ 'lilac', 'linen', 'lotus', 'lunar', 'magnet', 'mango', 'maple', 'marble',
+ 'marsh', 'meadow', 'meteor', 'mint', 'mirror', 'mist', 'monsoon', 'moss',
+ 'moth', 'mountain', 'nectar', 'needle', 'nest', 'nettle', 'night', 'oasis',
+ 'ocean', 'olive', 'onyx', 'opal', 'orbit', 'orchard', 'orchid', 'osprey',
+ 'otter', 'owl', 'oyster', 'palace', 'palm', 'panda', 'papaya', 'paper',
+ 'peach', 'pearl', 'pebble', 'pecan', 'penguin', 'peony', 'pepper', 'petal',
+ 'pine', 'plateau', 'plum', 'pollen', 'pond', 'poplar', 'poppy', 'prairie',
+ 'prism', 'pumpkin', 'quail', 'quarry', 'quartz', 'rabbit', 'radish', 'rain',
+ 'raven', 'reef', 'ridge', 'river', 'robin', 'rosemary', 'sage', 'salmon',
+ 'sand', 'sapphire', 'satellite', 'scallop', 'shadow', 'shell', 'shore', 'shuttle',
+ 'silk', 'silver', 'slate', 'snow', 'solar', 'sparrow', 'spruce', 'star',
+ 'stone', 'storm', 'stream', 'sugar', 'summit', 'sunset', 'swan', 'thistle',
+ 'thunder', 'tide', 'tiger', 'timber', 'topaz', 'trail', 'tulip', 'tundra',
+ 'turtle', 'valley', 'vapor', 'velvet', 'vine', 'violet', 'walnut', 'wave',
+ 'whale', 'wheat', 'willow', 'winter', 'wolf', 'wren', 'yacht', 'yarrow',
+ 'yew', 'zinc', 'zephyr', 'almond', 'basil', 'cinnamon', 'cypress', 'damson',
+ 'elder', 'fennel', 'garlic', 'hibiscus', 'iris', 'jasmine', 'linden', 'melon',
+ 'nutmeg', 'sesame', 'sorrel', 'thyme', 'anise', 'chicory', 'dill', 'endive',
+];
+
+__ovLockerModule.exports = { WORDS };
+
+ },
+ };
+ const cache = {};
+ function req(name) {
+ const key = String(name).replace(/^\.\//, '');
+ if (!defs[key]) throw new Error('ov-locker-core: no module ' + name);
+ if (!cache[key]) { const m = { exports: {} }; cache[key] = m; defs[key](m, m.exports, req); }
+ return cache[key].exports;
+ }
+ root.OVLockerCore = Object.assign({}, req('index'), { SDK_VERSION: "0.101.0" });
+})(typeof globalThis !== 'undefined' ? globalThis : this);
@@ -38,7 +38,7 @@ const path = require('path');
@@ -38,7 +38,7 @@ const path = require('path');
const ALLOWED_KEYS = new Set(['manifest_version', 'name', 'short_name', 'version', 'description', 'homepage_url', 'author', 'icons',
const ALLOWED_KEYS = new Set(['manifest_version', 'name', 'short_name', 'version', 'description', 'homepage_url', 'author', 'icons',
'action', 'options_ui', 'permissions', 'host_permissions', 'content_security_policy', 'minimum_chrome_version', 'browser_specific_settings',
'action', 'options_ui', 'permissions', 'host_permissions', 'content_security_policy', 'minimum_chrome_version', 'browser_specific_settings',
'side_panel', 'sidebar_action', 'key', 'background']);
'side_panel', 'sidebar_action', 'key', 'background']);
-const BASE_PERMISSIONS = ['activeTab', 'identity', 'scripting', 'storage'];
+const BASE_PERMISSIONS = ['activeTab', 'alarms', 'clipboardWrite', 'identity', 'offscreen', 'scripting', 'storage'];
const SIDE_PERMISSIONS = ['sidePanel', 'sidebarAction'];
const SIDE_PERMISSIONS = ['sidePanel', 'sidebarAction'];
const PERMISSIONS = BASE_PERMISSIONS.concat(['sidePanel']);
const PERMISSIONS = BASE_PERMISSIONS.concat(['sidePanel']);
const BACKGROUND = { service_worker: 'background.js', type: 'module' };
const BACKGROUND = { service_worker: 'background.js', type: 'module' };
@@ -119,16 +119,17 @@ function auditDir(dir) {
@@ -119,16 +119,17 @@ function auditDir(dir) {
const chat = (configSrc.match(/CHAT_ORIGIN:\s*'([^']+)'/) || [])[1];
const chat = (configSrc.match(/CHAT_ORIGIN:\s*'([^']+)'/) || [])[1];
const codes = (configSrc.match(/CODES_ORIGIN:\s*'([^']+)'/) || [])[1];
const codes = (configSrc.match(/CODES_ORIGIN:\s*'([^']+)'/) || [])[1];
const mail = (configSrc.match(/MAIL_ORIGIN:\s*'([^']+)'/) || [])[1];
const mail = (configSrc.match(/MAIL_ORIGIN:\s*'([^']+)'/) || [])[1];
+ const locker = (configSrc.match(/LOCKER_ORIGIN:\s*'([^']+)'/) || [])[1];
const handoffs = ((configSrc.match(/HANDOFF_ORIGINS:\s*Object\.freeze\(\[([^\]]*)\]\)/) || [])[1] || '').split(',').map((x) => x.trim().replace(/^'|'$/g, '')).filter(Boolean);
const handoffs = ((configSrc.match(/HANDOFF_ORIGINS:\s*Object\.freeze\(\[([^\]]*)\]\)/) || [])[1] || '').split(',').map((x) => x.trim().replace(/^'|'$/g, '')).filter(Boolean);
const problems = [];
const problems = [];
if (!origin) problems.push('lib/config.js has no API_ORIGIN');
if (!origin) problems.push('lib/config.js has no API_ORIGIN');
if (!handoffs.length) problems.push('lib/config.js has no HANDOFF_ORIGINS');
if (!handoffs.length) problems.push('lib/config.js has no HANDOFF_ORIGINS');
for (const h of handoffs) if (!/^https:\/\/openvibe\.[a-z]+$/.test(h)) problems.push(`hand-off origin ${h} is not an https://openvibe.<tld> site`);
for (const h of handoffs) if (!/^https:\/\/openvibe\.[a-z]+$/.test(h)) problems.push(`hand-off origin ${h} is not an https://openvibe.<tld> site`);
- const origins = [origin, network, chat, codes].filter(Boolean);
- for (const o of [network, chat, codes, mail].filter(Boolean)) if (!/^https:\/\/openvibe\.[a-z]+$/.test(o)) problems.push(`${o} is not an https://openvibe.<tld> site`);
+ const origins = [origin, network, chat, codes, locker].filter(Boolean);
+ for (const o of [network, chat, codes, mail, locker].filter(Boolean)) if (!/^https:\/\/openvibe\.[a-z]+$/.test(o)) problems.push(`${o} is not an https://openvibe.<tld> site`);
const connectOrigins = origins.concat(mail ? [mail] : []);
const connectOrigins = origins.concat(mail ? [mail] : []);
problems.push(...auditManifest(manifest, { apiOrigin: origin, origins, connectOrigins }));
problems.push(...auditManifest(manifest, { apiOrigin: origin, origins, connectOrigins }));
- for (const page of ['popup.html', 'options.html', 'panel.html']) problems.push(...auditPage(fs.readFileSync(path.join(dir, page), 'utf8'), page));
+ for (const page of ['popup.html', 'options.html', 'panel.html', 'clipboard.html']) problems.push(...auditPage(fs.readFileSync(path.join(dir, page), 'utf8'), page));
return { manifest, origin, origins, connectOrigins, problems };
return { manifest, origin, origins, connectOrigins, problems };
}
}
@@ -85,7 +85,7 @@ function forTarget(files, target) {
@@ -85,7 +85,7 @@ function forTarget(files, target) {
delete m.minimum_chrome_version;
delete m.minimum_chrome_version;
delete m.side_panel;
delete m.side_panel;
delete m.key; // Chrome's public key is not a Firefox manifest field
delete m.key; // Chrome's public key is not a Firefox manifest field
- m.permissions = (m.permissions || []).filter((p) => p !== 'sidePanel');
+ m.permissions = (m.permissions || []).filter((p) => p !== 'sidePanel' && p !== 'offscreen');
// Firefox has no background service worker: it runs the same file as a background page.
// Firefox has no background service worker: it runs the same file as a background page.
m.background = { scripts: ['background.js'], type: 'module' };
m.background = { scripts: ['background.js'], type: 'module' };
} else throw new Error('target must be chrome or firefox');
} else throw new Error('target must be chrome or firefox');
@@ -6,20 +6,24 @@ reads, sends or stores, change this file, the README's "What it can see" table a
@@ -6,20 +6,24 @@ reads, sends or stores, change this file, the README's "What it can see" table a
## Single purpose (Chrome Web Store)
## Single purpose (Chrome Web Store)
-> Help with the page in the active tab, only when the person opens the Companion: coupon codes for the shop they are on (looked up on OpenVibe Coupons by hostname and tried in the page's coupon box on request), and one-press hand-offs of that page to OpenVibe services (Rent, Watch, Food, Reviews) in a tab the person sees.
+> Help with the page in the active tab, only when the person opens the Companion: coupon codes for the shop they are on (looked up on OpenVibe Coupons by hostname and tried in the page's coupon box on request), saved Locker passwords filled only on request into the exact saved origin, and one-press hand-offs of that page to OpenVibe services (Rent, Watch, Food, Reviews) in a tab the person sees.
## Permission justifications (Chrome Web Store)
## Permission justifications (Chrome Web Store)
| Permission | Justification |
| Permission | Justification |
|---|---|
|---|---|
-| `activeTab` | When the person opens the popup, it reads the active tab's address and title, to look up coupon codes for that site by its hostname and to fill the hand-off buttons. No access to any tab before that click, and none to other tabs. |
-| `scripting` | Four buttons inject a script packaged in the extension into the active tab's top frame, only after the press: "Try these codes on this page" runs `lib/autoapply.js` (finds the coupon box, types each code, reads whether the order total changed), "Save its rating to OpenVibe Reviews" runs `lib/rating.js` (reads the rating the page states), and "Keep on my Rent board" and "Check the rent" run `lib/listing.js` (reads the price, city and bedrooms a listing states). None makes a request or reads cookies or storage. |
-| `storage` | Keeps the optional `cpx_` install token, and the Dock's sign-in: the OAuth access token in `storage.session` (memory) and the rotating refresh token in `storage.local` (never synced). A pasted 0.7.0 `networkToken` is kept only as that fallback. |
-| `identity` | Starts the Dock's one-click sign-in (`launchWebAuthFlow`) at openvibe.network; the code returns to the browser's own extension redirect URL. No password is read by the extension. |
+| `activeTab` | When the person opens the popup, it reads the active tab's address and title, to look up coupon codes for that site by its hostname and to fill the hand-off buttons. No access to any tab before that click, and none to other tabs. Locker compares this origin exactly to decrypted saved logins, without sending that origin to Locker. |
+| `scripting` | Four buttons inject a script packaged in the extension into the active tab's top frame, only after the press: "Try these codes on this page" runs `lib/autoapply.js` (finds the coupon box, types each code, reads whether the order total changed), "Save its rating to OpenVibe Reviews" runs `lib/rating.js` (reads the rating the page states), and "Keep on my Rent board" and "Check the rent" run `lib/listing.js` (reads the price, city and bedrooms a listing states). Locker Fill / Fill and sign in also uses scripting to write visible inputs in frame 0, with an exact-origin recheck before typing. None makes a request or reads cookies or storage. |
+| `storage` | Keeps the optional `cpx_` install token, and the Dock's sign-in: the OAuth access token in `storage.session` (memory) and the rotating refresh token in `storage.local` (never synced). Unlocked Locker private keys and their expiry live only in storage.session or memory. A pasted 0.7.0 `networkToken` is kept only as that fallback. |
+| `identity` | Starts the Dock's one-click sign-in (`launchWebAuthFlow`) at openvibe.network; the code returns to the browser's own extension redirect URL. This flow never reads the Network password; the separate Locker form accepts a Locker passphrase or recovery kit locally. |
| Host permission `https://openvibe.coupons/*` | The Coupons API: look up a merchant by hostname, list its codes, and report whether a code worked. |
| Host permission `https://openvibe.coupons/*` | The Coupons API: look up a merchant by hostname, list its codes, and report whether a code worked. |
| Host permission `https://openvibe.network/*` | The Dock's state (`GET`/`PUT /api/v1/me/dock`) and its notifications, with the person's own token. |
| Host permission `https://openvibe.network/*` | The Dock's state (`GET`/`PUT /api/v1/me/dock`) and its notifications, with the person's own token. |
| Host permission `https://openvibe.chat/*` | The Dock's Chats tab: the person's conversations and DMs (`GET /api/dm/conversations`), with the same token. |
| Host permission `https://openvibe.chat/*` | The Dock's Chats tab: the person's conversations and DMs (`GET /api/dm/conversations`), with the same token. |
| Host permission `https://openvibe.codes/*` | The Dock's agent view: the person's own desk stream, its still, and the Pause/Resume, Curtain now, Mute/Unmute and End controls (`/api/v1/me/agent-view`), with the same token. |
| Host permission `https://openvibe.codes/*` | The Dock's agent view: the person's own desk stream, its still, and the Pause/Resume, Curtain now, Mute/Unmute and End controls (`/api/v1/me/agent-view`), with the same token. |
+| `alarms` | Enforces the 15-minute idle lock when the service worker is asleep. |
+| `clipboardWrite` | Copies a password or locally computed TOTP code only on request, then clears the clipboard after 30 seconds. Never reads the clipboard. |
+| `offscreen` (Chrome only) | Keeps the clipboard-clearing document alive when the popup closes. Firefox uses its background document. |
+| Host permission `https://openvibe.locker/*` | Reads wrapped keys and encrypted login items, and uploads locally sealed login items with the person's scoped token. No plaintext login or unlock secret is sent to Locker. |
| Remote code | **No.** All JavaScript is in the package; the CSP is `script-src 'self'` with no `eval`. The agent view's `https://openvibe.live/embed/agent/…` frame is the only remote frame (`frame-src`), and it is Live's own page, not script the extension runs. |
| Remote code | **No.** All JavaScript is in the package; the CSP is `script-src 'self'` with no `eval`. The agent view's `https://openvibe.live/embed/agent/…` frame is the only remote frame (`frame-src`), and it is Live's own page, not script the extension runs. |
## Data usage (Chrome Web Store "Privacy practices" tab)
## Data usage (Chrome Web Store "Privacy practices" tab)
@@ -30,7 +34,7 @@ Tick these data types, because each leaves the device in some case:
@@ -30,7 +34,7 @@ Tick these data types, because each leaves the device in some case:
|---|---|---|
|---|---|---|
| Web history | **Yes** | The active tab's **hostname** goes to openvibe.coupons when the popup opens on a site. On a hand-off press, the page's address (credentials, fragment and tracking parameters removed) and title go to openvibe.rent, openvibe.watch, openvibe.food or openvibe.reviews in the address of a tab the person sees. |
| Web history | **Yes** | The active tab's **hostname** goes to openvibe.coupons when the popup opens on a site. On a hand-off press, the page's address (credentials, fragment and tracking parameters removed) and title go to openvibe.rent, openvibe.watch, openvibe.food or openvibe.reviews in the address of a tab the person sees. |
| Website content | **Yes** | The Dock reads the person's own agent view (the task line, the worked time, the stream's state) from openvibe.codes and frames the stream from openvibe.live; it is their own content, not the page's. On "Save its rating to OpenVibe Reviews": the business name, rating, scale, count of ratings and city the page states, in the address of the openvibe.reviews tab. On "Compare delivery apps": the restaurant or store name taken from the page title, to openvibe.food. On "Keep on my Rent board" and "Check the rent": the price, its currency, the city and region (never the street) and the bedrooms the listing states, in the address of the openvibe.rent tab. |
| Website content | **Yes** | The Dock reads the person's own agent view (the task line, the worked time, the stream's state) from openvibe.codes and frames the stream from openvibe.live; it is their own content, not the page's. On "Save its rating to OpenVibe Reviews": the business name, rating, scale, count of ratings and city the page states, in the address of the openvibe.reviews tab. On "Compare delivery apps": the restaurant or store name taken from the page title, to openvibe.food. On "Keep on my Rent board" and "Check the rent": the price, its currency, the city and region (never the street) and the bedrooms the listing states, in the address of the openvibe.rent tab. |
-| Authentication information | **Yes** | Only if the person connects the extension: the `cpx_` install token they pasted is sent to openvibe.coupons as `Authorization: Bearer` on its API calls. The Dock's one-click sign-in sends an OAuth access token to openvibe.network, openvibe.chat and openvibe.codes as `Authorization: Bearer` (scopes `dock`, `activity`, `chat.read`) and keeps a refresh token on this device. |
+| Authentication information | **Yes** | Only if the person connects the extension: the `cpx_` install token they pasted is sent to openvibe.coupons as `Authorization: Bearer` on its API calls. The Dock's one-click sign-in sends an OAuth access token to openvibe.network, openvibe.chat and openvibe.codes as `Authorization: Bearer` (scopes `dock`, `activity`, `chat.read`) and keeps a refresh token on this device. The new locker.login.manage scope reaches openvibe.locker. Locker passwords, usernames, site origins and TOTP secrets are sealed locally before upload; only explicit Fill / Fill and sign in sends plaintext to the exact saved origin. Passphrases and recovery words stay in the extension. Copy sends the requested password/code to the local system clipboard, which clears after 30 seconds. |
| User activity | No | No clicks, keystrokes or scrolls are recorded. A report sends a code id, `worked`/`failed` and an optional reason the person chose, which is the person's own feedback. |
| User activity | No | No clicks, keystrokes or scrolls are recorded. A report sends a code id, `worked`/`failed` and an optional reason the person chose, which is the person's own feedback. |
| Financial and payment information | No | `lib/autoapply.js` reads the order total in the page to tell which codes lowered it; the total stays in the popup and is never sent. |
| Financial and payment information | No | `lib/autoapply.js` reads the order total in the page to tell which codes lowered it; the total stays in the popup and is never sent. |
| Personally identifiable information, health, personal communications, location | No | Not read. (The city sent with a rating is the business's, and with a listing the home's, from the page; never the person's.) |
| Personally identifiable information, health, personal communications, location | No | Not read. (The city sent with a rating is the business's, and with a listing the home's, from the page; never the person's.) |
@@ -65,7 +65,7 @@ function fetcher(responses) {
@@ -65,7 +65,7 @@ function fetcher(responses) {
assert.strictEqual(u.searchParams.get('client_id'), 'companion');
assert.strictEqual(u.searchParams.get('client_id'), 'companion');
assert.strictEqual(u.searchParams.get('redirect_uri'), CHROME);
assert.strictEqual(u.searchParams.get('redirect_uri'), CHROME);
assert.strictEqual(u.searchParams.get('response_type'), 'code');
assert.strictEqual(u.searchParams.get('response_type'), 'code');
- assert.strictEqual(u.searchParams.get('scope'), 'dock activity chat.read agent.view mail.mask.manage');
+ assert.strictEqual(u.searchParams.get('scope'), 'dock activity chat.read agent.view mail.mask.manage locker.login.manage');
assert.strictEqual(u.searchParams.get('code_challenge_method'), 'S256');
assert.strictEqual(u.searchParams.get('code_challenge_method'), 'S256');
assert.match(state, /^[A-Za-z0-9_-]{43}$/);
assert.match(state, /^[A-Za-z0-9_-]{43}$/);
issuedChallenge = u.searchParams.get('code_challenge');
issuedChallenge = u.searchParams.get('code_challenge');
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+// Optional real-browser check. PLAYWRIGHT_MODULE may point at an installed Playwright module.
+const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
+const assert = require('node:assert/strict');
+const fs = require('node:fs'), path = require('node:path'), os = require('node:os');
+const { sourceFiles, forTarget } = require('../scripts/pack');
+const root = path.join(__dirname, '..');
+const artifacts = process.env.LOCKER_SHOTS || path.join(root, 'artifacts', 'locker');
+const pause = (ms) => new Promise((r) => setTimeout(r, ms));
+(async () => {
+ fs.mkdirSync(artifacts, { recursive: true });
+ const browser = await chromium.launch({ headless: true });
+ try {
+ for (const width of [320, 390, 1280]) for (const scheme of ['light', 'dark']) {
+ const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme });
+ const errors = []; page.on('pageerror', (error) => errors.push(error.message));
+ await page.setContent('<html><head></head><body><header><h1>OpenVibe Companion</h1></header><section id="locker" class="locker"></section></body></html>');
+ await page.addStyleTag({ path: path.join(root, 'browser/popup.css') });
+ await page.addScriptTag({ path: path.join(root, 'browser/lib/locker-panel.js') });
+ await page.evaluate(() => {
+ let locked = true; window.actions = [];
+ OVLockerPanel.mount({ container: document.getElementById('locker'), platform: { tabs: { active: async () => ({ id: 7, url: 'https://example.test/signin' }) } },
+ ask: async (m) => {
+ window.actions.push(m.type);
+ if (m.type === 'locker:status') return { ok: true, locked };
+ if (m.type === 'locker:unlock') { locked = false; return { ok: true }; }
+ if (m.type === 'locker:lock') { locked = true; return { ok: true }; }
+ if (m.type === 'locker:list') return { ok: true, items: [{ id: 'fixture', username: '[email protected]', hasTotp: true }] };
+ return { ok: true };
+ } });
+ });
+ await page.getByRole('button', { name: 'Unlock Locker', exact: true }).waitFor();
+ await page.screenshot({ path: path.join(artifacts, `locked-${width}-${scheme}.png`), fullPage: true });
+ await page.locator('[name=secret]').fill('fixture passphrase');
+ await page.getByRole('button', { name: 'Unlock Locker', exact: true }).click();
+ await page.getByRole('button', { name: 'Fill', exact: true }).waitFor();
+ assert.deepEqual(await page.evaluate(() => actions), ['locker:status', 'locker:unlock', 'locker:list']);
+ await page.getByRole('button', { name: 'Fill', exact: true }).click();
+ await page.getByRole('button', { name: 'Fill and sign in', exact: true }).click();
+ await page.getByText('Save this login', { exact: true }).click();
+ await page.locator('[name=username]').fill('[email protected]'); await page.locator('[name=password]').fill('fixture password');
+ await page.screenshot({ path: path.join(artifacts, `unlocked-${width}-${scheme}.png`), fullPage: true });
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth), width);
+ assert.equal(await page.locator('#locker button').evaluateAll((nodes) => nodes.every((n) => n.getBoundingClientRect().height >= 44)), true);
+ await page.getByRole('button', { name: 'Save encrypted login', exact: true }).click();
+ await page.getByRole('button', { name: 'Lock now', exact: true }).click();
+ assert.equal(await page.locator('[name=password]').count(), 0);
+ assert.deepEqual(errors, []); await page.close();
+ }
+ } finally { await browser.close(); }
+ console.log('Popup: 12 screenshots, 320/390/1280 px, both themes, no overflow or console errors, 44 px controls; trusted clicks exercised.');
+
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'companion-locker-browser-'));
+ const files = sourceFiles('https://openvibe.coupons'); forTarget(files, 'chrome');
+ for (const [name, data] of files) { fs.mkdirSync(path.dirname(path.join(tmp, name)), { recursive: true }); fs.writeFileSync(path.join(tmp, name), data); }
+ const context = await chromium.launchPersistentContext('', { headless: true, channel: 'chromium', args: [`--disable-extensions-except=${tmp}`, `--load-extension=${tmp}`] });
+ try {
+ const worker = context.serviceWorkers()[0] || await context.waitForEvent('serviceworker');
+ await context.route('https://example.test/**', (route) => route.fulfill({ contentType: 'text/html', body: '<html><body>Clipboard test</body></html>' }));
+ const page = await context.newPage(); await page.goto('https://example.test');
+ await context.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: 'https://example.test' });
+ await worker.evaluate(async () => { await OVPlatform.createPlatform(chrome, OVConfig).clipboard.write('clipboard test fixture'); });
+ assert.equal(await page.evaluate(() => navigator.clipboard.readText()), 'clipboard test fixture');
+ console.log('Packaged worker/offscreen copied successfully; checking 30-second clearing with no popup open.');
+ await pause(31000);
+ assert.equal(await page.evaluate(() => navigator.clipboard.readText()), '');
+ assert.equal(await worker.evaluate(async () => (await chrome.storage.local.get('lockerUnlock')).lockerUnlock), undefined);
+ console.log('Real clipboard cleared after 30 seconds with no popup open.');
+ } finally { await context.close(); fs.rmSync(tmp, { recursive: true, force: true }); }
+})().catch((error) => { console.error(error); process.exitCode = 1; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,156 @@
@@ -0,0 +1,156 @@
+'use strict';
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const vm = require('node:vm');
+const fs = require('node:fs');
+require('../browser/shared/ov-locker-core');
+const core = globalThis.OVLockerCore;
+const payload = require('../browser/lib/locker-payload');
+const passwords = require('../browser/lib/locker-passwords');
+const { create, KEY, IDLE } = require('../browser/lib/locker-vault');
+const { fill } = require('../browser/lib/locker-fill');
+const SUBJECT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', POPUP = 'chrome-extension://test/popup.html';
+
+async function fixture() {
+ let clock = 1000, active = { id: 1, url: 'https://example.test/login' }, account = 'owner';
+ const memory = new Map(), ciphertexts = new Map(), writes = [], fills = [], clipboard = [];
+ const person = await core.generatePersonKeys(); const priv = await core.exportPrivateKeys(person);
+ const pass = 'test passphrase used only by fixtures';
+ const recovery = 'test recovery words';
+ const sealBackup = async (password) => Buffer.from((await core.seal(JSON.stringify(priv), { lock: 'password', password })).bytes).toString('base64');
+ const backups = { passphrase: { envelope: await sealBackup(pass) }, recovery: { envelope: await sealBackup(recovery) } };
+ const api = {
+ keys: async () => ({ subject: SUBJECT, backups }), list: async () => [...ciphertexts.keys()].map((id) => ({ id })),
+ content: async (id) => ciphertexts.get(id), save: async (bytes) => { ciphertexts.set(String(ciphertexts.size + 1), bytes); return { id: '1' }; },
+ };
+ const storage = { getSession: async (k) => memory.get(k), setSession: async (obj) => { writes.push(obj); for (const [k, v] of Object.entries(obj)) memory.set(k, v); }, removeSession: async (keys) => keys.forEach((k) => memory.delete(k)) };
+ const options = { core, payload, passwords, api, storage, scheduleLock: async () => {}, cancelLock: async () => {}, tabs: { active: async () => active }, fill: async (...args) => { fills.push(args); return { ok: true }; }, clipboard: { write: async (v) => clipboard.push(v) }, account: async () => account, now: () => clock };
+ const vault = create(options);
+ const ask = (type, values = {}, sender = { url: POPUP }) => vault.handle({ type: 'locker:' + type, tabId: 1, site: 'https://example.test', ...values }, sender, POPUP);
+ return { vault, ask, memory, fills, clipboard, api, options, writes, pass, recovery, ciphertexts,
+ advance: (ms) => { clock += ms; }, active: (tab) => { active = tab; }, account: (id) => { account = id; } };
+}
+
+test('passphrase/recovery unlock, sealed save/list, local TOTP and explicit-click fill/copy only', async () => {
+ const f = await fixture();
+ assert.deepEqual(await f.ask('unlock', { secret: f.pass }), { ok: false });
+ assert.deepEqual(await f.ask('unlock', { clicked: true, secret: f.pass }, { url: 'https://example.test' }), { ok: false });
+ await f.ask('unlock', { clicked: true, secret: f.pass });
+ assert.equal((await f.ask('status')).locked, false);
+ await f.ask('save', { clicked: true, username: 'alice', password: 'fixture password', totp: 'otpauth://totp/x?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ' });
+ assert.ok(!Buffer.from(f.ciphertexts.get('1')).includes(Buffer.from('fixture password')));
+ assert.deepEqual((await f.ask('list')).items, [{ id: '1', username: 'alice', hasTotp: true }]);
+ assert.equal(f.fills.length, 0, 'unlock and list never fill');
+ assert.deepEqual(await f.ask('fill', { id: '1' }), { ok: false });
+ assert.deepEqual(await f.ask('fill', { id: '1', clicked: true }, { url: POPUP, tab: { id: 1 } }), { ok: false });
+ await f.ask('fill', { id: '1', clicked: true }); assert.equal(f.fills[0][2], false);
+ await f.ask('submit', { id: '1', clicked: true }); assert.equal(f.fills[1][2], true);
+ await f.ask('password', { id: '1', clicked: true }); assert.equal(f.clipboard[0], 'fixture password');
+ await f.ask('totp', { id: '1', clicked: true }); assert.match(f.clipboard[1], /^\d{6}$/);
+ assert.ok(!JSON.stringify(f.writes).includes('fixture password'), 'passwords never go to storage');
+ await f.ask('lock', { clicked: true }); assert.equal(f.memory.has(KEY), false);
+ await assert.rejects(f.ask('unlock', { clicked: true, secret: 'wrong' }), /could not unlock/);
+ await f.ask('unlock', { clicked: true, mode: 'recovery', secret: ' TEST recovery WORDS ' });
+ assert.equal((await f.ask('status')).locked, false);
+});
+
+test('idle expiry survives worker restart; polling does not keep it unlocked; browser restart and account change lock', async () => {
+ const f = await fixture(); await f.ask('unlock', { clicked: true, secret: f.pass });
+ f.advance(IDLE - 1); assert.equal((await f.ask('status')).locked, false);
+ const restarted = create(f.options); f.advance(1); assert.equal(await restarted.state(), null); assert.equal(f.memory.has(KEY), false);
+ await f.ask('unlock', { clicked: true, secret: f.pass });
+ f.advance(IDLE - 1); await f.ask('activity', { clicked: true }); f.advance(IDLE - 1);
+ assert.equal((await f.ask('status')).locked, false, 'trusted popup activity extends the deadline');
+ f.memory.clear(); assert.equal((await f.ask('status')).locked, true, 'storage.session clears at browser restart');
+ await f.ask('unlock', { clicked: true, secret: f.pass }); f.account('other'); assert.equal((await f.ask('status')).locked, true);
+});
+
+test('saved origin never follows subdomains, protocol changes, another active tab or navigation', async () => {
+ const f = await fixture(); await f.ask('unlock', { clicked: true, secret: f.pass });
+ await f.ask('save', { clicked: true, username: 'alice', password: 'fixture password' });
+ for (const site of ['https://sub.example.test', 'http://example.test', 'https://example.test:444']) {
+ f.active({ id: 1, url: site });
+ await assert.rejects(f.ask('fill', { id: '1', clicked: true }), /active tab changed/);
+ await assert.rejects(f.ask('fill', { id: '1', site, clicked: true }), /different origin/);
+ }
+ f.active({ id: 2, url: 'https://example.test' }); await assert.rejects(f.ask('fill', { id: '1', clicked: true }), /active tab changed/);
+ assert.equal(f.fills.length, 0);
+});
+
+test('locking during a pending decrypt cancels the fill', async () => {
+ const f = await fixture(); await f.ask('unlock', { clicked: true, secret: f.pass });
+ await f.ask('save', { clicked: true, username: 'alice', password: 'fixture password' });
+ let release, entered;
+ const started = new Promise((r) => { entered = r; });
+ f.api.content = () => { entered(); return new Promise((r) => { release = r; }); };
+ const pending = f.ask('fill', { id: '1', clicked: true }); await started; await f.vault.lock(); release(f.ciphertexts.get('1'));
+ await assert.rejects(pending, /locked/); assert.equal(f.fills.length, 0);
+});
+
+function page({ site = 'https://example.test', frame = false, clicked = true, submit = false, hidden = false, transparentParent = false, navigate = false } = {}) {
+ const calls = []; const form = { querySelectorAll: () => [username, password] };
+ class Input { set value(v) { calls.push(v); } }
+ class Form { requestSubmit() { calls.push('submit'); } }
+ const input = (type, name) => Object.assign(new Input(), { type, name, id: name, autocomplete: name, form, parentElement: transparentParent ? { transparent: true } : null, isConnected: true, getClientRects: () => hidden ? [] : [{ width: 160, height: 40 }], closest: () => null, dispatchEvent: () => { if (navigate) ctx.location.origin = 'https://evil.test'; } });
+ const username = input('text', 'username'), password = input('password', 'password');
+ const win = {}; win.top = frame ? {} : win;
+ const ctx = { window: win, location: { origin: site }, document: { querySelectorAll: () => [password] }, HTMLInputElement: Input, HTMLFormElement: Form, Event: class {}, getComputedStyle: (el) => ({ display: 'block', visibility: 'visible', opacity: el.transparent ? '0' : '1' }) };
+ const result = vm.runInNewContext('(' + fill.toString() + ')(login, submit, clicked)', { ...ctx, login: { site: 'https://example.test', username: 'alice', password: 'fixture password' }, submit, clicked });
+ return { result, calls };
+}
+test('injected function rechecks origin and top frame before typing and submits only on explicit request', () => {
+ assert.deepEqual(page().calls, ['alice', 'fixture password']); assert.deepEqual(page({ submit: true }).calls, ['alice', 'fixture password', 'submit']);
+ for (const opts of [{ site: 'https://sub.example.test' }, { site: 'http://example.test' }, { frame: true }, { clicked: false }, { hidden: true }, { transparentParent: true }]) assert.deepEqual(page(opts).calls, []);
+ assert.deepEqual(page({ navigate: true }).calls, ['alice'], 'navigation from a username handler prevents password typing');
+});
+
+test('clipboard clear is scheduled outside popup, replacement resets the deadline, and TOTP matches RFC vector', async () => {
+ const { create } = require('../browser/lib/locker-clipboard'); let callback, delay, cancelled = 0; const writes = [];
+ const c = create({ write: async (v) => writes.push(v), later: (cb, ms) => { callback = cb; delay = ms; return 1; }, cancel: () => { cancelled++; } });
+ await c.write('fixture password'); assert.equal(delay, 30000); await c.write('123456'); assert.equal(cancelled, 2);
+ callback(); await c.clear(); assert.equal(writes.at(-1), '');
+ assert.equal(await passwords.totp('otpauth://totp/x?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ', 59000), '287082');
+ const bg = fs.readFileSync('browser/background.js', 'utf8'); assert.match(bg, /onStartup.addListener\(\(\) => \{ vault.lock\(\)/);
+});
+
+
+test('platform injection targets only the active top frame in the isolated world', async () => {
+ const { createPlatform } = require('../browser/lib/platform');
+ const calls = []; globalThis.self = { OVLockerFill: { fill } };
+ try {
+ const p = createPlatform({ scripting: { executeScript: async (arg) => { calls.push(arg); return [{ result: { ok: true } }]; } } }, {});
+ await p.scripting.fillLogin(42, { site: 'https://example.test', username: 'alice', password: 'fixture' }, false);
+ assert.deepEqual(calls[0].target, { tabId: 42, allFrames: false });
+ assert.equal(calls[0].world, 'ISOLATED'); assert.equal(calls[0].func, fill); assert.equal(calls[0].args[2], true);
+ } finally { delete globalThis.self; }
+});
+
+test('Locker HTTP client only sends the token and sealed bytes; failure bodies are not reflected', async () => {
+ const { create } = require('../browser/lib/locker-api'); const requests = [];
+ const client = create({ origin: 'https://openvibe.locker', getToken: async () => 'fixture-token', fetch: async (url, options) => {
+ requests.push({ url, options }); return new Response(JSON.stringify({ id: 'fixture' }), { status: 201 });
+ } });
+ const sealed = (await core.seal('fixture password', { lock: 'password', password: 'fixture passphrase' })).bytes;
+ await client.save(sealed);
+ assert.equal(requests[0].url, 'https://openvibe.locker/api/v1/companion/logins');
+ assert.equal(requests[0].options.credentials, 'omit'); assert.equal(requests[0].options.redirect, 'error');
+ const wire = Buffer.from(await requests[0].options.body.arrayBuffer());
+ assert.deepEqual(wire, Buffer.from(sealed)); assert.ok(!wire.includes(Buffer.from('fixture password')));
+ const bad = create({ origin: 'https://openvibe.locker', getToken: async () => 'fixture-token', fetch: async () => new Response('sensitive remote error', { status: 500 }) });
+ await assert.rejects(bad.keys(), (e) => !e.message.includes('sensitive') && /Locker/.test(e.message));
+});
+
+
+test('Firefox clipboard alarm survives event-page unloading without persisting plaintext', async () => {
+ const { createPlatform } = require('../browser/lib/platform'); const writes = [], alarms = [];
+ const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
+ Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { clipboard: { writeText: async (v) => writes.push(v) } } });
+ globalThis.self = { OVLockerClipboard: { create: ({ write }) => ({ write, clear: () => write('') }) } };
+ try {
+ const ext = { alarms: { create: async (name, info) => alarms.push({ name, ...info }) } };
+ const before = Date.now(); await createPlatform(ext, {}).clipboard.write('fixture');
+ assert.equal(alarms[0].name, 'locker-clipboard-clear'); assert.ok(alarms[0].when >= before + 30000);
+ // A fresh platform has no timer/value in memory. The alarm can still clear it.
+ await createPlatform(ext, {}).clipboard.clear(); assert.deepEqual(writes, ['fixture', '']);
+ } finally { delete globalThis.self; Object.defineProperty(globalThis, 'navigator', originalNavigator); }
+});
@@ -11,7 +11,7 @@ const { check, done } = require('./helpers/harness');
@@ -11,7 +11,7 @@ const { check, done } = require('./helpers/harness');
const DIR = path.join(__dirname, '..', 'browser');
const DIR = path.join(__dirname, '..', 'browser');
const ORIGIN = 'https://openvibe.coupons';
const ORIGIN = 'https://openvibe.coupons';
-const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat', 'https://openvibe.codes'];
+const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat', 'https://openvibe.codes', 'https://openvibe.locker'];
(async () => {
(async () => {
const { manifest, origin, problems } = auditDir(DIR);
const { manifest, origin, problems } = auditDir(DIR);
@@ -19,8 +19,8 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
@@ -19,8 +19,8 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
await check('the shipped manifest passes the audit: activeTab + identity + scripting + storage + sidePanel, four OpenVibe host permissions, MV3', async () => {
await check('the shipped manifest passes the audit: activeTab + identity + scripting + storage + sidePanel, four OpenVibe host permissions, MV3', async () => {
assert.deepStrictEqual(problems, []);
assert.deepStrictEqual(problems, []);
assert.strictEqual(origin, ORIGIN);
assert.strictEqual(origin, ORIGIN);
- assert.deepStrictEqual(manifest.permissions.slice().sort(), ['activeTab', 'identity', 'scripting', 'storage']);
- assert.deepStrictEqual(manifest.host_permissions, ['https://openvibe.coupons/*', 'https://openvibe.network/*', 'https://openvibe.codes/*', 'https://openvibe.chat/*']);
+ assert.deepStrictEqual(manifest.permissions.slice().sort(), ['activeTab', 'alarms', 'clipboardWrite', 'identity', 'offscreen', 'scripting', 'storage']);
+ assert.deepStrictEqual(manifest.host_permissions, ['https://openvibe.coupons/*', 'https://openvibe.network/*', 'https://openvibe.codes/*', 'https://openvibe.chat/*', 'https://openvibe.locker/*']);
assert.strictEqual(manifest.sidebar_action.default_panel, 'panel.html', 'the Dock lives in the side panel');
assert.strictEqual(manifest.sidebar_action.default_panel, 'panel.html', 'the Dock lives in the side panel');
for (const k of ['content_scripts', 'web_accessible_resources', 'externally_connectable', 'optional_permissions', 'optional_host_permissions']) {
for (const k of ['content_scripts', 'web_accessible_resources', 'externally_connectable', 'optional_permissions', 'optional_host_permissions']) {
assert.ok(!(k in manifest), `${k} must not be present`);
assert.ok(!(k in manifest), `${k} must not be present`);
@@ -95,7 +95,7 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
@@ -95,7 +95,7 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
const csp = manifest.content_security_policy.extension_pages;
const csp = manifest.content_security_policy.extension_pages;
// OpenVibe.Mail is in the CSP's connect-src (the Companion reaches the masks with CORS,
// OpenVibe.Mail is in the CSP's connect-src (the Companion reaches the masks with CORS,
// the way the Coupons API lets a first-party extension in) but it is not a host permission.
// the way the Coupons API lets a first-party extension in) but it is not a host permission.
- assert.match(csp, /connect-src https:\/\/openvibe\.coupons https:\/\/openvibe\.network https:\/\/openvibe\.chat https:\/\/openvibe\.codes https:\/\/openvibe\.email;/);
+ assert.match(csp, /connect-src https:\/\/openvibe\.coupons https:\/\/openvibe\.network https:\/\/openvibe\.chat https:\/\/openvibe\.codes https:\/\/openvibe\.email https:\/\/openvibe\.locker;/);
assert.match(csp, /style-src 'self' 'unsafe-inline';/);
assert.match(csp, /style-src 'self' 'unsafe-inline';/);
assert.match(csp, /img-src 'self' blob:;/);
assert.match(csp, /img-src 'self' blob:;/);
assert.match(csp, /frame-src https:\/\/openvibe\.live;/);
assert.match(csp, /frame-src https:\/\/openvibe\.live;/);
@@ -124,7 +124,7 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
@@ -124,7 +124,7 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
const src = fs.readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"])\/\/.*$/gm, '$1');
const src = fs.readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"])\/\/.*$/gm, '$1');
for (const re of forbidden) assert.doesNotMatch(src, re, `${path.relative(DIR, f)} uses ${re}`);
for (const re of forbidden) assert.doesNotMatch(src, re, `${path.relative(DIR, f)} uses ${re}`);
const rel0 = path.relative(DIR, f);
const rel0 = path.relative(DIR, f);
- if (![path.join('background.js'), path.join('lib', 'mask-fill.js'), path.join('popup.js')].includes(rel0)) {
+ if (![path.join('background.js'), path.join('lib', 'mask-fill.js'), path.join('popup.js'), path.join('lib', 'platform.js')].includes(rel0)) {
assert.doesNotMatch(src, /sendMessage/, `${rel0} messages the extension`);
assert.doesNotMatch(src, /sendMessage/, `${rel0} messages the extension`);
}
}
const rel = path.relative(DIR, f);
const rel = path.relative(DIR, f);
@@ -139,7 +139,7 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
@@ -139,7 +139,7 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
assert.deepStrictEqual(injects, [path.join('lib', 'platform.js')], 'only the platform wrapper runs a script in a page');
assert.deepStrictEqual(injects, [path.join('lib', 'platform.js')], 'only the platform wrapper runs a script in a page');
const plat = fs.readFileSync(path.join(DIR, 'lib', 'platform.js'), 'utf8');
const plat = fs.readFileSync(path.join(DIR, 'lib', 'platform.js'), 'utf8');
assert.deepStrictEqual([...plat.matchAll(/files:\s*\[([^\]]*)\]/g)].map((m) => m[1].trim()), ["'lib/autoapply.js'", "'lib/rating.js'", "'lib/mask.js', 'lib/mask-fill.js'", "'lib/listing.js'"], 'the files it injects');
assert.deepStrictEqual([...plat.matchAll(/files:\s*\[([^\]]*)\]/g)].map((m) => m[1].trim()), ["'lib/autoapply.js'", "'lib/rating.js'", "'lib/mask.js', 'lib/mask-fill.js'", "'lib/listing.js'"], 'the files it injects');
- assert.doesNotMatch(plat, /allFrames|world:\s*['"]MAIN|tabId:(?!\s*tabId\b)/, 'the active tab only, its top frame, the isolated world');
+ assert.doesNotMatch(plat, /allFrames:\s*true|world:\s*['"]MAIN|tabId:(?!\s*tabId\b)/, 'the active tab only, its top frame, the isolated world');
// What runs in the page makes no request and reads no stored data.
// What runs in the page makes no request and reads no stored data.
for (const file of ['autoapply.js', 'rating.js', 'listing.js']) {
for (const file of ['autoapply.js', 'rating.js', 'listing.js']) {
const src = fs.readFileSync(path.join(DIR, 'lib', file), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
const src = fs.readFileSync(path.join(DIR, 'lib', file), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
@@ -47,7 +47,7 @@ function unzip(buf) {
@@ -47,7 +47,7 @@ function unzip(buf) {
assert.ok(files.has('manifest.json') && files.has('popup.html') && files.has('lib/api.js') && files.has('icons/icon-128.png'));
assert.ok(files.has('manifest.json') && files.has('popup.html') && files.has('lib/api.js') && files.has('icons/icon-128.png'));
// OpenVibe.Mail is in the CSP's connect-src (the Companion reaches the masks with CORS) but it is
// OpenVibe.Mail is in the CSP's connect-src (the Companion reaches the masks with CORS) but it is
// not one of the four host permissions.
// not one of the four host permissions.
- assert.deepStrictEqual(auditManifest(JSON.parse(files.get('manifest.json')), { apiOrigin: 'https://openvibe.coupons', origins: ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat', 'https://openvibe.codes'], connectOrigins: ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat', 'https://openvibe.codes', 'https://openvibe.email'] }), []);
+ assert.deepStrictEqual(auditManifest(JSON.parse(files.get('manifest.json')), { apiOrigin: 'https://openvibe.coupons', origins: ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat', 'https://openvibe.codes', 'https://openvibe.locker'], connectOrigins: ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat', 'https://openvibe.codes', 'https://openvibe.email', 'https://openvibe.locker'] }), []);
});
});
await check('the build is reproducible', async () => {
await check('the build is reproducible', async () => {
@@ -61,8 +61,8 @@ function unzip(buf) {
@@ -61,8 +61,8 @@ function unzip(buf) {
assert.match(r.out, /-dev\.zip$/);
assert.match(r.out, /-dev\.zip$/);
const files = unzip(fs.readFileSync(r.out));
const files = unzip(fs.readFileSync(r.out));
const m = JSON.parse(files.get('manifest.json'));
const m = JSON.parse(files.get('manifest.json'));
- assert.deepStrictEqual(m.host_permissions, ['http://localhost:4850/*', 'https://openvibe.network/*', 'https://openvibe.codes/*', 'https://openvibe.chat/*']);
- assert.match(m.content_security_policy.extension_pages, /connect-src http:\/\/localhost:4850 https:\/\/openvibe\.network https:\/\/openvibe\.chat https:\/\/openvibe\.codes https:\/\/openvibe\.email;/);
+ assert.deepStrictEqual(m.host_permissions, ['http://localhost:4850/*', 'https://openvibe.network/*', 'https://openvibe.codes/*', 'https://openvibe.chat/*', 'https://openvibe.locker/*']);
+ assert.match(m.content_security_policy.extension_pages, /connect-src http:\/\/localhost:4850 https:\/\/openvibe\.network https:\/\/openvibe\.chat https:\/\/openvibe\.codes https:\/\/openvibe\.email https:\/\/openvibe\.locker;/);
assert.match(m.name, /development/);
assert.match(m.name, /development/);
assert.match(files.get('lib/config.js').toString(), /API_ORIGIN: 'http:\/\/localhost:4850'/);
assert.match(files.get('lib/config.js').toString(), /API_ORIGIN: 'http:\/\/localhost:4850'/);
assert.doesNotMatch(files.get('manifest.json').toString() + files.get('lib/config.js').toString(), /openvibe\.coupons\//);
assert.doesNotMatch(files.get('manifest.json').toString() + files.get('lib/config.js').toString(), /openvibe\.coupons\//);
@@ -85,7 +85,7 @@ function unzip(buf) {
@@ -85,7 +85,7 @@ function unzip(buf) {
} else {
} else {
delete want.side_panel;
delete want.side_panel;
delete want.key;
delete want.key;
- want.permissions = want.permissions.filter((p) => p !== 'sidePanel');
+ want.permissions = want.permissions.filter((p) => p !== 'sidePanel' && p !== 'offscreen');
// Firefox has no background service worker: it runs the same file as a background page.
// Firefox has no background service worker: it runs the same file as a background page.
want.background = { scripts: ['background.js'], type: 'module' };
want.background = { scripts: ['background.js'], type: 'module' };
}
}