/* Generated by scripts/build-locker-core.js from openvibe-sdk 0.160.0 (src/locker). Do not edit: change the SDK, * release it, bump the pin, and run the script. The SDK's files, unchanged but for the names of require and module: * bytes.js 80a5a560c683f489 * errors.js e73f94b7711452e7 * format.js f7793a59fdb67ad9 * index.js eee174e3a87df494 * inspect.js 2f28ff0da8c96a4e * keys.js fbe20e7949924f2b * padme.js e0dd4b08e0d0f24f * recovery.js 2829d07a02561674 * seal.js ff6ad448082c636c * server.js 6177fc986b2b705d * words.js 8f4f51abdadfbfd1 */ (function (root) { 'use strict'; if (!root || root.OVLockerCore) return; const defs = { "bytes": function (__ovLockerModule, exports, __ovLockerReq) { 'use strict'; /** * Byte helpers for the Locker envelope. Framing only, no cryptography: every value is a * Uint8Array, so the same code runs in Node and in the browser. */ const encoder = new TextEncoder(); const decoder = new TextDecoder(); /** The UTF-8 bytes of a string. */ function utf8(text) { return encoder.encode(text); } /** The string of UTF-8 bytes. */ function fromUtf8(bytes) { return decoder.decode(bytes); } /** One buffer of all the parts, in order. */ function concat(...parts) { let total = 0; for (const part of parts) total += part.length; const out = new Uint8Array(total); let at = 0; for (const part of parts) { out.set(part, at); at += part.length; } return out; } /** Standard base64. btoa/atob are in every browser and in Node. */ function base64(bytes) { let binary = ''; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary); } /** The bytes of a standard base64 string. */ function fromBase64(text) { const binary = atob(text); const out = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); return out; } /** base64url: the same alphabet without the padding, for keys that travel in a URL. */ function base64url(bytes) { return base64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } /** The bytes of a base64url string. */ function fromBase64url(text) { const padded = text.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (text.length % 4)) % 4); return fromBase64(padded); } /** A number as four bytes, big endian. */ function u32be(value) { const out = new Uint8Array(4); new DataView(out.buffer).setUint32(0, value >>> 0, false); return out; } /** The number four bytes at `at` hold, big endian. */ function readU32be(bytes, at) { return new DataView(bytes.buffer, bytes.byteOffset + at, 4).getUint32(0, false); } /** A number as eight bytes, big endian. */ function u64be(value) { const out = new Uint8Array(8); new DataView(out.buffer).setBigUint64(0, BigInt(value), false); return out; } /** The number eight bytes at `at` hold, big endian. */ function readU64be(bytes, at) { return new DataView(bytes.buffer, bytes.byteOffset + at, 8).getBigUint64(0, false); } /** * The canonical JSON of a value: object keys sorted, no whitespace. Two sides that hold the * same value therefore hold the same bytes, which is what a signature or an associated data * needs. (JSON.stringify would keep the order the object was built in, which is not a promise * we can make across two implementations.) */ function canonicalJson(value) { if (value === null || typeof value !== 'object') return JSON.stringify(value); if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; const keys = Object.keys(value).filter(key => value[key] !== undefined).sort(); return `{${keys.map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`; } __ovLockerModule.exports = { utf8, fromUtf8, concat, base64, fromBase64, base64url, fromBase64url, u32be, readU32be, u64be, readU64be, canonicalJson, }; }, "errors": function (__ovLockerModule, exports, __ovLockerReq) { 'use strict'; /** * The one error Locker throws. The code says what went wrong, so a caller can tell a wrong * password from a truncated download from bytes that are not an envelope at all. * * The codes: locker.invalid (the caller asked for something impossible), locker.bad_envelope * (the bytes are not an envelope we wrote), locker.truncated (the bytes stop early), * locker.wrong_key (the key is not the one), locker.no_key (no key was given), locker.no_slot * (there is no slot for this key), locker.size_mismatch (the size does not add up) and * locker.no_crypto (the runtime has no WebCrypto). */ class LockerError extends Error { constructor(code, message) { super(message); this.name = 'LockerError'; this.code = code; } } /** A Locker error, or a plain Error with the same shape. */ function fail(code, message) { throw new LockerError(code, message); } __ovLockerModule.exports = { LockerError, fail }; }, "format": function (__ovLockerModule, exports, __ovLockerReq) { 'use strict'; /** * The Locker envelope on the wire (locker.envelope@1). This file is the format and nothing * else: the numbers, the framing and the associated data. The cryptography lives in keys.js * and seal.js; the rules for a service that only reads the header live in server.js. * * 0 4 8 8 + header length * +------+------+--------------------------+--------------------------+ * | OVLK | len | header JSON (UTF-8) | chunk frames | * +------+------+--------------------------+--------------------------+ * * each chunk frame: * 0 4 5 17 * +------+------+--------------------------+--------------------------+ * | len | flags| nonce (12 bytes) | ciphertext + 16-byte tag | * +------+------+--------------------------+--------------------------+ */ const { utf8, fromUtf8, concat, base64, fromBase64, u32be, readU32be, canonicalJson } = __ovLockerReq('./bytes'); const { fail } = __ovLockerReq('./errors'); const { padme } = __ovLockerReq('./padme'); /** The four bytes that say "this is a Locker envelope". */ const MAGIC = 'OVLK'; /** The format this library reads and writes. */ const VERSION = 'locker.envelope.v1'; /** The only suite in this version: AES-256-GCM, a 32-byte key. */ const SUITE = 'AES-256-GCM'; /** The chunk size in bytes: 1 MiB. */ const CHUNK_SIZE = 1048576; /** The GCM tag is appended to the ciphertext. */ const TAG_SIZE = 16; /** The GCM nonce is 12 bytes. */ const NONCE_SIZE = 12; /** The first half of every nonce: eight random bytes, in the header. */ const NONCE_BASE_SIZE = 8; /** The padded plaintext starts with the true length, as eight bytes. */ const LENGTH_PREFIX = 8; /** * The smallest padded plaintext we write. A short note still costs 256 bytes: below that the * padded length would say too much about the real one (Padmé's grid is coarse down there), and * 256 bytes is nothing next to a chunk. */ const MIN_PADDED_SIZE = 256; /** A header is small. A bigger one is not a header we wrote, and a service must not read it. */ const MAX_HEADER_BYTES = 65536; /** The bit that marks the last chunk of an envelope. */ const FINAL_FLAG = 0x01; /** * The header fields every chunk authenticates. The slots are not in here, on purpose: adding a * member to a people-locked item re-wraps the content key without re-encrypting the content, so * the core cannot change either. Each slot is authenticated by its own tag instead. */ function coreOf(header) { return { version: header.version, suite: header.suite, chunk_size: header.chunk_size, padded_size: header.padded_size, nonce_base: header.nonce_base, lock: header.lock, }; } /** The canonical JSON of the core: the same bytes on both sides, whatever order the header was written in. */ function coreBytes(header) { return utf8(canonicalJson(coreOf(header))); } /** The associated data of one chunk: the core, the chunk counter and the flags. */ function chunkAad(header, counter, flags) { return concat(coreBytes(header), u32be(counter), Uint8Array.of(flags)); } /** The header as the bytes that go on the wire. */ function encodeHeader(header) { return utf8(canonicalJson(header)); } /** The header's JSON bytes, as they were written. */ function decodeHeader(headerBytes) { let header; try { header = JSON.parse(fromUtf8(headerBytes)); } catch { fail('locker.bad_envelope', 'The envelope header is not JSON'); } return header; } /** The whole envelope: the magic, the header and the chunks. */ function encodeEnvelope(headerBytes, chunks) { return concat(utf8(MAGIC), u32be(headerBytes.length), headerBytes, chunks); } /** * Reads the header out of envelope bytes. It stops at the header: the chunks are not touched, * so this is safe to do on bytes that are not an envelope at all. */ function readHeader(bytes) { if (!bytes || bytes.length < 8) fail('locker.bad_envelope', 'The envelope is too short to hold a header'); const magic = fromUtf8(bytes.slice(0, 4)); if (magic !== MAGIC) fail('locker.bad_envelope', 'These bytes are not a Locker envelope'); const headerLength = readU32be(bytes, 4); if (headerLength === 0 || headerLength > MAX_HEADER_BYTES) { fail('locker.bad_envelope', `The envelope header is ${headerLength} bytes, which is not one we write`); } if (bytes.length < 8 + headerLength) fail('locker.truncated', 'The envelope ends inside its header'); const headerBytes = bytes.slice(8, 8 + headerLength); return { header: decodeHeader(headerBytes), headerBytes, chunkOffset: 8 + headerLength }; } /** * How many chunks a padded plaintext of this size takes. Every chunk but the last is a whole * chunk; the last holds what is left of it. */ function chunkCount(padded) { return Math.max(1, Math.ceil(padded / CHUNK_SIZE)); } /** How many bytes the last chunk of a padded plaintext of this size holds: more than zero. */ function lastChunkSize(padded) { return padded - (chunkCount(padded) - 1) * CHUNK_SIZE; } /** * The padded size of a content of this size: the length prefix plus the content, padded with * Padmé up to the next size on its grid, and never below the floor. */ function paddedSize(size) { return Math.max(MIN_PADDED_SIZE, padme(LENGTH_PREFIX + size)); } /** The nonce of a chunk: the header's base, then the counter. No nonce repeats inside an envelope. */ function chunkNonce(nonceBase, counter) { const nonce = new Uint8Array(NONCE_SIZE); nonce.set(nonceBase, 0); new DataView(nonce.buffer).setUint32(8, counter >>> 0, false); return nonce; } /** One chunk frame: its length, its flags, its nonce and the ciphertext with the tag. */ function encodeChunk(counter, flags, nonce, ciphertext) { return concat(u32be(ciphertext.length), Uint8Array.of(flags), nonce, ciphertext); } /** The header's nonce base as bytes. */ function nonceBaseBytes(header) { const bytes = fromBase64(header.nonce_base); if (bytes.length !== NONCE_BASE_SIZE) fail('locker.bad_envelope', 'The envelope nonce base is not eight bytes'); return bytes; } __ovLockerModule.exports = { MAGIC, VERSION, SUITE, CHUNK_SIZE, TAG_SIZE, NONCE_SIZE, NONCE_BASE_SIZE, LENGTH_PREFIX, MIN_PADDED_SIZE, MAX_HEADER_BYTES, FINAL_FLAG, coreOf, coreBytes, chunkAad, encodeHeader, decodeHeader, encodeEnvelope, readHeader, chunkCount, lastChunkSize, paddedSize, chunkNonce, encodeChunk, nonceBaseBytes, }; }, "index": function (__ovLockerModule, exports, __ovLockerReq) { 'use strict'; /** * openvibe-sdk/locker: end-to-end encryption for anything on OpenVibe. * * One format, one library, two runtimes: Node and the browser, WebCrypto only. A locked item is * stored and served as ciphertext; the keys never reach our servers. * * const { seal, open, generatePersonKeys } = locker; // openvibe-sdk/locker * const person = await generatePersonKeys(); * const sealed = await seal('a note', { lock: 'people', keys: [{ subject, publicKey: person.ecdhPublic }] }); * const note = await open(sealed, { privateKey: person.ecdh.privateKey, subject }); */ const format = __ovLockerReq('./format'); const keys = __ovLockerReq('./keys'); const seal = __ovLockerReq('./seal'); const { padme } = __ovLockerReq('./padme'); const { inspect } = __ovLockerReq('./inspect'); const { recoveryWords, isRecoveryPhrase, RECOVERY_WORDS } = __ovLockerReq('./recovery'); const { WORDS } = __ovLockerReq('./words'); const { LockerError } = __ovLockerReq('./errors'); __ovLockerModule.exports = { // The format. VERSION: format.VERSION, SUITE: format.SUITE, CHUNK_SIZE: format.CHUNK_SIZE, MIN_PADDED_SIZE: format.MIN_PADDED_SIZE, MAX_HEADER_BYTES: format.MAX_HEADER_BYTES, PASSWORD_ITERATIONS: keys.PASSWORD_ITERATIONS, PASSWORD_ITERATIONS_MIN: keys.PASSWORD_ITERATIONS_MIN, PASSWORD_ITERATIONS_MAX: keys.PASSWORD_ITERATIONS_MAX, // The content. seal: seal.seal, open: seal.open, sealStream: seal.sealStream, openStream: seal.openStream, rewrap: seal.rewrap, // What an envelope says about itself, without a key. inspect, // The padding: the padded size of a content of a given size. padme, // The recovery phrase: words to write down, and a check on a typed one. recoveryWords, isRecoveryPhrase, RECOVERY_WORDS, WORDS, // A person's keys, and the eight words that say which keys they are. generatePersonKeys: keys.generatePersonKeys, exportPersonKeys: keys.exportPersonKeys, exportPrivateKeys: keys.exportPrivateKeys, importPersonKeys: keys.importPersonKeys, verifyPersonKeys: keys.verifyPersonKeys, fingerprintOf: keys.fingerprintOf, fingerprintWords: keys.fingerprintWords, jwkThumbprint: keys.jwkThumbprint, // The key slots: the content key, wrapped for one person or for one password. wrapFor: keys.wrapFor, wrapForPassword: keys.wrapForPassword, unwrapPeopleSlot: keys.unwrapPeopleSlot, unwrapPasswordSlot: keys.unwrapPasswordSlot, LockerError, }; }, "inspect": function (__ovLockerModule, exports, __ovLockerReq) { 'use strict'; /** * inspect: what an envelope says about itself, without a key and without decrypting anything. * * A service, or a page that shows a locked item, often needs to know the lock kind, how big * the item is, who it is for and how strong the password is — before it has any key at all. * This reads the header and nothing else: no chunk is touched and no key is used. * * The header holds no content, only wrapped keys, so this says nothing the server does not * already see. */ const { envelopeBytes, readCore } = __ovLockerReq('./seal'); const { readHeader } = __ovLockerReq('./format'); /** * What an envelope says about itself. * * { version, lock, padded_size, recipients, iterations } * * `recipients` holds the thumbprint of each recipient's key for a people lock (the same * strings the slots carry), and `iterations` the PBKDF2 count for a password lock. The other * field is empty for the other lock kinds. */ function inspect(bytes) { const data = envelopeBytes(bytes); const { header } = readHeader(data); readCore(header); const out = { version: header.version, lock: header.lock, padded_size: header.padded_size, recipients: [], iterations: null, }; if (header.lock === 'people') { out.recipients = (header.slots.people || []).map(slot => slot.recipient_key); } else if (header.lock === 'password' && header.slots.password) { out.iterations = header.slots.password.iterations; } return out; } __ovLockerModule.exports = { inspect }; }, "keys": function (__ovLockerModule, exports, __ovLockerReq) { 'use strict'; /** * The keys: a person's two key pairs, the content key, and the two ways of wrapping the content * key for someone else (their public key, or a password). * * WebCrypto only: globalThis.crypto.subtle. No other crypto library, nothing hand-rolled: the * ECDH, the ECDSA, the HKDF, the PBKDF2 and the AES-GCM are all the browser's own. */ const { utf8, concat, base64, base64url, fromBase64, canonicalJson } = __ovLockerReq('./bytes'); const { fail } = __ovLockerReq('./errors'); const { WORDS } = __ovLockerReq('./words'); /** The suite of the key pairs: P-256, which every browser's WebCrypto has. */ const CURVE = 'P-256'; /** How the content key is wrapped for a person. */ const PEOPLE_ALGORITHM = 'ECDH-P256/HKDF-SHA256/AES-256-GCM'; /** How the content key is wrapped for a password. */ const PASSWORD_ALGORITHM = 'PBKDF2-SHA256/AES-256-GCM'; /** The PBKDF2 floor for this version. The count may go up; it may not go down. */ const PASSWORD_ITERATIONS = 600000; /** The smallest PBKDF2 count we accept, on both sides: the floor above. */ const PASSWORD_ITERATIONS_MIN = 600000; /** * The largest PBKDF2 count we accept. The count comes from the header, so a hostile header could * ask for a derivation that takes hours; the ceiling keeps a browser honest. */ const PASSWORD_ITERATIONS_MAX = 10000000; /** The content key is 32 bytes: AES-256. */ const KEY_BYTES = 32; /** The salt of a people slot (HKDF) and of a password slot (PBKDF2). */ const SALT_BYTES = 16; /** WebCrypto, or a clear error when the runtime has none. */ function subtle() { const crypto = globalThis.crypto; if (!crypto || !crypto.subtle) { fail('locker.no_crypto', 'This runtime has no WebCrypto (globalThis.crypto.subtle)'); } return crypto.subtle; } /** Random bytes from the platform. */ function platformRandom(length) { const bytes = new Uint8Array(length); globalThis.crypto.getRandomValues(bytes); return bytes; } /** The SHA-256 of some bytes. */ async function sha256(bytes) { return new Uint8Array(await subtle().digest('SHA-256', bytes)); } /** The bytes of a public JWK, as they go on the wire: no key_ops, no ext. */ function publicJwk(jwk) { return { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }; } /** The message a person signs: both public keys, canonically. */ function fingerprintMessage(ecdhPublic, signingPublic) { return utf8(canonicalJson({ ecdh: publicJwk(ecdhPublic), signing: publicJwk(signingPublic) })); } /** * The eight words of a fingerprint: the first eight bytes of SHA-256, eight bits each, in two * groups of four. Sixty-four bits, not thirty-two: a server that swapped in its own key would * need about 2^32 key generations to land on the same four words, which is not "verify a * person"; with eight words it would need 2^64. * * The two groups are joined with a middle dot, not a hyphen: a hyphen reads as one compound * word ("plateau yew-apple petal"), and the groups are meant to be read apart. */ function fingerprintWords(hash) { const words = []; for (let i = 0; i < 8; i++) words.push(WORDS[hash[i]]); return `${words.slice(0, 4).join(' ')} · ${words.slice(4).join(' ')}`; } /** * The RFC 7638 thumbprint of a public JWK: SHA-256 over the canonical JSON of its four * required members, base64url. It names one key and nothing else, so a slot can be bound to * the key of the person it is for. */ async function jwkThumbprint(jwk) { const canonical = canonicalJson({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }); return base64url(await sha256(utf8(canonical))); } /** The fingerprint of a pair of public keys. */ async function fingerprintOf(ecdhPublic, signingPublic) { return fingerprintWords(await sha256(fingerprintMessage(ecdhPublic, signingPublic))); } /** * A person's two key pairs: an ECDH pair that receives content keys, and an ECDSA pair that signs * the fingerprint. The signing key signs both public keys, so the pair is self-certifying: a * reader can check that the holder owns the signing key, and the fingerprint changes whenever * either key changes. */ async function generatePersonKeys() { const s = subtle(); const ecdh = await s.generateKey({ name: 'ECDH', namedCurve: CURVE }, true, ['deriveBits']); const signing = await s.generateKey({ name: 'ECDSA', namedCurve: CURVE }, true, ['sign', 'verify']); const ecdhPublic = publicJwk(await s.exportKey('jwk', ecdh.publicKey)); const signingPublic = publicJwk(await s.exportKey('jwk', signing.publicKey)); const message = fingerprintMessage(ecdhPublic, signingPublic); const signature = new Uint8Array(await s.sign({ name: 'ECDSA', hash: 'SHA-256' }, signing.privateKey, message)); return { ecdh: { publicKey: ecdh.publicKey, privateKey: ecdh.privateKey }, signing: { publicKey: signing.publicKey, privateKey: signing.privateKey }, ecdhPublic, signingPublic, fingerprint: fingerprintWords(await sha256(message)), fingerprintSignature: signature, }; } /** The public half of a person's keys, as it is published: locker.public-keys@1. */ async function exportPersonKeys(person) { const ecdhPublic = publicJwk(person.ecdhPublic || await subtle().exportKey('jwk', person.ecdh.publicKey)); const signingPublic = publicJwk(person.signingPublic || await subtle().exportKey('jwk', person.signing.publicKey)); const message = fingerprintMessage(ecdhPublic, signingPublic); const signature = person.fingerprintSignature || new Uint8Array(await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, (person.signing?.privateKey || person.signingPrivateKey), 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 || person.ecdhPrivateKey); const signing = await subtle().exportKey('jwk', person.signing?.privateKey || person.signingPrivateKey); return { ecdh, signing }; } /** * A person's keys, ready to use: the private keys as CryptoKeys and the public keys as JWKs. * The private keys may be JWKs (as they are stored) or CryptoKeys (as they are in memory). */ async function importPersonKeys(data) { const s = subtle(); const ecdhPrivate = data.ecdhPrivateKey || await s.importKey('jwk', data.ecdh, { name: 'ECDH', namedCurve: CURVE }, true, ['deriveBits']); const signingPrivate = data.signingPrivateKey || await s.importKey('jwk', data.signing, { name: 'ECDSA', namedCurve: CURVE }, true, ['sign']); return { ecdhPrivateKey: ecdhPrivate, signingPrivateKey: signingPrivate, ecdhPublic: data.ecdh ? publicJwk(data.ecdh) : publicJwk(await s.exportKey('jwk', ecdhPrivate)), signingPublic: data.signing ? publicJwk(data.signing) : publicJwk(await s.exportKey('jwk', signingPrivate)), }; } /** * Checks a published set of keys: the signature must verify with the signing key, and the four * words must be the fingerprint of the two keys. Returns false instead of throwing, so a caller * can show a warning rather than an error. */ async function verifyPersonKeys(data) { try { const s = subtle(); const message = fingerprintMessage(data.ecdh, data.signing); const signature = fromBase64(data.fingerprint_signature); const key = await s.importKey('jwk', data.signing, { name: 'ECDSA', namedCurve: CURVE }, true, ['verify']); const ok = await s.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, signature, message); if (!ok) return false; return fingerprintWords(await sha256(message)) === data.fingerprint; } catch { return false; } } /** The content key as an AES-GCM CryptoKey. */ async function importContentKey(bytes, usages) { if (!bytes || bytes.length !== KEY_BYTES) fail('locker.invalid', 'The content key is 32 bytes'); return subtle().importKey('raw', bytes, { name: 'AES-GCM', length: 256 }, false, usages); } /** The wrapping key for a person: the ECDH secret, put through HKDF-SHA-256. */ async function deriveWrappingKey(sharedSecret, salt, info) { const s = subtle(); const material = await s.importKey('raw', sharedSecret, 'HKDF', false, ['deriveKey']); return s.deriveKey( { name: 'HKDF', hash: 'SHA-256', salt, info }, material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'], ); } /** The wrapping key for a password: PBKDF2-SHA-256, with the parameters the header names. */ async function derivePasswordKey(password, salt, iterations) { if (typeof password !== 'string' || !password.length) fail('locker.invalid', 'A password lock needs a password'); // The count comes from the header, so it is checked before it is used: a hostile header must // not be able to make a reader spend minutes (or hours) on the derivation. if (!Number.isInteger(iterations) || iterations < PASSWORD_ITERATIONS_MIN || iterations > PASSWORD_ITERATIONS_MAX) { fail('locker.bad_envelope', `The iteration count is not between ${PASSWORD_ITERATIONS_MIN} and ${PASSWORD_ITERATIONS_MAX}`); } const s = subtle(); const material = await s.importKey('raw', utf8(password), 'PBKDF2', false, ['deriveKey']); return s.deriveKey( { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' }, material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'], ); } /** * The associated data of a people slot: it binds the slot to one person, to that person's key * and to one envelope. */ function peopleSlotAad(subject, ephemeralPublic, salt, recipientKey) { const id = typeof subject === 'string' ? subject : `${subject.type}:${subject.id}`; return utf8(`openvibe-locker people-slot v1|${id}|${recipientKey}|${canonicalJson(publicJwk(ephemeralPublic))}|${base64(salt)}`); } /** The associated data of a password slot: it binds the slot to its own parameters. */ function passwordSlotAad(iterations, salt) { return utf8(`openvibe-locker password-slot v1|${iterations}|${base64(salt)}`); } /** A public key as WebCrypto wants it. */ async function importPublicKey(jwk) { try { return await subtle().importKey('jwk', publicJwk(jwk), { name: 'ECDH', namedCurve: CURVE }, true, []); } catch { fail('locker.invalid', 'The public key is not a P-256 key'); } } /** * The envelope's one ephemeral ECDH key pair. It may be given (the shared test vectors need the * same bytes every time), or made fresh. */ async function resolveEphemeral(ephemeral) { const s = subtle(); if (!ephemeral) { const pair = await s.generateKey({ name: 'ECDH', namedCurve: CURVE }, true, ['deriveBits']); return { privateKey: pair.privateKey, publicKey: pair.publicKey }; } if (ephemeral.privateKey) return ephemeral; const privateKey = await s.importKey('jwk', ephemeral.private, { name: 'ECDH', namedCurve: CURVE }, true, ['deriveBits']); return { privateKey }; } /** * Wraps the content key for one person: the content key, sealed for their public key. * The envelope has one ephemeral ECDH key pair; the shared secret with each person goes through * HKDF-SHA-256, and the slot's tag covers the person, their key, the ephemeral key and the * salt, so a slot cannot be moved to another person or replayed from another envelope. */ async function wrapFor(contentKey, publicKey, { subject, salt, random = platformRandom, ephemeral } = {}) { const s = subtle(); if (!subject) fail('locker.invalid', 'A people slot needs the subject it is for'); const theirs = await importPublicKey(publicKey); const pair = await resolveEphemeral(ephemeral); const ephemeralPublic = publicJwk(await s.exportKey('jwk', pair.publicKey || pair.privateKey)); // The slot names the key it is for, so an opener can check it before deriving anything. const recipientKey = await jwkThumbprint(publicJwk(publicKey)); const shared = new Uint8Array(await s.deriveBits({ name: 'ECDH', public: theirs }, pair.privateKey, 256)); const slotSalt = salt || random(SALT_BYTES); const key = await deriveWrappingKey(shared, slotSalt, utf8(`openvibe-locker people-slot v1|${subject.type}:${subject.id}|${recipientKey}`)); const nonce = random(12); const sealed = new Uint8Array(await s.encrypt( { name: 'AES-GCM', iv: nonce, additionalData: peopleSlotAad(subject, ephemeralPublic, slotSalt, recipientKey) }, key, contentKey, )); return { subject, algorithm: PEOPLE_ALGORITHM, recipient_key: recipientKey, ephemeral_public_key: ephemeralPublic, salt: base64(slotSalt), nonce: base64(nonce), wrapped_key: base64(sealed), }; } /** The content key out of a people slot: the recipient's private key and the slot. */ async function unwrapPeopleSlot(slot, privateKey) { const s = subtle(); // The slot names the key it was made for. If it is not this key, the slot is not this // person's, and nothing is derived: the check comes before the work. if (!slot.recipient_key) fail('locker.bad_envelope', 'The slot does not name the key it is for'); const theirPublic = publicJwk(await s.exportKey('jwk', privateKey)); const recipientKey = await jwkThumbprint(theirPublic); if (slot.recipient_key !== recipientKey) { fail('locker.wrong_key', 'This slot is for another key'); } const theirs = await s.importKey('jwk', publicJwk(slot.ephemeral_public_key), { name: 'ECDH', namedCurve: CURVE }, true, []); const shared = new Uint8Array(await s.deriveBits({ name: 'ECDH', public: theirs }, privateKey, 256)); const salt = fromBase64(slot.salt); const key = await deriveWrappingKey(shared, salt, utf8(`openvibe-locker people-slot v1|${slot.subject.type}:${slot.subject.id}|${recipientKey}`)); try { const plain = new Uint8Array(await s.decrypt( { name: 'AES-GCM', iv: fromBase64(slot.nonce), additionalData: peopleSlotAad(slot.subject, slot.ephemeral_public_key, salt, recipientKey) }, key, fromBase64(slot.wrapped_key), )); if (plain.length !== KEY_BYTES) fail('locker.wrong_key', 'The slot does not hold a content key'); return plain; } catch (err) { if (err && err.code) throw err; fail('locker.wrong_key', 'This key does not open that slot'); } } /** Wraps the content key with a password: PBKDF2-SHA-256, then AES-256-GCM. */ async function wrapForPassword(contentKey, password, { salt, iterations = PASSWORD_ITERATIONS, random = platformRandom } = {}) { const s = subtle(); const slotSalt = salt || random(SALT_BYTES); const key = await derivePasswordKey(password, slotSalt, iterations); const nonce = random(12); const sealed = new Uint8Array(await s.encrypt( { name: 'AES-GCM', iv: nonce, additionalData: passwordSlotAad(iterations, slotSalt) }, key, contentKey, )); return { algorithm: PASSWORD_ALGORITHM, kdf: 'PBKDF2-SHA-256', iterations, salt: base64(slotSalt), nonce: base64(nonce), wrapped_key: base64(sealed), }; } /** The content key out of a password slot. The parameters come from the slot, never from here. */ async function unwrapPasswordSlot(slot, password) { const s = subtle(); const salt = fromBase64(slot.salt); const key = await derivePasswordKey(password, salt, slot.iterations); try { const plain = new Uint8Array(await s.decrypt( { name: 'AES-GCM', iv: fromBase64(slot.nonce), additionalData: passwordSlotAad(slot.iterations, salt) }, key, fromBase64(slot.wrapped_key), )); if (plain.length !== KEY_BYTES) fail('locker.wrong_key', 'The slot does not hold a content key'); return plain; } catch (err) { if (err && err.code) throw err; fail('locker.wrong_key', 'That password does not open this item'); } } __ovLockerModule.exports = { CURVE, PEOPLE_ALGORITHM, PASSWORD_ALGORITHM, PASSWORD_ITERATIONS, PASSWORD_ITERATIONS_MIN, PASSWORD_ITERATIONS_MAX, KEY_BYTES, SALT_BYTES, subtle, platformRandom, sha256, publicJwk, fingerprintMessage, fingerprintWords, fingerprintOf, jwkThumbprint, generatePersonKeys, exportPersonKeys, exportPrivateKeys, importPersonKeys, verifyPersonKeys, importContentKey, deriveWrappingKey, derivePasswordKey, resolveEphemeral, peopleSlotAad, passwordSlotAad, wrapFor, unwrapPeopleSlot, wrapForPassword, unwrapPasswordSlot, }; }, "padme": function (__ovLockerModule, exports, __ovLockerReq) { 'use strict'; /** * Padmé padding (Nikitin et al., "Reducing Metadata Leakage from Encrypted Files and Network * Traffic", PoPETs 2019). * * The point of padding a locked item: the server must not learn how big it is. Rounding the size * up to a whole 1 MiB chunk says too much — a 200-byte note becomes a 1 MiB object, and every * item under a megabyte looks the same, which is a leak of its own. * * Padmé rounds the length up to the next size on a grid that gets finer as the length grows: * * E = floor(log2(n)) the band the length is in * S = floor(log2(E)) + 1 how many steps the band has * d = 2^(E - S) the step of the band * P(n) = d * ceil(n / d) the padded length * * The padded length is one of a small number of values per band, and the bands double, so the * number of possible padded lengths up to n grows like log(n): knowing the padded length says * O(log log n) bits about the real one. The expansion stays under 12% (the worst case is a * length just above a band boundary, 129 bytes padded to 144). * * The arithmetic is exact above 2^31: there are no 32-bit bitwise operators here, only * comparisons and division by a power of two, both exact on the doubles we have. */ /** The largest e with 2^e <= size: floor(log2(size)), without a logarithm. */ function exponentOf(size) { let e = 0; let power = 1; while (power * 2 <= size) { power *= 2; e++; } return e; } /** * The padded length of a content of this size: the smallest length on Padmé's grid that is not * smaller than it. Zero for zero, which the caller handles. */ function padme(size) { if (!Number.isInteger(size) || size <= 0) return 0; if (size === 1) return 1; const band = exponentOf(size); const steps = exponentOf(band) + 1; const step = Math.pow(2, band - steps); // Dividing by a power of two is exact, so the ceiling is the ceiling and not nearly it. return Math.ceil(size / step) * step; } __ovLockerModule.exports = { padme, exponentOf }; }, "recovery": function (__ovLockerModule, exports, __ovLockerReq) { 'use strict'; /** * The recovery phrase: the words a person writes down. * * A phrase of n words from the same 256-word list the fingerprint uses, one byte per word from * the platform's CSPRNG (crypto.getRandomValues). Eight bits per word, so the usual 24 words * carry 192 bits: enough for a key nobody else has, and short enough to write on paper. * * There is no checksum in the phrase: the words are the secret, and a phrase that is typed * wrong simply does not open anything. isRecoveryPhrase only says whether every word is one of * the 256, so a UI can tell a typo from a word that was never in the list. */ const { fail } = __ovLockerReq('./errors'); const { platformRandom } = __ovLockerReq('./keys'); const { WORDS } = __ovLockerReq('./words'); /** How many words a recovery phrase has, unless the caller says otherwise. */ const RECOVERY_WORDS = 24; /** * A recovery phrase: n words from the list, one byte each. * * recoveryWords() // 24 words, 192 bits * recoveryWords(12) // 12 words, 96 bits */ function recoveryWords(count = RECOVERY_WORDS, { random = platformRandom } = {}) { if (!Number.isInteger(count) || count < 1 || count > WORDS.length) { fail('locker.invalid', `A recovery phrase is one to ${WORDS.length} words`); } // One byte per word: the list has 256 words, so a byte picks one of them exactly. const bytes = random(count); const words = []; for (const byte of bytes) words.push(WORDS[byte]); return words.join(' '); } /** * Whether a typed phrase is made of words from the list. It says nothing about the order or * about whether the phrase is the right one: only that every word could have come from us. */ function isRecoveryPhrase(phrase) { if (typeof phrase !== 'string') return false; const words = phrase.trim().split(/\s+/).filter(Boolean); if (!words.length) return false; return words.every(word => WORDS.includes(word.toLowerCase())); } __ovLockerModule.exports = { RECOVERY_WORDS, recoveryWords, isRecoveryPhrase }; }, "seal": function (__ovLockerModule, exports, __ovLockerReq) { 'use strict'; /** * seal and open: the content, the three locks, and the streams. * * The envelope is written once and read once. Every chunk is sealed with AES-256-GCM under the * content key, with the header's core, the chunk counter and the flags as associated data, so a * chunk cannot be cut, reordered or truncated without the open failing. */ const { utf8, fromUtf8, concat, base64, base64url, fromBase64url, u32be, readU32be, u64be, readU64be, } = __ovLockerReq('./bytes'); const { fail } = __ovLockerReq('./errors'); const { MAGIC, VERSION, SUITE, CHUNK_SIZE, TAG_SIZE, NONCE_BASE_SIZE, LENGTH_PREFIX, MIN_PADDED_SIZE, FINAL_FLAG, coreOf, chunkAad, encodeHeader, encodeEnvelope, readHeader, chunkCount, lastChunkSize, paddedSize, chunkNonce, encodeChunk, nonceBaseBytes, } = __ovLockerReq('./format'); const { KEY_BYTES, PASSWORD_ITERATIONS, subtle, platformRandom, importContentKey, publicJwk, jwkThumbprint, wrapFor, unwrapPeopleSlot, wrapForPassword, unwrapPasswordSlot, resolveEphemeral, } = __ovLockerReq('./keys'); /** The content as bytes: a string is UTF-8, anything else is taken as it is. */ function toBytes(content) { if (typeof content === 'string') return utf8(content); if (content instanceof Uint8Array) return content; if (content instanceof ArrayBuffer) return new Uint8Array(content); if (ArrayBuffer.isView(content)) return new Uint8Array(content.buffer, content.byteOffset, content.byteLength); fail('locker.invalid', 'The content must be a string or bytes'); } /** The bytes of an envelope: what seal returns, or what the caller kept. */ function envelopeBytes(envelope) { if (envelope instanceof Uint8Array) return envelope; if (envelope && envelope.bytes instanceof Uint8Array) return envelope.bytes; fail('locker.invalid', 'That is not a Locker envelope'); } /** A source of bytes: an async iterable of Uint8Array. */ async function* once(bytes) { yield bytes; } /** * Collects bytes into blocks of a fixed size, without ever holding more than one block and the * part that has not been used yet. This is what keeps a stream seal in constant memory. */ class Chunker { constructor(size) { this.size = size; this.parts = []; this.length = 0; } push(part) { this.parts.push(part); this.length += part.length; } /** The next full block, or null when there is not enough yet. */ take() { if (this.length < this.size) return null; const block = new Uint8Array(this.size); let at = 0; while (at < this.size) { const head = this.parts[0]; const take = Math.min(head.length, this.size - at); block.set(head.subarray(0, take), at); at += take; if (take === head.length) this.parts.shift(); else this.parts[0] = head.subarray(take); } this.length -= this.size; return block; } /** What is left, as one block of exactly this many bytes: the bytes that are there, then zeros. */ takeExactly(count) { if (this.length > count) fail('locker.size_mismatch', 'The content is longer than the size it was given'); const block = new Uint8Array(count); let at = 0; for (const part of this.parts) { block.set(part, at); at += part.length; } this.parts = []; this.length = 0; return block; } } /** The header, the content key and the nonce base: everything a seal needs before it writes. */ async function prepare(options) { const lock = options.lock; if (!['people', 'password', 'link'].includes(lock)) { fail('locker.invalid', 'A lock is people, password or link'); } const random = options.random || platformRandom; const contentKey = options.contentKey ? toBytes(options.contentKey) : random(KEY_BYTES); if (contentKey.length !== KEY_BYTES) fail('locker.invalid', 'The content key is 32 bytes'); const nonceBase = options.nonceBase ? toBytes(options.nonceBase) : random(NONCE_BASE_SIZE); if (nonceBase.length !== NONCE_BASE_SIZE) fail('locker.invalid', 'The nonce base is eight bytes'); let slots; if (lock === 'people') { const keys = options.keys || []; if (!keys.length) fail('locker.invalid', 'A people lock needs at least one recipient'); // One ephemeral key pair for the whole envelope: the shared secret differs per person. const pair = await resolveEphemeral(options.ephemeral); const people = []; for (const key of keys) { people.push(await wrapFor(contentKey, key.publicKey, { subject: key.subject, random, ephemeral: pair, })); } slots = { people }; } else if (lock === 'password') { slots = { password: await wrapForPassword(contentKey, options.password, { salt: options.salt, iterations: options.iterations || PASSWORD_ITERATIONS, random, }), }; } else { // A link lock stores no key: the content key travels in the part of the link after the #. slots = { link: {} }; } return { contentKey, nonceBase, slots }; } /** The header of an envelope. */ function makeHeader(lock, slots, padded, nonceBase) { return { version: VERSION, suite: SUITE, chunk_size: CHUNK_SIZE, padded_size: padded, nonce_base: base64(nonceBase), lock, slots, }; } /** Seals one block of the padded plaintext. */ async function sealBlock(key, header, nonceBase, counter, block, isFinal) { const flags = isFinal ? FINAL_FLAG : 0; const nonce = chunkNonce(nonceBase, counter); const ciphertext = new Uint8Array(await subtle().encrypt( { name: 'AES-GCM', iv: nonce, additionalData: chunkAad(header, counter, flags) }, key, block, )); return encodeChunk(counter, flags, nonce, ciphertext); } /** * The envelope, piece by piece. The header comes first (it holds the padded size, so the size * has to be known before the first byte is written), then one frame per chunk. */ async function* sealPieces(source, options) { const size = options.size; const { contentKey, nonceBase, slots } = await prepare(options); const padded = paddedSize(size); const header = makeHeader(options.lock, slots, padded, nonceBase); const headerBytes = encodeHeader(header); yield { header, headerBytes, contentKey, bytes: concat(utf8(MAGIC), u32be(headerBytes.length), headerBytes) }; const key = await importContentKey(contentKey, ['encrypt']); const total = chunkCount(padded); const chunker = new Chunker(CHUNK_SIZE); let counter = 0; // The padded plaintext starts with the true length, so the server cannot read the size. chunker.push(u64be(size)); for await (const part of source) { chunker.push(part); // Every chunk but the last is a whole chunk. for (;;) { if (counter >= total - 1) break; const block = chunker.take(); if (!block) break; yield { bytes: await sealBlock(key, header, nonceBase, counter, block, false) }; counter++; } } // The content is done; the padding fills the rest of the padded plaintext. (A content that // is longer than the size it was given fails in takeExactly, below.) while (counter < total - 1) { const block = chunker.takeExactly(CHUNK_SIZE); yield { bytes: await sealBlock(key, header, nonceBase, counter, block, false) }; counter++; } // The last chunk: what is left of the plaintext, then zeros up to the padded size. const tail = chunker.takeExactly(lastChunkSize(padded)); yield { bytes: await sealBlock(key, header, nonceBase, counter, tail, true) }; } /** * Seals content: a string or bytes. The result holds the envelope's bytes and its header. * * seal('a note', { lock: 'password', password: 'correct horse' }) * seal(bytes, { lock: 'people', keys: [{ subject, publicKey }] }) * seal(bytes, { lock: 'link' }) // the key comes back as linkKey */ async function seal(content, options = {}) { const bytes = toBytes(content); const pieces = []; let header = null; let headerBytes = null; let contentKey = null; for await (const piece of sealPieces(once(bytes), { ...options, size: bytes.length })) { if (piece.header) { header = piece.header; headerBytes = piece.headerBytes; contentKey = piece.contentKey; } pieces.push(piece.bytes); } const envelope = { version: VERSION, header, headerBytes, bytes: concat(...pieces), chunkOffset: 8 + headerBytes.length, }; if (options.lock === 'link') { // The link carries the content key; the envelope itself stores nothing. envelope.linkKey = base64url(contentKey); } return envelope; } /** The header's core, as a reader needs it: the fields must be the ones we write. */ function readCore(header) { if (!header || typeof header !== 'object' || Array.isArray(header)) { fail('locker.bad_envelope', 'The envelope header is not an object'); } if (header.version !== VERSION) fail('locker.bad_envelope', 'This envelope is another version'); if (header.suite !== SUITE) fail('locker.bad_envelope', 'This envelope uses another suite'); if (header.chunk_size !== CHUNK_SIZE) fail('locker.bad_envelope', 'This envelope uses another chunk size'); if (!Number.isInteger(header.padded_size) || header.padded_size < MIN_PADDED_SIZE) { fail('locker.bad_envelope', 'The padded size is not a size we write'); } if (!['people', 'password', 'link'].includes(header.lock)) { fail('locker.bad_envelope', 'The lock is not one we know'); } if (!header.slots || typeof header.slots !== 'object') { fail('locker.bad_envelope', 'The envelope has no slots'); } return header; } /** The content key out of the header, with the credentials the reader has. */ async function openContentKey(header, credentials = {}) { // A caller that still holds the content key needs no private key: an agent that sealed the item // can open it, or wrap it for the people who stay, with the key it kept. A string is base64url, // as the link key travels. if (credentials.contentKey) { const given = typeof credentials.contentKey === 'string' ? fromBase64url(credentials.contentKey) : toBytes(credentials.contentKey); if (given.length !== KEY_BYTES) fail('locker.invalid', 'The content key is 32 bytes'); return given; } if (header.lock === 'people') { // A person may be named by their key instead of their subject: the slot says which key // it is for, so the reader does not need to know who they are. if (credentials.person) { const slot = await slotForPerson(header, credentials.person); const privateKey = await importPrivateKey(privateKeyOf(credentials.person) || credentials.privateKey); if (!privateKey) fail('locker.no_key', 'A people lock needs the private key of a recipient'); return unwrapPeopleSlot(slot, privateKey); } const slot = pickSlot(header, credentials.subject); const privateKey = await importPrivateKey(credentials.privateKey); if (!privateKey) fail('locker.no_key', 'A people lock needs the private key of a recipient'); return unwrapPeopleSlot(slot, privateKey); } if (header.lock === 'password') { if (!credentials.password) fail('locker.no_key', 'A password lock needs the password'); if (!header.slots.password) fail('locker.bad_envelope', 'The envelope has no password slot'); return unwrapPasswordSlot(header.slots.password, credentials.password); } if (!credentials.key) fail('locker.no_key', 'A link lock needs the key from the link'); const key = typeof credentials.key === 'string' ? fromBase64url(credentials.key) : toBytes(credentials.key); if (key.length !== KEY_BYTES) fail('locker.invalid', 'The link key is 32 bytes'); return key; } /** The slot of the person who is opening: theirs, or the only one there is. */ function pickSlot(header, subject) { const slots = (header.slots && header.slots.people) || []; if (!slots.length) fail('locker.bad_envelope', 'The envelope has no people slots'); if (subject) { const id = typeof subject === 'string' ? subject : subject.id; const type = typeof subject === 'string' ? null : subject.type; const found = slots.find(slot => slot.subject.id === id && (type === null || slot.subject.type === type)); if (!found) fail('locker.no_key', 'This envelope has no slot for that person'); return found; } if (slots.length > 1) fail('locker.no_key', 'This envelope has several slots: say which person you are'); return slots[0]; } /** The ECDH public key of a person, as a JWK. */ async function publicKeyOf(person) { if (!person || typeof person !== 'object') fail('locker.invalid', 'A people lock needs the person'); if (person.ecdhPublic) return publicJwk(person.ecdhPublic); if (person.ecdh && person.ecdh.kty) return publicJwk(person.ecdh); if (person.kty === 'EC') return publicJwk(person); if (person.ecdh && person.ecdh.publicKey) { return publicJwk(await subtle().exportKey('jwk', person.ecdh.publicKey)); } fail('locker.invalid', 'The person has no ECDH public key'); } /** The ECDH private key of a person, if they brought one. */ function privateKeyOf(person) { if (!person || typeof person !== 'object') return null; if (person.ecdh && person.ecdh.privateKey) return person.ecdh.privateKey; if (person.ecdhPrivateKey) return person.ecdhPrivateKey; if (person.privateKey) return person.privateKey; return null; } /** * The slot that names this person's key. Every people slot carries the thumbprint of the key * it was made for, so the reader finds its own slot without knowing anybody's subject. An * envelope that has no slot for this key is not an envelope this person can open: that is * locker.no_slot, which says the key is not on the list (not that the key is wrong). */ async function slotForPerson(header, person) { const slots = (header.slots && header.slots.people) || []; if (!slots.length) fail('locker.bad_envelope', 'The envelope has no people slots'); const thumbprint = await jwkThumbprint(await publicKeyOf(person)); const found = slots.find(slot => slot.recipient_key === thumbprint); if (!found) fail('locker.no_slot', 'This envelope has no slot for that key'); return found; } /** A private key as WebCrypto wants it: a CryptoKey, or a JWK to import. */ async function importPrivateKey(key) { if (!key) return null; if (key.type === 'private' && key.algorithm && key.algorithm.name === 'ECDH') return key; if (key.kty === 'EC' && key.crv === 'P-256' && key.d) { try { return await subtle().importKey('jwk', key, { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']); } catch { fail('locker.invalid', 'The private key is not a P-256 key'); } } fail('locker.invalid', 'The private key must be an ECDH P-256 key'); } /** Reads exact byte counts out of an envelope that is already in memory. */ function arrayReader(bytes) { let at = 0; return { /** The next bytes, or null when the envelope is over. */ async probe(count) { if (at >= bytes.length) return null; return this.take(count); }, async take(count) { if (at + count > bytes.length) fail('locker.truncated', 'The envelope ends inside a chunk'); const out = bytes.slice(at, at + count); at += count; return out; }, }; } /** Reads exact byte counts out of a stream, keeping only what it must. */ function streamReader(iterator) { let buffer = new Uint8Array(0); let ended = false; async function fill(count) { while (buffer.length < count && !ended) { const next = await iterator.next(); if (next.done) { ended = true; break; } buffer = buffer.length ? concat(buffer, next.value) : next.value; } return buffer.length; } return { /** The next bytes, or null when the stream is over. */ async probe(count) { const have = await fill(count); if (have === 0) return null; return this.take(count); }, async take(count) { const have = await fill(count); if (have < count) fail('locker.truncated', 'The envelope ends inside a chunk'); const out = buffer.slice(0, count); buffer = buffer.slice(count); return out; }, }; } /** * Opens the chunks: the plaintext, piece by piece. The reader may be an envelope in memory or a * stream; both are read the same way, so the same checks run on both. */ async function* openPieces(reader, header, key) { const expected = chunkCount(header.padded_size); const lastLength = lastChunkSize(header.padded_size); const nonceBase = nonceBaseBytes(header); let counter = 0; let length = null; let emitted = 0; for (;;) { const lengthBytes = await reader.probe(4); if (lengthBytes === null) { if (counter !== expected) fail('locker.truncated', 'The envelope ends before its last chunk'); break; } if (counter >= expected) fail('locker.bad_envelope', 'The envelope has more chunks than its size says'); const cipherLength = readU32be(lengthBytes, 0); // The length is checked before it is used: a header that claims a huge chunk must not // make the reader wait for it. Every chunk but the last is a whole chunk; the last holds // what is left of the padded size. const wantedLength = counter === expected - 1 ? lastLength : CHUNK_SIZE; if (cipherLength !== wantedLength + TAG_SIZE) { fail('locker.bad_envelope', 'A chunk is not the size the envelope says'); } const flags = (await reader.take(1))[0]; const nonce = await reader.take(12); const ciphertext = await reader.take(cipherLength); const isFinal = (flags & FINAL_FLAG) === FINAL_FLAG; if (isFinal !== (counter === expected - 1)) { fail('locker.bad_envelope', 'The envelope marks the wrong chunk as the last one'); } const wanted = chunkNonce(nonceBase, counter); for (let i = 0; i < wanted.length; i++) { if (nonce[i] !== wanted[i]) fail('locker.bad_envelope', 'A chunk carries the wrong nonce'); } let plain; try { plain = new Uint8Array(await subtle().decrypt( { name: 'AES-GCM', iv: nonce, additionalData: chunkAad(header, counter, flags) }, key, ciphertext, )); } catch (err) { if (err && err.code) throw err; fail('locker.wrong_key', 'This envelope does not open with this key'); } if (counter === 0) { if (plain.length < LENGTH_PREFIX) fail('locker.bad_envelope', 'The first chunk is too short'); length = Number(readU64be(plain, 0)); if (length > header.padded_size - LENGTH_PREFIX) { fail('locker.size_mismatch', 'The content is longer than the envelope says'); } } const start = counter === 0 ? LENGTH_PREFIX : 0; const part = plain.subarray(start); const room = length - emitted; const out = part.length > room ? part.subarray(0, room) : part; emitted += out.length; counter++; if (out.length) yield out; } if (emitted !== length) fail('locker.size_mismatch', 'The envelope holds fewer bytes than it says'); } /** * Opens an envelope: the content as bytes. * * open(envelope, { privateKey, subject }) // a people lock, by subject * open(envelope, { person }) // a people lock, by key * open(envelope, { password }) // a password lock * open(envelope, { key }) // a link lock */ async function open(envelope, credentials = {}) { const bytes = envelopeBytes(envelope); const { header, chunkOffset } = readHeader(bytes); readCore(header); const contentKey = await openContentKey(header, credentials); const key = await importContentKey(contentKey, ['decrypt']); const reader = arrayReader(bytes.subarray(chunkOffset)); const parts = []; for await (const part of openPieces(reader, header, key)) parts.push(part); return concat(...parts); } /** A stream of the bytes of a WHATWG stream (or of any async iterable of bytes). */ async function* readChunks(stream) { if (stream && typeof stream.getReader === 'function') { const reader = stream.getReader(); try { for (;;) { const { value, done } = await reader.read(); if (done) return; yield value instanceof Uint8Array ? value : new Uint8Array(value); } } finally { reader.releaseLock(); } return; } for await (const part of stream) yield part instanceof Uint8Array ? part : new Uint8Array(part); } /** * Seals a stream: the envelope comes out piece by piece, and only one chunk is ever in memory. * The size of the content has to be known, because the padded size is in the header and the * header is written first. */ function sealStream(stream, options = {}) { if (typeof options.size !== 'number' || options.size < 0) { fail('locker.invalid', 'A stream seal needs the size of the content'); } const iterator = sealPieces(readChunks(stream), { ...options, size: options.size })[Symbol.asyncIterator](); return new ReadableStream({ async pull(controller) { const next = await iterator.next(); if (next.done) controller.close(); else controller.enqueue(next.value.bytes); }, async cancel(reason) { if (iterator.return) await iterator.return(reason); }, }); } /** * Opens a stream: the content comes out piece by piece, and only one chunk is ever in memory. * * The chunks are yielded as they are verified, so a reader sees the earlier ones before the last * one is reached. A stream that ends early throws at the end, when the missing chunk shows up: * discard what it yielded, because the content was never complete. */ function openStream(stream, credentials = {}) { const iterator = (async function* openStreamPieces() { const reader = streamReader(readChunks(stream)[Symbol.asyncIterator]()); const head = await reader.probe(8); if (head === null) fail('locker.truncated', 'The envelope is too short to hold a header'); if (fromUtf8(head.subarray(0, 4)) !== MAGIC) fail('locker.bad_envelope', 'These bytes are not a Locker envelope'); const headerLength = readU32be(head, 4); if (headerLength === 0 || headerLength > 65536) { fail('locker.bad_envelope', `The envelope header is ${headerLength} bytes, which is not one we write`); } const headerBytes = await reader.take(headerLength); let header; try { header = JSON.parse(fromUtf8(headerBytes)); } catch { fail('locker.bad_envelope', 'The envelope header is not JSON'); } readCore(header); const contentKey = await openContentKey(header, credentials); const key = await importContentKey(contentKey, ['decrypt']); yield* openPieces(reader, header, key); })()[Symbol.asyncIterator](); return new ReadableStream({ async pull(controller) { const next = await iterator.next(); if (next.done) controller.close(); else controller.enqueue(next.value); }, async cancel(reason) { if (iterator.return) await iterator.return(reason); }, }); } /** * Wraps the content key for other people, without touching the content: the envelope's header * changes, the chunks do not, because the slots are not part of what the chunks authenticate. * This is how a member is added to a people-locked item: the people who are already on it keep * their slots, and the new ones get one of their own. * * Removing a member wants a fresh content key, not just a slot that is gone: the person who is * removed may still hold the old key. So a removal opens the content and seals it again, with a * new key and a new nonce base, for the people who are left. Their public keys are needed for * that, so the caller gives them in `keys`. */ async function rewrap(envelope, { add = [], remove = [], keys = [], credentials = {}, random = platformRandom, ephemeral, } = {}) { const bytes = envelopeBytes(envelope); const { header, chunkOffset } = readHeader(bytes); if (header.lock !== 'people') fail('locker.invalid', 'Only a people-locked envelope can be re-wrapped'); readCore(header); const gone = new Set(remove.map(who => (typeof who === 'string' ? who : who.id))); const staying = (header.slots.people || []).filter(slot => !gone.has(slot.subject.id)); if (!gone.size) { // Adding: the content key and the chunks stay as they are. const contentKey = await openContentKey(header, credentials); const people = staying.slice(); if (add.length) { const pair = await resolveEphemeral(ephemeral); for (const recipient of add) { people.push(await wrapFor(contentKey, recipient.publicKey, { subject: recipient.subject, ephemeral: pair, random, })); } } if (!people.length) fail('locker.invalid', 'A people-locked envelope needs at least one slot'); const newHeader = { ...coreOf(header), slots: { people } }; const headerBytes = encodeHeader(newHeader); return { version: VERSION, header: newHeader, headerBytes, bytes: encodeEnvelope(headerBytes, bytes.subarray(chunkOffset)), chunkOffset: 8 + headerBytes.length, }; } // Removing: the content is opened and sealed again under a new key, so the key the person // who is leaving still holds opens nothing. const contentKey = await openContentKey(header, credentials); const plain = await open(bytes, credentials); const recipients = []; for (const slot of staying) { const found = keys.find(key => (typeof key.subject === 'string' ? key.subject : key.subject.id) === slot.subject.id); if (!found) { fail('locker.invalid', `Removing a person needs the public key of ${slot.subject.id}, who stays`); } recipients.push({ subject: slot.subject, publicKey: found.publicKey }); } for (const recipient of add) recipients.push({ subject: recipient.subject, publicKey: recipient.publicKey }); if (!recipients.length) fail('locker.invalid', 'A people-locked envelope needs at least one slot'); return seal(plain, { lock: 'people', keys: recipients, random, ephemeral }); } __ovLockerModule.exports = { seal, open, sealStream, openStream, rewrap, envelopeBytes, toBytes, openContentKey, readCore, prepare, makeHeader, Chunker, publicKeyOf, privateKeyOf, slotForPerson, }; }, "server": function (__ovLockerModule, exports, __ovLockerReq) { 'use strict'; /** * What a service needs to know about an envelope without opening it: the header's shape and the * envelope's size. Nothing here decrypts anything, and nothing here needs a key: a service may * hold the bytes of an item it can never read. * * A service that stores an envelope should: * - check it with inspectEnvelope() and refuse what does not pass; * - keep the header as it is (it holds no key material, only wrapped keys); * - skip indexing, previews, thumbnails, transcoding and content AI; * - show the lock badge, and serve the bytes only to the people canView allows. */ const { fromUtf8, fromBase64, readU32be } = __ovLockerReq('./bytes'); const { MAGIC, VERSION, SUITE, CHUNK_SIZE, TAG_SIZE, NONCE_SIZE, NONCE_BASE_SIZE, MIN_PADDED_SIZE, MAX_HEADER_BYTES, chunkCount, lastChunkSize, } = __ovLockerReq('./format'); /** The bytes of a base64 field, or null when it is not base64 of that many bytes. */ function bytesOf(text, length) { if (typeof text !== 'string' || !text.length) return null; if (!/^[A-Za-z0-9+/]+={0,2}$/.test(text)) return null; let bytes; try { bytes = fromBase64(text); } catch { return null; } return bytes.length === length ? bytes : null; } /** A subject: { type, id }, both there. */ function subjectOk(subject) { return !!subject && typeof subject === 'object' && typeof subject.type === 'string' && typeof subject.id === 'string' && subject.type.length > 0 && subject.id.length > 0; } /** A public key as a JWK: P-256, with its two coordinates. */ function publicKeyOk(key) { return !!key && typeof key === 'object' && key.kty === 'EC' && key.crv === 'P-256' && typeof key.x === 'string' && /^[A-Za-z0-9_-]{43}$/.test(key.x) && typeof key.y === 'string' && /^[A-Za-z0-9_-]{43}$/.test(key.y); } /** One people slot: whose it is, which key it is for, and the wrapped key with the bytes it must have. */ function peopleSlotOk(slot) { if (!slot || typeof slot !== 'object') return false; if (!subjectOk(slot.subject)) return false; if (slot.algorithm !== 'ECDH-P256/HKDF-SHA256/AES-256-GCM') return false; // The thumbprint of the recipient's key: the slot names the key it is for. if (typeof slot.recipient_key !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(slot.recipient_key)) return false; if (!publicKeyOk(slot.ephemeral_public_key)) return false; if (!bytesOf(slot.salt, 16)) return false; if (!bytesOf(slot.nonce, NONCE_SIZE)) return false; return !!bytesOf(slot.wrapped_key, 48); } /** The password slot: the parameters and the wrapped key. */ function passwordSlotOk(slot) { if (!slot || typeof slot !== 'object') return false; if (slot.algorithm !== 'PBKDF2-SHA256/AES-256-GCM') return false; if (slot.kdf !== 'PBKDF2-SHA-256') return false; if (!Number.isInteger(slot.iterations) || slot.iterations < 600000 || slot.iterations > 10000000) return false; if (!bytesOf(slot.salt, 16)) return false; if (!bytesOf(slot.nonce, NONCE_SIZE)) return false; return !!bytesOf(slot.wrapped_key, 48); } /** * The header's shape: what it says, and whether the slots match the lock it names. The problems * are returned as words, so a service can log them; nothing is thrown and nothing is decrypted. */ function checkHeader(header) { const errors = []; if (!header || typeof header !== 'object' || Array.isArray(header)) { return ['the header is not an object']; } if (header.version !== VERSION) errors.push(`the version is not ${VERSION}`); if (header.suite !== SUITE) errors.push(`the suite is not ${SUITE}`); if (header.chunk_size !== CHUNK_SIZE) errors.push(`the chunk size is not ${CHUNK_SIZE}`); if (!Number.isInteger(header.padded_size) || header.padded_size < MIN_PADDED_SIZE) { errors.push(`the padded size is not at least ${MIN_PADDED_SIZE} bytes`); } if (!bytesOf(header.nonce_base, NONCE_BASE_SIZE)) errors.push('the nonce base is not eight bytes'); if (!['people', 'password', 'link'].includes(header.lock)) errors.push('the lock is not one we know'); const slots = header.slots; if (!slots || typeof slots !== 'object' || Array.isArray(slots)) { errors.push('the header has no slots'); return errors; } const kinds = Object.keys(slots); const wanted = header.lock === 'people' ? 'people' : header.lock; if (kinds.length !== 1 || kinds[0] !== wanted) { errors.push(`the slots do not match the ${header.lock} lock`); return errors; } if (wanted === 'people') { if (!Array.isArray(slots.people) || !slots.people.length || slots.people.length > 100) { errors.push('the people slots are not a list of one to a hundred'); } else if (!slots.people.every(peopleSlotOk)) { errors.push('a people slot is not the shape it must be'); } } else if (wanted === 'password') { if (!passwordSlotOk(slots.password)) errors.push('the password slot is not the shape it must be'); } else if (Object.keys(slots.link).length) { errors.push('a link slot stores nothing, and this one does'); } return errors; } /** * Reads an envelope's header and checks its shape and its size. Nothing is decrypted and no key * is needed: this is what a service runs on bytes it may never be able to read. */ function inspectBytes(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}, totalBytes = bytes && bytes.length) { const errors = []; const data = bytes instanceof Uint8Array ? bytes : null; if (!data) return { valid: false, errors: ['the envelope is not bytes'], header: null }; 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 }; } if (!header || typeof header !== 'object' || Array.isArray(header)) return { valid: false, errors: ['the header is not an object'], header: null }; try { errors.push(...checkHeader(header)); } catch { errors.push('the slots are not an object'); } const chunkOffset = 8 + headerLength; let expectedBytes = null; if (Number.isInteger(header.padded_size) && header.padded_size >= MIN_PADDED_SIZE && 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 (totalBytes < 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, }; } function inspectEnvelope(bytes, options) { return inspectBytes(bytes, options); } /** A streamed upload: inspect just its bounded header against the ACTUAL stored byte count. * The caller must have counted and bounded the complete ciphertext first, not trust Content-Length. * No chunks are read or authenticated here; as with inspectEnvelope, only a client can do that. */ function inspectEnvelopePrefix(prefix, totalBytes, options) { const info = inspectBytes(prefix, options, totalBytes); if (!Number.isSafeInteger(totalBytes) || !Number.isSafeInteger(info.expectedBytes) || totalBytes !== info.expectedBytes) { info.errors.push('the stored byte count does not match the envelope'); } if (info.header && !Number.isSafeInteger(info.header.padded_size)) info.errors.push('the padded size is not a safe integer'); info.valid = info.errors.length === 0; return info; } __ovLockerModule.exports = { inspectEnvelope, inspectEnvelopePrefix, checkHeader, peopleSlotOk }; }, "words": function (__ovLockerModule, exports, __ovLockerReq) { 'use strict'; /** * The 256 words of a fingerprint. A fingerprint is the first eight bytes of SHA-256 over a * person's two public keys, read as eight words in two groups of four: eight bits each, so the * words say which of the 256 they are. It is short enough to read out loud and compare once, * which is the whole point: a person checks that the eight words on the screen are the eight * words the other person sees. * * The order is part of the format: word number n is the nth word here. Changing the list would * change every fingerprint, so it is frozen with the version. */ const WORDS = [ 'amber', 'anchor', 'apple', 'arrow', 'aspen', 'autumn', 'badge', 'bamboo', 'beacon', 'berry', 'birch', 'bison', 'blossom', 'breeze', 'brick', 'brook', 'bucket', 'bugle', 'cabin', 'cactus', 'candle', 'canoe', 'canyon', 'cedar', 'cellar', 'chalk', 'cherry', 'cider', 'cinder', 'citrus', 'clay', 'clover', 'cobalt', 'cocoa', 'comet', 'coral', 'cosmos', 'cotton', 'cove', 'crane', 'crater', 'crayon', 'cricket', 'crimson', 'crystal', 'cymbal', 'daisy', 'dapple', 'dawn', 'delta', 'desert', 'diamond', 'dolphin', 'domino', 'donkey', 'dragon', 'drift', 'drizzle', 'drum', 'dune', 'dusk', 'eagle', 'ember', 'emerald', 'estuary', 'fable', 'falcon', 'feather', 'fern', 'ferry', 'field', 'flame', 'flint', 'forest', 'fossil', 'fox', 'frost', 'galaxy', 'garden', 'garnet', 'gazelle', 'geyser', 'ginger', 'glacier', 'granite', 'grape', 'gravel', 'grove', 'guitar', 'haiku', 'harbor', 'hazel', 'heather', 'hollow', 'honey', 'horizon', 'ice', 'indigo', 'island', 'ivory', 'jasper', 'jungle', 'juniper', 'kayak', 'kernel', 'kestrel', 'lagoon', 'lantern', 'lattice', 'laurel', 'lemon', 'lichen', 'lilac', 'linen', 'lotus', 'lunar', 'magnet', 'mango', 'maple', 'marble', 'marsh', 'meadow', 'meteor', 'mint', 'mirror', 'mist', 'monsoon', 'moss', 'moth', 'mountain', 'nectar', 'needle', 'nest', 'nettle', 'night', 'oasis', 'ocean', 'olive', 'onyx', 'opal', 'orbit', 'orchard', 'orchid', 'osprey', 'otter', 'owl', 'oyster', 'palace', 'palm', 'panda', 'papaya', 'paper', 'peach', 'pearl', 'pebble', 'pecan', 'penguin', 'peony', 'pepper', 'petal', 'pine', 'plateau', 'plum', 'pollen', 'pond', 'poplar', 'poppy', 'prairie', 'prism', 'pumpkin', 'quail', 'quarry', 'quartz', 'rabbit', 'radish', 'rain', 'raven', 'reef', 'ridge', 'river', 'robin', 'rosemary', 'sage', 'salmon', 'sand', 'sapphire', 'satellite', 'scallop', 'shadow', 'shell', 'shore', 'shuttle', 'silk', 'silver', 'slate', 'snow', 'solar', 'sparrow', 'spruce', 'star', 'stone', 'storm', 'stream', 'sugar', 'summit', 'sunset', 'swan', 'thistle', 'thunder', 'tide', 'tiger', 'timber', 'topaz', 'trail', 'tulip', 'tundra', 'turtle', 'valley', 'vapor', 'velvet', 'vine', 'violet', 'walnut', 'wave', 'whale', 'wheat', 'willow', 'winter', 'wolf', 'wren', 'yacht', 'yarrow', 'yew', 'zinc', 'zephyr', 'almond', 'basil', 'cinnamon', 'cypress', 'damson', 'elder', 'fennel', 'garlic', 'hibiscus', 'iris', 'jasmine', 'linden', 'melon', 'nutmeg', 'sesame', 'sorrel', 'thyme', 'anise', 'chicory', 'dill', 'endive', ]; __ovLockerModule.exports = { WORDS }; }, }; const cache = {}; function req(name) { const key = String(name).replace(/^\.\//, ''); if (!defs[key]) throw new Error('ov-locker-core: no module ' + name); if (!cache[key]) { const m = { exports: {} }; cache[key] = m; defs[key](m, m.exports, req); } return cache[key].exports; } root.OVLockerCore = Object.assign({}, req('index'), { SDK_VERSION: "0.160.0" }); })(typeof globalThis !== 'undefined' ? globalThis : this);