openvibers/openvibe.shared Commits

4.156.0: Locker passkey unlock and streamed opens (core from openvibe-sdk 0.101.0) (#347)

OP OpenVibers committed 5 days ago · 725216771a · 14 files, +239 −40

* Support device passkeys and streaming Locker shares

* Pin the completed Locker SDK browser build

* Document and verify the immutable Locker SDK review pin

* Guide passkey recovery and support same-origin key proxies

* Merge remote-tracking branch 'origin/main' into feat/locker-device-streams

# Conflicts:
#	navbar.js

* 4.156.0: Locker passkey unlock and streamed opens; core regenerated from openvibe-sdk 0.101.0

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 0dd2756..921936d 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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 …
ddiff --git a/README.md b/README.md
iindex a4b5919..46462ac 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
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
the tag it pins.
@@@ -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).
++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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 49fab01..1b2e8b5 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/docs/locker.md b/docs/locker.md
iindex 87aebf5..a84dca1 100644
---- a/docs/locker.md
++++ b/docs/locker.md
@@@ -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.
ddiff --git a/docs/share.md b/docs/share.md
iindex 35bd319..7220034 100644
---- a/docs/share.md
++++ b/docs/share.md
@@@ -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.
ddiff --git a/navbar.js b/navbar.js
iindex d81c0e4..caa7eed 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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">.
ddiff --git a/ov-locker-core.js b/ov-locker-core.js
iindex a254580..c099413 100644
---- a/ov-locker-core.js
++++ b/ov-locker-core.js
@@@ -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);
ddiff --git a/ov-locker.css b/ov-locker.css
iindex d444d06..e50199a 100644
---- a/ov-locker.css
++++ b/ov-locker.css
@@@ -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; }
++}
ddiff --git a/ov-locker.js b/ov-locker.js
iindex 4875c50..544a004 100644
---- a/ov-locker.js
++++ b/ov-locker.js
@@@ -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);
ddiff --git a/ov-share.js b/ov-share.js
iindex 2cc1d81..c69405b 100644
---- a/ov-share.js
++++ b/ov-share.js
@@@ -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));
ddiff --git a/package-lock.json b/package-lock.json
iindex f6d2cdf..0322726 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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": {
ddiff --git a/package.json b/package.json
iindex bfa87b3..5ce8c7e 100644
---- a/package.json
++++ b/package.json
@@@ -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"
}
}
ddiff --git a/test/docs-status.test.js b/test/docs-status.test.js
iindex 660a4c7..90324a6 100644
---- a/test/docs-status.test.js
++++ b/test/docs-status.test.js
@@@ -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);
ddiff --git a/test/ov-locker-e2e-chrome.test.js b/test/ov-locker-e2e-chrome.test.js
iindex 005bc04..d5c002f 100644
---- a/test/ov-locker-e2e-chrome.test.js
++++ b/test/ov-locker-e2e-chrome.test.js
@@@ -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();