openvibers/openvibe.sdk Commits

Add the Locker library: end-to-end encryption for anything (#119)

OP OpenVibers committed 7 days ago · 011dec216e · 39 files, +4907 −76

* Add transactional sharing core and agent client

* Add the Locker library: end-to-end encryption for anything

openvibe-sdk/locker reads and writes the Locker envelope (locker.envelope@1)
in Node and in the browser, with WebCrypto only: globalThis.crypto.subtle, no
other crypto library, nothing hand-rolled.

seal and open take text or bytes; sealStream and openStream take a stream and
hold one 1 MiB chunk at a time, so a 64 MiB file costs a few MiB of memory and
never fits in one. The three locks are people (the content key is wrapped for
each recipient with ECDH P-256 and HKDF-SHA-256), password (PBKDF2-SHA-256,
600000 iterations, the parameters read from the header) and link (the key
travels in the part of the link after the #, which browsers never send to us).

A person has two key pairs: ECDH P-256 for content keys and ECDSA P-256 for
the fingerprint. The fingerprint is four words, short enough to read out loud
and compare once, and the signature over both public keys proves the holder
owns the signing key.

wrapFor and rewrap let a member be added to a people-locked item without
touching the content: the slots are not part of what the chunks authenticate,
so the header changes and the chunks do not.

openvibe-sdk/locker/server gives a service the header's shape and the
envelope's size, with no key and no decryption, so a service can hold bytes it
can never read. openvibe-sdk/locker/vectors holds the shared test vectors, so
the browser module runs the same cases and gets the same bytes.

The tests are the point: round trips for every lock and for the sizes around
the chunk boundary, and a header that was changed, a chunk that was changed,
chunks in the wrong order, a missing last chunk, a chunk that claims to be
huge and the wrong key all fail.

* Regenerate the browser bundle with the locker fixes

* Merge main into feat/locker-core-sdk

The sharing core landed in main (#118) while this branch was open, so main is merged in. The sharing content is identical to what the branch was built on, so the merge changes no file: the diff against main is the Locker work alone.

* Refresh the contracts tarball hash after the branch moved

The openvibe-contracts dependency is pinned to the Locker contracts branch until that branch is tagged, so every push to the branch changes the tarball. The lockfile still named the old hash and npm ci failed on it in CI; this is the hash of the branch's current tip.

* Take the sharing files from main after the merge

The sharing core was merged as a squash (#118), so main carries the sharing job's later fixes. The merge into this branch resolved the shared files to the older copy the branch was built on, which would have reverted those fixes; they now come from main, and the changelog, the readme and STATUS.json are main's with the Locker lines added.

* Bind the Locker slots to a key, pad with Padme and widen the fingerprint

Opus read the Locker core line by line; the framing, the AAD, the chunk checks and the wrapping are right. Five things are fixed here.

The fingerprint was 32 bits, so a server that swapped in its own key needed about 2^32 key generations to match it. It is 64 bits now: eight words, the first eight bytes of SHA-256 over the two public keys, in two groups of four.

The PBKDF2 count came from the header unchecked, so a hostile header could ask for a derivation that takes hours. It is bounded on both sides now, 600000 to 10000000, and a count outside that is refused with locker.bad_envelope before anything is derived.

The padded size was a whole number of 1 MiB chunks, so a 200-byte note cost a megabyte. It is padded with Padme (PoPETs 2019) now: at most about twelve per cent over the real size, never below 256 bytes, and O(log log n) bits of leakage. Every chunk but the last is a whole chunk; the last holds the rest, and the reader checks each chunk against the size the header says.

A people slot was bound to a subject but not to a key. Each slot now carries recipient_key, the RFC 7638 thumbprint of the recipient's ECDH public key, and the thumbprint is in the HKDF info and in the slot's associated data; the reader checks it before it derives anything.

And the streams say plainly what they do: the chunks are yielded as they are verified, so a stream that ends early throws at the end. Discard what it yielded: the content was never complete.

The shared vectors are regenerated (scripts/make-vectors.js writes them), and rewrap now re-seals with a new content key when a member is removed, so the key the person who left holds opens nothing.

* Keep the old vector's key in the gitleaks allowlist

The secret scan reads the branch's whole history, so the wrapped key the first version of the vectors had is still in it. The allowlist covers it by value, like the current one.

* Add the recovery phrase, opening by key and inspect

Three things the browser page needs.

recoveryWords(n) gives a phrase of words from the same 256-word list the fingerprint uses, one byte per word from the platform's CSPRNG, so the usual 24 words carry 192 bits. isRecoveryPhrase says whether every word of a typed phrase is one of the 256, and WORDS is the list itself.

A people lock opens by key now: open(bytes, { person }) finds the slot that names the reader's key, so no subject is needed. An envelope with no slot for that key fails with locker.no_slot, a new code that says the key is not on the list; locker.wrong_key is for a key that is on it.

inspect(bytes) reads the header and nothing else: the lock, the version, the padded size, the recipients' key thumbprints and the password's iteration count. No key, no decryption.

And the fingerprint's two groups of four are joined by a middle dot, not a hyphen: a hyphen reads as one compound word, and the groups are meant to be read apart. The contracts follow (Contracts #302).

The shared vectors carry all of it: the recovery phrase, what inspect says about each case, and the new fingerprint.

* Pin openvibe-contracts v0.292.0 (Locker released)

Changes

ddiff --git a/.gitleaks.toml b/.gitleaks.toml
nnew file mode 100644
iindex 0000000..1143628
---- /dev/null
++++ b/.gitleaks.toml
@@@ -0,0 +1,17 @@
++# gitleaks configuration, used by the shared security workflow
++# (OpenVibers/OpenVibe.Shared/.github/workflows/security.yml). The default rules stay on; every
++# allowlist below is one proven false positive, matched on path AND value so nothing else is hidden.
++[extend]
++useDefault = true
++
++[[allowlists]]
++description = "The test vectors' wrapped content keys and the recipient key thumbprint: made-up base64 in the file that exists to be checked"
++condition = "AND"
++paths = ['''^src/locker/vectors\.js$''']
++regexes = ['''^(IpXVDIbStzw/kky7eKnrPQ7SOufRZ8zjU8iWB3c59OhfRjKLXuwDs4NQ4OlMAtGN|t9FBCQEiSb87EYFaqq9yjm45qITqW8p8qMoz49vKUhbbUznoz9T0iwJXSmBBaTLN|pnLvXu5bTR3lmWGT2GFfWc2v43dG67dM7lrStqWOPrL4f\+SI6YivVWwXK0BvVjiR|TCGh2gH6HIuT0-A9Ge0jOTrFmRXWQkdA6fZG8JxikTA)$''']
++
++[[allowlists]]
++description = "The name of the password key derivation, in a test that reads it from the header"
++condition = "AND"
++paths = ['''^test/locker\.test\.js$''']
++regexes = ['''^PBKDF2-SHA-256$''']
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex dbecc70..f4f489a 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,23 @@
# Changelog
++## 0.86.0 (2026-11-06)
++
++- Add `openvibe-sdk/locker`: the Locker envelope (`locker.envelope@1`), end-to-end encryption for anything on OpenVibe, in Node and in the browser. WebCrypto only (`globalThis.crypto.subtle`), no other crypto library and nothing hand-rolled.
++ - `seal` and `open` for text and bytes, and `sealStream` and `openStream` for files: a stream holds one 1 MiB chunk at a time, so a 64 MiB file costs a few MiB of memory. A stream that ends early throws at the end: discard what it yielded.
++ - The size is padded with Padmé (PoPETs 2019): at most about twelve per cent over the real size, never below 256 bytes, and O(log log n) bits of leakage. Every chunk but the last is a whole chunk; the last holds the rest.
++ - A people slot names the recipient's key by its RFC 7638 thumbprint, and the reader checks it before it derives anything.
++ - The PBKDF2 iteration count is bounded on both sides: 600000 to 10000000, so a hostile header cannot make a reader work for hours.
++ - The three locks: `people` (the content key is wrapped for each recipient with ECDH P-256 and HKDF-SHA-256), `password` (PBKDF2-SHA-256, 600000 iterations, the parameters read from the header) and `link` (the key travels in the part of the link after the `#`).
++ - Person key pairs (ECDH P-256 for content keys, ECDSA P-256 for the fingerprint), the eight-word fingerprint (sixty-four bits, in two groups of four) and the signature that proves a person owns their signing key.
++ - `wrapFor` and `rewrap`: adding a member to a people-locked item re-wraps the content key and leaves the content alone.
++- Add `openvibe-sdk/locker/server`: what a service needs to know about an envelope without opening it, the header's shape and the envelope's size, with no key and no decryption.
++- Add `openvibe-sdk/locker/vectors`: the shared test vectors, so the browser module (`openvibe-shared/ov-locker.js`) runs the same cases and gets the same bytes.
++- Add `recoveryWords(n)`: a phrase of words from the same 256-word list, one byte per word from the platform's CSPRNG, so 24 words carry 192 bits. `isRecoveryPhrase` says whether every word of a typed phrase is one of the 256, and `WORDS` is the list itself.
++- A people lock can now be opened by key: `open(bytes, { person })` finds the slot that names the reader's key, without a subject. An envelope with no slot for that key fails with the new code `locker.no_slot`, which says the key is not on the list (`locker.wrong_key` is for a key that is on it).
++- Add `inspect(bytes)`: what an envelope says about itself — the lock, the version, the padded size, the recipients' key thumbprints and the password's iteration count — without a key and without decrypting anything.
++- The fingerprint's two groups of four are joined by a middle dot, not a hyphen: a hyphen reads as one compound word.
++- Pins openvibe-contracts v0.292.0 (locker.envelope.v1 and the key directory API).
++
## 0.85.0 (2026-11-05)
- Add server-only `openvibe-sdk/share`: PostgreSQL/PGlite store and expand-only migration, audience/role checks, exact name resolution with persistent per-caller limits, expiring membership, access requests, and recipient-bound seven-day ownership transfers. Each mutation and its append-only audit and delivery records commit atomically. Transactional owner/audience hooks update local resources.
ddiff --git a/README.md b/README.md
iindex 91ed558..51db9cc 100644
---- a/README.md
++++ b/README.md
@@@ -2,16 +2,16 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.85.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
++**Status:** alpha, v0.86.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
--After v0.85.0 is tagged:
++After v0.86.0 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.85.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.86.0
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).
@@@ -564,9 +564,13 @@ Nothing at runtime. `openvibe-contracts` and `@electric-sql/pglite` are devDepen
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
--- openvibe-contracts: v0.291.0
++- openvibe-contracts: v0.292.0
<!-- versions:end -->
### Network-wide sharing (Node)
`openvibe-sdk/share` provides local access enforcement, expiring memberships and confirmed ownership transfer. See [the sharing module](docs/share.md) for integration, API signatures, migrations and the uniform OpenAPI fragment. Agents use `require('openvibe-sdk').share({ base, token }).for('paste', id)`.
++
++### Locker: end-to-end encryption (Node and the browser)
++
++`openvibe-sdk/locker` seals anything and opens it again: the Locker envelope (`locker.envelope@1`), the three locks (people, password, link), streams for files, person key pairs and the eight-word fingerprint, `wrapFor` and `rewrap` for sharing, `recoveryWords` for the phrase to write down, `inspect` for what an envelope says about itself, and opening a people lock by key. `openvibe-sdk/locker/server` gives a service the header checks without a key. WebCrypto only, in Node and the browser. See [the locker module](docs/locker.md).
ddiff --git a/STATUS.json b/STATUS.json
iindex b6eb051..b9209be 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,12 +3,13 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.85.0",
++ "version": "0.86.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), \u00a73.2; roadmap \u00a730",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
-- "contracts": "openvibe-contracts v0.291.0",
++ "contracts": "openvibe-contracts v0.292.0",
"features": [
++ "locker (openvibe-sdk/locker, openvibe-sdk/locker/server): the Locker envelope (locker.envelope@1) for end-to-end encryption, WebCrypto only, in Node and the browser: seal/open for text and bytes, sealStream/openStream, the three locks (people, password, link), person key pairs (ECDH P-256, ECDSA P-256), the eight-word fingerprint, wrapFor and rewrap, recoveryWords for the phrase to write down, opening a people lock by key, inspect for what an envelope says about itself, and the header checks a service runs without a key",
"share (openvibe-sdk/share): transactional local sharing, audience and membership checks, exact-name resolution, recipient-bound ownership transfers, audit, durable events/notifications, uniform API and agent client",
"redact (openvibe-sdk/redact): the shared secret scrubber for memory, the same shapes and corpus as OpenVibe.Node's internal/memory/redact.go (redact, onlySecret)",
"core client: retries, timeouts, problem+json errors, trace propagation",
@@@ -40,5 +41,5 @@
"openvibe-sdk/machines: the network's machines hub (list, get, catalogue, pair, rename, roles, revoke) against OpenVibe.Network, for a person and for the apps they consented to",
"vibes (openvibe-sdk/vibes): Vibes Pay charges (create with the app reference as the Idempotency-Key, get, refund) and constant-time webhook verification (X-OpenVibe-Signature-V2, \u00b1300 s) for developer apps"
],
-- "updated": "2026-11-05"
++ "updated": "2026-11-06"
}
ddiff --git a/browser.js b/browser.js
iindex 4973c72..66af843 100644
---- a/browser.js
++++ b/browser.js
@@@ -8,6 +8,7 @@ const core = require('./src/core');
module.exports = {
...core,
auth: require('./src/auth/browser'),
++ locker: require('./src/locker'),
registry: require('./src/registry'),
modules: require('./src/modules'),
realtime: require('./src/realtime'),
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 4f45a4a..9ff5f20 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,6 +1,6 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.85.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.86.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
--// (auth, registry, modules, realtime, media, community, bot, search, bench, meme, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
++// (auth, locker, registry, modules, realtime, media, community, bot, search, bench, meme, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
const __defs = [
// ── 0: browser.js ──────────────────────────────────────────────────
@@@ -15,24 +15,25 @@ const core = __load(1);
module.exports = {
...core,
auth: __load(8),
-- registry: __load(9),
-- modules: __load(10),
-- realtime: __load(11),
-- media: __load(12),
-- community: __load(14),
-- bot: __load(15),
-- search: __load(16),
-- bench: __load(17),
-- meme: __load(18),
-- vision: __load(19),
-- themes: __load(20),
-- redact: __load(21),
-- chat: __load(22),
-- ai: __load(23),
-- jobs: __load(24),
-- tools: __load(26),
-- projects: __load(27),
-- geo: __load(28),
++ locker: __load(9),
++ registry: __load(20),
++ modules: __load(21),
++ realtime: __load(22),
++ media: __load(23),
++ community: __load(25),
++ bot: __load(26),
++ search: __load(27),
++ bench: __load(28),
++ meme: __load(29),
++ vision: __load(30),
++ themes: __load(31),
++ redact: __load(32),
++ chat: __load(33),
++ ai: __load(34),
++ jobs: __load(35),
++ tools: __load(37),
++ projects: __load(38),
++ geo: __load(39),
};
},
// ── 1: src/core/index.js ───────────────────────────────────────────
@@@ -47,7 +48,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.85.0';
++const SDK_VERSION = '0.86.0';
module.exports = {
createClient,
@@@ -838,7 +839,1519 @@ function unverifiedClaims(token) {
module.exports = { createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization, readCallback, base64url, decodeUnverified, unverifiedClaims };
},
--// ── 9: src/registry.js ─────────────────────────────────────────────
++// ── 9: src/locker.js ───────────────────────────────────────────────
++function (module, exports) {
++'use strict';
++/**
++ * openvibe-sdk/locker: the subpath entry. The format, the three locks, the streams and the
++ * keys. WebCrypto only, in Node and in the browser.
++ */
++module.exports = __load(10);
++},
++// ── 10: src/locker/index.js ─────────────────────────────────────────
++function (module, exports) {
++'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 = __load(11);
++const keys = __load(15);
++const seal = __load(17);
++const { padme } = __load(14);
++const { inspect } = __load(18);
++const { recoveryWords, isRecoveryPhrase, RECOVERY_WORDS } = __load(19);
++const { WORDS } = __load(16);
++const { LockerError } = __load(13);
++
++module.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,
++};
++},
++// ── 11: src/locker/format.js ────────────────────────────────────────
++function (module, exports) {
++'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 } = __load(12);
++const { fail } = __load(13);
++const { padme } = __load(14);
++
++/** 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;
++}
++
++module.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,
++};
++},
++// ── 12: src/locker/bytes.js ─────────────────────────────────────────
++function (module, exports) {
++'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(',')}}`;
++}
++
++module.exports = {
++ utf8, fromUtf8, concat, base64, fromBase64, base64url, fromBase64url,
++ u32be, readU32be, u64be, readU64be, canonicalJson,
++};
++},
++// ── 13: src/locker/errors.js ────────────────────────────────────────
++function (module, exports) {
++'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); }
++
++module.exports = { LockerError, fail };
++},
++// ── 14: src/locker/padme.js ─────────────────────────────────────────
++function (module, exports) {
++'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;
++}
++
++module.exports = { padme, exponentOf };
++},
++// ── 15: src/locker/keys.js ──────────────────────────────────────────
++function (module, exports) {
++'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 } = __load(12);
++const { fail } = __load(13);
++const { WORDS } = __load(16);
++
++/** 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');
++ }
++}
++
++module.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,
++};
++},
++// ── 16: src/locker/words.js ─────────────────────────────────────────
++function (module, exports) {
++'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',
++];
++
++module.exports = { WORDS };
++},
++// ── 17: src/locker/seal.js ──────────────────────────────────────────
++function (module, exports) {
++'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,
++} = __load(12);
++const { fail } = __load(13);
++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,
++} = __load(11);
++const {
++ KEY_BYTES, PASSWORD_ITERATIONS, subtle, platformRandom, importContentKey, publicJwk,
++ jwkThumbprint, wrapFor, unwrapPeopleSlot, wrapForPassword, unwrapPasswordSlot, resolveEphemeral,
++} = __load(15);
++
++/** 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 });
++}
++
++module.exports = {
++ seal, open, sealStream, openStream, rewrap,
++ envelopeBytes, toBytes, openContentKey, readCore, prepare, makeHeader, Chunker,
++ publicKeyOf, privateKeyOf, slotForPerson,
++};
++},
++// ── 18: src/locker/inspect.js ───────────────────────────────────────
++function (module, exports) {
++'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 } = __load(17);
++const { readHeader } = __load(11);
++
++/**
++ * 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;
++}
++
++module.exports = { inspect };
++},
++// ── 19: src/locker/recovery.js ──────────────────────────────────────
++function (module, exports) {
++'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 } = __load(13);
++const { platformRandom } = __load(15);
++const { WORDS } = __load(16);
++
++/** 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()));
++}
++
++module.exports = { RECOVERY_WORDS, recoveryWords, isRecoveryPhrase };
++},
++// ── 20: src/registry.js ─────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -875,7 +2388,7 @@ function createRegistryClient(client, { baseUrl } = {}) {
module.exports = { createRegistryClient };
},
--// ── 10: src/modules.js ──────────────────────────────────────────────
++// ── 21: src/modules.js ──────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -954,7 +2467,7 @@ function createModulesClient(client, { baseUrl, maxAttempts = 5 } = {}) {
module.exports = { createModulesClient };
},
--// ── 11: src/realtime.js ─────────────────────────────────────────────
++// ── 22: src/realtime.js ─────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -1207,7 +2720,7 @@ function createRealtimeClient(client, defaults = {}) {
module.exports = { subscribe, createRealtimeClient, parseSSE, DEFAULT_ORIGIN };
},
--// ── 12: src/media.js ────────────────────────────────────────────────
++// ── 23: src/media.js ────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -1229,7 +2742,7 @@ function (module, exports) {
*/
const { isOpenVibeError } = __load(3);
const { paginate, offsetPager } = __load(7);
--const { createObjectsClient } = __load(13);
++const { createObjectsClient } = __load(24);
const DEFAULT_PUBLIC_ORIGIN = 'https://openvibe.media';
const enc = encodeURIComponent;
@@@ -1321,7 +2834,7 @@ function createMediaClient(client, { app, apiKey, actingUserId, baseUrl, publicO
module.exports = { createMediaClient, createObjectsClient, mediaUrls, DEFAULT_PUBLIC_ORIGIN };
},
--// ── 13: src/objects.js ──────────────────────────────────────────────
++// ── 24: src/objects.js ──────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -1640,7 +3153,7 @@ function createObjectsClient({
module.exports = { createObjectsClient };
},
--// ── 14: src/community.js ────────────────────────────────────────────
++// ── 25: src/community.js ────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -1759,7 +3272,7 @@ function createCommunityClient(client, defaults = {}) {
module.exports = { createCommunityClient, actingHeaders };
},
--// ── 15: src/bot.js ──────────────────────────────────────────────────
++// ── 26: src/bot.js ──────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -1981,7 +3494,7 @@ function createBotClient(client, defaults = {}) {
module.exports = { createBotClient, actingHeaders };
},
--// ── 16: src/search.js ───────────────────────────────────────────────
++// ── 27: src/search.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2094,7 +3607,7 @@ function createSearchClient(client, defaults = {}) {
module.exports = { createSearchClient, queryOf, fabricOf };
},
--// ── 17: src/bench.js ────────────────────────────────────────────────
++// ── 28: src/bench.js ────────────────────────────────────────────────
function (module, exports) {
'use strict';
/** openvibe-sdk/bench: OpenVibe.Bench's JSON API and streamed open dataset. Browser-safe. */
@@@ -2195,7 +3708,7 @@ function createBenchClient(client, defaults = {}) {
module.exports = { createBenchClient };
},
--// ── 18: src/meme.js ─────────────────────────────────────────────────
++// ── 29: src/meme.js ─────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2263,7 +3776,7 @@ function createMemeClient(client, defaults = {}) {
module.exports = { createMemeClient };
},
--// ── 19: src/vision.js ───────────────────────────────────────────────
++// ── 30: src/vision.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2421,7 +3934,7 @@ function pick(o, keys) {
module.exports = { createVisionClient, DEFAULT_AUDIENCE };
},
--// ── 20: src/themes.js ───────────────────────────────────────────────
++// ── 31: src/themes.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2571,7 +4084,7 @@ function followTheme(fn, opts = {}) {
module.exports = { createThemesClient, followTheme, readTheme, applyTheme, seasonOn, onColor, DISPLAY_OPTIONS, TOKENS };
},
--// ── 21: src/redact.js ───────────────────────────────────────────────
++// ── 32: src/redact.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2818,7 +4331,7 @@ function onlySecret(text) {
module.exports = { redact, onlySecret };
},
--// ── 22: src/chat.js ─────────────────────────────────────────────────
++// ── 33: src/chat.js ─────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -3060,7 +4573,7 @@ function createFeedPublisher(client, { feed, key, baseUrl } = {}) {
module.exports = { createChatClient, createInboxVisitor, createFeedPublisher };
},
--// ── 23: src/ai.js ───────────────────────────────────────────────────
++// ── 34: src/ai.js ───────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -3135,7 +4648,7 @@ function createAiClient(client, defaults = {}) {
module.exports = { createAiClient, TERMINAL, OPS };
},
--// ── 24: src/jobs.js ─────────────────────────────────────────────────
++// ── 35: src/jobs.js ─────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -3173,8 +4686,8 @@ function (module, exports) {
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
--const { appendFiles } = __load(25);
--const { parseSSE } = __load(11);
++const { appendFiles } = __load(36);
++const { parseSSE } = __load(22);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
const TERMINAL_EVENTS = new Set(['job.succeeded', 'job.failed', 'job.cancelled']);
@@@ -3337,7 +4850,7 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
},
--// ── 25: src/core/form.js ────────────────────────────────────────────
++// ── 36: src/core/form.js ────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -3371,7 +4884,7 @@ function appendFiles(form, list, where) {
module.exports = { isBlob, isUpload, appendFiles };
},
--// ── 26: src/tools.js ────────────────────────────────────────────────
++// ── 37: src/tools.js ────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -3405,8 +4918,8 @@ function (module, exports) {
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
--const { isUpload, appendFiles } = __load(25);
--const { createJobsClient } = __load(24);
++const { isUpload, appendFiles } = __load(36);
++const { createJobsClient } = __load(35);
const enc = encodeURIComponent;
const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
@@@ -3656,7 +5169,7 @@ function createToolsClient(client, { baseUrl, service = 'tools', audience = 'ope
module.exports = { createToolsClient, ToolRunError, isToolRunError };
},
--// ── 27: src/projects.js ─────────────────────────────────────────────
++// ── 38: src/projects.js ─────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -3772,7 +5285,7 @@ function createProjectsClient(client, { baseUrl } = {}) {
module.exports = { createProjectsClient };
},
--// ── 28: src/geo.js ──────────────────────────────────────────────────
++// ── 39: src/geo.js ──────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -3851,8 +5364,9 @@ export const {
createClient, OpenVibeError, isOpenVibeError, paginate, offsetPager, parseTraceparent,
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
-- registry, modules, realtime, media, community, bot,
-- search, bench, meme, vision, themes, redact,
-- chat, ai, jobs, tools, projects, geo,
++ locker, registry, modules, realtime, media, community,
++ bot, search, bench, meme, vision, themes,
++ redact, chat, ai, jobs, tools, projects,
++ geo,
} = sdk;
export default sdk;
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex 4e4d2d7..1e7fa92 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -157,6 +157,42 @@
"createLiveClient"
]
},
++ "locker": {
++ "object": [
++ "CHUNK_SIZE",
++ "LockerError",
++ "MAX_HEADER_BYTES",
++ "MIN_PADDED_SIZE",
++ "PASSWORD_ITERATIONS",
++ "PASSWORD_ITERATIONS_MAX",
++ "PASSWORD_ITERATIONS_MIN",
++ "RECOVERY_WORDS",
++ "SUITE",
++ "VERSION",
++ "WORDS",
++ "exportPersonKeys",
++ "exportPrivateKeys",
++ "fingerprintOf",
++ "fingerprintWords",
++ "generatePersonKeys",
++ "importPersonKeys",
++ "inspect",
++ "isRecoveryPhrase",
++ "jwkThumbprint",
++ "open",
++ "openStream",
++ "padme",
++ "recoveryWords",
++ "rewrap",
++ "seal",
++ "sealStream",
++ "unwrapPasswordSlot",
++ "unwrapPeopleSlot",
++ "verifyPersonKeys",
++ "wrapFor",
++ "wrapForPassword"
++ ]
++ },
"media": {
"object": [
"DEFAULT_PUBLIC_ORIGIN",
@@@ -598,6 +634,77 @@
"openvibe-sdk/live": {
"createLiveClient": "function"
},
++ "openvibe-sdk/locker": {
++ "CHUNK_SIZE": "number",
++ "LockerError": "class",
++ "MAX_HEADER_BYTES": "number",
++ "MIN_PADDED_SIZE": "number",
++ "PASSWORD_ITERATIONS": "number",
++ "PASSWORD_ITERATIONS_MAX": "number",
++ "PASSWORD_ITERATIONS_MIN": "number",
++ "RECOVERY_WORDS": "number",
++ "SUITE": "string",
++ "VERSION": "string",
++ "WORDS": "array",
++ "exportPersonKeys": "function",
++ "exportPrivateKeys": "function",
++ "fingerprintOf": "function",
++ "fingerprintWords": "function",
++ "generatePersonKeys": "function",
++ "importPersonKeys": "function",
++ "inspect": "function",
++ "isRecoveryPhrase": "function",
++ "jwkThumbprint": "function",
++ "open": "function",
++ "openStream": "function",
++ "padme": "function",
++ "recoveryWords": "function",
++ "rewrap": "function",
++ "seal": "function",
++ "sealStream": "function",
++ "unwrapPasswordSlot": "function",
++ "unwrapPeopleSlot": "function",
++ "verifyPersonKeys": "function",
++ "wrapFor": "function",
++ "wrapForPassword": "function"
++ },
++ "openvibe-sdk/locker/server": {
++ "checkHeader": "function",
++ "inspectEnvelope": "function"
++ },
++ "openvibe-sdk/locker/vectors": {
++ "CASES": "array",
++ "CONTENT": "string",
++ "INSPECT": {
++ "object": [
++ "link",
++ "password",
++ "people"
++ ]
++ },
++ "PERSON": {
++ "object": [
++ "ecdh",
++ "ecdhPublic",
++ "fingerprint",
++ "signing",
++ "signingPublic"
++ ]
++ },
++ "RECOVERY": {
++ "object": [
++ "phrase",
++ "words"
++ ]
++ },
++ "SUBJECT": {
++ "object": [
++ "id",
++ "type"
++ ]
++ },
++ "fixedRandom": "function"
++ },
"openvibe-sdk/machines": {
"DEFAULT_TIMEOUT_MS": "number",
"MachinesError": "class",
ddiff --git a/docs/locker.md b/docs/locker.md
nnew file mode 100644
iindex 0000000..332b9ed
---- /dev/null
++++ b/docs/locker.md
@@@ -0,0 +1,217 @@
++# Locker: end-to-end encryption, in the SDK
++
++`openvibe-sdk/locker` seals anything and opens it again, in Node and in the browser, with WebCrypto
++only: `globalThis.crypto.subtle`, no other crypto library, nothing hand-rolled. The format is
++`locker.envelope@1` in OpenVibe.Contracts; the written-out format is
++[docs/locker.md in Contracts](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/locker.md).
++
++With a locked item, OpenVibe stores and serves only ciphertext. The keys never reach our servers, and
++neither does anything that can read the content.
++
++```bash
++npm install openvibe-sdk # the package; the subpaths need no extra install
++```
++
++```js
++const { seal, open, generatePersonKeys } = require('openvibe-sdk/locker');
++```
++
++In a browser, with a bundler, the same names come from `openvibe-sdk/locker`; with no bundler, from the
++one-file bundle `browser/openvibe-sdk.mjs` (`sdk.locker`).
++
++## The three locks
++
++| Lock | Who can open it | What you need to open it |
++| --- | --- | --- |
++| `people` | you and the people you share with | your private key |
++| `password` | anyone you give the password to | the password |
++| `link` | anyone with the full link | the key from the part after `#` |
++
++## Sealing and opening
++
++```js
++const person = await generatePersonKeys();
++// person.ecdhPublic, person.signingPublic, person.fingerprint ("eight words")
++const subject = { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' };
++
++// For the people on the list.
++const sealed = await seal('a note for @maticus', {
++ lock: 'people',
++ keys: [{ subject, publicKey: person.ecdhPublic }],
++});
++const note = await open(sealed, { privateKey: person.ecdh.privateKey, subject });
++
++// With a password.
++const withPassword = await seal('a note', { lock: 'password', password: 'correct horse battery staple' });
++await open(withPassword, { password: 'correct horse battery staple' });
++
++// With the link: the key travels in the part after the #, which browsers never send to us.
++const withLink = await seal('a note', { lock: 'link' });
++const url = `https://openvibe.example/paste/abc#${withLink.linkKey}`;
++await open(withLink, { key: withLink.linkKey });
++```
++
++`seal` takes a string or bytes and returns an envelope:
++
++```js
++{
++ version: 'locker.envelope.v1',
++ header, // the JSON that opens every chunk (locker.envelope@1)
++ headerBytes,
++ bytes, // the whole envelope: magic, header, chunks
++ chunkOffset, // where the chunks start in bytes
++ linkKey, // a link lock only: the content key, for the link
++}
++```
++
++`open` takes that envelope (or its bytes) and the credentials of the lock it names, and returns the
++content as bytes. A wrong key, a changed header, a changed chunk, chunks in the wrong order and a
++missing last chunk all fail with a `LockerError` whose `code` says what went wrong
++(`locker.wrong_key`, `locker.truncated`, `locker.bad_envelope`, `locker.no_key`).
++
++The header holds the **padded size**, not the size: the content is padded with Padmé (PoPETs 2019), so
++a 200-byte note costs 256 bytes and not a whole megabyte, the expansion stays under about twelve per
++cent, and the padded size leaks only O(log log n) bits about the real one. `locker.padme(size)` gives the
++padded size of a content of that size; every chunk but the last is a whole chunk, and the last holds the
++rest.
++
++## Files: the streams
++
++A large file never has to fit in memory, in either direction.
++
++```js
++const sealed = locker.sealStream(file.stream(), { lock: 'people', keys, size: file.size });
++const plain = locker.openStream(await (await fetch(url)).body, { privateKey, subject });
++```
++
++`sealStream` needs the size of the content: the padded size is in the header, and the header is written
++first. The stream holds one chunk at a time (1 MiB), so sealing a 64 MiB file costs a few MiB of
++memory, not 64.
++
++`openStream` yields the chunks as it verifies them, so it gives you the earlier ones before it reaches the
++last one. **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. The same goes for the whole-content `open`: it either
++returns everything or it fails.
++
++## Sharing: wrapFor and rewrap
++
++The content key is wrapped for one person at a time, so adding a member to a people-locked item does
++not re-encrypt the content: the slots are not part of what the chunks authenticate.
++
++```js
++const slot = await wrapFor(contentKey, person.ecdhPublic, { subject });
++
++const again = await rewrap(sealed, {
++ add: [{ subject: other, publicKey: otherPerson.ecdhPublic }],
++ credentials: { privateKey: person.ecdh.privateKey, subject },
++});
++```
++
++`rewrap` keeps the slots of the people who are already on it and gives the new ones one of their own;
++the chunks are the same bytes.
++
++Removing a member wants a fresh content key, not a slot that is gone: the person who is removed may still
++hold the old key, and it would open the item again. So a removal opens the content and seals it again with
++a new content 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`:
++
++```js
++const without = await rewrap(sealed, {
++ remove: [other],
++ keys: [{ subject, publicKey: person.ecdhPublic }],
++ credentials: { privateKey: person.ecdh.privateKey, subject },
++});
++```
++
++The old content key opens nothing now, and the person who left has no slot at all.
++
++## A person's keys
++
++A person has two key pairs: an **ECDH P-256** pair that receives content keys and an **ECDSA P-256**
++pair that signs the fingerprint. The **fingerprint** is eight words in two groups of four (the first eight
++bytes of SHA-256 over both public keys, read from a list of 256 words): short enough to read out loud and
++compare once, and sixty-four bits, so a server that swapped in its own key would need about 2^64 key
++generations to match it. The signature over both public keys proves the holder owns the signing key, so
++the pair is self-certifying, and a change of keys changes the eight words.
++
++```js
++const person = await generatePersonKeys();
++const published = await exportPersonKeys(person); // locker.public-keys@1
++await verifyPersonKeys(published); // true
++```
++
++The private keys are stored wrapped, by a key from the locker passphrase (PBKDF2-SHA-256) or by a
++passkey; see `locker.key-backup@1` in Contracts. There is no reset: if the passphrase and the recovery
++kit are both lost, nobody can open the locker, including OpenVibe.
++
++## The recovery phrase: words to write down
++
++`recoveryWords` gives a phrase of words from the same 256-word list, one byte per word from the
++platform's CSPRNG, so the usual 24 words carry 192 bits. `isRecoveryPhrase` says whether every word
++of a typed phrase is one of the 256, so a page can tell a typo from a word that was never ours.
++
++```js
++const { recoveryWords, isRecoveryPhrase, WORDS } = require('openvibe-sdk/locker');
++
++const phrase = recoveryWords(); // 24 words, 192 bits
++recoveryWords(12); // 12 words, 96 bits
++isRecoveryPhrase(phrase); // true
++isRecoveryPhrase('amber anchor zzzz'); // false: zzzz is not one of the 256
++WORDS.length; // 256
++```
++
++## Opening by key: `person`
++
++A people lock can be opened without knowing anybody's subject: every slot names the key it was
++made for, so the reader finds its own slot by that key.
++
++```js
++const note = await open(sealed.bytes, { person }); // person.ecdhPublic and person.ecdh.privateKey
++```
++
++An envelope that has no slot for that key fails with `locker.no_slot`, which says the key is not on
++the list. `locker.wrong_key` is for a key that is on the list but does not open the slot.
++
++## What an envelope is: `inspect`
++
++`inspect` reads the header and nothing else: no key, no decryption. It is what a page shows before
++the reader has any key.
++
++```js
++const { lock, version, padded_size, recipients, iterations } = inspect(bytes);
++// recipients: the thumbprint of each recipient's key, for a people lock
++// iterations: the PBKDF2 count, for a password lock
++```
++
++The same checks, with the errors as words rather than exceptions, are in `openvibe-sdk/locker/server`.
++
++## What a service does: openvibe-sdk/locker/server
++
++A service stores an envelope as opaque bytes and never opens it. This module gives it the checks in
++one place, and needs no key:
++
++```js
++const { inspectEnvelope } = require('openvibe-sdk/locker/server');
++const info = inspectEnvelope(bytes);
++// { valid, errors, header, headerLength, chunkOffset, lock, paddedSize, chunkCount, expectedBytes }
++```
++
++It reads the header and checks its shape and the envelope's size, without decrypting anything. Then the
++service skips indexing, previews, thumbnails, transcoding and content AI, shows the lock badge, and
++serves the bytes only to the people `canView` allows. A locked item is never public, and locked storage
++has per-account quotas.
++
++## The shared test vectors
++
++`openvibe-sdk/locker/vectors` holds the cases the browser module (`openvibe-shared/ov-locker.js`) runs
++as well: the same fixed keys, the same fixed byte source, the same content, and the header and the
++envelope hash they must produce. Both runtimes must give the same bytes for the same input.
++
++```js
++const { CASES, PERSON, SUBJECT, CONTENT, fixedRandom } = require('openvibe-sdk/locker/vectors');
++```
++
++## What we cannot do
++
++We cannot scan or moderate what is inside a locked item. That matters most for uploads. A recipient can
++always keep what they opened, and the server still sees who owns what, how big it is (padded) and when.
ddiff --git a/esm/index.mjs b/esm/index.mjs
iindex 062f772..0865ff3 100644
---- a/esm/index.mjs
++++ b/esm/index.mjs
@@@ -5,13 +5,14 @@ export const {
createClient, OpenVibeError, isOpenVibeError, paginate, offsetPager, parseTraceparent,
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, share,
-- auth, registry, identity, modules, events, realtime,
-- media, community, bot, search, bench, meme,
-- vision, themes, redact, chat, ai, jobs,
-- tools, projects, resources, vip, quest, live,
-- inventory, tips, blog, reviews, wiki, coupons,
-- deals, trade, space, host, news, food,
-- actor, run, video, work, zone, play,
-- download, openre, geo, usage, commerce, telemetry,
++ locker, auth, registry, identity, modules, events,
++ realtime, media, community, bot, search, bench,
++ meme, vision, themes, redact, chat, ai,
++ jobs, tools, projects, resources, vip, quest,
++ live, inventory, tips, blog, reviews, wiki,
++ coupons, deals, trade, space, host, news,
++ food, actor, run, video, work, zone,
++ play, download, openre, geo, usage, commerce,
++ telemetry,
} = cjs;
export default cjs;
ddiff --git a/esm/locker-server.mjs b/esm/locker-server.mjs
nnew file mode 100644
iindex 0000000..cb7198c
---- /dev/null
++++ b/esm/locker-server.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/locker/server.js. Do not edit.
++import cjs from '../src/locker/server.js';
++
++export const {
++ inspectEnvelope, checkHeader,
++} = cjs;
++export default cjs;
ddiff --git a/esm/locker-vectors.mjs b/esm/locker-vectors.mjs
nnew file mode 100644
iindex 0000000..ff9d264
---- /dev/null
++++ b/esm/locker-vectors.mjs
@@@ -0,0 +1,8 @@
++// Generated by scripts/esm.js from src/locker/vectors.js. Do not edit.
++import cjs from '../src/locker/vectors.js';
++
++export const {
++ fixedRandom, CONTENT, PERSON, SUBJECT, CASES, RECOVERY,
++ INSPECT,
++} = cjs;
++export default cjs;
ddiff --git a/esm/locker.mjs b/esm/locker.mjs
nnew file mode 100644
iindex 0000000..8c4f670
---- /dev/null
++++ b/esm/locker.mjs
@@@ -0,0 +1,12 @@
++// Generated by scripts/esm.js from src/locker.js. Do not edit.
++import cjs from '../src/locker.js';
++
++export const {
++ VERSION, SUITE, CHUNK_SIZE, MIN_PADDED_SIZE, MAX_HEADER_BYTES, PASSWORD_ITERATIONS,
++ PASSWORD_ITERATIONS_MIN, PASSWORD_ITERATIONS_MAX, seal, open, sealStream, openStream,
++ rewrap, inspect, padme, recoveryWords, isRecoveryPhrase, RECOVERY_WORDS,
++ WORDS, generatePersonKeys, exportPersonKeys, exportPrivateKeys, importPersonKeys, verifyPersonKeys,
++ fingerprintOf, fingerprintWords, jwkThumbprint, wrapFor, wrapForPassword, unwrapPeopleSlot,
++ unwrapPasswordSlot, LockerError,
++} = cjs;
++export default cjs;
ddiff --git a/index.js b/index.js
iindex 487e94d..9e8ce16 100644
---- a/index.js
++++ b/index.js
@@@ -9,6 +9,7 @@ const core = require('./src/core');
module.exports = {
...core,
share: require('./src/share').share,
++ locker: require('./src/locker'),
auth: require('./src/auth'),
registry: require('./src/registry'),
identity: require('./src/identity'),
ddiff --git a/package-lock.json b/package-lock.json
iindex ea4514f..1b17150 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.85.0",
++ "version": "0.86.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.85.0",
++ "version": "0.86.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
@@@ -14,7 +14,7 @@
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.291.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.292.0",
"pg": "^8.23.0"
},
"engines": {
@@@ -198,9 +198,9 @@
"license": "MIT"
},
"node_modules/openvibe-contracts": {
-- "version": "0.291.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.291.0",
-- "integrity": "sha512-/jZNmCA6rLPFe2Jt1FJbC8rfELcMMd0fk7466HzoXrsKzGTXQaB/hr2sjnnm407+mcEYtKBcdB6Oisjyjehh1Q==",
++ "version": "0.292.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.292.0",
++ "integrity": "sha512-zJwkQHzMJ5/SyLzujbIGOOOtRSwILwe9oQo1INb3sFnyB7KDoYKAhAgGFLs7Y7ooQYlO+Bo+Q6GlHfyfe858ew==",
"dev": true,
"license": "AGPL-3.0-only",
"dependencies": {
ddiff --git a/package.json b/package.json
iindex 4e8991c..2e53866 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.85.0",
++ "version": "0.86.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
@@@ -454,6 +454,21 @@
"browser": null,
"import": "./esm/share.mjs",
"default": "./src/share.js"
++ },
++ "./locker": {
++ "types": "./types/locker.d.ts",
++ "import": "./esm/locker.mjs",
++ "default": "./src/locker.js"
++ },
++ "./locker/server": {
++ "types": "./types/locker-server.d.ts",
++ "import": "./esm/locker-server.mjs",
++ "default": "./src/locker/server.js"
++ },
++ "./locker/vectors": {
++ "types": "./types/locker-vectors.d.ts",
++ "import": "./esm/locker-vectors.mjs",
++ "default": "./src/locker/vectors.js"
}
},
"bin": {
@@@ -499,7 +514,7 @@
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.291.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.292.0",
"pg": "^8.23.0"
},
"peerDependencies": {
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex e9b4c62..008ea84 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -13,6 +13,9 @@ const path = require('path');
const ROOT = path.join(__dirname, '..');
const ENTRIES = {
'share.mjs': '../src/share.js',
++ 'locker.mjs': '../src/locker.js',
++ 'locker-server.mjs': '../src/locker/server.js',
++ 'locker-vectors.mjs': '../src/locker/vectors.js',
'index.mjs': '../index.js',
'core.mjs': '../src/core/index.js',
'auth.mjs': '../src/auth/index.js',
ddiff --git a/scripts/make-vectors.js b/scripts/make-vectors.js
nnew file mode 100644
iindex 0000000..18a6644
---- /dev/null
++++ b/scripts/make-vectors.js
@@@ -0,0 +1,105 @@
++'use strict';
++/**
++ * Regenerates src/locker/vectors.js from the library, so the shared vectors are the bytes the
++ * library writes today. Run from the SDK root: node scripts/make-vectors.js
++ */
++const crypto = require('node:crypto');
++const fs = require('node:fs');
++const path = require('node:path');
++const locker = require('../src/locker');
++const { fixedRandom, CONTENT, PERSON, SUBJECT } = require('../src/locker/vectors');
++
++const sha256 = (bytes) => crypto.createHash('sha256').update(Buffer.from(bytes)).digest('hex');
++
++(async () => {
++ const person = {
++ ecdh: PERSON.ecdh,
++ signing: PERSON.signing,
++ ecdhPublic: PERSON.ecdhPublic,
++ signingPublic: PERSON.signingPublic,
++ };
++ const random = fixedRandom();
++ const made = {
++ people: await locker.seal(CONTENT, {
++ lock: 'people',
++ keys: [{ subject: SUBJECT, publicKey: person.ecdhPublic }],
++ random,
++ ephemeral: { private: person.ecdh },
++ }),
++ password: await locker.seal(CONTENT, {
++ lock: 'password', password: 'correct horse battery staple', random,
++ }),
++ link: await locker.seal(CONTENT, { lock: 'link', random }),
++ };
++ const fingerprint = await locker.fingerprintOf(person.ecdhPublic, person.signingPublic);
++ // A recovery phrase from its own fixed byte source, so the words are the same everywhere.
++ const recovery = {
++ phrase: locker.recoveryWords(24, { random: fixedRandom(0x5eed1e) }),
++ words: locker.recoveryWords(24, { random: fixedRandom(0x5eed1e) }).split(' '),
++ };
++ // What inspect() says about each case, without a key.
++ const inspect = {};
++ for (const name of ['people', 'password', 'link']) {
++ inspect[name] = locker.inspect(made[name].bytes);
++ }
++ const cases = ['people', 'password', 'link'].map(name => ({
++ name,
++ envelope_sha256: sha256(made[name].bytes),
++ header: made[name].header,
++ }));
++ const file = `\
++'use strict';
++/**
++ * The shared test vectors. The browser module (openvibe-shared/ov-locker.js) runs the same cases,
++ * so both runtimes must produce the same bytes for the same input: the same header, the same
++ * envelope, the same eight words.
++ *
++ * Everything here is fixed: the keys, the byte source and the content. The keys below are test
++ * keys, made for this file and used for nothing else.
++ */
++
++/**
++ * A fixed byte source: the same bytes every time, so a vector is the same on every runtime and
++ * in every browser. It is a test helper, not a random source: never use it for anything real.
++ */
++function fixedRandom(seed = 0x1a2b3c4d) {
++ let state = seed >>> 0;
++ return (length) => {
++ const out = new Uint8Array(length);
++ for (let i = 0; i < length; i++) {
++ state ^= state << 13; state >>>= 0;
++ state ^= state >>> 17;
++ state ^= state << 5; state >>>= 0;
++ out[i] = state & 0xff;
++ }
++ return out;
++ };
++}
++
++/** The content of every case. */
++const CONTENT = ${JSON.stringify(CONTENT)};
++
++/** A person's keys, made for these vectors. */
++const PERSON = ${JSON.stringify({ ...PERSON, fingerprint }, null, 4)};
++
++/** The subject the people case is for. */
++const SUBJECT = ${JSON.stringify(SUBJECT, null, 4)};
++
++/** The cases: the same content, sealed three ways. */
++const CASES = ${JSON.stringify(cases, null, 4)};
++
++/** A recovery phrase from a fixed byte source: the same words on every runtime. */
++const RECOVERY = ${JSON.stringify(recovery, null, 4)};
++
++/** What inspect() says about each case: the header, read without a key. */
++const INSPECT = ${JSON.stringify(inspect, null, 4)};
++
++module.exports = { fixedRandom, CONTENT, PERSON, SUBJECT, CASES, RECOVERY, INSPECT };
++`;
++ fs.writeFileSync(path.join(__dirname, '..', 'src', 'locker', 'vectors.js'), file);
++ console.log('wrote src/locker/vectors.js');
++ console.log('fingerprint:', fingerprint);
++ for (const testCase of cases) {
++ console.log(testCase.name, testCase.envelope_sha256, 'padded', testCase.header.padded_size);
++ }
++})();
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 75c5ba4..30d4e14 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.85.0';
++const SDK_VERSION = '0.86.0';
module.exports = {
createClient,
ddiff --git a/src/locker.js b/src/locker.js
nnew file mode 100644
iindex 0000000..cccac9d
---- /dev/null
++++ b/src/locker.js
@@@ -0,0 +1,6 @@
++'use strict';
++/**
++ * openvibe-sdk/locker: the subpath entry. The format, the three locks, the streams and the
++ * keys. WebCrypto only, in Node and in the browser.
++ */
++module.exports = require('./locker/index');
ddiff --git a/src/locker/bytes.js b/src/locker/bytes.js
nnew file mode 100644
iindex 0000000..4de55af
---- /dev/null
++++ b/src/locker/bytes.js
@@@ -0,0 +1,86 @@
++'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(',')}}`;
++}
++
++module.exports = {
++ utf8, fromUtf8, concat, base64, fromBase64, base64url, fromBase64url,
++ u32be, readU32be, u64be, readU64be, canonicalJson,
++};
ddiff --git a/src/locker/errors.js b/src/locker/errors.js
nnew file mode 100644
iindex 0000000..99accd6
---- /dev/null
++++ b/src/locker/errors.js
@@@ -0,0 +1,23 @@
++'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); }
++
++module.exports = { LockerError, fail };
ddiff --git a/src/locker/format.js b/src/locker/format.js
nnew file mode 100644
iindex 0000000..7bec0b9
---- /dev/null
++++ b/src/locker/format.js
@@@ -0,0 +1,146 @@
++'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 } = require('./bytes');
++const { fail } = require('./errors');
++const { padme } = require('./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;
++}
++
++module.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,
++};
ddiff --git a/src/locker/index.js b/src/locker/index.js
nnew file mode 100644
iindex 0000000..ea6ac05
---- /dev/null
++++ b/src/locker/index.js
@@@ -0,0 +1,69 @@
++'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 = require('./format');
++const keys = require('./keys');
++const seal = require('./seal');
++const { padme } = require('./padme');
++const { inspect } = require('./inspect');
++const { recoveryWords, isRecoveryPhrase, RECOVERY_WORDS } = require('./recovery');
++const { WORDS } = require('./words');
++const { LockerError } = require('./errors');
++
++module.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,
++};
ddiff --git a/src/locker/inspect.js b/src/locker/inspect.js
nnew file mode 100644
iindex 0000000..5e8ba60
---- /dev/null
++++ b/src/locker/inspect.js
@@@ -0,0 +1,43 @@
++'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 } = require('./seal');
++const { readHeader } = require('./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;
++}
++
++module.exports = { inspect };
ddiff --git a/src/locker/keys.js b/src/locker/keys.js
nnew file mode 100644
iindex 0000000..ab20e66
---- /dev/null
++++ b/src/locker/keys.js
@@@ -0,0 +1,365 @@
++'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 } = require('./bytes');
++const { fail } = require('./errors');
++const { WORDS } = require('./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');
++ }
++}
++
++module.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,
++};
ddiff --git a/src/locker/padme.js b/src/locker/padme.js
nnew file mode 100644
iindex 0000000..56be732
---- /dev/null
++++ b/src/locker/padme.js
@@@ -0,0 +1,48 @@
++'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;
++}
++
++module.exports = { padme, exponentOf };
ddiff --git a/src/locker/recovery.js b/src/locker/recovery.js
nnew file mode 100644
iindex 0000000..e0dae9f
---- /dev/null
++++ b/src/locker/recovery.js
@@@ -0,0 +1,48 @@
++'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 } = require('./errors');
++const { platformRandom } = require('./keys');
++const { WORDS } = require('./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()));
++}
++
++module.exports = { RECOVERY_WORDS, recoveryWords, isRecoveryPhrase };
ddiff --git a/src/locker/seal.js b/src/locker/seal.js
nnew file mode 100644
iindex 0000000..9769077
---- /dev/null
++++ b/src/locker/seal.js
@@@ -0,0 +1,598 @@
++'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,
++} = require('./bytes');
++const { fail } = require('./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,
++} = require('./format');
++const {
++ KEY_BYTES, PASSWORD_ITERATIONS, subtle, platformRandom, importContentKey, publicJwk,
++ jwkThumbprint, wrapFor, unwrapPeopleSlot, wrapForPassword, unwrapPasswordSlot, resolveEphemeral,
++} = require('./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 });
++}
++
++module.exports = {
++ seal, open, sealStream, openStream, rewrap,
++ envelopeBytes, toBytes, openContentKey, readCore, prepare, makeHeader, Chunker,
++ publicKeyOf, privateKeyOf, slotForPerson,
++};
ddiff --git a/src/locker/server.js b/src/locker/server.js
nnew file mode 100644
iindex 0000000..9dadddf
---- /dev/null
++++ b/src/locker/server.js
@@@ -0,0 +1,156 @@
++'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 } = require('./bytes');
++const {
++ MAGIC, VERSION, SUITE, CHUNK_SIZE, TAG_SIZE, NONCE_SIZE, NONCE_BASE_SIZE, MIN_PADDED_SIZE,
++ MAX_HEADER_BYTES, chunkCount, lastChunkSize,
++} = require('./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,
++ };
++}
++
++module.exports = { inspectEnvelope, checkHeader };
ddiff --git a/src/locker/vectors.js b/src/locker/vectors.js
nnew file mode 100644
iindex 0000000..4f58faa
---- /dev/null
++++ b/src/locker/vectors.js
@@@ -0,0 +1,209 @@
++'use strict';
++/**
++ * The shared test vectors. The browser module (openvibe-shared/ov-locker.js) runs the same cases,
++ * so both runtimes must produce the same bytes for the same input: the same header, the same
++ * envelope, the same eight words.
++ *
++ * Everything here is fixed: the keys, the byte source and the content. The keys below are test
++ * keys, made for this file and used for nothing else.
++ */
++
++/**
++ * A fixed byte source: the same bytes every time, so a vector is the same on every runtime and
++ * in every browser. It is a test helper, not a random source: never use it for anything real.
++ */
++function fixedRandom(seed = 0x1a2b3c4d) {
++ let state = seed >>> 0;
++ return (length) => {
++ const out = new Uint8Array(length);
++ for (let i = 0; i < length; i++) {
++ state ^= state << 13; state >>>= 0;
++ state ^= state >>> 17;
++ state ^= state << 5; state >>>= 0;
++ out[i] = state & 0xff;
++ }
++ return out;
++ };
++}
++
++/** The content of every case. */
++const CONTENT = "The quick brown fox jumps over the lazy dog.";
++
++/** A person's keys, made for these vectors. */
++const PERSON = {
++ "ecdh": {
++ "key_ops": [
++ "deriveBits"
++ ],
++ "ext": true,
++ "kty": "EC",
++ "x": "pNngpTSE3X3C_CGzfpAtenjliLPFZ9arBVNtI0ctPXw",
++ "y": "fXZT2JcDDiXEb6xH3LUrqlPakM3GQwe3aqbV_1vhVLI",
++ "crv": "P-256",
++ "d": "cG9YgmeoPeaI8lXZRpijV5--2sC4rYWSqWbyGCYKPu0"
++ },
++ "signing": {
++ "key_ops": [
++ "sign"
++ ],
++ "ext": true,
++ "kty": "EC",
++ "x": "ytN7TbH08BDhIld7Tt-8R80FoRosnzgTxSn75z135Gk",
++ "y": "D0axhLHKheyPLSc4GW0PpSfM48FPCLcC4OiRn-ZqN1w",
++ "crv": "P-256",
++ "d": "fMd3QfF8zWlqrml-sgPHlQygx49XEW_FHWACSihxAEk"
++ },
++ "ecdhPublic": {
++ "kty": "EC",
++ "crv": "P-256",
++ "x": "pNngpTSE3X3C_CGzfpAtenjliLPFZ9arBVNtI0ctPXw",
++ "y": "fXZT2JcDDiXEb6xH3LUrqlPakM3GQwe3aqbV_1vhVLI"
++ },
++ "signingPublic": {
++ "kty": "EC",
++ "crv": "P-256",
++ "x": "ytN7TbH08BDhIld7Tt-8R80FoRosnzgTxSn75z135Gk",
++ "y": "D0axhLHKheyPLSc4GW0PpSfM48FPCLcC4OiRn-ZqN1w"
++ },
++ "fingerprint": "lichen brick plateau yew · apple petal rabbit sand"
++};
++
++/** The subject the people case is for. */
++const SUBJECT = {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++};
++
++/** The cases: the same content, sealed three ways. */
++const CASES = [
++ {
++ "name": "people",
++ "envelope_sha256": "a07abdad22e7400831299bb6c363c50241c816bb49c361e555f90bd148690b47",
++ "header": {
++ "version": "locker.envelope.v1",
++ "suite": "AES-256-GCM",
++ "chunk_size": 1048576,
++ "padded_size": 256,
++ "nonce_base": "ghT0ZBfkXps=",
++ "lock": "people",
++ "slots": {
++ "people": [
++ {
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "algorithm": "ECDH-P256/HKDF-SHA256/AES-256-GCM",
++ "recipient_key": "TCGh2gH6HIuT0-A9Ge0jOTrFmRXWQkdA6fZG8JxikTA",
++ "ephemeral_public_key": {
++ "kty": "EC",
++ "crv": "P-256",
++ "x": "pNngpTSE3X3C_CGzfpAtenjliLPFZ9arBVNtI0ctPXw",
++ "y": "fXZT2JcDDiXEb6xH3LUrqlPakM3GQwe3aqbV_1vhVLI"
++ },
++ "salt": "pGphaA6OCYR5+nzvUl/WQQ==",
++ "nonce": "0uLZzrvm1lBfW87O",
++ "wrapped_key": "IpXVDIbStzw/kky7eKnrPQ7SOufRZ8zjU8iWB3c59OhfRjKLXuwDs4NQ4OlMAtGN"
++ }
++ ]
++ }
++ }
++ },
++ {
++ "name": "password",
++ "envelope_sha256": "61f9d8f4ad38c4f2e4303f5a48f697e0903b41462f9b54351bb839dcbdc42c1b",
++ "header": {
++ "version": "locker.envelope.v1",
++ "suite": "AES-256-GCM",
++ "chunk_size": 1048576,
++ "padded_size": 256,
++ "nonce_base": "YXEf22DEcq8=",
++ "lock": "password",
++ "slots": {
++ "password": {
++ "algorithm": "PBKDF2-SHA256/AES-256-GCM",
++ "kdf": "PBKDF2-SHA-256",
++ "iterations": 600000,
++ "salt": "jtNXQP80hz5f78yryNnM0w==",
++ "nonce": "onI04DQDAwHgUsR4",
++ "wrapped_key": "pnLvXu5bTR3lmWGT2GFfWc2v43dG67dM7lrStqWOPrL4f+SI6YivVWwXK0BvVjiR"
++ }
++ }
++ }
++ },
++ {
++ "name": "link",
++ "envelope_sha256": "63cd81542d4994c05e3add3c4fcc819ebebc2b917fb4219c6070ecbea2337b54",
++ "header": {
++ "version": "locker.envelope.v1",
++ "suite": "AES-256-GCM",
++ "chunk_size": 1048576,
++ "padded_size": 256,
++ "nonce_base": "Rb2p0icJ0nY=",
++ "lock": "link",
++ "slots": {
++ "link": {}
++ }
++ }
++ }
++];
++
++/** A recovery phrase from a fixed byte source: the same words on every runtime. */
++const RECOVERY = {
++ "phrase": "whale robin orchid cricket olive jasmine dill clover kestrel night cypress arrow canyon opal shell coral indigo solar storm indigo forest comet desert melon",
++ "words": [
++ "whale",
++ "robin",
++ "orchid",
++ "cricket",
++ "olive",
++ "jasmine",
++ "dill",
++ "clover",
++ "kestrel",
++ "night",
++ "cypress",
++ "arrow",
++ "canyon",
++ "opal",
++ "shell",
++ "coral",
++ "indigo",
++ "solar",
++ "storm",
++ "indigo",
++ "forest",
++ "comet",
++ "desert",
++ "melon"
++ ]
++};
++
++/** What inspect() says about each case: the header, read without a key. */
++const INSPECT = {
++ "people": {
++ "version": "locker.envelope.v1",
++ "lock": "people",
++ "padded_size": 256,
++ "recipients": [
++ "TCGh2gH6HIuT0-A9Ge0jOTrFmRXWQkdA6fZG8JxikTA"
++ ],
++ "iterations": null
++ },
++ "password": {
++ "version": "locker.envelope.v1",
++ "lock": "password",
++ "padded_size": 256,
++ "recipients": [],
++ "iterations": 600000
++ },
++ "link": {
++ "version": "locker.envelope.v1",
++ "lock": "link",
++ "padded_size": 256,
++ "recipients": [],
++ "iterations": null
++ }
++};
++
++module.exports = { fixedRandom, CONTENT, PERSON, SUBJECT, CASES, RECOVERY, INSPECT };
ddiff --git a/src/locker/words.js b/src/locker/words.js
nnew file mode 100644
iindex 0000000..bab0375
---- /dev/null
++++ b/src/locker/words.js
@@@ -0,0 +1,47 @@
++'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',
++];
++
++module.exports = { WORDS };
ddiff --git a/test/locker-memory.js b/test/locker-memory.js
nnew file mode 100644
iindex 0000000..bbc3f30
---- /dev/null
++++ b/test/locker-memory.js
@@@ -0,0 +1,40 @@
++'use strict';
++/**
++ * The memory check of the stream seal, in its own process.
++ *
++ * It runs with --expose-gc so it can ask for a collection and see what is really still held,
++ * instead of counting what the collector has not got to yet. A seal that buffered the whole file
++ * would show 64 MiB here; one that holds a chunk at a time shows a few.
++ *
++ * Run by test/locker.test.js: node --expose-gc test/locker-memory.js
++ */
++const locker = require('../src/locker');
++
++(async () => {
++ const SIZE = 64 * 1024 * 1024;
++ let produced = 0;
++ const source = new ReadableStream({
++ pull(controller) {
++ if (produced >= SIZE) { controller.close(); return; }
++ const chunk = new Uint8Array(1024 * 1024);
++ chunk.fill(produced % 251);
++ produced += chunk.length;
++ controller.enqueue(chunk);
++ },
++ });
++ const sealed = locker.sealStream(source, { lock: 'password', password: 'x', size: SIZE });
++ const reader = sealed.getReader();
++ let peak = 0;
++ let total = 0;
++ let firstFrame = 0;
++ for (;;) {
++ const { value, done } = await reader.read();
++ if (done) break;
++ total += value.length;
++ if (!firstFrame) firstFrame = produced;
++ global.gc();
++ const now = process.memoryUsage();
++ peak = Math.max(peak, now.heapUsed + now.external);
++ }
++ process.stdout.write(JSON.stringify({ peak, total, firstFrame }));
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/locker.test.js b/test/locker.test.js
nnew file mode 100644
iindex 0000000..b0f1309
---- /dev/null
++++ b/test/locker.test.js
@@@ -0,0 +1,628 @@
++'use strict';
++/**
++ * Locker: the format, the three locks, the streams, the keys and the tampering.
++ *
++ * The point of these tests is that a sealed item opens with the right key and only with it: a
++ * header that was changed, a chunk that was changed, chunks in the wrong order, a missing last
++ * chunk and the wrong key all have to fail, and none of them may leak the content.
++ */
++const assert = require('node:assert/strict');
++const crypto = require('node:crypto');
++const path = require('node:path');
++const { execFileSync } = require('node:child_process');
++const { run } = require('./helpers');
++const locker = require('../src/locker');
++const server = require('../src/locker/server');
++const vectors = require('../src/locker/vectors');
++const contracts = require('openvibe-contracts');
++
++const CHUNK = locker.CHUNK_SIZE;
++const A = { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' };
++const B = { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR' };
++const decoder = new TextDecoder();
++const sha256 = (bytes) => crypto.createHash('sha256').update(Buffer.from(bytes)).digest('hex');
++
++/** The bytes of a string, without Buffer (the library is browser-safe). */
++const text = (value) => new TextEncoder().encode(value);
++
++/** base64url without padding, for the thumbprints. */
++const base64url = (bytes) => Buffer.from(bytes).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
++
++/** A person with keys, made once for the whole file. */
++let alice = null;
++let bob = null;
++
++/** Seals for the people lock, with the two people in it. */
++const sealFor = (content, options = {}) => locker.seal(content, {
++ lock: 'people',
++ keys: [{ subject: A, publicKey: alice.ecdhPublic }, { subject: B, publicKey: bob.ecdhPublic }],
++ ...options,
++});
++
++/** The bytes of one chunk frame, and where the next one starts. */
++function frames(bytes, offset) {
++ const out = [];
++ let at = offset;
++ while (at < bytes.length) {
++ const length = new DataView(bytes.buffer, bytes.byteOffset + at, 4).getUint32(0, false);
++ out.push({ start: at, length: length + 17, end: at + length + 17 });
++ at += length + 17;
++ }
++ return out;
++}
++
++const fails = async (fn, code) => {
++ await assert.rejects(fn, (err) => {
++ assert.ok(err instanceof locker.LockerError, `expected a LockerError, got ${err && err.name}`);
++ if (code) assert.equal(err.code, code, `expected ${code}, got ${err.code}: ${err.message}`);
++ return true;
++ });
++};
++
++/** The envelope bytes with a changed header: the header is written again and its length fixed. */
++function withHeader(envelope, change) {
++ const headerBytes = text(JSON.stringify(change(envelope.header)));
++ const out = new Uint8Array(8 + headerBytes.length + envelope.bytes.length - envelope.chunkOffset);
++ out.set(text('OVLK'), 0);
++ new DataView(out.buffer).setUint32(4, headerBytes.length, false);
++ out.set(headerBytes, 8);
++ out.set(envelope.bytes.subarray(envelope.chunkOffset), 8 + headerBytes.length);
++ return out;
++}
++
++/** Decrypts the first chunk of an envelope with a content key: does that key open it? */
++async function decryptFirstChunk(envelope, contentKey) {
++ const subtle = globalThis.crypto.subtle;
++ const { header, bytes, chunkOffset } = envelope;
++ const length = new DataView(bytes.buffer, bytes.byteOffset + chunkOffset, 4).getUint32(0, false);
++ const flags = bytes[chunkOffset + 4];
++ const nonce = bytes.subarray(chunkOffset + 5, chunkOffset + 17);
++ const ciphertext = bytes.subarray(chunkOffset + 17, chunkOffset + 17 + length);
++ // The associated data: the core in canonical JSON, the counter and the flags.
++ const core = {
++ chunk_size: header.chunk_size, lock: header.lock, nonce_base: header.nonce_base,
++ padded_size: header.padded_size, suite: header.suite, version: header.version,
++ };
++ const aad = text(JSON.stringify(core));
++ const full = new Uint8Array(aad.length + 5);
++ full.set(aad, 0);
++ new DataView(full.buffer).setUint32(aad.length, 0, false);
++ full[aad.length + 4] = flags;
++ const key = await subtle.importKey('raw', contentKey, { name: 'AES-GCM', length: 256 }, false, ['decrypt']);
++ return new Uint8Array(await subtle.decrypt({ name: 'AES-GCM', iv: nonce, additionalData: full }, key, ciphertext));
++}
++
++run([
++ ['a person has two key pairs and eight words', async () => {
++ alice = await locker.generatePersonKeys();
++ bob = await locker.generatePersonKeys();
++ assert.equal(alice.ecdhPublic.kty, 'EC');
++ assert.equal(alice.ecdhPublic.crv, 'P-256');
++ assert.equal(alice.signingPublic.kty, 'EC');
++ // Eight words in two groups of four, joined by a middle dot: sixty-four bits.
++ assert.match(alice.fingerprint, /^[a-z]+ [a-z]+ [a-z]+ [a-z]+ · [a-z]+ [a-z]+ [a-z]+ [a-z]+$/);
++ assert.equal(alice.fingerprint.split(' · ').length, 2);
++ assert.equal(alice.fingerprint.split(' · ')[0].split(' ').length, 4);
++ assert.notEqual(alice.fingerprint, bob.fingerprint);
++ const published = await locker.exportPersonKeys(alice);
++ assert.equal(published.fingerprint, alice.fingerprint);
++ assert.equal(await locker.verifyPersonKeys(published), true);
++ // And the published keys are the contract.
++ const checked = contracts.validate('locker.public-keys', {
++ subject: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' },
++ ecdh: published.ecdh,
++ signing: published.signing,
++ fingerprint: published.fingerprint,
++ fingerprint_signature: published.fingerprint_signature,
++ updated_at: '2026-11-06T00:00:00Z',
++ });
++ assert.equal(checked.valid, true, JSON.stringify(checked.errors));
++ // A fingerprint that does not belong to the keys is caught.
++ assert.equal(await locker.verifyPersonKeys({ ...published, fingerprint: 'wrong words here now · bad bad bad bad' }), false);
++ // A signature over other keys is caught.
++ const other = await locker.exportPersonKeys(bob);
++ assert.equal(await locker.verifyPersonKeys({ ...published, fingerprint_signature: other.fingerprint_signature }), false);
++ }],
++
++ ['a recovery phrase is words from the list', async () => {
++ assert.equal(locker.WORDS.length, 256);
++ assert.equal(locker.RECOVERY_WORDS, 24);
++ const phrase = locker.recoveryWords();
++ const words = phrase.split(' ');
++ assert.equal(words.length, 24, 'the phrase has 24 words');
++ for (const word of words) assert.ok(locker.WORDS.includes(word), `${word} is one of the 256`);
++ // Two phrases are never the same: the words come from the platform's CSPRNG.
++ assert.notEqual(phrase, locker.recoveryWords());
++ assert.notEqual(phrase.split(' ')[0], locker.recoveryWords().split(' ')[0]);
++ // Another number of words, and the same check on a typed phrase.
++ assert.equal(locker.recoveryWords(12).split(' ').length, 12);
++ assert.equal(locker.isRecoveryPhrase(phrase), true);
++ assert.equal(locker.isRecoveryPhrase(' amber anchor '), true, 'the spacing does not matter');
++ assert.equal(locker.isRecoveryPhrase('amber anchor harbor zzzz'), false, 'a word that is not in the list');
++ assert.equal(locker.isRecoveryPhrase(''), false);
++ assert.equal(locker.isRecoveryPhrase('AMBER ANCHOR'), true, 'the list is lower case');
++ // The words come from the CSPRNG, not from a fixed source: the platform's bytes.
++ assert.throws(() => locker.recoveryWords(0), (err) => err.code === 'locker.invalid');
++ assert.throws(() => locker.recoveryWords(257), (err) => err.code === 'locker.invalid');
++ }],
++
++ ['a people lock opens by key, without a subject', async () => {
++ const sealed = await sealFor('a note');
++ // The reader knows their own key and nothing else.
++ const opened = await locker.open(sealed.bytes, { person: alice });
++ assert.equal(decoder.decode(opened), 'a note');
++ // The other person on the list opens it with their own key.
++ assert.equal(decoder.decode(await locker.open(sealed.bytes, { person: bob })), 'a note');
++ // A key that is not on the list: there is no slot for it, which is not a wrong key.
++ const stranger = await locker.generatePersonKeys();
++ await fails(() => locker.open(sealed.bytes, { person: stranger }), 'locker.no_slot');
++ // A person with no private key cannot open it either.
++ await fails(() => locker.open(sealed.bytes, { person: { ecdhPublic: alice.ecdhPublic } }), 'locker.no_key');
++ }],
++
++ ['inspect says what an envelope is, without a key', async () => {
++ const people = await sealFor('a note');
++ const seen = locker.inspect(people.bytes);
++ assert.equal(seen.version, 'locker.envelope.v1');
++ assert.equal(seen.lock, 'people');
++ assert.equal(seen.padded_size, people.header.padded_size);
++ assert.equal(seen.recipients.length, 2);
++ assert.equal(seen.recipients[0], people.header.slots.people[0].recipient_key);
++ assert.equal(seen.recipients[1], people.header.slots.people[1].recipient_key);
++ assert.equal(seen.iterations, null);
++ // A password lock names its iteration count.
++ const password = await locker.seal('a note', { lock: 'password', password: 'x', iterations: 700000 });
++ const seenPassword = locker.inspect(password.bytes);
++ assert.equal(seenPassword.lock, 'password');
++ assert.equal(seenPassword.iterations, 700000);
++ assert.deepEqual(seenPassword.recipients, []);
++ // A link lock says nothing about anybody.
++ const link = await locker.seal('a note', { lock: 'link' });
++ assert.deepEqual(locker.inspect(link.bytes).recipients, []);
++ assert.equal(locker.inspect(link.bytes).iterations, null);
++ // Bytes that are not an envelope at all. (inspect is synchronous, so it throws.)
++ assert.throws(() => locker.inspect(text('not an envelope')), (err) => err.code === 'locker.bad_envelope');
++ }],
++
++ ['the three locks round trip', async () => {
++ const note = 'a note for @maticus';
++ const people = await sealFor(note);
++ assert.equal(decoder.decode(await locker.open(people, { privateKey: alice.ecdh.privateKey, subject: A })), note);
++ assert.equal(decoder.decode(await locker.open(people, { privateKey: bob.ecdh.privateKey, subject: B })), note);
++ const password = await locker.seal(note, { lock: 'password', password: 'correct horse battery staple' });
++ assert.equal(decoder.decode(await locker.open(password, { password: 'correct horse battery staple' })), note);
++ const link = await locker.seal(note, { lock: 'link' });
++ assert.deepEqual(link.header.slots.link, {});
++ assert.equal(decoder.decode(await locker.open(link, { key: link.linkKey })), note);
++ // The link key is in the link, not in the envelope.
++ assert.equal(JSON.stringify(link.header).includes(link.linkKey), false);
++ }],
++
++ ['every size around the chunk boundary round trips', async () => {
++ for (const size of [0, 1, CHUNK - 1, CHUNK, CHUNK + 1, 3 * CHUNK + 7]) {
++ const content = new Uint8Array(size);
++ for (let i = 0; i < size; i++) content[i] = (i * 7 + 13) % 251;
++ const sealed = await locker.seal(content, { lock: 'password', password: 'x' });
++ assert.ok(sealed.header.padded_size >= size, 'the padded size covers the content');
++ const opened = await locker.open(sealed, { password: 'x' });
++ assert.equal(opened.length, size, `the content comes back whole (${size})`);
++ assert.equal(sha256(opened), sha256(content), `the content is the same (${size})`);
++ }
++ }],
++
++ ['the padded size is Padmé\'s, and it hides the real size', async () => {
++ const sizes = [0, 1, 247, 248, 249, CHUNK - 9, CHUNK - 8, CHUNK, CHUNK + 1, 5 * 1024 * 1024 + 3];
++ for (const size of sizes) {
++ const content = new Uint8Array(size);
++ for (let i = 0; i < size; i++) content[i] = (i * 11 + 3) % 251;
++ const sealed = await locker.seal(content, { lock: 'password', password: 'x' });
++ const padded = sealed.header.padded_size;
++ // The padded size is the length prefix and the content, padded with Padmé.
++ assert.equal(padded, Math.max(256, locker.padme(size + 8)), `the padded size (${size})`);
++ assert.ok(padded >= size + 8, 'the padded size covers the length prefix and the content');
++ // Padmé's expansion stays under twelve per cent.
++ const expansion = (locker.padme(size + 8) - (size + 8)) / (size + 8);
++ assert.ok(expansion <= 0.12, `the padding of ${size} bytes is ${(expansion * 100).toFixed(2)}%`);
++ // Every chunk but the last is a whole chunk; the last holds the rest. (A frame is
++ // its length, its flags and its nonce, then the ciphertext with the tag.)
++ const list = frames(sealed.bytes, sealed.chunkOffset);
++ assert.equal(list.length, Math.ceil(padded / CHUNK), `the number of chunks (${size})`);
++ for (const frame of list.slice(0, -1)) assert.equal(frame.length - 17 - 16, CHUNK, 'a whole chunk');
++ assert.equal(
++ list[list.length - 1].length - 17 - 16,
++ padded - (list.length - 1) * CHUNK,
++ 'the last chunk holds the rest',
++ );
++ // The content comes back whole.
++ const opened = await locker.open(sealed, { password: 'x' });
++ assert.equal(opened.length, size, `the content comes back whole (${size})`);
++ assert.equal(sha256(opened), sha256(content), `the content is the same (${size})`);
++ }
++ // And the expansion never goes over twelve per cent, over a wide range of sizes.
++ let worst = 0;
++ for (let n = 2; n <= 200000; n++) worst = Math.max(worst, (locker.padme(n) - n) / n);
++ assert.ok(worst <= 0.12, `the worst padding is ${(worst * 100).toFixed(2)}%`);
++ // The arithmetic is exact above 2^31: no 32-bit bitwise operators.
++ assert.equal(locker.padme(2 ** 31 + 1), 2 ** 31 + 2 ** 26);
++ assert.equal(locker.padme(2 ** 40), 2 ** 40);
++ assert.equal(locker.padme(3 * 2 ** 40), 3 * 2 ** 40);
++ }],
++
++ ['the server sees the padded size, not the size', async () => {
++ const sealed = await locker.seal('short', { lock: 'password', password: 'x' });
++ assert.equal(sealed.header.padded_size, 256);
++ assert.ok(!JSON.stringify(sealed.header).includes('short'));
++ }],
++
++ ['a header that was changed fails', async () => {
++ const sealed = await sealFor('a note');
++ const bytes = sealed.bytes.slice();
++ // Change the lock kind in the header: the chunks authenticate the core.
++ const headerText = decoder.decode(bytes.subarray(8, sealed.chunkOffset));
++ const changed = headerText.replace('"lock":"people"', '"lock":"link"');
++ assert.notEqual(changed, headerText);
++ bytes.set(text(changed), 8);
++ await fails(() => locker.open(bytes, { privateKey: alice.ecdh.privateKey, subject: A }));
++ }],
++
++ ['a chunk that was changed fails', async () => {
++ const sealed = await sealFor('a note');
++ const bytes = sealed.bytes.slice();
++ const [first] = frames(bytes, sealed.chunkOffset);
++ bytes[first.start + 20] ^= 0x01; // one bit of the ciphertext
++ await fails(() => locker.open(bytes, { privateKey: alice.ecdh.privateKey, subject: A }));
++ }],
++
++ ['chunks in the wrong order fail', async () => {
++ const content = new Uint8Array(CHUNK * 2 + 5);
++ const sealed = await locker.seal(content, { lock: 'password', password: 'x' });
++ const bytes = sealed.bytes.slice();
++ const list = frames(bytes, sealed.chunkOffset);
++ assert.equal(list.length, 3);
++ // Swap the first two chunks.
++ const first = bytes.slice(list[0].start, list[0].end);
++ const second = bytes.slice(list[1].start, list[1].end);
++ bytes.set(second, list[0].start);
++ bytes.set(first, list[1].start);
++ await fails(() => locker.open(bytes, { password: 'x' }));
++ }],
++
++ ['a chunk that claims to be huge is refused', async () => {
++ const sealed = await sealFor('a note');
++ const bytes = sealed.bytes.slice();
++ // The length of the first chunk, in the frame: a lie the reader must not wait for.
++ const at = sealed.chunkOffset;
++ new DataView(bytes.buffer, bytes.byteOffset + at, 4).setUint32(0, 0xffffffff, false);
++ await fails(() => locker.open(bytes, { privateKey: alice.ecdh.privateKey, subject: A }), 'locker.bad_envelope');
++ }],
++
++ ['a missing last chunk fails', async () => {
++ const content = new Uint8Array(CHUNK * 2 + 5);
++ const sealed = await locker.seal(content, { lock: 'password', password: 'x' });
++ const bytes = sealed.bytes.slice();
++ const list = frames(bytes, sealed.chunkOffset);
++ await fails(() => locker.open(bytes.subarray(0, list[list.length - 1].start), { password: 'x' }), 'locker.truncated');
++ }],
++
++ ['a cut envelope fails', async () => {
++ const sealed = await sealFor('a note');
++ await fails(() => locker.open(sealed.bytes.subarray(0, 20), { privateKey: alice.ecdh.privateKey, subject: A }));
++ await fails(() => locker.open(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]), { password: 'x' }));
++ }],
++
++ ['the wrong key fails', async () => {
++ const password = await locker.seal('a note', { lock: 'password', password: 'right' });
++ await fails(() => locker.open(password, { password: 'wrong' }), 'locker.wrong_key');
++ await fails(() => locker.open(password, {}), 'locker.no_key');
++ const people = await sealFor('a note');
++ const stranger = await locker.generatePersonKeys();
++ await fails(() => locker.open(people, { privateKey: stranger.ecdh.privateKey, subject: A }), 'locker.wrong_key');
++ // Alice's key does not open Bob's slot.
++ await fails(() => locker.open(people, { privateKey: alice.ecdh.privateKey, subject: B }), 'locker.wrong_key');
++ // And there is no slot for someone who is not on the list.
++ await fails(() => locker.open(people, {
++ privateKey: alice.ecdh.privateKey,
++ subject: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPS' },
++ }), 'locker.no_key');
++ const link = await locker.seal('a note', { lock: 'link' });
++ await fails(() => locker.open(link, { key: new Uint8Array(32) }), 'locker.wrong_key');
++ }],
++
++ ['the password parameters come from the header', async () => {
++ const sealed = await locker.seal('a note', { lock: 'password', password: 'right', iterations: 700000 });
++ assert.equal(sealed.header.slots.password.iterations, 700000);
++ assert.equal(sealed.header.slots.password.kdf, 'PBKDF2-SHA-256');
++ assert.equal(sealed.header.slots.password.salt.length, 24);
++ // The open takes the salt and the count from the header, not from its own defaults.
++ assert.equal(decoder.decode(await locker.open(sealed, { password: 'right' })), 'a note');
++ // Weakening them in the header does not open the item: the tag covers them.
++ const bytes = sealed.bytes.slice();
++ const headerText = decoder.decode(bytes.subarray(8, sealed.chunkOffset));
++ bytes.set(text(headerText.replace('700000', '600000')), 8);
++ await fails(() => locker.open(bytes, { password: 'right' }), 'locker.wrong_key');
++ }],
++
++ ['the iteration count is bounded on both sides', async () => {
++ const sealed = await locker.seal('a note', { lock: 'password', password: 'right', iterations: 700000 });
++ // A hostile header must not be able to make a reader spend minutes on the derivation.
++ for (const iterations of [0, 1, 599999, 10000001, 1000000000]) {
++ const bytes = withHeader(sealed, (header) => ({
++ ...header,
++ slots: { password: { ...header.slots.password, iterations } },
++ }));
++ await fails(() => locker.open(bytes, { password: 'right' }), 'locker.bad_envelope');
++ }
++ // And the writer refuses a count outside the range too.
++ await fails(() => locker.seal('a note', { lock: 'password', password: 'right', iterations: 599999 }), 'locker.bad_envelope');
++ await fails(() => locker.seal('a note', { lock: 'password', password: 'right', iterations: 10000001 }), 'locker.bad_envelope');
++ // The bounds are the ones the contract names.
++ assert.equal(locker.PASSWORD_ITERATIONS_MIN, 600000);
++ assert.equal(locker.PASSWORD_ITERATIONS_MAX, 10000000);
++ assert.equal(locker.PASSWORD_ITERATIONS, 600000);
++ // A count inside the range is fine.
++ const fine = await locker.seal('a note', { lock: 'password', password: 'right', iterations: 10000000 });
++ assert.equal(decoder.decode(await locker.open(fine, { password: 'right' })), 'a note');
++ }],
++
++ ['a people slot is bound to the key of its recipient', async () => {
++ const sealed = await sealFor('a note');
++ const slot = sealed.header.slots.people[0];
++ // The slot names the key it is for, by the RFC 7638 thumbprint of that key.
++ const canonical = `{"crv":"P-256","kty":"EC","x":"${alice.ecdhPublic.x}","y":"${alice.ecdhPublic.y}"}`;
++ const expected = base64url(crypto.createHash('sha256').update(canonical).digest());
++ assert.equal(slot.recipient_key, expected);
++ assert.equal(slot.recipient_key, await locker.jwkThumbprint(alice.ecdhPublic));
++ assert.notEqual(slot.recipient_key, await locker.jwkThumbprint(bob.ecdhPublic));
++ // A slot that names another key is refused before anything is derived.
++ const swapped = { ...slot, recipient_key: await locker.jwkThumbprint(bob.ecdhPublic) };
++ await assert.rejects(
++ () => locker.unwrapPeopleSlot(swapped, alice.ecdh.privateKey),
++ (err) => err.code === 'locker.wrong_key',
++ );
++ // And an envelope with that slot in it fails the same way.
++ const bobThumbprint = await locker.jwkThumbprint(bob.ecdhPublic);
++ const bytes = withHeader(sealed, (header) => ({
++ ...header,
++ slots: { people: [{ ...slot, recipient_key: bobThumbprint }] },
++ }));
++ await fails(() => locker.open(bytes, { privateKey: alice.ecdh.privateKey, subject: A }), 'locker.wrong_key');
++ // A slot with no key named at all is not one we wrote.
++ const nameless = { ...slot };
++ delete nameless.recipient_key;
++ await assert.rejects(
++ () => locker.unwrapPeopleSlot(nameless, alice.ecdh.privateKey),
++ (err) => err.code === 'locker.bad_envelope',
++ );
++ }],
++
++ ['wrapFor wraps the content key for one person', async () => {
++ const contentKey = new Uint8Array(32).fill(7);
++ const slot = await locker.wrapFor(contentKey, bob.ecdhPublic, { subject: B });
++ assert.equal(slot.subject.id, B.id);
++ assert.equal(slot.algorithm, 'ECDH-P256/HKDF-SHA256/AES-256-GCM');
++ const back = await locker.unwrapPeopleSlot(slot, bob.ecdh.privateKey);
++ assert.equal(sha256(back), sha256(contentKey));
++ // Another person's key does not open it.
++ await assert.rejects(() => locker.unwrapPeopleSlot(slot, alice.ecdh.privateKey), (err) => err.code === 'locker.wrong_key');
++ }],
++
++ ['rewrap adds a person without touching the content', async () => {
++ const sealed = await sealFor('a note');
++ const again = await locker.rewrap(sealed, {
++ add: [{ subject: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPS' }, publicKey: alice.ecdhPublic }],
++ credentials: { privateKey: alice.ecdh.privateKey, subject: A },
++ });
++ assert.equal(again.header.slots.people.length, 3);
++ // The chunks are the same bytes: the slots are not part of what they authenticate.
++ assert.equal(
++ sha256(sealed.bytes.subarray(sealed.chunkOffset)),
++ sha256(again.bytes.subarray(again.chunkOffset)),
++ );
++ // Everyone on the list can still open it.
++ assert.equal(decoder.decode(await locker.open(again, { privateKey: alice.ecdh.privateKey, subject: A })), 'a note');
++ assert.equal(decoder.decode(await locker.open(again, { privateKey: bob.ecdh.privateKey, subject: B })), 'a note');
++ }],
++
++ ['removing a person re-seals with a new content key', async () => {
++ const sealed = await sealFor('a note');
++ const oldKey = await locker.unwrapPeopleSlot(sealed.header.slots.people[0], alice.ecdh.privateKey);
++ const again = await locker.rewrap(sealed, {
++ remove: [B],
++ keys: [{ subject: A, publicKey: alice.ecdhPublic }],
++ credentials: { privateKey: alice.ecdh.privateKey, subject: A },
++ });
++ // Only the person who stays is on it now.
++ assert.equal(again.header.slots.people.length, 1);
++ assert.equal(again.header.slots.people[0].subject.id, A.id);
++ assert.equal(decoder.decode(await locker.open(again, { privateKey: alice.ecdh.privateKey, subject: A })), 'a note');
++ // The person who is gone has no slot.
++ await fails(() => locker.open(again, { privateKey: bob.ecdh.privateKey, subject: B }), 'locker.no_key');
++ // The content key is a new one, so the key the person who left still holds opens nothing.
++ const newKey = await locker.unwrapPeopleSlot(again.header.slots.people[0], alice.ecdh.privateKey);
++ assert.notEqual(sha256(oldKey), sha256(newKey));
++ await assert.rejects(() => decryptFirstChunk(again, oldKey), 'the old content key opens nothing');
++ // The nonce base is new too, so the envelope is not the old bytes with a header.
++ assert.notEqual(again.header.nonce_base, sealed.header.nonce_base);
++ assert.notEqual(sha256(again.bytes), sha256(sealed.bytes));
++ // A removal needs the public keys of the people who stay.
++ await fails(() => locker.rewrap(sealed, {
++ remove: [B],
++ credentials: { privateKey: alice.ecdh.privateKey, subject: A },
++ }), 'locker.invalid');
++ }],
++
++ ['every seal makes a new key and a new nonce base', async () => {
++ const one = await locker.seal('the same note', { lock: 'password', password: 'x' });
++ const two = await locker.seal('the same note', { lock: 'password', password: 'x' });
++ // The same bytes under the same password are two different envelopes.
++ assert.notEqual(one.header.nonce_base, two.header.nonce_base);
++ assert.notEqual(sha256(one.bytes), sha256(two.bytes));
++ const keyOne = await locker.unwrapPasswordSlot(one.header.slots.password, 'x');
++ const keyTwo = await locker.unwrapPasswordSlot(two.header.slots.password, 'x');
++ assert.notEqual(sha256(keyOne), sha256(keyTwo));
++ // And both open to the same note.
++ assert.equal(decoder.decode(await locker.open(one, { password: 'x' })), 'the same note');
++ assert.equal(decoder.decode(await locker.open(two, { password: 'x' })), 'the same note');
++ }],
++
++ ['the streams round trip', async () => {
++ const content = new Uint8Array(CHUNK * 2 + 11);
++ for (let i = 0; i < content.length; i++) content[i] = (i * 3) % 251;
++ const source = new ReadableStream({
++ start(controller) { controller.enqueue(content); controller.close(); },
++ });
++ const sealed = locker.sealStream(source, { lock: 'password', password: 'x', size: content.length });
++ const reader = sealed.getReader();
++ const parts = [];
++ for (;;) {
++ const { value, done } = await reader.read();
++ if (done) break;
++ parts.push(value);
++ }
++ const bytes = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
++ let at = 0;
++ for (const part of parts) { bytes.set(part, at); at += part.length; }
++ const opened = await locker.open(bytes, { password: 'x' });
++ assert.equal(sha256(opened), sha256(content));
++ // And the other way round: a stream in, a stream out.
++ const back = locker.openStream(new ReadableStream({
++ start(controller) { controller.enqueue(bytes); controller.close(); },
++ }), { password: 'x' });
++ const reader2 = back.getReader();
++ const plain = [];
++ for (;;) {
++ const { value, done } = await reader2.read();
++ if (done) break;
++ plain.push(value);
++ }
++ const whole = new Uint8Array(plain.reduce((n, p) => n + p.length, 0));
++ at = 0;
++ for (const part of plain) { whole.set(part, at); at += part.length; }
++ assert.equal(sha256(whole), sha256(content));
++ }],
++
++ ['a stream seal needs the size of the content', async () => {
++ const source = new ReadableStream({ start(c) { c.close(); } });
++ assert.throws(() => locker.sealStream(source, { lock: 'password', password: 'x' }), (err) => err.code === 'locker.invalid');
++ }],
++
++ ['a stream that ends early yields what it had, then throws', async () => {
++ const content = new Uint8Array(CHUNK * 2 + 11);
++ const sealed = await locker.seal(content, { lock: 'password', password: 'x' });
++ const list = frames(sealed.bytes, sealed.chunkOffset);
++ assert.equal(list.length, 3);
++ // The last chunk never arrives: the earlier ones come out, and then the stream throws.
++ const cut = sealed.bytes.subarray(0, list[list.length - 1].start);
++ const stream = locker.openStream(new ReadableStream({
++ start(controller) { controller.enqueue(cut); controller.close(); },
++ }), { password: 'x' });
++ const reader = stream.getReader();
++ const parts = [];
++ let error = null;
++ try {
++ for (;;) {
++ const { value, done } = await reader.read();
++ if (done) break;
++ parts.push(value);
++ }
++ } catch (err) { error = err; }
++ assert.ok(error, 'the stream threw');
++ assert.equal(error.code, 'locker.truncated');
++ assert.equal(parts.length, 2, 'the two chunks that were there came out first');
++ // What came out is not the whole content: the item was never complete.
++ const got = parts.reduce((n, p) => n + p.length, 0);
++ assert.ok(got < content.length, 'the content was not complete');
++ }],
++
++ ['a 64 MiB stream is sealed in bounded memory', async () => {
++ // The check runs in its own process, with --expose-gc, so it can see what is really still
++ // held after a collection instead of counting what the collector has not got to yet.
++ const out = execFileSync(process.execPath, ['--expose-gc', path.join(__dirname, 'locker-memory.js')], {
++ encoding: 'utf8', maxBuffer: 1024 * 1024,
++ });
++ const { peak, total, firstFrame } = JSON.parse(out);
++ assert.ok(total > 64 * 1024 * 1024, 'the envelope holds the content');
++ assert.ok(firstFrame <= 4 * 1024 * 1024, `the seal read ${firstFrame} bytes before the first frame`);
++ assert.ok(peak < 32 * 1024 * 1024, `the seal held ${Math.round(peak / 1048576)} MiB for a 64 MiB stream`);
++ }],
++
++ ['the shared test vectors are the same bytes every time', async () => {
++ const person = {
++ ecdh: vectors.PERSON.ecdh,
++ signing: vectors.PERSON.signing,
++ ecdhPublic: vectors.PERSON.ecdhPublic,
++ signingPublic: vectors.PERSON.signingPublic,
++ };
++ const random = vectors.fixedRandom();
++ const people = await locker.seal(vectors.CONTENT, {
++ lock: 'people',
++ keys: [{ subject: vectors.SUBJECT, publicKey: person.ecdhPublic }],
++ random,
++ ephemeral: { private: person.ecdh },
++ });
++ const password = await locker.seal(vectors.CONTENT, {
++ lock: 'password', password: 'correct horse battery staple', random,
++ });
++ const link = await locker.seal(vectors.CONTENT, { lock: 'link', random });
++ const made = { people, password, link };
++ for (const testCase of vectors.CASES) {
++ assert.deepEqual(made[testCase.name].header, testCase.header, `the ${testCase.name} header`);
++ assert.equal(sha256(made[testCase.name].bytes), testCase.envelope_sha256, `the ${testCase.name} envelope`);
++ }
++ // The fingerprint of the vector's keys is the one the file says.
++ assert.equal(await locker.fingerprintOf(person.ecdhPublic, person.signingPublic), vectors.PERSON.fingerprint);
++ // The recovery phrase is the same words on every runtime.
++ assert.equal(locker.recoveryWords(24, { random: vectors.fixedRandom(0x5eed1e) }), vectors.RECOVERY.phrase);
++ assert.equal(locker.isRecoveryPhrase(vectors.RECOVERY.phrase), true);
++ assert.equal(vectors.RECOVERY.words.length, 24);
++ // And what inspect says about each case, without a key.
++ for (const testCase of vectors.CASES) {
++ assert.deepEqual(locker.inspect(made[testCase.name].bytes), vectors.INSPECT[testCase.name]);
++ }
++ // And the vectors open with the vector's keys.
++ const privateKey = await locker.importPersonKeys({ ecdh: person.ecdh, signing: person.signing });
++ assert.equal(
++ decoder.decode(await locker.open(people, { privateKey: privateKey.ecdhPrivateKey, subject: vectors.SUBJECT })),
++ vectors.CONTENT,
++ );
++ }],
++
++ ['a service can check a header without opening it', async () => {
++ const sealed = await sealFor('a note');
++ const checked = server.inspectEnvelope(sealed.bytes);
++ assert.equal(checked.valid, true, JSON.stringify(checked.errors));
++ assert.equal(checked.lock, 'people');
++ assert.equal(checked.paddedSize, 256);
++ assert.equal(checked.chunkCount, 1);
++ assert.equal(checked.expectedBytes, sealed.bytes.length);
++ // A header that says something else does not pass.
++ const bytes = withHeader(sealed, (header) => ({ ...header, padded_size: 100 }));
++ const bad = server.inspectEnvelope(bytes);
++ assert.equal(bad.valid, false);
++ assert.ok(bad.errors.length > 0);
++ // Bytes that are not an envelope at all.
++ assert.equal(server.inspectEnvelope(new Uint8Array([1, 2, 3])).valid, false);
++ assert.equal(server.inspectEnvelope(text('hello there, this is not an envelope')).valid, false);
++ }],
++
++ ['the headers the SDK writes are the contract', async () => {
++ const people = await sealFor('a note');
++ const password = await locker.seal('a note', { lock: 'password', password: 'x' });
++ const link = await locker.seal('a note', { lock: 'link' });
++ for (const sealed of [people, password, link]) {
++ const checked = contracts.validate('locker.envelope', sealed.header);
++ assert.equal(checked.valid, true, JSON.stringify(checked.errors));
++ }
++ // And the server's own checks agree with the contract.
++ assert.equal(server.checkHeader(people.header).length, 0);
++ assert.equal(server.checkHeader({ ...people.header, padded_size: 100 }).length > 0, true);
++ }],
++
++ ['a service checks the header without a key', async () => {
++ const sealed = await locker.seal('a note', { lock: 'password', password: 'x' });
++ const checked = server.inspectEnvelope(sealed.bytes);
++ assert.equal(checked.valid, true, JSON.stringify(checked.errors));
++ assert.equal(checked.lock, 'password');
++ assert.equal(JSON.stringify(checked.header).includes('a note'), false, 'the header holds no content');
++ }],
++]);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex 3a49652..00d5e3e 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -24,7 +24,7 @@ run([
const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8', shell });
const info = JSON.parse(out)[0];
const files = info.files.map((f) => f.path);
-- for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/vibes.js', 'esm/vibes.mjs', 'types/vibes.d.ts', 'src/bot.js', 'esm/bot.mjs', 'types/bot.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'src/resources.js', 'esm/resources.mjs', 'types/resources.d.ts', 'src/vision.js', 'esm/vision.mjs', 'types/vision.d.ts', 'src/redact.js', 'esm/redact.mjs', 'types/redact.d.ts', 'docs/redact.md', 'src/share.js', 'src/share/store.js', 'esm/share.mjs', 'types/share.d.ts', 'docs/share.md', 'browser/openvibe-sdk.mjs']) {
++ for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/vibes.js', 'esm/vibes.mjs', 'types/vibes.d.ts', 'src/bot.js', 'esm/bot.mjs', 'types/bot.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'src/resources.js', 'esm/resources.mjs', 'types/resources.d.ts', 'src/vision.js', 'esm/vision.mjs', 'types/vision.d.ts', 'src/redact.js', 'esm/redact.mjs', 'types/redact.d.ts', 'docs/redact.md', 'src/share.js', 'src/share/store.js', 'esm/share.mjs', 'types/share.d.ts', 'docs/share.md', 'src/locker.js', 'src/locker/seal.js', 'src/locker/server.js', 'esm/locker.mjs', 'esm/locker-server.mjs', 'types/locker.d.ts', 'types/locker-server.d.ts', 'types/locker-vectors.d.ts', 'esm/locker-vectors.mjs', 'docs/locker.md', 'browser/openvibe-sdk.mjs']) {
assert.ok(files.includes(must), `tarball contains ${must}`);
}
assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');
@@@ -68,7 +68,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
-- assert.equal(subpaths.length, 78); // 0.85.0 adds the server-only openvibe-sdk/share entry
++ assert.equal(subpaths.length, 81); // 0.86.0 adds openvibe-sdk/locker, its server and its vectors
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/types/bundle.d.ts b/types/bundle.d.ts
iindex 2cdde15..15d7283 100644
---- a/types/bundle.d.ts
++++ b/types/bundle.d.ts
@@@ -18,6 +18,7 @@ import * as jobs from './jobs';
import * as tools from './tools';
import * as projects from './projects';
import * as geo from './geo';
++import * as locker from './locker';
import * as core from './core';
export * from './core';
@@@ -40,9 +41,10 @@ export * as jobs from './jobs';
export * as tools from './tools';
export * as projects from './projects';
export * as geo from './geo';
++export * as locker from './locker';
declare const sdk: typeof core & {
auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;
-- media: typeof media; community: typeof community; bot: typeof bot; search: typeof search; bench: typeof bench; meme: typeof meme; vision: typeof vision; themes: typeof themes; redact: typeof redact; chat: typeof chat; ai: typeof ai; jobs: typeof jobs; tools: typeof tools; projects: typeof projects; geo: typeof geo;
++ media: typeof media; community: typeof community; bot: typeof bot; search: typeof search; bench: typeof bench; meme: typeof meme; vision: typeof vision; themes: typeof themes; redact: typeof redact; chat: typeof chat; ai: typeof ai; jobs: typeof jobs; tools: typeof tools; projects: typeof projects; geo: typeof geo; locker: typeof locker;
};
export default sdk;
ddiff --git a/types/index.d.ts b/types/index.d.ts
iindex e161d8a..edc51ac 100644
---- a/types/index.d.ts
++++ b/types/index.d.ts
@@@ -48,3 +48,4 @@ export * as usage from './usage';
export * as commerce from './commerce';
export * as telemetry from './telemetry';
export const share: typeof import('./share').share;
++export * as locker from './locker';
ddiff --git a/types/locker-server.d.ts b/types/locker-server.d.ts
nnew file mode 100644
iindex 0000000..e5e85d6
---- /dev/null
++++ b/types/locker-server.d.ts
@@@ -0,0 +1,31 @@
++/**
++ * openvibe-sdk/locker/server: 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.
++ */
++import type { LockerHeader, LockKind } from './locker';
++
++/** What an envelope holds, as far as a service may know. */
++export interface EnvelopeInfo {
++ /** Whether the header and the size pass every check. */
++ valid: boolean;
++ /** What is wrong, in words, when something is. */
++ errors: string[];
++ /** The header itself. It holds no key material, only wrapped keys. */
++ header: LockerHeader | null;
++ headerLength: number;
++ chunkOffset: number;
++ lock: LockKind | undefined;
++ /** The size of the padded plaintext: a whole number of chunks. */
++ paddedSize: number | undefined;
++ chunkCount: number | null;
++ /** The bytes the envelope must have, if its own header is true. */
++ expectedBytes: number | null;
++}
++
++/** Reads an envelope's header and checks its shape and its size. Nothing is decrypted. */
++export function inspectEnvelope(bytes: Uint8Array, options?: { maxHeaderBytes?: number }): EnvelopeInfo;
++
++/** The header's shape: what it says, and whether the slots match the lock it names. */
++export function checkHeader(header: unknown): string[];
ddiff --git a/types/locker-vectors.d.ts b/types/locker-vectors.d.ts
nnew file mode 100644
iindex 0000000..21ff048
---- /dev/null
++++ b/types/locker-vectors.d.ts
@@@ -0,0 +1,48 @@
++/**
++ * openvibe-sdk/locker/vectors: the shared test vectors.
++ *
++ * The browser module (openvibe-shared/ov-locker.js) runs the same cases, so both runtimes must
++ * produce the same bytes for the same input: the same header, the same envelope, the same eight
++ * words, the same recovery phrase and the same reading of each envelope.
++ */
++import type { LockerHeader, PublicKeyJwk } from './locker';
++
++/** A fixed byte source: the same bytes every time. A test helper, never for anything real. */
++export function fixedRandom(seed?: number): (length: number) => Uint8Array;
++
++/** The content of every case. */
++export const CONTENT: string;
++
++/** A person's keys, made for these vectors. */
++export const PERSON: {
++ ecdh: Record<string, unknown>;
++ signing: Record<string, unknown>;
++ ecdhPublic: PublicKeyJwk;
++ signingPublic: PublicKeyJwk;
++ fingerprint: string;
++};
++
++/** The subject the people case is for. */
++export const SUBJECT: { type: string; id: string };
++
++/** One case: the same content, sealed one way, and what it must produce. */
++export interface VectorCase {
++ name: string;
++ envelope_sha256: string;
++ header: LockerHeader;
++}
++
++/** The cases. */
++export const CASES: VectorCase[];
++
++/** A recovery phrase from a fixed byte source: the same words on every runtime. */
++export const RECOVERY: { phrase: string; words: string[] };
++
++/** What inspect() says about each case: the header, read without a key. */
++export const INSPECT: Record<string, {
++ version: string;
++ lock: string;
++ padded_size: number;
++ recipients: string[];
++ iterations: number | null;
++}>;
ddiff --git a/types/locker.d.ts b/types/locker.d.ts
nnew file mode 100644
iindex 0000000..f61c04e
---- /dev/null
++++ b/types/locker.d.ts
@@@ -0,0 +1,206 @@
++/**
++ * 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.
++ */
++export type LockKind = 'people' | 'password' | 'link';
++export type SubjectRef = { type: string; id: string };
++
++/** A P-256 public key as a JWK. */
++export interface PublicKeyJwk { kty: 'EC'; crv: 'P-256'; x: string; y: string; ext?: boolean; key_ops?: string[] }
++
++/** The key slot of one person: the content key, wrapped for their public key. */
++export interface PeopleSlot {
++ subject: SubjectRef;
++ algorithm: 'ECDH-P256/HKDF-SHA256/AES-256-GCM';
++ /** The RFC 7638 thumbprint of the recipient's ECDH public key: the slot is bound to it. */
++ recipient_key: string;
++ ephemeral_public_key: PublicKeyJwk;
++ salt: string;
++ nonce: string;
++ wrapped_key: string;
++}
++
++/** The key slot of a password: the content key, wrapped with PBKDF2-SHA-256. */
++export interface PasswordSlot {
++ algorithm: 'PBKDF2-SHA256/AES-256-GCM';
++ kdf: 'PBKDF2-SHA-256';
++ iterations: number;
++ salt: string;
++ nonce: string;
++ wrapped_key: string;
++}
++
++/** The header of an envelope. The content key is never in it. */
++export interface LockerHeader {
++ version: 'locker.envelope.v1';
++ suite: 'AES-256-GCM';
++ chunk_size: number;
++ padded_size: number;
++ nonce_base: string;
++ lock: LockKind;
++ slots: { people?: PeopleSlot[]; password?: PasswordSlot; link?: Record<string, never> };
++}
++
++/** What an envelope says about itself: the header, read without a key. */
++export interface EnvelopeInfo {
++ version: string;
++ lock: LockKind;
++ padded_size: number;
++ /** For a people lock: the thumbprint of each recipient's key. */
++ recipients: string[];
++ /** For a password lock: the PBKDF2 iteration count. */
++ iterations: number | null;
++}
++
++/** A sealed envelope: its bytes and its header. */
++export interface Envelope {
++ version: string;
++ header: LockerHeader;
++ headerBytes: Uint8Array;
++ bytes: Uint8Array;
++ chunkOffset: number;
++ /** A link lock only: the content key, which travels in the part of the link after the #. */
++ linkKey?: string;
++}
++
++/** A person's two key pairs, in memory. */
++export interface PersonKeys {
++ ecdh: { publicKey: CryptoKey; privateKey: CryptoKey };
++ signing: { publicKey: CryptoKey; privateKey: CryptoKey };
++ ecdhPublic: PublicKeyJwk;
++ signingPublic: PublicKeyJwk;
++ fingerprint: string;
++ fingerprintSignature: Uint8Array;
++}
++
++/** A person's public keys, as they are published: locker.public-keys@1. */
++export interface PublicKeys {
++ ecdh: PublicKeyJwk;
++ signing: PublicKeyJwk;
++ fingerprint: string;
++ fingerprint_signature: string;
++}
++
++/** What a seal needs. */
++export interface SealOptions {
++ lock: LockKind;
++ /** A people lock: the recipients, each with their public key. */
++ keys?: Array<{ subject: SubjectRef; publicKey: PublicKeyJwk }>;
++ /** A password lock. */
++ password?: string;
++ /** A link lock: the content key, if the caller has one. */
++ contentKey?: Uint8Array;
++ /** The PBKDF2 iteration count of a password lock. */
++ iterations?: number;
++ /** Test vectors only: a fixed byte source instead of the platform's. */
++ random?: (length: number) => Uint8Array;
++ /** Test vectors only: a fixed ephemeral key pair. */
++ ephemeral?: { private?: Record<string, unknown>; privateKey?: CryptoKey; publicKey?: CryptoKey };
++}
++
++/** What an open needs: the credentials of the lock it names. */
++export interface OpenCredentials {
++ /** A people lock: the private key of a recipient. */
++ privateKey?: CryptoKey | Record<string, unknown>;
++ /** A people lock: which person you are. */
++ subject?: SubjectRef | string;
++ /**
++ * A people lock: the person who is opening, with their ECDH key. The slot that names their
++ * key is found without knowing anybody's subject; an envelope with no slot for that key
++ * fails with locker.no_slot.
++ */
++ person?: {
++ ecdhPublic?: PublicKeyJwk;
++ ecdh?: { publicKey?: CryptoKey; privateKey?: CryptoKey } | PublicKeyJwk;
++ ecdhPrivateKey?: CryptoKey;
++ privateKey?: CryptoKey;
++ kty?: string;
++ crv?: string;
++ x?: string;
++ y?: string;
++ };
++ /** A password lock. */
++ password?: string;
++ /** A link lock: the key from the link. */
++ key?: Uint8Array | string;
++}
++
++/** What a re-wrap needs: who to add, who to drop, and how to open it first. */
++export interface RewrapOptions {
++ add?: Array<{ subject: SubjectRef; publicKey: PublicKeyJwk }>;
++ remove?: Array<SubjectRef | string>;
++ /** The public keys of the people who stay: a removal re-seals, and that needs their keys. */
++ keys?: Array<{ subject: SubjectRef; publicKey: PublicKeyJwk }>;
++ credentials?: OpenCredentials;
++ random?: (length: number) => Uint8Array;
++ ephemeral?: { private?: Record<string, unknown>; privateKey?: CryptoKey; publicKey?: CryptoKey };
++}
++
++export class LockerError extends Error {
++ code: string;
++}
++
++/** Seals content: a string or bytes. */
++export function seal(content: string | Uint8Array | ArrayBuffer, options: SealOptions): Promise<Envelope>;
++/** Opens an envelope: the content as bytes. */
++export function open(envelope: Envelope | Uint8Array, credentials?: OpenCredentials): Promise<Uint8Array>;
++/** Seals a stream: the envelope comes out piece by piece. The size of the content is needed. */
++export function sealStream(stream: ReadableStream<Uint8Array>, options: SealOptions & { size: number }): ReadableStream<Uint8Array>;
++/**
++ * Opens a stream: the content comes out piece by piece, as the chunks are verified. A stream that
++ * ends early throws at the end, when the missing chunk shows up: discard what it yielded.
++ */
++export function openStream(stream: ReadableStream<Uint8Array>, credentials?: OpenCredentials): ReadableStream<Uint8Array>;
++/** Wraps the content key for other people, without touching the content. */
++export function rewrap(envelope: Envelope | Uint8Array, options?: RewrapOptions): Promise<Envelope>;
++
++/** A person's two key pairs, made in the browser. */
++export function generatePersonKeys(): Promise<PersonKeys>;
++/** The public half of a person's keys, as it is published. */
++export function exportPersonKeys(person: PersonKeys): Promise<PublicKeys>;
++/** The private half of a person's keys, as it is stored. */
++export function exportPrivateKeys(person: PersonKeys): Promise<{ ecdh: Record<string, unknown>; signing: Record<string, unknown> }>;
++/** A person's keys, ready to use. */
++export function importPersonKeys(data: { ecdh?: Record<string, unknown>; signing?: Record<string, unknown>; ecdhPrivateKey?: CryptoKey; signingPrivateKey?: CryptoKey }): Promise<{ ecdhPrivateKey: CryptoKey; signingPrivateKey: CryptoKey; ecdhPublic: PublicKeyJwk; signingPublic: PublicKeyJwk }>;
++/** Checks a published set of keys: the signature and the eight words. */
++export function verifyPersonKeys(data: PublicKeys): Promise<boolean>;
++/** The eight words of a pair of public keys. */
++export function fingerprintOf(ecdhPublic: PublicKeyJwk, signingPublic: PublicKeyJwk): Promise<string>;
++/** The eight words of a hash. */
++export function fingerprintWords(hash: Uint8Array): string;
++/** The RFC 7638 thumbprint of a public key: SHA-256 over the canonical JSON of its members, base64url. */
++export function jwkThumbprint(jwk: PublicKeyJwk): Promise<string>;
++/** The padded size of a content of this size: Padme's grid, never below the floor. */
++export function padme(size: number): number;
++
++/** What an envelope says about itself, without a key and without decrypting anything. */
++export function inspect(bytes: Uint8Array | Envelope): EnvelopeInfo;
++
++/** A recovery phrase: n words from the list, one byte each. */
++export function recoveryWords(count?: number, options?: { random?: (length: number) => Uint8Array }): string;
++/** Whether every word of a typed phrase is one of the 256. */
++export function isRecoveryPhrase(phrase: string): boolean;
++
++/** Wraps the content key for one person. */
++export function wrapFor(contentKey: Uint8Array, publicKey: PublicKeyJwk, options: { subject: SubjectRef; salt?: Uint8Array; random?: (length: number) => Uint8Array; ephemeral?: { private?: Record<string, unknown>; privateKey?: CryptoKey; publicKey?: CryptoKey } }): Promise<PeopleSlot>;
++/** Wraps the content key with a password. */
++export function wrapForPassword(contentKey: Uint8Array, password: string, options?: { salt?: Uint8Array; iterations?: number; random?: (length: number) => Uint8Array }): Promise<PasswordSlot>;
++/** The content key out of a people slot. */
++export function unwrapPeopleSlot(slot: PeopleSlot, privateKey: CryptoKey): Promise<Uint8Array>;
++/** The content key out of a password slot. */
++export function unwrapPasswordSlot(slot: PasswordSlot, password: string): Promise<Uint8Array>;
++
++export const VERSION: string;
++export const SUITE: string;
++export const CHUNK_SIZE: number;
++export const MIN_PADDED_SIZE: number;
++export const MAX_HEADER_BYTES: number;
++export const PASSWORD_ITERATIONS: number;
++export const PASSWORD_ITERATIONS_MIN: number;
++export const PASSWORD_ITERATIONS_MAX: number;
++/** How many words a recovery phrase has, unless the caller says otherwise. */
++export const RECOVERY_WORDS: number;
++/** The 256 words a fingerprint and a recovery phrase are made of. */
++export const WORDS: string[];