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)
Changes
@@@ -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@@@ -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 deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -902,6 +902,13 @@ so forms, `select.value` and `change` listeners work as before. An option can saswitch; `.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 -->@@@ -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",nnew file mode 100644@@@ -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`@@@ -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)nnew file mode 100644@@@ -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);nnew file mode 100644@@@ -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; } }nnew file mode 100644@@@ -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);@@@ -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": {@@@ -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"}}nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });
@@ -1,5 +1,26 @@
@@ -1,5 +1,26 @@
# Changelog
# 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
## 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
- **One Share sheet on every site** (`ov-share.js`, `ov-share.css`; plan track Q; docs/share.md). Press Share
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.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
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -902,6 +902,13 @@ so forms, `select.value` and `change` listeners work as before. An option can sa
@@ -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
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).
`<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`)
### 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.
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 [
@@ -1079,5 +1086,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.289.1
- openvibe-contracts: v0.289.1
-- openvibe-sdk: v0.82.0
+- openvibe-sdk: v0.86.0
<!-- versions:end -->
<!-- versions:end -->
@@ -4,12 +4,12 @@
@@ -4,12 +4,12 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.114.0",
+ "version": "4.115.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"contracts": "openvibe-contracts v0.289.1 (devDependency: the release manifest contract)",
"contracts": "openvibe-contracts v0.289.1 (devDependency: the release manifest contract)",
- "sdk": "openvibe-sdk v0.82.0",
+ "sdk": "openvibe-sdk v0.86.0",
"features": [
"features": [
"the OpenVibe Frame: navbar, footer, panels, account switcher, notification UI with realtime",
"the OpenVibe Frame: navbar, footer, panels, account switcher, notification UI with realtime",
"the launcher beside the brand lists every site of the network, in groups; Ask Actor and the Vibes chip in the account menu",
"the launcher beside the brand lists every site of the network, in groups; Ask Actor and the Vibes chip in the account menu",
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -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`
@@ -35,6 +35,8 @@ const BROWSER = Object.freeze([
@@ -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-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-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-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-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-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)
'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)
@@ -75,6 +77,7 @@ const STYLES = Object.freeze([
@@ -75,6 +77,7 @@ const STYLES = Object.freeze([
'player.css', // player.js's controls
'player.css', // player.js's controls
'kit.css', // the OpenVibe Kit's look (kit.js / openvibe-shared/kit build the markup; docs/kit.md)
'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-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)
'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)
'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)
'thread.css', // the conversation UI's look (openvibe-shared/thread, ov-thread.js; plan L55 C4)
new file mode 100644
new file mode 100644
@@ -0,0 +1,1690 @@
@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,123 @@
@@ -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; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,454 @@
@@ -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);
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.114.0",
+ "version": "4.115.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.114.0",
+ "version": "4.115.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
"express": "^4.22.3",
"express": "^4.22.3",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.86.0"
}
}
},
},
"node_modules/@electric-sql/pglite": {
"node_modules/@electric-sql/pglite": {
@@ -1078,9 +1078,9 @@
@@ -1078,9 +1078,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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,
"dev": true,
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -57,6 +57,8 @@
@@ -57,6 +57,8 @@
"./ov-forms": "./ov-forms.js",
"./ov-forms": "./ov-forms.js",
"./ov-qr": "./ov-qr.js",
"./ov-qr": "./ov-qr.js",
"./ov-share": "./ov-share.js",
"./ov-share": "./ov-share.js",
+ "./ov-locker": "./ov-locker.js",
+ "./ov-locker-core": "./ov-locker-core.js",
"./ov-labels": "./ov-labels.js",
"./ov-labels": "./ov-labels.js",
"./ov-look": "./ov-look.js",
"./ov-look": "./ov-look.js",
"./ov-fx": "./ov-fx.js",
"./ov-fx": "./ov-fx.js",
@@ -146,6 +148,6 @@
@@ -146,6 +148,6 @@
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
"express": "^4.22.3",
"express": "^4.22.3",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.86.0"
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,171 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -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); });