openvibers/openvibe.shared Commits
4.156.0: Locker passkey unlock and streamed opens (core from openvibe-sdk 0.101.0) (#347)
Changes
@@@ -1,5 +1,16 @@# Changelog++## 4.156.0 — 2026-11-08++++- **Locker unlocks with a passkey on this device.** Setting up a locker can use a passkey instead of a passphrase:++ the passkey's PRF output (user verification required) seals the private keys, and only the credential id and its++ salt are kept in this browser; the 24-word recovery kit stays the way back on any other device. Unlocking offers++ "Unlock with this device's passkey" when one is set up. The kit can also be printed.++- **Shared files open as streams** (`OVLocker.openStream`): large locked files are read chunk by chunk, each one++ verified, without holding the whole file in memory. No private key is handed to a service or a caller.++- `ov-locker-core.js` is regenerated from openvibe-sdk 0.101.0 (bounded header checks against a stored byte count,++ key export after recovery).++## 4.155.0 — 2026-11-08- **The menu names each site by a short product name** (the owner, 11-08: "the names of websites … not cut off …@@@ -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.155.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.156.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.@@@ -1120,7 +1120,9 @@ A bad release is undone by pinning the previous tag; a released tag is never movPart of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).++The Locker browser core currently uses the immutable SDK review commit `80f212323869347385e34545d6ee0a911017f5af` (OpenVibe.SDK #141). Replace it with the reviewed release pin before the next library release.++<!-- versions:start -->- openvibe-contracts: v0.338.0--- openvibe-sdk: v0.94.0++- openvibe-sdk: v0.101.0<!-- versions:end -->@@@ -4,12 +4,12 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.155.0",++ "version": "4.156.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.338.0 (devDependency: the release manifest contract)",-- "sdk": "openvibe-sdk v0.94.0",++ "sdk": "openvibe-sdk v0.101.0","features": ["an agent's live panel: ov-canvas.js draws actor.canvas@1 (headings, text, lists, checklists, tables, metrics, bar and line charts, pictures from openvibe.media, https links, buttons) the same on every site; built node by node, a button only asks, a tick tells, versions only grow (4.145.0)","send it on OpenVibe: the share sheet's first place for a signed-in person is OpenVibe Chat; the Dock opens on Send to… (a conversation, or someone new by their name) and the message waits in the box until the person sends it, OpenVibeDock.share({ text, title }) (4.144.0)",@@@ -62,3 +62,25 @@ The key directory it talks to is Network's:- `PUT /api/v1/me/keys`- `GET /api/v1/me/keys/backup`- `GET /api/v1/people/:subject/keys`++++### Device passkeys and streaming (Locker v1)++++Setup also offers a passkey on devices with WebAuthn PRF support. The PRF output is used only as the password to the++existing SDK password lock; cryptography stays in `ov-locker-core.js`. The credential ID and public PRF input are++stored on this origin under a subject-specific localStorage key. Neither the PRF output nor private keys are stored++there. This convenience unlock is specific to this device/origin; another device uses the recovery kit. If PRF is++unavailable, setup keeps the passphrase option enabled. The recovery kit can be saved or printed and is shown only++while it is being created. Keep it before leaving setup.++++`OVLocker.openStream(readable, credentials?)` delegates to the SDK's streaming opener, using the unlocked person++keys for a people lock. Private keys remain in the Locker module. An explicit `{ key }` or `{ password }` opens a++link or password lock. The caller must consume the authenticated stream to completion before committing a saved++file; a cancelled prefix read is suitable only for a provisional, decrypted label.++++Network's backup response is `{ subject, backups: { passphrase, recovery, passkey } }`. The browser accepts that++wrapper and the older unwrapped form. Configure `OpenVibeLockerConfig.subject` to scope the public passkey handle++to the signed-in person.++++A service with an HTTP-only SSO session may set `network: ''` and proxy the three public-key / encrypted-backup++endpoints through its own authenticated backend. Set `reauthUrl` to its reauthentication entry point; this avoids++requiring third-party Network cookies and keeps access tokens out of JavaScript. No plaintext keys are proxied.@@@ -123,3 +123,11 @@ design rather than a web view of it:The calls the sheet makes are the same uniform API for every client: `openvibe-sdk/share`'s router,`GET`/`PUT /api/v1/<kind>/<id>/share` and the rest. An agent uses `sdk.share({ base, token }).for(kind, id)`.++++For large encrypted files, pass `prepareEnvelope({ keys, remove, add })` to `OVShare.open`. The callback receives++public recipient keys including the owner, streams a fresh envelope with the existing Locker SDK, and uploads it++to the service's authenticated staging endpoint. Return `{ envelope_ref, header }`; the sheet derives each++member's key slot from that header and submits the opaque receipt alongside the complete member list. The++service must implement the SDK's staged-reseal hooks and bind the receipt to the owner, item, current revision++and expiry, atomically replacing ciphertext and access. The callback never sends private keys or plaintext.++Without this option the existing inline `OVLocker.rewrap` path is unchanged.@@@ -539,7 +539,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': '1a0d8ce6909f', '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': 'fa4ded2928f4', 'ov-share.css': 'cbd18e6ce991', 'search.js': 'b2e5ef9b3773', 'dock.js': '4dacf614797b', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': '25960cf0c075' };++ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '1a0d8ce6909f', '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': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': 'b2e5ef9b3773', 'dock.js': '4dacf614797b', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': '25960cf0c075' };// 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,15 +1,15 @@--/* Generated by scripts/build-locker-core.js from openvibe-sdk 0.94.0 (src/locker). Do not edit: change the SDK,++/* Generated by scripts/build-locker-core.js from openvibe-sdk 0.101.0 (src/locker). Do not edit: change the SDK,* release it, bump the pin, and run the script. The SDK's files, unchanged but for the names of require and module:* bytes.js 80a5a560c683f489* errors.js e73f94b7711452e7* format.js f7793a59fdb67ad9* index.js eee174e3a87df494* inspect.js 2f28ff0da8c96a4e-- * keys.js 18663474b10352ed++ * keys.js fbe20e7949924f2b* padme.js e0dd4b08e0d0f24f* recovery.js 2829d07a02561674* seal.js ff6ad448082c636c-- * server.js 56414198b01fbecd++ * server.js 6177fc986b2b705d* words.js 8f4f51abdadfbfd1*/(function (root) {@@@ -522,7 +522,7 @@ async function exportPersonKeys(person) {const signingPublic = publicJwk(person.signingPublic || await subtle().exportKey('jwk', person.signing.publicKey));const message = fingerprintMessage(ecdhPublic, signingPublic);const signature = person.fingerprintSignature-- || new Uint8Array(await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, person.signing.privateKey, message));++ || new Uint8Array(await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, (person.signing?.privateKey || person.signingPrivateKey), message));return {ecdh: ecdhPublic,signing: signingPublic,@@@ -535,8 +535,8 @@ async function exportPersonKeys(person) {* The private half of a person's keys, as it is stored: the two private keys as JWKs.*/async function exportPrivateKeys(person) {-- const ecdh = await subtle().exportKey('jwk', person.ecdh.privateKey);-- const signing = await subtle().exportKey('jwk', person.signing.privateKey);++ const ecdh = await subtle().exportKey('jwk', person.ecdh?.privateKey || person.ecdhPrivateKey);++ const signing = await subtle().exportKey('jwk', person.signing?.privateKey || person.signingPrivateKey);return { ecdh, signing };}@@@ -1589,7 +1589,7 @@ function checkHeader(header) {* Reads an envelope's header and checks its shape and its size. Nothing is decrypted and no key* is needed: this is what a service runs on bytes it may never be able to read.*/--function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {++function inspectBytes(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}, totalBytes = bytes && bytes.length) {const errors = [];const data = bytes instanceof Uint8Array ? bytes : null;if (!data) return { valid: false, errors: ['the envelope is not bytes'], header: null };@@@ -1607,7 +1607,8 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {let header = null;try { header = JSON.parse(fromUtf8(data.subarray(8, 8 + headerLength))); }catch { return { valid: false, errors: ['the envelope header is not JSON'], header: null }; }-- errors.push(...checkHeader(header));++ if (!header || typeof header !== 'object' || Array.isArray(header)) return { valid: false, errors: ['the header is not an object'], header: null };++ try { errors.push(...checkHeader(header)); } catch { errors.push('the slots are not an object'); }const chunkOffset = 8 + headerLength;let expectedBytes = null;if (Number.isInteger(header.padded_size) && header.padded_size >= MIN_PADDED_SIZE@@@ -1619,7 +1620,7 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {const whole = (chunks - 1) * (frame + CHUNK_SIZE + TAG_SIZE);const last = frame + lastChunkSize(header.padded_size) + TAG_SIZE;expectedBytes = chunkOffset + whole + last;-- if (data.length < expectedBytes) errors.push('the envelope is shorter than its own header says');++ if (totalBytes < expectedBytes) errors.push('the envelope is shorter than its own header says');}return {valid: errors.length === 0,@@@ -1634,7 +1635,22 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {};}--__ovLockerModule.exports = { inspectEnvelope, checkHeader, peopleSlotOk };++function inspectEnvelope(bytes, options) { return inspectBytes(bytes, options); }++++/** A streamed upload: inspect just its bounded header against the ACTUAL stored byte count.++ * The caller must have counted and bounded the complete ciphertext first, not trust Content-Length.++ * No chunks are read or authenticated here; as with inspectEnvelope, only a client can do that.++ */++function inspectEnvelopePrefix(prefix, totalBytes, options) {++ const info = inspectBytes(prefix, options, totalBytes);++ if (!Number.isSafeInteger(totalBytes) || !Number.isSafeInteger(info.expectedBytes) || totalBytes !== info.expectedBytes) {++ info.errors.push('the stored byte count does not match the envelope');++ }++ if (info.header && !Number.isSafeInteger(info.header.padded_size)) info.errors.push('the padded size is not a safe integer');++ info.valid = info.errors.length === 0;++ return info;++}++__ovLockerModule.exports = { inspectEnvelope, inspectEnvelopePrefix, checkHeader, peopleSlotOk };},"words": function (__ovLockerModule, exports, __ovLockerReq) {@@@ -1695,5 +1711,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.94.0" });++ root.OVLockerCore = Object.assign({}, req('index'), { SDK_VERSION: "0.101.0" });})(typeof globalThis !== 'undefined' ? globalThis : this);@@@ -121,3 +121,11 @@ html.ovl-locked, html.ovl-locked body { overflow: hidden; }.ovl-done .ovl-ico.is-big { animation: none; }}@media (forced-colors: active) { .ovl-sheet, .ovl-card { border: 1px solid CanvasText; } }++++/* The one-time recovery kit is the only printed content. */++@media print {++ body:has(.ovl-words) > :not(dialog) { display: none !important; }++ dialog:has(.ovl-words) { position: static; max-height: none; border: 0; box-shadow: none; }++ dialog:has(.ovl-words) .ovl-acts, dialog:has(.ovl-words) form, dialog:has(.ovl-words) button { display: none !important; }++ .ovl-words { color: #111; background: #fff; }++}@@@ -174,15 +174,48 @@|| (err.status === 403 && /reauth|sign in again/i.test(err.message));function offerReauth(msg) {say(msg, 'For your safety, sign in again, then return to your locker.', true);-- const href = `${cfg().network.replace(/\/+$/, '')}/login?reauth=1&return=${encodeURIComponent(root.location.href)}`;++ const href = cfg().reauthUrl || `${cfg().network.replace(/\/+$/, '')}/login?reauth=1&return=${encodeURIComponent(root.location.href)}`;msg.appendChild(doc.createTextNode(' '));msg.appendChild(el('a', 'ovl-btn is-quiet', { href }, 'Sign in again'));}++ // Only the public credential id and PRF input are remembered on this origin. The PRF result,++ // passphrase and private keys never enter localStorage or a network request.++ const passkeyStorage = () => `ov-locker-passkey:${cfg().subject || 'device'}`;++ const randomBytes = () => root.crypto.getRandomValues(new Uint8Array(32));++ function devicePasskey() {++ try { return JSON.parse(root.localStorage.getItem(passkeyStorage()) || 'null'); } catch (_) { return null; }++ }++ async function passkeySecret(saved) {++ const assertion = await root.navigator.credentials.get({ publicKey: {++ challenge: randomBytes(), rpId: root.location.hostname, userVerification: 'required',++ allowCredentials: [{ type: 'public-key', id: unb64(saved.id) }],++ extensions: { prf: { eval: { first: unb64(saved.salt) } } },++ } });++ const first = assertion && assertion.getClientExtensionResults().prf?.results?.first;++ if (!first || first.byteLength !== 32) throw new Error('This passkey cannot unlock a locker. Use your passphrase or recovery kit.');++ return b64(new Uint8Array(first));++ }++ async function makePasskey() {++ const salt = randomBytes();++ const credential = await root.navigator.credentials.create({ publicKey: {++ challenge: randomBytes(), rp: { id: root.location.hostname, name: 'OpenVibe Locker' },++ user: { id: randomBytes(), name: cfg().subject || 'My locker', displayName: 'My OpenVibe locker' },++ pubKeyCredParams: [{ type: 'public-key', alg: -7 }],++ authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' },++ extensions: { prf: { eval: { first: salt } } },++ } });++ if (!credential?.getClientExtensionResults().prf?.enabled) throw new Error('This passkey does not support Locker. Set up with a passphrase instead.');++ const saved = { id: b64(new Uint8Array(credential.rawId)), salt: b64(salt) };++ // Some authenticators only evaluate a PRF on get(), not on create(). Always confirm it here.++ return { saved, secret: await passkeySecret(saved) };++ }++// ── Make your locker ────────────────────────────────────────────────────────────────────────────────────async function setup(opts) {const o = Object.assign({}, opts || {});const s = sheet('Make your locker');++ let device = null;const step1 = () => {s.body.replaceChildren();s.body.appendChild(el('p', 'ovl-lead', null, 'Your locker holds the keys that open what people lock for you. OpenVibe keeps only a locked copy: nobody here can open it, and nobody can reset it.'));@@@ -204,6 +237,16 @@step2(p1.input.value);});s.body.appendChild(f);++ s.body.appendChild(el('p', 'ovl-hint', null, 'If you lose the password (or your locker passphrase and recovery kit), nobody can open this, including OpenVibe.'));++ if (root.PublicKeyCredential && root.navigator.credentials) {++ const pk = button('Use a passkey on this device', 'quiet', { 'data-k': 'setup-passkey' });++ pk.addEventListener('click', async () => {++ pk.disabled = true;++ try { device = await makePasskey(); await step2(null); }++ catch (e) { device = null; say(msg, e.message, true); pk.disabled = false; }++ });++ s.body.appendChild(pk);++ }setTimeout(() => p1.input.focus(), 0);};const step2 = async (passphrase) => {@@@ -235,7 +278,9 @@const a = el('a', null, { href: URL.createObjectURL(new Blob([text], { type: 'text/plain' })), download: 'openvibe-locker-recovery-kit.txt' });doc.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 4000);});-- acts.appendChild(copy); acts.appendChild(save);++ const print = button('Print the kit', 'quiet', { 'data-k': 'print' });++ print.addEventListener('click', () => root.print());++ acts.appendChild(copy); acts.appendChild(save); acts.appendChild(print);s.body.appendChild(acts);// Two words back, so the kit was really kept.const pick = [3 + Math.floor(Math.random() * 8), 13 + Math.floor(Math.random() * 10)];@@@ -258,14 +303,18 @@const pub = await c.exportPersonKeys(person);const priv = utf8(JSON.stringify(await c.exportPrivateKeys(person)));// seal() answers { header, bytes, … }: the bytes are the envelope that goes on the wire.-- const byPass = (await c.seal(priv, { lock: 'password', password: passphrase })).bytes;++ const byPass = (await c.seal(priv, { lock: 'password', password: device ? device.secret : passphrase })).bytes;const byWords = (await c.seal(priv, { lock: 'password', password: words.join(' ') })).bytes;await network('PUT', '/api/v1/me/keys', {ecdh_public: pub.ecdh, signing_public: pub.signing, self_signature: pub.fingerprint_signature, fingerprint: pub.fingerprint,...(o.reset ? { reset: true } : {}),-- backups: { passphrase: { format: 'locker.envelope.v1', envelope: b64(byPass) }, recovery: { format: 'locker.envelope.v1', envelope: b64(byWords) } },++ backups: { [device ? 'passkey' : 'passphrase']: { format: 'locker.envelope.v1', envelope: b64(byPass) }, recovery: { format: 'locker.envelope.v1', envelope: b64(byWords) } },});-- session = { person, subject: null, fingerprint: pub.fingerprint };++ if (device) {++ root.localStorage.setItem(passkeyStorage(), JSON.stringify(device.saved));++ device.secret = null;++ }++ session = { person, subject: cfg().subject || null, fingerprint: pub.fingerprint };doc.dispatchEvent(new CustomEvent('ov-locker:change', { detail: state() }));step4(pub.fingerprint);} catch (err) {@@@ -339,8 +388,11 @@try {const c = await core();if (!backups) backups = await network('GET', '/api/v1/me/keys/backup');-- const b = backups[mode === 'words' ? 'recovery' : 'passphrase'];-- if (!b || !b.envelope) throw Object.assign(new Error('nothing'), { status: 404 });++ const b = (backups.backups || backups)[mode === 'words' ? 'recovery' : 'passphrase'];++ if (!b || !b.envelope) {++ if (mode !== 'words' && (backups.backups || backups).passkey) throw Object.assign(new Error('Use the passkey on its original device or choose your recovery kit.'), { code: 'locker.use_recovery' });++ throw Object.assign(new Error('nothing'), { status: 404 });++ }const priv = JSON.parse(fromUtf8(await c.open(unb64(b.envelope), { password: secret })));const person = await c.importPersonKeys(priv);// The fingerprint from the public keys alone (an imported set signs nothing here).@@@ -352,6 +404,7 @@} catch (err) {go.disabled = false;if (needsReauth(err)) offerReauth(msg);++ else if (err.code === 'locker.use_recovery') say(msg, err.message, true);else if (err.status === 404) {say(msg, 'You have no locker yet.', true);const mk = button('Make my locker', 'quiet', { 'data-k': 'make-instead' });@@@ -382,6 +435,27 @@s.body.appendChild(go);};draw('pass');++ const saved = devicePasskey();++ if (saved && root.navigator.credentials) {++ const pk = button('Unlock with this device’s passkey', 'quiet', { 'data-k': 'unlock-passkey' });++ const msg = el('p', 'ovl-msg', { 'aria-live': 'polite' });++ pk.addEventListener('click', async () => {++ pk.disabled = true;++ try {++ const secret = await passkeySecret(saved);++ const c = await core();++ const backup = await network('GET', '/api/v1/me/keys/backup');++ const b = (backup.backups || backup).passkey;++ if (!b) throw new Error('This locker has no passkey backup. Use the recovery kit.');++ const person = await c.importPersonKeys(JSON.parse(fromUtf8(await c.open(unb64(b.envelope), { password: secret }))));++ session = { person, subject: backup.subject || null, fingerprint: await c.fingerprintOf(person.ecdhPublic, person.signingPublic) };++ doc.dispatchEvent(new CustomEvent('ov-locker:change', { detail: state() }));++ s.close(state());++ if (typeof o.onDone === 'function') o.onDone(state());++ } catch (e) { pk.disabled = false; say(msg, e.message, true); }++ });++ s.body.appendChild(pk); s.body.appendChild(msg);++ }return s.finished;}@@@ -512,8 +586,16 @@});}++ /** Opens verified chunks with this tab's keys. No private key is exported to a service or caller. */++ async function openStream(stream, credentials) {++ const c = await core();++ if (credentials) return c.openStream(stream, credentials);++ await ensure();++ return c.openStream(stream, { person: session.person });++ }++root.OVLocker = {-- open, setup, unlock, ensure, keys, publicKeys, rewrap, seal, inspect: inspectBytes,++ open, openStream, setup, unlock, ensure, keys, publicKeys, rewrap, seal, inspect: inspectBytes,state, forget, keyFromLink, strength, version: 2,};})(typeof window !== 'undefined' ? window : null);@@@ -822,7 +822,6 @@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);@@@ -836,9 +835,13 @@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 });++ if ((remove.length || st.o.prepareEnvelope) && my && me) keys.push({ subject: my, publicKey: me.ecdh });++ if (remove.length || st.o.prepareEnvelope) for (const m of after) keys.push({ subject: m.subject, publicKey: await publicKey(m) });++ // File services may stream a new envelope to their ciphertext store and return a short receipt.++ // The sheet still supplies the recipients and saves their slots and access list together.++ const out = typeof st.o.prepareEnvelope === 'function'++ ? await st.o.prepareEnvelope({ keys, remove, add })++ : await L.rewrap(await envelopeBytes(st), { 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));@@@ -847,7 +850,7 @@if (m.expires_at) r.expires_at = m.expires_at;return r;});-- return { members: list, envelope: b64(out.bytes) };++ return { members: list, ...(out.envelope_ref ? { envelope_ref: out.envelope_ref } : { envelope: b64(out.bytes) }) };}// The @name field: Enter or Add checks the name with Network (through the service), then adds a chip-row.@@@ -1209,7 +1212,7 @@// 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; }++ if (sealed) { body.members = sealed.members; if (sealed.envelope_ref) body.envelope_ref = sealed.envelope_ref; else 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));@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.143.0",++ "version": "4.156.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.143.0",++ "version": "4.156.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -19,7 +19,7 @@"@electric-sql/pglite": "^0.5.8","express": "^4.22.3","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.101.0"}},"node_modules/@electric-sql/pglite": {@@@ -1089,9 +1089,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.94.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",-- "integrity": "sha512-p3buZSOSFV81OdZgs5GoSIfMCQNJjW23eAIXIt0RVFIOvh3TByNNEMmuCanxRybFIbzh+9IDMbBwHJrBLl3JeA==",++ "version": "0.101.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.101.0",++ "integrity": "sha512-W0WVhpc4cL0zJOW7IRAzz1polzR1MU571SMyDUEh9CeJWlocENlISbz9rr78AZmrcmqCvAn+25D3aLTNQpO7Ug==","dev": true,"license": "MIT","bin": {@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.155.0",++ "version": "4.156.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -156,6 +156,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.338.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.101.0"}}@@@ -39,7 +39,14 @@ assert.strictEqual(applyBlock('a\n\n<!-- versions:start -->\n- openvibe-sdk: v0.const r = inspect(root);// The expectation comes from package.json itself, so a pin bump (scripts/pin-bump.js) needs no test edit.assert.deepStrictEqual(r.pins, pinsIn(JSON.parse(read(root, 'package.json'))));-- assert.deepStrictEqual(Object.keys(r.pins).sort(), ['openvibe-contracts', 'openvibe-sdk']);++ // This check lists release pins. Locker's companion SDK uses an immutable review commit until release.++ const sdkSpec = JSON.parse(read(root, 'package.json')).devDependencies['openvibe-sdk'];++ const reviewCommit = /OpenVibe\.SDK\/tar\.gz\/([a-f0-9]{40})$/.exec(sdkSpec);++ assert.deepStrictEqual(Object.keys(r.pins).sort(), reviewCommit ? ['openvibe-contracts'] : ['openvibe-contracts', 'openvibe-sdk']);++ if (reviewCommit) {++ assert.ok(JSON.parse(read(root, 'STATUS.json')).sdk.includes(reviewCommit[1]), 'STATUS names the exact reviewed SDK commit');++ assert.ok(read(root, 'README.md').includes(reviewCommit[1]), 'README names the exact reviewed SDK commit');++ }assert.deepStrictEqual(r.findings.filter((f) => !f.ok), [], 'the checked-in STATUS.json and README block are current');const cli = cp.spawnSync(process.execPath, [script, root, '--check'], { encoding: 'utf8' });assert.strictEqual(cli.status, 0, cli.stdout + cli.stderr);@@@ -38,7 +38,7 @@ const server = http.createServer((req, res) => {if (u.pathname === '/api/v1/me/keys' && req.method === 'PUT') { stored = JSON.parse(body); res.writeHead(200, { 'content-type': 'application/json' }); return res.end('{"ok":true}'); }if (u.pathname === '/api/v1/me/keys/backup') {if (!stored) { res.writeHead(404, { 'content-type': 'application/json' }); return res.end('{"error":"no keys"}'); }-- res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ subject: 'usr_x', ...stored.backups }));++ res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ subject: 'usr_x', backups: stored.backups }));}if (u.pathname.startsWith('/item/') && items[u.pathname.slice(6)]) { res.writeHead(200, { 'content-type': 'application/octet-stream' }); return res.end(Buffer.from(items[u.pathname.slice(6)])); }res.writeHead(404); return res.end();@@@ -112,6 +112,46 @@ const server = http.createServer((req, res) => {await c.evaluate(type('words', words.join(' ').toUpperCase())); await c.evaluate(press('unlock'));await wait(5000);assert.strictEqual(await c.evaluate('OVLocker.state().fingerprint'), fp, 'the recovery words open the same locker');++ // A PRF-capable authenticator is stubbed at the browser boundary; sealing and opening remain real WebCrypto.++ // Hardware availability varies, so the unsupported case must leave setup usable too.++ const d = await h.openPage(browser, await browser.newContext(), { width: 390 });++ await d.goto(`${base}/`, { maxMs: 5000 });++ await d.evaluate(`(() => {++ window.__prf = crypto.getRandomValues(new Uint8Array(32));++ Object.defineProperty(navigator, 'credentials', { configurable: true, value: {++ create: async () => ({ rawId: new Uint8Array([1,2,3,4]).buffer, getClientExtensionResults: () => ({ prf: { enabled: true } }) }),++ get: async (options) => {++ if (options.publicKey.userVerification !== 'required') throw new Error('verification missing');++ return { getClientExtensionResults: () => ({ prf: { results: { first: window.__prf.buffer } } }) };++ },++ } });++ OVLocker.setup();++ })()`);++ await wait(300); await d.evaluate(press('setup-passkey')); await wait(1500);++ const recovery = await d.evaluate("[...document.querySelectorAll('.ovl-words li')].map(x => x.textContent)");++ assert.strictEqual(recovery.length, 24);++ assert.strictEqual(await d.evaluate("!!document.querySelector('[data-k=print]')"), true);++ const ask = await d.evaluate("[...document.querySelectorAll('dialog .ovl-pair .ovl-label')].map(x => Number(x.textContent.replace(/\\D/g, '')))");++ await d.evaluate(type('w1', recovery[ask[0] - 1])); await d.evaluate(type('w2', recovery[ask[1] - 1])); await d.evaluate(press('make')); await wait(9000);++ assert.ok(stored.backups.passkey && stored.backups.recovery && !stored.backups.passphrase);++ const storage = await d.evaluate("JSON.parse(localStorage.getItem('ov-locker-passkey:device'))");++ assert.deepStrictEqual(Object.keys(storage).sort(), ['id', 'salt'], 'only public passkey lookup data stays on the device');++ const pkFp = await d.evaluate('OVLocker.state().fingerprint');++ await d.evaluate(press('done'));++ await d.evaluate('(() => { OVLocker.forget(); OVLocker.unlock(); })()'); await wait(300);++ await d.evaluate(press('unlock-passkey')); await wait(5000);++ assert.strictEqual(await d.evaluate('OVLocker.state().fingerprint'), pkFp, 'PRF opens the same SDK-protected keys');++ items.stream = (await locker.seal('streamed on device', { lock: 'people', keys: [{ subject: { type: 'user', id: 'usr_x' }, publicKey: stored.ecdh_public }] })).bytes;++ assert.strictEqual(await d.evaluate("(async () => new Response(await OVLocker.openStream((await fetch('/item/stream')).body)).text())()"), 'streamed on device');++ await d.evaluate('(() => { OVLocker.forget(); OVLocker.unlock(); })()'); await wait(300);++ await d.evaluate(type('pass', PASS)); await d.evaluate(press('unlock')); await wait(300);++ assert.match(await d.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /recovery kit/);++ assert.strictEqual(await d.evaluate("!!document.querySelector('[data-k=make-instead]')"), false, 'an existing passkey locker is never described as missing');++ await d.evaluate("document.querySelector('dialog button[aria-label=Close]').click()");++ await d.evaluate(`(() => { OVLocker.forget(); navigator.credentials.create = async () => ({ getClientExtensionResults: () => ({}) }); OVLocker.setup(); })()`);++ await wait(300); await d.evaluate(press('setup-passkey')); await wait(300);++ assert.match(await d.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /does not support Locker/);++ assert.strictEqual(await d.evaluate("document.querySelector('[data-k=setup-passkey]').disabled"), false);console.log('ov-locker-e2e-chrome: real crypto: make a locker, unlock it on a new device by passphrase and by recovery words, open what was locked for you, refuse what was not');} finally {await browser.close();
@@ -1,5 +1,16 @@
@@ -1,5 +1,16 @@
# Changelog
# Changelog
+## 4.156.0 — 2026-11-08
+
+- **Locker unlocks with a passkey on this device.** Setting up a locker can use a passkey instead of a passphrase:
+ the passkey's PRF output (user verification required) seals the private keys, and only the credential id and its
+ salt are kept in this browser; the 24-word recovery kit stays the way back on any other device. Unlocking offers
+ "Unlock with this device's passkey" when one is set up. The kit can also be printed.
+- **Shared files open as streams** (`OVLocker.openStream`): large locked files are read chunk by chunk, each one
+ verified, without holding the whole file in memory. No private key is handed to a service or a caller.
+- `ov-locker-core.js` is regenerated from openvibe-sdk 0.101.0 (bounded header checks against a stored byte count,
+ key export after recovery).
+
## 4.155.0 — 2026-11-08
## 4.155.0 — 2026-11-08
- **The menu names each site by a short product name** (the owner, 11-08: "the names of websites … not cut off …
- **The menu names each site by a short product name** (the owner, 11-08: "the names of websites … not cut off …
@@ -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.155.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.156.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.
@@ -1120,7 +1120,9 @@ A bad release is undone by pinning the previous tag; a released tag is never mov
@@ -1120,7 +1120,9 @@ A bad release is undone by pinning the previous tag; a released tag is never mov
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
+The Locker browser core currently uses the immutable SDK review commit `80f212323869347385e34545d6ee0a911017f5af` (OpenVibe.SDK #141). Replace it with the reviewed release pin before the next library release.
+
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.338.0
- openvibe-contracts: v0.338.0
-- openvibe-sdk: v0.94.0
+- openvibe-sdk: v0.101.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.155.0",
+ "version": "4.156.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.338.0 (devDependency: the release manifest contract)",
"contracts": "openvibe-contracts v0.338.0 (devDependency: the release manifest contract)",
- "sdk": "openvibe-sdk v0.94.0",
+ "sdk": "openvibe-sdk v0.101.0",
"features": [
"features": [
"an agent's live panel: ov-canvas.js draws actor.canvas@1 (headings, text, lists, checklists, tables, metrics, bar and line charts, pictures from openvibe.media, https links, buttons) the same on every site; built node by node, a button only asks, a tick tells, versions only grow (4.145.0)",
"an agent's live panel: ov-canvas.js draws actor.canvas@1 (headings, text, lists, checklists, tables, metrics, bar and line charts, pictures from openvibe.media, https links, buttons) the same on every site; built node by node, a button only asks, a tick tells, versions only grow (4.145.0)",
"send it on OpenVibe: the share sheet's first place for a signed-in person is OpenVibe Chat; the Dock opens on Send to… (a conversation, or someone new by their name) and the message waits in the box until the person sends it, OpenVibeDock.share({ text, title }) (4.144.0)",
"send it on OpenVibe: the share sheet's first place for a signed-in person is OpenVibe Chat; the Dock opens on Send to… (a conversation, or someone new by their name) and the message waits in the box until the person sends it, OpenVibeDock.share({ text, title }) (4.144.0)",
@@ -62,3 +62,25 @@ The key directory it talks to is Network's:
@@ -62,3 +62,25 @@ The key directory it talks to is Network's:
- `PUT /api/v1/me/keys`
- `PUT /api/v1/me/keys`
- `GET /api/v1/me/keys/backup`
- `GET /api/v1/me/keys/backup`
- `GET /api/v1/people/:subject/keys`
- `GET /api/v1/people/:subject/keys`
+
+### Device passkeys and streaming (Locker v1)
+
+Setup also offers a passkey on devices with WebAuthn PRF support. The PRF output is used only as the password to the
+existing SDK password lock; cryptography stays in `ov-locker-core.js`. The credential ID and public PRF input are
+stored on this origin under a subject-specific localStorage key. Neither the PRF output nor private keys are stored
+there. This convenience unlock is specific to this device/origin; another device uses the recovery kit. If PRF is
+unavailable, setup keeps the passphrase option enabled. The recovery kit can be saved or printed and is shown only
+while it is being created. Keep it before leaving setup.
+
+`OVLocker.openStream(readable, credentials?)` delegates to the SDK's streaming opener, using the unlocked person
+keys for a people lock. Private keys remain in the Locker module. An explicit `{ key }` or `{ password }` opens a
+link or password lock. The caller must consume the authenticated stream to completion before committing a saved
+file; a cancelled prefix read is suitable only for a provisional, decrypted label.
+
+Network's backup response is `{ subject, backups: { passphrase, recovery, passkey } }`. The browser accepts that
+wrapper and the older unwrapped form. Configure `OpenVibeLockerConfig.subject` to scope the public passkey handle
+to the signed-in person.
+
+A service with an HTTP-only SSO session may set `network: ''` and proxy the three public-key / encrypted-backup
+endpoints through its own authenticated backend. Set `reauthUrl` to its reauthentication entry point; this avoids
+requiring third-party Network cookies and keeps access tokens out of JavaScript. No plaintext keys are proxied.
@@ -123,3 +123,11 @@ design rather than a web view of it:
@@ -123,3 +123,11 @@ design rather than a web view of it:
The calls the sheet makes are the same uniform API for every client: `openvibe-sdk/share`'s router,
The calls the sheet makes are the same uniform API for every client: `openvibe-sdk/share`'s router,
`GET`/`PUT /api/v1/<kind>/<id>/share` and the rest. An agent uses `sdk.share({ base, token }).for(kind, id)`.
`GET`/`PUT /api/v1/<kind>/<id>/share` and the rest. An agent uses `sdk.share({ base, token }).for(kind, id)`.
+
+For large encrypted files, pass `prepareEnvelope({ keys, remove, add })` to `OVShare.open`. The callback receives
+public recipient keys including the owner, streams a fresh envelope with the existing Locker SDK, and uploads it
+to the service's authenticated staging endpoint. Return `{ envelope_ref, header }`; the sheet derives each
+member's key slot from that header and submits the opaque receipt alongside the complete member list. The
+service must implement the SDK's staged-reseal hooks and bind the receipt to the owner, item, current revision
+and expiry, atomically replacing ciphertext and access. The callback never sends private keys or plaintext.
+Without this option the existing inline `OVLocker.rewrap` path is unchanged.
@@ -539,7 +539,7 @@
@@ -539,7 +539,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': '1a0d8ce6909f', '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': 'fa4ded2928f4', 'ov-share.css': 'cbd18e6ce991', 'search.js': 'b2e5ef9b3773', 'dock.js': '4dacf614797b', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': '25960cf0c075' };
+ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '1a0d8ce6909f', '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': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': 'b2e5ef9b3773', 'dock.js': '4dacf614797b', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': '25960cf0c075' };
// 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,15 +1,15 @@
@@ -1,15 +1,15 @@
-/* Generated by scripts/build-locker-core.js from openvibe-sdk 0.94.0 (src/locker). Do not edit: change the SDK,
+/* Generated by scripts/build-locker-core.js from openvibe-sdk 0.101.0 (src/locker). Do not edit: change the SDK,
* release it, bump the pin, and run the script. The SDK's files, unchanged but for the names of require and module:
* 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
* format.js f7793a59fdb67ad9
* format.js f7793a59fdb67ad9
* index.js eee174e3a87df494
* index.js eee174e3a87df494
* inspect.js 2f28ff0da8c96a4e
* inspect.js 2f28ff0da8c96a4e
- * keys.js 18663474b10352ed
+ * keys.js fbe20e7949924f2b
* padme.js e0dd4b08e0d0f24f
* padme.js e0dd4b08e0d0f24f
* recovery.js 2829d07a02561674
* recovery.js 2829d07a02561674
* seal.js ff6ad448082c636c
* seal.js ff6ad448082c636c
- * server.js 56414198b01fbecd
+ * server.js 6177fc986b2b705d
* words.js 8f4f51abdadfbfd1
* words.js 8f4f51abdadfbfd1
*/
*/
(function (root) {
(function (root) {
@@ -522,7 +522,7 @@ async function exportPersonKeys(person) {
@@ -522,7 +522,7 @@ async function exportPersonKeys(person) {
const signingPublic = publicJwk(person.signingPublic || await subtle().exportKey('jwk', person.signing.publicKey));
const signingPublic = publicJwk(person.signingPublic || await subtle().exportKey('jwk', person.signing.publicKey));
const message = fingerprintMessage(ecdhPublic, signingPublic);
const message = fingerprintMessage(ecdhPublic, signingPublic);
const signature = person.fingerprintSignature
const signature = person.fingerprintSignature
- || new Uint8Array(await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, person.signing.privateKey, message));
+ || new Uint8Array(await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, (person.signing?.privateKey || person.signingPrivateKey), message));
return {
return {
ecdh: ecdhPublic,
ecdh: ecdhPublic,
signing: signingPublic,
signing: signingPublic,
@@ -535,8 +535,8 @@ async function exportPersonKeys(person) {
@@ -535,8 +535,8 @@ async function exportPersonKeys(person) {
* The private half of a person's keys, as it is stored: the two private keys as JWKs.
* The private half of a person's keys, as it is stored: the two private keys as JWKs.
*/
*/
async function exportPrivateKeys(person) {
async function exportPrivateKeys(person) {
- const ecdh = await subtle().exportKey('jwk', person.ecdh.privateKey);
- const signing = await subtle().exportKey('jwk', person.signing.privateKey);
+ const ecdh = await subtle().exportKey('jwk', person.ecdh?.privateKey || person.ecdhPrivateKey);
+ const signing = await subtle().exportKey('jwk', person.signing?.privateKey || person.signingPrivateKey);
return { ecdh, signing };
return { ecdh, signing };
}
}
@@ -1589,7 +1589,7 @@ function checkHeader(header) {
@@ -1589,7 +1589,7 @@ function checkHeader(header) {
* Reads an envelope's header and checks its shape and its size. Nothing is decrypted and no key
* Reads an envelope's header and checks its shape and its size. Nothing is decrypted and no key
* is needed: this is what a service runs on bytes it may never be able to read.
* is needed: this is what a service runs on bytes it may never be able to read.
*/
*/
-function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {
+function inspectBytes(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}, totalBytes = bytes && bytes.length) {
const errors = [];
const errors = [];
const data = bytes instanceof Uint8Array ? bytes : null;
const data = bytes instanceof Uint8Array ? bytes : null;
if (!data) return { valid: false, errors: ['the envelope is not bytes'], header: null };
if (!data) return { valid: false, errors: ['the envelope is not bytes'], header: null };
@@ -1607,7 +1607,8 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {
@@ -1607,7 +1607,8 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {
let header = null;
let header = null;
try { header = JSON.parse(fromUtf8(data.subarray(8, 8 + headerLength))); }
try { header = JSON.parse(fromUtf8(data.subarray(8, 8 + headerLength))); }
catch { return { valid: false, errors: ['the envelope header is not JSON'], header: null }; }
catch { return { valid: false, errors: ['the envelope header is not JSON'], header: null }; }
- errors.push(...checkHeader(header));
+ if (!header || typeof header !== 'object' || Array.isArray(header)) return { valid: false, errors: ['the header is not an object'], header: null };
+ try { errors.push(...checkHeader(header)); } catch { errors.push('the slots are not an object'); }
const chunkOffset = 8 + headerLength;
const chunkOffset = 8 + headerLength;
let expectedBytes = null;
let expectedBytes = null;
if (Number.isInteger(header.padded_size) && header.padded_size >= MIN_PADDED_SIZE
if (Number.isInteger(header.padded_size) && header.padded_size >= MIN_PADDED_SIZE
@@ -1619,7 +1620,7 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {
@@ -1619,7 +1620,7 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {
const whole = (chunks - 1) * (frame + CHUNK_SIZE + TAG_SIZE);
const whole = (chunks - 1) * (frame + CHUNK_SIZE + TAG_SIZE);
const last = frame + lastChunkSize(header.padded_size) + TAG_SIZE;
const last = frame + lastChunkSize(header.padded_size) + TAG_SIZE;
expectedBytes = chunkOffset + whole + last;
expectedBytes = chunkOffset + whole + last;
- if (data.length < expectedBytes) errors.push('the envelope is shorter than its own header says');
+ if (totalBytes < expectedBytes) errors.push('the envelope is shorter than its own header says');
}
}
return {
return {
valid: errors.length === 0,
valid: errors.length === 0,
@@ -1634,7 +1635,22 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {
@@ -1634,7 +1635,22 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {
};
};
}
}
-__ovLockerModule.exports = { inspectEnvelope, checkHeader, peopleSlotOk };
+function inspectEnvelope(bytes, options) { return inspectBytes(bytes, options); }
+
+/** A streamed upload: inspect just its bounded header against the ACTUAL stored byte count.
+ * The caller must have counted and bounded the complete ciphertext first, not trust Content-Length.
+ * No chunks are read or authenticated here; as with inspectEnvelope, only a client can do that.
+ */
+function inspectEnvelopePrefix(prefix, totalBytes, options) {
+ const info = inspectBytes(prefix, options, totalBytes);
+ if (!Number.isSafeInteger(totalBytes) || !Number.isSafeInteger(info.expectedBytes) || totalBytes !== info.expectedBytes) {
+ info.errors.push('the stored byte count does not match the envelope');
+ }
+ if (info.header && !Number.isSafeInteger(info.header.padded_size)) info.errors.push('the padded size is not a safe integer');
+ info.valid = info.errors.length === 0;
+ return info;
+}
+__ovLockerModule.exports = { inspectEnvelope, inspectEnvelopePrefix, checkHeader, peopleSlotOk };
},
},
"words": function (__ovLockerModule, exports, __ovLockerReq) {
"words": function (__ovLockerModule, exports, __ovLockerReq) {
@@ -1695,5 +1711,5 @@ __ovLockerModule.exports = { WORDS };
@@ -1695,5 +1711,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.94.0" });
+ root.OVLockerCore = Object.assign({}, req('index'), { SDK_VERSION: "0.101.0" });
})(typeof globalThis !== 'undefined' ? globalThis : this);
})(typeof globalThis !== 'undefined' ? globalThis : this);
@@ -121,3 +121,11 @@ html.ovl-locked, html.ovl-locked body { overflow: hidden; }
@@ -121,3 +121,11 @@ html.ovl-locked, html.ovl-locked body { overflow: hidden; }
.ovl-done .ovl-ico.is-big { animation: none; }
.ovl-done .ovl-ico.is-big { animation: none; }
}
}
@media (forced-colors: active) { .ovl-sheet, .ovl-card { border: 1px solid CanvasText; } }
@media (forced-colors: active) { .ovl-sheet, .ovl-card { border: 1px solid CanvasText; } }
+
+/* The one-time recovery kit is the only printed content. */
+@media print {
+ body:has(.ovl-words) > :not(dialog) { display: none !important; }
+ dialog:has(.ovl-words) { position: static; max-height: none; border: 0; box-shadow: none; }
+ dialog:has(.ovl-words) .ovl-acts, dialog:has(.ovl-words) form, dialog:has(.ovl-words) button { display: none !important; }
+ .ovl-words { color: #111; background: #fff; }
+}
@@ -174,15 +174,48 @@
@@ -174,15 +174,48 @@
|| (err.status === 403 && /reauth|sign in again/i.test(err.message));
|| (err.status === 403 && /reauth|sign in again/i.test(err.message));
function offerReauth(msg) {
function offerReauth(msg) {
say(msg, 'For your safety, sign in again, then return to your locker.', true);
say(msg, 'For your safety, sign in again, then return to your locker.', true);
- const href = `${cfg().network.replace(/\/+$/, '')}/login?reauth=1&return=${encodeURIComponent(root.location.href)}`;
+ const href = cfg().reauthUrl || `${cfg().network.replace(/\/+$/, '')}/login?reauth=1&return=${encodeURIComponent(root.location.href)}`;
msg.appendChild(doc.createTextNode(' '));
msg.appendChild(doc.createTextNode(' '));
msg.appendChild(el('a', 'ovl-btn is-quiet', { href }, 'Sign in again'));
msg.appendChild(el('a', 'ovl-btn is-quiet', { href }, 'Sign in again'));
}
}
+ // Only the public credential id and PRF input are remembered on this origin. The PRF result,
+ // passphrase and private keys never enter localStorage or a network request.
+ const passkeyStorage = () => `ov-locker-passkey:${cfg().subject || 'device'}`;
+ const randomBytes = () => root.crypto.getRandomValues(new Uint8Array(32));
+ function devicePasskey() {
+ try { return JSON.parse(root.localStorage.getItem(passkeyStorage()) || 'null'); } catch (_) { return null; }
+ }
+ async function passkeySecret(saved) {
+ const assertion = await root.navigator.credentials.get({ publicKey: {
+ challenge: randomBytes(), rpId: root.location.hostname, userVerification: 'required',
+ allowCredentials: [{ type: 'public-key', id: unb64(saved.id) }],
+ extensions: { prf: { eval: { first: unb64(saved.salt) } } },
+ } });
+ const first = assertion && assertion.getClientExtensionResults().prf?.results?.first;
+ if (!first || first.byteLength !== 32) throw new Error('This passkey cannot unlock a locker. Use your passphrase or recovery kit.');
+ return b64(new Uint8Array(first));
+ }
+ async function makePasskey() {
+ const salt = randomBytes();
+ const credential = await root.navigator.credentials.create({ publicKey: {
+ challenge: randomBytes(), rp: { id: root.location.hostname, name: 'OpenVibe Locker' },
+ user: { id: randomBytes(), name: cfg().subject || 'My locker', displayName: 'My OpenVibe locker' },
+ pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
+ authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' },
+ extensions: { prf: { eval: { first: salt } } },
+ } });
+ if (!credential?.getClientExtensionResults().prf?.enabled) throw new Error('This passkey does not support Locker. Set up with a passphrase instead.');
+ const saved = { id: b64(new Uint8Array(credential.rawId)), salt: b64(salt) };
+ // Some authenticators only evaluate a PRF on get(), not on create(). Always confirm it here.
+ return { saved, secret: await passkeySecret(saved) };
+ }
+
// ── Make your locker ────────────────────────────────────────────────────────────────────────────────────
// ── Make your locker ────────────────────────────────────────────────────────────────────────────────────
async function setup(opts) {
async function setup(opts) {
const o = Object.assign({}, opts || {});
const o = Object.assign({}, opts || {});
const s = sheet('Make your locker');
const s = sheet('Make your locker');
+ let device = null;
const step1 = () => {
const step1 = () => {
s.body.replaceChildren();
s.body.replaceChildren();
s.body.appendChild(el('p', 'ovl-lead', null, 'Your locker holds the keys that open what people lock for you. OpenVibe keeps only a locked copy: nobody here can open it, and nobody can reset it.'));
s.body.appendChild(el('p', 'ovl-lead', null, 'Your locker holds the keys that open what people lock for you. OpenVibe keeps only a locked copy: nobody here can open it, and nobody can reset it.'));
@@ -204,6 +237,16 @@
@@ -204,6 +237,16 @@
step2(p1.input.value);
step2(p1.input.value);
});
});
s.body.appendChild(f);
s.body.appendChild(f);
+ s.body.appendChild(el('p', 'ovl-hint', null, 'If you lose the password (or your locker passphrase and recovery kit), nobody can open this, including OpenVibe.'));
+ if (root.PublicKeyCredential && root.navigator.credentials) {
+ const pk = button('Use a passkey on this device', 'quiet', { 'data-k': 'setup-passkey' });
+ pk.addEventListener('click', async () => {
+ pk.disabled = true;
+ try { device = await makePasskey(); await step2(null); }
+ catch (e) { device = null; say(msg, e.message, true); pk.disabled = false; }
+ });
+ s.body.appendChild(pk);
+ }
setTimeout(() => p1.input.focus(), 0);
setTimeout(() => p1.input.focus(), 0);
};
};
const step2 = async (passphrase) => {
const step2 = async (passphrase) => {
@@ -235,7 +278,9 @@
@@ -235,7 +278,9 @@
const a = el('a', null, { href: URL.createObjectURL(new Blob([text], { type: 'text/plain' })), download: 'openvibe-locker-recovery-kit.txt' });
const a = el('a', null, { href: URL.createObjectURL(new Blob([text], { type: 'text/plain' })), download: 'openvibe-locker-recovery-kit.txt' });
doc.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 4000);
doc.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 4000);
});
});
- acts.appendChild(copy); acts.appendChild(save);
+ const print = button('Print the kit', 'quiet', { 'data-k': 'print' });
+ print.addEventListener('click', () => root.print());
+ acts.appendChild(copy); acts.appendChild(save); acts.appendChild(print);
s.body.appendChild(acts);
s.body.appendChild(acts);
// Two words back, so the kit was really kept.
// Two words back, so the kit was really kept.
const pick = [3 + Math.floor(Math.random() * 8), 13 + Math.floor(Math.random() * 10)];
const pick = [3 + Math.floor(Math.random() * 8), 13 + Math.floor(Math.random() * 10)];
@@ -258,14 +303,18 @@
@@ -258,14 +303,18 @@
const pub = await c.exportPersonKeys(person);
const pub = await c.exportPersonKeys(person);
const priv = utf8(JSON.stringify(await c.exportPrivateKeys(person)));
const priv = utf8(JSON.stringify(await c.exportPrivateKeys(person)));
// seal() answers { header, bytes, … }: the bytes are the envelope that goes on the wire.
// seal() answers { header, bytes, … }: the bytes are the envelope that goes on the wire.
- const byPass = (await c.seal(priv, { lock: 'password', password: passphrase })).bytes;
+ const byPass = (await c.seal(priv, { lock: 'password', password: device ? device.secret : passphrase })).bytes;
const byWords = (await c.seal(priv, { lock: 'password', password: words.join(' ') })).bytes;
const byWords = (await c.seal(priv, { lock: 'password', password: words.join(' ') })).bytes;
await network('PUT', '/api/v1/me/keys', {
await network('PUT', '/api/v1/me/keys', {
ecdh_public: pub.ecdh, signing_public: pub.signing, self_signature: pub.fingerprint_signature, fingerprint: pub.fingerprint,
ecdh_public: pub.ecdh, signing_public: pub.signing, self_signature: pub.fingerprint_signature, fingerprint: pub.fingerprint,
...(o.reset ? { reset: true } : {}),
...(o.reset ? { reset: true } : {}),
- backups: { passphrase: { format: 'locker.envelope.v1', envelope: b64(byPass) }, recovery: { format: 'locker.envelope.v1', envelope: b64(byWords) } },
+ backups: { [device ? 'passkey' : 'passphrase']: { format: 'locker.envelope.v1', envelope: b64(byPass) }, recovery: { format: 'locker.envelope.v1', envelope: b64(byWords) } },
});
});
- session = { person, subject: null, fingerprint: pub.fingerprint };
+ if (device) {
+ root.localStorage.setItem(passkeyStorage(), JSON.stringify(device.saved));
+ device.secret = null;
+ }
+ session = { person, subject: cfg().subject || null, fingerprint: pub.fingerprint };
doc.dispatchEvent(new CustomEvent('ov-locker:change', { detail: state() }));
doc.dispatchEvent(new CustomEvent('ov-locker:change', { detail: state() }));
step4(pub.fingerprint);
step4(pub.fingerprint);
} catch (err) {
} catch (err) {
@@ -339,8 +388,11 @@
@@ -339,8 +388,11 @@
try {
try {
const c = await core();
const c = await core();
if (!backups) backups = await network('GET', '/api/v1/me/keys/backup');
if (!backups) backups = await network('GET', '/api/v1/me/keys/backup');
- const b = backups[mode === 'words' ? 'recovery' : 'passphrase'];
- if (!b || !b.envelope) throw Object.assign(new Error('nothing'), { status: 404 });
+ const b = (backups.backups || backups)[mode === 'words' ? 'recovery' : 'passphrase'];
+ if (!b || !b.envelope) {
+ if (mode !== 'words' && (backups.backups || backups).passkey) throw Object.assign(new Error('Use the passkey on its original device or choose your recovery kit.'), { code: 'locker.use_recovery' });
+ throw Object.assign(new Error('nothing'), { status: 404 });
+ }
const priv = JSON.parse(fromUtf8(await c.open(unb64(b.envelope), { password: secret })));
const priv = JSON.parse(fromUtf8(await c.open(unb64(b.envelope), { password: secret })));
const person = await c.importPersonKeys(priv);
const person = await c.importPersonKeys(priv);
// The fingerprint from the public keys alone (an imported set signs nothing here).
// The fingerprint from the public keys alone (an imported set signs nothing here).
@@ -352,6 +404,7 @@
@@ -352,6 +404,7 @@
} catch (err) {
} catch (err) {
go.disabled = false;
go.disabled = false;
if (needsReauth(err)) offerReauth(msg);
if (needsReauth(err)) offerReauth(msg);
+ else if (err.code === 'locker.use_recovery') say(msg, err.message, true);
else if (err.status === 404) {
else if (err.status === 404) {
say(msg, 'You have no locker yet.', true);
say(msg, 'You have no locker yet.', true);
const mk = button('Make my locker', 'quiet', { 'data-k': 'make-instead' });
const mk = button('Make my locker', 'quiet', { 'data-k': 'make-instead' });
@@ -382,6 +435,27 @@
@@ -382,6 +435,27 @@
s.body.appendChild(go);
s.body.appendChild(go);
};
};
draw('pass');
draw('pass');
+ const saved = devicePasskey();
+ if (saved && root.navigator.credentials) {
+ const pk = button('Unlock with this device’s passkey', 'quiet', { 'data-k': 'unlock-passkey' });
+ const msg = el('p', 'ovl-msg', { 'aria-live': 'polite' });
+ pk.addEventListener('click', async () => {
+ pk.disabled = true;
+ try {
+ const secret = await passkeySecret(saved);
+ const c = await core();
+ const backup = await network('GET', '/api/v1/me/keys/backup');
+ const b = (backup.backups || backup).passkey;
+ if (!b) throw new Error('This locker has no passkey backup. Use the recovery kit.');
+ const person = await c.importPersonKeys(JSON.parse(fromUtf8(await c.open(unb64(b.envelope), { password: secret }))));
+ session = { person, subject: backup.subject || null, fingerprint: await c.fingerprintOf(person.ecdhPublic, person.signingPublic) };
+ doc.dispatchEvent(new CustomEvent('ov-locker:change', { detail: state() }));
+ s.close(state());
+ if (typeof o.onDone === 'function') o.onDone(state());
+ } catch (e) { pk.disabled = false; say(msg, e.message, true); }
+ });
+ s.body.appendChild(pk); s.body.appendChild(msg);
+ }
return s.finished;
return s.finished;
}
}
@@ -512,8 +586,16 @@
@@ -512,8 +586,16 @@
});
});
}
}
+ /** Opens verified chunks with this tab's keys. No private key is exported to a service or caller. */
+ async function openStream(stream, credentials) {
+ const c = await core();
+ if (credentials) return c.openStream(stream, credentials);
+ await ensure();
+ return c.openStream(stream, { person: session.person });
+ }
+
root.OVLocker = {
root.OVLocker = {
- open, setup, unlock, ensure, keys, publicKeys, rewrap, seal, inspect: inspectBytes,
+ open, openStream, setup, unlock, ensure, keys, publicKeys, rewrap, seal, inspect: inspectBytes,
state, forget, keyFromLink, strength, version: 2,
state, forget, keyFromLink, strength, version: 2,
};
};
})(typeof window !== 'undefined' ? window : null);
})(typeof window !== 'undefined' ? window : null);
@@ -822,7 +822,6 @@
@@ -822,7 +822,6 @@
const L = await locker();
const L = await locker();
if (!L) throw new Error('The Locker could not load. Check your connection and try again.');
if (!L) throw new Error('The Locker could not load. Check your connection and try again.');
await L.ensure();
await L.ensure();
- const bytes = await envelopeBytes(st);
const publicKey = async (m) => {
const publicKey = async (m) => {
if (m._publicKey) return m._publicKey;
if (m._publicKey) return m._publicKey;
const keys = await L.keys(m.subject);
const keys = await L.keys(m.subject);
@@ -836,9 +835,13 @@
@@ -836,9 +835,13 @@
const keys = [];
const keys = [];
const me = await L.publicKeys();
const me = await L.publicKeys();
const my = st.server.you && st.server.you.subject;
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 });
+ if ((remove.length || st.o.prepareEnvelope) && my && me) keys.push({ subject: my, publicKey: me.ecdh });
+ if (remove.length || st.o.prepareEnvelope) for (const m of after) keys.push({ subject: m.subject, publicKey: await publicKey(m) });
+ // File services may stream a new envelope to their ciphertext store and return a short receipt.
+ // The sheet still supplies the recipients and saves their slots and access list together.
+ const out = typeof st.o.prepareEnvelope === 'function'
+ ? await st.o.prepareEnvelope({ keys, remove, add })
+ : await L.rewrap(await envelopeBytes(st), { add, remove, keys });
const slots = new Map((out.header.slots.people || []).map((slot) => [sid(slot.subject), slot]));
const slots = new Map((out.header.slots.people || []).map((slot) => [sid(slot.subject), slot]));
const list = after.map((m) => {
const list = after.map((m) => {
const slot = slots.get(sid(m.subject));
const slot = slots.get(sid(m.subject));
@@ -847,7 +850,7 @@
@@ -847,7 +850,7 @@
if (m.expires_at) r.expires_at = m.expires_at;
if (m.expires_at) r.expires_at = m.expires_at;
return r;
return r;
});
});
- return { members: list, envelope: b64(out.bytes) };
+ return { members: list, ...(out.envelope_ref ? { envelope_ref: out.envelope_ref } : { 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.
@@ -1209,7 +1212,7 @@
@@ -1209,7 +1212,7 @@
// sealed again. The bytes go up with the list, and this browser is the only place they were opened.
// 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') {
if (isLocked(st) && st.server.lock === 'people') {
const sealed = await resealIfNeeded(st);
const sealed = await resealIfNeeded(st);
- if (sealed) { body.members = sealed.members; body.envelope = sealed.envelope; }
+ if (sealed) { body.members = sealed.members; if (sealed.envelope_ref) body.envelope_ref = sealed.envelope_ref; else body.envelope = sealed.envelope; }
}
}
const out = await call(st.o, 'PUT', endpoint(st.o).path, body, st.etag ? { 'if-match': st.etag } : null);
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));
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.143.0",
+ "version": "4.156.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.143.0",
+ "version": "4.156.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"@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.338.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.101.0"
}
}
},
},
"node_modules/@electric-sql/pglite": {
"node_modules/@electric-sql/pglite": {
@@ -1089,9 +1089,9 @@
@@ -1089,9 +1089,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.94.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
- "integrity": "sha512-p3buZSOSFV81OdZgs5GoSIfMCQNJjW23eAIXIt0RVFIOvh3TByNNEMmuCanxRybFIbzh+9IDMbBwHJrBLl3JeA==",
+ "version": "0.101.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.101.0",
+ "integrity": "sha512-W0WVhpc4cL0zJOW7IRAzz1polzR1MU571SMyDUEh9CeJWlocENlISbz9rr78AZmrcmqCvAn+25D3aLTNQpO7Ug==",
"dev": true,
"dev": true,
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.155.0",
+ "version": "4.156.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": {
@@ -156,6 +156,6 @@
@@ -156,6 +156,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.338.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.101.0"
}
}
}
}
@@ -39,7 +39,14 @@ assert.strictEqual(applyBlock('a\n\n<!-- versions:start -->\n- openvibe-sdk: v0.
@@ -39,7 +39,14 @@ assert.strictEqual(applyBlock('a\n\n<!-- versions:start -->\n- openvibe-sdk: v0.
const r = inspect(root);
const r = inspect(root);
// The expectation comes from package.json itself, so a pin bump (scripts/pin-bump.js) needs no test edit.
// The expectation comes from package.json itself, so a pin bump (scripts/pin-bump.js) needs no test edit.
assert.deepStrictEqual(r.pins, pinsIn(JSON.parse(read(root, 'package.json'))));
assert.deepStrictEqual(r.pins, pinsIn(JSON.parse(read(root, 'package.json'))));
- assert.deepStrictEqual(Object.keys(r.pins).sort(), ['openvibe-contracts', 'openvibe-sdk']);
+ // This check lists release pins. Locker's companion SDK uses an immutable review commit until release.
+ const sdkSpec = JSON.parse(read(root, 'package.json')).devDependencies['openvibe-sdk'];
+ const reviewCommit = /OpenVibe\.SDK\/tar\.gz\/([a-f0-9]{40})$/.exec(sdkSpec);
+ assert.deepStrictEqual(Object.keys(r.pins).sort(), reviewCommit ? ['openvibe-contracts'] : ['openvibe-contracts', 'openvibe-sdk']);
+ if (reviewCommit) {
+ assert.ok(JSON.parse(read(root, 'STATUS.json')).sdk.includes(reviewCommit[1]), 'STATUS names the exact reviewed SDK commit');
+ assert.ok(read(root, 'README.md').includes(reviewCommit[1]), 'README names the exact reviewed SDK commit');
+ }
assert.deepStrictEqual(r.findings.filter((f) => !f.ok), [], 'the checked-in STATUS.json and README block are current');
assert.deepStrictEqual(r.findings.filter((f) => !f.ok), [], 'the checked-in STATUS.json and README block are current');
const cli = cp.spawnSync(process.execPath, [script, root, '--check'], { encoding: 'utf8' });
const cli = cp.spawnSync(process.execPath, [script, root, '--check'], { encoding: 'utf8' });
assert.strictEqual(cli.status, 0, cli.stdout + cli.stderr);
assert.strictEqual(cli.status, 0, cli.stdout + cli.stderr);
@@ -38,7 +38,7 @@ const server = http.createServer((req, res) => {
@@ -38,7 +38,7 @@ const server = http.createServer((req, res) => {
if (u.pathname === '/api/v1/me/keys' && req.method === 'PUT') { stored = JSON.parse(body); res.writeHead(200, { 'content-type': 'application/json' }); return res.end('{"ok":true}'); }
if (u.pathname === '/api/v1/me/keys' && req.method === 'PUT') { stored = JSON.parse(body); res.writeHead(200, { 'content-type': 'application/json' }); return res.end('{"ok":true}'); }
if (u.pathname === '/api/v1/me/keys/backup') {
if (u.pathname === '/api/v1/me/keys/backup') {
if (!stored) { res.writeHead(404, { 'content-type': 'application/json' }); return res.end('{"error":"no keys"}'); }
if (!stored) { res.writeHead(404, { 'content-type': 'application/json' }); return res.end('{"error":"no keys"}'); }
- res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ subject: 'usr_x', ...stored.backups }));
+ res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ subject: 'usr_x', backups: stored.backups }));
}
}
if (u.pathname.startsWith('/item/') && items[u.pathname.slice(6)]) { res.writeHead(200, { 'content-type': 'application/octet-stream' }); return res.end(Buffer.from(items[u.pathname.slice(6)])); }
if (u.pathname.startsWith('/item/') && items[u.pathname.slice(6)]) { res.writeHead(200, { 'content-type': 'application/octet-stream' }); return res.end(Buffer.from(items[u.pathname.slice(6)])); }
res.writeHead(404); return res.end();
res.writeHead(404); return res.end();
@@ -112,6 +112,46 @@ const server = http.createServer((req, res) => {
@@ -112,6 +112,46 @@ const server = http.createServer((req, res) => {
await c.evaluate(type('words', words.join(' ').toUpperCase())); await c.evaluate(press('unlock'));
await c.evaluate(type('words', words.join(' ').toUpperCase())); await c.evaluate(press('unlock'));
await wait(5000);
await wait(5000);
assert.strictEqual(await c.evaluate('OVLocker.state().fingerprint'), fp, 'the recovery words open the same locker');
assert.strictEqual(await c.evaluate('OVLocker.state().fingerprint'), fp, 'the recovery words open the same locker');
+ // A PRF-capable authenticator is stubbed at the browser boundary; sealing and opening remain real WebCrypto.
+ // Hardware availability varies, so the unsupported case must leave setup usable too.
+ const d = await h.openPage(browser, await browser.newContext(), { width: 390 });
+ await d.goto(`${base}/`, { maxMs: 5000 });
+ await d.evaluate(`(() => {
+ window.__prf = crypto.getRandomValues(new Uint8Array(32));
+ Object.defineProperty(navigator, 'credentials', { configurable: true, value: {
+ create: async () => ({ rawId: new Uint8Array([1,2,3,4]).buffer, getClientExtensionResults: () => ({ prf: { enabled: true } }) }),
+ get: async (options) => {
+ if (options.publicKey.userVerification !== 'required') throw new Error('verification missing');
+ return { getClientExtensionResults: () => ({ prf: { results: { first: window.__prf.buffer } } }) };
+ },
+ } });
+ OVLocker.setup();
+ })()`);
+ await wait(300); await d.evaluate(press('setup-passkey')); await wait(1500);
+ const recovery = await d.evaluate("[...document.querySelectorAll('.ovl-words li')].map(x => x.textContent)");
+ assert.strictEqual(recovery.length, 24);
+ assert.strictEqual(await d.evaluate("!!document.querySelector('[data-k=print]')"), true);
+ const ask = await d.evaluate("[...document.querySelectorAll('dialog .ovl-pair .ovl-label')].map(x => Number(x.textContent.replace(/\\D/g, '')))");
+ await d.evaluate(type('w1', recovery[ask[0] - 1])); await d.evaluate(type('w2', recovery[ask[1] - 1])); await d.evaluate(press('make')); await wait(9000);
+ assert.ok(stored.backups.passkey && stored.backups.recovery && !stored.backups.passphrase);
+ const storage = await d.evaluate("JSON.parse(localStorage.getItem('ov-locker-passkey:device'))");
+ assert.deepStrictEqual(Object.keys(storage).sort(), ['id', 'salt'], 'only public passkey lookup data stays on the device');
+ const pkFp = await d.evaluate('OVLocker.state().fingerprint');
+ await d.evaluate(press('done'));
+ await d.evaluate('(() => { OVLocker.forget(); OVLocker.unlock(); })()'); await wait(300);
+ await d.evaluate(press('unlock-passkey')); await wait(5000);
+ assert.strictEqual(await d.evaluate('OVLocker.state().fingerprint'), pkFp, 'PRF opens the same SDK-protected keys');
+ items.stream = (await locker.seal('streamed on device', { lock: 'people', keys: [{ subject: { type: 'user', id: 'usr_x' }, publicKey: stored.ecdh_public }] })).bytes;
+ assert.strictEqual(await d.evaluate("(async () => new Response(await OVLocker.openStream((await fetch('/item/stream')).body)).text())()"), 'streamed on device');
+ await d.evaluate('(() => { OVLocker.forget(); OVLocker.unlock(); })()'); await wait(300);
+ await d.evaluate(type('pass', PASS)); await d.evaluate(press('unlock')); await wait(300);
+ assert.match(await d.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /recovery kit/);
+ assert.strictEqual(await d.evaluate("!!document.querySelector('[data-k=make-instead]')"), false, 'an existing passkey locker is never described as missing');
+ await d.evaluate("document.querySelector('dialog button[aria-label=Close]').click()");
+ await d.evaluate(`(() => { OVLocker.forget(); navigator.credentials.create = async () => ({ getClientExtensionResults: () => ({}) }); OVLocker.setup(); })()`);
+ await wait(300); await d.evaluate(press('setup-passkey')); await wait(300);
+ assert.match(await d.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /does not support Locker/);
+ assert.strictEqual(await d.evaluate("document.querySelector('[data-k=setup-passkey]').disabled"), false);
console.log('ov-locker-e2e-chrome: real crypto: make a locker, unlock it on a new device by passphrase and by recovery words, open what was locked for you, refuse what was not');
console.log('ov-locker-e2e-chrome: real crypto: make a locker, unlock it on a new device by passphrase and by recovery words, open what was locked for you, refuse what was not');
} finally {
} finally {
await browser.close();
await browser.close();