openvibers/openvibe.shared Commits

4.115.0: Locker in the browser (ov-locker.js, ov-locker-core.js from openvibe-sdk 0.86.0) (#297)

OP OpenVibers committed 7 days ago · aab1ba19da · 14 files, +2780 −12

* Locker in the browser: a locked item's card, making and unlocking your locker (ov-locker.js)

A locked item shows a card fitted to its lock: the link's key after #k= opens it by itself (a link missing its key says
so), a password opens it on the device, and a people lock asks to unlock your locker. Making a locker takes a passphrase
(a strength meter), then a recovery kit of 24 words shown once and checked with two of them; Network keeps the public
keys and two Locker envelopes of the private keys (passphrase, recovery words). Unlocking uses either. Secrets stay in
the tab's memory: never sent, never stored. The crypto is ov-locker-core.js, generated from openvibe-sdk/locker (next).

* Locker: the generator for ov-locker-core.js (openvibe-sdk/locker for the browser) and its test

scripts/build-locker-core.js copies the SDK's src/locker files, unchanged, into one classic script with a module table
(window.OVLockerCore), recording the SDK version and each file's hash. test/ov-locker-core.test.js checks the file is
current, runs it in a VM with only browser globals, opens the SDK's vectors through it, and crosses envelopes between
the browser build and Node both ways. The UI takes seal()'s bytes. ov-locker-core.js itself is generated once the
reviewed SDK is released and pinned.

* Locker UI: send the keys' self-signature under the SDK's name (fingerprint_signature) and the fingerprint

* Locker UI: lost both the passphrase and the kit — start a new locker, said plainly

From the recovery-kit screen, "I lost both" explains that anything locked for the old keys cannot be opened again by
anyone, waits for the person to tick that they understand, then makes a new locker with reset: true. When Network asks
for a fresh sign-in first (its rule for a reset), the sheet says so and links to sign in again.

* 4.115.0: Locker in the browser, with ov-locker-core.js generated from openvibe-sdk 0.86.0

