openvibers/openvibe.shared Commits
4.116.0: the Share sheet's Locked part (people, password and link locks) (#299)
Changes
@@@ -1,5 +1,27 @@# Changelog++## 4.116.0 — 2026-11-06++++- **The Share sheet's Locked part** (`ov-share.js`, `ov-share.css`; Locker design 2026-11-06, the pilot; docs/share.md).++ A locked item (`lock: people | password | link` in the share API's answer) gets its own part between Send it and Who++ can open it:++ - the lock in plain words, and a link lock says when the link in front of it is missing its `#k=` key;++ - a people lock shows each person's eight-word fingerprint, warns when their key changed since the slot was made,++ and offers **Use their new key**;++ - adding someone asks Network for their public key and wraps their content key on this device (the member row and++ the slot are saved together); someone without a locker is told so, not added;++ - removing someone seals the content again under a fresh key for those who stay, uploads the new envelope with the++ members, and says "They had the key before you removed them: they may still have a copy of what they already++ opened."++ - `ov-locker.js` loads on the first press of the part, never with the sheet.++- **`ov-locker.js`** gains the calls the sheet needs, all keeping the keys in the tab: `ensure()`, `keys(who)` (Network's++ key directory), `publicKeys()`, `rewrap(bytes, { add, remove, keys })` and `inspect(bytes)` (docs/locker.md).++- **`ov-locker-core.js`** regenerated from openvibe-sdk 0.89.0 (`seal.js` and `server.js` moved since 0.86.0).++ openvibe-sdk devDependency 0.86.0 → 0.89.0.++- test/ov-share-lock-chrome.test.js: the Locked part with the real crypto — lazy load, fingerprints, adding wraps for++ them (and the envelope that goes up opens for them), removing re-seals (and the removed person's key opens nothing),++ and a link lock keeps its key after `#`.++## 4.115.1 — 2026-11-06- **Share sheet:** its headings no longer take a site's own `h2` rule (Wiki's bottom border and margin showed under the sheet's title). Every property a site might set on an `h2` is reset inside the sheet and the access gate.@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.115.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.116.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -1086,5 +1086,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [<!-- versions:start -->- openvibe-contracts: v0.289.1--- openvibe-sdk: v0.86.0++- openvibe-sdk: v0.89.0<!-- versions:end -->@@@ -4,12 +4,12 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.115.1",++ "version": "4.116.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)","contracts": "openvibe-contracts v0.289.1 (devDependency: the release manifest contract)",-- "sdk": "openvibe-sdk v0.86.0",++ "sdk": "openvibe-sdk v0.89.0","features": ["the OpenVibe Frame: navbar, footer, panels, account switcher, notification UI with realtime","the launcher beside the brand lists every site of the network, in groups; Ask Actor and the Vibes chip in the account menu",@@@ -42,6 +42,17 @@ OVLocker.unlock({ onDone }); // unlock it oOVLocker.state(); // { unlocked, fingerprint, subject }OVLocker.forget(); // drop the unlocked keysOVLocker.keyFromLink(); // the 32-byte key after #k=, or null++OVLocker.ensure(); // resolves once unlocked here, or opens the unlock sheet; rejects when declined++OVLocker.keys(subject|'@name'); // Network's public keys for one person, or null when they have no locker++OVLocker.publicKeys(); // my public keys (for a people lock that wraps for me too)++OVLocker.rewrap(bytes, { add, remove, keys });++ // a people-locked envelope re-wrapped here: add [{ subject, publicKey }] wraps the++ // content key for someone; remove [subject] seals the content under a fresh key for++ // `keys` (the public keys of those who stay). → { bytes, header }++OVLocker.seal(content, { lock, keys, password });++ // seal content here (a new locked item): the key is made and kept in this tab; a link++ // lock comes back with linkKey, the 32 bytes that go in the link after #k=++OVLocker.inspect(bytes); // the envelope's header, without a keywindow.OpenVibeLockerConfig = { network: 'https://openvibe.network' };```@@@ -13,6 +13,7 @@ computer. It has two parts and no tabs:|---|---|---|| **Send it** | everyone | the link and **Copy**; on a phone, **Send with your phone** (the phone's own share sheet: contacts, messages, every app); X, Bluesky, WhatsApp, Reddit, Discord, Telegram, Threads, Mastodon, Facebook, Email, Messages (phones), LinkedIn, Pinterest (with a picture), Tumblr, Hacker News, LINE; **QR code**; **Embed**; **Start at 1:23** for a video or a recording || **Who can open it** | the owner (and staff) of a thing with a share API | Everyone · Anyone with the link · Specific people · Only me; people by @name, each Can view or Can edit, with an end date; people who asked; **Transfer** |++| **Locked** | the owner of a locked item (one sealed with [Locker](locker.md)) | what the lock means in plain words; each person's eight-word fingerprint; add someone (their key wrapped on this device) or remove them (the content sealed again) |The first eight places show at once, with the person's three most recent first. The rest are behind a counted fold(**More (n)**), never hidden.@@@ -62,6 +63,31 @@ OVShare.fromButton(button);A site's analytics may count these.- `ov-share:change` `{ kind, id, state }` after a save, a transfer or an answer.++## Locked items (Locker)++++A locked item carries the lock's kind in the share API's answer (`lock: people | password | link`) and the sheet draws a++**Locked** part between the two above. It opens on the first press, and only then does the sheet load `ov-locker.js`: a++page that never touches a locked item never loads the crypto.++++- **For the people on the list** — the sheet shows each person's eight-word fingerprint (the one Network publishes with++ their keys). It warns when their key changed since the slot was made, with **Use their new key**; nothing is wrapped++ for a key the person has not seen.++ - Adding someone asks Network for their public key first. Their content key is wrapped for it on this device, and the++ member row and the slot are saved together. Someone without a locker cannot be added, and the sheet says so in++ words.++ - Removing someone seals the content again under a fresh key for those who stay, then uploads the new envelope. The++ sheet says: "They had the key before you removed them: they may still have a copy of what they already opened."++- **With a link** — the key is the part of the link after `#`. The page's address has it, so **Copy** and every place++ the sheet sends keep the whole link; the sheet never invents a key, and says plainly when the link in front of it is++ missing one.++- **With a password** — nothing is stored: the password opens the envelope on the reader's device. Lose it and nobody++ can open the item, including OpenVibe (the sheet says this at the moment it matters).++- The envelope's URL comes from the service's `describe(ref)` as `envelope_url`; the sheet fetches it, re-wraps or++ re-seals it with `OVLocker.rewrap()`, and sends the base64 in the save's `envelope` field. The content key and the++ plaintext never leave the browser.++- The service must register the lock when the item is made (`register(ref, { lock })`), report it in the GET, store a++ re-seal through `onReseal(ref, bytes, tx)`, and keep the item out of public lists: a locked item can never be public.++## What it promises- **The service decides.** The sheet only asks. Who may open a thing is enforced by the service that holds it@@@ -491,7 +491,7 @@const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)// END generated// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '715972edd968', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': 'd5bbb14b108e', 'ov-share.css': 'f694a088190f' };++ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '715972edd968', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': 'c70f014c25de', 'ov-share.css': 'cbd18e6ce991' };// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.@@@ -1,4 +1,4 @@--/* Generated by scripts/build-locker-core.js from openvibe-sdk 0.86.0 (src/locker). Do not edit: change the SDK,++/* Generated by scripts/build-locker-core.js from openvibe-sdk 0.89.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@@@ -8,8 +8,8 @@* keys.js 18663474b10352ed* padme.js e0dd4b08e0d0f24f* recovery.js 2829d07a02561674-- * seal.js b70d01522636486d-- * server.js 44a92f3cbb3e6bc1++ * seal.js ff6ad448082c636c++ * server.js 56414198b01fbecd* words.js 8f4f51abdadfbfd1*/(function (root) {@@@ -1103,6 +1103,15 @@ function readCore(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.@@@ -1625,7 +1634,7 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {};}--__ovLockerModule.exports = { inspectEnvelope, checkHeader };++__ovLockerModule.exports = { inspectEnvelope, checkHeader, peopleSlotOk };},"words": function (__ovLockerModule, exports, __ovLockerReq) {@@@ -1686,5 +1695,5 @@ __ovLockerModule.exports = { WORDS };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.86.0" });++ root.OVLockerCore = Object.assign({}, req('index'), { SDK_VERSION: "0.89.0" });})(typeof globalThis !== 'undefined' ? globalThis : this);@@@ -10,6 +10,12 @@* OVLocker.state() { unlocked, fingerprint, subject }* OVLocker.forget() drop the unlocked keys from this tab* OVLocker.keyFromLink(href?) the content key a link carries after #k= (link locks)++ * OVLocker.ensure() resolves once the locker is unlocked here (the unlock sheet when it is not)++ * OVLocker.keys(who) a person's public keys from Network: null when they have no locker yet++ * OVLocker.publicKeys() my public keys, for locking something for myself as well as for others++ * OVLocker.rewrap(bytes, { add, remove, keys }) a people-locked envelope re-wrapped, the content never leaving++ * OVLocker.seal(content, { lock, keys, password }) seal content here: the lock is made in this tab++ * OVLocker.inspect(bytes) the header of an envelope, without a key* window.OpenVibeLockerConfig = { network: 'https://openvibe.network' } where the key directory is** The cryptography is ov-locker-core.js beside this file (generated from openvibe-sdk/locker: WebCrypto only, the@@@ -372,6 +378,55 @@return s.finished;}++ // ── The locker as a tool: a public key, a lookup, and a re-wrap ─────────────────────────────────────────++ /** The locker, unlocked on this device: unlocks it (the sheet) when it is not, and says so when that is declined. */++ async function ensure() {++ if (session) return state();++ const st = await unlock();++ if (!st || !st.unlocked) throw Object.assign(new Error('Unlock your locker to do this.'), { code: 'locker.locked' });++ return st;++ }++ /** My public keys, as they are published: what a people lock wraps for me too. */++ async function publicKeys() {++ if (!session) return null;++ const c = await core();++ return await c.exportPersonKeys(session.person);++ }++ /**++ * A people-locked envelope, its key slots wrapped again: `add` [{ subject, publicKey }] keeps the content and++ * the chunks and adds their slots; `remove` [subject] seals the content under a fresh key for `keys` (the public++ * keys of those who stay), so the person who left opens nothing. The content key and the plaintext stay here.++ */++ async function rewrap(bytes, { add = [], remove = [], keys = [] } = {}) {++ await ensure();++ const c = await core();++ const out = await c.rewrap(bytes, { add, remove, keys, credentials: { person: session.person } });++ return { bytes: out.bytes, header: out.header };++ }++ /** A person's public keys from Network's directory (by subject or by @name): null when they have no locker yet. */++ async function keys(who) {++ const id = who && typeof who === 'object' ? (who.subject && who.subject.id) || who.id : null;++ const path = id++ ? `/api/v1/people/${encodeURIComponent(id)}/keys`++ : `/api/v1/people/@${encodeURIComponent(String(who || '').replace(/^@+/, ''))}/keys`;++ try { return await network('GET', path); } catch (e) { if (e.status === 404) return null; throw e; }++ }++ /**++ * Seal content on this device (a new locked item): `{ lock: 'people', keys: [{ subject, publicKey }] }`,++ * `{ lock: 'password', password }`, or `{ lock: 'link' }`, whose key comes back in `linkKey` for the link's #k=.++ * The content key is made and kept here; only the envelope leaves.++ */++ async function seal(content, opts = {}) {++ const c = await core();++ const out = await c.seal(content, opts);++ return { bytes: out.bytes, header: out.header, linkKey: out.linkKey || null };++ }++ /** An envelope's header, without any key. */++ async function inspectBytes(bytes) {++ const c = await core();++ return c.inspect(bytes);++ }++// ── The card a locked item shows ────────────────────────────────────────────────────────────────────────async function open(container, opts) {const o = Object.assign({}, opts || {});@@@ -450,5 +505,8 @@});}-- root.OVLocker = { open, setup, unlock, state, forget, keyFromLink, strength, version: 1 };++ root.OVLocker = {++ open, setup, unlock, ensure, keys, publicKeys, rewrap, seal, inspect: inspectBytes,++ state, forget, keyFromLink, strength, version: 2,++ };})(typeof window !== 'undefined' ? window : null);@@@ -130,6 +130,27 @@ html.ov-share-locked, html.ov-share-locked body { overflow: hidden; overscroll-b.ov-share-seglabel { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ov-share-aud:disabled .ov-share-segopt:not(:has(input:checked)) { opacity: .55; }++/* ── Locked: what the lock means, and the fingerprints it is wrapped for ───────────────────────────────────── */++.ov-share-lock { display: grid; gap: 8px; min-width: 0; padding: 10px 12px; border-radius: var(--ovs-radius); background: color-mix(in srgb, var(--ovs-accent) 6%, var(--ovs-hover)); }++.ov-share-lockhead {++ -webkit-appearance: none; appearance: none; display: flex; align-items: center; gap: 10px; width: 100%; min-height: 34px;++ margin: 0; padding: 0; border: 0; background: none; color: var(--ovs-text); font: inherit; text-align: left; cursor: pointer;++}++.ov-share-lockhead .ov-share-ico { color: var(--ovs-accent); }++.ov-share-lockhead:focus-visible { outline: 3px solid color-mix(in srgb, var(--ovs-accent) 40%, transparent); outline-offset: 3px; border-radius: 8px; }++.ov-share-locklabel { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; }++.ov-share-locktitle { font-weight: 700; font-size: 14px; }++.ov-share-lockcaret { flex: none; width: 20px; text-align: center; color: var(--ovs-muted); font-size: 17px; line-height: 1; }++.ov-share-lockshort { font-size: 12.5px; }++.ov-share-lockbody { display: grid; gap: 8px; min-width: 0; }++.ov-share-lockmeans { margin: 0; font-size: 13.5px; color: var(--ovs-text); }++.ov-share-fps { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }++.ov-share-fprow { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; min-width: 0; font-size: 12.5px; }++.ov-share-fpwho { flex: none; color: var(--ovs-text); font-weight: 650; }++.ov-share-fp { min-width: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--ovs-muted); white-space: normal; overflow-wrap: anywhere; }++.ov-share-fp.is-changed { color: var(--warning-text, var(--ovs-warn)); }++.ov-share-tag.is-warn { color: var(--warning-text, var(--ovs-warn)); background: color-mix(in srgb, var(--ovs-warn) 16%, transparent); }++/* ── People ─────────────────────────────────────────────────────────────────────────────────────────────────── */.ov-share-people { display: grid; gap: 10px; min-width: 0; }.ov-share-people .ov-share-label { margin: 0; }@@@ -12,6 +12,13 @@* Who can open it for the owner, when the thing has kind and id: Everyone, Anyone with the link, Specific people* (by @name, each Can view or Can edit, with an end date), Only me; people who asked; Transfer.*++ * A locked item (Locker, ov-locker.js) gets one more part between them: what its lock means in plain words, each++ * person's eight-word fingerprint (and a warning when their key changed), and, for a people lock, adding someone (their++ * content key is wrapped for them on this device) and removing someone (the content is sealed again under a fresh key,++ * and the sheet says they keep what they already opened). A link lock's key is the part of the link after #, which++ * browsers never send; the sheet never invents it, it copies the whole link. ov-locker.js is loaded on the first press++ * of that part, never before.++ ** <button type="button" data-ov-share-sheet data-title="…">Share</button> the page's own address* data-url="https://…" the link to share (else the page's address)* data-text="…" a line to send with it (email, the phone's sheet)@@@ -54,6 +61,22 @@private: { label: 'Only me', title: 'Only me', desc: 'Only you can open it.' },};const ROLE_WORDS = { viewer: 'Can view', editor: 'Can edit' };++ // What each lock means, in the words the person choosing it reads (Locker design 2026-11-06: "OpenVibe can't read++ // what's inside"). The same three sentences are on the page that makes a locked item.++ const LOCK_WORDS = {++ people: {++ label: 'For the people on the list',++ means: 'Only you and the people below can open it, each with their own keys. OpenVibe can’t read what’s inside.',++ },++ password: {++ label: 'With a password',++ means: 'Anyone you give the password to can open it. OpenVibe can’t read what’s inside, and if the password is lost, nobody can open it, including us.',++ },++ link: {++ label: 'With the link',++ means: 'The key travels in the link, after the #, which browsers never send to OpenVibe. Anyone with the whole link can open it.',++ },++ };const svg = (d, extra) => `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"${extra || ''}>${d}</svg>`;const ICON = {public: svg('<circle cx="12" cy="12" r="8.5"/><path d="M3.5 12h17M12 3.5c2.6 2.4 3.8 5.3 3.8 8.5s-1.2 6.1-3.8 8.5c-2.6-2.4-3.8-5.3-3.8-8.5s1.2-6.1 3.8-8.5Z"/>'),@@@ -183,11 +206,20 @@return [...recent.map((id) => list.find((t) => t[0] === id)), ...list.filter((t) => !recent.includes(t[0]))];}const hms = (s) => { s = Math.max(0, Math.floor(s)); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), x = s % 60; return h ? `${h}:${String(m).padStart(2, '0')}:${String(x).padStart(2, '0')}` : `${m}:${String(x).padStart(2, '0')}`; };++ /** The key a link carries after #k= (a link lock): base64url, 32 bytes. The same rule as ov-locker.js. */++ function linkKey(href) {++ const h = String(href == null ? root.location.hash : href).replace(/^#/, '');++ const m = h.match(/(?:^|&)k=([A-Za-z0-9_-]{43})(?:&|$)/) || String(root.location.hash || '').match(/(?:^|&)k=([A-Za-z0-9_-]{43})(?:&|$)/);++ return m ? m[1] : null;++ }function theLink(st) {const raw = (st.server && st.server.link) || st.o.link || st.o.url || root.location.href;let u;try { u = new URL(raw, root.location.href); } catch (_) { return raw; }if (st.atTime && st.o.time > 0) u.searchParams.set(st.o.timeParam || 't', String(Math.floor(st.o.time)));++ // A link-locked item is opened by the part after #; the service's own link never has it, the page's address does.++ const key = st.server && st.server.lock === 'link' ? linkKey() : null;++ if (key && !linkKey(u.href)) u.hash = `k=${key}`;return u.href;}const shownLink = (href) => href.replace(/^https?:\/\//, '').replace(/\/$/, '');@@@ -428,6 +460,7 @@o, server: null, audience: 'private', members: [], notify: true, view: 'main', busy: false,note: '', error: '', confirmLeave: false, saved: null, resolve, giveTo: null, keepAccess: false,access: !!(o.kind && o.id), first: true, atTime: false, panel: '', allTargets: false,++ lockOpen: false, reseal: false,};st.dlg = el('dialog', 'ov-share', { 'aria-labelledby': 'ov-share-title' });st.close = (force) => close(st, force);@@@ -468,6 +501,7 @@function memberKey(m) { return m.subject ? sid(m.subject) : `name:${String(m.name || m.username).toLowerCase()}`; }function dirty(st) {if (!st.server) return false;++ if (st.reseal) return true;if (st.audience !== (st.server.audience || 'private')) return true;const a = (st.server.members || []).map((m) => `${memberKey(m)}|${m.role}|${m.expires_at || ''}`).sort().join(',');const b = st.members.map((m) => `${memberKey(m)}|${m.role}|${m.expires_at || ''}`).sort().join(',');@@@ -546,7 +580,9 @@if (s.transfer && s.transfer.state === 'pending' && mine) body.appendChild(pendingBanner(st));// Who can open this: four choices, one line saying what the chosen one means.-- const offered = (Array.isArray(s.audiences) && s.audiences.length ? s.audiences : Object.keys(AUDIENCES)).filter((a) => AUDIENCES[a]);++ // A locked item is never public (the service refuses it too): the choice is not offered.++ const offered = (Array.isArray(s.audiences) && s.audiences.length ? s.audiences : Object.keys(AUDIENCES))++ .filter((a) => AUDIENCES[a] && !(isLocked(st) && a === 'public'));const fs = el('fieldset', 'ov-share-aud', { disabled: !mine || st.busy });fs.appendChild(el('legend', 'ov-share-label', null, 'Who can open this'));const seg = el('div', 'ov-share-seg', { style: `--n:${offered.length}` });@@@ -567,6 +603,9 @@fs.appendChild(desc);body.appendChild(fs);++ // Locked items: what the lock means, the fingerprints, and the people they are wrapped for.++ if (isLocked(st)) body.appendChild(lockSection(st));++// People: who has access (or, on an open item, who may edit it).const open = st.audience === 'public' || st.audience === 'link';const canEditRole = roles(st).includes('editor');@@@ -619,6 +658,183 @@if (st.error) body.appendChild(el('p', 'ov-share-error', { role: 'alert' }, st.error));}++ // ── Locked items (Locker) ────────────────────────────────────────────────────────────────────────────────++ // ov-locker.js loads on the first press of the Locked part, never before: the lock's words and the fingerprints++ // come from the service's own answer; only the wrapping and the re-seal need the keys, and they live in that file.++ const isLocked = (st) => !!(st.server && st.server.lock && st.server.lock !== 'none');++ async function locker() {++ if (root.OVLocker) return root.OVLocker;++ const ok = await loadSibling('ov-locker.js', 'OVLocker');++ return ok && root.OVLocker ? root.OVLocker : null;++ }++ const b64 = (u8) => { let s = ''; for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return root.btoa(s); };++ /** The people whose slot was in the stored envelope and who are no longer on the list. */++ function removedMembers(st) {++ if (!st.server) return [];++ return (st.server.members || []).filter((m) => m.key_slot && !st.members.some((x) => same(x.subject, m.subject)));++ }++ function lockSection(st) {++ const kind = st.server.lock;++ const words = LOCK_WORDS[kind] || { label: kind, means: '' };++ const sec = el('section', 'ov-share-lock', { 'aria-labelledby': 'ov-share-lock-h' });++ const head = el('button', 'ov-share-lockhead', { type: 'button', 'data-k': 'lock-open', 'aria-expanded': st.lockOpen ? 'true' : 'false', 'aria-controls': 'ov-share-lock-body' });++ head.appendChild(iconEl('private'));++ const w = el('span', 'ov-share-locklabel');++ w.appendChild(el('span', 'ov-share-locktitle', { id: 'ov-share-lock-h' }, 'Locked'));++ w.appendChild(el('span', 'ov-share-muted', null, words.label));++ head.appendChild(w);++ head.appendChild(el('span', 'ov-share-lockcaret', { 'aria-hidden': 'true' }, st.lockOpen ? '−' : '+'));++ head.addEventListener('click', () => {++ st.lockOpen = !st.lockOpen;++ if (st.lockOpen && !root.OVLocker) locker(); // the words need nothing; the sealing later does++ draw(st);++ });++ sec.appendChild(head);++ if (!st.lockOpen) {++ sec.appendChild(el('p', 'ov-share-muted ov-share-lockshort', null, 'OpenVibe can’t read what’s inside.'));++ return sec;++ }++ const inner = el('div', 'ov-share-lockbody', { id: 'ov-share-lock-body' });++ inner.appendChild(el('p', 'ov-share-lockmeans', null, words.means));++ if (kind === 'link') {++ const key = linkKey(theLink(st));++ inner.appendChild(el('p', key ? 'ov-share-muted' : 'ov-share-warn', { role: key ? null : 'status' }, key++ ? 'The whole link, including the part after #, is the key. Copy sends the whole link.'++ : 'This link is missing its key: a locked link ends with #k=… Ask whoever shared it for the whole link. OpenVibe never had the key, so it cannot send it.'));++ }++ if (kind === 'password') {++ inner.appendChild(el('p', 'ov-share-muted', null, 'The password is not stored anywhere. If you lose it, nobody can open this: there is no reset.'));++ }++ if (kind === 'people') {++ inner.appendChild(el('p', 'ov-share-muted', null, 'Each person’s content key is wrapped for their locker on this device. OpenVibe never sees a key.'));++ const known = st.members.filter((m) => m.key_fingerprint);++ if (known.length) {++ const ul = el('ul', 'ov-share-fps', { 'aria-label': 'Fingerprints' });++ for (const m of known) {++ const li = el('li', 'ov-share-fprow');++ li.appendChild(el('span', 'ov-share-fpwho', null, handle(m) || nameOf(m)));++ li.appendChild(el('span', 'ov-share-fp', null, m.key_fingerprint));++ if (m.fingerprint_changed) li.appendChild(el('span', 'ov-share-tag is-warn', null, 'key changed'));++ ul.appendChild(li);++ }++ inner.appendChild(ul);++ }++ const changed = st.members.filter((m) => m.fingerprint_changed);++ if (changed.length) {++ const who = changed.map((m) => handle(m) || nameOf(m)).join(', ');++ inner.appendChild(el('p', 'ov-share-warn', { role: 'status' }, `${who} changed locker keys since this was shared for them. Compare the new fingerprint with them, in person or on a call, before you use it.`));++ const use = el('button', 'ov-share-btn is-small is-quiet', { type: 'button', 'data-k': 'use-new-key' }, 'Use their new key');++ use.addEventListener('click', async () => {++ use.disabled = true;++ try {++ const L = await locker();++ if (!L) throw new Error('The Locker could not load. Check your connection and try again.');++ for (const m of changed) {++ const keys = await L.keys(m.subject);++ if (!keys) throw new Error(`${handle(m) || nameOf(m)} has no locker now.`);++ m._publicKey = keys.ecdh_public || keys.ecdh;++ m._keyFingerprint = keys.fingerprint || null;++ m.fingerprint_changed = false;++ m.useNewKey = true;++ }++ st.reseal = true; st.lockNote = 'Their new key is used when you save.';++ st.lockError = '';++ } catch (e) { st.lockError = e.message; }++ draw(st);++ });++ inner.appendChild(use);++ }++ const gone = removedMembers(st);++ if (gone.length) {++ const who = gone.map((m) => handle(m) || nameOf(m)).join(', ');++ inner.appendChild(el('p', 'ov-share-warn', { role: 'status' },++ `They had the key before you removed them (${who}). They may still have a copy of what they already opened.`));++ }++ if (st.lockNote) inner.appendChild(el('p', 'ov-share-note', { role: 'status' }, st.lockNote));++ if (st.lockError) inner.appendChild(el('p', 'ov-share-error', { role: 'alert' }, st.lockError));++ }++ sec.appendChild(inner);++ return sec;++ }++++ /** The person's public key, asked of Network through the Locker: a people lock cannot be made without it. */++ async function attachKey(st, p) {++ if (!p || !p.subject) { st.addMsg = `We could not confirm ${handle(p) || nameOf(p)}’s account, so no key is known.`; st.addMsgBad = true; return false; }++ try {++ const L = await locker();++ if (!L) throw new Error('The Locker could not load. Check your connection and try again.');++ const keys = await L.keys(p.subject);++ if (!keys) {++ st.addMsg = `${handle(p) || nameOf(p)} has no locker yet: they make one the first time they open a locked item.`;++ st.addMsgBad = true;++ return false;++ }++ p._publicKey = keys.ecdh_public || keys.ecdh;++ p._keyFingerprint = keys.fingerprint || null;++ p.needsKey = true;++ return true;++ } catch (e) {++ st.addMsg = `Could not get ${handle(p) || nameOf(p)}’s key: ${e.message}`;++ st.addMsgBad = true;++ return false;++ }++ }++++ /** The bytes a locked item serves: its envelope. */++ async function envelopeBytes(st) {++ const url = (st.server && st.server.envelope_url) || st.o.envelope_url;++ if (!url) throw new Error('This service did not say where the locked item is.');++ const r = await fetch(url, { credentials: st.o.base ? 'include' : 'same-origin', headers: { accept: 'application/octet-stream' } });++ if (!r.ok) throw new Error(`The locked item answered ${r.status}.`);++ return new Uint8Array(await r.arrayBuffer());++ }++++ /**++ * For a people lock whose list changed: the envelope re-wrapped here. Someone added gets their slot (the content key++ * never leaves this device); someone removed means the content is sealed again under a fresh key for those who stay,++ * so the key the person leaving holds opens nothing. The service stores the bytes and never reads them.++ */++ async function resealIfNeeded(st) {++ const before = st.server.members || [];++ const after = st.audience === 'private' ? [] : st.members;++ const gone = before.filter((m) => !after.some((x) => same(x.subject, m.subject)));++ const needing = after.filter((m) => m.needsKey || m.fingerprint_changed || m.useNewKey);++ // A slot that is already there has to go when it is wrapped again for the same person: two slots for one++ // person would leave the old key in the envelope.++ const stale = needing.filter((m) => m.key_slot);++ const remove = [...gone, ...stale].map((m) => m.subject);++ if (!remove.length && !needing.length && !st.reseal) return null;++ const L = await locker();++ if (!L) throw new Error('The Locker could not load. Check your connection and try again.');++ await L.ensure();++ const bytes = await envelopeBytes(st);++ const publicKey = async (m) => {++ if (m._publicKey) return m._publicKey;++ const keys = await L.keys(m.subject);++ if (!keys) throw new Error(`${handle(m) || nameOf(m)} has no locker now.`);++ m._publicKey = keys.ecdh_public || keys.ecdh;++ m._keyFingerprint = keys.fingerprint || null;++ return m._publicKey;++ };++ const add = [];++ for (const m of needing) add.push({ subject: m.subject, publicKey: await publicKey(m) });++ const keys = [];++ const me = await L.publicKeys();++ const my = st.server.you && st.server.you.subject;++ if (remove.length && my && me) keys.push({ subject: my, publicKey: me.ecdh });++ if (remove.length) for (const m of after) keys.push({ subject: m.subject, publicKey: await publicKey(m) });++ const out = await L.rewrap(bytes, { add, remove, keys });++ const slots = new Map((out.header.slots.people || []).map((slot) => [sid(slot.subject), slot]));++ const list = after.map((m) => {++ const slot = slots.get(sid(m.subject));++ if (!slot) throw new Error(`The sealed envelope has no key for ${handle(m) || nameOf(m)}.`);++ const r = { subject: m.subject, role: m.role, key_slot: slot };++ if (m.expires_at) r.expires_at = m.expires_at;++ return r;++ });++ return { members: list, envelope: b64(out.bytes) };++ }++// The @name field: Enter or Add checks the name with Network (through the service), then adds a chip-row.function adder(st) {const wrap = el('form', 'ov-share-add', { novalidate: true });@@@ -667,15 +883,20 @@st.checking = true; st.addMsg = ''; draw(st);const missing = [];try {++ const peopleLock = !!(st.server && st.server.lock === 'people');for (const n of names) {const p = await lookup(st.o, n);if (p === null) missing.push(n);++ else if (p === undefined && peopleLock) { st.addMsg = `We could not check @${n}, so no key is known for them. Try again.`; st.addMsgBad = true; }else if (p === undefined) addPerson(st, { name: n, username: n, unchecked: true }, true);++ else if (peopleLock) { if (await attachKey(st, p)) addPerson(st, p, true); }else addPerson(st, p, true);}st.draftName = missing.join(' ');-- st.addMsg = missing.length ? `No OpenVibe account is called ${missing.map((n) => `@${n}`).join(', ')}.` : '';-- st.addMsgBad = !!missing.length;++ if (missing.length) {++ st.addMsg = `No OpenVibe account is called ${missing.map((n) => `@${n}`).join(', ')}.`;++ st.addMsgBad = true;++ }} catch (e) {st.addMsg = e.message; st.addMsgBad = true;}@@@ -688,7 +909,14 @@if (st.members.some((m) => memberKey(m) === memberKey(p))) { st.addMsg = `${handle(p)} is on the list already.`; st.addMsgBad = false; if (!quiet) draw(st); return; }const open = st.audience === 'public' || st.audience === 'link';const role = open && roles(st).includes('editor') ? 'editor' : 'viewer';-- st.members.push({ subject: p.subject, name: p.subject ? undefined : p.name, username: p.username, display_name: p.display_name, avatar_url: p.avatar_url, role, expires_at: null, isNew: true, unchecked: !!p.unchecked });++ st.members.push({++ subject: p.subject, name: p.subject ? undefined : p.name, username: p.username, display_name: p.display_name,++ avatar_url: p.avatar_url, role, expires_at: null, isNew: true, unchecked: !!p.unchecked,++ ...(p.key_slot ? { key_slot: p.key_slot } : {}),++ ...(p.needsKey ? { needsKey: true } : {}),++ ...(p._publicKey ? { _publicKey: p._publicKey } : {}),++ ...(p._keyFingerprint || p.key_fingerprint ? { key_fingerprint: p._keyFingerprint || p.key_fingerprint } : {}),++ });if (st.audience === 'private') { st.audience = 'people'; st.note = 'Switched to Specific people, so they can open it.'; }if (!quiet) { st.draftName = ''; draw(st); }}@@@ -706,6 +934,11 @@if (p.username) sub.appendChild(el('span', null, null, handle(p)));if (x.member && x.member.unchecked) sub.appendChild(el('span', 'ov-share-soft', null, 'checked when you save'));if (x.member) sub.appendChild(expiry(st, x.member));++ // A people lock shows which key the slot is for: the eight words a person can compare out of band.++ if (x.member && isLocked(st) && st.server.lock === 'people' && x.member.key_fingerprint) {++ sub.appendChild(el('span', `ov-share-fp${x.member.fingerprint_changed ? ' is-changed' : ''}`, { title: 'Their locker’s fingerprint' }, x.member.key_fingerprint));++ if (x.member.fingerprint_changed) sub.appendChild(el('span', 'ov-share-soft', null, 'key changed'));++ }words.appendChild(sub);li.appendChild(words);if (x.tag) { li.appendChild(el('span', 'ov-share-role is-fixed', null, x.tag)); return li; }@@@ -724,7 +957,9 @@rm.appendChild(iconEl('close'));rm.addEventListener('click', () => {st.members.splice(st.members.indexOf(m), 1);-- st.note = m.isNew ? '' : `${handle(m) || nameOf(m)} loses access when you save. They may still have a copy of what they already opened.`;++ st.note = m.isNew ? '' : isLocked(st) && st.server.lock === 'people'++ ? `${handle(m) || nameOf(m)} loses access when you save. They had the key before you removed them: they may still have a copy of what they already opened.`++ : `${handle(m) || nameOf(m)} loses access when you save. They may still have a copy of what they already opened.`;draw(st);});li.appendChild(rm);@@@ -922,7 +1157,7 @@}async function save(st) {-- st.busy = true; st.error = ''; draw(st);++ st.busy = true; st.error = ''; st.lockError = ''; draw(st);const members = st.audience === 'private' ? [] : st.members.map((m) => {const r = { role: m.role };if (m.expires_at) r.expires_at = m.expires_at;@@@ -930,9 +1165,16 @@return r;});try {-- const out = await call(st.o, 'PUT', endpoint(st.o).path, { audience: st.audience, members, notify: !!st.notify }, st.etag ? { 'if-match': st.etag } : null);++ const body = { audience: st.audience, members, notify: !!st.notify };++ // A people lock follows its people: someone added is wrapped for now, someone removed means the content is++ // sealed again. The bytes go up with the list, and this browser is the only place they were opened.++ if (isLocked(st) && st.server.lock === 'people') {++ const sealed = await resealIfNeeded(st);++ if (sealed) { body.members = sealed.members; body.envelope = sealed.envelope; }++ }++ const out = await call(st.o, 'PUT', endpoint(st.o).path, body, st.etag ? { 'if-match': st.etag } : null);take(st, out && out.audience ? out : await call(st.o, 'GET', endpoint(st.o).path));-- st.saved = st.server; st.note = 'Saved.';++ st.saved = st.server; st.note = 'Saved.'; st.reseal = false; st.lockNote = '';announce(st.o, st.server);} catch (e) {if (e.status === 412) {@@@ -941,7 +1183,12 @@draw(st); return;}const names = e.body && Array.isArray(e.body.names) ? e.body.names : null;-- st.error = names && names.length ? `No OpenVibe account is called ${names.map((n) => `@${n}`).join(', ')}. Remove them, then save.` : `Could not save: ${e.message}`;++ const code = (e.body && e.body.code) || e.code;++ if (code === 'share.key_changed') st.error = 'Someone’s locker key changed. Open the Locked part and use their new key, then save again.';++ else if (code === 'share.no_key') st.error = 'Someone on the list has no locker yet. They make one the first time they open a locked item.';++ else if (code === 'share.reseal_required') st.error = 'Removing someone from a locked item seals it again here. Unlock your locker and save again.';++ else if (code === 'share.slot_required') st.error = 'Every person on a locked item needs their key. Add them again, then save.';++ else st.error = names && names.length ? `No OpenVibe account is called ${names.map((n) => `@${n}`).join(', ')}. Remove them, then save.` : `Could not save: ${e.message}`;}st.busy = false; draw(st);}@@@ -1046,5 +1293,5 @@time: time >= 5 ? time : 0, timeParam: d.timeParam, base: d.base, path: d.path };}-- root.OVShare = { open, offer, gate, fromButton, version: 2 };++ root.OVShare = { open, offer, gate, fromButton, version: 3 };})(typeof window !== 'undefined' ? window : null);@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.115.1",++ "version": "4.116.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.115.1",++ "version": "4.116.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -19,13 +19,13 @@"@electric-sql/pglite": "^0.5.8","express": "^4.22.3","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.86.0"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.89.0"}},"node_modules/@electric-sql/pglite": {-- "version": "0.5.8",-- "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",-- "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",++ "version": "0.5.15",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.15.tgz",++ "integrity": "sha512-ERZWgaRTxueGV4cvL1tfpAOzFCWTXoUtbCBqnf2FhHDVs/so9eAKaanvrwWF9Dntx7BYsmFQujVQ0/B4S8Mmiw==","dev": true,"license": "Apache-2.0"},@@@ -86,9 +86,9 @@"license": "MIT"},"node_modules/body-parser": {-- "version": "1.20.8",-- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",-- "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",++ "version": "1.20.9",++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz",++ "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==","dev": true,"license": "MIT","dependencies": {@@@ -243,11 +243,14 @@"url": "https://github.com/sponsors/fb55"}},-- "node_modules/cssom": {-- "version": "0.5.0",-- "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",-- "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",-- "license": "MIT"++ "node_modules/cssomnom": {++ "version": "0.1.11",++ "resolved": "https://registry.npmjs.org/cssomnom/-/cssomnom-0.1.11.tgz",++ "integrity": "sha512-P4RkO1Sq2Ual77xypEmAc4MkM5k2Ha4UwtBPCOpJmXculGCYPgzsU/xnOCy084esZaPePgGkkJg4Il/HE7GS5A==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=20.0.0"++ }},"node_modules/debug": {"version": "2.6.9",@@@ -259,13 +262,6 @@"ms": "2.0.0"}},-- "node_modules/debug/node_modules/ms": {-- "version": "2.0.0",-- "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",-- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",-- "dev": true,-- "license": "MIT"-- },"node_modules/depd": {"version": "2.0.0","resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",@@@ -386,13 +382,17 @@"license": "MIT"},"node_modules/encodeurl": {-- "version": "2.0.0",-- "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",-- "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.1.tgz",++ "integrity": "sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A==","dev": true,"license": "MIT","engines": {"node": ">= 0.8"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"}},"node_modules/entities": {@@@ -512,9 +512,9 @@"license": "MIT"},"node_modules/fast-uri": {-- "version": "3.1.8",-- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",-- "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",++ "version": "3.1.9",++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",++ "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==","dev": true,"funding": [{@@@ -839,6 +839,12 @@"npm": ">=6"}},++ "node_modules/jsonwebtoken/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },"node_modules/jwa": {"version": "2.0.1","resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",@@@ -861,13 +867,13 @@}},"node_modules/linkedom": {-- "version": "0.18.13",-- "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.13.tgz",-- "integrity": "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==",++ "version": "0.18.15",++ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.15.tgz",++ "integrity": "sha512-o0w7eD/SvoCao+pUi6dzocCLuMR11g4Tv/et0W2OUzwWmUcewntSzyl+Ua/K+0yKym0Y/Mav3nFAaI5Av5jvZg==","license": "ISC","dependencies": {"css-select": "^7.0.0",-- "cssom": "^0.5.0",++ "cssomnom": "^0.1.11","html-escaper": "^3.0.3","htmlparser2": "^10.1.0","uhyphen": "^0.2.0"@@@ -1003,9 +1009,10 @@}},"node_modules/ms": {-- "version": "2.1.3",-- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",-- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",++ "dev": true,"license": "MIT"},"node_modules/negotiator": {@@@ -1048,9 +1055,9 @@}},"node_modules/on-finished": {-- "version": "2.4.1",-- "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",-- "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",++ "version": "2.4.2",++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",++ "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==","dev": true,"license": "MIT","dependencies": {@@@ -1058,6 +1065,10 @@},"engines": {"node": ">= 0.8"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"}},"node_modules/openvibe-contracts": {@@@ -1078,9 +1089,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.86.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.86.0",-- "integrity": "sha512-APn4qK1ErX9f/gR6l2AhGfj+n8/3ul87Otm69xN4wyJUt43WF6A2b0lMKDG9Ds6oQiOoCZJCjxCSanP0wtMuGA==",++ "version": "0.89.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.89.0",++ "integrity": "sha512-vZLwKIrjHPHnCXCJ9tHoKPGmm4jQLzzXxjdRgxWTwBks9vyhnWXkjCvA+SvPL45VDTeGJBQjZZsQPZVjduTsCQ==","dev": true,"license": "MIT","bin": {@@@ -1142,9 +1153,9 @@}},"node_modules/qs": {-- "version": "6.16.0",-- "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",-- "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",++ "version": "6.16.2",++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",++ "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==","dev": true,"license": "BSD-3-Clause","dependencies": {@@@ -1222,9 +1233,9 @@"license": "MIT"},"node_modules/semver": {-- "version": "7.8.5",-- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",-- "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",++ "version": "7.8.6",++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz",++ "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==","license": "ISC","bin": {"semver": "bin/semver.js"@@@ -1258,6 +1269,13 @@"node": ">= 0.8.0"}},++ "node_modules/send/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "dev": true,++ "license": "MIT"++ },"node_modules/serve-static": {"version": "1.16.3","resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.115.1",++ "version": "4.116.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -148,6 +148,6 @@"@electric-sql/pglite": "^0.5.8","express": "^4.22.3","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.86.0"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.89.0"}}nnew file mode 100644@@@ -0,0 +1,219 @@++'use strict';++// The Share sheet's Locked part, end to end in a real headless Chrome with the REAL crypto (ov-locker-core.js from++// openvibe-sdk/locker) and a stand-in service + Network key directory:++// ov-locker.js is not loaded until the Locked part is pressed;++// a people-locked paste names its lock and its people's fingerprints in plain words;++// adding @quinn wraps the content key for quinn on this device, and the envelope that goes up opens for quinn;++// removing @maticus seals the content again under a fresh key: the new bytes open for quinn, not for maticus;++// a link-locked paste's link keeps the key after #k=, and says so when the key is missing.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const h = require('../browser-harness');++++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {++ console.log('ov-share-lock-chrome: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++++const ROOT = path.join(__dirname, '..');++const locker = require('openvibe-sdk/locker');++const PASS = 'violet lantern river cobalt';++const OWN = { type: 'user', id: 'usr_own' };++const MAT = { type: 'user', id: 'usr_mat' };++const QUI = { type: 'user', id: 'usr_q' };++const PEOPLE = {++ maticus: { subject: MAT, username: 'maticus', display_name: 'Maticus' },++ quinn: { subject: QUI, username: 'quinn', display_name: 'Quinn' },++};++const keys = {}; // subject id → what Network publishes: { subject, ecdh_public, fingerprint }++const items = {}; // path → envelope bytes++let lockerPut = null; // the browser's PUT /api/v1/me/keys++const puts = []; // every share save body++let share = null; // the share GET body++++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Locked</title><link rel="icon" href="data:,">++<link rel="stylesheet" href="/ov-share.css"></head><body style="font:15px system-ui;padding:16px">++<button type="button" id="sh" data-ov-share-sheet data-kind="paste" data-id="locked" data-title="Locked note">Share</button>++<div id="item"></div>++<script>window.OpenVibeLockerConfig = { network: '' };</script><script src="/ov-share.js"></script></body></html>`;++++const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ let body = '';++ req.on('data', (c) => { body += c; });++ req.on('end', () => {++ const json = (code, o) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };++ const file = { '/ov-share.js': 'text/javascript', '/ov-share.css': 'text/css', '/ov-locker.js': 'text/javascript', '/ov-locker.css': 'text/css', '/ov-locker-core.js': 'text/javascript' }[u.pathname];++ if (file) { res.writeHead(200, { 'content-type': `${file}; charset=utf-8` }); return res.end(fs.readFileSync(path.join(ROOT, u.pathname.slice(1)))); }++ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE); }++ if (u.pathname === '/api/v1/me/keys' && req.method === 'PUT') { lockerPut = JSON.parse(body); return json(200, { ok: true }); }++ if (u.pathname.startsWith('/p/') && u.pathname.endsWith('/raw') && items[u.pathname]) {++ res.writeHead(200, { 'content-type': 'application/octet-stream' }); return res.end(Buffer.from(items[u.pathname]));++ }++ const who = u.pathname.match(/^\/api\/v1\/people\/([^/]+)\/keys$/);++ if (who) {++ const found = keys[decodeURIComponent(who[1])];++ return found ? json(200, found) : json(404, { code: 'locker.no_keys', error: 'no keys' });++ }++ if (u.pathname === '/api/v1/share/people') {++ const n = String(u.searchParams.get('name') || '').toLowerCase();++ return json(200, { person: PEOPLE[n] || null });++ }++ if (u.pathname === '/api/v1/paste/locked/share' && req.method === 'GET') {++ res.writeHead(200, { 'content-type': 'application/json', etag: '"4"' }); return res.end(JSON.stringify(share));++ }++ if (u.pathname === '/api/v1/paste/locked/share' && req.method === 'PUT') {++ const b = JSON.parse(body);++ puts.push(b);++ // What the store does: the member rows as they were sent, with what the key directory has for them.++ share = {++ ...share,++ audience: b.audience,++ members: (b.members || []).map((m) => {++ const id = m.subject && m.subject.id;++ const known = Object.values(PEOPLE).find((p) => p.subject.id === id);++ return { ...(known || { subject: m.subject, username: m.subject ? null : String(m.name) }), role: m.role, key_slot: m.key_slot };++ }),++ };++ return json(200, share);++ }++ res.writeHead(404); res.end();++ });++});++++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const mat = await locker.generatePersonKeys();++ const matPub = await locker.exportPersonKeys(mat);++ const qui = await locker.generatePersonKeys();++ const quiPub = await locker.exportPersonKeys(qui);++ keys[MAT.id] = { subject: MAT, ecdh_public: matPub.ecdh, signing_public: matPub.signing, fingerprint: matPub.fingerprint };++ keys[QUI.id] = { subject: QUI, ecdh_public: quiPub.ecdh, signing_public: quiPub.signing, fingerprint: quiPub.fingerprint };++++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-share-lock-'));++ const browser = await h.launch({ tmpDir });++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));++ const q = (k) => `document.querySelector('[data-k="${k}"]')`;++ const type = (k, v) => `(() => { const i = ${q(k)}; i.value = ${JSON.stringify(v)}; i.dispatchEvent(new Event('input', { bubbles: true })); })()`;++ const press = (k) => `${q(k)}.click()`;++ const text = (sel) => `(document.querySelector(${JSON.stringify(sel)}) || {}).textContent || ''`;++ try {++ const page = await h.openPage(browser, await browser.newContext(), { width: 1280 });++ await page.goto(`${base}/`, { maxMs: 5000 });++++ // The owner's locker, with the real crypto (two PBKDF2 seals). The sheet has already loaded ov-locker.js in++ // that tab; here it is loaded the way a page that unlocks a locker does.++ await page.evaluate(`(() => { const s = document.createElement('script'); s.src = '/ov-locker.js'; document.head.appendChild(s); })()`);++ await wait(600);++ await page.evaluate('(() => { OVLocker.setup(); })()'); await wait(300);++ await page.evaluate(type('pass', PASS)); await page.evaluate(type('pass2', PASS)); await page.evaluate(press('next'));++ await wait(1500);++ const words = await page.evaluate("[...document.querySelectorAll('.ovl-words li')].map((li) => li.textContent)");++ const labels = await page.evaluate("[...document.querySelectorAll('dialog .ovl-pair .ovl-label')].map((l) => Number(l.textContent.replace(/\\D/g, '')))");++ await page.evaluate(type('w1', words[labels[0] - 1])); await page.evaluate(type('w2', words[labels[1] - 1])); await page.evaluate(press('make'));++ await wait(9000);++ await page.evaluate(press('done')); // the locker's own Done: the setup sheet closes++ await wait(300);++ assert.ok(lockerPut && lockerPut.ecdh_public, 'the owner has a locker');++ keys[OWN.id] = { subject: OWN, ecdh_public: lockerPut.ecdh_public, signing_public: lockerPut.signing_public, fingerprint: lockerPut.fingerprint };++++ // The paste as it was made: a people lock for the owner and maticus, sealed before it was uploaded.++ const plain = JSON.stringify({ v: 1, type: 'paste', title: 'Locked note', content: 'the secret words', language: 'text' });++ const sealed = await locker.seal(plain, { lock: 'people', keys: [{ subject: OWN, publicKey: keys[OWN.id].ecdh_public }, { subject: MAT, publicKey: matPub.ecdh }] });++ items['/p/locked/raw'] = sealed.bytes;++ share = {++ audience: 'people', audiences: ['people', 'private'], roles: ['viewer'], title: 'Locked note', link: `${base}/p/locked`,++ lock: 'people', envelope_url: '/p/locked/raw',++ you: { subject: OWN, role: 'owner' }, owner: { subject: OWN, username: 'owner', display_name: 'Owner' },++ members: [{++ ...PEOPLE.maticus, role: 'viewer', added_at: '2026-11-06T10:00:00Z', added_by: OWN,++ key_slot: sealed.header.slots.people.find((s) => s.subject.id === MAT.id),++ key_fingerprint: matPub.fingerprint, fingerprint_changed: false,++ }],++ requests: [], transfer: null,++ };++++ // The sheet itself loads no Locker: a fresh tab proves it, and that the first press of the part does.++ const fresh = await h.openPage(browser, await browser.newContext(), { width: 390 });++ await fresh.goto(`${base}/`, { maxMs: 5000 });++ assert.strictEqual(await fresh.evaluate('!!window.OVLocker'), false, 'ov-share.js alone loads no Locker');++ await fresh.evaluate("document.getElementById('sh').click()");++ await wait(400);++ assert.match(await fresh.evaluate(text('.ov-share-lock')), /Locked/, 'a fresh tab sees the item is locked');++ await fresh.evaluate(press('lock-open'));++ await wait(600);++ assert.ok(await fresh.evaluate('!!window.OVLocker'), 'pressing Locked loads ov-locker.js');++ await fresh.evaluate(press('close'));++++ // 1. The Locked part says what the lock means, in plain words, and whose keys it is wrapped for.++ await page.evaluate("document.getElementById('sh').click()");++ await wait(400);++ assert.match(await page.evaluate(text('.ov-share-lock')), /Locked/, 'the sheet says the item is locked');++ assert.match(await page.evaluate(text('.ov-share-lockshort')), /can’t read what’s inside/, 'and says it in plain words');++ await page.evaluate(press('lock-open'));++ await wait(500);++ assert.match(await page.evaluate(text('.ov-share-lockmeans')), /Only you and the people below can open it/, 'what a people lock means');++ assert.ok((await page.evaluate(text('.ov-share-fp'))).includes(matPub.fingerprint.split(' · ')[0]), 'the member’s fingerprint is shown');++++ // 2. Add @quinn: their public key is wrapped for them here.++ await page.evaluate(type('add', 'quinn'));++ await page.evaluate(press('add-btn'));++ await wait(600);++ assert.match(await page.evaluate(text('.ov-share-list')), /Quinn/, 'quinn is on the list');++ await page.evaluate(press('done'));++ await wait(2500); // the re-wrap++ assert.strictEqual(puts.length, 1, 'one save');++ const addPut = puts[0];++ assert.ok(addPut.envelope, 'the save carries the envelope sealed again');++ const quinnRow = addPut.members.find((m) => m.subject && m.subject.id === QUI.id);++ assert.ok(quinnRow && quinnRow.key_slot, 'quinn’s member row carries their key slot');++ assert.strictEqual(quinnRow.key_slot.recipient_key, await locker.jwkThumbprint(quiPub.ecdh), 'the slot names quinn’s key');++ const afterAdd = Buffer.from(addPut.envelope, 'base64');++ assert.deepStrictEqual([...new Uint8Array(await locker.open(afterAdd, { privateKey: qui.ecdh.privateKey, subject: QUI }))], [...Buffer.from(plain)], 'the envelope that went up opens for quinn');++ assert.deepStrictEqual([...new Uint8Array(await locker.open(afterAdd, { privateKey: mat.ecdh.privateKey, subject: MAT }))], [...Buffer.from(plain)], 'and for maticus, whose slot stayed');++++ // 3. Remove @maticus: fresh key, and the bytes that went up do not open for them.++ items['/p/locked/raw'] = afterAdd;++ await page.evaluate("document.getElementById('sh').click()");++ await wait(400);++ await page.evaluate(press('rm-user:usr_mat'));++ await wait(200);++ assert.match(await page.evaluate(text('.ov-share-note')), /They had the key before you removed them/, 'removal says they keep what they opened');++ await page.evaluate(press('done'));++ await wait(3000);++ assert.strictEqual(puts.length, 2, 'the removal saved');++ const removePut = puts[1];++ assert.ok(removePut.envelope, 'the removal carries a re-sealed envelope');++ const afterRemove = Buffer.from(removePut.envelope, 'base64');++ assert.deepStrictEqual([...new Uint8Array(await locker.open(afterRemove, { privateKey: qui.ecdh.privateKey, subject: QUI }))], [...Buffer.from(plain)], 'quinn still opens it');++ await assert.rejects(async () => await locker.open(afterRemove, { privateKey: mat.ecdh.privateKey, subject: MAT }), 'maticus cannot open the new envelope');++ assert.ok(!removePut.members.some((m) => m.subject && m.subject.id === MAT.id), 'maticus is not a member');++++ // 4. A link lock: the key after # is part of the link the sheet copies, and a link without it says so.++ const linkSealed = await locker.seal(plain, { lock: 'link' });++ const linkPage = await h.openPage(browser, await browser.newContext(), { width: 390 });++ await linkPage.goto(`${base}/#k=${linkSealed.linkKey}`, { maxMs: 5000 });++ share = { ...share, lock: 'link', members: [], audiences: ['link', 'private'] };++ items['/p/locked/raw'] = linkSealed.bytes;++ await linkPage.evaluate(`(() => { OVShare.open({ kind: 'paste', id: 'locked', title: 'Locked note' }); })()`);++ await wait(500);++ assert.match(await linkPage.evaluate(`${q('link')}.value`), /#k=[A-Za-z0-9_-]{43}/, 'the copied link keeps the key after #');++ await linkPage.evaluate(press('lock-open'));++ await wait(400);++ await linkPage.evaluate('history.replaceState(null, "", location.pathname)');++ await linkPage.evaluate(press('lock-open'));++ await linkPage.evaluate(press('lock-open'));++ await wait(500);++ assert.match(await linkPage.evaluate(text('.ov-share-lock')), /missing its key/i, 'a link with no key says so');++++ console.log('ov-share-lock-chrome: the Locked part with real crypto: lazy load, fingerprints, add wraps for them, removal re-seals, the link lock keeps its key');++ } finally {++ await browser.close();++ server.close();++ fs.rmSync(tmpDir, { recursive: true, force: true });++ }++})().catch((e) => { console.error(e); process.exit(1); });
@@ -1,5 +1,27 @@
@@ -1,5 +1,27 @@
# Changelog
# Changelog
+## 4.116.0 — 2026-11-06
+
+- **The Share sheet's Locked part** (`ov-share.js`, `ov-share.css`; Locker design 2026-11-06, the pilot; docs/share.md).
+ A locked item (`lock: people | password | link` in the share API's answer) gets its own part between Send it and Who
+ can open it:
+ - the lock in plain words, and a link lock says when the link in front of it is missing its `#k=` key;
+ - a people lock shows each person's eight-word fingerprint, warns when their key changed since the slot was made,
+ and offers **Use their new key**;
+ - adding someone asks Network for their public key and wraps their content key on this device (the member row and
+ the slot are saved together); someone without a locker is told so, not added;
+ - removing someone seals the content again under a fresh key for those who stay, uploads the new envelope with the
+ members, and says "They had the key before you removed them: they may still have a copy of what they already
+ opened."
+ - `ov-locker.js` loads on the first press of the part, never with the sheet.
+- **`ov-locker.js`** gains the calls the sheet needs, all keeping the keys in the tab: `ensure()`, `keys(who)` (Network's
+ key directory), `publicKeys()`, `rewrap(bytes, { add, remove, keys })` and `inspect(bytes)` (docs/locker.md).
+- **`ov-locker-core.js`** regenerated from openvibe-sdk 0.89.0 (`seal.js` and `server.js` moved since 0.86.0).
+ openvibe-sdk devDependency 0.86.0 → 0.89.0.
+- test/ov-share-lock-chrome.test.js: the Locked part with the real crypto — lazy load, fingerprints, adding wraps for
+ them (and the envelope that goes up opens for them), removing re-seals (and the removed person's key opens nothing),
+ and a link lock keeps its key after `#`.
+
## 4.115.1 — 2026-11-06
## 4.115.1 — 2026-11-06
- **Share sheet:** its headings no longer take a site's own `h2` rule (Wiki's bottom border and margin showed under the sheet's title). Every property a site might set on an `h2` is reset inside the sheet and the access gate.
- **Share sheet:** its headings no longer take a site's own `h2` rule (Wiki's bottom border and margin showed under the sheet's title). Every property a site might set on an `h2` is reset inside the sheet and the access gate.
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.115.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.116.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -1086,5 +1086,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -1086,5 +1086,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.289.1
- openvibe-contracts: v0.289.1
-- openvibe-sdk: v0.86.0
+- openvibe-sdk: v0.89.0
<!-- versions:end -->
<!-- versions:end -->
@@ -4,12 +4,12 @@
@@ -4,12 +4,12 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.115.1",
+ "version": "4.116.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"contracts": "openvibe-contracts v0.289.1 (devDependency: the release manifest contract)",
"contracts": "openvibe-contracts v0.289.1 (devDependency: the release manifest contract)",
- "sdk": "openvibe-sdk v0.86.0",
+ "sdk": "openvibe-sdk v0.89.0",
"features": [
"features": [
"the OpenVibe Frame: navbar, footer, panels, account switcher, notification UI with realtime",
"the OpenVibe Frame: navbar, footer, panels, account switcher, notification UI with realtime",
"the launcher beside the brand lists every site of the network, in groups; Ask Actor and the Vibes chip in the account menu",
"the launcher beside the brand lists every site of the network, in groups; Ask Actor and the Vibes chip in the account menu",
@@ -42,6 +42,17 @@ OVLocker.unlock({ onDone }); // unlock it o
@@ -42,6 +42,17 @@ OVLocker.unlock({ onDone }); // unlock it o
OVLocker.state(); // { unlocked, fingerprint, subject }
OVLocker.state(); // { unlocked, fingerprint, subject }
OVLocker.forget(); // drop the unlocked keys
OVLocker.forget(); // drop the unlocked keys
OVLocker.keyFromLink(); // the 32-byte key after #k=, or null
OVLocker.keyFromLink(); // the 32-byte key after #k=, or null
+OVLocker.ensure(); // resolves once unlocked here, or opens the unlock sheet; rejects when declined
+OVLocker.keys(subject|'@name'); // Network's public keys for one person, or null when they have no locker
+OVLocker.publicKeys(); // my public keys (for a people lock that wraps for me too)
+OVLocker.rewrap(bytes, { add, remove, keys });
+ // a people-locked envelope re-wrapped here: add [{ subject, publicKey }] wraps the
+ // content key for someone; remove [subject] seals the content under a fresh key for
+ // `keys` (the public keys of those who stay). → { bytes, header }
+OVLocker.seal(content, { lock, keys, password });
+ // seal content here (a new locked item): the key is made and kept in this tab; a link
+ // lock comes back with linkKey, the 32 bytes that go in the link after #k=
+OVLocker.inspect(bytes); // the envelope's header, without a key
window.OpenVibeLockerConfig = { network: 'https://openvibe.network' };
window.OpenVibeLockerConfig = { network: 'https://openvibe.network' };
```
```
@@ -13,6 +13,7 @@ computer. It has two parts and no tabs:
@@ -13,6 +13,7 @@ computer. It has two parts and no tabs:
|---|---|---|
|---|---|---|
| **Send it** | everyone | the link and **Copy**; on a phone, **Send with your phone** (the phone's own share sheet: contacts, messages, every app); X, Bluesky, WhatsApp, Reddit, Discord, Telegram, Threads, Mastodon, Facebook, Email, Messages (phones), LinkedIn, Pinterest (with a picture), Tumblr, Hacker News, LINE; **QR code**; **Embed**; **Start at 1:23** for a video or a recording |
| **Send it** | everyone | the link and **Copy**; on a phone, **Send with your phone** (the phone's own share sheet: contacts, messages, every app); X, Bluesky, WhatsApp, Reddit, Discord, Telegram, Threads, Mastodon, Facebook, Email, Messages (phones), LinkedIn, Pinterest (with a picture), Tumblr, Hacker News, LINE; **QR code**; **Embed**; **Start at 1:23** for a video or a recording |
| **Who can open it** | the owner (and staff) of a thing with a share API | Everyone · Anyone with the link · Specific people · Only me; people by @name, each Can view or Can edit, with an end date; people who asked; **Transfer** |
| **Who can open it** | the owner (and staff) of a thing with a share API | Everyone · Anyone with the link · Specific people · Only me; people by @name, each Can view or Can edit, with an end date; people who asked; **Transfer** |
+| **Locked** | the owner of a locked item (one sealed with [Locker](locker.md)) | what the lock means in plain words; each person's eight-word fingerprint; add someone (their key wrapped on this device) or remove them (the content sealed again) |
The first eight places show at once, with the person's three most recent first. The rest are behind a counted fold
The first eight places show at once, with the person's three most recent first. The rest are behind a counted fold
(**More (n)**), never hidden.
(**More (n)**), never hidden.
@@ -62,6 +63,31 @@ OVShare.fromButton(button);
@@ -62,6 +63,31 @@ OVShare.fromButton(button);
A site's analytics may count these.
A site's analytics may count these.
- `ov-share:change` `{ kind, id, state }` after a save, a transfer or an answer.
- `ov-share:change` `{ kind, id, state }` after a save, a transfer or an answer.
+## Locked items (Locker)
+
+A locked item carries the lock's kind in the share API's answer (`lock: people | password | link`) and the sheet draws a
+**Locked** part between the two above. It opens on the first press, and only then does the sheet load `ov-locker.js`: a
+page that never touches a locked item never loads the crypto.
+
+- **For the people on the list** — the sheet shows each person's eight-word fingerprint (the one Network publishes with
+ their keys). It warns when their key changed since the slot was made, with **Use their new key**; nothing is wrapped
+ for a key the person has not seen.
+ - Adding someone asks Network for their public key first. Their content key is wrapped for it on this device, and the
+ member row and the slot are saved together. Someone without a locker cannot be added, and the sheet says so in
+ words.
+ - Removing someone seals the content again under a fresh key for those who stay, then uploads the new envelope. The
+ sheet says: "They had the key before you removed them: they may still have a copy of what they already opened."
+- **With a link** — the key is the part of the link after `#`. The page's address has it, so **Copy** and every place
+ the sheet sends keep the whole link; the sheet never invents a key, and says plainly when the link in front of it is
+ missing one.
+- **With a password** — nothing is stored: the password opens the envelope on the reader's device. Lose it and nobody
+ can open the item, including OpenVibe (the sheet says this at the moment it matters).
+- The envelope's URL comes from the service's `describe(ref)` as `envelope_url`; the sheet fetches it, re-wraps or
+ re-seals it with `OVLocker.rewrap()`, and sends the base64 in the save's `envelope` field. The content key and the
+ plaintext never leave the browser.
+- The service must register the lock when the item is made (`register(ref, { lock })`), report it in the GET, store a
+ re-seal through `onReseal(ref, bytes, tx)`, and keep the item out of public lists: a locked item can never be public.
+
## What it promises
## What it promises
- **The service decides.** The sheet only asks. Who may open a thing is enforced by the service that holds it
- **The service decides.** The sheet only asks. Who may open a thing is enforced by the service that holds it
@@ -491,7 +491,7 @@
@@ -491,7 +491,7 @@
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '715972edd968', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': 'd5bbb14b108e', 'ov-share.css': 'f694a088190f' };
+ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '715972edd968', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': 'c70f014c25de', 'ov-share.css': 'cbd18e6ce991' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-/* Generated by scripts/build-locker-core.js from openvibe-sdk 0.86.0 (src/locker). Do not edit: change the SDK,
+/* Generated by scripts/build-locker-core.js from openvibe-sdk 0.89.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:
* 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
* bytes.js 80a5a560c683f489
* errors.js e73f94b7711452e7
* errors.js e73f94b7711452e7
@@ -8,8 +8,8 @@
@@ -8,8 +8,8 @@
* keys.js 18663474b10352ed
* keys.js 18663474b10352ed
* padme.js e0dd4b08e0d0f24f
* padme.js e0dd4b08e0d0f24f
* recovery.js 2829d07a02561674
* recovery.js 2829d07a02561674
- * seal.js b70d01522636486d
- * server.js 44a92f3cbb3e6bc1
+ * seal.js ff6ad448082c636c
+ * server.js 56414198b01fbecd
* words.js 8f4f51abdadfbfd1
* words.js 8f4f51abdadfbfd1
*/
*/
(function (root) {
(function (root) {
@@ -1103,6 +1103,15 @@ function readCore(header) {
@@ -1103,6 +1103,15 @@ function readCore(header) {
/** The content key out of the header, with the credentials the reader has. */
/** The content key out of the header, with the credentials the reader has. */
async function openContentKey(header, credentials = {}) {
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') {
if (header.lock === 'people') {
// A person may be named by their key instead of their subject: the slot says which key
// 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.
// it is for, so the reader does not need to know who they are.
@@ -1625,7 +1634,7 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {
@@ -1625,7 +1634,7 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {
};
};
}
}
-__ovLockerModule.exports = { inspectEnvelope, checkHeader };
+__ovLockerModule.exports = { inspectEnvelope, checkHeader, peopleSlotOk };
},
},
"words": function (__ovLockerModule, exports, __ovLockerReq) {
"words": function (__ovLockerModule, exports, __ovLockerReq) {
@@ -1686,5 +1695,5 @@ __ovLockerModule.exports = { WORDS };
@@ -1686,5 +1695,5 @@ __ovLockerModule.exports = { WORDS };
if (!cache[key]) { const m = { exports: {} }; cache[key] = m; defs[key](m, m.exports, req); }
if (!cache[key]) { const m = { exports: {} }; cache[key] = m; defs[key](m, m.exports, req); }
return cache[key].exports;
return cache[key].exports;
}
}
- root.OVLockerCore = Object.assign({}, req('index'), { SDK_VERSION: "0.86.0" });
+ root.OVLockerCore = Object.assign({}, req('index'), { SDK_VERSION: "0.89.0" });
})(typeof globalThis !== 'undefined' ? globalThis : this);
})(typeof globalThis !== 'undefined' ? globalThis : this);
@@ -10,6 +10,12 @@
@@ -10,6 +10,12 @@
* OVLocker.state() { unlocked, fingerprint, subject }
* OVLocker.state() { unlocked, fingerprint, subject }
* OVLocker.forget() drop the unlocked keys from this tab
* OVLocker.forget() drop the unlocked keys from this tab
* OVLocker.keyFromLink(href?) the content key a link carries after #k= (link locks)
* OVLocker.keyFromLink(href?) the content key a link carries after #k= (link locks)
+ * OVLocker.ensure() resolves once the locker is unlocked here (the unlock sheet when it is not)
+ * OVLocker.keys(who) a person's public keys from Network: null when they have no locker yet
+ * OVLocker.publicKeys() my public keys, for locking something for myself as well as for others
+ * OVLocker.rewrap(bytes, { add, remove, keys }) a people-locked envelope re-wrapped, the content never leaving
+ * OVLocker.seal(content, { lock, keys, password }) seal content here: the lock is made in this tab
+ * OVLocker.inspect(bytes) the header of an envelope, without a key
* window.OpenVibeLockerConfig = { network: 'https://openvibe.network' } where the key directory is
* window.OpenVibeLockerConfig = { network: 'https://openvibe.network' } where the key directory is
*
*
* The cryptography is ov-locker-core.js beside this file (generated from openvibe-sdk/locker: WebCrypto only, the
* The cryptography is ov-locker-core.js beside this file (generated from openvibe-sdk/locker: WebCrypto only, the
@@ -372,6 +378,55 @@
@@ -372,6 +378,55 @@
return s.finished;
return s.finished;
}
}
+ // ── The locker as a tool: a public key, a lookup, and a re-wrap ─────────────────────────────────────────
+ /** The locker, unlocked on this device: unlocks it (the sheet) when it is not, and says so when that is declined. */
+ async function ensure() {
+ if (session) return state();
+ const st = await unlock();
+ if (!st || !st.unlocked) throw Object.assign(new Error('Unlock your locker to do this.'), { code: 'locker.locked' });
+ return st;
+ }
+ /** My public keys, as they are published: what a people lock wraps for me too. */
+ async function publicKeys() {
+ if (!session) return null;
+ const c = await core();
+ return await c.exportPersonKeys(session.person);
+ }
+ /**
+ * A people-locked envelope, its key slots wrapped again: `add` [{ subject, publicKey }] keeps the content and
+ * the chunks and adds their slots; `remove` [subject] seals the content under a fresh key for `keys` (the public
+ * keys of those who stay), so the person who left opens nothing. The content key and the plaintext stay here.
+ */
+ async function rewrap(bytes, { add = [], remove = [], keys = [] } = {}) {
+ await ensure();
+ const c = await core();
+ const out = await c.rewrap(bytes, { add, remove, keys, credentials: { person: session.person } });
+ return { bytes: out.bytes, header: out.header };
+ }
+ /** A person's public keys from Network's directory (by subject or by @name): null when they have no locker yet. */
+ async function keys(who) {
+ const id = who && typeof who === 'object' ? (who.subject && who.subject.id) || who.id : null;
+ const path = id
+ ? `/api/v1/people/${encodeURIComponent(id)}/keys`
+ : `/api/v1/people/@${encodeURIComponent(String(who || '').replace(/^@+/, ''))}/keys`;
+ try { return await network('GET', path); } catch (e) { if (e.status === 404) return null; throw e; }
+ }
+ /**
+ * Seal content on this device (a new locked item): `{ lock: 'people', keys: [{ subject, publicKey }] }`,
+ * `{ lock: 'password', password }`, or `{ lock: 'link' }`, whose key comes back in `linkKey` for the link's #k=.
+ * The content key is made and kept here; only the envelope leaves.
+ */
+ async function seal(content, opts = {}) {
+ const c = await core();
+ const out = await c.seal(content, opts);
+ return { bytes: out.bytes, header: out.header, linkKey: out.linkKey || null };
+ }
+ /** An envelope's header, without any key. */
+ async function inspectBytes(bytes) {
+ const c = await core();
+ return c.inspect(bytes);
+ }
+
// ── The card a locked item shows ────────────────────────────────────────────────────────────────────────
// ── The card a locked item shows ────────────────────────────────────────────────────────────────────────
async function open(container, opts) {
async function open(container, opts) {
const o = Object.assign({}, opts || {});
const o = Object.assign({}, opts || {});
@@ -450,5 +505,8 @@
@@ -450,5 +505,8 @@
});
});
}
}
- root.OVLocker = { open, setup, unlock, state, forget, keyFromLink, strength, version: 1 };
+ root.OVLocker = {
+ open, setup, unlock, ensure, keys, publicKeys, rewrap, seal, inspect: inspectBytes,
+ state, forget, keyFromLink, strength, version: 2,
+ };
})(typeof window !== 'undefined' ? window : null);
})(typeof window !== 'undefined' ? window : null);
@@ -130,6 +130,27 @@ html.ov-share-locked, html.ov-share-locked body { overflow: hidden; overscroll-b
@@ -130,6 +130,27 @@ html.ov-share-locked, html.ov-share-locked body { overflow: hidden; overscroll-b
.ov-share-seglabel { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ov-share-seglabel { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ov-share-aud:disabled .ov-share-segopt:not(:has(input:checked)) { opacity: .55; }
.ov-share-aud:disabled .ov-share-segopt:not(:has(input:checked)) { opacity: .55; }
+/* ── Locked: what the lock means, and the fingerprints it is wrapped for ───────────────────────────────────── */
+.ov-share-lock { display: grid; gap: 8px; min-width: 0; padding: 10px 12px; border-radius: var(--ovs-radius); background: color-mix(in srgb, var(--ovs-accent) 6%, var(--ovs-hover)); }
+.ov-share-lockhead {
+ -webkit-appearance: none; appearance: none; display: flex; align-items: center; gap: 10px; width: 100%; min-height: 34px;
+ margin: 0; padding: 0; border: 0; background: none; color: var(--ovs-text); font: inherit; text-align: left; cursor: pointer;
+}
+.ov-share-lockhead .ov-share-ico { color: var(--ovs-accent); }
+.ov-share-lockhead:focus-visible { outline: 3px solid color-mix(in srgb, var(--ovs-accent) 40%, transparent); outline-offset: 3px; border-radius: 8px; }
+.ov-share-locklabel { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; }
+.ov-share-locktitle { font-weight: 700; font-size: 14px; }
+.ov-share-lockcaret { flex: none; width: 20px; text-align: center; color: var(--ovs-muted); font-size: 17px; line-height: 1; }
+.ov-share-lockshort { font-size: 12.5px; }
+.ov-share-lockbody { display: grid; gap: 8px; min-width: 0; }
+.ov-share-lockmeans { margin: 0; font-size: 13.5px; color: var(--ovs-text); }
+.ov-share-fps { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
+.ov-share-fprow { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; min-width: 0; font-size: 12.5px; }
+.ov-share-fpwho { flex: none; color: var(--ovs-text); font-weight: 650; }
+.ov-share-fp { min-width: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--ovs-muted); white-space: normal; overflow-wrap: anywhere; }
+.ov-share-fp.is-changed { color: var(--warning-text, var(--ovs-warn)); }
+.ov-share-tag.is-warn { color: var(--warning-text, var(--ovs-warn)); background: color-mix(in srgb, var(--ovs-warn) 16%, transparent); }
+
/* ── People ─────────────────────────────────────────────────────────────────────────────────────────────────── */
/* ── People ─────────────────────────────────────────────────────────────────────────────────────────────────── */
.ov-share-people { display: grid; gap: 10px; min-width: 0; }
.ov-share-people { display: grid; gap: 10px; min-width: 0; }
.ov-share-people .ov-share-label { margin: 0; }
.ov-share-people .ov-share-label { margin: 0; }
@@ -12,6 +12,13 @@
@@ -12,6 +12,13 @@
* Who can open it for the owner, when the thing has kind and id: Everyone, Anyone with the link, Specific people
* Who can open it for the owner, when the thing has kind and id: Everyone, Anyone with the link, Specific people
* (by @name, each Can view or Can edit, with an end date), Only me; people who asked; Transfer.
* (by @name, each Can view or Can edit, with an end date), Only me; people who asked; Transfer.
*
*
+ * A locked item (Locker, ov-locker.js) gets one more part between them: what its lock means in plain words, each
+ * person's eight-word fingerprint (and a warning when their key changed), and, for a people lock, adding someone (their
+ * content key is wrapped for them on this device) and removing someone (the content is sealed again under a fresh key,
+ * and the sheet says they keep what they already opened). A link lock's key is the part of the link after #, which
+ * browsers never send; the sheet never invents it, it copies the whole link. ov-locker.js is loaded on the first press
+ * of that part, never before.
+ *
* <button type="button" data-ov-share-sheet data-title="…">Share</button> the page's own address
* <button type="button" data-ov-share-sheet data-title="…">Share</button> the page's own address
* data-url="https://…" the link to share (else the page's address)
* data-url="https://…" the link to share (else the page's address)
* data-text="…" a line to send with it (email, the phone's sheet)
* data-text="…" a line to send with it (email, the phone's sheet)
@@ -54,6 +61,22 @@
@@ -54,6 +61,22 @@
private: { label: 'Only me', title: 'Only me', desc: 'Only you can open it.' },
private: { label: 'Only me', title: 'Only me', desc: 'Only you can open it.' },
};
};
const ROLE_WORDS = { viewer: 'Can view', editor: 'Can edit' };
const ROLE_WORDS = { viewer: 'Can view', editor: 'Can edit' };
+ // What each lock means, in the words the person choosing it reads (Locker design 2026-11-06: "OpenVibe can't read
+ // what's inside"). The same three sentences are on the page that makes a locked item.
+ const LOCK_WORDS = {
+ people: {
+ label: 'For the people on the list',
+ means: 'Only you and the people below can open it, each with their own keys. OpenVibe can’t read what’s inside.',
+ },
+ password: {
+ label: 'With a password',
+ means: 'Anyone you give the password to can open it. OpenVibe can’t read what’s inside, and if the password is lost, nobody can open it, including us.',
+ },
+ link: {
+ label: 'With the link',
+ means: 'The key travels in the link, after the #, which browsers never send to OpenVibe. Anyone with the whole link can open it.',
+ },
+ };
const svg = (d, extra) => `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"${extra || ''}>${d}</svg>`;
const svg = (d, extra) => `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"${extra || ''}>${d}</svg>`;
const ICON = {
const ICON = {
public: svg('<circle cx="12" cy="12" r="8.5"/><path d="M3.5 12h17M12 3.5c2.6 2.4 3.8 5.3 3.8 8.5s-1.2 6.1-3.8 8.5c-2.6-2.4-3.8-5.3-3.8-8.5s1.2-6.1 3.8-8.5Z"/>'),
public: svg('<circle cx="12" cy="12" r="8.5"/><path d="M3.5 12h17M12 3.5c2.6 2.4 3.8 5.3 3.8 8.5s-1.2 6.1-3.8 8.5c-2.6-2.4-3.8-5.3-3.8-8.5s1.2-6.1 3.8-8.5Z"/>'),
@@ -183,11 +206,20 @@
@@ -183,11 +206,20 @@
return [...recent.map((id) => list.find((t) => t[0] === id)), ...list.filter((t) => !recent.includes(t[0]))];
return [...recent.map((id) => list.find((t) => t[0] === id)), ...list.filter((t) => !recent.includes(t[0]))];
}
}
const hms = (s) => { s = Math.max(0, Math.floor(s)); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), x = s % 60; return h ? `${h}:${String(m).padStart(2, '0')}:${String(x).padStart(2, '0')}` : `${m}:${String(x).padStart(2, '0')}`; };
const hms = (s) => { s = Math.max(0, Math.floor(s)); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), x = s % 60; return h ? `${h}:${String(m).padStart(2, '0')}:${String(x).padStart(2, '0')}` : `${m}:${String(x).padStart(2, '0')}`; };
+ /** The key a link carries after #k= (a link lock): base64url, 32 bytes. The same rule as ov-locker.js. */
+ function linkKey(href) {
+ const h = String(href == null ? root.location.hash : href).replace(/^#/, '');
+ const m = h.match(/(?:^|&)k=([A-Za-z0-9_-]{43})(?:&|$)/) || String(root.location.hash || '').match(/(?:^|&)k=([A-Za-z0-9_-]{43})(?:&|$)/);
+ return m ? m[1] : null;
+ }
function theLink(st) {
function theLink(st) {
const raw = (st.server && st.server.link) || st.o.link || st.o.url || root.location.href;
const raw = (st.server && st.server.link) || st.o.link || st.o.url || root.location.href;
let u;
let u;
try { u = new URL(raw, root.location.href); } catch (_) { return raw; }
try { u = new URL(raw, root.location.href); } catch (_) { return raw; }
if (st.atTime && st.o.time > 0) u.searchParams.set(st.o.timeParam || 't', String(Math.floor(st.o.time)));
if (st.atTime && st.o.time > 0) u.searchParams.set(st.o.timeParam || 't', String(Math.floor(st.o.time)));
+ // A link-locked item is opened by the part after #; the service's own link never has it, the page's address does.
+ const key = st.server && st.server.lock === 'link' ? linkKey() : null;
+ if (key && !linkKey(u.href)) u.hash = `k=${key}`;
return u.href;
return u.href;
}
}
const shownLink = (href) => href.replace(/^https?:\/\//, '').replace(/\/$/, '');
const shownLink = (href) => href.replace(/^https?:\/\//, '').replace(/\/$/, '');
@@ -428,6 +460,7 @@
@@ -428,6 +460,7 @@
o, server: null, audience: 'private', members: [], notify: true, view: 'main', busy: false,
o, server: null, audience: 'private', members: [], notify: true, view: 'main', busy: false,
note: '', error: '', confirmLeave: false, saved: null, resolve, giveTo: null, keepAccess: false,
note: '', error: '', confirmLeave: false, saved: null, resolve, giveTo: null, keepAccess: false,
access: !!(o.kind && o.id), first: true, atTime: false, panel: '', allTargets: false,
access: !!(o.kind && o.id), first: true, atTime: false, panel: '', allTargets: false,
+ lockOpen: false, reseal: false,
};
};
st.dlg = el('dialog', 'ov-share', { 'aria-labelledby': 'ov-share-title' });
st.dlg = el('dialog', 'ov-share', { 'aria-labelledby': 'ov-share-title' });
st.close = (force) => close(st, force);
st.close = (force) => close(st, force);
@@ -468,6 +501,7 @@
@@ -468,6 +501,7 @@
function memberKey(m) { return m.subject ? sid(m.subject) : `name:${String(m.name || m.username).toLowerCase()}`; }
function memberKey(m) { return m.subject ? sid(m.subject) : `name:${String(m.name || m.username).toLowerCase()}`; }
function dirty(st) {
function dirty(st) {
if (!st.server) return false;
if (!st.server) return false;
+ if (st.reseal) return true;
if (st.audience !== (st.server.audience || 'private')) return true;
if (st.audience !== (st.server.audience || 'private')) return true;
const a = (st.server.members || []).map((m) => `${memberKey(m)}|${m.role}|${m.expires_at || ''}`).sort().join(',');
const a = (st.server.members || []).map((m) => `${memberKey(m)}|${m.role}|${m.expires_at || ''}`).sort().join(',');
const b = st.members.map((m) => `${memberKey(m)}|${m.role}|${m.expires_at || ''}`).sort().join(',');
const b = st.members.map((m) => `${memberKey(m)}|${m.role}|${m.expires_at || ''}`).sort().join(',');
@@ -546,7 +580,9 @@
@@ -546,7 +580,9 @@
if (s.transfer && s.transfer.state === 'pending' && mine) body.appendChild(pendingBanner(st));
if (s.transfer && s.transfer.state === 'pending' && mine) body.appendChild(pendingBanner(st));
// Who can open this: four choices, one line saying what the chosen one means.
// Who can open this: four choices, one line saying what the chosen one means.
- const offered = (Array.isArray(s.audiences) && s.audiences.length ? s.audiences : Object.keys(AUDIENCES)).filter((a) => AUDIENCES[a]);
+ // A locked item is never public (the service refuses it too): the choice is not offered.
+ const offered = (Array.isArray(s.audiences) && s.audiences.length ? s.audiences : Object.keys(AUDIENCES))
+ .filter((a) => AUDIENCES[a] && !(isLocked(st) && a === 'public'));
const fs = el('fieldset', 'ov-share-aud', { disabled: !mine || st.busy });
const fs = el('fieldset', 'ov-share-aud', { disabled: !mine || st.busy });
fs.appendChild(el('legend', 'ov-share-label', null, 'Who can open this'));
fs.appendChild(el('legend', 'ov-share-label', null, 'Who can open this'));
const seg = el('div', 'ov-share-seg', { style: `--n:${offered.length}` });
const seg = el('div', 'ov-share-seg', { style: `--n:${offered.length}` });
@@ -567,6 +603,9 @@
@@ -567,6 +603,9 @@
fs.appendChild(desc);
fs.appendChild(desc);
body.appendChild(fs);
body.appendChild(fs);
+ // Locked items: what the lock means, the fingerprints, and the people they are wrapped for.
+ if (isLocked(st)) body.appendChild(lockSection(st));
+
// People: who has access (or, on an open item, who may edit it).
// People: who has access (or, on an open item, who may edit it).
const open = st.audience === 'public' || st.audience === 'link';
const open = st.audience === 'public' || st.audience === 'link';
const canEditRole = roles(st).includes('editor');
const canEditRole = roles(st).includes('editor');
@@ -619,6 +658,183 @@
@@ -619,6 +658,183 @@
if (st.error) body.appendChild(el('p', 'ov-share-error', { role: 'alert' }, st.error));
if (st.error) body.appendChild(el('p', 'ov-share-error', { role: 'alert' }, st.error));
}
}
+ // ── Locked items (Locker) ────────────────────────────────────────────────────────────────────────────────
+ // ov-locker.js loads on the first press of the Locked part, never before: the lock's words and the fingerprints
+ // come from the service's own answer; only the wrapping and the re-seal need the keys, and they live in that file.
+ const isLocked = (st) => !!(st.server && st.server.lock && st.server.lock !== 'none');
+ async function locker() {
+ if (root.OVLocker) return root.OVLocker;
+ const ok = await loadSibling('ov-locker.js', 'OVLocker');
+ return ok && root.OVLocker ? root.OVLocker : null;
+ }
+ const b64 = (u8) => { let s = ''; for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return root.btoa(s); };
+ /** The people whose slot was in the stored envelope and who are no longer on the list. */
+ function removedMembers(st) {
+ if (!st.server) return [];
+ return (st.server.members || []).filter((m) => m.key_slot && !st.members.some((x) => same(x.subject, m.subject)));
+ }
+ function lockSection(st) {
+ const kind = st.server.lock;
+ const words = LOCK_WORDS[kind] || { label: kind, means: '' };
+ const sec = el('section', 'ov-share-lock', { 'aria-labelledby': 'ov-share-lock-h' });
+ const head = el('button', 'ov-share-lockhead', { type: 'button', 'data-k': 'lock-open', 'aria-expanded': st.lockOpen ? 'true' : 'false', 'aria-controls': 'ov-share-lock-body' });
+ head.appendChild(iconEl('private'));
+ const w = el('span', 'ov-share-locklabel');
+ w.appendChild(el('span', 'ov-share-locktitle', { id: 'ov-share-lock-h' }, 'Locked'));
+ w.appendChild(el('span', 'ov-share-muted', null, words.label));
+ head.appendChild(w);
+ head.appendChild(el('span', 'ov-share-lockcaret', { 'aria-hidden': 'true' }, st.lockOpen ? '−' : '+'));
+ head.addEventListener('click', () => {
+ st.lockOpen = !st.lockOpen;
+ if (st.lockOpen && !root.OVLocker) locker(); // the words need nothing; the sealing later does
+ draw(st);
+ });
+ sec.appendChild(head);
+ if (!st.lockOpen) {
+ sec.appendChild(el('p', 'ov-share-muted ov-share-lockshort', null, 'OpenVibe can’t read what’s inside.'));
+ return sec;
+ }
+ const inner = el('div', 'ov-share-lockbody', { id: 'ov-share-lock-body' });
+ inner.appendChild(el('p', 'ov-share-lockmeans', null, words.means));
+ if (kind === 'link') {
+ const key = linkKey(theLink(st));
+ inner.appendChild(el('p', key ? 'ov-share-muted' : 'ov-share-warn', { role: key ? null : 'status' }, key
+ ? 'The whole link, including the part after #, is the key. Copy sends the whole link.'
+ : 'This link is missing its key: a locked link ends with #k=… Ask whoever shared it for the whole link. OpenVibe never had the key, so it cannot send it.'));
+ }
+ if (kind === 'password') {
+ inner.appendChild(el('p', 'ov-share-muted', null, 'The password is not stored anywhere. If you lose it, nobody can open this: there is no reset.'));
+ }
+ if (kind === 'people') {
+ inner.appendChild(el('p', 'ov-share-muted', null, 'Each person’s content key is wrapped for their locker on this device. OpenVibe never sees a key.'));
+ const known = st.members.filter((m) => m.key_fingerprint);
+ if (known.length) {
+ const ul = el('ul', 'ov-share-fps', { 'aria-label': 'Fingerprints' });
+ for (const m of known) {
+ const li = el('li', 'ov-share-fprow');
+ li.appendChild(el('span', 'ov-share-fpwho', null, handle(m) || nameOf(m)));
+ li.appendChild(el('span', 'ov-share-fp', null, m.key_fingerprint));
+ if (m.fingerprint_changed) li.appendChild(el('span', 'ov-share-tag is-warn', null, 'key changed'));
+ ul.appendChild(li);
+ }
+ inner.appendChild(ul);
+ }
+ const changed = st.members.filter((m) => m.fingerprint_changed);
+ if (changed.length) {
+ const who = changed.map((m) => handle(m) || nameOf(m)).join(', ');
+ inner.appendChild(el('p', 'ov-share-warn', { role: 'status' }, `${who} changed locker keys since this was shared for them. Compare the new fingerprint with them, in person or on a call, before you use it.`));
+ const use = el('button', 'ov-share-btn is-small is-quiet', { type: 'button', 'data-k': 'use-new-key' }, 'Use their new key');
+ use.addEventListener('click', async () => {
+ use.disabled = true;
+ try {
+ const L = await locker();
+ if (!L) throw new Error('The Locker could not load. Check your connection and try again.');
+ for (const m of changed) {
+ const keys = await L.keys(m.subject);
+ if (!keys) throw new Error(`${handle(m) || nameOf(m)} has no locker now.`);
+ m._publicKey = keys.ecdh_public || keys.ecdh;
+ m._keyFingerprint = keys.fingerprint || null;
+ m.fingerprint_changed = false;
+ m.useNewKey = true;
+ }
+ st.reseal = true; st.lockNote = 'Their new key is used when you save.';
+ st.lockError = '';
+ } catch (e) { st.lockError = e.message; }
+ draw(st);
+ });
+ inner.appendChild(use);
+ }
+ const gone = removedMembers(st);
+ if (gone.length) {
+ const who = gone.map((m) => handle(m) || nameOf(m)).join(', ');
+ inner.appendChild(el('p', 'ov-share-warn', { role: 'status' },
+ `They had the key before you removed them (${who}). They may still have a copy of what they already opened.`));
+ }
+ if (st.lockNote) inner.appendChild(el('p', 'ov-share-note', { role: 'status' }, st.lockNote));
+ if (st.lockError) inner.appendChild(el('p', 'ov-share-error', { role: 'alert' }, st.lockError));
+ }
+ sec.appendChild(inner);
+ return sec;
+ }
+
+ /** The person's public key, asked of Network through the Locker: a people lock cannot be made without it. */
+ async function attachKey(st, p) {
+ if (!p || !p.subject) { st.addMsg = `We could not confirm ${handle(p) || nameOf(p)}’s account, so no key is known.`; st.addMsgBad = true; return false; }
+ try {
+ const L = await locker();
+ if (!L) throw new Error('The Locker could not load. Check your connection and try again.');
+ const keys = await L.keys(p.subject);
+ if (!keys) {
+ st.addMsg = `${handle(p) || nameOf(p)} has no locker yet: they make one the first time they open a locked item.`;
+ st.addMsgBad = true;
+ return false;
+ }
+ p._publicKey = keys.ecdh_public || keys.ecdh;
+ p._keyFingerprint = keys.fingerprint || null;
+ p.needsKey = true;
+ return true;
+ } catch (e) {
+ st.addMsg = `Could not get ${handle(p) || nameOf(p)}’s key: ${e.message}`;
+ st.addMsgBad = true;
+ return false;
+ }
+ }
+
+ /** The bytes a locked item serves: its envelope. */
+ async function envelopeBytes(st) {
+ const url = (st.server && st.server.envelope_url) || st.o.envelope_url;
+ if (!url) throw new Error('This service did not say where the locked item is.');
+ const r = await fetch(url, { credentials: st.o.base ? 'include' : 'same-origin', headers: { accept: 'application/octet-stream' } });
+ if (!r.ok) throw new Error(`The locked item answered ${r.status}.`);
+ return new Uint8Array(await r.arrayBuffer());
+ }
+
+ /**
+ * For a people lock whose list changed: the envelope re-wrapped here. Someone added gets their slot (the content key
+ * never leaves this device); someone removed means the content is sealed again under a fresh key for those who stay,
+ * so the key the person leaving holds opens nothing. The service stores the bytes and never reads them.
+ */
+ async function resealIfNeeded(st) {
+ const before = st.server.members || [];
+ const after = st.audience === 'private' ? [] : st.members;
+ const gone = before.filter((m) => !after.some((x) => same(x.subject, m.subject)));
+ const needing = after.filter((m) => m.needsKey || m.fingerprint_changed || m.useNewKey);
+ // A slot that is already there has to go when it is wrapped again for the same person: two slots for one
+ // person would leave the old key in the envelope.
+ const stale = needing.filter((m) => m.key_slot);
+ const remove = [...gone, ...stale].map((m) => m.subject);
+ if (!remove.length && !needing.length && !st.reseal) return null;
+ const L = await locker();
+ if (!L) throw new Error('The Locker could not load. Check your connection and try again.');
+ await L.ensure();
+ const bytes = await envelopeBytes(st);
+ const publicKey = async (m) => {
+ if (m._publicKey) return m._publicKey;
+ const keys = await L.keys(m.subject);
+ if (!keys) throw new Error(`${handle(m) || nameOf(m)} has no locker now.`);
+ m._publicKey = keys.ecdh_public || keys.ecdh;
+ m._keyFingerprint = keys.fingerprint || null;
+ return m._publicKey;
+ };
+ const add = [];
+ for (const m of needing) add.push({ subject: m.subject, publicKey: await publicKey(m) });
+ const keys = [];
+ const me = await L.publicKeys();
+ const my = st.server.you && st.server.you.subject;
+ if (remove.length && my && me) keys.push({ subject: my, publicKey: me.ecdh });
+ if (remove.length) for (const m of after) keys.push({ subject: m.subject, publicKey: await publicKey(m) });
+ const out = await L.rewrap(bytes, { add, remove, keys });
+ const slots = new Map((out.header.slots.people || []).map((slot) => [sid(slot.subject), slot]));
+ const list = after.map((m) => {
+ const slot = slots.get(sid(m.subject));
+ if (!slot) throw new Error(`The sealed envelope has no key for ${handle(m) || nameOf(m)}.`);
+ const r = { subject: m.subject, role: m.role, key_slot: slot };
+ if (m.expires_at) r.expires_at = m.expires_at;
+ return r;
+ });
+ return { members: list, envelope: b64(out.bytes) };
+ }
+
// The @name field: Enter or Add checks the name with Network (through the service), then adds a chip-row.
// The @name field: Enter or Add checks the name with Network (through the service), then adds a chip-row.
function adder(st) {
function adder(st) {
const wrap = el('form', 'ov-share-add', { novalidate: true });
const wrap = el('form', 'ov-share-add', { novalidate: true });
@@ -667,15 +883,20 @@
@@ -667,15 +883,20 @@
st.checking = true; st.addMsg = ''; draw(st);
st.checking = true; st.addMsg = ''; draw(st);
const missing = [];
const missing = [];
try {
try {
+ const peopleLock = !!(st.server && st.server.lock === 'people');
for (const n of names) {
for (const n of names) {
const p = await lookup(st.o, n);
const p = await lookup(st.o, n);
if (p === null) missing.push(n);
if (p === null) missing.push(n);
+ else if (p === undefined && peopleLock) { st.addMsg = `We could not check @${n}, so no key is known for them. Try again.`; st.addMsgBad = true; }
else if (p === undefined) addPerson(st, { name: n, username: n, unchecked: true }, true);
else if (p === undefined) addPerson(st, { name: n, username: n, unchecked: true }, true);
+ else if (peopleLock) { if (await attachKey(st, p)) addPerson(st, p, true); }
else addPerson(st, p, true);
else addPerson(st, p, true);
}
}
st.draftName = missing.join(' ');
st.draftName = missing.join(' ');
- st.addMsg = missing.length ? `No OpenVibe account is called ${missing.map((n) => `@${n}`).join(', ')}.` : '';
- st.addMsgBad = !!missing.length;
+ if (missing.length) {
+ st.addMsg = `No OpenVibe account is called ${missing.map((n) => `@${n}`).join(', ')}.`;
+ st.addMsgBad = true;
+ }
} catch (e) {
} catch (e) {
st.addMsg = e.message; st.addMsgBad = true;
st.addMsg = e.message; st.addMsgBad = true;
}
}
@@ -688,7 +909,14 @@
@@ -688,7 +909,14 @@
if (st.members.some((m) => memberKey(m) === memberKey(p))) { st.addMsg = `${handle(p)} is on the list already.`; st.addMsgBad = false; if (!quiet) draw(st); return; }
if (st.members.some((m) => memberKey(m) === memberKey(p))) { st.addMsg = `${handle(p)} is on the list already.`; st.addMsgBad = false; if (!quiet) draw(st); return; }
const open = st.audience === 'public' || st.audience === 'link';
const open = st.audience === 'public' || st.audience === 'link';
const role = open && roles(st).includes('editor') ? 'editor' : 'viewer';
const role = open && roles(st).includes('editor') ? 'editor' : 'viewer';
- st.members.push({ subject: p.subject, name: p.subject ? undefined : p.name, username: p.username, display_name: p.display_name, avatar_url: p.avatar_url, role, expires_at: null, isNew: true, unchecked: !!p.unchecked });
+ st.members.push({
+ subject: p.subject, name: p.subject ? undefined : p.name, username: p.username, display_name: p.display_name,
+ avatar_url: p.avatar_url, role, expires_at: null, isNew: true, unchecked: !!p.unchecked,
+ ...(p.key_slot ? { key_slot: p.key_slot } : {}),
+ ...(p.needsKey ? { needsKey: true } : {}),
+ ...(p._publicKey ? { _publicKey: p._publicKey } : {}),
+ ...(p._keyFingerprint || p.key_fingerprint ? { key_fingerprint: p._keyFingerprint || p.key_fingerprint } : {}),
+ });
if (st.audience === 'private') { st.audience = 'people'; st.note = 'Switched to Specific people, so they can open it.'; }
if (st.audience === 'private') { st.audience = 'people'; st.note = 'Switched to Specific people, so they can open it.'; }
if (!quiet) { st.draftName = ''; draw(st); }
if (!quiet) { st.draftName = ''; draw(st); }
}
}
@@ -706,6 +934,11 @@
@@ -706,6 +934,11 @@
if (p.username) sub.appendChild(el('span', null, null, handle(p)));
if (p.username) sub.appendChild(el('span', null, null, handle(p)));
if (x.member && x.member.unchecked) sub.appendChild(el('span', 'ov-share-soft', null, 'checked when you save'));
if (x.member && x.member.unchecked) sub.appendChild(el('span', 'ov-share-soft', null, 'checked when you save'));
if (x.member) sub.appendChild(expiry(st, x.member));
if (x.member) sub.appendChild(expiry(st, x.member));
+ // A people lock shows which key the slot is for: the eight words a person can compare out of band.
+ if (x.member && isLocked(st) && st.server.lock === 'people' && x.member.key_fingerprint) {
+ sub.appendChild(el('span', `ov-share-fp${x.member.fingerprint_changed ? ' is-changed' : ''}`, { title: 'Their locker’s fingerprint' }, x.member.key_fingerprint));
+ if (x.member.fingerprint_changed) sub.appendChild(el('span', 'ov-share-soft', null, 'key changed'));
+ }
words.appendChild(sub);
words.appendChild(sub);
li.appendChild(words);
li.appendChild(words);
if (x.tag) { li.appendChild(el('span', 'ov-share-role is-fixed', null, x.tag)); return li; }
if (x.tag) { li.appendChild(el('span', 'ov-share-role is-fixed', null, x.tag)); return li; }
@@ -724,7 +957,9 @@
@@ -724,7 +957,9 @@
rm.appendChild(iconEl('close'));
rm.appendChild(iconEl('close'));
rm.addEventListener('click', () => {
rm.addEventListener('click', () => {
st.members.splice(st.members.indexOf(m), 1);
st.members.splice(st.members.indexOf(m), 1);
- st.note = m.isNew ? '' : `${handle(m) || nameOf(m)} loses access when you save. They may still have a copy of what they already opened.`;
+ st.note = m.isNew ? '' : isLocked(st) && st.server.lock === 'people'
+ ? `${handle(m) || nameOf(m)} loses access when you save. They had the key before you removed them: they may still have a copy of what they already opened.`
+ : `${handle(m) || nameOf(m)} loses access when you save. They may still have a copy of what they already opened.`;
draw(st);
draw(st);
});
});
li.appendChild(rm);
li.appendChild(rm);
@@ -922,7 +1157,7 @@
@@ -922,7 +1157,7 @@
}
}
async function save(st) {
async function save(st) {
- st.busy = true; st.error = ''; draw(st);
+ st.busy = true; st.error = ''; st.lockError = ''; draw(st);
const members = st.audience === 'private' ? [] : st.members.map((m) => {
const members = st.audience === 'private' ? [] : st.members.map((m) => {
const r = { role: m.role };
const r = { role: m.role };
if (m.expires_at) r.expires_at = m.expires_at;
if (m.expires_at) r.expires_at = m.expires_at;
@@ -930,9 +1165,16 @@
@@ -930,9 +1165,16 @@
return r;
return r;
});
});
try {
try {
- const out = await call(st.o, 'PUT', endpoint(st.o).path, { audience: st.audience, members, notify: !!st.notify }, st.etag ? { 'if-match': st.etag } : null);
+ const body = { audience: st.audience, members, notify: !!st.notify };
+ // A people lock follows its people: someone added is wrapped for now, someone removed means the content is
+ // sealed again. The bytes go up with the list, and this browser is the only place they were opened.
+ if (isLocked(st) && st.server.lock === 'people') {
+ const sealed = await resealIfNeeded(st);
+ if (sealed) { body.members = sealed.members; body.envelope = sealed.envelope; }
+ }
+ const out = await call(st.o, 'PUT', endpoint(st.o).path, body, st.etag ? { 'if-match': st.etag } : null);
take(st, out && out.audience ? out : await call(st.o, 'GET', endpoint(st.o).path));
take(st, out && out.audience ? out : await call(st.o, 'GET', endpoint(st.o).path));
- st.saved = st.server; st.note = 'Saved.';
+ st.saved = st.server; st.note = 'Saved.'; st.reseal = false; st.lockNote = '';
announce(st.o, st.server);
announce(st.o, st.server);
} catch (e) {
} catch (e) {
if (e.status === 412) {
if (e.status === 412) {
@@ -941,7 +1183,12 @@
@@ -941,7 +1183,12 @@
draw(st); return;
draw(st); return;
}
}
const names = e.body && Array.isArray(e.body.names) ? e.body.names : null;
const names = e.body && Array.isArray(e.body.names) ? e.body.names : null;
- st.error = names && names.length ? `No OpenVibe account is called ${names.map((n) => `@${n}`).join(', ')}. Remove them, then save.` : `Could not save: ${e.message}`;
+ const code = (e.body && e.body.code) || e.code;
+ if (code === 'share.key_changed') st.error = 'Someone’s locker key changed. Open the Locked part and use their new key, then save again.';
+ else if (code === 'share.no_key') st.error = 'Someone on the list has no locker yet. They make one the first time they open a locked item.';
+ else if (code === 'share.reseal_required') st.error = 'Removing someone from a locked item seals it again here. Unlock your locker and save again.';
+ else if (code === 'share.slot_required') st.error = 'Every person on a locked item needs their key. Add them again, then save.';
+ else st.error = names && names.length ? `No OpenVibe account is called ${names.map((n) => `@${n}`).join(', ')}. Remove them, then save.` : `Could not save: ${e.message}`;
}
}
st.busy = false; draw(st);
st.busy = false; draw(st);
}
}
@@ -1046,5 +1293,5 @@
@@ -1046,5 +1293,5 @@
time: time >= 5 ? time : 0, timeParam: d.timeParam, base: d.base, path: d.path };
time: time >= 5 ? time : 0, timeParam: d.timeParam, base: d.base, path: d.path };
}
}
- root.OVShare = { open, offer, gate, fromButton, version: 2 };
+ root.OVShare = { open, offer, gate, fromButton, version: 3 };
})(typeof window !== 'undefined' ? window : null);
})(typeof window !== 'undefined' ? window : null);
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.115.1",
+ "version": "4.116.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.115.1",
+ "version": "4.116.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -19,13 +19,13 @@
@@ -19,13 +19,13 @@
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
"express": "^4.22.3",
"express": "^4.22.3",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.86.0"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.89.0"
}
}
},
},
"node_modules/@electric-sql/pglite": {
"node_modules/@electric-sql/pglite": {
- "version": "0.5.8",
- "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
- "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
+ "version": "0.5.15",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.15.tgz",
+ "integrity": "sha512-ERZWgaRTxueGV4cvL1tfpAOzFCWTXoUtbCBqnf2FhHDVs/so9eAKaanvrwWF9Dntx7BYsmFQujVQ0/B4S8Mmiw==",
"dev": true,
"dev": true,
"license": "Apache-2.0"
"license": "Apache-2.0"
},
},
@@ -86,9 +86,9 @@
@@ -86,9 +86,9 @@
"license": "MIT"
"license": "MIT"
},
},
"node_modules/body-parser": {
"node_modules/body-parser": {
- "version": "1.20.8",
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
- "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
+ "version": "1.20.9",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz",
+ "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==",
"dev": true,
"dev": true,
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
@@ -243,11 +243,14 @@
@@ -243,11 +243,14 @@
"url": "https://github.com/sponsors/fb55"
"url": "https://github.com/sponsors/fb55"
}
}
},
},
- "node_modules/cssom": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",
- "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
- "license": "MIT"
+ "node_modules/cssomnom": {
+ "version": "0.1.11",
+ "resolved": "https://registry.npmjs.org/cssomnom/-/cssomnom-0.1.11.tgz",
+ "integrity": "sha512-P4RkO1Sq2Ual77xypEmAc4MkM5k2Ha4UwtBPCOpJmXculGCYPgzsU/xnOCy084esZaPePgGkkJg4Il/HE7GS5A==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=20.0.0"
+ }
},
},
"node_modules/debug": {
"node_modules/debug": {
"version": "2.6.9",
"version": "2.6.9",
@@ -259,13 +262,6 @@
@@ -259,13 +262,6 @@
"ms": "2.0.0"
"ms": "2.0.0"
}
}
},
},
- "node_modules/debug/node_modules/ms": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/depd": {
"node_modules/depd": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -386,13 +382,17 @@
@@ -386,13 +382,17 @@
"license": "MIT"
"license": "MIT"
},
},
"node_modules/encodeurl": {
"node_modules/encodeurl": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
- "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.1.tgz",
+ "integrity": "sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A==",
"dev": true,
"dev": true,
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">= 0.8"
"node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
}
}
},
},
"node_modules/entities": {
"node_modules/entities": {
@@ -512,9 +512,9 @@
@@ -512,9 +512,9 @@
"license": "MIT"
"license": "MIT"
},
},
"node_modules/fast-uri": {
"node_modules/fast-uri": {
- "version": "3.1.8",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
- "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
+ "version": "3.1.9",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
+ "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
"dev": true,
"dev": true,
"funding": [
"funding": [
{
{
@@ -839,6 +839,12 @@
@@ -839,6 +839,12 @@
"npm": ">=6"
"npm": ">=6"
}
}
},
},
+ "node_modules/jsonwebtoken/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
"node_modules/jwa": {
"node_modules/jwa": {
"version": "2.0.1",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@@ -861,13 +867,13 @@
@@ -861,13 +867,13 @@
}
}
},
},
"node_modules/linkedom": {
"node_modules/linkedom": {
- "version": "0.18.13",
- "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.13.tgz",
- "integrity": "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==",
+ "version": "0.18.15",
+ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.15.tgz",
+ "integrity": "sha512-o0w7eD/SvoCao+pUi6dzocCLuMR11g4Tv/et0W2OUzwWmUcewntSzyl+Ua/K+0yKym0Y/Mav3nFAaI5Av5jvZg==",
"license": "ISC",
"license": "ISC",
"dependencies": {
"dependencies": {
"css-select": "^7.0.0",
"css-select": "^7.0.0",
- "cssom": "^0.5.0",
+ "cssomnom": "^0.1.11",
"html-escaper": "^3.0.3",
"html-escaper": "^3.0.3",
"htmlparser2": "^10.1.0",
"htmlparser2": "^10.1.0",
"uhyphen": "^0.2.0"
"uhyphen": "^0.2.0"
@@ -1003,9 +1009,10 @@
@@ -1003,9 +1009,10 @@
}
}
},
},
"node_modules/ms": {
"node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true,
"license": "MIT"
"license": "MIT"
},
},
"node_modules/negotiator": {
"node_modules/negotiator": {
@@ -1048,9 +1055,9 @@
@@ -1048,9 +1055,9 @@
}
}
},
},
"node_modules/on-finished": {
"node_modules/on-finished": {
- "version": "2.4.1",
- "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
- "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",
+ "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==",
"dev": true,
"dev": true,
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
@@ -1058,6 +1065,10 @@
@@ -1058,6 +1065,10 @@
},
},
"engines": {
"engines": {
"node": ">= 0.8"
"node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
@@ -1078,9 +1089,9 @@
@@ -1078,9 +1089,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.86.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.86.0",
- "integrity": "sha512-APn4qK1ErX9f/gR6l2AhGfj+n8/3ul87Otm69xN4wyJUt43WF6A2b0lMKDG9Ds6oQiOoCZJCjxCSanP0wtMuGA==",
+ "version": "0.89.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.89.0",
+ "integrity": "sha512-vZLwKIrjHPHnCXCJ9tHoKPGmm4jQLzzXxjdRgxWTwBks9vyhnWXkjCvA+SvPL45VDTeGJBQjZZsQPZVjduTsCQ==",
"dev": true,
"dev": true,
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
@@ -1142,9 +1153,9 @@
@@ -1142,9 +1153,9 @@
}
}
},
},
"node_modules/qs": {
"node_modules/qs": {
- "version": "6.16.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
- "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
+ "version": "6.16.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",
+ "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==",
"dev": true,
"dev": true,
"license": "BSD-3-Clause",
"license": "BSD-3-Clause",
"dependencies": {
"dependencies": {
@@ -1222,9 +1233,9 @@
@@ -1222,9 +1233,9 @@
"license": "MIT"
"license": "MIT"
},
},
"node_modules/semver": {
"node_modules/semver": {
- "version": "7.8.5",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
- "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "version": "7.8.6",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz",
+ "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==",
"license": "ISC",
"license": "ISC",
"bin": {
"bin": {
"semver": "bin/semver.js"
"semver": "bin/semver.js"
@@ -1258,6 +1269,13 @@
@@ -1258,6 +1269,13 @@
"node": ">= 0.8.0"
"node": ">= 0.8.0"
}
}
},
},
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/serve-static": {
"node_modules/serve-static": {
"version": "1.16.3",
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.115.1",
+ "version": "4.116.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -148,6 +148,6 @@
@@ -148,6 +148,6 @@
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
"express": "^4.22.3",
"express": "^4.22.3",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.86.0"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.89.0"
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,219 @@
@@ -0,0 +1,219 @@
+'use strict';
+// The Share sheet's Locked part, end to end in a real headless Chrome with the REAL crypto (ov-locker-core.js from
+// openvibe-sdk/locker) and a stand-in service + Network key directory:
+// ov-locker.js is not loaded until the Locked part is pressed;
+// a people-locked paste names its lock and its people's fingerprints in plain words;
+// adding @quinn wraps the content key for quinn on this device, and the envelope that goes up opens for quinn;
+// removing @maticus seals the content again under a fresh key: the new bytes open for quinn, not for maticus;
+// a link-locked paste's link keeps the key after #k=, and says so when the key is missing.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const h = require('../browser-harness');
+
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
+ console.log('ov-share-lock-chrome: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+
+const ROOT = path.join(__dirname, '..');
+const locker = require('openvibe-sdk/locker');
+const PASS = 'violet lantern river cobalt';
+const OWN = { type: 'user', id: 'usr_own' };
+const MAT = { type: 'user', id: 'usr_mat' };
+const QUI = { type: 'user', id: 'usr_q' };
+const PEOPLE = {
+ maticus: { subject: MAT, username: 'maticus', display_name: 'Maticus' },
+ quinn: { subject: QUI, username: 'quinn', display_name: 'Quinn' },
+};
+const keys = {}; // subject id → what Network publishes: { subject, ecdh_public, fingerprint }
+const items = {}; // path → envelope bytes
+let lockerPut = null; // the browser's PUT /api/v1/me/keys
+const puts = []; // every share save body
+let share = null; // the share GET body
+
+const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Locked</title><link rel="icon" href="data:,">
+<link rel="stylesheet" href="/ov-share.css"></head><body style="font:15px system-ui;padding:16px">
+<button type="button" id="sh" data-ov-share-sheet data-kind="paste" data-id="locked" data-title="Locked note">Share</button>
+<div id="item"></div>
+<script>window.OpenVibeLockerConfig = { network: '' };</script><script src="/ov-share.js"></script></body></html>`;
+
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ const json = (code, o) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
+ const file = { '/ov-share.js': 'text/javascript', '/ov-share.css': 'text/css', '/ov-locker.js': 'text/javascript', '/ov-locker.css': 'text/css', '/ov-locker-core.js': 'text/javascript' }[u.pathname];
+ if (file) { res.writeHead(200, { 'content-type': `${file}; charset=utf-8` }); return res.end(fs.readFileSync(path.join(ROOT, u.pathname.slice(1)))); }
+ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE); }
+ if (u.pathname === '/api/v1/me/keys' && req.method === 'PUT') { lockerPut = JSON.parse(body); return json(200, { ok: true }); }
+ if (u.pathname.startsWith('/p/') && u.pathname.endsWith('/raw') && items[u.pathname]) {
+ res.writeHead(200, { 'content-type': 'application/octet-stream' }); return res.end(Buffer.from(items[u.pathname]));
+ }
+ const who = u.pathname.match(/^\/api\/v1\/people\/([^/]+)\/keys$/);
+ if (who) {
+ const found = keys[decodeURIComponent(who[1])];
+ return found ? json(200, found) : json(404, { code: 'locker.no_keys', error: 'no keys' });
+ }
+ if (u.pathname === '/api/v1/share/people') {
+ const n = String(u.searchParams.get('name') || '').toLowerCase();
+ return json(200, { person: PEOPLE[n] || null });
+ }
+ if (u.pathname === '/api/v1/paste/locked/share' && req.method === 'GET') {
+ res.writeHead(200, { 'content-type': 'application/json', etag: '"4"' }); return res.end(JSON.stringify(share));
+ }
+ if (u.pathname === '/api/v1/paste/locked/share' && req.method === 'PUT') {
+ const b = JSON.parse(body);
+ puts.push(b);
+ // What the store does: the member rows as they were sent, with what the key directory has for them.
+ share = {
+ ...share,
+ audience: b.audience,
+ members: (b.members || []).map((m) => {
+ const id = m.subject && m.subject.id;
+ const known = Object.values(PEOPLE).find((p) => p.subject.id === id);
+ return { ...(known || { subject: m.subject, username: m.subject ? null : String(m.name) }), role: m.role, key_slot: m.key_slot };
+ }),
+ };
+ return json(200, share);
+ }
+ res.writeHead(404); res.end();
+ });
+});
+
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const mat = await locker.generatePersonKeys();
+ const matPub = await locker.exportPersonKeys(mat);
+ const qui = await locker.generatePersonKeys();
+ const quiPub = await locker.exportPersonKeys(qui);
+ keys[MAT.id] = { subject: MAT, ecdh_public: matPub.ecdh, signing_public: matPub.signing, fingerprint: matPub.fingerprint };
+ keys[QUI.id] = { subject: QUI, ecdh_public: quiPub.ecdh, signing_public: quiPub.signing, fingerprint: quiPub.fingerprint };
+
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-share-lock-'));
+ const browser = await h.launch({ tmpDir });
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+ const q = (k) => `document.querySelector('[data-k="${k}"]')`;
+ const type = (k, v) => `(() => { const i = ${q(k)}; i.value = ${JSON.stringify(v)}; i.dispatchEvent(new Event('input', { bubbles: true })); })()`;
+ const press = (k) => `${q(k)}.click()`;
+ const text = (sel) => `(document.querySelector(${JSON.stringify(sel)}) || {}).textContent || ''`;
+ try {
+ const page = await h.openPage(browser, await browser.newContext(), { width: 1280 });
+ await page.goto(`${base}/`, { maxMs: 5000 });
+
+ // The owner's locker, with the real crypto (two PBKDF2 seals). The sheet has already loaded ov-locker.js in
+ // that tab; here it is loaded the way a page that unlocks a locker does.
+ await page.evaluate(`(() => { const s = document.createElement('script'); s.src = '/ov-locker.js'; document.head.appendChild(s); })()`);
+ await wait(600);
+ await page.evaluate('(() => { OVLocker.setup(); })()'); await wait(300);
+ await page.evaluate(type('pass', PASS)); await page.evaluate(type('pass2', PASS)); await page.evaluate(press('next'));
+ await wait(1500);
+ const words = await page.evaluate("[...document.querySelectorAll('.ovl-words li')].map((li) => li.textContent)");
+ const labels = await page.evaluate("[...document.querySelectorAll('dialog .ovl-pair .ovl-label')].map((l) => Number(l.textContent.replace(/\\D/g, '')))");
+ await page.evaluate(type('w1', words[labels[0] - 1])); await page.evaluate(type('w2', words[labels[1] - 1])); await page.evaluate(press('make'));
+ await wait(9000);
+ await page.evaluate(press('done')); // the locker's own Done: the setup sheet closes
+ await wait(300);
+ assert.ok(lockerPut && lockerPut.ecdh_public, 'the owner has a locker');
+ keys[OWN.id] = { subject: OWN, ecdh_public: lockerPut.ecdh_public, signing_public: lockerPut.signing_public, fingerprint: lockerPut.fingerprint };
+
+ // The paste as it was made: a people lock for the owner and maticus, sealed before it was uploaded.
+ const plain = JSON.stringify({ v: 1, type: 'paste', title: 'Locked note', content: 'the secret words', language: 'text' });
+ const sealed = await locker.seal(plain, { lock: 'people', keys: [{ subject: OWN, publicKey: keys[OWN.id].ecdh_public }, { subject: MAT, publicKey: matPub.ecdh }] });
+ items['/p/locked/raw'] = sealed.bytes;
+ share = {
+ audience: 'people', audiences: ['people', 'private'], roles: ['viewer'], title: 'Locked note', link: `${base}/p/locked`,
+ lock: 'people', envelope_url: '/p/locked/raw',
+ you: { subject: OWN, role: 'owner' }, owner: { subject: OWN, username: 'owner', display_name: 'Owner' },
+ members: [{
+ ...PEOPLE.maticus, role: 'viewer', added_at: '2026-11-06T10:00:00Z', added_by: OWN,
+ key_slot: sealed.header.slots.people.find((s) => s.subject.id === MAT.id),
+ key_fingerprint: matPub.fingerprint, fingerprint_changed: false,
+ }],
+ requests: [], transfer: null,
+ };
+
+ // The sheet itself loads no Locker: a fresh tab proves it, and that the first press of the part does.
+ const fresh = await h.openPage(browser, await browser.newContext(), { width: 390 });
+ await fresh.goto(`${base}/`, { maxMs: 5000 });
+ assert.strictEqual(await fresh.evaluate('!!window.OVLocker'), false, 'ov-share.js alone loads no Locker');
+ await fresh.evaluate("document.getElementById('sh').click()");
+ await wait(400);
+ assert.match(await fresh.evaluate(text('.ov-share-lock')), /Locked/, 'a fresh tab sees the item is locked');
+ await fresh.evaluate(press('lock-open'));
+ await wait(600);
+ assert.ok(await fresh.evaluate('!!window.OVLocker'), 'pressing Locked loads ov-locker.js');
+ await fresh.evaluate(press('close'));
+
+ // 1. The Locked part says what the lock means, in plain words, and whose keys it is wrapped for.
+ await page.evaluate("document.getElementById('sh').click()");
+ await wait(400);
+ assert.match(await page.evaluate(text('.ov-share-lock')), /Locked/, 'the sheet says the item is locked');
+ assert.match(await page.evaluate(text('.ov-share-lockshort')), /can’t read what’s inside/, 'and says it in plain words');
+ await page.evaluate(press('lock-open'));
+ await wait(500);
+ assert.match(await page.evaluate(text('.ov-share-lockmeans')), /Only you and the people below can open it/, 'what a people lock means');
+ assert.ok((await page.evaluate(text('.ov-share-fp'))).includes(matPub.fingerprint.split(' · ')[0]), 'the member’s fingerprint is shown');
+
+ // 2. Add @quinn: their public key is wrapped for them here.
+ await page.evaluate(type('add', 'quinn'));
+ await page.evaluate(press('add-btn'));
+ await wait(600);
+ assert.match(await page.evaluate(text('.ov-share-list')), /Quinn/, 'quinn is on the list');
+ await page.evaluate(press('done'));
+ await wait(2500); // the re-wrap
+ assert.strictEqual(puts.length, 1, 'one save');
+ const addPut = puts[0];
+ assert.ok(addPut.envelope, 'the save carries the envelope sealed again');
+ const quinnRow = addPut.members.find((m) => m.subject && m.subject.id === QUI.id);
+ assert.ok(quinnRow && quinnRow.key_slot, 'quinn’s member row carries their key slot');
+ assert.strictEqual(quinnRow.key_slot.recipient_key, await locker.jwkThumbprint(quiPub.ecdh), 'the slot names quinn’s key');
+ const afterAdd = Buffer.from(addPut.envelope, 'base64');
+ assert.deepStrictEqual([...new Uint8Array(await locker.open(afterAdd, { privateKey: qui.ecdh.privateKey, subject: QUI }))], [...Buffer.from(plain)], 'the envelope that went up opens for quinn');
+ assert.deepStrictEqual([...new Uint8Array(await locker.open(afterAdd, { privateKey: mat.ecdh.privateKey, subject: MAT }))], [...Buffer.from(plain)], 'and for maticus, whose slot stayed');
+
+ // 3. Remove @maticus: fresh key, and the bytes that went up do not open for them.
+ items['/p/locked/raw'] = afterAdd;
+ await page.evaluate("document.getElementById('sh').click()");
+ await wait(400);
+ await page.evaluate(press('rm-user:usr_mat'));
+ await wait(200);
+ assert.match(await page.evaluate(text('.ov-share-note')), /They had the key before you removed them/, 'removal says they keep what they opened');
+ await page.evaluate(press('done'));
+ await wait(3000);
+ assert.strictEqual(puts.length, 2, 'the removal saved');
+ const removePut = puts[1];
+ assert.ok(removePut.envelope, 'the removal carries a re-sealed envelope');
+ const afterRemove = Buffer.from(removePut.envelope, 'base64');
+ assert.deepStrictEqual([...new Uint8Array(await locker.open(afterRemove, { privateKey: qui.ecdh.privateKey, subject: QUI }))], [...Buffer.from(plain)], 'quinn still opens it');
+ await assert.rejects(async () => await locker.open(afterRemove, { privateKey: mat.ecdh.privateKey, subject: MAT }), 'maticus cannot open the new envelope');
+ assert.ok(!removePut.members.some((m) => m.subject && m.subject.id === MAT.id), 'maticus is not a member');
+
+ // 4. A link lock: the key after # is part of the link the sheet copies, and a link without it says so.
+ const linkSealed = await locker.seal(plain, { lock: 'link' });
+ const linkPage = await h.openPage(browser, await browser.newContext(), { width: 390 });
+ await linkPage.goto(`${base}/#k=${linkSealed.linkKey}`, { maxMs: 5000 });
+ share = { ...share, lock: 'link', members: [], audiences: ['link', 'private'] };
+ items['/p/locked/raw'] = linkSealed.bytes;
+ await linkPage.evaluate(`(() => { OVShare.open({ kind: 'paste', id: 'locked', title: 'Locked note' }); })()`);
+ await wait(500);
+ assert.match(await linkPage.evaluate(`${q('link')}.value`), /#k=[A-Za-z0-9_-]{43}/, 'the copied link keeps the key after #');
+ await linkPage.evaluate(press('lock-open'));
+ await wait(400);
+ await linkPage.evaluate('history.replaceState(null, "", location.pathname)');
+ await linkPage.evaluate(press('lock-open'));
+ await linkPage.evaluate(press('lock-open'));
+ await wait(500);
+ assert.match(await linkPage.evaluate(text('.ov-share-lock')), /missing its key/i, 'a link with no key says so');
+
+ console.log('ov-share-lock-chrome: the Locked part with real crypto: lazy load, fingerprints, add wraps for them, removal re-seals, the link lock keeps its key');
+ } finally {
+ await browser.close();
+ server.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+})().catch((e) => { console.error(e); process.exit(1); });