The real crypto is in: ov-locker-core.js is openvibe-sdk/locker 0.86.0, generated by scripts/build-locker-core.js
from every file of src/locker (no require and no module.exports left in a browser file). It makes the SDK's vectors
again with the same bytes in a browser-only VM. A real-crypto Chrome test makes a locker, unlocks it on a new
device by passphrase and by recovery words, opens what was locked for that person and refuses what was not.
The UI takes the recovery phrase as the SDK's string, and unlock reads the fingerprint from the public keys.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 8dbf8d0..6148666 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,26 @@
# Changelog
++## 4.115.0 — 2026-11-06
++
++- **Locker in the browser** (`ov-locker.js`, `ov-locker.css`; plan Q2; docs/locker.md). The owner: "completely
++ encrypted on our end … decrypted on the client's end, so we don't have access to any of the user data."
++ - `OVLocker.open(el, { bytes | url })`: a locked item's card. A link lock opens with the key after `#k=`, and a link
++ missing it says so. A password lock opens with its password, on the device. A people lock asks to unlock your
++ locker, and someone not on the list is told so.
++ - `OVLocker.setup()`: make your locker. A passphrase with a strength meter, a 24-word recovery kit shown once and
++ checked with two of its words, then the eight-word fingerprint. Network keeps the public keys, a self-signature
++ and two password-locked envelopes of the private keys.
++ - `OVLocker.unlock()`: unlock with the passphrase or the recovery kit. "I lost both" starts a new locker (reset)
++ after a plain warning.
++ - Secrets stay in the tab's memory: never sent, never stored.
++- **`ov-locker-core.js`**: `openvibe-sdk/locker` 0.86.0 for the browser, generated by `scripts/build-locker-core.js`
++ from the SDK's own files (WebCrypto only).
++ - test/ov-locker-core.test.js makes the SDK's vectors again in a browser-only VM, with the same bytes, and crosses
++ envelopes between the browser and Node.
++ - test/ov-locker-e2e-chrome.test.js uses the 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.
++- openvibe-sdk devDependency 0.82.0 → 0.86.0.
++
## 4.114.0 — 2026-11-06
- **One Share sheet on every site** (`ov-share.js`, `ov-share.css`; plan track Q; docs/share.md). Press Share
ddiff --git a/README.md b/README.md
iindex aea408a..f810f24 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.114.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.115.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.
@@@ -902,6 +902,13 @@ so forms, `select.value` and `change` listeners work as before. An option can sa
switch; `.ov-field`, `.ov-input` and `.ov-choice` are the full field look. Turn it off with `data-ov-native` or
`<html data-ov-forms="off">`. See [docs/forms.md](docs/forms.md).
++### Locker (`ov-locker.js`, `ov-locker.css`, `ov-locker-core.js`)
++
++End-to-end encryption in the browser (plan Q2): a locked item's card (the link's key, a password, or your locker),
++making your locker (a passphrase, then a 24-word recovery kit), and unlocking it on a new device. Nothing readable
++leaves the page. `ov-locker-core.js` is `openvibe-sdk/locker`, generated (`node scripts/build-locker-core.js`), and
++reproduces the SDK's test vectors exactly. See [docs/locker.md](docs/locker.md).
++
### Share (`ov-share.js`, `ov-share.css`)
One Share sheet on every site (plan track Q): a sheet from the bottom on a phone, a dialog on a computer.
@@@ -1079,5 +1086,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
- openvibe-contracts: v0.289.1
--- openvibe-sdk: v0.82.0
++- openvibe-sdk: v0.86.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 3dfe67f..c5ccf16 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,12 +4,12 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.114.0",
++ "version": "4.115.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"contracts": "openvibe-contracts v0.289.1 (devDependency: the release manifest contract)",
-- "sdk": "openvibe-sdk v0.82.0",
++ "sdk": "openvibe-sdk v0.86.0",
"features": [
"the OpenVibe Frame: navbar, footer, panels, account switcher, notification UI with realtime",
"the launcher beside the brand lists every site of the network, in groups; Ask Actor and the Vibes chip in the account menu",
ddiff --git a/docs/locker.md b/docs/locker.md
nnew file mode 100644
iindex 0000000..9b68324
---- /dev/null
++++ b/docs/locker.md
@@@ -0,0 +1,53 @@
++# Locker in the browser (`ov-locker.js`, `ov-locker.css`)
++
++Plan track Q2. The owner, 2026-11-06: "an encryption feature for everything … the person can encrypt it and set the
++encryption password … completely encrypted on our end … decrypted on the client's end, so we don't have access to any
++of the user data." The format and the cryptography are `openvibe-sdk/locker` (`locker.envelope.v1`).
++`ov-locker-core.js` here is a generated copy of that module for the browser: WebCrypto only, with the same test
++vectors.
++
++## What a person sees
++
++- **A locked item** shows a card instead of its content. The card fits the lock:
++ - **With the link:** the key is after `#k=` in the link, and the item opens by itself. A link missing its key says
++ so, and that OpenVibe never had the key.
++ - **With a password:** the person types the password they were given, and the item opens on their device.
++ - **For specific people:** "Unlock my locker", then it opens. A person who isn't on the list is told so.
++- **Your locker** holds a person's own keys, made on their device the first time they need one:
++ 1. **A passphrase**: four or more words. A strength meter says how easy it is to guess.
++ 2. **A recovery kit**: 24 words, shown once. The person can copy them or save them as a file, then types two of
++ them back so the kit was really kept.
++ 3. **Done**: the locker's fingerprint, eight words someone can compare with them in person.
++
++ OpenVibe.Network keeps the public keys and two locked copies of the private keys: one opens with the passphrase,
++ one with the recovery words. Nobody at OpenVibe can open either one or reset them.
++- **Unlocking on a new device**: the passphrase, or "I forgot it: use my recovery kit".
++
++## What it promises
++
++- **Nothing readable leaves the device.**
++ - Passwords, passphrases, recovery words, content keys and private keys stay in the tab's memory.
++ - They are gone when the tab closes, or with `OVLocker.forget()`.
++ - They are never in `localStorage`, a cookie or a request.
++- **Text, never markup.** Every word is set as text.
++- **Motion.** Nothing spins: a wait is words. Under `prefers-reduced-motion` nothing moves.
++- **Phones.** A sheet from the bottom, 16 px fields, 44 px targets, and the recovery words in two columns.
++
++## From a script
++
++```js
++OVLocker.open(el, { bytes | url, title, onOpen(bytes, info) }); // the card a locked item shows
++OVLocker.setup({ onDone }); // make your locker
++OVLocker.unlock({ onDone }); // unlock it on this device
++OVLocker.state(); // { unlocked, fingerprint, subject }
++OVLocker.forget(); // drop the unlocked keys
++OVLocker.keyFromLink(); // the 32-byte key after #k=, or null
++window.OpenVibeLockerConfig = { network: 'https://openvibe.network' };
++```
++
++Events on `document`: `ov-locker:change` (`state()`), and `ov-locker:opened` (`{ lock }`).
++
++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`
ddiff --git a/files.js b/files.js
iindex eb1431c..dc24734 100644
---- a/files.js
++++ b/files.js
@@@ -35,6 +35,8 @@ const BROWSER = Object.freeze([
'ov-labels.js', // content labels (L1): each viewer's show / blur / hide; navbar.js loads it when a page has [data-ov-labels]
'ov-forms.js', // OpenVibe's own select (a menu on a computer, a sheet on a phone); navbar.js loads it when a page has a form control
'ov-qr.js', // a QR code from a link, on the device: the Share sheet's QR code (ov-share.js loads it on first use)
++ 'ov-locker-core.js', // openvibe-sdk/locker for the browser, generated (scripts/build-locker-core.js): WebCrypto only, the SDK's own vectors
++ 'ov-locker.js', // Locker in the browser (plan Q2): a locked item's card, making and unlocking your locker; crypto in ov-locker-core.js
'ov-share.js', // the one Share sheet (plan track Q): who can open a thing, people by @name, transfer; navbar.js loads it on the first press of a [data-ov-share-sheet] button
'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css
'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)
@@@ -75,6 +77,7 @@ const STYLES = Object.freeze([
'player.css', // player.js's controls
'kit.css', // the OpenVibe Kit's look (kit.js / openvibe-shared/kit build the markup; docs/kit.md)
'ov-forms.css', // the look of ov-forms.js's select, the site-coloured checkboxes, switches and sliders, .ov-field (docs/forms.md)
++ 'ov-locker.css', // ov-locker.js's look: the locked card, the locker sheets, the recovery kit (docs/locker.md)
'ov-share.css', // ov-share.js's look: the sheet, the recipient's offer banner, the not-on-the-list gate (docs/share.md)
'room.css', // kit.card, kit.rail and a room's accent (4.29.1; load after kit.css where cards, rails or sc.room() are)
'thread.css', // the conversation UI's look (openvibe-shared/thread, ov-thread.js; plan L55 C4)
ddiff --git a/ov-locker-core.js b/ov-locker-core.js
nnew file mode 100644
iindex 0000000..45f4596
---- /dev/null
++++ b/ov-locker-core.js
@@@ -0,0 +1,1690 @@
++/* Generated by scripts/build-locker-core.js from openvibe-sdk 0.86.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
++ * padme.js e0dd4b08e0d0f24f
++ * recovery.js 2829d07a02561674
++ * seal.js b70d01522636486d
++ * server.js 44a92f3cbb3e6bc1
++ * words.js 8f4f51abdadfbfd1
++ */
++(function (root) {
++ 'use strict';
++ if (!root || root.OVLockerCore) return;
++ const defs = {
++ "bytes": function (__ovLockerModule, exports, __ovLockerReq) {
++'use strict';
++/**
++ * Byte helpers for the Locker envelope. Framing only, no cryptography: every value is a
++ * Uint8Array, so the same code runs in Node and in the browser.
++ */
++
++const encoder = new TextEncoder();
++const decoder = new TextDecoder();
++
++/** The UTF-8 bytes of a string. */
++function utf8(text) { return encoder.encode(text); }
++
++/** The string of UTF-8 bytes. */
++function fromUtf8(bytes) { return decoder.decode(bytes); }
++
++/** One buffer of all the parts, in order. */
++function concat(...parts) {
++ let total = 0;
++ for (const part of parts) total += part.length;
++ const out = new Uint8Array(total);
++ let at = 0;
++ for (const part of parts) { out.set(part, at); at += part.length; }
++ return out;
++}
++
++/** Standard base64. btoa/atob are in every browser and in Node. */
++function base64(bytes) {
++ let binary = '';
++ for (const byte of bytes) binary += String.fromCharCode(byte);
++ return btoa(binary);
++}
++
++/** The bytes of a standard base64 string. */
++function fromBase64(text) {
++ const binary = atob(text);
++ const out = new Uint8Array(binary.length);
++ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
++ return out;
++}
++
++/** base64url: the same alphabet without the padding, for keys that travel in a URL. */
++function base64url(bytes) { return base64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
++
++/** The bytes of a base64url string. */
++function fromBase64url(text) {
++ const padded = text.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (text.length % 4)) % 4);
++ return fromBase64(padded);
++}
++
++/** A number as four bytes, big endian. */
++function u32be(value) {
++ const out = new Uint8Array(4);
++ new DataView(out.buffer).setUint32(0, value >>> 0, false);
++ return out;
++}
++
++/** The number four bytes at `at` hold, big endian. */
++function readU32be(bytes, at) { return new DataView(bytes.buffer, bytes.byteOffset + at, 4).getUint32(0, false); }
++
++/** A number as eight bytes, big endian. */
++function u64be(value) {
++ const out = new Uint8Array(8);
++ new DataView(out.buffer).setBigUint64(0, BigInt(value), false);
++ return out;
++}
++
++/** The number eight bytes at `at` hold, big endian. */
++function readU64be(bytes, at) { return new DataView(bytes.buffer, bytes.byteOffset + at, 8).getBigUint64(0, false); }
++
++/**
++ * The canonical JSON of a value: object keys sorted, no whitespace. Two sides that hold the
++ * same value therefore hold the same bytes, which is what a signature or an associated data
++ * needs. (JSON.stringify would keep the order the object was built in, which is not a promise
++ * we can make across two implementations.)
++ */
++function canonicalJson(value) {
++ if (value === null || typeof value !== 'object') return JSON.stringify(value);
++ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
++ const keys = Object.keys(value).filter(key => value[key] !== undefined).sort();
++ return `{${keys.map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
++}
++
++__ovLockerModule.exports = {
++ utf8, fromUtf8, concat, base64, fromBase64, base64url, fromBase64url,
++ u32be, readU32be, u64be, readU64be, canonicalJson,
++};
++
++ },
++ "errors": function (__ovLockerModule, exports, __ovLockerReq) {
++'use strict';
++/**
++ * The one error Locker throws. The code says what went wrong, so a caller can tell a wrong
++ * password from a truncated download from bytes that are not an envelope at all.
++ *
++ * The codes: locker.invalid (the caller asked for something impossible), locker.bad_envelope
++ * (the bytes are not an envelope we wrote), locker.truncated (the bytes stop early),
++ * locker.wrong_key (the key is not the one), locker.no_key (no key was given), locker.no_slot
++ * (there is no slot for this key), locker.size_mismatch (the size does not add up) and
++ * locker.no_crypto (the runtime has no WebCrypto).
++ */
++class LockerError extends Error {
++ constructor(code, message) {
++ super(message);
++ this.name = 'LockerError';
++ this.code = code;
++ }
++}
++
++/** A Locker error, or a plain Error with the same shape. */
++function fail(code, message) { throw new LockerError(code, message); }
++
++__ovLockerModule.exports = { LockerError, fail };
++
++ },
++ "format": function (__ovLockerModule, exports, __ovLockerReq) {
++'use strict';
++/**
++ * The Locker envelope on the wire (locker.envelope@1). This file is the format and nothing
++ * else: the numbers, the framing and the associated data. The cryptography lives in keys.js
++ * and seal.js; the rules for a service that only reads the header live in server.js.
++ *
++ * 0 4 8 8 + header length
++ * +------+------+--------------------------+--------------------------+
++ * | OVLK | len | header JSON (UTF-8) | chunk frames |
++ * +------+------+--------------------------+--------------------------+
++ *
++ * each chunk frame:
++ * 0 4 5 17
++ * +------+------+--------------------------+--------------------------+
++ * | len | flags| nonce (12 bytes) | ciphertext + 16-byte tag |
++ * +------+------+--------------------------+--------------------------+
++ */
++const { utf8, fromUtf8, concat, base64, fromBase64, u32be, readU32be, canonicalJson } = __ovLockerReq('./bytes');
++const { fail } = __ovLockerReq('./errors');
++const { padme } = __ovLockerReq('./padme');
++
++/** The four bytes that say "this is a Locker envelope". */
++const MAGIC = 'OVLK';
++/** The format this library reads and writes. */
++const VERSION = 'locker.envelope.v1';
++/** The only suite in this version: AES-256-GCM, a 32-byte key. */
++const SUITE = 'AES-256-GCM';
++/** The chunk size in bytes: 1 MiB. */
++const CHUNK_SIZE = 1048576;
++/** The GCM tag is appended to the ciphertext. */
++const TAG_SIZE = 16;
++/** The GCM nonce is 12 bytes. */
++const NONCE_SIZE = 12;
++/** The first half of every nonce: eight random bytes, in the header. */
++const NONCE_BASE_SIZE = 8;
++/** The padded plaintext starts with the true length, as eight bytes. */
++const LENGTH_PREFIX = 8;
++/**
++ * The smallest padded plaintext we write. A short note still costs 256 bytes: below that the
++ * padded length would say too much about the real one (Padmé's grid is coarse down there), and
++ * 256 bytes is nothing next to a chunk.
++ */
++const MIN_PADDED_SIZE = 256;
++/** A header is small. A bigger one is not a header we wrote, and a service must not read it. */
++const MAX_HEADER_BYTES = 65536;
++/** The bit that marks the last chunk of an envelope. */
++const FINAL_FLAG = 0x01;
++
++/**
++ * The header fields every chunk authenticates. The slots are not in here, on purpose: adding a
++ * member to a people-locked item re-wraps the content key without re-encrypting the content, so
++ * the core cannot change either. Each slot is authenticated by its own tag instead.
++ */
++function coreOf(header) {
++ return {
++ version: header.version,
++ suite: header.suite,
++ chunk_size: header.chunk_size,
++ padded_size: header.padded_size,
++ nonce_base: header.nonce_base,
++ lock: header.lock,
++ };
++}
++
++/** The canonical JSON of the core: the same bytes on both sides, whatever order the header was written in. */
++function coreBytes(header) { return utf8(canonicalJson(coreOf(header))); }
++
++/** The associated data of one chunk: the core, the chunk counter and the flags. */
++function chunkAad(header, counter, flags) {
++ return concat(coreBytes(header), u32be(counter), Uint8Array.of(flags));
++}
++
++/** The header as the bytes that go on the wire. */
++function encodeHeader(header) { return utf8(canonicalJson(header)); }
++
++/** The header's JSON bytes, as they were written. */
++function decodeHeader(headerBytes) {
++ let header;
++ try { header = JSON.parse(fromUtf8(headerBytes)); }
++ catch { fail('locker.bad_envelope', 'The envelope header is not JSON'); }
++ return header;
++}
++
++/** The whole envelope: the magic, the header and the chunks. */
++function encodeEnvelope(headerBytes, chunks) {
++ return concat(utf8(MAGIC), u32be(headerBytes.length), headerBytes, chunks);
++}
++
++/**
++ * Reads the header out of envelope bytes. It stops at the header: the chunks are not touched,
++ * so this is safe to do on bytes that are not an envelope at all.
++ */
++function readHeader(bytes) {
++ if (!bytes || bytes.length < 8) fail('locker.bad_envelope', 'The envelope is too short to hold a header');
++ const magic = fromUtf8(bytes.slice(0, 4));
++ if (magic !== MAGIC) fail('locker.bad_envelope', 'These bytes are not a Locker envelope');
++ const headerLength = readU32be(bytes, 4);
++ if (headerLength === 0 || headerLength > MAX_HEADER_BYTES) {
++ fail('locker.bad_envelope', `The envelope header is ${headerLength} bytes, which is not one we write`);
++ }
++ if (bytes.length < 8 + headerLength) fail('locker.truncated', 'The envelope ends inside its header');
++ const headerBytes = bytes.slice(8, 8 + headerLength);
++ return { header: decodeHeader(headerBytes), headerBytes, chunkOffset: 8 + headerLength };
++}
++
++/**
++ * How many chunks a padded plaintext of this size takes. Every chunk but the last is a whole
++ * chunk; the last holds what is left of it.
++ */
++function chunkCount(padded) { return Math.max(1, Math.ceil(padded / CHUNK_SIZE)); }
++
++/** How many bytes the last chunk of a padded plaintext of this size holds: more than zero. */
++function lastChunkSize(padded) { return padded - (chunkCount(padded) - 1) * CHUNK_SIZE; }
++
++/**
++ * The padded size of a content of this size: the length prefix plus the content, padded with
++ * Padmé up to the next size on its grid, and never below the floor.
++ */
++function paddedSize(size) { return Math.max(MIN_PADDED_SIZE, padme(LENGTH_PREFIX + size)); }
++
++/** The nonce of a chunk: the header's base, then the counter. No nonce repeats inside an envelope. */
++function chunkNonce(nonceBase, counter) {
++ const nonce = new Uint8Array(NONCE_SIZE);
++ nonce.set(nonceBase, 0);
++ new DataView(nonce.buffer).setUint32(8, counter >>> 0, false);
++ return nonce;
++}
++
++/** One chunk frame: its length, its flags, its nonce and the ciphertext with the tag. */
++function encodeChunk(counter, flags, nonce, ciphertext) {
++ return concat(u32be(ciphertext.length), Uint8Array.of(flags), nonce, ciphertext);
++}
++
++/** The header's nonce base as bytes. */
++function nonceBaseBytes(header) {
++ const bytes = fromBase64(header.nonce_base);
++ if (bytes.length !== NONCE_BASE_SIZE) fail('locker.bad_envelope', 'The envelope nonce base is not eight bytes');
++ return bytes;
++}
++
++__ovLockerModule.exports = {
++ MAGIC, VERSION, SUITE, CHUNK_SIZE, TAG_SIZE, NONCE_SIZE, NONCE_BASE_SIZE, LENGTH_PREFIX,
++ MIN_PADDED_SIZE, MAX_HEADER_BYTES, FINAL_FLAG,
++ coreOf, coreBytes, chunkAad, encodeHeader, decodeHeader, encodeEnvelope, readHeader,
++ chunkCount, lastChunkSize, paddedSize, chunkNonce, encodeChunk, nonceBaseBytes,
++};
++
++ },
++ "index": function (__ovLockerModule, exports, __ovLockerReq) {
++'use strict';
++/**
++ * openvibe-sdk/locker: end-to-end encryption for anything on OpenVibe.
++ *
++ * One format, one library, two runtimes: Node and the browser, WebCrypto only. A locked item is
++ * stored and served as ciphertext; the keys never reach our servers.
++ *
++ * const { seal, open, generatePersonKeys } = locker; // openvibe-sdk/locker
++ * const person = await generatePersonKeys();
++ * const sealed = await seal('a note', { lock: 'people', keys: [{ subject, publicKey: person.ecdhPublic }] });
++ * const note = await open(sealed, { privateKey: person.ecdh.privateKey, subject });
++ */
++const format = __ovLockerReq('./format');
++const keys = __ovLockerReq('./keys');
++const seal = __ovLockerReq('./seal');
++const { padme } = __ovLockerReq('./padme');
++const { inspect } = __ovLockerReq('./inspect');
++const { recoveryWords, isRecoveryPhrase, RECOVERY_WORDS } = __ovLockerReq('./recovery');
++const { WORDS } = __ovLockerReq('./words');
++const { LockerError } = __ovLockerReq('./errors');
++
++__ovLockerModule.exports = {
++ // The format.
++ VERSION: format.VERSION,
++ SUITE: format.SUITE,
++ CHUNK_SIZE: format.CHUNK_SIZE,
++ MIN_PADDED_SIZE: format.MIN_PADDED_SIZE,
++ MAX_HEADER_BYTES: format.MAX_HEADER_BYTES,
++ PASSWORD_ITERATIONS: keys.PASSWORD_ITERATIONS,
++ PASSWORD_ITERATIONS_MIN: keys.PASSWORD_ITERATIONS_MIN,
++ PASSWORD_ITERATIONS_MAX: keys.PASSWORD_ITERATIONS_MAX,
++
++ // The content.
++ seal: seal.seal,
++ open: seal.open,
++ sealStream: seal.sealStream,
++ openStream: seal.openStream,
++ rewrap: seal.rewrap,
++
++ // What an envelope says about itself, without a key.
++ inspect,
++
++ // The padding: the padded size of a content of a given size.
++ padme,
++
++ // The recovery phrase: words to write down, and a check on a typed one.
++ recoveryWords,
++ isRecoveryPhrase,
++ RECOVERY_WORDS,
++ WORDS,
++
++ // A person's keys, and the eight words that say which keys they are.
++ generatePersonKeys: keys.generatePersonKeys,
++ exportPersonKeys: keys.exportPersonKeys,
++ exportPrivateKeys: keys.exportPrivateKeys,
++ importPersonKeys: keys.importPersonKeys,
++ verifyPersonKeys: keys.verifyPersonKeys,
++ fingerprintOf: keys.fingerprintOf,
++ fingerprintWords: keys.fingerprintWords,
++ jwkThumbprint: keys.jwkThumbprint,
++
++ // The key slots: the content key, wrapped for one person or for one password.
++ wrapFor: keys.wrapFor,
++ wrapForPassword: keys.wrapForPassword,
++ unwrapPeopleSlot: keys.unwrapPeopleSlot,
++ unwrapPasswordSlot: keys.unwrapPasswordSlot,
++
++ LockerError,
++};
++
++ },
++ "inspect": function (__ovLockerModule, exports, __ovLockerReq) {
++'use strict';
++/**
++ * inspect: what an envelope says about itself, without a key and without decrypting anything.
++ *
++ * A service, or a page that shows a locked item, often needs to know the lock kind, how big
++ * the item is, who it is for and how strong the password is — before it has any key at all.
++ * This reads the header and nothing else: no chunk is touched and no key is used.
++ *
++ * The header holds no content, only wrapped keys, so this says nothing the server does not
++ * already see.
++ */
++const { envelopeBytes, readCore } = __ovLockerReq('./seal');
++const { readHeader } = __ovLockerReq('./format');
++
++/**
++ * What an envelope says about itself.
++ *
++ * { version, lock, padded_size, recipients, iterations }
++ *
++ * `recipients` holds the thumbprint of each recipient's key for a people lock (the same
++ * strings the slots carry), and `iterations` the PBKDF2 count for a password lock. The other
++ * field is empty for the other lock kinds.
++ */
++function inspect(bytes) {
++ const data = envelopeBytes(bytes);
++ const { header } = readHeader(data);
++ readCore(header);
++ const out = {
++ version: header.version,
++ lock: header.lock,
++ padded_size: header.padded_size,
++ recipients: [],
++ iterations: null,
++ };
++ if (header.lock === 'people') {
++ out.recipients = (header.slots.people || []).map(slot => slot.recipient_key);
++ } else if (header.lock === 'password' && header.slots.password) {
++ out.iterations = header.slots.password.iterations;
++ }
++ return out;
++}
++
++__ovLockerModule.exports = { inspect };
++
++ },
++ "keys": function (__ovLockerModule, exports, __ovLockerReq) {
++'use strict';
++/**
++ * The keys: a person's two key pairs, the content key, and the two ways of wrapping the content
++ * key for someone else (their public key, or a password).
++ *
++ * WebCrypto only: globalThis.crypto.subtle. No other crypto library, nothing hand-rolled: the
++ * ECDH, the ECDSA, the HKDF, the PBKDF2 and the AES-GCM are all the browser's own.
++ */
++const { utf8, concat, base64, base64url, fromBase64, canonicalJson } = __ovLockerReq('./bytes');
++const { fail } = __ovLockerReq('./errors');
++const { WORDS } = __ovLockerReq('./words');
++
++/** The suite of the key pairs: P-256, which every browser's WebCrypto has. */
++const CURVE = 'P-256';
++/** How the content key is wrapped for a person. */
++const PEOPLE_ALGORITHM = 'ECDH-P256/HKDF-SHA256/AES-256-GCM';
++/** How the content key is wrapped for a password. */
++const PASSWORD_ALGORITHM = 'PBKDF2-SHA256/AES-256-GCM';
++/** The PBKDF2 floor for this version. The count may go up; it may not go down. */
++const PASSWORD_ITERATIONS = 600000;
++/** The smallest PBKDF2 count we accept, on both sides: the floor above. */
++const PASSWORD_ITERATIONS_MIN = 600000;
++/**
++ * The largest PBKDF2 count we accept. The count comes from the header, so a hostile header could
++ * ask for a derivation that takes hours; the ceiling keeps a browser honest.
++ */
++const PASSWORD_ITERATIONS_MAX = 10000000;
++/** The content key is 32 bytes: AES-256. */
++const KEY_BYTES = 32;
++/** The salt of a people slot (HKDF) and of a password slot (PBKDF2). */
++const SALT_BYTES = 16;
++
++/** WebCrypto, or a clear error when the runtime has none. */
++function subtle() {
++ const crypto = globalThis.crypto;
++ if (!crypto || !crypto.subtle) {
++ fail('locker.no_crypto', 'This runtime has no WebCrypto (globalThis.crypto.subtle)');
++ }
++ return crypto.subtle;
++}
++
++/** Random bytes from the platform. */
++function platformRandom(length) {
++ const bytes = new Uint8Array(length);
++ globalThis.crypto.getRandomValues(bytes);
++ return bytes;
++}
++
++/** The SHA-256 of some bytes. */
++async function sha256(bytes) {
++ return new Uint8Array(await subtle().digest('SHA-256', bytes));
++}
++
++/** The bytes of a public JWK, as they go on the wire: no key_ops, no ext. */
++function publicJwk(jwk) {
++ return { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
++}
++
++/** The message a person signs: both public keys, canonically. */
++function fingerprintMessage(ecdhPublic, signingPublic) {
++ return utf8(canonicalJson({ ecdh: publicJwk(ecdhPublic), signing: publicJwk(signingPublic) }));
++}
++
++/**
++ * The eight words of a fingerprint: the first eight bytes of SHA-256, eight bits each, in two
++ * groups of four. Sixty-four bits, not thirty-two: a server that swapped in its own key would
++ * need about 2^32 key generations to land on the same four words, which is not "verify a
++ * person"; with eight words it would need 2^64.
++ *
++ * The two groups are joined with a middle dot, not a hyphen: a hyphen reads as one compound
++ * word ("plateau yew-apple petal"), and the groups are meant to be read apart.
++ */
++function fingerprintWords(hash) {
++ const words = [];
++ for (let i = 0; i < 8; i++) words.push(WORDS[hash[i]]);
++ return `${words.slice(0, 4).join(' ')} · ${words.slice(4).join(' ')}`;
++}
++
++/**
++ * The RFC 7638 thumbprint of a public JWK: SHA-256 over the canonical JSON of its four
++ * required members, base64url. It names one key and nothing else, so a slot can be bound to
++ * the key of the person it is for.
++ */
++async function jwkThumbprint(jwk) {
++ const canonical = canonicalJson({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
++ return base64url(await sha256(utf8(canonical)));
++}
++
++/** The fingerprint of a pair of public keys. */
++async function fingerprintOf(ecdhPublic, signingPublic) {
++ return fingerprintWords(await sha256(fingerprintMessage(ecdhPublic, signingPublic)));
++}
++
++/**
++ * A person's two key pairs: an ECDH pair that receives content keys, and an ECDSA pair that signs
++ * the fingerprint. The signing key signs both public keys, so the pair is self-certifying: a
++ * reader can check that the holder owns the signing key, and the fingerprint changes whenever
++ * either key changes.
++ */
++async function generatePersonKeys() {
++ const s = subtle();
++ const ecdh = await s.generateKey({ name: 'ECDH', namedCurve: CURVE }, true, ['deriveBits']);
++ const signing = await s.generateKey({ name: 'ECDSA', namedCurve: CURVE }, true, ['sign', 'verify']);
++ const ecdhPublic = publicJwk(await s.exportKey('jwk', ecdh.publicKey));
++ const signingPublic = publicJwk(await s.exportKey('jwk', signing.publicKey));
++ const message = fingerprintMessage(ecdhPublic, signingPublic);
++ const signature = new Uint8Array(await s.sign({ name: 'ECDSA', hash: 'SHA-256' }, signing.privateKey, message));
++ return {
++ ecdh: { publicKey: ecdh.publicKey, privateKey: ecdh.privateKey },
++ signing: { publicKey: signing.publicKey, privateKey: signing.privateKey },
++ ecdhPublic,
++ signingPublic,
++ fingerprint: fingerprintWords(await sha256(message)),
++ fingerprintSignature: signature,
++ };
++}
++
++/** The public half of a person's keys, as it is published: locker.public-keys@1. */
++async function exportPersonKeys(person) {
++ const ecdhPublic = publicJwk(person.ecdhPublic || await subtle().exportKey('jwk', person.ecdh.publicKey));
++ const signingPublic = publicJwk(person.signingPublic || await subtle().exportKey('jwk', person.signing.publicKey));
++ const message = fingerprintMessage(ecdhPublic, signingPublic);
++ const signature = person.fingerprintSignature
++ || new Uint8Array(await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, person.signing.privateKey, message));
++ return {
++ ecdh: ecdhPublic,
++ signing: signingPublic,
++ fingerprint: fingerprintWords(await sha256(message)),
++ fingerprint_signature: base64(signature),
++ };
++}
++
++/**
++ * The private half of a person's keys, as it is stored: the two private keys as JWKs.
++ */
++async function exportPrivateKeys(person) {
++ const ecdh = await subtle().exportKey('jwk', person.ecdh.privateKey);
++ const signing = await subtle().exportKey('jwk', person.signing.privateKey);
++ return { ecdh, signing };
++}
++
++/**
++ * A person's keys, ready to use: the private keys as CryptoKeys and the public keys as JWKs.
++ * The private keys may be JWKs (as they are stored) or CryptoKeys (as they are in memory).
++ */
++async function importPersonKeys(data) {
++ const s = subtle();
++ const ecdhPrivate = data.ecdhPrivateKey
++ || await s.importKey('jwk', data.ecdh, { name: 'ECDH', namedCurve: CURVE }, true, ['deriveBits']);
++ const signingPrivate = data.signingPrivateKey
++ || await s.importKey('jwk', data.signing, { name: 'ECDSA', namedCurve: CURVE }, true, ['sign']);
++ return {
++ ecdhPrivateKey: ecdhPrivate,
++ signingPrivateKey: signingPrivate,
++ ecdhPublic: data.ecdh ? publicJwk(data.ecdh) : publicJwk(await s.exportKey('jwk', ecdhPrivate)),
++ signingPublic: data.signing ? publicJwk(data.signing) : publicJwk(await s.exportKey('jwk', signingPrivate)),
++ };
++}
++
++/**
++ * Checks a published set of keys: the signature must verify with the signing key, and the four
++ * words must be the fingerprint of the two keys. Returns false instead of throwing, so a caller
++ * can show a warning rather than an error.
++ */
++async function verifyPersonKeys(data) {
++ try {
++ const s = subtle();
++ const message = fingerprintMessage(data.ecdh, data.signing);
++ const signature = fromBase64(data.fingerprint_signature);
++ const key = await s.importKey('jwk', data.signing, { name: 'ECDSA', namedCurve: CURVE }, true, ['verify']);
++ const ok = await s.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, signature, message);
++ if (!ok) return false;
++ return fingerprintWords(await sha256(message)) === data.fingerprint;
++ } catch {
++ return false;
++ }
++}
++
++/** The content key as an AES-GCM CryptoKey. */
++async function importContentKey(bytes, usages) {
++ if (!bytes || bytes.length !== KEY_BYTES) fail('locker.invalid', 'The content key is 32 bytes');
++ return subtle().importKey('raw', bytes, { name: 'AES-GCM', length: 256 }, false, usages);
++}
++
++/** The wrapping key for a person: the ECDH secret, put through HKDF-SHA-256. */
++async function deriveWrappingKey(sharedSecret, salt, info) {
++ const s = subtle();
++ const material = await s.importKey('raw', sharedSecret, 'HKDF', false, ['deriveKey']);
++ return s.deriveKey(
++ { name: 'HKDF', hash: 'SHA-256', salt, info },
++ material,
++ { name: 'AES-GCM', length: 256 },
++ false,
++ ['encrypt', 'decrypt'],
++ );
++}
++
++/** The wrapping key for a password: PBKDF2-SHA-256, with the parameters the header names. */
++async function derivePasswordKey(password, salt, iterations) {
++ if (typeof password !== 'string' || !password.length) fail('locker.invalid', 'A password lock needs a password');
++ // The count comes from the header, so it is checked before it is used: a hostile header must
++ // not be able to make a reader spend minutes (or hours) on the derivation.
++ if (!Number.isInteger(iterations) || iterations < PASSWORD_ITERATIONS_MIN || iterations > PASSWORD_ITERATIONS_MAX) {
++ fail('locker.bad_envelope', `The iteration count is not between ${PASSWORD_ITERATIONS_MIN} and ${PASSWORD_ITERATIONS_MAX}`);
++ }
++ const s = subtle();
++ const material = await s.importKey('raw', utf8(password), 'PBKDF2', false, ['deriveKey']);
++ return s.deriveKey(
++ { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
++ material,
++ { name: 'AES-GCM', length: 256 },
++ false,
++ ['encrypt', 'decrypt'],
++ );
++}
++
++/**
++ * The associated data of a people slot: it binds the slot to one person, to that person's key
++ * and to one envelope.
++ */
++function peopleSlotAad(subject, ephemeralPublic, salt, recipientKey) {
++ const id = typeof subject === 'string' ? subject : `${subject.type}:${subject.id}`;
++ return utf8(`openvibe-locker people-slot v1|${id}|${recipientKey}|${canonicalJson(publicJwk(ephemeralPublic))}|${base64(salt)}`);
++}
++
++/** The associated data of a password slot: it binds the slot to its own parameters. */
++function passwordSlotAad(iterations, salt) {
++ return utf8(`openvibe-locker password-slot v1|${iterations}|${base64(salt)}`);
++}
++
++/** A public key as WebCrypto wants it. */
++async function importPublicKey(jwk) {
++ try {
++ return await subtle().importKey('jwk', publicJwk(jwk), { name: 'ECDH', namedCurve: CURVE }, true, []);
++ } catch {
++ fail('locker.invalid', 'The public key is not a P-256 key');
++ }
++}
++
++/**
++ * The envelope's one ephemeral ECDH key pair. It may be given (the shared test vectors need the
++ * same bytes every time), or made fresh.
++ */
++async function resolveEphemeral(ephemeral) {
++ const s = subtle();
++ if (!ephemeral) {
++ const pair = await s.generateKey({ name: 'ECDH', namedCurve: CURVE }, true, ['deriveBits']);
++ return { privateKey: pair.privateKey, publicKey: pair.publicKey };
++ }
++ if (ephemeral.privateKey) return ephemeral;
++ const privateKey = await s.importKey('jwk', ephemeral.private, { name: 'ECDH', namedCurve: CURVE }, true, ['deriveBits']);
++ return { privateKey };
++}
++
++/**
++ * Wraps the content key for one person: the content key, sealed for their public key.
++ * The envelope has one ephemeral ECDH key pair; the shared secret with each person goes through
++ * HKDF-SHA-256, and the slot's tag covers the person, their key, the ephemeral key and the
++ * salt, so a slot cannot be moved to another person or replayed from another envelope.
++ */
++async function wrapFor(contentKey, publicKey, { subject, salt, random = platformRandom, ephemeral } = {}) {
++ const s = subtle();
++ if (!subject) fail('locker.invalid', 'A people slot needs the subject it is for');
++ const theirs = await importPublicKey(publicKey);
++ const pair = await resolveEphemeral(ephemeral);
++ const ephemeralPublic = publicJwk(await s.exportKey('jwk', pair.publicKey || pair.privateKey));
++ // The slot names the key it is for, so an opener can check it before deriving anything.
++ const recipientKey = await jwkThumbprint(publicJwk(publicKey));
++ const shared = new Uint8Array(await s.deriveBits({ name: 'ECDH', public: theirs }, pair.privateKey, 256));
++ const slotSalt = salt || random(SALT_BYTES);
++ const key = await deriveWrappingKey(shared, slotSalt, utf8(`openvibe-locker people-slot v1|${subject.type}:${subject.id}|${recipientKey}`));
++ const nonce = random(12);
++ const sealed = new Uint8Array(await s.encrypt(
++ { name: 'AES-GCM', iv: nonce, additionalData: peopleSlotAad(subject, ephemeralPublic, slotSalt, recipientKey) },
++ key,
++ contentKey,
++ ));
++ return {
++ subject,
++ algorithm: PEOPLE_ALGORITHM,
++ recipient_key: recipientKey,
++ ephemeral_public_key: ephemeralPublic,
++ salt: base64(slotSalt),
++ nonce: base64(nonce),
++ wrapped_key: base64(sealed),
++ };
++}
++
++/** The content key out of a people slot: the recipient's private key and the slot. */
++async function unwrapPeopleSlot(slot, privateKey) {
++ const s = subtle();
++ // The slot names the key it was made for. If it is not this key, the slot is not this
++ // person's, and nothing is derived: the check comes before the work.
++ if (!slot.recipient_key) fail('locker.bad_envelope', 'The slot does not name the key it is for');
++ const theirPublic = publicJwk(await s.exportKey('jwk', privateKey));
++ const recipientKey = await jwkThumbprint(theirPublic);
++ if (slot.recipient_key !== recipientKey) {
++ fail('locker.wrong_key', 'This slot is for another key');
++ }
++ const theirs = await s.importKey('jwk', publicJwk(slot.ephemeral_public_key), { name: 'ECDH', namedCurve: CURVE }, true, []);
++ const shared = new Uint8Array(await s.deriveBits({ name: 'ECDH', public: theirs }, privateKey, 256));
++ const salt = fromBase64(slot.salt);
++ const key = await deriveWrappingKey(shared, salt, utf8(`openvibe-locker people-slot v1|${slot.subject.type}:${slot.subject.id}|${recipientKey}`));
++ try {
++ const plain = new Uint8Array(await s.decrypt(
++ { name: 'AES-GCM', iv: fromBase64(slot.nonce), additionalData: peopleSlotAad(slot.subject, slot.ephemeral_public_key, salt, recipientKey) },
++ key,
++ fromBase64(slot.wrapped_key),
++ ));
++ if (plain.length !== KEY_BYTES) fail('locker.wrong_key', 'The slot does not hold a content key');
++ return plain;
++ } catch (err) {
++ if (err && err.code) throw err;
++ fail('locker.wrong_key', 'This key does not open that slot');
++ }
++}
++
++/** Wraps the content key with a password: PBKDF2-SHA-256, then AES-256-GCM. */
++async function wrapForPassword(contentKey, password, { salt, iterations = PASSWORD_ITERATIONS, random = platformRandom } = {}) {
++ const s = subtle();
++ const slotSalt = salt || random(SALT_BYTES);
++ const key = await derivePasswordKey(password, slotSalt, iterations);
++ const nonce = random(12);
++ const sealed = new Uint8Array(await s.encrypt(
++ { name: 'AES-GCM', iv: nonce, additionalData: passwordSlotAad(iterations, slotSalt) },
++ key,
++ contentKey,
++ ));
++ return {
++ algorithm: PASSWORD_ALGORITHM,
++ kdf: 'PBKDF2-SHA-256',
++ iterations,
++ salt: base64(slotSalt),
++ nonce: base64(nonce),
++ wrapped_key: base64(sealed),
++ };
++}
++
++/** The content key out of a password slot. The parameters come from the slot, never from here. */
++async function unwrapPasswordSlot(slot, password) {
++ const s = subtle();
++ const salt = fromBase64(slot.salt);
++ const key = await derivePasswordKey(password, salt, slot.iterations);
++ try {
++ const plain = new Uint8Array(await s.decrypt(
++ { name: 'AES-GCM', iv: fromBase64(slot.nonce), additionalData: passwordSlotAad(slot.iterations, salt) },
++ key,
++ fromBase64(slot.wrapped_key),
++ ));
++ if (plain.length !== KEY_BYTES) fail('locker.wrong_key', 'The slot does not hold a content key');
++ return plain;
++ } catch (err) {
++ if (err && err.code) throw err;
++ fail('locker.wrong_key', 'That password does not open this item');
++ }
++}
++
++__ovLockerModule.exports = {
++ CURVE, PEOPLE_ALGORITHM, PASSWORD_ALGORITHM, PASSWORD_ITERATIONS,
++ PASSWORD_ITERATIONS_MIN, PASSWORD_ITERATIONS_MAX, KEY_BYTES, SALT_BYTES,
++ subtle, platformRandom, sha256, publicJwk, fingerprintMessage, fingerprintWords, fingerprintOf,
++ jwkThumbprint, generatePersonKeys, exportPersonKeys, exportPrivateKeys, importPersonKeys,
++ verifyPersonKeys, importContentKey, deriveWrappingKey, derivePasswordKey, resolveEphemeral,
++ peopleSlotAad, passwordSlotAad, wrapFor, unwrapPeopleSlot, wrapForPassword, unwrapPasswordSlot,
++};
++
++ },
++ "padme": function (__ovLockerModule, exports, __ovLockerReq) {
++'use strict';
++/**
++ * Padmé padding (Nikitin et al., "Reducing Metadata Leakage from Encrypted Files and Network
++ * Traffic", PoPETs 2019).
++ *
++ * The point of padding a locked item: the server must not learn how big it is. Rounding the size
++ * up to a whole 1 MiB chunk says too much — a 200-byte note becomes a 1 MiB object, and every
++ * item under a megabyte looks the same, which is a leak of its own.
++ *
++ * Padmé rounds the length up to the next size on a grid that gets finer as the length grows:
++ *
++ * E = floor(log2(n)) the band the length is in
++ * S = floor(log2(E)) + 1 how many steps the band has
++ * d = 2^(E - S) the step of the band
++ * P(n) = d * ceil(n / d) the padded length
++ *
++ * The padded length is one of a small number of values per band, and the bands double, so the
++ * number of possible padded lengths up to n grows like log(n): knowing the padded length says
++ * O(log log n) bits about the real one. The expansion stays under 12% (the worst case is a
++ * length just above a band boundary, 129 bytes padded to 144).
++ *
++ * The arithmetic is exact above 2^31: there are no 32-bit bitwise operators here, only
++ * comparisons and division by a power of two, both exact on the doubles we have.
++ */
++
++/** The largest e with 2^e <= size: floor(log2(size)), without a logarithm. */
++function exponentOf(size) {
++ let e = 0;
++ let power = 1;
++ while (power * 2 <= size) { power *= 2; e++; }
++ return e;
++}
++
++/**
++ * The padded length of a content of this size: the smallest length on Padmé's grid that is not
++ * smaller than it. Zero for zero, which the caller handles.
++ */
++function padme(size) {
++ if (!Number.isInteger(size) || size <= 0) return 0;
++ if (size === 1) return 1;
++ const band = exponentOf(size);
++ const steps = exponentOf(band) + 1;
++ const step = Math.pow(2, band - steps);
++ // Dividing by a power of two is exact, so the ceiling is the ceiling and not nearly it.
++ return Math.ceil(size / step) * step;
++}
++
++__ovLockerModule.exports = { padme, exponentOf };
++
++ },
++ "recovery": function (__ovLockerModule, exports, __ovLockerReq) {
++'use strict';
++/**
++ * The recovery phrase: the words a person writes down.
++ *
++ * A phrase of n words from the same 256-word list the fingerprint uses, one byte per word from
++ * the platform's CSPRNG (crypto.getRandomValues). Eight bits per word, so the usual 24 words
++ * carry 192 bits: enough for a key nobody else has, and short enough to write on paper.
++ *
++ * There is no checksum in the phrase: the words are the secret, and a phrase that is typed
++ * wrong simply does not open anything. isRecoveryPhrase only says whether every word is one of
++ * the 256, so a UI can tell a typo from a word that was never in the list.
++ */
++const { fail } = __ovLockerReq('./errors');
++const { platformRandom } = __ovLockerReq('./keys');
++const { WORDS } = __ovLockerReq('./words');
++
++/** How many words a recovery phrase has, unless the caller says otherwise. */
++const RECOVERY_WORDS = 24;
++
++/**
++ * A recovery phrase: n words from the list, one byte each.
++ *
++ * recoveryWords() // 24 words, 192 bits
++ * recoveryWords(12) // 12 words, 96 bits
++ */
++function recoveryWords(count = RECOVERY_WORDS, { random = platformRandom } = {}) {
++ if (!Number.isInteger(count) || count < 1 || count > WORDS.length) {
++ fail('locker.invalid', `A recovery phrase is one to ${WORDS.length} words`);
++ }
++ // One byte per word: the list has 256 words, so a byte picks one of them exactly.
++ const bytes = random(count);
++ const words = [];
++ for (const byte of bytes) words.push(WORDS[byte]);
++ return words.join(' ');
++}
++
++/**
++ * Whether a typed phrase is made of words from the list. It says nothing about the order or
++ * about whether the phrase is the right one: only that every word could have come from us.
++ */
++function isRecoveryPhrase(phrase) {
++ if (typeof phrase !== 'string') return false;
++ const words = phrase.trim().split(/\s+/).filter(Boolean);
++ if (!words.length) return false;
++ return words.every(word => WORDS.includes(word.toLowerCase()));
++}
++
++__ovLockerModule.exports = { RECOVERY_WORDS, recoveryWords, isRecoveryPhrase };
++
++ },
++ "seal": function (__ovLockerModule, exports, __ovLockerReq) {
++'use strict';
++/**
++ * seal and open: the content, the three locks, and the streams.
++ *
++ * The envelope is written once and read once. Every chunk is sealed with AES-256-GCM under the
++ * content key, with the header's core, the chunk counter and the flags as associated data, so a
++ * chunk cannot be cut, reordered or truncated without the open failing.
++ */
++const {
++ utf8, fromUtf8, concat, base64, base64url, fromBase64url, u32be, readU32be, u64be, readU64be,
++} = __ovLockerReq('./bytes');
++const { fail } = __ovLockerReq('./errors');
++const {
++ MAGIC, VERSION, SUITE, CHUNK_SIZE, TAG_SIZE, NONCE_BASE_SIZE, LENGTH_PREFIX, MIN_PADDED_SIZE,
++ FINAL_FLAG, coreOf, chunkAad, encodeHeader, encodeEnvelope, readHeader, chunkCount,
++ lastChunkSize, paddedSize, chunkNonce, encodeChunk, nonceBaseBytes,
++} = __ovLockerReq('./format');
++const {
++ KEY_BYTES, PASSWORD_ITERATIONS, subtle, platformRandom, importContentKey, publicJwk,
++ jwkThumbprint, wrapFor, unwrapPeopleSlot, wrapForPassword, unwrapPasswordSlot, resolveEphemeral,
++} = __ovLockerReq('./keys');
++
++/** The content as bytes: a string is UTF-8, anything else is taken as it is. */
++function toBytes(content) {
++ if (typeof content === 'string') return utf8(content);
++ if (content instanceof Uint8Array) return content;
++ if (content instanceof ArrayBuffer) return new Uint8Array(content);
++ if (ArrayBuffer.isView(content)) return new Uint8Array(content.buffer, content.byteOffset, content.byteLength);
++ fail('locker.invalid', 'The content must be a string or bytes');
++}
++
++/** The bytes of an envelope: what seal returns, or what the caller kept. */
++function envelopeBytes(envelope) {
++ if (envelope instanceof Uint8Array) return envelope;
++ if (envelope && envelope.bytes instanceof Uint8Array) return envelope.bytes;
++ fail('locker.invalid', 'That is not a Locker envelope');
++}
++
++/** A source of bytes: an async iterable of Uint8Array. */
++async function* once(bytes) { yield bytes; }
++
++/**
++ * Collects bytes into blocks of a fixed size, without ever holding more than one block and the
++ * part that has not been used yet. This is what keeps a stream seal in constant memory.
++ */
++class Chunker {
++ constructor(size) { this.size = size; this.parts = []; this.length = 0; }
++
++ push(part) { this.parts.push(part); this.length += part.length; }
++
++ /** The next full block, or null when there is not enough yet. */
++ take() {
++ if (this.length < this.size) return null;
++ const block = new Uint8Array(this.size);
++ let at = 0;
++ while (at < this.size) {
++ const head = this.parts[0];
++ const take = Math.min(head.length, this.size - at);
++ block.set(head.subarray(0, take), at);
++ at += take;
++ if (take === head.length) this.parts.shift();
++ else this.parts[0] = head.subarray(take);
++ }
++ this.length -= this.size;
++ return block;
++ }
++
++ /** What is left, as one block of exactly this many bytes: the bytes that are there, then zeros. */
++ takeExactly(count) {
++ if (this.length > count) fail('locker.size_mismatch', 'The content is longer than the size it was given');
++ const block = new Uint8Array(count);
++ let at = 0;
++ for (const part of this.parts) { block.set(part, at); at += part.length; }
++ this.parts = [];
++ this.length = 0;
++ return block;
++ }
++}
++
++/** The header, the content key and the nonce base: everything a seal needs before it writes. */
++async function prepare(options) {
++ const lock = options.lock;
++ if (!['people', 'password', 'link'].includes(lock)) {
++ fail('locker.invalid', 'A lock is people, password or link');
++ }
++ const random = options.random || platformRandom;
++ const contentKey = options.contentKey ? toBytes(options.contentKey) : random(KEY_BYTES);
++ if (contentKey.length !== KEY_BYTES) fail('locker.invalid', 'The content key is 32 bytes');
++ const nonceBase = options.nonceBase ? toBytes(options.nonceBase) : random(NONCE_BASE_SIZE);
++ if (nonceBase.length !== NONCE_BASE_SIZE) fail('locker.invalid', 'The nonce base is eight bytes');
++ let slots;
++ if (lock === 'people') {
++ const keys = options.keys || [];
++ if (!keys.length) fail('locker.invalid', 'A people lock needs at least one recipient');
++ // One ephemeral key pair for the whole envelope: the shared secret differs per person.
++ const pair = await resolveEphemeral(options.ephemeral);
++ const people = [];
++ for (const key of keys) {
++ people.push(await wrapFor(contentKey, key.publicKey, {
++ subject: key.subject, random, ephemeral: pair,
++ }));
++ }
++ slots = { people };
++ } else if (lock === 'password') {
++ slots = {
++ password: await wrapForPassword(contentKey, options.password, {
++ salt: options.salt, iterations: options.iterations || PASSWORD_ITERATIONS, random,
++ }),
++ };
++ } else {
++ // A link lock stores no key: the content key travels in the part of the link after the #.
++ slots = { link: {} };
++ }
++ return { contentKey, nonceBase, slots };
++}
++
++/** The header of an envelope. */
++function makeHeader(lock, slots, padded, nonceBase) {
++ return {
++ version: VERSION,
++ suite: SUITE,
++ chunk_size: CHUNK_SIZE,
++ padded_size: padded,
++ nonce_base: base64(nonceBase),
++ lock,
++ slots,
++ };
++}
++
++/** Seals one block of the padded plaintext. */
++async function sealBlock(key, header, nonceBase, counter, block, isFinal) {
++ const flags = isFinal ? FINAL_FLAG : 0;
++ const nonce = chunkNonce(nonceBase, counter);
++ const ciphertext = new Uint8Array(await subtle().encrypt(
++ { name: 'AES-GCM', iv: nonce, additionalData: chunkAad(header, counter, flags) },
++ key,
++ block,
++ ));
++ return encodeChunk(counter, flags, nonce, ciphertext);
++}
++
++/**
++ * The envelope, piece by piece. The header comes first (it holds the padded size, so the size
++ * has to be known before the first byte is written), then one frame per chunk.
++ */
++async function* sealPieces(source, options) {
++ const size = options.size;
++ const { contentKey, nonceBase, slots } = await prepare(options);
++ const padded = paddedSize(size);
++ const header = makeHeader(options.lock, slots, padded, nonceBase);
++ const headerBytes = encodeHeader(header);
++ yield { header, headerBytes, contentKey, bytes: concat(utf8(MAGIC), u32be(headerBytes.length), headerBytes) };
++ const key = await importContentKey(contentKey, ['encrypt']);
++ const total = chunkCount(padded);
++ const chunker = new Chunker(CHUNK_SIZE);
++ let counter = 0;
++ // The padded plaintext starts with the true length, so the server cannot read the size.
++ chunker.push(u64be(size));
++ for await (const part of source) {
++ chunker.push(part);
++ // Every chunk but the last is a whole chunk.
++ for (;;) {
++ if (counter >= total - 1) break;
++ const block = chunker.take();
++ if (!block) break;
++ yield { bytes: await sealBlock(key, header, nonceBase, counter, block, false) };
++ counter++;
++ }
++ }
++ // The content is done; the padding fills the rest of the padded plaintext. (A content that
++ // is longer than the size it was given fails in takeExactly, below.)
++ while (counter < total - 1) {
++ const block = chunker.takeExactly(CHUNK_SIZE);
++ yield { bytes: await sealBlock(key, header, nonceBase, counter, block, false) };
++ counter++;
++ }
++ // The last chunk: what is left of the plaintext, then zeros up to the padded size.
++ const tail = chunker.takeExactly(lastChunkSize(padded));
++ yield { bytes: await sealBlock(key, header, nonceBase, counter, tail, true) };
++}
++
++/**
++ * Seals content: a string or bytes. The result holds the envelope's bytes and its header.
++ *
++ * seal('a note', { lock: 'password', password: 'correct horse' })
++ * seal(bytes, { lock: 'people', keys: [{ subject, publicKey }] })
++ * seal(bytes, { lock: 'link' }) // the key comes back as linkKey
++ */
++async function seal(content, options = {}) {
++ const bytes = toBytes(content);
++ const pieces = [];
++ let header = null;
++ let headerBytes = null;
++ let contentKey = null;
++ for await (const piece of sealPieces(once(bytes), { ...options, size: bytes.length })) {
++ if (piece.header) { header = piece.header; headerBytes = piece.headerBytes; contentKey = piece.contentKey; }
++ pieces.push(piece.bytes);
++ }
++ const envelope = {
++ version: VERSION,
++ header,
++ headerBytes,
++ bytes: concat(...pieces),
++ chunkOffset: 8 + headerBytes.length,
++ };
++ if (options.lock === 'link') {
++ // The link carries the content key; the envelope itself stores nothing.
++ envelope.linkKey = base64url(contentKey);
++ }
++ return envelope;
++}
++
++/** The header's core, as a reader needs it: the fields must be the ones we write. */
++function readCore(header) {
++ if (!header || typeof header !== 'object' || Array.isArray(header)) {
++ fail('locker.bad_envelope', 'The envelope header is not an object');
++ }
++ if (header.version !== VERSION) fail('locker.bad_envelope', 'This envelope is another version');
++ if (header.suite !== SUITE) fail('locker.bad_envelope', 'This envelope uses another suite');
++ if (header.chunk_size !== CHUNK_SIZE) fail('locker.bad_envelope', 'This envelope uses another chunk size');
++ if (!Number.isInteger(header.padded_size) || header.padded_size < MIN_PADDED_SIZE) {
++ fail('locker.bad_envelope', 'The padded size is not a size we write');
++ }
++ if (!['people', 'password', 'link'].includes(header.lock)) {
++ fail('locker.bad_envelope', 'The lock is not one we know');
++ }
++ if (!header.slots || typeof header.slots !== 'object') {
++ fail('locker.bad_envelope', 'The envelope has no slots');
++ }
++ return header;
++}
++
++/** The content key out of the header, with the credentials the reader has. */
++async function openContentKey(header, credentials = {}) {
++ if (header.lock === 'people') {
++ // A person may be named by their key instead of their subject: the slot says which key
++ // it is for, so the reader does not need to know who they are.
++ if (credentials.person) {
++ const slot = await slotForPerson(header, credentials.person);
++ const privateKey = await importPrivateKey(privateKeyOf(credentials.person) || credentials.privateKey);
++ if (!privateKey) fail('locker.no_key', 'A people lock needs the private key of a recipient');
++ return unwrapPeopleSlot(slot, privateKey);
++ }
++ const slot = pickSlot(header, credentials.subject);
++ const privateKey = await importPrivateKey(credentials.privateKey);
++ if (!privateKey) fail('locker.no_key', 'A people lock needs the private key of a recipient');
++ return unwrapPeopleSlot(slot, privateKey);
++ }
++ if (header.lock === 'password') {
++ if (!credentials.password) fail('locker.no_key', 'A password lock needs the password');
++ if (!header.slots.password) fail('locker.bad_envelope', 'The envelope has no password slot');
++ return unwrapPasswordSlot(header.slots.password, credentials.password);
++ }
++ if (!credentials.key) fail('locker.no_key', 'A link lock needs the key from the link');
++ const key = typeof credentials.key === 'string' ? fromBase64url(credentials.key) : toBytes(credentials.key);
++ if (key.length !== KEY_BYTES) fail('locker.invalid', 'The link key is 32 bytes');
++ return key;
++}
++
++/** The slot of the person who is opening: theirs, or the only one there is. */
++function pickSlot(header, subject) {
++ const slots = (header.slots && header.slots.people) || [];
++ if (!slots.length) fail('locker.bad_envelope', 'The envelope has no people slots');
++ if (subject) {
++ const id = typeof subject === 'string' ? subject : subject.id;
++ const type = typeof subject === 'string' ? null : subject.type;
++ const found = slots.find(slot => slot.subject.id === id && (type === null || slot.subject.type === type));
++ if (!found) fail('locker.no_key', 'This envelope has no slot for that person');
++ return found;
++ }
++ if (slots.length > 1) fail('locker.no_key', 'This envelope has several slots: say which person you are');
++ return slots[0];
++}
++
++/** The ECDH public key of a person, as a JWK. */
++async function publicKeyOf(person) {
++ if (!person || typeof person !== 'object') fail('locker.invalid', 'A people lock needs the person');
++ if (person.ecdhPublic) return publicJwk(person.ecdhPublic);
++ if (person.ecdh && person.ecdh.kty) return publicJwk(person.ecdh);
++ if (person.kty === 'EC') return publicJwk(person);
++ if (person.ecdh && person.ecdh.publicKey) {
++ return publicJwk(await subtle().exportKey('jwk', person.ecdh.publicKey));
++ }
++ fail('locker.invalid', 'The person has no ECDH public key');
++}
++
++/** The ECDH private key of a person, if they brought one. */
++function privateKeyOf(person) {
++ if (!person || typeof person !== 'object') return null;
++ if (person.ecdh && person.ecdh.privateKey) return person.ecdh.privateKey;
++ if (person.ecdhPrivateKey) return person.ecdhPrivateKey;
++ if (person.privateKey) return person.privateKey;
++ return null;
++}
++
++/**
++ * The slot that names this person's key. Every people slot carries the thumbprint of the key
++ * it was made for, so the reader finds its own slot without knowing anybody's subject. An
++ * envelope that has no slot for this key is not an envelope this person can open: that is
++ * locker.no_slot, which says the key is not on the list (not that the key is wrong).
++ */
++async function slotForPerson(header, person) {
++ const slots = (header.slots && header.slots.people) || [];
++ if (!slots.length) fail('locker.bad_envelope', 'The envelope has no people slots');
++ const thumbprint = await jwkThumbprint(await publicKeyOf(person));
++ const found = slots.find(slot => slot.recipient_key === thumbprint);
++ if (!found) fail('locker.no_slot', 'This envelope has no slot for that key');
++ return found;
++}
++
++/** A private key as WebCrypto wants it: a CryptoKey, or a JWK to import. */
++async function importPrivateKey(key) {
++ if (!key) return null;
++ if (key.type === 'private' && key.algorithm && key.algorithm.name === 'ECDH') return key;
++ if (key.kty === 'EC' && key.crv === 'P-256' && key.d) {
++ try {
++ return await subtle().importKey('jwk', key, { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
++ } catch {
++ fail('locker.invalid', 'The private key is not a P-256 key');
++ }
++ }
++ fail('locker.invalid', 'The private key must be an ECDH P-256 key');
++}
++
++/** Reads exact byte counts out of an envelope that is already in memory. */
++function arrayReader(bytes) {
++ let at = 0;
++ return {
++ /** The next bytes, or null when the envelope is over. */
++ async probe(count) {
++ if (at >= bytes.length) return null;
++ return this.take(count);
++ },
++ async take(count) {
++ if (at + count > bytes.length) fail('locker.truncated', 'The envelope ends inside a chunk');
++ const out = bytes.slice(at, at + count);
++ at += count;
++ return out;
++ },
++ };
++}
++
++/** Reads exact byte counts out of a stream, keeping only what it must. */
++function streamReader(iterator) {
++ let buffer = new Uint8Array(0);
++ let ended = false;
++ async function fill(count) {
++ while (buffer.length < count && !ended) {
++ const next = await iterator.next();
++ if (next.done) { ended = true; break; }
++ buffer = buffer.length ? concat(buffer, next.value) : next.value;
++ }
++ return buffer.length;
++ }
++ return {
++ /** The next bytes, or null when the stream is over. */
++ async probe(count) {
++ const have = await fill(count);
++ if (have === 0) return null;
++ return this.take(count);
++ },
++ async take(count) {
++ const have = await fill(count);
++ if (have < count) fail('locker.truncated', 'The envelope ends inside a chunk');
++ const out = buffer.slice(0, count);
++ buffer = buffer.slice(count);
++ return out;
++ },
++ };
++}
++
++/**
++ * Opens the chunks: the plaintext, piece by piece. The reader may be an envelope in memory or a
++ * stream; both are read the same way, so the same checks run on both.
++ */
++async function* openPieces(reader, header, key) {
++ const expected = chunkCount(header.padded_size);
++ const lastLength = lastChunkSize(header.padded_size);
++ const nonceBase = nonceBaseBytes(header);
++ let counter = 0;
++ let length = null;
++ let emitted = 0;
++ for (;;) {
++ const lengthBytes = await reader.probe(4);
++ if (lengthBytes === null) {
++ if (counter !== expected) fail('locker.truncated', 'The envelope ends before its last chunk');
++ break;
++ }
++ if (counter >= expected) fail('locker.bad_envelope', 'The envelope has more chunks than its size says');
++ const cipherLength = readU32be(lengthBytes, 0);
++ // The length is checked before it is used: a header that claims a huge chunk must not
++ // make the reader wait for it. Every chunk but the last is a whole chunk; the last holds
++ // what is left of the padded size.
++ const wantedLength = counter === expected - 1 ? lastLength : CHUNK_SIZE;
++ if (cipherLength !== wantedLength + TAG_SIZE) {
++ fail('locker.bad_envelope', 'A chunk is not the size the envelope says');
++ }
++ const flags = (await reader.take(1))[0];
++ const nonce = await reader.take(12);
++ const ciphertext = await reader.take(cipherLength);
++ const isFinal = (flags & FINAL_FLAG) === FINAL_FLAG;
++ if (isFinal !== (counter === expected - 1)) {
++ fail('locker.bad_envelope', 'The envelope marks the wrong chunk as the last one');
++ }
++ const wanted = chunkNonce(nonceBase, counter);
++ for (let i = 0; i < wanted.length; i++) {
++ if (nonce[i] !== wanted[i]) fail('locker.bad_envelope', 'A chunk carries the wrong nonce');
++ }
++ let plain;
++ try {
++ plain = new Uint8Array(await subtle().decrypt(
++ { name: 'AES-GCM', iv: nonce, additionalData: chunkAad(header, counter, flags) },
++ key,
++ ciphertext,
++ ));
++ } catch (err) {
++ if (err && err.code) throw err;
++ fail('locker.wrong_key', 'This envelope does not open with this key');
++ }
++ if (counter === 0) {
++ if (plain.length < LENGTH_PREFIX) fail('locker.bad_envelope', 'The first chunk is too short');
++ length = Number(readU64be(plain, 0));
++ if (length > header.padded_size - LENGTH_PREFIX) {
++ fail('locker.size_mismatch', 'The content is longer than the envelope says');
++ }
++ }
++ const start = counter === 0 ? LENGTH_PREFIX : 0;
++ const part = plain.subarray(start);
++ const room = length - emitted;
++ const out = part.length > room ? part.subarray(0, room) : part;
++ emitted += out.length;
++ counter++;
++ if (out.length) yield out;
++ }
++ if (emitted !== length) fail('locker.size_mismatch', 'The envelope holds fewer bytes than it says');
++}
++
++/**
++ * Opens an envelope: the content as bytes.
++ *
++ * open(envelope, { privateKey, subject }) // a people lock, by subject
++ * open(envelope, { person }) // a people lock, by key
++ * open(envelope, { password }) // a password lock
++ * open(envelope, { key }) // a link lock
++ */
++async function open(envelope, credentials = {}) {
++ const bytes = envelopeBytes(envelope);
++ const { header, chunkOffset } = readHeader(bytes);
++ readCore(header);
++ const contentKey = await openContentKey(header, credentials);
++ const key = await importContentKey(contentKey, ['decrypt']);
++ const reader = arrayReader(bytes.subarray(chunkOffset));
++ const parts = [];
++ for await (const part of openPieces(reader, header, key)) parts.push(part);
++ return concat(...parts);
++}
++
++/** A stream of the bytes of a WHATWG stream (or of any async iterable of bytes). */
++async function* readChunks(stream) {
++ if (stream && typeof stream.getReader === 'function') {
++ const reader = stream.getReader();
++ try {
++ for (;;) {
++ const { value, done } = await reader.read();
++ if (done) return;
++ yield value instanceof Uint8Array ? value : new Uint8Array(value);
++ }
++ } finally {
++ reader.releaseLock();
++ }
++ return;
++ }
++ for await (const part of stream) yield part instanceof Uint8Array ? part : new Uint8Array(part);
++}
++
++/**
++ * Seals a stream: the envelope comes out piece by piece, and only one chunk is ever in memory.
++ * The size of the content has to be known, because the padded size is in the header and the
++ * header is written first.
++ */
++function sealStream(stream, options = {}) {
++ if (typeof options.size !== 'number' || options.size < 0) {
++ fail('locker.invalid', 'A stream seal needs the size of the content');
++ }
++ const iterator = sealPieces(readChunks(stream), { ...options, size: options.size })[Symbol.asyncIterator]();
++ return new ReadableStream({
++ async pull(controller) {
++ const next = await iterator.next();
++ if (next.done) controller.close();
++ else controller.enqueue(next.value.bytes);
++ },
++ async cancel(reason) { if (iterator.return) await iterator.return(reason); },
++ });
++}
++
++/**
++ * Opens a stream: the content comes out piece by piece, and only one chunk is ever in memory.
++ *
++ * The chunks are yielded as they are verified, so a reader sees the earlier ones before the last
++ * one is reached. A stream that ends early throws at the end, when the missing chunk shows up:
++ * discard what it yielded, because the content was never complete.
++ */
++function openStream(stream, credentials = {}) {
++ const iterator = (async function* openStreamPieces() {
++ const reader = streamReader(readChunks(stream)[Symbol.asyncIterator]());
++ const head = await reader.probe(8);
++ if (head === null) fail('locker.truncated', 'The envelope is too short to hold a header');
++ if (fromUtf8(head.subarray(0, 4)) !== MAGIC) fail('locker.bad_envelope', 'These bytes are not a Locker envelope');
++ const headerLength = readU32be(head, 4);
++ if (headerLength === 0 || headerLength > 65536) {
++ fail('locker.bad_envelope', `The envelope header is ${headerLength} bytes, which is not one we write`);
++ }
++ const headerBytes = await reader.take(headerLength);
++ let header;
++ try { header = JSON.parse(fromUtf8(headerBytes)); }
++ catch { fail('locker.bad_envelope', 'The envelope header is not JSON'); }
++ readCore(header);
++ const contentKey = await openContentKey(header, credentials);
++ const key = await importContentKey(contentKey, ['decrypt']);
++ yield* openPieces(reader, header, key);
++ })()[Symbol.asyncIterator]();
++ return new ReadableStream({
++ async pull(controller) {
++ const next = await iterator.next();
++ if (next.done) controller.close();
++ else controller.enqueue(next.value);
++ },
++ async cancel(reason) { if (iterator.return) await iterator.return(reason); },
++ });
++}
++
++/**
++ * Wraps the content key for other people, without touching the content: the envelope's header
++ * changes, the chunks do not, because the slots are not part of what the chunks authenticate.
++ * This is how a member is added to a people-locked item: the people who are already on it keep
++ * their slots, and the new ones get one of their own.
++ *
++ * Removing a member wants a fresh content key, not just a slot that is gone: the person who is
++ * removed may still hold the old key. So a removal opens the content and seals it again, with a
++ * new key and a new nonce base, for the people who are left. Their public keys are needed for
++ * that, so the caller gives them in `keys`.
++ */
++async function rewrap(envelope, {
++ add = [], remove = [], keys = [], credentials = {}, random = platformRandom, ephemeral,
++} = {}) {
++ const bytes = envelopeBytes(envelope);
++ const { header, chunkOffset } = readHeader(bytes);
++ if (header.lock !== 'people') fail('locker.invalid', 'Only a people-locked envelope can be re-wrapped');
++ readCore(header);
++ const gone = new Set(remove.map(who => (typeof who === 'string' ? who : who.id)));
++ const staying = (header.slots.people || []).filter(slot => !gone.has(slot.subject.id));
++
++ if (!gone.size) {
++ // Adding: the content key and the chunks stay as they are.
++ const contentKey = await openContentKey(header, credentials);
++ const people = staying.slice();
++ if (add.length) {
++ const pair = await resolveEphemeral(ephemeral);
++ for (const recipient of add) {
++ people.push(await wrapFor(contentKey, recipient.publicKey, {
++ subject: recipient.subject, ephemeral: pair, random,
++ }));
++ }
++ }
++ if (!people.length) fail('locker.invalid', 'A people-locked envelope needs at least one slot');
++ const newHeader = { ...coreOf(header), slots: { people } };
++ const headerBytes = encodeHeader(newHeader);
++ return {
++ version: VERSION,
++ header: newHeader,
++ headerBytes,
++ bytes: encodeEnvelope(headerBytes, bytes.subarray(chunkOffset)),
++ chunkOffset: 8 + headerBytes.length,
++ };
++ }
++
++ // Removing: the content is opened and sealed again under a new key, so the key the person
++ // who is leaving still holds opens nothing.
++ const contentKey = await openContentKey(header, credentials);
++ const plain = await open(bytes, credentials);
++ const recipients = [];
++ for (const slot of staying) {
++ const found = keys.find(key => (typeof key.subject === 'string' ? key.subject : key.subject.id) === slot.subject.id);
++ if (!found) {
++ fail('locker.invalid', `Removing a person needs the public key of ${slot.subject.id}, who stays`);
++ }
++ recipients.push({ subject: slot.subject, publicKey: found.publicKey });
++ }
++ for (const recipient of add) recipients.push({ subject: recipient.subject, publicKey: recipient.publicKey });
++ if (!recipients.length) fail('locker.invalid', 'A people-locked envelope needs at least one slot');
++ return seal(plain, { lock: 'people', keys: recipients, random, ephemeral });
++}
++
++__ovLockerModule.exports = {
++ seal, open, sealStream, openStream, rewrap,
++ envelopeBytes, toBytes, openContentKey, readCore, prepare, makeHeader, Chunker,
++ publicKeyOf, privateKeyOf, slotForPerson,
++};
++
++ },
++ "server": function (__ovLockerModule, exports, __ovLockerReq) {
++'use strict';
++/**
++ * What a service needs to know about an envelope without opening it: the header's shape and the
++ * envelope's size. Nothing here decrypts anything, and nothing here needs a key: a service may
++ * hold the bytes of an item it can never read.
++ *
++ * A service that stores an envelope should:
++ * - check it with inspectEnvelope() and refuse what does not pass;
++ * - keep the header as it is (it holds no key material, only wrapped keys);
++ * - skip indexing, previews, thumbnails, transcoding and content AI;
++ * - show the lock badge, and serve the bytes only to the people canView allows.
++ */
++const { fromUtf8, fromBase64, readU32be } = __ovLockerReq('./bytes');
++const {
++ MAGIC, VERSION, SUITE, CHUNK_SIZE, TAG_SIZE, NONCE_SIZE, NONCE_BASE_SIZE, MIN_PADDED_SIZE,
++ MAX_HEADER_BYTES, chunkCount, lastChunkSize,
++} = __ovLockerReq('./format');
++
++/** The bytes of a base64 field, or null when it is not base64 of that many bytes. */
++function bytesOf(text, length) {
++ if (typeof text !== 'string' || !text.length) return null;
++ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(text)) return null;
++ let bytes;
++ try { bytes = fromBase64(text); } catch { return null; }
++ return bytes.length === length ? bytes : null;
++}
++
++/** A subject: { type, id }, both there. */
++function subjectOk(subject) {
++ return !!subject && typeof subject === 'object' && typeof subject.type === 'string'
++ && typeof subject.id === 'string' && subject.type.length > 0 && subject.id.length > 0;
++}
++
++/** A public key as a JWK: P-256, with its two coordinates. */
++function publicKeyOk(key) {
++ return !!key && typeof key === 'object' && key.kty === 'EC' && key.crv === 'P-256'
++ && typeof key.x === 'string' && /^[A-Za-z0-9_-]{43}$/.test(key.x)
++ && typeof key.y === 'string' && /^[A-Za-z0-9_-]{43}$/.test(key.y);
++}
++
++/** One people slot: whose it is, which key it is for, and the wrapped key with the bytes it must have. */
++function peopleSlotOk(slot) {
++ if (!slot || typeof slot !== 'object') return false;
++ if (!subjectOk(slot.subject)) return false;
++ if (slot.algorithm !== 'ECDH-P256/HKDF-SHA256/AES-256-GCM') return false;
++ // The thumbprint of the recipient's key: the slot names the key it is for.
++ if (typeof slot.recipient_key !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(slot.recipient_key)) return false;
++ if (!publicKeyOk(slot.ephemeral_public_key)) return false;
++ if (!bytesOf(slot.salt, 16)) return false;
++ if (!bytesOf(slot.nonce, NONCE_SIZE)) return false;
++ return !!bytesOf(slot.wrapped_key, 48);
++}
++
++/** The password slot: the parameters and the wrapped key. */
++function passwordSlotOk(slot) {
++ if (!slot || typeof slot !== 'object') return false;
++ if (slot.algorithm !== 'PBKDF2-SHA256/AES-256-GCM') return false;
++ if (slot.kdf !== 'PBKDF2-SHA-256') return false;
++ if (!Number.isInteger(slot.iterations) || slot.iterations < 600000 || slot.iterations > 10000000) return false;
++ if (!bytesOf(slot.salt, 16)) return false;
++ if (!bytesOf(slot.nonce, NONCE_SIZE)) return false;
++ return !!bytesOf(slot.wrapped_key, 48);
++}
++
++/**
++ * The header's shape: what it says, and whether the slots match the lock it names. The problems
++ * are returned as words, so a service can log them; nothing is thrown and nothing is decrypted.
++ */
++function checkHeader(header) {
++ const errors = [];
++ if (!header || typeof header !== 'object' || Array.isArray(header)) {
++ return ['the header is not an object'];
++ }
++ if (header.version !== VERSION) errors.push(`the version is not ${VERSION}`);
++ if (header.suite !== SUITE) errors.push(`the suite is not ${SUITE}`);
++ if (header.chunk_size !== CHUNK_SIZE) errors.push(`the chunk size is not ${CHUNK_SIZE}`);
++ if (!Number.isInteger(header.padded_size) || header.padded_size < MIN_PADDED_SIZE) {
++ errors.push(`the padded size is not at least ${MIN_PADDED_SIZE} bytes`);
++ }
++ if (!bytesOf(header.nonce_base, NONCE_BASE_SIZE)) errors.push('the nonce base is not eight bytes');
++ if (!['people', 'password', 'link'].includes(header.lock)) errors.push('the lock is not one we know');
++ const slots = header.slots;
++ if (!slots || typeof slots !== 'object' || Array.isArray(slots)) {
++ errors.push('the header has no slots');
++ return errors;
++ }
++ const kinds = Object.keys(slots);
++ const wanted = header.lock === 'people' ? 'people' : header.lock;
++ if (kinds.length !== 1 || kinds[0] !== wanted) {
++ errors.push(`the slots do not match the ${header.lock} lock`);
++ return errors;
++ }
++ if (wanted === 'people') {
++ if (!Array.isArray(slots.people) || !slots.people.length || slots.people.length > 100) {
++ errors.push('the people slots are not a list of one to a hundred');
++ } else if (!slots.people.every(peopleSlotOk)) {
++ errors.push('a people slot is not the shape it must be');
++ }
++ } else if (wanted === 'password') {
++ if (!passwordSlotOk(slots.password)) errors.push('the password slot is not the shape it must be');
++ } else if (Object.keys(slots.link).length) {
++ errors.push('a link slot stores nothing, and this one does');
++ }
++ return errors;
++}
++
++/**
++ * Reads an envelope's header and checks its shape and its size. Nothing is decrypted and no key
++ * is needed: this is what a service runs on bytes it may never be able to read.
++ */
++function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {
++ const errors = [];
++ const data = bytes instanceof Uint8Array ? bytes : null;
++ if (!data) return { valid: false, errors: ['the envelope is not bytes'], header: null };
++ if (data.length < 8) return { valid: false, errors: ['the envelope is too short to hold a header'], header: null };
++ if (fromUtf8(data.subarray(0, 4)) !== MAGIC) {
++ return { valid: false, errors: ['these bytes are not a Locker envelope'], header: null };
++ }
++ const headerLength = readU32be(data, 4);
++ if (headerLength === 0 || headerLength > maxHeaderBytes) {
++ return { valid: false, errors: [`the envelope header is ${headerLength} bytes, which is not one we write`], header: null };
++ }
++ if (data.length < 8 + headerLength) {
++ return { valid: false, errors: ['the envelope ends inside its header'], header: null };
++ }
++ let header = null;
++ try { header = JSON.parse(fromUtf8(data.subarray(8, 8 + headerLength))); }
++ catch { return { valid: false, errors: ['the envelope header is not JSON'], header: null }; }
++ errors.push(...checkHeader(header));
++ const chunkOffset = 8 + headerLength;
++ let expectedBytes = null;
++ if (Number.isInteger(header.padded_size) && header.padded_size >= MIN_PADDED_SIZE
++ && header.chunk_size === CHUNK_SIZE) {
++ const chunks = chunkCount(header.padded_size);
++ // Every chunk: its length, its flags, its nonce, the ciphertext and the tag. Every chunk
++ // but the last is a whole chunk; the last holds what is left of the padded size.
++ const frame = 4 + 1 + NONCE_SIZE;
++ const whole = (chunks - 1) * (frame + CHUNK_SIZE + TAG_SIZE);
++ const last = frame + lastChunkSize(header.padded_size) + TAG_SIZE;
++ expectedBytes = chunkOffset + whole + last;
++ if (data.length < expectedBytes) errors.push('the envelope is shorter than its own header says');
++ }
++ return {
++ valid: errors.length === 0,
++ errors,
++ header,
++ headerLength,
++ chunkOffset,
++ lock: header.lock,
++ paddedSize: header.padded_size,
++ chunkCount: Number.isInteger(header.padded_size) ? chunkCount(header.padded_size) : null,
++ expectedBytes,
++ };
++}
++
++__ovLockerModule.exports = { inspectEnvelope, checkHeader };
++
++ },
++ "words": function (__ovLockerModule, exports, __ovLockerReq) {
++'use strict';
++/**
++ * The 256 words of a fingerprint. A fingerprint is the first eight bytes of SHA-256 over a
++ * person's two public keys, read as eight words in two groups of four: eight bits each, so the
++ * words say which of the 256 they are. It is short enough to read out loud and compare once,
++ * which is the whole point: a person checks that the eight words on the screen are the eight
++ * words the other person sees.
++ *
++ * The order is part of the format: word number n is the nth word here. Changing the list would
++ * change every fingerprint, so it is frozen with the version.
++ */
++const WORDS = [
++ 'amber', 'anchor', 'apple', 'arrow', 'aspen', 'autumn', 'badge', 'bamboo',
++ 'beacon', 'berry', 'birch', 'bison', 'blossom', 'breeze', 'brick', 'brook',
++ 'bucket', 'bugle', 'cabin', 'cactus', 'candle', 'canoe', 'canyon', 'cedar',
++ 'cellar', 'chalk', 'cherry', 'cider', 'cinder', 'citrus', 'clay', 'clover',
++ 'cobalt', 'cocoa', 'comet', 'coral', 'cosmos', 'cotton', 'cove', 'crane',
++ 'crater', 'crayon', 'cricket', 'crimson', 'crystal', 'cymbal', 'daisy', 'dapple',
++ 'dawn', 'delta', 'desert', 'diamond', 'dolphin', 'domino', 'donkey', 'dragon',
++ 'drift', 'drizzle', 'drum', 'dune', 'dusk', 'eagle', 'ember', 'emerald',
++ 'estuary', 'fable', 'falcon', 'feather', 'fern', 'ferry', 'field', 'flame',
++ 'flint', 'forest', 'fossil', 'fox', 'frost', 'galaxy', 'garden', 'garnet',
++ 'gazelle', 'geyser', 'ginger', 'glacier', 'granite', 'grape', 'gravel', 'grove',
++ 'guitar', 'haiku', 'harbor', 'hazel', 'heather', 'hollow', 'honey', 'horizon',
++ 'ice', 'indigo', 'island', 'ivory', 'jasper', 'jungle', 'juniper', 'kayak',
++ 'kernel', 'kestrel', 'lagoon', 'lantern', 'lattice', 'laurel', 'lemon', 'lichen',
++ 'lilac', 'linen', 'lotus', 'lunar', 'magnet', 'mango', 'maple', 'marble',
++ 'marsh', 'meadow', 'meteor', 'mint', 'mirror', 'mist', 'monsoon', 'moss',
++ 'moth', 'mountain', 'nectar', 'needle', 'nest', 'nettle', 'night', 'oasis',
++ 'ocean', 'olive', 'onyx', 'opal', 'orbit', 'orchard', 'orchid', 'osprey',
++ 'otter', 'owl', 'oyster', 'palace', 'palm', 'panda', 'papaya', 'paper',
++ 'peach', 'pearl', 'pebble', 'pecan', 'penguin', 'peony', 'pepper', 'petal',
++ 'pine', 'plateau', 'plum', 'pollen', 'pond', 'poplar', 'poppy', 'prairie',
++ 'prism', 'pumpkin', 'quail', 'quarry', 'quartz', 'rabbit', 'radish', 'rain',
++ 'raven', 'reef', 'ridge', 'river', 'robin', 'rosemary', 'sage', 'salmon',
++ 'sand', 'sapphire', 'satellite', 'scallop', 'shadow', 'shell', 'shore', 'shuttle',
++ 'silk', 'silver', 'slate', 'snow', 'solar', 'sparrow', 'spruce', 'star',
++ 'stone', 'storm', 'stream', 'sugar', 'summit', 'sunset', 'swan', 'thistle',
++ 'thunder', 'tide', 'tiger', 'timber', 'topaz', 'trail', 'tulip', 'tundra',
++ 'turtle', 'valley', 'vapor', 'velvet', 'vine', 'violet', 'walnut', 'wave',
++ 'whale', 'wheat', 'willow', 'winter', 'wolf', 'wren', 'yacht', 'yarrow',
++ 'yew', 'zinc', 'zephyr', 'almond', 'basil', 'cinnamon', 'cypress', 'damson',
++ 'elder', 'fennel', 'garlic', 'hibiscus', 'iris', 'jasmine', 'linden', 'melon',
++ 'nutmeg', 'sesame', 'sorrel', 'thyme', 'anise', 'chicory', 'dill', 'endive',
++];
++
++__ovLockerModule.exports = { WORDS };
++
++ },
++ };
++ const cache = {};
++ function req(name) {
++ const key = String(name).replace(/^\.\//, '');
++ if (!defs[key]) throw new Error('ov-locker-core: no module ' + name);
++ if (!cache[key]) { const m = { exports: {} }; cache[key] = m; defs[key](m, m.exports, req); }
++ return cache[key].exports;
++ }
++ root.OVLockerCore = Object.assign({}, req('index'), { SDK_VERSION: "0.86.0" });
++})(typeof globalThis !== 'undefined' ? globalThis : this);
ddiff --git a/ov-locker.css b/ov-locker.css
nnew file mode 100644
iindex 0000000..d444d06
---- /dev/null
++++ b/ov-locker.css
@@@ -0,0 +1,123 @@
++/* openvibe-shared/ov-locker.css — the look of ov-locker.js: a locked item's card, and the sheets that make and unlock
++ * your locker. The same tokens and fallbacks as ov-share.css, so the two read as one family. Nothing spins. */
++
++.ovl-sheet, .ovl-card {
++ --ovl-accent: var(--accent, #3b82f6);
++ --ovl-on-accent: var(--on-accent, #fff);
++ --ovl-surface: var(--bg-card, var(--bg-secondary, Canvas));
++ --ovl-text: var(--text-primary, CanvasText);
++ --ovl-muted: var(--text-muted, var(--text-secondary, color-mix(in srgb, CanvasText 60%, Canvas)));
++ --ovl-line: var(--border, color-mix(in srgb, CanvasText 14%, transparent));
++ --ovl-hover: var(--bg-hover, color-mix(in srgb, CanvasText 7%, transparent));
++ --ovl-field: var(--bg-input, var(--bg-tertiary, Field));
++ --ovl-bad: var(--danger, #ef4444);
++ --ovl-ok: var(--success, #16a34a);
++ --ovl-lockhue: color-mix(in srgb, var(--ovl-accent) 55%, #8b5cf6);
++ color: var(--ovl-text); font-family: inherit; font-size: 14px; line-height: 1.5; text-align: start;
++}
++.ovl-sheet *, .ovl-card * { box-sizing: border-box; }
++
++/* ── The sheet ──────────────────────────────────────────────────────────────────────────────────────────── */
++.ovl-sheet {
++ box-sizing: border-box; width: min(460px, calc(100vw - 24px)); max-width: none; max-height: min(90dvh, 760px); max-height: min(90vh, 760px);
++ margin: auto; padding: 0; overflow: hidden; background: var(--ovl-surface); border: 1px solid var(--ovl-line); border-radius: var(--ov-radius-3, 20px);
++ box-shadow: 0 28px 70px -18px rgb(0 0 0 / .5), 0 4px 14px rgb(0 0 0 / .14);
++ opacity: 0; transform: translateY(10px) scale(.985); transition: opacity .16s ease, transform .2s cubic-bezier(.2, .8, .2, 1);
++}
++.ovl-sheet.is-open { opacity: 1; transform: none; }
++.ovl-sheet::backdrop { background: rgb(6 10 16 / .55); -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px); }
++html.ovl-locked, html.ovl-locked body { overflow: hidden; }
++.ovl-box { display: flex; flex-direction: column; max-height: inherit; min-height: 0; }
++.ovl-head { display: flex; align-items: center; gap: 10px; padding: 16px 10px 8px 20px; }
++.ovl-title { flex: 1; min-width: 0; margin: 0; font-size: 17px; font-weight: 700; line-height: 1.3; }
++.ovl-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 4px 20px 20px; display: grid; align-content: start; gap: 14px; }
++@media (max-width: 640px), (pointer: coarse) and (max-width: 900px) {
++ .ovl-sheet { width: 100%; max-width: 100%; margin: auto 0 0; border: 0; border-radius: var(--ov-radius-3, 20px) var(--ov-radius-3, 20px) 0 0;
++ opacity: 1; transform: translateY(100%); transition: transform .26s cubic-bezier(.2, .85, .25, 1); }
++ .ovl-sheet.is-open { transform: none; }
++ .ovl-head { position: relative; padding-top: 20px; }
++ .ovl-head::before { content: ""; position: absolute; top: 7px; left: 50%; width: 38px; height: 5px; margin-left: -19px; border-radius: 99px; background: color-mix(in srgb, var(--ovl-text) 22%, transparent); }
++ .ovl-body { padding: 4px max(16px, env(safe-area-inset-right, 0px)) max(20px, env(safe-area-inset-bottom, 0px)) max(16px, env(safe-area-inset-left, 0px)); font-size: 15px; }
++}
++
++/* ── Words, fields, buttons ─────────────────────────────────────────────────────────────────────────────── */
++.ovl-lead { margin: 0; color: var(--ovl-muted); font-size: 14px; }
++.ovl-muted { margin: 0; color: var(--ovl-muted); font-size: 14px; max-width: 42ch; }
++.ovl-label { display: block; margin: 0; font-size: 12px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: var(--ovl-muted); }
++.ovl-hint { display: block; font-size: 12.5px; color: var(--ovl-muted); }
++.ovl-msg { margin: 0; min-height: 1em; font-size: 13px; color: var(--ovl-muted); }
++.ovl-msg:empty { display: none; }
++.ovl-msg.is-bad { color: var(--danger-text, var(--ovl-bad)); }
++.ovl-form { display: grid; gap: 12px; margin: 0; }
++.ovl-field { display: grid; gap: 6px; min-width: 0; }
++.ovl-inputrow { display: flex; align-items: center; gap: 4px; min-width: 0; min-height: 46px; padding: 0 4px 0 12px; background: var(--ovl-field); border: 1px solid var(--ovl-line); border-radius: 12px; transition: border-color .14s ease, box-shadow .14s ease; }
++.ovl-inputrow:focus-within, .ovl-wordsin:focus { border-color: var(--ovl-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovl-accent) 26%, transparent); outline: none; }
++.ovl-input { flex: 1; min-width: 0; width: 100%; height: 44px; padding: 0; border: 0; outline: 0; background: none; color: var(--ovl-text); font: inherit; font-size: 15px; letter-spacing: .01em; }
++.ovl-wordsin { width: 100%; height: auto; min-height: 110px; padding: 10px 12px; resize: vertical; background: var(--ovl-field); border: 1px solid var(--ovl-line); border-radius: 12px; font: 15px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
++@media (pointer: coarse) { .ovl-input, .ovl-wordsin { font-size: 16px; } }
++.ovl-btn {
++ -webkit-appearance: none; appearance: none; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 46px; padding: 0 18px; margin: 0;
++ border: 1px solid var(--ovl-line); border-radius: 12px; background: transparent; color: var(--ovl-text); font: inherit; font-size: 15px; font-weight: 650; cursor: pointer;
++ transition: background-color .14s ease, transform .1s ease;
++}
++.ovl-btn:hover:not(:disabled) { background: var(--ovl-hover); }
++.ovl-btn:active:not(:disabled) { transform: scale(.98); }
++.ovl-btn:disabled { opacity: .55; cursor: default; }
++.ovl-btn.is-primary { background: var(--ovl-accent); border-color: var(--ovl-accent); color: var(--ovl-on-accent); }
++.ovl-btn.is-primary:hover:not(:disabled) { background: color-mix(in srgb, var(--ovl-accent) 88%, #000); }
++.ovl-btn.is-quiet { border-color: var(--ovl-line); min-height: 40px; font-size: 14px; }
++.ovl-btn:focus-visible, .ovl-iconbtn:focus-visible, .ovl-link:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovl-accent) 40%, transparent); }
++.ovl-iconbtn { -webkit-appearance: none; appearance: none; flex: none; display: inline-grid; place-items: center; width: 40px; height: 40px; margin: 0; padding: 0; border: 0; border-radius: 10px; background: none; color: var(--ovl-muted); cursor: pointer; }
++.ovl-iconbtn:hover { background: var(--ovl-hover); color: var(--ovl-text); }
++.ovl-iconbtn.is-small { width: 38px; height: 38px; }
++.ovl-iconbtn[aria-pressed="true"] { color: var(--ovl-accent); }
++.ovl-link { -webkit-appearance: none; appearance: none; justify-self: center; margin: 0; padding: 8px 4px; border: 0; background: none; color: var(--ovl-accent); font: inherit; font-weight: 600; cursor: pointer; border-radius: 6px; }
++.ovl-link:hover { text-decoration: underline; }
++.ovl-link.is-quiet { color: var(--ovl-muted); font-weight: 500; }
++.ovl-check { display: flex; align-items: flex-start; gap: 10px; font-size: 14px; cursor: pointer; }
++.ovl-check input { flex: none; width: 18px; height: 18px; margin: 2px 0 0; accent-color: var(--ovl-accent); }
++.ovl-acts { display: flex; flex-wrap: wrap; gap: 8px; }
++.ovl-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
++.ovl-ico { flex: none; display: inline-grid; place-items: center; width: 20px; height: 20px; }
++.ovl-ico svg { width: 100%; height: 100%; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
++.ovl-ico.is-badge { width: 34px; height: 34px; padding: 7px; border-radius: 10px; color: #fff; background: linear-gradient(140deg, var(--ovl-lockhue), var(--ovl-accent)); }
++.ovl-ico.is-big { width: 52px; height: 52px; padding: 12px; border-radius: 16px; color: #fff; background: linear-gradient(140deg, var(--ovl-lockhue), var(--ovl-accent)); box-shadow: 0 10px 28px -10px color-mix(in srgb, var(--ovl-accent) 70%, transparent); }
++
++/* The strength meter: four segments that fill and colour as the passphrase grows. */
++.ovl-meter { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 10px; }
++.ovl-meterbar { position: relative; height: 6px; border-radius: 99px; background: var(--ovl-hover); overflow: hidden; }
++.ovl-meterbar::after { content: ""; position: absolute; inset: 0 auto 0 0; width: 0; border-radius: inherit; background: var(--ovl-bad); transition: width .25s ease, background-color .25s ease; }
++.ovl-meter[data-score="1"] .ovl-meterbar::after { width: 25%; }
++.ovl-meter[data-score="2"] .ovl-meterbar::after { width: 50%; background: var(--warning, #f59e0b); }
++.ovl-meter[data-score="3"] .ovl-meterbar::after { width: 75%; background: color-mix(in srgb, var(--ovl-ok) 70%, var(--warning, #f59e0b)); }
++.ovl-meter[data-score="4"] .ovl-meterbar::after { width: 100%; background: var(--ovl-ok); }
++.ovl-meterwords { font-size: 12.5px; font-weight: 600; color: var(--ovl-muted); min-width: 0; }
++
++/* The recovery kit: numbered words in a printable grid. */
++.ovl-words { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin: 0; padding: 12px; list-style: none; counter-reset: ovlw;
++ border: 1px dashed color-mix(in srgb, var(--ovl-accent) 45%, var(--ovl-line)); border-radius: 14px; background: color-mix(in srgb, var(--ovl-accent) 5%, transparent); }
++.ovl-words li { counter-increment: ovlw; display: flex; align-items: baseline; gap: 6px; min-width: 0; padding: 5px 8px; border-radius: 8px; background: var(--ovl-surface);
++ font: 600 14px/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; user-select: all; }
++.ovl-words li::before { content: counter(ovlw); flex: none; min-width: 1.6em; color: var(--ovl-muted); font-size: 12px; font-weight: 500; text-align: right; }
++@media (max-width: 400px) { .ovl-words { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
++.ovl-done { display: grid; justify-items: center; gap: 12px; text-align: center; }
++.ovl-done .ovl-ico.is-big { background: linear-gradient(140deg, var(--ovl-ok), color-mix(in srgb, var(--ovl-ok) 60%, var(--ovl-accent))); animation: ovl-pop .5s cubic-bezier(.3, 1.6, .5, 1); }
++.ovl-fp { margin: 0; padding: 10px 14px; border-radius: 12px; background: var(--ovl-hover); font: 650 15px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; letter-spacing: .02em; word-spacing: .2em; }
++@keyframes ovl-pop { 0% { transform: scale(.5); opacity: 0; } 70% { transform: scale(1.1); opacity: 1; } 100% { transform: none; } }
++
++/* ── A locked item's card ───────────────────────────────────────────────────────────────────────────────── */
++.ovl-card {
++ display: grid; justify-items: center; gap: 12px; max-width: 460px; margin: 40px auto; padding: 32px 24px; text-align: center;
++ background: var(--ovl-surface); border: 1px solid var(--ovl-line); border-radius: var(--ov-radius-3, 20px);
++ background-image: radial-gradient(120% 60% at 50% 0%, color-mix(in srgb, var(--ovl-lockhue) 9%, transparent), transparent 70%);
++}
++.ovl-card.is-opened { display: none; }
++.ovl-cardtitle { margin: 4px 0 0; font-size: 19px; font-weight: 700; line-height: 1.3; text-wrap: balance; }
++.ovl-card .ovl-form { width: 100%; max-width: 340px; text-align: start; }
++.ovl-card .ovl-btn.is-primary { width: 100%; }
++
++@media (prefers-reduced-motion: reduce) {
++ .ovl-sheet, .ovl-btn, .ovl-inputrow, .ovl-meterbar::after { transition: none !important; }
++ .ovl-done .ovl-ico.is-big { animation: none; }
++}
++@media (forced-colors: active) { .ovl-sheet, .ovl-card { border: 1px solid CanvasText; } }
ddiff --git a/ov-locker.js b/ov-locker.js
nnew file mode 100644
iindex 0000000..9c3d183
---- /dev/null
++++ b/ov-locker.js
@@@ -0,0 +1,454 @@
++/**
++ * openvibe-shared/ov-locker.js — Locker in the browser (plan track Q2; the owner, 2026-11-06: "encrypt it … set the
++ * encryption password … completely encrypted on our end … decrypted on the client's end, so we don't have access to
++ * any of the user data"). The look is ov-locker.css, linked from beside this file; docs/locker.md describes both.
++ *
++ * OVLocker.open(el, { bytes | url, title?, onOpen(bytes, info) }) the card a locked item shows: it opens with the
++ * link's key, a password, or your locker
++ * OVLocker.setup({ onDone }) make your locker: a passphrase, then a recovery kit (24 words), saved locked
++ * OVLocker.unlock({ onDone }) unlock it on this device (the passphrase, or the recovery kit)
++ * OVLocker.state() { unlocked, fingerprint, subject }
++ * OVLocker.forget() drop the unlocked keys from this tab
++ * OVLocker.keyFromLink(href?) the content key a link carries after #k= (link locks)
++ * window.OpenVibeLockerConfig = { network: 'https://openvibe.network' } where the key directory is
++ *
++ * The cryptography is ov-locker-core.js beside this file (generated from openvibe-sdk/locker: WebCrypto only, the
++ * shared test vectors). This file never sends a password, a passphrase, the recovery words, a content key or a private
++ * key anywhere: they live in this tab's memory and go when it closes, or with forget(). What Network keeps is the
++ * public keys and two locked copies of the private keys (one opens with the passphrase, one with the recovery words).
++ *
++ * Every word on screen is text, never markup. Nothing spins: a wait is words.
++ */
++(function (root) {
++ 'use strict';
++ if (!root || !root.document || root.OVLocker) return;
++ const doc = root.document;
++ const cfg = () => Object.assign({ network: 'https://openvibe.network' }, root.OpenVibeLockerConfig || {});
++ const RECOVERY_WORDS = 24;
++ const svg = (d) => `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">${d}</svg>`;
++ const ICON = {
++ lock: svg('<rect x="5" y="10.5" width="14" height="9.5" rx="2.2"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/>'),
++ open: svg('<rect x="5" y="10.5" width="14" height="9.5" rx="2.2"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 6.8-1.2"/>'),
++ key: svg('<circle cx="8" cy="15" r="3.5"/><path d="m10.5 12.5 8-8M15.5 7.5l2 2M13.5 9.5l1.5 1.5"/>'),
++ eye: svg('<path d="M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="2.8"/>'),
++ close: svg('<path d="m6.5 6.5 11 11M17.5 6.5l-11 11"/>'),
++ tick: svg('<path d="m5 12.5 4.5 4.5L19 7.5"/>'),
++ };
++
++ // ── Helpers ─────────────────────────────────────────────────────────────────────────────────────────────
++ const el = (tag, cls, attrs, text) => {
++ const n = doc.createElement(tag);
++ if (cls) n.className = cls;
++ if (attrs) for (const k of Object.keys(attrs)) if (attrs[k] != null && attrs[k] !== false) n.setAttribute(k, attrs[k] === true ? '' : attrs[k]);
++ if (text != null) n.textContent = text;
++ return n;
++ };
++ const icon = (name, cls) => { const s = el('span', cls || 'ovl-ico'); s.innerHTML = ICON[name]; return s; }; // constants only
++ const reduce = () => !!(root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches);
++ const b64 = (u8) => { let s = ''; for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return root.btoa(s); };
++ const unb64 = (s) => Uint8Array.from(root.atob(String(s)), (c) => c.charCodeAt(0));
++ const unb64url = (s) => unb64(String(s).replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((String(s).length + 3) % 4));
++ const utf8 = (s) => new TextEncoder().encode(s);
++ const fromUtf8 = (u8) => new TextDecoder().decode(u8);
++
++ function beside(name) {
++ const me = doc.currentScript || doc.querySelector('script[src*="ov-locker.js"]');
++ return (me && me.src ? me.src : '/shared/ov-locker.js').replace(/ov-locker\.js(\?[^#]*)?.*$/, `${name}$1`);
++ }
++ function stylesheet() {
++ if (doc.querySelector('link[href*="ov-locker.css"]')) return;
++ doc.head.appendChild(el('link', null, { rel: 'stylesheet', href: beside('ov-locker.css') }));
++ }
++ stylesheet();
++ let coreLoad = null;
++ /** The crypto core, once (ov-locker-core.js, generated from openvibe-sdk/locker). */
++ function core() {
++ if (root.OVLockerCore) return Promise.resolve(root.OVLockerCore);
++ if (!coreLoad) {
++ coreLoad = new Promise((resolve, reject) => {
++ const sc = el('script', null, { src: beside('ov-locker-core.js'), async: true });
++ sc.onload = () => (root.OVLockerCore ? resolve(root.OVLockerCore) : reject(new Error('The Locker could not start.')));
++ sc.onerror = () => { coreLoad = null; reject(new Error('The Locker could not load. Check your connection and try again.')); };
++ doc.head.appendChild(sc);
++ });
++ }
++ return coreLoad;
++ }
++ async function network(method, path, body) {
++ const r = await fetch(cfg().network.replace(/\/+$/, '') + path, {
++ method, credentials: 'include',
++ headers: Object.assign({ accept: 'application/json', 'x-requested-with': 'ov-locker' }, body ? { 'content-type': 'application/json' } : {}),
++ body: body ? JSON.stringify(body) : undefined,
++ });
++ let j = null;
++ try { j = await r.json(); } catch (_) { /* empty */ }
++ if (!r.ok) { const e = new Error((j && (j.error || j.message)) || `OpenVibe answered ${r.status}`); e.status = r.status; throw e; }
++ return j || {};
++ }
++
++ // ── This tab's unlocked locker: memory only ─────────────────────────────────────────────────────────────
++ let session = null; // { person, subject, fingerprint }
++ const state = () => ({ unlocked: !!session, fingerprint: session ? session.fingerprint : null, subject: session ? session.subject : null });
++ function forget() { session = null; doc.dispatchEvent(new CustomEvent('ov-locker:change', { detail: state() })); }
++
++ /** The content key a link carries: #k=<base64url>, anywhere in the part after #. */
++ function keyFromLink(href) {
++ const h = String(href == null ? root.location.hash : (String(href).split('#')[1] || '')).replace(/^#/, '');
++ const m = h.match(/(?:^|&)k=([A-Za-z0-9_-]{43})(?:&|$)/);
++ if (!m) return null;
++ try { const k = unb64url(m[1]); return k.length === 32 ? k : null; } catch (_) { return null; }
++ }
++
++ // ── How strong a passphrase is, in plain words (no list to download: length, variety, and the obvious) ─────
++ const COMMON = /^(password|passw0rd|letmein|qwerty|iloveyou|admin|welcome|monkey|dragon|openvibe|123456|abc123)/i;
++ function strength(p) {
++ const s = String(p || '');
++ if (!s) return { score: 0, words: '' };
++ const words = s.trim().split(/[\s\-_.]+/).filter((w) => w.length >= 3).length;
++ const classes = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9\s]/].filter((r) => r.test(s)).length;
++ let score = s.length >= 20 || words >= 4 ? 4 : s.length >= 16 || (words >= 3 && s.length >= 14) ? 3 : s.length >= 12 && classes >= 2 ? 2 : 1;
++ if (COMMON.test(s) || /^(.)\1+$/.test(s) || /^(0123|1234|abcd)/i.test(s)) score = 1;
++ return { score, words: ['', 'Too easy to guess', 'Getting there: add a word', 'Good', 'Strong'][score] };
++ }
++
++ // ── A sheet of our own (setup, unlock): a dialog, a bottom sheet on a phone ──────────────────────────────
++ function sheet(title) {
++ const dlg = el('dialog', 'ovl-sheet', { 'aria-labelledby': 'ovl-title' });
++ const box = el('div', 'ovl-box');
++ const head = el('div', 'ovl-head');
++ head.appendChild(icon('lock', 'ovl-ico is-badge'));
++ head.appendChild(el('h2', 'ovl-title', { id: 'ovl-title' }, title));
++ const x = el('button', 'ovl-iconbtn', { type: 'button', 'aria-label': 'Close' });
++ x.appendChild(icon('close'));
++ head.appendChild(x);
++ const body = el('div', 'ovl-body');
++ box.appendChild(head); box.appendChild(body);
++ dlg.appendChild(box);
++ doc.body.appendChild(dlg);
++ let done = null;
++ const finished = new Promise((r) => { done = r; });
++ const close = (value) => {
++ if (!dlg.isConnected) return;
++ doc.documentElement.classList.remove('ovl-locked');
++ const end = () => { try { dlg.close(); } catch (_) { /* closed */ } dlg.remove(); done(value); };
++ if (reduce()) end(); else { dlg.classList.remove('is-open'); setTimeout(end, 220); }
++ };
++ x.addEventListener('click', () => close(null));
++ dlg.addEventListener('cancel', (e) => { e.preventDefault(); close(null); });
++ dlg.showModal();
++ doc.documentElement.classList.add('ovl-locked');
++ if (reduce()) dlg.classList.add('is-open'); else root.requestAnimationFrame(() => root.requestAnimationFrame(() => dlg.classList.add('is-open')));
++ return { dlg, body, close, finished, setTitle: (t) => { head.querySelector('.ovl-title').textContent = t; } };
++ }
++ function field(label, attrs, hint) {
++ const wrap = el('label', 'ovl-field');
++ wrap.appendChild(el('span', 'ovl-label', null, label));
++ const row = el('span', 'ovl-inputrow');
++ const input = el('input', 'ovl-input', Object.assign({ autocomplete: 'off', spellcheck: 'false', autocapitalize: 'none' }, attrs));
++ row.appendChild(input);
++ if (attrs.type === 'password') {
++ const eye = el('button', 'ovl-iconbtn is-small', { type: 'button', 'aria-label': 'Show', 'aria-pressed': 'false' });
++ eye.appendChild(icon('eye'));
++ eye.addEventListener('click', () => {
++ const show = input.type === 'password';
++ input.type = show ? 'text' : 'password';
++ eye.setAttribute('aria-pressed', String(show)); eye.setAttribute('aria-label', show ? 'Hide' : 'Show');
++ input.focus();
++ });
++ row.appendChild(eye);
++ }
++ wrap.appendChild(row);
++ if (hint) wrap.appendChild(el('span', 'ovl-hint', null, hint));
++ return { wrap, input };
++ }
++ const button = (label, kind, attrs) => el('button', `ovl-btn${kind ? ` is-${kind}` : ''}`, Object.assign({ type: 'button' }, attrs || {}), label);
++ const say = (box, words, bad) => { box.textContent = words || ''; box.className = `ovl-msg${bad ? ' is-bad' : ''}`; };
++
++ // ── Make your locker ────────────────────────────────────────────────────────────────────────────────────
++ async function setup(opts) {
++ const o = Object.assign({}, opts || {});
++ const s = sheet('Make your locker');
++ 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.'));
++ const f = el('form', 'ovl-form', { novalidate: true });
++ const p1 = field('A passphrase', { type: 'password', autocomplete: 'new-password', 'data-k': 'pass' }, 'Four or more words you will remember. Not one you use anywhere else.');
++ const meter = el('span', 'ovl-meter', { 'data-score': '0', 'aria-live': 'polite' });
++ meter.appendChild(el('span', 'ovl-meterbar'));
++ meter.appendChild(el('span', 'ovl-meterwords'));
++ p1.wrap.appendChild(meter);
++ const p2 = field('The same again', { type: 'password', autocomplete: 'new-password', 'data-k': 'pass2' });
++ const msg = el('p', 'ovl-msg', { 'aria-live': 'polite' });
++ const go = button('Next', 'primary', { type: 'submit', 'data-k': 'next' });
++ p1.input.addEventListener('input', () => { const st = strength(p1.input.value); meter.dataset.score = String(st.score); meter.lastChild.textContent = st.words; });
++ f.appendChild(p1.wrap); f.appendChild(p2.wrap); f.appendChild(msg); f.appendChild(go);
++ f.addEventListener('submit', (e) => {
++ e.preventDefault();
++ if (strength(p1.input.value).score < 3) return say(msg, 'Make it longer: four words, or 16 characters or more.', true);
++ if (p1.input.value !== p2.input.value) return say(msg, 'The two do not match.', true);
++ step2(p1.input.value);
++ });
++ s.body.appendChild(f);
++ setTimeout(() => p1.input.focus(), 0);
++ };
++ const step2 = async (passphrase) => {
++ s.body.replaceChildren(el('p', 'ovl-lead', { role: 'status' }, 'Making your keys on this device…'));
++ let c, person, words;
++ try {
++ c = await core();
++ person = await c.generatePersonKeys();
++ // The SDK answers the phrase as one string of words.
++ words = String(await c.recoveryWords(RECOVERY_WORDS)).trim().split(/\s+/);
++ } catch (e) { s.body.replaceChildren(el('p', 'ovl-msg is-bad', { role: 'alert' }, e.message)); return; }
++ step3(c, person, passphrase, words);
++ };
++ const step3 = (c, person, passphrase, words) => {
++ s.setTitle('Your recovery kit');
++ s.body.replaceChildren();
++ s.body.appendChild(el('p', 'ovl-lead', null, 'If you forget your passphrase, these 24 words are the only other way into your locker. Write them down or save them somewhere only you can reach. They are shown once.'));
++ const grid = el('ol', 'ovl-words', { 'aria-label': 'Your 24 recovery words' });
++ words.forEach((w) => grid.appendChild(el('li', null, null, w)));
++ s.body.appendChild(grid);
++ const acts = el('div', 'ovl-acts');
++ const copy = button('Copy the words', 'quiet', { 'data-k': 'copy' });
++ copy.addEventListener('click', async () => {
++ try { await root.navigator.clipboard.writeText(words.join(' ')); copy.textContent = 'Copied'; } catch (_) { copy.textContent = 'Select and copy them'; }
++ });
++ const save = button('Save as a file', 'quiet', { 'data-k': 'save' });
++ save.addEventListener('click', () => {
++ const text = `OpenVibe Locker recovery kit\n\nThese 24 words open your locker if you forget your passphrase.\nKeep them where only you can reach them. Anyone with them can open your locker.\n\n${words.map((w, i) => `${String(i + 1).padStart(2, ' ')}. ${w}`).join('\n')}\n`;
++ 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);
++ 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)];
++ const f = el('form', 'ovl-form', { novalidate: true });
++ const w1 = field(`Word ${pick[0] + 1}`, { type: 'text', 'data-k': 'w1', autocomplete: 'off' });
++ const w2 = field(`Word ${pick[1] + 1}`, { type: 'text', 'data-k': 'w2', autocomplete: 'off' });
++ const row = el('div', 'ovl-pair'); row.appendChild(w1.wrap); row.appendChild(w2.wrap);
++ f.appendChild(el('p', 'ovl-label', null, 'Check you kept them'));
++ f.appendChild(row);
++ const msg = el('p', 'ovl-msg', { 'aria-live': 'polite' });
++ f.appendChild(msg);
++ const go = button('Make my locker', 'primary', { type: 'submit', 'data-k': 'make' });
++ f.appendChild(go);
++ f.addEventListener('submit', async (e) => {
++ e.preventDefault();
++ const ok = [w1.input.value, w2.input.value].map((v) => v.trim().toLowerCase());
++ if (ok[0] !== words[pick[0]] || ok[1] !== words[pick[1]]) return say(msg, 'Those are not the words in your kit. Look again.', true);
++ go.disabled = true; say(msg, 'Locking your keys and saving the locked copies…');
++ try {
++ 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 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) } },
++ });
++ session = { person, subject: null, fingerprint: pub.fingerprint };
++ doc.dispatchEvent(new CustomEvent('ov-locker:change', { detail: state() }));
++ step4(pub.fingerprint);
++ } catch (err) {
++ go.disabled = false;
++ if (err.status === 403 && /reauth|sign in again/i.test(err.message)) {
++ say(msg, 'For your safety, sign in again, then start your new locker.', true);
++ const a = el('a', 'ovl-btn is-quiet', { href: `${cfg().network.replace(/\/+$/, '')}/login?next=${encodeURIComponent(root.location.href)}` }, 'Sign in again');
++ msg.appendChild(doc.createTextNode(' ')); msg.appendChild(a);
++ } else say(msg, err.status === 401 ? 'Sign in to OpenVibe first, then make your locker.' : `Could not save your locker: ${err.message}`, true);
++ }
++ });
++ s.body.appendChild(f);
++ };
++ const step4 = (fingerprint) => {
++ s.setTitle('Your locker is ready');
++ s.body.replaceChildren();
++ const ok = el('div', 'ovl-done');
++ ok.appendChild(icon('tick', 'ovl-ico is-big'));
++ ok.appendChild(el('p', 'ovl-lead', null, 'People can now lock things for you, and only you can open them.'));
++ ok.appendChild(el('p', 'ovl-label', null, 'Your locker’s fingerprint'));
++ ok.appendChild(el('p', 'ovl-fp', { 'data-k': 'fingerprint' }, fingerprint));
++ ok.appendChild(el('p', 'ovl-hint', null, 'Someone who wants to be sure a locked item is for you can compare these words with you, in person or on a call.'));
++ const d = button('Done', 'primary', { 'data-k': 'done' });
++ d.addEventListener('click', () => s.close(state()));
++ ok.appendChild(d);
++ s.body.appendChild(ok);
++ if (typeof o.onDone === 'function') o.onDone(state());
++ };
++ step1();
++ return s.finished;
++ }
++
++ // ── Unlock your locker on this device ───────────────────────────────────────────────────────────────────
++ async function unlock(opts) {
++ const o = Object.assign({}, opts || {});
++ if (session) return state();
++ const s = sheet('Unlock your locker');
++ let backups = null;
++ const draw = (mode) => {
++ s.body.replaceChildren();
++ s.body.appendChild(el('p', 'ovl-lead', null, mode === 'words'
++ ? 'Type your 24 recovery words, in order, with spaces between them.'
++ : 'Your passphrase opens your locker on this device. It stays here: OpenVibe never sees it.'));
++ const f = el('form', 'ovl-form', { novalidate: true });
++ let input;
++ if (mode === 'words') {
++ const wrap = el('label', 'ovl-field');
++ wrap.appendChild(el('span', 'ovl-label', null, 'Recovery words'));
++ input = el('textarea', 'ovl-input ovl-wordsin', { rows: '4', 'data-k': 'words', autocomplete: 'off', spellcheck: 'false', autocapitalize: 'none' });
++ wrap.appendChild(input);
++ f.appendChild(wrap);
++ } else {
++ const p = field('Passphrase', { type: 'password', autocomplete: 'current-password', 'data-k': 'pass' });
++ input = p.input;
++ f.appendChild(p.wrap);
++ }
++ const msg = el('p', 'ovl-msg', { 'aria-live': 'polite' });
++ f.appendChild(msg);
++ const go = button('Unlock', 'primary', { type: 'submit', 'data-k': 'unlock' });
++ f.appendChild(go);
++ const other = el('button', 'ovl-link', { type: 'button', 'data-k': 'other' }, mode === 'words' ? 'Use my passphrase instead' : 'I forgot it: use my recovery kit');
++ other.addEventListener('click', () => draw(mode === 'words' ? 'pass' : 'words'));
++ f.appendChild(other);
++ if (mode === 'words') {
++ const lost = el('button', 'ovl-link is-quiet', { type: 'button', 'data-k': 'lost' }, 'I lost both: start a new locker');
++ lost.addEventListener('click', () => drawLost());
++ f.appendChild(lost);
++ }
++ f.addEventListener('submit', async (e) => {
++ e.preventDefault();
++ const secret = mode === 'words' ? input.value.trim().toLowerCase().split(/\s+/).join(' ') : input.value;
++ if (!secret) return say(msg, mode === 'words' ? 'Type the 24 words.' : 'Type your passphrase.', true);
++ go.disabled = true; say(msg, 'Unlocking…');
++ 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 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).
++ const fingerprint = await c.fingerprintOf(person.ecdhPublic, person.signingPublic);
++ session = { person, subject: backups.subject || null, fingerprint };
++ doc.dispatchEvent(new CustomEvent('ov-locker:change', { detail: state() }));
++ s.close(state());
++ if (typeof o.onDone === 'function') o.onDone(state());
++ } catch (err) {
++ go.disabled = false;
++ if (err.status === 404) {
++ say(msg, 'You have no locker yet.', true);
++ const mk = button('Make my locker', 'quiet', { 'data-k': 'make-instead' });
++ mk.addEventListener('click', async () => { s.close(null); const st = await setup(); if (st && typeof o.onDone === 'function') o.onDone(st); });
++ msg.appendChild(doc.createTextNode(' ')); msg.appendChild(mk);
++ } else if (err.status === 401) say(msg, 'Sign in to OpenVibe first.', true);
++ else if (err.code === 'locker.wrong_key') say(msg, mode === 'words' ? 'Those words do not open your locker. Check each one.' : 'That passphrase does not open your locker.', true);
++ else say(msg, `Could not unlock: ${err.message}`, true);
++ }
++ });
++ s.body.appendChild(f);
++ setTimeout(() => input.focus(), 0);
++ };
++ // Lost the passphrase and the kit: a new locker, said plainly. What was locked for the old keys stays locked.
++ const drawLost = () => {
++ s.setTitle('Start a new locker');
++ s.body.replaceChildren();
++ s.body.appendChild(el('p', 'ovl-lead', null, 'A new locker makes new keys. Anything locked for your old locker cannot be opened again, by you or by anyone at OpenVibe: ask the people who shared it to share it with you again.'));
++ s.body.appendChild(el('p', 'ovl-lead', null, 'For your safety, OpenVibe may ask you to sign in again first.'));
++ const l = el('label', 'ovl-check');
++ const cb = el('input', null, { type: 'checkbox', 'data-k': 'lost-ok' });
++ l.appendChild(cb);
++ l.appendChild(el('span', null, null, 'I understand that what was locked for my old locker is gone.'));
++ s.body.appendChild(l);
++ const go = button('Start a new locker', 'primary', { 'data-k': 'lost-go', disabled: true });
++ cb.addEventListener('change', () => { go.disabled = !cb.checked; });
++ go.addEventListener('click', async () => { s.close(null); const st = await setup({ reset: true }); if (st && typeof o.onDone === 'function') o.onDone(st); });
++ s.body.appendChild(go);
++ };
++ draw('pass');
++ return s.finished;
++ }
++
++ // ── The card a locked item shows ────────────────────────────────────────────────────────────────────────
++ async function open(container, opts) {
++ const o = Object.assign({}, opts || {});
++ const card = el('div', 'ovl-card', { role: 'region', 'aria-label': 'Locked' });
++ container.replaceChildren(card);
++ const top = (title, words, lockIcon) => {
++ card.replaceChildren();
++ card.appendChild(icon(lockIcon || 'lock', 'ovl-ico is-big'));
++ card.appendChild(el('h2', 'ovl-cardtitle', null, title));
++ if (words) card.appendChild(el('p', 'ovl-muted', null, words));
++ };
++ top('Opening…', '');
++ let bytes = o.bytes, c, info;
++ try {
++ c = await core();
++ if (!bytes && o.url) {
++ const r = await fetch(o.url, { credentials: 'include' });
++ if (!r.ok) throw Object.assign(new Error(`The locked item answered ${r.status}`), { status: r.status });
++ bytes = new Uint8Array(await r.arrayBuffer());
++ }
++ info = c.inspect(bytes);
++ } catch (e) { top('This locked item cannot open', e.message); return null; }
++ const done = (plain) => {
++ card.replaceChildren();
++ card.classList.add('is-opened');
++ if (typeof o.onOpen === 'function') o.onOpen(plain, info);
++ doc.dispatchEvent(new CustomEvent('ov-locker:opened', { detail: { lock: info.lock } }));
++ return plain;
++ };
++ const tryOpen = async (credentials) => done(await c.open(bytes, credentials));
++
++ if (info.lock === 'link') {
++ const key = keyFromLink(o.link);
++ if (key) {
++ try { return await tryOpen({ key }); } catch (_) { top('This link does not open it', 'The key in the link is not the one it was locked with. Ask for the link again.'); return null; }
++ }
++ top('This link is missing its key', 'A locked link carries its key after the # at the end. Ask for the whole link: OpenVibe never had the key, so it cannot send it.');
++ return null;
++ }
++
++ if (info.lock === 'password') {
++ top(o.title ? `“${o.title}” is locked` : 'This is locked with a password', 'Type the password you were given. It stays on this device: OpenVibe cannot read what is inside.');
++ const f = el('form', 'ovl-form', { novalidate: true });
++ const p = field('Password', { type: 'password', autocomplete: 'off', 'data-k': 'item-pass' });
++ const msg = el('p', 'ovl-msg', { 'aria-live': 'polite' });
++ const go = button('Open', 'primary', { type: 'submit', 'data-k': 'item-open' });
++ f.appendChild(p.wrap); f.appendChild(msg); f.appendChild(go);
++ card.appendChild(f);
++ return new Promise((resolve) => {
++ f.addEventListener('submit', async (e) => {
++ e.preventDefault();
++ if (!p.input.value) return say(msg, 'Type the password.', true);
++ go.disabled = true; say(msg, 'Opening…');
++ try { resolve(await tryOpen({ password: p.input.value })); }
++ catch (err) { go.disabled = false; say(msg, err.code === 'locker.wrong_key' ? 'That password does not open it.' : `Could not open it: ${err.message}`, true); p.input.select(); }
++ });
++ });
++ }
++
++ // people: your locker opens it, if it is for you.
++ const forMe = async () => {
++ try { return await tryOpen({ person: session.person }); }
++ catch (err) {
++ if (err.code === 'locker.no_slot' || err.code === 'locker.wrong_key') {
++ top('This is not locked for you', 'It is locked for specific people, and your locker is not one of them. Ask the person who shared it.');
++ } else top('This locked item cannot open', err.message);
++ return null;
++ }
++ };
++ if (session) return forMe();
++ top(o.title ? `“${o.title}” is locked for you` : 'This is locked for specific people', 'Unlock your locker on this device to open it. OpenVibe cannot read what is inside.');
++ const go = button('Unlock my locker', 'primary', { 'data-k': 'item-unlock' });
++ card.appendChild(go);
++ return new Promise((resolve) => {
++ go.addEventListener('click', async () => { const st = await unlock(); if (st && st.unlocked) resolve(await forMe()); });
++ });
++ }
++
++ root.OVLocker = { open, setup, unlock, state, forget, keyFromLink, strength, version: 1 };
++})(typeof window !== 'undefined' ? window : null);
ddiff --git a/package-lock.json b/package-lock.json
iindex ba1baee..4039963 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.114.0",
++ "version": "4.115.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.114.0",
++ "version": "4.115.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.289.1",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.86.0"
}
},
"node_modules/@electric-sql/pglite": {
@@@ -1078,9 +1078,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.82.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
-- "integrity": "sha512-oJarlzFGEkqYY0eFumIRjJUqMpOT70kgbhgeDtA8QHnuMZR3VIGQubM2UlOaqXgkxD5uhdZ/G6jLHbjvUhqTLQ==",
++ "version": "0.86.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.86.0",
++ "integrity": "sha512-APn4qK1ErX9f/gR6l2AhGfj+n8/3ul87Otm69xN4wyJUt43WF6A2b0lMKDG9Ds6oQiOoCZJCjxCSanP0wtMuGA==",
"dev": true,
"license": "MIT",
"bin": {
ddiff --git a/package.json b/package.json
iindex dc78fe3..4f8c73b 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.114.0",
++ "version": "4.115.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -57,6 +57,8 @@
"./ov-forms": "./ov-forms.js",
"./ov-qr": "./ov-qr.js",
"./ov-share": "./ov-share.js",
++ "./ov-locker": "./ov-locker.js",
++ "./ov-locker-core": "./ov-locker-core.js",
"./ov-labels": "./ov-labels.js",
"./ov-look": "./ov-look.js",
"./ov-fx": "./ov-fx.js",
@@@ -146,6 +148,6 @@
"@electric-sql/pglite": "^0.5.8",
"express": "^4.22.3",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.86.0"
}
}
ddiff --git a/scripts/build-locker-core.js b/scripts/build-locker-core.js
nnew file mode 100644
iindex 0000000..0460ab5
---- /dev/null
++++ b/scripts/build-locker-core.js
@@@ -0,0 +1,65 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Writes ov-locker-core.js: openvibe-sdk/locker for the browser, as one classic script that sets window.OVLockerCore.
++ * One implementation of the cryptography, not two: this copies the SDK's own src/locker files (WebCrypto only, no
++ * dependency) into a tiny module table, records the SDK version and each file's SHA-256, and changes nothing in them
++ * but the names of require and module (a browser file says neither).
++ * test/ov-locker-core.test.js checks the file is current and runs the SDK's test vectors through it.
++ *
++ * node scripts/build-locker-core.js [<path to openvibe-sdk>] # default: node_modules/openvibe-sdk
++ */
++const fs = require('fs');
++const path = require('path');
++const crypto = require('crypto');
++
++const ROOT = path.join(__dirname, '..');
++// Every file in src/locker, in no particular order (the table resolves requires), except vectors.js, which stays in
++// Node: a new file in the SDK is picked up by itself, so the browser build never lags the module.
++const NODE_ONLY = new Set(['vectors']);
++const filesOf = (dir) => fs.readdirSync(dir).filter((f) => f.endsWith('.js')).map((f) => f.slice(0, -3)).filter((n) => !NODE_ONLY.has(n)).sort();
++
++function build(sdkDir = path.join(ROOT, 'node_modules', 'openvibe-sdk')) {
++ const dir = path.join(sdkDir, 'src', 'locker');
++ const pkg = JSON.parse(fs.readFileSync(path.join(sdkDir, 'package.json'), 'utf8'));
++ const parts = [];
++ const hashes = [];
++ for (const name of filesOf(dir)) {
++ const file = path.join(dir, `${name}.js`);
++ if (!fs.existsSync(file)) throw new Error(`openvibe-sdk ${pkg.version} has no src/locker/${name}.js`);
++ const src = fs.readFileSync(file, 'utf8');
++ const bad = [...src.matchAll(/require\((['"])([^'"]+)\1\)/g)].map((m) => m[2]).filter((r) => !/^\.\/[a-z]+$/.test(r));
++ if (bad.length) throw new Error(`src/locker/${name}.js requires ${bad.join(', ')}: the browser build takes only ./locker files`);
++ hashes.push(`${name}.js ${crypto.createHash('sha256').update(src).digest('hex').slice(0, 16)}`);
++ // The one change: the files' require( and module.exports take the table's own names, so the browser file says neither.
++ const body = src.replace(/\brequire\(/g, '__ovLockerReq(').replace(/\bmodule\.exports\b/g, '__ovLockerModule.exports');
++ parts.push(` ${JSON.stringify(name)}: function (__ovLockerModule, exports, __ovLockerReq) {\n${body}\n },`);
++ }
++ return `/* Generated by scripts/build-locker-core.js from openvibe-sdk ${pkg.version} (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:
++${hashes.map((h) => ` * ${h}`).join('\n')}
++ */
++(function (root) {
++ 'use strict';
++ if (!root || root.OVLockerCore) return;
++ const defs = {
++${parts.join('\n')}
++ };
++ const cache = {};
++ function req(name) {
++ const key = String(name).replace(/^\\.\\//, '');
++ if (!defs[key]) throw new Error('ov-locker-core: no module ' + name);
++ if (!cache[key]) { const m = { exports: {} }; cache[key] = m; defs[key](m, m.exports, req); }
++ return cache[key].exports;
++ }
++ root.OVLockerCore = Object.assign({}, req('index'), { SDK_VERSION: ${JSON.stringify(pkg.version)} });
++})(typeof globalThis !== 'undefined' ? globalThis : this);
++`;
++}
++
++if (require.main === module) {
++ const out = build(process.argv[2]);
++ fs.writeFileSync(path.join(ROOT, 'ov-locker-core.js'), out);
++ console.log(`ov-locker-core.js: ${out.length} bytes`);
++}
++module.exports = { build, filesOf, NODE_ONLY };
ddiff --git a/test/ov-locker-chrome.test.js b/test/ov-locker-chrome.test.js
nnew file mode 100644
iindex 0000000..5168e5d
---- /dev/null
++++ b/test/ov-locker-chrome.test.js
@@@ -0,0 +1,171 @@
++'use strict';
++// ov-locker.js and ov-locker.css in a real headless Chrome, with a stand-in crypto core (the real one, generated from
++// openvibe-sdk/locker, has its own vectors) and a stand-in Network: a password-locked item opens with its password
++// only; a link lock opens with the key after #k= and says so when the key is missing; a people lock asks to unlock the
++// locker; making a locker refuses a weak or mismatched passphrase, shows 24 words once and checks two of them; and,
++// the point of all of it, the passphrase and the recovery words never leave the page, and nothing is stored.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const h = require('../browser-harness');
++
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
++ console.log('ov-locker-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++const ROOT = path.join(__dirname, '..');
++const PASS = 'correct horse battery staple';
++const CORE = `window.OVLockerCore = {
++ WORDS: 'acid atom bold calm cedar coral delta ember fable fern gecko glow harbor iris jade kite lumen maple nectar onyx'.split(' '),
++ async generatePersonKeys() { return { id: 'p1' }; },
++ recoveryWords(n) { const w = this.WORDS; return Array.from({ length: n }, (_, i) => w[(i * 7) % w.length]).join(' '); },
++ async exportPersonKeys() { return { ecdh: { kty: 'EC' }, signing: { kty: 'EC' }, fingerprint_signature: 'sig', fingerprint: 'cedar ember lumen onyx fable maple gecko iris' }; },
++ async exportPrivateKeys() { return { d: 'private' }; },
++ async importPersonKeys() { return { id: 'p1', ecdhPublic: {}, signingPublic: {} }; },
++ async fingerprintOf() { return 'cedar ember lumen onyx fable maple gecko iris'; },
++ async seal(b, o) { return { bytes: new TextEncoder().encode('sealed-by-' + o.lock) }; },
++ inspect(b) { return { lock: new TextDecoder().decode(b).split(':')[0] }; },
++ async open(b, c) {
++ const [lock, secret, body] = new TextDecoder().decode(b).split(':');
++ const bad = () => { throw Object.assign(new Error('no'), { code: 'locker.wrong_key' }); };
++ if (lock === 'password' && c.password !== secret) bad();
++ if (lock === 'link' && !(c.key && c.key.length === 32)) bad();
++ if (lock === 'people' && !c.person) bad();
++ if (lock === 'backup') { if (c.password !== secret) bad(); return new TextEncoder().encode('{"d":"private"}'); }
++ return new TextEncoder().encode(body || 'inside');
++ },
++};`;
++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Locker</title><link rel="icon" href="data:,">
++<link rel="stylesheet" href="/ov-locker.css"></head><body style="font:15px system-ui;padding:16px"><div id="item"></div>
++<script>window.OpenVibeLockerConfig = { network: '' }; window.__opened = []; </script><script src="/ov-locker-core.js"></script><script src="/ov-locker.js"></script></body></html>`;
++const sent = [];
++const server = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ let body = '';
++ req.on('data', (c) => { body += c; });
++ req.on('end', () => {
++ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE); }
++ if (u.pathname === '/ov-locker-core.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(CORE); }
++ if (u.pathname === '/ov-locker.js' || u.pathname === '/ov-locker.css') {
++ res.writeHead(200, { 'content-type': u.pathname.endsWith('.css') ? 'text/css; charset=utf-8' : 'application/javascript; charset=utf-8' });
++ return res.end(fs.readFileSync(path.join(ROOT, u.pathname.slice(1))));
++ }
++ sent.push(`${req.method} ${u.pathname}${u.search} ${body}`);
++ if (u.pathname === '/api/v1/me/keys' && req.method === 'PUT') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end('{"ok":true}'); }
++ if (u.pathname === '/api/v1/me/keys/backup') {
++ res.writeHead(200, { 'content-type': 'application/json' });
++ return res.end(JSON.stringify({ passphrase: { envelope: Buffer.from(`backup:${PASS}`).toString('base64') }, recovery: { envelope: Buffer.from('backup:x').toString('base64') } }));
++ }
++ res.writeHead(404); return res.end();
++ });
++});
++
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-locker-'));
++ const browser = await h.launch({ tmpDir });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ const q = (k) => `document.querySelector('[data-k="${k}"]')`;
++ const type = (k, v) => `(() => { const i = ${q(k)}; i.value = ${JSON.stringify(v)}; i.dispatchEvent(new Event('input', { bubbles: true })); })()`;
++ const submit = (k) => `${q(k)}.click()`;
++ const openItem = (text, extra = '') => `(() => { OVLocker.open(document.getElementById('item'), { bytes: new TextEncoder().encode(${JSON.stringify(text)}), title: 'notes', onOpen: (b) => window.__opened.push(new TextDecoder().decode(b)) ${extra} }); })()`;
++ try {
++ const context = await browser.newContext();
++ const p = await h.openPage(browser, context, { width: 1280 });
++ await p.goto(`${base}/`, { maxMs: 5000 });
++ await wait(150);
++
++ // A password lock: only its password opens it; the card says so plainly.
++ await p.evaluate(openItem('password:hunter22:the notes'));
++ await wait(150);
++ assert.match(await p.evaluate("document.querySelector('.ovl-cardtitle').textContent"), /is locked/);
++ await p.evaluate(type('item-pass', 'wrong')); await p.evaluate(submit('item-open')); await wait(120);
++ assert.match(await p.evaluate("document.querySelector('.ovl-card .ovl-msg').textContent"), /does not open it/);
++ assert.deepStrictEqual(await p.evaluate('window.__opened'), []);
++ await p.evaluate(type('item-pass', 'hunter22')); await p.evaluate(submit('item-open')); await wait(120);
++ assert.deepStrictEqual(await p.evaluate('window.__opened'), ['the notes'], 'the right password opens it');
++
++ // A link lock without its key, then with it.
++ await p.evaluate(openItem('link::linked'));
++ await wait(120);
++ assert.match(await p.evaluate("document.querySelector('.ovl-cardtitle').textContent"), /missing its key/);
++ const key = 'A'.repeat(43);
++ await p.evaluate(openItem('link::linked', `, link: 'https://x.test/p/1#k=${key}'`));
++ await wait(120);
++ assert.deepStrictEqual(await p.evaluate('window.__opened'), ['the notes', 'linked'], 'the key after #k= opens it by itself');
++ assert.strictEqual(await p.evaluate("OVLocker.keyFromLink('https://x.test/#k=short')"), null, 'a key that is not 32 bytes is no key');
++
++ // A people lock: unlock the locker (wrong passphrase first), then it opens.
++ await p.evaluate(openItem('people::for you'));
++ await wait(120);
++ await p.evaluate(submit('item-unlock')); await wait(300);
++ await p.evaluate(type('pass', 'nope')); await p.evaluate(submit('unlock')); await wait(150);
++ assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /does not open your locker/);
++ await p.evaluate(type('pass', PASS)); await p.evaluate(submit('unlock')); await wait(400);
++ assert.strictEqual(await p.evaluate('OVLocker.state().unlocked'), true);
++ assert.deepStrictEqual(await p.evaluate('window.__opened'), ['the notes', 'linked', 'for you'], 'unlocked, it opens');
++ await p.evaluate('OVLocker.forget()');
++ assert.strictEqual(await p.evaluate('OVLocker.state().unlocked'), false, 'forget() drops the keys');
++
++ // Making a locker: weak, then mismatched, then good; 24 words; two checked; saved locked.
++ await p.evaluate('(() => { OVLocker.setup(); })()');
++ await wait(300);
++ await p.evaluate(type('pass', 'short')); await p.evaluate(type('pass2', 'short')); await p.evaluate(submit('next')); await wait(80);
++ assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /longer/, 'a weak passphrase is refused');
++ await p.evaluate(type('pass', 'violet lantern river cobalt')); await p.evaluate(type('pass2', 'violet lantern river cobalT')); await p.evaluate(submit('next')); await wait(80);
++ assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /do not match/);
++ await p.evaluate(type('pass2', 'violet lantern river cobalt')); await p.evaluate(submit('next')); await wait(300);
++ const words = await p.evaluate("[...document.querySelectorAll('.ovl-words li')].map((li) => li.textContent)");
++ assert.strictEqual(words.length, 24, 'the kit is 24 words');
++ const labels = await p.evaluate("[...document.querySelectorAll('dialog .ovl-pair .ovl-label')].map((l) => Number(l.textContent.replace(/\\D/g, '')))");
++ await p.evaluate(type('w1', 'wrong')); await p.evaluate(type('w2', 'words')); await p.evaluate(submit('make')); await wait(100);
++ assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /not the words/);
++ assert.ok(!sent.some((s) => s.startsWith('PUT')), 'nothing is saved before the kit is checked');
++ await p.evaluate(type('w1', words[labels[0] - 1])); await p.evaluate(type('w2', words[labels[1] - 1])); await p.evaluate(submit('make')); await wait(400);
++ assert.strictEqual(await p.evaluate(`${q('fingerprint')}.textContent`), 'cedar ember lumen onyx fable maple gecko iris', 'the fingerprint, eight words');
++ const put = sent.find((s) => s.startsWith('PUT /api/v1/me/keys'));
++ assert.ok(put, 'the locked copies are saved');
++ assert.ok(!put.includes('violet') && !put.includes('lantern'), 'the passphrase never leaves the page');
++ // The fingerprint is public and drawn from the same word list: set it aside before looking for the kit's words.
++ const rest = put.replace(/"fingerprint":"[^"]*"/, '');
++ assert.ok(!words.filter((w) => rest.includes(` ${w} `) || rest.includes(`"${w}`)).length && !put.includes(words.join(' ')), 'nor do the recovery words');
++ assert.ok(!put.includes('"d":"private"') && !put.includes('private'), 'nor the private keys in the clear');
++ assert.match(put, /"backups":\{"passphrase":\{"format":"locker.envelope.v1"/, 'what is saved is two Locker envelopes');
++ assert.match(put, /"self_signature":"sig"/, 'with the keys\u2019 self-signature (the SDK\u2019s fingerprint_signature)');
++ assert.strictEqual(await p.evaluate('localStorage.length + sessionStorage.length'), 0, 'nothing is stored in the browser');
++
++ // Lost both: a plain warning, a checkbox that gates the button, then a new locker saved with reset: true.
++ await p.evaluate('OVLocker.forget()');
++ await p.evaluate("document.querySelectorAll('dialog').forEach((d) => d.remove())");
++ await p.evaluate('(() => { OVLocker.unlock(); })()'); await wait(300);
++ await p.evaluate(submit('other')); await wait(80);
++ await p.evaluate(submit('lost')); await wait(80);
++ assert.match(await p.evaluate("document.querySelector('dialog .ovl-body').textContent"), /cannot be opened again/, 'it says what is lost');
++ assert.strictEqual(await p.evaluate(`${q('lost-go')}.disabled`), true, 'nothing starts until the person says they understand');
++ await p.evaluate(`${q('lost-ok')}.click()`); await p.evaluate(submit('lost-go')); await wait(400);
++ await p.evaluate(type('pass', 'amber window tide falcon')); await p.evaluate(type('pass2', 'amber window tide falcon')); await p.evaluate(submit('next')); await wait(300);
++ const w2 = await p.evaluate("[...document.querySelectorAll('.ovl-words li')].map((li) => li.textContent)");
++ const l2 = await p.evaluate("[...document.querySelectorAll('dialog .ovl-pair .ovl-label')].map((l) => Number(l.textContent.replace(/\\D/g, '')))");
++ await p.evaluate(type('w1', w2[l2[0] - 1])); await p.evaluate(type('w2', w2[l2[1] - 1])); await p.evaluate(submit('make')); await wait(400);
++ assert.match(sent.filter((x) => x.startsWith('PUT')).pop(), /"reset":true/, 'a new locker after losing both says reset');
++
++ // A phone: the kit fits, no sideways scroll.
++ const phone = await h.openPage(browser, context, { width: 390 });
++ await phone.goto(`${base}/`, { maxMs: 5000 });
++ await phone.evaluate('(() => { OVLocker.setup(); })()');
++ await wait(300);
++ await phone.evaluate(type('pass', 'violet lantern river cobalt')); await phone.evaluate(type('pass2', 'violet lantern river cobalt')); await phone.evaluate(submit('next')); await wait(300);
++ assert.strictEqual(await phone.evaluate('document.documentElement.scrollWidth'), 390);
++ assert.ok(await phone.evaluate("(() => { const d = document.querySelector('dialog').getBoundingClientRect(); return [...document.querySelectorAll('dialog input')].every((i) => i.getBoundingClientRect().right <= d.right + 0.5); })()"), 'every field fits inside the sheet');
++ await phone.close();
++ console.log('ov-locker-chrome: password, link and people locks; making and unlocking a locker; nothing secret leaves the page or is stored; the phone ok');
++ } finally {
++ await browser.close();
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/ov-locker-core.test.js b/test/ov-locker-core.test.js
nnew file mode 100644
iindex 0000000..d2b7fe9
---- /dev/null
++++ b/test/ov-locker-core.test.js
@@@ -0,0 +1,58 @@
++'use strict';
++// ov-locker-core.js is openvibe-sdk/locker for the browser, generated, never edited (scripts/build-locker-core.js):
++// the file is what the pinned SDK builds to, it runs where only browser globals exist (a VM with WebCrypto and the
++// encoding and stream classes, no require, no Buffer, no process), and the SDK's own test vectors open through it.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++const { build } = require('../scripts/build-locker-core');
++
++const ROOT = path.join(__dirname, '..');
++const file = fs.readFileSync(path.join(ROOT, 'ov-locker-core.js'), 'utf8');
++assert.strictEqual(file, build(), 'ov-locker-core.js is current: run node scripts/build-locker-core.js');
++
++const ctx = { crypto: globalThis.crypto, TextEncoder, TextDecoder, ReadableStream, TransformStream, WritableStream, Uint8Array, ArrayBuffer, DataView, console, btoa, atob };
++ctx.globalThis = ctx;
++vm.createContext(ctx);
++vm.runInContext(file, ctx);
++const C = ctx.OVLockerCore;
++const sdk = require('openvibe-sdk/package.json');
++assert.strictEqual(C.SDK_VERSION, sdk.version, 'built from the pinned SDK');
++['seal', 'open', 'inspect', 'recoveryWords', 'generatePersonKeys', 'exportPersonKeys', 'exportPrivateKeys', 'importPersonKeys', 'fingerprintOf'].forEach((fn) => {
++ assert.strictEqual(typeof C[fn], 'function', `the core has ${fn} (ov-locker.js calls it)`);
++});
++
++(async () => {
++ // The SDK's shared vectors, made again by the browser build: the same headers, the same envelope bytes (SHA-256),
++ // the same fingerprint, recovery phrase and inspect answers, as test/locker.test.js checks in the SDK.
++ const vectors = require('openvibe-sdk/locker/vectors');
++ const sha256 = (b) => require('crypto').createHash('sha256').update(Buffer.from(b)).digest('hex');
++ const P = vectors.PERSON;
++ const random = vectors.fixedRandom();
++ const made = {
++ people: await C.seal(vectors.CONTENT, { lock: 'people', keys: [{ subject: vectors.SUBJECT, publicKey: P.ecdhPublic }], random, ephemeral: { private: P.ecdh } }),
++ password: await C.seal(vectors.CONTENT, { lock: 'password', password: 'correct horse battery staple', random }),
++ link: await C.seal(vectors.CONTENT, { lock: 'link', random }),
++ };
++ let opened = 0;
++ vectors.CASES.forEach((tc) => {
++ assert.deepStrictEqual(JSON.parse(JSON.stringify(made[tc.name].header)), tc.header, `the ${tc.name} header`);
++ assert.strictEqual(sha256(made[tc.name].bytes), tc.envelope_sha256, `the ${tc.name} envelope, the same bytes`);
++ assert.deepStrictEqual(JSON.parse(JSON.stringify(C.inspect(made[tc.name].bytes))), vectors.INSPECT[tc.name], `inspect on ${tc.name}`);
++ opened++;
++ });
++ assert.strictEqual(await C.fingerprintOf(P.ecdhPublic, P.signingPublic), P.fingerprint, 'the same fingerprint');
++ assert.strictEqual(C.recoveryWords(24, { random: vectors.fixedRandom(0x5eed1e) }), vectors.RECOVERY.phrase, 'the same recovery phrase');
++ const keysBack = await C.importPersonKeys({ ecdh: P.ecdh, signing: P.signing });
++ assert.strictEqual(new TextDecoder().decode(await C.open(made.people.bytes, { person: keysBack })), vectors.CONTENT, 'the people vector opens with its person');
++ // A round trip made in the browser build opens in Node's SDK, and the other way round.
++ const node = require('openvibe-sdk/locker');
++ const crossed = await C.seal(new TextEncoder().encode('from the browser'), { lock: 'password', password: 'violet lantern river cobalt' });
++ assert.strictEqual(Buffer.from(await node.open(crossed.bytes, { password: 'violet lantern river cobalt' })).toString(), 'from the browser');
++ const back = await node.seal(Buffer.from('from node'), { lock: 'password', password: 'violet lantern river cobalt' });
++ assert.strictEqual(new TextDecoder().decode(await C.open(back.bytes, { password: 'violet lantern river cobalt' })), 'from node');
++ const kit = C.recoveryWords(24);
++ assert.strictEqual(String(kit).split(' ').length, 24, 'a recovery kit is 24 words');
++ console.log(`ov-locker-core: current with openvibe-sdk ${sdk.version}; ${opened} vectors made again, the same bytes, in a browser-only VM; browser and Node envelopes cross both ways`);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/ov-locker-e2e-chrome.test.js b/test/ov-locker-e2e-chrome.test.js
nnew file mode 100644
iindex 0000000..005bc04
---- /dev/null
++++ b/test/ov-locker-e2e-chrome.test.js
@@@ -0,0 +1,121 @@
++'use strict';
++// Locker end to end in a real headless Chrome with the REAL crypto (ov-locker-core.js, generated from
++// openvibe-sdk/locker) and a stand-in Network that only stores what it is given:
++// make a locker (passphrase + recovery kit) → the page sends public keys and two locked backups;
++// a second, empty tab (a new device) unlocks with the passphrase from the stored backup;
++// an item locked in Node for that person's public key opens in the browser; a stranger's locker cannot open it;
++// a password-locked item opens with its password only; the recovery words open the locker too.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const h = require('../browser-harness');
++
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
++ console.log('ov-locker-e2e-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++const ROOT = path.join(__dirname, '..');
++const locker = require('openvibe-sdk/locker');
++const PASS = 'violet lantern river cobalt';
++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Locker</title><link rel="icon" href="data:,">
++<link rel="stylesheet" href="/ov-locker.css"></head><body style="font:15px system-ui;padding:16px"><div id="item"></div>
++<script>window.OpenVibeLockerConfig = { network: '' }; window.__opened = [];</script><script src="/ov-locker-core.js"></script><script src="/ov-locker.js"></script></body></html>`;
++let stored = null; // what Network keeps: the PUT body
++const items = {}; // locked items served as bytes
++const server = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ let body = '';
++ req.on('data', (c) => { body += c; });
++ req.on('end', () => {
++ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE); }
++ if (['/ov-locker.js', '/ov-locker.css', '/ov-locker-core.js'].includes(u.pathname)) {
++ res.writeHead(200, { 'content-type': u.pathname.endsWith('.css') ? 'text/css; charset=utf-8' : 'application/javascript; charset=utf-8' });
++ return res.end(fs.readFileSync(path.join(ROOT, u.pathname.slice(1))));
++ }
++ 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 }));
++ }
++ 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();
++ });
++});
++
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-locker-e2e-'));
++ const browser = await h.launch({ tmpDir });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ const q = (k) => `document.querySelector('[data-k="${k}"]')`;
++ const type = (k, v) => `(() => { const i = ${q(k)}; i.value = ${JSON.stringify(v)}; i.dispatchEvent(new Event('input', { bubbles: true })); })()`;
++ const press = (k) => `${q(k)}.click()`;
++ try {
++ const context = await browser.newContext();
++ // Device one: make the locker with the real crypto.
++ const a = await h.openPage(browser, context, { width: 1280 });
++ await a.goto(`${base}/`, { maxMs: 5000 });
++ await a.evaluate('(() => { OVLocker.setup(); })()'); await wait(300);
++ await a.evaluate(type('pass', PASS)); await a.evaluate(type('pass2', PASS)); await a.evaluate(press('next'));
++ await wait(1500); // real key generation
++ const words = await a.evaluate("[...document.querySelectorAll('.ovl-words li')].map((li) => li.textContent)");
++ assert.strictEqual(words.length, 24, 'a real recovery kit of 24 words');
++ assert.ok(locker.isRecoveryPhrase(words.join(' ')), 'every word is from the list');
++ const labels = await a.evaluate("[...document.querySelectorAll('dialog .ovl-pair .ovl-label')].map((l) => Number(l.textContent.replace(/\\D/g, '')))");
++ await a.evaluate(type('w1', words[labels[0] - 1])); await a.evaluate(type('w2', words[labels[1] - 1])); await a.evaluate(press('make'));
++ await wait(9000); // two PBKDF2 seals at 600 000 iterations
++ assert.ok(stored, 'the locker was saved');
++ const fp = await a.evaluate(`${q('fingerprint')}.textContent`);
++ assert.strictEqual(fp, await locker.fingerprintOf(stored.ecdh_public, stored.signing_public), 'the fingerprint shown is the keys’ own');
++ assert.ok(await locker.verifyPersonKeys({ ecdh: stored.ecdh_public, signing: stored.signing_public, fingerprint_signature: stored.self_signature, fingerprint: fp }), 'the self-signature verifies');
++ const body = JSON.stringify(stored);
++ assert.ok(!body.includes(PASS) && !body.includes(words.join(' ')), 'neither the passphrase nor the recovery words were sent');
++ ['passphrase', 'recovery'].forEach((k) => assert.strictEqual(locker.inspect(Buffer.from(stored.backups[k].envelope, 'base64')).lock, 'password', `the ${k} backup is a password-locked envelope`));
++
++ // An item locked in Node for that person, and one for a stranger.
++ const mine = await locker.seal(Buffer.from('for you only'), { lock: 'people', keys: [{ subject: { type: 'user', id: 'usr_x' }, publicKey: stored.ecdh_public }] });
++ items.mine = mine.bytes;
++ const stranger = await locker.generatePersonKeys();
++ const strangerPub = await locker.exportPersonKeys(stranger);
++ items.theirs = (await locker.seal(Buffer.from('not yours'), { lock: 'people', keys: [{ subject: { type: 'user', id: 'usr_y' }, publicKey: strangerPub.ecdh }] })).bytes;
++ items.pw = (await locker.seal(Buffer.from('with a password'), { lock: 'password', password: 'hunter22 hunter22' })).bytes;
++
++ // Device two: an empty tab unlocks from the backup with the passphrase, then opens the item.
++ const b = await h.openPage(browser, await browser.newContext(), { width: 390 });
++ await b.goto(`${base}/`, { maxMs: 5000 });
++ await b.evaluate(`(() => { OVLocker.open(document.getElementById('item'), { url: '/item/mine', onOpen: (x) => window.__opened.push(new TextDecoder().decode(x)) }); })()`);
++ await wait(300);
++ await b.evaluate(press('item-unlock')); await wait(300);
++ await b.evaluate(type('pass', PASS)); await b.evaluate(press('unlock'));
++ await wait(5000);
++ if (process.env.LOCKER_E2E_DEBUG) console.log('DEBUG', await b.evaluate("JSON.stringify({ card: (document.querySelector('.ovl-card') || {}).textContent, msg: (document.querySelector('dialog .ovl-msg') || {}).textContent, state: OVLocker.state() })"));
++ assert.deepStrictEqual(await b.evaluate('window.__opened'), ['for you only'], 'a new device unlocks with the passphrase and opens what was locked for this person');
++ await b.evaluate(`(() => { OVLocker.open(document.getElementById('item'), { url: '/item/theirs' }); })()`);
++ await wait(800);
++ assert.match(await b.evaluate("document.querySelector('.ovl-cardtitle').textContent"), /not locked for you/, 'someone else’s item says so');
++ await b.evaluate(`(() => { OVLocker.open(document.getElementById('item'), { url: '/item/pw', onOpen: (x) => window.__opened.push(new TextDecoder().decode(x)) }); })()`);
++ await wait(300);
++ await b.evaluate(type('item-pass', 'wrong one')); await b.evaluate(press('item-open')); await wait(3500);
++ assert.match(await b.evaluate("document.querySelector('.ovl-card .ovl-msg').textContent"), /does not open it/);
++ await b.evaluate(type('item-pass', 'hunter22 hunter22')); await b.evaluate(press('item-open')); await wait(3500);
++ assert.deepStrictEqual(await b.evaluate('window.__opened'), ['for you only', 'with a password'], 'the password item opens with its password');
++
++ // Device three: the recovery words open the locker too.
++ const c = await h.openPage(browser, await browser.newContext(), { width: 1280 });
++ await c.goto(`${base}/`, { maxMs: 5000 });
++ await c.evaluate('(() => { OVLocker.unlock(); })()'); await wait(300);
++ await c.evaluate(press('other')); await wait(80);
++ 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');
++ 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();
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });