openvibers/openvibe.shared ov-locker-core.js
ov-locker-core.js
| 1 | /* Generated by scripts/build-locker-core.js from openvibe-sdk 0.160.0 (src/locker). Do not edit: change the SDK, |
| 2 | * release it, bump the pin, and run the script. The SDK's files, unchanged but for the names of require and module: |
| 3 | * bytes.js 80a5a560c683f489 |
| 4 | * errors.js e73f94b7711452e7 |
| 5 | * format.js f7793a59fdb67ad9 |
| 6 | * index.js eee174e3a87df494 |
| 7 | * inspect.js 2f28ff0da8c96a4e |
| 8 | * keys.js fbe20e7949924f2b |
| 9 | * padme.js e0dd4b08e0d0f24f |
| 10 | * recovery.js 2829d07a02561674 |
| 11 | * seal.js ff6ad448082c636c |
| 12 | * server.js 6177fc986b2b705d |
| 13 | * words.js 8f4f51abdadfbfd1 |
| 14 | */ |
| 15 | (function (root) { |
| 16 | ; |
| 17 | if (!root || root.OVLockerCore) return; |
| 18 | const defs = { |
| 19 | "bytes": function (__ovLockerModule, exports, __ovLockerReq) { |
| 20 | ; |
| 21 | /** |
| 22 | * Byte helpers for the Locker envelope. Framing only, no cryptography: every value is a |
| 23 | * Uint8Array, so the same code runs in Node and in the browser. |
| 24 | */ |
| 25 | |
| 26 | const encoder = new TextEncoder(); |
| 27 | const decoder = new TextDecoder(); |
| 28 | |
| 29 | /** The UTF-8 bytes of a string. */ |
| 30 | function utf8(text) { return encoder.encode(text); } |
| 31 | |
| 32 | /** The string of UTF-8 bytes. */ |
| 33 | function fromUtf8(bytes) { return decoder.decode(bytes); } |
| 34 | |
| 35 | /** One buffer of all the parts, in order. */ |
| 36 | function concat(...parts) { |
| 37 | let total = 0; |
| 38 | for (const part of parts) total += part.length; |
| 39 | const out = new Uint8Array(total); |
| 40 | let at = 0; |
| 41 | for (const part of parts) { out.set(part, at); at += part.length; } |
| 42 | return out; |
| 43 | } |
| 44 | |
| 45 | /** Standard base64. btoa/atob are in every browser and in Node. */ |
| 46 | function base64(bytes) { |
| 47 | let binary = ''; |
| 48 | for (const byte of bytes) binary += String.fromCharCode(byte); |
| 49 | return btoa(binary); |
| 50 | } |
| 51 | |
| 52 | /** The bytes of a standard base64 string. */ |
| 53 | function fromBase64(text) { |
| 54 | const binary = atob(text); |
| 55 | const out = new Uint8Array(binary.length); |
| 56 | for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); |
| 57 | return out; |
| 58 | } |
| 59 | |
| 60 | /** base64url: the same alphabet without the padding, for keys that travel in a URL. */ |
| 61 | function base64url(bytes) { return base64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } |
| 62 | |
| 63 | /** The bytes of a base64url string. */ |
| 64 | function fromBase64url(text) { |
| 65 | const padded = text.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (text.length % 4)) % 4); |
| 66 | return fromBase64(padded); |
| 67 | } |
| 68 | |
| 69 | /** A number as four bytes, big endian. */ |
| 70 | function u32be(value) { |
| 71 | const out = new Uint8Array(4); |
| 72 | new DataView(out.buffer).setUint32(0, value >>> 0, false); |
| 73 | return out; |
| 74 | } |
| 75 | |
| 76 | /** The number four bytes at `at` hold, big endian. */ |
| 77 | function readU32be(bytes, at) { return new DataView(bytes.buffer, bytes.byteOffset + at, 4).getUint32(0, false); } |
| 78 | |
| 79 | /** A number as eight bytes, big endian. */ |
| 80 | function u64be(value) { |
| 81 | const out = new Uint8Array(8); |
| 82 | new DataView(out.buffer).setBigUint64(0, BigInt(value), false); |
| 83 | return out; |
| 84 | } |
| 85 | |
| 86 | /** The number eight bytes at `at` hold, big endian. */ |
| 87 | function readU64be(bytes, at) { return new DataView(bytes.buffer, bytes.byteOffset + at, 8).getBigUint64(0, false); } |
| 88 | |
| 89 | /** |
| 90 | * The canonical JSON of a value: object keys sorted, no whitespace. Two sides that hold the |
| 91 | * same value therefore hold the same bytes, which is what a signature or an associated data |
| 92 | * needs. (JSON.stringify would keep the order the object was built in, which is not a promise |
| 93 | * we can make across two implementations.) |
| 94 | */ |
| 95 | function canonicalJson(value) { |
| 96 | if (value === null || typeof value !== 'object') return JSON.stringify(value); |
| 97 | if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; |
| 98 | const keys = Object.keys(value).filter(key => value[key] !== undefined).sort(); |
| 99 | return `{${keys.map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`; |
| 100 | } |
| 101 | |
| 102 | __ovLockerModule.exports = { |
| 103 | utf8, fromUtf8, concat, base64, fromBase64, base64url, fromBase64url, |
| 104 | u32be, readU32be, u64be, readU64be, canonicalJson, |
| 105 | }; |
| 106 | |
| 107 | }, |
| 108 | "errors": function (__ovLockerModule, exports, __ovLockerReq) { |
| 109 | ; |
| 110 | /** |
| 111 | * The one error Locker throws. The code says what went wrong, so a caller can tell a wrong |
| 112 | * password from a truncated download from bytes that are not an envelope at all. |
| 113 | * |
| 114 | * The codes: locker.invalid (the caller asked for something impossible), locker.bad_envelope |
| 115 | * (the bytes are not an envelope we wrote), locker.truncated (the bytes stop early), |
| 116 | * locker.wrong_key (the key is not the one), locker.no_key (no key was given), locker.no_slot |
| 117 | * (there is no slot for this key), locker.size_mismatch (the size does not add up) and |
| 118 | * locker.no_crypto (the runtime has no WebCrypto). |
| 119 | */ |
| 120 | class LockerError extends Error { |
| 121 | constructor(code, message) { |
| 122 | super(message); |
| 123 | this.name = 'LockerError'; |
| 124 | this.code = code; |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | /** A Locker error, or a plain Error with the same shape. */ |
| 129 | function fail(code, message) { throw new LockerError(code, message); } |
| 130 | |
| 131 | __ovLockerModule.exports = { LockerError, fail }; |
| 132 | |
| 133 | }, |
| 134 | "format": function (__ovLockerModule, exports, __ovLockerReq) { |
| 135 | ; |
| 136 | /** |
| 137 | * The Locker envelope on the wire (locker.envelope@1). This file is the format and nothing |
| 138 | * else: the numbers, the framing and the associated data. The cryptography lives in keys.js |
| 139 | * and seal.js; the rules for a service that only reads the header live in server.js. |
| 140 | * |
| 141 | * 0 4 8 8 + header length |
| 142 | * +------+------+--------------------------+--------------------------+ |
| 143 | * | OVLK | len | header JSON (UTF-8) | chunk frames | |
| 144 | * +------+------+--------------------------+--------------------------+ |
| 145 | * |
| 146 | * each chunk frame: |
| 147 | * 0 4 5 17 |
| 148 | * +------+------+--------------------------+--------------------------+ |
| 149 | * | len | flags| nonce (12 bytes) | ciphertext + 16-byte tag | |
| 150 | * +------+------+--------------------------+--------------------------+ |
| 151 | */ |
| 152 | const { utf8, fromUtf8, concat, base64, fromBase64, u32be, readU32be, canonicalJson } = __ovLockerReq('./bytes'); |
| 153 | const { fail } = __ovLockerReq('./errors'); |
| 154 | const { padme } = __ovLockerReq('./padme'); |
| 155 | |
| 156 | /** The four bytes that say "this is a Locker envelope". */ |
| 157 | const MAGIC = 'OVLK'; |
| 158 | /** The format this library reads and writes. */ |
| 159 | const VERSION = 'locker.envelope.v1'; |
| 160 | /** The only suite in this version: AES-256-GCM, a 32-byte key. */ |
| 161 | const SUITE = 'AES-256-GCM'; |
| 162 | /** The chunk size in bytes: 1 MiB. */ |
| 163 | const CHUNK_SIZE = 1048576; |
| 164 | /** The GCM tag is appended to the ciphertext. */ |
| 165 | const TAG_SIZE = 16; |
| 166 | /** The GCM nonce is 12 bytes. */ |
| 167 | const NONCE_SIZE = 12; |
| 168 | /** The first half of every nonce: eight random bytes, in the header. */ |
| 169 | const NONCE_BASE_SIZE = 8; |
| 170 | /** The padded plaintext starts with the true length, as eight bytes. */ |
| 171 | const LENGTH_PREFIX = 8; |
| 172 | /** |
| 173 | * The smallest padded plaintext we write. A short note still costs 256 bytes: below that the |
| 174 | * padded length would say too much about the real one (Padmé's grid is coarse down there), and |
| 175 | * 256 bytes is nothing next to a chunk. |
| 176 | */ |
| 177 | const MIN_PADDED_SIZE = 256; |
| 178 | /** A header is small. A bigger one is not a header we wrote, and a service must not read it. */ |
| 179 | const MAX_HEADER_BYTES = 65536; |
| 180 | /** The bit that marks the last chunk of an envelope. */ |
| 181 | const FINAL_FLAG = 0x01; |
| 182 | |
| 183 | /** |
| 184 | * The header fields every chunk authenticates. The slots are not in here, on purpose: adding a |
| 185 | * member to a people-locked item re-wraps the content key without re-encrypting the content, so |
| 186 | * the core cannot change either. Each slot is authenticated by its own tag instead. |
| 187 | */ |
| 188 | function coreOf(header) { |
| 189 | return { |
| 190 | version: header.version, |
| 191 | suite: header.suite, |
| 192 | chunk_size: header.chunk_size, |
| 193 | padded_size: header.padded_size, |
| 194 | nonce_base: header.nonce_base, |
| 195 | lock: header.lock, |
| 196 | }; |
| 197 | } |
| 198 | |
| 199 | /** The canonical JSON of the core: the same bytes on both sides, whatever order the header was written in. */ |
| 200 | function coreBytes(header) { return utf8(canonicalJson(coreOf(header))); } |
| 201 | |
| 202 | /** The associated data of one chunk: the core, the chunk counter and the flags. */ |
| 203 | function chunkAad(header, counter, flags) { |
| 204 | return concat(coreBytes(header), u32be(counter), Uint8Array.of(flags)); |
| 205 | } |
| 206 | |
| 207 | /** The header as the bytes that go on the wire. */ |
| 208 | function encodeHeader(header) { return utf8(canonicalJson(header)); } |
| 209 | |
| 210 | /** The header's JSON bytes, as they were written. */ |
| 211 | function decodeHeader(headerBytes) { |
| 212 | let header; |
| 213 | try { header = JSON.parse(fromUtf8(headerBytes)); } |
| 214 | catch { fail('locker.bad_envelope', 'The envelope header is not JSON'); } |
| 215 | return header; |
| 216 | } |
| 217 | |
| 218 | /** The whole envelope: the magic, the header and the chunks. */ |
| 219 | function encodeEnvelope(headerBytes, chunks) { |
| 220 | return concat(utf8(MAGIC), u32be(headerBytes.length), headerBytes, chunks); |
| 221 | } |
| 222 | |
| 223 | /** |
| 224 | * Reads the header out of envelope bytes. It stops at the header: the chunks are not touched, |
| 225 | * so this is safe to do on bytes that are not an envelope at all. |
| 226 | */ |
| 227 | function readHeader(bytes) { |
| 228 | if (!bytes || bytes.length < 8) fail('locker.bad_envelope', 'The envelope is too short to hold a header'); |
| 229 | const magic = fromUtf8(bytes.slice(0, 4)); |
| 230 | if (magic !== MAGIC) fail('locker.bad_envelope', 'These bytes are not a Locker envelope'); |
| 231 | const headerLength = readU32be(bytes, 4); |
| 232 | if (headerLength === 0 || headerLength > MAX_HEADER_BYTES) { |
| 233 | fail('locker.bad_envelope', `The envelope header is ${headerLength} bytes, which is not one we write`); |
| 234 | } |
| 235 | if (bytes.length < 8 + headerLength) fail('locker.truncated', 'The envelope ends inside its header'); |
| 236 | const headerBytes = bytes.slice(8, 8 + headerLength); |
| 237 | return { header: decodeHeader(headerBytes), headerBytes, chunkOffset: 8 + headerLength }; |
| 238 | } |
| 239 | |
| 240 | /** |
| 241 | * How many chunks a padded plaintext of this size takes. Every chunk but the last is a whole |
| 242 | * chunk; the last holds what is left of it. |
| 243 | */ |
| 244 | function chunkCount(padded) { return Math.max(1, Math.ceil(padded / CHUNK_SIZE)); } |
| 245 | |
| 246 | /** How many bytes the last chunk of a padded plaintext of this size holds: more than zero. */ |
| 247 | function lastChunkSize(padded) { return padded - (chunkCount(padded) - 1) * CHUNK_SIZE; } |
| 248 | |
| 249 | /** |
| 250 | * The padded size of a content of this size: the length prefix plus the content, padded with |
| 251 | * Padmé up to the next size on its grid, and never below the floor. |
| 252 | */ |
| 253 | function paddedSize(size) { return Math.max(MIN_PADDED_SIZE, padme(LENGTH_PREFIX + size)); } |
| 254 | |
| 255 | /** The nonce of a chunk: the header's base, then the counter. No nonce repeats inside an envelope. */ |
| 256 | function chunkNonce(nonceBase, counter) { |
| 257 | const nonce = new Uint8Array(NONCE_SIZE); |
| 258 | nonce.set(nonceBase, 0); |
| 259 | new DataView(nonce.buffer).setUint32(8, counter >>> 0, false); |
| 260 | return nonce; |
| 261 | } |
| 262 | |
| 263 | /** One chunk frame: its length, its flags, its nonce and the ciphertext with the tag. */ |
| 264 | function encodeChunk(counter, flags, nonce, ciphertext) { |
| 265 | return concat(u32be(ciphertext.length), Uint8Array.of(flags), nonce, ciphertext); |
| 266 | } |
| 267 | |
| 268 | /** The header's nonce base as bytes. */ |
| 269 | function nonceBaseBytes(header) { |
| 270 | const bytes = fromBase64(header.nonce_base); |
| 271 | if (bytes.length !== NONCE_BASE_SIZE) fail('locker.bad_envelope', 'The envelope nonce base is not eight bytes'); |
| 272 | return bytes; |
| 273 | } |
| 274 | |
| 275 | __ovLockerModule.exports = { |
| 276 | MAGIC, VERSION, SUITE, CHUNK_SIZE, TAG_SIZE, NONCE_SIZE, NONCE_BASE_SIZE, LENGTH_PREFIX, |
| 277 | MIN_PADDED_SIZE, MAX_HEADER_BYTES, FINAL_FLAG, |
| 278 | coreOf, coreBytes, chunkAad, encodeHeader, decodeHeader, encodeEnvelope, readHeader, |
| 279 | chunkCount, lastChunkSize, paddedSize, chunkNonce, encodeChunk, nonceBaseBytes, |
| 280 | }; |
| 281 | |
| 282 | }, |
| 283 | "index": function (__ovLockerModule, exports, __ovLockerReq) { |
| 284 | ; |
| 285 | /** |
| 286 | * openvibe-sdk/locker: end-to-end encryption for anything on OpenVibe. |
| 287 | * |
| 288 | * One format, one library, two runtimes: Node and the browser, WebCrypto only. A locked item is |
| 289 | * stored and served as ciphertext; the keys never reach our servers. |
| 290 | * |
| 291 | * const { seal, open, generatePersonKeys } = locker; // openvibe-sdk/locker |
| 292 | * const person = await generatePersonKeys(); |
| 293 | * const sealed = await seal('a note', { lock: 'people', keys: [{ subject, publicKey: person.ecdhPublic }] }); |
| 294 | * const note = await open(sealed, { privateKey: person.ecdh.privateKey, subject }); |
| 295 | */ |
| 296 | const format = __ovLockerReq('./format'); |
| 297 | const keys = __ovLockerReq('./keys'); |
| 298 | const seal = __ovLockerReq('./seal'); |
| 299 | const { padme } = __ovLockerReq('./padme'); |
| 300 | const { inspect } = __ovLockerReq('./inspect'); |
| 301 | const { recoveryWords, isRecoveryPhrase, RECOVERY_WORDS } = __ovLockerReq('./recovery'); |
| 302 | const { WORDS } = __ovLockerReq('./words'); |
| 303 | const { LockerError } = __ovLockerReq('./errors'); |
| 304 | |
| 305 | __ovLockerModule.exports = { |
| 306 | // The format. |
| 307 | VERSION: format.VERSION, |
| 308 | SUITE: format.SUITE, |
| 309 | CHUNK_SIZE: format.CHUNK_SIZE, |
| 310 | MIN_PADDED_SIZE: format.MIN_PADDED_SIZE, |
| 311 | MAX_HEADER_BYTES: format.MAX_HEADER_BYTES, |
| 312 | PASSWORD_ITERATIONS: keys.PASSWORD_ITERATIONS, |
| 313 | PASSWORD_ITERATIONS_MIN: keys.PASSWORD_ITERATIONS_MIN, |
| 314 | PASSWORD_ITERATIONS_MAX: keys.PASSWORD_ITERATIONS_MAX, |
| 315 | |
| 316 | // The content. |
| 317 | seal: seal.seal, |
| 318 | open: seal.open, |
| 319 | sealStream: seal.sealStream, |
| 320 | openStream: seal.openStream, |
| 321 | rewrap: seal.rewrap, |
| 322 | |
| 323 | // What an envelope says about itself, without a key. |
| 324 | inspect, |
| 325 | |
| 326 | // The padding: the padded size of a content of a given size. |
| 327 | padme, |
| 328 | |
| 329 | // The recovery phrase: words to write down, and a check on a typed one. |
| 330 | recoveryWords, |
| 331 | isRecoveryPhrase, |
| 332 | RECOVERY_WORDS, |
| 333 | WORDS, |
| 334 | |
| 335 | // A person's keys, and the eight words that say which keys they are. |
| 336 | generatePersonKeys: keys.generatePersonKeys, |
| 337 | exportPersonKeys: keys.exportPersonKeys, |
| 338 | exportPrivateKeys: keys.exportPrivateKeys, |
| 339 | importPersonKeys: keys.importPersonKeys, |
| 340 | verifyPersonKeys: keys.verifyPersonKeys, |
| 341 | fingerprintOf: keys.fingerprintOf, |
| 342 | fingerprintWords: keys.fingerprintWords, |
| 343 | jwkThumbprint: keys.jwkThumbprint, |
| 344 | |
| 345 | // The key slots: the content key, wrapped for one person or for one password. |
| 346 | wrapFor: keys.wrapFor, |
| 347 | wrapForPassword: keys.wrapForPassword, |
| 348 | unwrapPeopleSlot: keys.unwrapPeopleSlot, |
| 349 | unwrapPasswordSlot: keys.unwrapPasswordSlot, |
| 350 | |
| 351 | LockerError, |
| 352 | }; |
| 353 | |
| 354 | }, |
| 355 | "inspect": function (__ovLockerModule, exports, __ovLockerReq) { |
| 356 | ; |
| 357 | /** |
| 358 | * inspect: what an envelope says about itself, without a key and without decrypting anything. |
| 359 | * |
| 360 | * A service, or a page that shows a locked item, often needs to know the lock kind, how big |
| 361 | * the item is, who it is for and how strong the password is — before it has any key at all. |
| 362 | * This reads the header and nothing else: no chunk is touched and no key is used. |
| 363 | * |
| 364 | * The header holds no content, only wrapped keys, so this says nothing the server does not |
| 365 | * already see. |
| 366 | */ |
| 367 | const { envelopeBytes, readCore } = __ovLockerReq('./seal'); |
| 368 | const { readHeader } = __ovLockerReq('./format'); |
| 369 | |
| 370 | /** |
| 371 | * What an envelope says about itself. |
| 372 | * |
| 373 | * { version, lock, padded_size, recipients, iterations } |
| 374 | * |
| 375 | * `recipients` holds the thumbprint of each recipient's key for a people lock (the same |
| 376 | * strings the slots carry), and `iterations` the PBKDF2 count for a password lock. The other |
| 377 | * field is empty for the other lock kinds. |
| 378 | */ |
| 379 | function inspect(bytes) { |
| 380 | const data = envelopeBytes(bytes); |
| 381 | const { header } = readHeader(data); |
| 382 | readCore(header); |
| 383 | const out = { |
| 384 | version: header.version, |
| 385 | lock: header.lock, |
| 386 | padded_size: header.padded_size, |
| 387 | recipients: [], |
| 388 | iterations: null, |
| 389 | }; |
| 390 | if (header.lock === 'people') { |
| 391 | out.recipients = (header.slots.people || []).map(slot => slot.recipient_key); |
| 392 | } else if (header.lock === 'password' && header.slots.password) { |
| 393 | out.iterations = header.slots.password.iterations; |
| 394 | } |
| 395 | return out; |
| 396 | } |
| 397 | |
| 398 | __ovLockerModule.exports = { inspect }; |
| 399 | |
| 400 | }, |
| 401 | "keys": function (__ovLockerModule, exports, __ovLockerReq) { |
| 402 | ; |
| 403 | /** |
| 404 | * The keys: a person's two key pairs, the content key, and the two ways of wrapping the content |
| 405 | * key for someone else (their public key, or a password). |
| 406 | * |
| 407 | * WebCrypto only: globalThis.crypto.subtle. No other crypto library, nothing hand-rolled: the |
| 408 | * ECDH, the ECDSA, the HKDF, the PBKDF2 and the AES-GCM are all the browser's own. |
| 409 | */ |
| 410 | const { utf8, concat, base64, base64url, fromBase64, canonicalJson } = __ovLockerReq('./bytes'); |
| 411 | const { fail } = __ovLockerReq('./errors'); |
| 412 | const { WORDS } = __ovLockerReq('./words'); |
| 413 | |
| 414 | /** The suite of the key pairs: P-256, which every browser's WebCrypto has. */ |
| 415 | const CURVE = 'P-256'; |
| 416 | /** How the content key is wrapped for a person. */ |
| 417 | const PEOPLE_ALGORITHM = 'ECDH-P256/HKDF-SHA256/AES-256-GCM'; |
| 418 | /** How the content key is wrapped for a password. */ |
| 419 | const PASSWORD_ALGORITHM = 'PBKDF2-SHA256/AES-256-GCM'; |
| 420 | /** The PBKDF2 floor for this version. The count may go up; it may not go down. */ |
| 421 | const PASSWORD_ITERATIONS = 600000; |
| 422 | /** The smallest PBKDF2 count we accept, on both sides: the floor above. */ |
| 423 | const PASSWORD_ITERATIONS_MIN = 600000; |
| 424 | /** |
| 425 | * The largest PBKDF2 count we accept. The count comes from the header, so a hostile header could |
| 426 | * ask for a derivation that takes hours; the ceiling keeps a browser honest. |
| 427 | */ |
| 428 | const PASSWORD_ITERATIONS_MAX = 10000000; |
| 429 | /** The content key is 32 bytes: AES-256. */ |
| 430 | const KEY_BYTES = 32; |
| 431 | /** The salt of a people slot (HKDF) and of a password slot (PBKDF2). */ |
| 432 | const SALT_BYTES = 16; |
| 433 | |
| 434 | /** WebCrypto, or a clear error when the runtime has none. */ |
| 435 | function subtle() { |
| 436 | const crypto = globalThis.crypto; |
| 437 | if (!crypto || !crypto.subtle) { |
| 438 | fail('locker.no_crypto', 'This runtime has no WebCrypto (globalThis.crypto.subtle)'); |
| 439 | } |
| 440 | return crypto.subtle; |
| 441 | } |
| 442 | |
| 443 | /** Random bytes from the platform. */ |
| 444 | function platformRandom(length) { |
| 445 | const bytes = new Uint8Array(length); |
| 446 | globalThis.crypto.getRandomValues(bytes); |
| 447 | return bytes; |
| 448 | } |
| 449 | |
| 450 | /** The SHA-256 of some bytes. */ |
| 451 | async function sha256(bytes) { |
| 452 | return new Uint8Array(await subtle().digest('SHA-256', bytes)); |
| 453 | } |
| 454 | |
| 455 | /** The bytes of a public JWK, as they go on the wire: no key_ops, no ext. */ |
| 456 | function publicJwk(jwk) { |
| 457 | return { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }; |
| 458 | } |
| 459 | |
| 460 | /** The message a person signs: both public keys, canonically. */ |
| 461 | function fingerprintMessage(ecdhPublic, signingPublic) { |
| 462 | return utf8(canonicalJson({ ecdh: publicJwk(ecdhPublic), signing: publicJwk(signingPublic) })); |
| 463 | } |
| 464 | |
| 465 | /** |
| 466 | * The eight words of a fingerprint: the first eight bytes of SHA-256, eight bits each, in two |
| 467 | * groups of four. Sixty-four bits, not thirty-two: a server that swapped in its own key would |
| 468 | * need about 2^32 key generations to land on the same four words, which is not "verify a |
| 469 | * person"; with eight words it would need 2^64. |
| 470 | * |
| 471 | * The two groups are joined with a middle dot, not a hyphen: a hyphen reads as one compound |
| 472 | * word ("plateau yew-apple petal"), and the groups are meant to be read apart. |
| 473 | */ |
| 474 | function fingerprintWords(hash) { |
| 475 | const words = []; |
| 476 | for (let i = 0; i < 8; i++) words.push(WORDS[hash[i]]); |
| 477 | return `${words.slice(0, 4).join(' ')} · ${words.slice(4).join(' ')}`; |
| 478 | } |
| 479 | |
| 480 | /** |
| 481 | * The RFC 7638 thumbprint of a public JWK: SHA-256 over the canonical JSON of its four |
| 482 | * required members, base64url. It names one key and nothing else, so a slot can be bound to |
| 483 | * the key of the person it is for. |
| 484 | */ |
| 485 | async function jwkThumbprint(jwk) { |
| 486 | const canonical = canonicalJson({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }); |
| 487 | return base64url(await sha256(utf8(canonical))); |
| 488 | } |
| 489 | |
| 490 | /** The fingerprint of a pair of public keys. */ |
| 491 | async function fingerprintOf(ecdhPublic, signingPublic) { |
| 492 | return fingerprintWords(await sha256(fingerprintMessage(ecdhPublic, signingPublic))); |
| 493 | } |
| 494 | |
| 495 | /** |
| 496 | * A person's two key pairs: an ECDH pair that receives content keys, and an ECDSA pair that signs |
| 497 | * the fingerprint. The signing key signs both public keys, so the pair is self-certifying: a |
| 498 | * reader can check that the holder owns the signing key, and the fingerprint changes whenever |
| 499 | * either key changes. |
| 500 | */ |
| 501 | async function generatePersonKeys() { |
| 502 | const s = subtle(); |
| 503 | const ecdh = await s.generateKey({ name: 'ECDH', namedCurve: CURVE }, true, ['deriveBits']); |
| 504 | const signing = await s.generateKey({ name: 'ECDSA', namedCurve: CURVE }, true, ['sign', 'verify']); |
| 505 | const ecdhPublic = publicJwk(await s.exportKey('jwk', ecdh.publicKey)); |
| 506 | const signingPublic = publicJwk(await s.exportKey('jwk', signing.publicKey)); |
| 507 | const message = fingerprintMessage(ecdhPublic, signingPublic); |
| 508 | const signature = new Uint8Array(await s.sign({ name: 'ECDSA', hash: 'SHA-256' }, signing.privateKey, message)); |
| 509 | return { |
| 510 | ecdh: { publicKey: ecdh.publicKey, privateKey: ecdh.privateKey }, |
| 511 | signing: { publicKey: signing.publicKey, privateKey: signing.privateKey }, |
| 512 | ecdhPublic, |
| 513 | signingPublic, |
| 514 | fingerprint: fingerprintWords(await sha256(message)), |
| 515 | fingerprintSignature: signature, |
| 516 | }; |
| 517 | } |
| 518 | |
| 519 | /** The public half of a person's keys, as it is published: locker.public-keys@1. */ |
| 520 | async function exportPersonKeys(person) { |
| 521 | const ecdhPublic = publicJwk(person.ecdhPublic || await subtle().exportKey('jwk', person.ecdh.publicKey)); |
| 522 | const signingPublic = publicJwk(person.signingPublic || await subtle().exportKey('jwk', person.signing.publicKey)); |
| 523 | const message = fingerprintMessage(ecdhPublic, signingPublic); |
| 524 | const signature = person.fingerprintSignature |
| 525 | || new Uint8Array(await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, (person.signing?.privateKey || person.signingPrivateKey), message)); |
| 526 | return { |
| 527 | ecdh: ecdhPublic, |
| 528 | signing: signingPublic, |
| 529 | fingerprint: fingerprintWords(await sha256(message)), |
| 530 | fingerprint_signature: base64(signature), |
| 531 | }; |
| 532 | } |
| 533 | |
| 534 | /** |
| 535 | * The private half of a person's keys, as it is stored: the two private keys as JWKs. |
| 536 | */ |
| 537 | async function exportPrivateKeys(person) { |
| 538 | const ecdh = await subtle().exportKey('jwk', person.ecdh?.privateKey || person.ecdhPrivateKey); |
| 539 | const signing = await subtle().exportKey('jwk', person.signing?.privateKey || person.signingPrivateKey); |
| 540 | return { ecdh, signing }; |
| 541 | } |
| 542 | |
| 543 | /** |
| 544 | * A person's keys, ready to use: the private keys as CryptoKeys and the public keys as JWKs. |
| 545 | * The private keys may be JWKs (as they are stored) or CryptoKeys (as they are in memory). |
| 546 | */ |
| 547 | async function importPersonKeys(data) { |
| 548 | const s = subtle(); |
| 549 | const ecdhPrivate = data.ecdhPrivateKey |
| 550 | || await s.importKey('jwk', data.ecdh, { name: 'ECDH', namedCurve: CURVE }, true, ['deriveBits']); |
| 551 | const signingPrivate = data.signingPrivateKey |
| 552 | || await s.importKey('jwk', data.signing, { name: 'ECDSA', namedCurve: CURVE }, true, ['sign']); |
| 553 | return { |
| 554 | ecdhPrivateKey: ecdhPrivate, |
| 555 | signingPrivateKey: signingPrivate, |
| 556 | ecdhPublic: data.ecdh ? publicJwk(data.ecdh) : publicJwk(await s.exportKey('jwk', ecdhPrivate)), |
| 557 | signingPublic: data.signing ? publicJwk(data.signing) : publicJwk(await s.exportKey('jwk', signingPrivate)), |
| 558 | }; |
| 559 | } |
| 560 | |
| 561 | /** |
| 562 | * Checks a published set of keys: the signature must verify with the signing key, and the four |
| 563 | * words must be the fingerprint of the two keys. Returns false instead of throwing, so a caller |
| 564 | * can show a warning rather than an error. |
| 565 | */ |
| 566 | async function verifyPersonKeys(data) { |
| 567 | try { |
| 568 | const s = subtle(); |
| 569 | const message = fingerprintMessage(data.ecdh, data.signing); |
| 570 | const signature = fromBase64(data.fingerprint_signature); |
| 571 | const key = await s.importKey('jwk', data.signing, { name: 'ECDSA', namedCurve: CURVE }, true, ['verify']); |
| 572 | const ok = await s.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, signature, message); |
| 573 | if (!ok) return false; |
| 574 | return fingerprintWords(await sha256(message)) === data.fingerprint; |
| 575 | } catch { |
| 576 | return false; |
| 577 | } |
| 578 | } |
| 579 | |
| 580 | /** The content key as an AES-GCM CryptoKey. */ |
| 581 | async function importContentKey(bytes, usages) { |
| 582 | if (!bytes || bytes.length !== KEY_BYTES) fail('locker.invalid', 'The content key is 32 bytes'); |
| 583 | return subtle().importKey('raw', bytes, { name: 'AES-GCM', length: 256 }, false, usages); |
| 584 | } |
| 585 | |
| 586 | /** The wrapping key for a person: the ECDH secret, put through HKDF-SHA-256. */ |
| 587 | async function deriveWrappingKey(sharedSecret, salt, info) { |
| 588 | const s = subtle(); |
| 589 | const material = await s.importKey('raw', sharedSecret, 'HKDF', false, ['deriveKey']); |
| 590 | return s.deriveKey( |
| 591 | { name: 'HKDF', hash: 'SHA-256', salt, info }, |
| 592 | material, |
| 593 | { name: 'AES-GCM', length: 256 }, |
| 594 | false, |
| 595 | ['encrypt', 'decrypt'], |
| 596 | ); |
| 597 | } |
| 598 | |
| 599 | /** The wrapping key for a password: PBKDF2-SHA-256, with the parameters the header names. */ |
| 600 | async function derivePasswordKey(password, salt, iterations) { |
| 601 | if (typeof password !== 'string' || !password.length) fail('locker.invalid', 'A password lock needs a password'); |
| 602 | // The count comes from the header, so it is checked before it is used: a hostile header must |
| 603 | // not be able to make a reader spend minutes (or hours) on the derivation. |
| 604 | if (!Number.isInteger(iterations) || iterations < PASSWORD_ITERATIONS_MIN || iterations > PASSWORD_ITERATIONS_MAX) { |
| 605 | fail('locker.bad_envelope', `The iteration count is not between ${PASSWORD_ITERATIONS_MIN} and ${PASSWORD_ITERATIONS_MAX}`); |
| 606 | } |
| 607 | const s = subtle(); |
| 608 | const material = await s.importKey('raw', utf8(password), 'PBKDF2', false, ['deriveKey']); |
| 609 | return s.deriveKey( |
| 610 | { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' }, |
| 611 | material, |
| 612 | { name: 'AES-GCM', length: 256 }, |
| 613 | false, |
| 614 | ['encrypt', 'decrypt'], |
| 615 | ); |
| 616 | } |
| 617 | |
| 618 | /** |
| 619 | * The associated data of a people slot: it binds the slot to one person, to that person's key |
| 620 | * and to one envelope. |
| 621 | */ |
| 622 | function peopleSlotAad(subject, ephemeralPublic, salt, recipientKey) { |
| 623 | const id = typeof subject === 'string' ? subject : `${subject.type}:${subject.id}`; |
| 624 | return utf8(`openvibe-locker people-slot v1|${id}|${recipientKey}|${canonicalJson(publicJwk(ephemeralPublic))}|${base64(salt)}`); |
| 625 | } |
| 626 | |
| 627 | /** The associated data of a password slot: it binds the slot to its own parameters. */ |
| 628 | function passwordSlotAad(iterations, salt) { |
| 629 | return utf8(`openvibe-locker password-slot v1|${iterations}|${base64(salt)}`); |
| 630 | } |
| 631 | |
| 632 | /** A public key as WebCrypto wants it. */ |
| 633 | async function importPublicKey(jwk) { |
| 634 | try { |
| 635 | return await subtle().importKey('jwk', publicJwk(jwk), { name: 'ECDH', namedCurve: CURVE }, true, []); |
| 636 | } catch { |
| 637 | fail('locker.invalid', 'The public key is not a P-256 key'); |
| 638 | } |
| 639 | } |
| 640 | |
| 641 | /** |
| 642 | * The envelope's one ephemeral ECDH key pair. It may be given (the shared test vectors need the |
| 643 | * same bytes every time), or made fresh. |
| 644 | */ |
| 645 | async function resolveEphemeral(ephemeral) { |
| 646 | const s = subtle(); |
| 647 | if (!ephemeral) { |
| 648 | const pair = await s.generateKey({ name: 'ECDH', namedCurve: CURVE }, true, ['deriveBits']); |
| 649 | return { privateKey: pair.privateKey, publicKey: pair.publicKey }; |
| 650 | } |
| 651 | if (ephemeral.privateKey) return ephemeral; |
| 652 | const privateKey = await s.importKey('jwk', ephemeral.private, { name: 'ECDH', namedCurve: CURVE }, true, ['deriveBits']); |
| 653 | return { privateKey }; |
| 654 | } |
| 655 | |
| 656 | /** |
| 657 | * Wraps the content key for one person: the content key, sealed for their public key. |
| 658 | * The envelope has one ephemeral ECDH key pair; the shared secret with each person goes through |
| 659 | * HKDF-SHA-256, and the slot's tag covers the person, their key, the ephemeral key and the |
| 660 | * salt, so a slot cannot be moved to another person or replayed from another envelope. |
| 661 | */ |
| 662 | async function wrapFor(contentKey, publicKey, { subject, salt, random = platformRandom, ephemeral } = {}) { |
| 663 | const s = subtle(); |
| 664 | if (!subject) fail('locker.invalid', 'A people slot needs the subject it is for'); |
| 665 | const theirs = await importPublicKey(publicKey); |
| 666 | const pair = await resolveEphemeral(ephemeral); |
| 667 | const ephemeralPublic = publicJwk(await s.exportKey('jwk', pair.publicKey || pair.privateKey)); |
| 668 | // The slot names the key it is for, so an opener can check it before deriving anything. |
| 669 | const recipientKey = await jwkThumbprint(publicJwk(publicKey)); |
| 670 | const shared = new Uint8Array(await s.deriveBits({ name: 'ECDH', public: theirs }, pair.privateKey, 256)); |
| 671 | const slotSalt = salt || random(SALT_BYTES); |
| 672 | const key = await deriveWrappingKey(shared, slotSalt, utf8(`openvibe-locker people-slot v1|${subject.type}:${subject.id}|${recipientKey}`)); |
| 673 | const nonce = random(12); |
| 674 | const sealed = new Uint8Array(await s.encrypt( |
| 675 | { name: 'AES-GCM', iv: nonce, additionalData: peopleSlotAad(subject, ephemeralPublic, slotSalt, recipientKey) }, |
| 676 | key, |
| 677 | contentKey, |
| 678 | )); |
| 679 | return { |
| 680 | subject, |
| 681 | algorithm: PEOPLE_ALGORITHM, |
| 682 | recipient_key: recipientKey, |
| 683 | ephemeral_public_key: ephemeralPublic, |
| 684 | salt: base64(slotSalt), |
| 685 | nonce: base64(nonce), |
| 686 | wrapped_key: base64(sealed), |
| 687 | }; |
| 688 | } |
| 689 | |
| 690 | /** The content key out of a people slot: the recipient's private key and the slot. */ |
| 691 | async function unwrapPeopleSlot(slot, privateKey) { |
| 692 | const s = subtle(); |
| 693 | // The slot names the key it was made for. If it is not this key, the slot is not this |
| 694 | // person's, and nothing is derived: the check comes before the work. |
| 695 | if (!slot.recipient_key) fail('locker.bad_envelope', 'The slot does not name the key it is for'); |
| 696 | const theirPublic = publicJwk(await s.exportKey('jwk', privateKey)); |
| 697 | const recipientKey = await jwkThumbprint(theirPublic); |
| 698 | if (slot.recipient_key !== recipientKey) { |
| 699 | fail('locker.wrong_key', 'This slot is for another key'); |
| 700 | } |
| 701 | const theirs = await s.importKey('jwk', publicJwk(slot.ephemeral_public_key), { name: 'ECDH', namedCurve: CURVE }, true, []); |
| 702 | const shared = new Uint8Array(await s.deriveBits({ name: 'ECDH', public: theirs }, privateKey, 256)); |
| 703 | const salt = fromBase64(slot.salt); |
| 704 | const key = await deriveWrappingKey(shared, salt, utf8(`openvibe-locker people-slot v1|${slot.subject.type}:${slot.subject.id}|${recipientKey}`)); |
| 705 | try { |
| 706 | const plain = new Uint8Array(await s.decrypt( |
| 707 | { name: 'AES-GCM', iv: fromBase64(slot.nonce), additionalData: peopleSlotAad(slot.subject, slot.ephemeral_public_key, salt, recipientKey) }, |
| 708 | key, |
| 709 | fromBase64(slot.wrapped_key), |
| 710 | )); |
| 711 | if (plain.length !== KEY_BYTES) fail('locker.wrong_key', 'The slot does not hold a content key'); |
| 712 | return plain; |
| 713 | } catch (err) { |
| 714 | if (err && err.code) throw err; |
| 715 | fail('locker.wrong_key', 'This key does not open that slot'); |
| 716 | } |
| 717 | } |
| 718 | |
| 719 | /** Wraps the content key with a password: PBKDF2-SHA-256, then AES-256-GCM. */ |
| 720 | async function wrapForPassword(contentKey, password, { salt, iterations = PASSWORD_ITERATIONS, random = platformRandom } = {}) { |
| 721 | const s = subtle(); |
| 722 | const slotSalt = salt || random(SALT_BYTES); |
| 723 | const key = await derivePasswordKey(password, slotSalt, iterations); |
| 724 | const nonce = random(12); |
| 725 | const sealed = new Uint8Array(await s.encrypt( |
| 726 | { name: 'AES-GCM', iv: nonce, additionalData: passwordSlotAad(iterations, slotSalt) }, |
| 727 | key, |
| 728 | contentKey, |
| 729 | )); |
| 730 | return { |
| 731 | algorithm: PASSWORD_ALGORITHM, |
| 732 | kdf: 'PBKDF2-SHA-256', |
| 733 | iterations, |
| 734 | salt: base64(slotSalt), |
| 735 | nonce: base64(nonce), |
| 736 | wrapped_key: base64(sealed), |
| 737 | }; |
| 738 | } |
| 739 | |
| 740 | /** The content key out of a password slot. The parameters come from the slot, never from here. */ |
| 741 | async function unwrapPasswordSlot(slot, password) { |
| 742 | const s = subtle(); |
| 743 | const salt = fromBase64(slot.salt); |
| 744 | const key = await derivePasswordKey(password, salt, slot.iterations); |
| 745 | try { |
| 746 | const plain = new Uint8Array(await s.decrypt( |
| 747 | { name: 'AES-GCM', iv: fromBase64(slot.nonce), additionalData: passwordSlotAad(slot.iterations, salt) }, |
| 748 | key, |
| 749 | fromBase64(slot.wrapped_key), |
| 750 | )); |
| 751 | if (plain.length !== KEY_BYTES) fail('locker.wrong_key', 'The slot does not hold a content key'); |
| 752 | return plain; |
| 753 | } catch (err) { |
| 754 | if (err && err.code) throw err; |
| 755 | fail('locker.wrong_key', 'That password does not open this item'); |
| 756 | } |
| 757 | } |
| 758 | |
| 759 | __ovLockerModule.exports = { |
| 760 | CURVE, PEOPLE_ALGORITHM, PASSWORD_ALGORITHM, PASSWORD_ITERATIONS, |
| 761 | PASSWORD_ITERATIONS_MIN, PASSWORD_ITERATIONS_MAX, KEY_BYTES, SALT_BYTES, |
| 762 | subtle, platformRandom, sha256, publicJwk, fingerprintMessage, fingerprintWords, fingerprintOf, |
| 763 | jwkThumbprint, generatePersonKeys, exportPersonKeys, exportPrivateKeys, importPersonKeys, |
| 764 | verifyPersonKeys, importContentKey, deriveWrappingKey, derivePasswordKey, resolveEphemeral, |
| 765 | peopleSlotAad, passwordSlotAad, wrapFor, unwrapPeopleSlot, wrapForPassword, unwrapPasswordSlot, |
| 766 | }; |
| 767 | |
| 768 | }, |
| 769 | "padme": function (__ovLockerModule, exports, __ovLockerReq) { |
| 770 | ; |
| 771 | /** |
| 772 | * Padmé padding (Nikitin et al., "Reducing Metadata Leakage from Encrypted Files and Network |
| 773 | * Traffic", PoPETs 2019). |
| 774 | * |
| 775 | * The point of padding a locked item: the server must not learn how big it is. Rounding the size |
| 776 | * up to a whole 1 MiB chunk says too much — a 200-byte note becomes a 1 MiB object, and every |
| 777 | * item under a megabyte looks the same, which is a leak of its own. |
| 778 | * |
| 779 | * Padmé rounds the length up to the next size on a grid that gets finer as the length grows: |
| 780 | * |
| 781 | * E = floor(log2(n)) the band the length is in |
| 782 | * S = floor(log2(E)) + 1 how many steps the band has |
| 783 | * d = 2^(E - S) the step of the band |
| 784 | * P(n) = d * ceil(n / d) the padded length |
| 785 | * |
| 786 | * The padded length is one of a small number of values per band, and the bands double, so the |
| 787 | * number of possible padded lengths up to n grows like log(n): knowing the padded length says |
| 788 | * O(log log n) bits about the real one. The expansion stays under 12% (the worst case is a |
| 789 | * length just above a band boundary, 129 bytes padded to 144). |
| 790 | * |
| 791 | * The arithmetic is exact above 2^31: there are no 32-bit bitwise operators here, only |
| 792 | * comparisons and division by a power of two, both exact on the doubles we have. |
| 793 | */ |
| 794 | |
| 795 | /** The largest e with 2^e <= size: floor(log2(size)), without a logarithm. */ |
| 796 | function exponentOf(size) { |
| 797 | let e = 0; |
| 798 | let power = 1; |
| 799 | while (power * 2 <= size) { power *= 2; e++; } |
| 800 | return e; |
| 801 | } |
| 802 | |
| 803 | /** |
| 804 | * The padded length of a content of this size: the smallest length on Padmé's grid that is not |
| 805 | * smaller than it. Zero for zero, which the caller handles. |
| 806 | */ |
| 807 | function padme(size) { |
| 808 | if (!Number.isInteger(size) || size <= 0) return 0; |
| 809 | if (size === 1) return 1; |
| 810 | const band = exponentOf(size); |
| 811 | const steps = exponentOf(band) + 1; |
| 812 | const step = Math.pow(2, band - steps); |
| 813 | // Dividing by a power of two is exact, so the ceiling is the ceiling and not nearly it. |
| 814 | return Math.ceil(size / step) * step; |
| 815 | } |
| 816 | |
| 817 | __ovLockerModule.exports = { padme, exponentOf }; |
| 818 | |
| 819 | }, |
| 820 | "recovery": function (__ovLockerModule, exports, __ovLockerReq) { |
| 821 | ; |
| 822 | /** |
| 823 | * The recovery phrase: the words a person writes down. |
| 824 | * |
| 825 | * A phrase of n words from the same 256-word list the fingerprint uses, one byte per word from |
| 826 | * the platform's CSPRNG (crypto.getRandomValues). Eight bits per word, so the usual 24 words |
| 827 | * carry 192 bits: enough for a key nobody else has, and short enough to write on paper. |
| 828 | * |
| 829 | * There is no checksum in the phrase: the words are the secret, and a phrase that is typed |
| 830 | * wrong simply does not open anything. isRecoveryPhrase only says whether every word is one of |
| 831 | * the 256, so a UI can tell a typo from a word that was never in the list. |
| 832 | */ |
| 833 | const { fail } = __ovLockerReq('./errors'); |
| 834 | const { platformRandom } = __ovLockerReq('./keys'); |
| 835 | const { WORDS } = __ovLockerReq('./words'); |
| 836 | |
| 837 | /** How many words a recovery phrase has, unless the caller says otherwise. */ |
| 838 | const RECOVERY_WORDS = 24; |
| 839 | |
| 840 | /** |
| 841 | * A recovery phrase: n words from the list, one byte each. |
| 842 | * |
| 843 | * recoveryWords() // 24 words, 192 bits |
| 844 | * recoveryWords(12) // 12 words, 96 bits |
| 845 | */ |
| 846 | function recoveryWords(count = RECOVERY_WORDS, { random = platformRandom } = {}) { |
| 847 | if (!Number.isInteger(count) || count < 1 || count > WORDS.length) { |
| 848 | fail('locker.invalid', `A recovery phrase is one to ${WORDS.length} words`); |
| 849 | } |
| 850 | // One byte per word: the list has 256 words, so a byte picks one of them exactly. |
| 851 | const bytes = random(count); |
| 852 | const words = []; |
| 853 | for (const byte of bytes) words.push(WORDS[byte]); |
| 854 | return words.join(' '); |
| 855 | } |
| 856 | |
| 857 | /** |
| 858 | * Whether a typed phrase is made of words from the list. It says nothing about the order or |
| 859 | * about whether the phrase is the right one: only that every word could have come from us. |
| 860 | */ |
| 861 | function isRecoveryPhrase(phrase) { |
| 862 | if (typeof phrase !== 'string') return false; |
| 863 | const words = phrase.trim().split(/\s+/).filter(Boolean); |
| 864 | if (!words.length) return false; |
| 865 | return words.every(word => WORDS.includes(word.toLowerCase())); |
| 866 | } |
| 867 | |
| 868 | __ovLockerModule.exports = { RECOVERY_WORDS, recoveryWords, isRecoveryPhrase }; |
| 869 | |
| 870 | }, |
| 871 | "seal": function (__ovLockerModule, exports, __ovLockerReq) { |
| 872 | ; |
| 873 | /** |
| 874 | * seal and open: the content, the three locks, and the streams. |
| 875 | * |
| 876 | * The envelope is written once and read once. Every chunk is sealed with AES-256-GCM under the |
| 877 | * content key, with the header's core, the chunk counter and the flags as associated data, so a |
| 878 | * chunk cannot be cut, reordered or truncated without the open failing. |
| 879 | */ |
| 880 | const { |
| 881 | utf8, fromUtf8, concat, base64, base64url, fromBase64url, u32be, readU32be, u64be, readU64be, |
| 882 | } = __ovLockerReq('./bytes'); |
| 883 | const { fail } = __ovLockerReq('./errors'); |
| 884 | const { |
| 885 | MAGIC, VERSION, SUITE, CHUNK_SIZE, TAG_SIZE, NONCE_BASE_SIZE, LENGTH_PREFIX, MIN_PADDED_SIZE, |
| 886 | FINAL_FLAG, coreOf, chunkAad, encodeHeader, encodeEnvelope, readHeader, chunkCount, |
| 887 | lastChunkSize, paddedSize, chunkNonce, encodeChunk, nonceBaseBytes, |
| 888 | } = __ovLockerReq('./format'); |
| 889 | const { |
| 890 | KEY_BYTES, PASSWORD_ITERATIONS, subtle, platformRandom, importContentKey, publicJwk, |
| 891 | jwkThumbprint, wrapFor, unwrapPeopleSlot, wrapForPassword, unwrapPasswordSlot, resolveEphemeral, |
| 892 | } = __ovLockerReq('./keys'); |
| 893 | |
| 894 | /** The content as bytes: a string is UTF-8, anything else is taken as it is. */ |
| 895 | function toBytes(content) { |
| 896 | if (typeof content === 'string') return utf8(content); |
| 897 | if (content instanceof Uint8Array) return content; |
| 898 | if (content instanceof ArrayBuffer) return new Uint8Array(content); |
| 899 | if (ArrayBuffer.isView(content)) return new Uint8Array(content.buffer, content.byteOffset, content.byteLength); |
| 900 | fail('locker.invalid', 'The content must be a string or bytes'); |
| 901 | } |
| 902 | |
| 903 | /** The bytes of an envelope: what seal returns, or what the caller kept. */ |
| 904 | function envelopeBytes(envelope) { |
| 905 | if (envelope instanceof Uint8Array) return envelope; |
| 906 | if (envelope && envelope.bytes instanceof Uint8Array) return envelope.bytes; |
| 907 | fail('locker.invalid', 'That is not a Locker envelope'); |
| 908 | } |
| 909 | |
| 910 | /** A source of bytes: an async iterable of Uint8Array. */ |
| 911 | async function* once(bytes) { yield bytes; } |
| 912 | |
| 913 | /** |
| 914 | * Collects bytes into blocks of a fixed size, without ever holding more than one block and the |
| 915 | * part that has not been used yet. This is what keeps a stream seal in constant memory. |
| 916 | */ |
| 917 | class Chunker { |
| 918 | constructor(size) { this.size = size; this.parts = []; this.length = 0; } |
| 919 | |
| 920 | push(part) { this.parts.push(part); this.length += part.length; } |
| 921 | |
| 922 | /** The next full block, or null when there is not enough yet. */ |
| 923 | take() { |
| 924 | if (this.length < this.size) return null; |
| 925 | const block = new Uint8Array(this.size); |
| 926 | let at = 0; |
| 927 | while (at < this.size) { |
| 928 | const head = this.parts[0]; |
| 929 | const take = Math.min(head.length, this.size - at); |
| 930 | block.set(head.subarray(0, take), at); |
| 931 | at += take; |
| 932 | if (take === head.length) this.parts.shift(); |
| 933 | else this.parts[0] = head.subarray(take); |
| 934 | } |
| 935 | this.length -= this.size; |
| 936 | return block; |
| 937 | } |
| 938 | |
| 939 | /** What is left, as one block of exactly this many bytes: the bytes that are there, then zeros. */ |
| 940 | takeExactly(count) { |
| 941 | if (this.length > count) fail('locker.size_mismatch', 'The content is longer than the size it was given'); |
| 942 | const block = new Uint8Array(count); |
| 943 | let at = 0; |
| 944 | for (const part of this.parts) { block.set(part, at); at += part.length; } |
| 945 | this.parts = []; |
| 946 | this.length = 0; |
| 947 | return block; |
| 948 | } |
| 949 | } |
| 950 | |
| 951 | /** The header, the content key and the nonce base: everything a seal needs before it writes. */ |
| 952 | async function prepare(options) { |
| 953 | const lock = options.lock; |
| 954 | if (!['people', 'password', 'link'].includes(lock)) { |
| 955 | fail('locker.invalid', 'A lock is people, password or link'); |
| 956 | } |
| 957 | const random = options.random || platformRandom; |
| 958 | const contentKey = options.contentKey ? toBytes(options.contentKey) : random(KEY_BYTES); |
| 959 | if (contentKey.length !== KEY_BYTES) fail('locker.invalid', 'The content key is 32 bytes'); |
| 960 | const nonceBase = options.nonceBase ? toBytes(options.nonceBase) : random(NONCE_BASE_SIZE); |
| 961 | if (nonceBase.length !== NONCE_BASE_SIZE) fail('locker.invalid', 'The nonce base is eight bytes'); |
| 962 | let slots; |
| 963 | if (lock === 'people') { |
| 964 | const keys = options.keys || []; |
| 965 | if (!keys.length) fail('locker.invalid', 'A people lock needs at least one recipient'); |
| 966 | // One ephemeral key pair for the whole envelope: the shared secret differs per person. |
| 967 | const pair = await resolveEphemeral(options.ephemeral); |
| 968 | const people = []; |
| 969 | for (const key of keys) { |
| 970 | people.push(await wrapFor(contentKey, key.publicKey, { |
| 971 | subject: key.subject, random, ephemeral: pair, |
| 972 | })); |
| 973 | } |
| 974 | slots = { people }; |
| 975 | } else if (lock === 'password') { |
| 976 | slots = { |
| 977 | password: await wrapForPassword(contentKey, options.password, { |
| 978 | salt: options.salt, iterations: options.iterations || PASSWORD_ITERATIONS, random, |
| 979 | }), |
| 980 | }; |
| 981 | } else { |
| 982 | // A link lock stores no key: the content key travels in the part of the link after the #. |
| 983 | slots = { link: {} }; |
| 984 | } |
| 985 | return { contentKey, nonceBase, slots }; |
| 986 | } |
| 987 | |
| 988 | /** The header of an envelope. */ |
| 989 | function makeHeader(lock, slots, padded, nonceBase) { |
| 990 | return { |
| 991 | version: VERSION, |
| 992 | suite: SUITE, |
| 993 | chunk_size: CHUNK_SIZE, |
| 994 | padded_size: padded, |
| 995 | nonce_base: base64(nonceBase), |
| 996 | lock, |
| 997 | slots, |
| 998 | }; |
| 999 | } |
| 1000 | |
| 1001 | /** Seals one block of the padded plaintext. */ |
| 1002 | async function sealBlock(key, header, nonceBase, counter, block, isFinal) { |
| 1003 | const flags = isFinal ? FINAL_FLAG : 0; |
| 1004 | const nonce = chunkNonce(nonceBase, counter); |
| 1005 | const ciphertext = new Uint8Array(await subtle().encrypt( |
| 1006 | { name: 'AES-GCM', iv: nonce, additionalData: chunkAad(header, counter, flags) }, |
| 1007 | key, |
| 1008 | block, |
| 1009 | )); |
| 1010 | return encodeChunk(counter, flags, nonce, ciphertext); |
| 1011 | } |
| 1012 | |
| 1013 | /** |
| 1014 | * The envelope, piece by piece. The header comes first (it holds the padded size, so the size |
| 1015 | * has to be known before the first byte is written), then one frame per chunk. |
| 1016 | */ |
| 1017 | async function* sealPieces(source, options) { |
| 1018 | const size = options.size; |
| 1019 | const { contentKey, nonceBase, slots } = await prepare(options); |
| 1020 | const padded = paddedSize(size); |
| 1021 | const header = makeHeader(options.lock, slots, padded, nonceBase); |
| 1022 | const headerBytes = encodeHeader(header); |
| 1023 | yield { header, headerBytes, contentKey, bytes: concat(utf8(MAGIC), u32be(headerBytes.length), headerBytes) }; |
| 1024 | const key = await importContentKey(contentKey, ['encrypt']); |
| 1025 | const total = chunkCount(padded); |
| 1026 | const chunker = new Chunker(CHUNK_SIZE); |
| 1027 | let counter = 0; |
| 1028 | // The padded plaintext starts with the true length, so the server cannot read the size. |
| 1029 | chunker.push(u64be(size)); |
| 1030 | for await (const part of source) { |
| 1031 | chunker.push(part); |
| 1032 | // Every chunk but the last is a whole chunk. |
| 1033 | for (;;) { |
| 1034 | if (counter >= total - 1) break; |
| 1035 | const block = chunker.take(); |
| 1036 | if (!block) break; |
| 1037 | yield { bytes: await sealBlock(key, header, nonceBase, counter, block, false) }; |
| 1038 | counter++; |
| 1039 | } |
| 1040 | } |
| 1041 | // The content is done; the padding fills the rest of the padded plaintext. (A content that |
| 1042 | // is longer than the size it was given fails in takeExactly, below.) |
| 1043 | while (counter < total - 1) { |
| 1044 | const block = chunker.takeExactly(CHUNK_SIZE); |
| 1045 | yield { bytes: await sealBlock(key, header, nonceBase, counter, block, false) }; |
| 1046 | counter++; |
| 1047 | } |
| 1048 | // The last chunk: what is left of the plaintext, then zeros up to the padded size. |
| 1049 | const tail = chunker.takeExactly(lastChunkSize(padded)); |
| 1050 | yield { bytes: await sealBlock(key, header, nonceBase, counter, tail, true) }; |
| 1051 | } |
| 1052 | |
| 1053 | /** |
| 1054 | * Seals content: a string or bytes. The result holds the envelope's bytes and its header. |
| 1055 | * |
| 1056 | * seal('a note', { lock: 'password', password: 'correct horse' }) |
| 1057 | * seal(bytes, { lock: 'people', keys: [{ subject, publicKey }] }) |
| 1058 | * seal(bytes, { lock: 'link' }) // the key comes back as linkKey |
| 1059 | */ |
| 1060 | async function seal(content, options = {}) { |
| 1061 | const bytes = toBytes(content); |
| 1062 | const pieces = []; |
| 1063 | let header = null; |
| 1064 | let headerBytes = null; |
| 1065 | let contentKey = null; |
| 1066 | for await (const piece of sealPieces(once(bytes), { ...options, size: bytes.length })) { |
| 1067 | if (piece.header) { header = piece.header; headerBytes = piece.headerBytes; contentKey = piece.contentKey; } |
| 1068 | pieces.push(piece.bytes); |
| 1069 | } |
| 1070 | const envelope = { |
| 1071 | version: VERSION, |
| 1072 | header, |
| 1073 | headerBytes, |
| 1074 | bytes: concat(...pieces), |
| 1075 | chunkOffset: 8 + headerBytes.length, |
| 1076 | }; |
| 1077 | if (options.lock === 'link') { |
| 1078 | // The link carries the content key; the envelope itself stores nothing. |
| 1079 | envelope.linkKey = base64url(contentKey); |
| 1080 | } |
| 1081 | return envelope; |
| 1082 | } |
| 1083 | |
| 1084 | /** The header's core, as a reader needs it: the fields must be the ones we write. */ |
| 1085 | function readCore(header) { |
| 1086 | if (!header || typeof header !== 'object' || Array.isArray(header)) { |
| 1087 | fail('locker.bad_envelope', 'The envelope header is not an object'); |
| 1088 | } |
| 1089 | if (header.version !== VERSION) fail('locker.bad_envelope', 'This envelope is another version'); |
| 1090 | if (header.suite !== SUITE) fail('locker.bad_envelope', 'This envelope uses another suite'); |
| 1091 | if (header.chunk_size !== CHUNK_SIZE) fail('locker.bad_envelope', 'This envelope uses another chunk size'); |
| 1092 | if (!Number.isInteger(header.padded_size) || header.padded_size < MIN_PADDED_SIZE) { |
| 1093 | fail('locker.bad_envelope', 'The padded size is not a size we write'); |
| 1094 | } |
| 1095 | if (!['people', 'password', 'link'].includes(header.lock)) { |
| 1096 | fail('locker.bad_envelope', 'The lock is not one we know'); |
| 1097 | } |
| 1098 | if (!header.slots || typeof header.slots !== 'object') { |
| 1099 | fail('locker.bad_envelope', 'The envelope has no slots'); |
| 1100 | } |
| 1101 | return header; |
| 1102 | } |
| 1103 | |
| 1104 | /** The content key out of the header, with the credentials the reader has. */ |
| 1105 | async function openContentKey(header, credentials = {}) { |
| 1106 | // A caller that still holds the content key needs no private key: an agent that sealed the item |
| 1107 | // can open it, or wrap it for the people who stay, with the key it kept. A string is base64url, |
| 1108 | // as the link key travels. |
| 1109 | if (credentials.contentKey) { |
| 1110 | const given = typeof credentials.contentKey === 'string' |
| 1111 | ? fromBase64url(credentials.contentKey) : toBytes(credentials.contentKey); |
| 1112 | if (given.length !== KEY_BYTES) fail('locker.invalid', 'The content key is 32 bytes'); |
| 1113 | return given; |
| 1114 | } |
| 1115 | if (header.lock === 'people') { |
| 1116 | // A person may be named by their key instead of their subject: the slot says which key |
| 1117 | // it is for, so the reader does not need to know who they are. |
| 1118 | if (credentials.person) { |
| 1119 | const slot = await slotForPerson(header, credentials.person); |
| 1120 | const privateKey = await importPrivateKey(privateKeyOf(credentials.person) || credentials.privateKey); |
| 1121 | if (!privateKey) fail('locker.no_key', 'A people lock needs the private key of a recipient'); |
| 1122 | return unwrapPeopleSlot(slot, privateKey); |
| 1123 | } |
| 1124 | const slot = pickSlot(header, credentials.subject); |
| 1125 | const privateKey = await importPrivateKey(credentials.privateKey); |
| 1126 | if (!privateKey) fail('locker.no_key', 'A people lock needs the private key of a recipient'); |
| 1127 | return unwrapPeopleSlot(slot, privateKey); |
| 1128 | } |
| 1129 | if (header.lock === 'password') { |
| 1130 | if (!credentials.password) fail('locker.no_key', 'A password lock needs the password'); |
| 1131 | if (!header.slots.password) fail('locker.bad_envelope', 'The envelope has no password slot'); |
| 1132 | return unwrapPasswordSlot(header.slots.password, credentials.password); |
| 1133 | } |
| 1134 | if (!credentials.key) fail('locker.no_key', 'A link lock needs the key from the link'); |
| 1135 | const key = typeof credentials.key === 'string' ? fromBase64url(credentials.key) : toBytes(credentials.key); |
| 1136 | if (key.length !== KEY_BYTES) fail('locker.invalid', 'The link key is 32 bytes'); |
| 1137 | return key; |
| 1138 | } |
| 1139 | |
| 1140 | /** The slot of the person who is opening: theirs, or the only one there is. */ |
| 1141 | function pickSlot(header, subject) { |
| 1142 | const slots = (header.slots && header.slots.people) || []; |
| 1143 | if (!slots.length) fail('locker.bad_envelope', 'The envelope has no people slots'); |
| 1144 | if (subject) { |
| 1145 | const id = typeof subject === 'string' ? subject : subject.id; |
| 1146 | const type = typeof subject === 'string' ? null : subject.type; |
| 1147 | const found = slots.find(slot => slot.subject.id === id && (type === null || slot.subject.type === type)); |
| 1148 | if (!found) fail('locker.no_key', 'This envelope has no slot for that person'); |
| 1149 | return found; |
| 1150 | } |
| 1151 | if (slots.length > 1) fail('locker.no_key', 'This envelope has several slots: say which person you are'); |
| 1152 | return slots[0]; |
| 1153 | } |
| 1154 | |
| 1155 | /** The ECDH public key of a person, as a JWK. */ |
| 1156 | async function publicKeyOf(person) { |
| 1157 | if (!person || typeof person !== 'object') fail('locker.invalid', 'A people lock needs the person'); |
| 1158 | if (person.ecdhPublic) return publicJwk(person.ecdhPublic); |
| 1159 | if (person.ecdh && person.ecdh.kty) return publicJwk(person.ecdh); |
| 1160 | if (person.kty === 'EC') return publicJwk(person); |
| 1161 | if (person.ecdh && person.ecdh.publicKey) { |
| 1162 | return publicJwk(await subtle().exportKey('jwk', person.ecdh.publicKey)); |
| 1163 | } |
| 1164 | fail('locker.invalid', 'The person has no ECDH public key'); |
| 1165 | } |
| 1166 | |
| 1167 | /** The ECDH private key of a person, if they brought one. */ |
| 1168 | function privateKeyOf(person) { |
| 1169 | if (!person || typeof person !== 'object') return null; |
| 1170 | if (person.ecdh && person.ecdh.privateKey) return person.ecdh.privateKey; |
| 1171 | if (person.ecdhPrivateKey) return person.ecdhPrivateKey; |
| 1172 | if (person.privateKey) return person.privateKey; |
| 1173 | return null; |
| 1174 | } |
| 1175 | |
| 1176 | /** |
| 1177 | * The slot that names this person's key. Every people slot carries the thumbprint of the key |
| 1178 | * it was made for, so the reader finds its own slot without knowing anybody's subject. An |
| 1179 | * envelope that has no slot for this key is not an envelope this person can open: that is |
| 1180 | * locker.no_slot, which says the key is not on the list (not that the key is wrong). |
| 1181 | */ |
| 1182 | async function slotForPerson(header, person) { |
| 1183 | const slots = (header.slots && header.slots.people) || []; |
| 1184 | if (!slots.length) fail('locker.bad_envelope', 'The envelope has no people slots'); |
| 1185 | const thumbprint = await jwkThumbprint(await publicKeyOf(person)); |
| 1186 | const found = slots.find(slot => slot.recipient_key === thumbprint); |
| 1187 | if (!found) fail('locker.no_slot', 'This envelope has no slot for that key'); |
| 1188 | return found; |
| 1189 | } |
| 1190 | |
| 1191 | /** A private key as WebCrypto wants it: a CryptoKey, or a JWK to import. */ |
| 1192 | async function importPrivateKey(key) { |
| 1193 | if (!key) return null; |
| 1194 | if (key.type === 'private' && key.algorithm && key.algorithm.name === 'ECDH') return key; |
| 1195 | if (key.kty === 'EC' && key.crv === 'P-256' && key.d) { |
| 1196 | try { |
| 1197 | return await subtle().importKey('jwk', key, { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']); |
| 1198 | } catch { |
| 1199 | fail('locker.invalid', 'The private key is not a P-256 key'); |
| 1200 | } |
| 1201 | } |
| 1202 | fail('locker.invalid', 'The private key must be an ECDH P-256 key'); |
| 1203 | } |
| 1204 | |
| 1205 | /** Reads exact byte counts out of an envelope that is already in memory. */ |
| 1206 | function arrayReader(bytes) { |
| 1207 | let at = 0; |
| 1208 | return { |
| 1209 | /** The next bytes, or null when the envelope is over. */ |
| 1210 | async probe(count) { |
| 1211 | if (at >= bytes.length) return null; |
| 1212 | return this.take(count); |
| 1213 | }, |
| 1214 | async take(count) { |
| 1215 | if (at + count > bytes.length) fail('locker.truncated', 'The envelope ends inside a chunk'); |
| 1216 | const out = bytes.slice(at, at + count); |
| 1217 | at += count; |
| 1218 | return out; |
| 1219 | }, |
| 1220 | }; |
| 1221 | } |
| 1222 | |
| 1223 | /** Reads exact byte counts out of a stream, keeping only what it must. */ |
| 1224 | function streamReader(iterator) { |
| 1225 | let buffer = new Uint8Array(0); |
| 1226 | let ended = false; |
| 1227 | async function fill(count) { |
| 1228 | while (buffer.length < count && !ended) { |
| 1229 | const next = await iterator.next(); |
| 1230 | if (next.done) { ended = true; break; } |
| 1231 | buffer = buffer.length ? concat(buffer, next.value) : next.value; |
| 1232 | } |
| 1233 | return buffer.length; |
| 1234 | } |
| 1235 | return { |
| 1236 | /** The next bytes, or null when the stream is over. */ |
| 1237 | async probe(count) { |
| 1238 | const have = await fill(count); |
| 1239 | if (have === 0) return null; |
| 1240 | return this.take(count); |
| 1241 | }, |
| 1242 | async take(count) { |
| 1243 | const have = await fill(count); |
| 1244 | if (have < count) fail('locker.truncated', 'The envelope ends inside a chunk'); |
| 1245 | const out = buffer.slice(0, count); |
| 1246 | buffer = buffer.slice(count); |
| 1247 | return out; |
| 1248 | }, |
| 1249 | }; |
| 1250 | } |
| 1251 | |
| 1252 | /** |
| 1253 | * Opens the chunks: the plaintext, piece by piece. The reader may be an envelope in memory or a |
| 1254 | * stream; both are read the same way, so the same checks run on both. |
| 1255 | */ |
| 1256 | async function* openPieces(reader, header, key) { |
| 1257 | const expected = chunkCount(header.padded_size); |
| 1258 | const lastLength = lastChunkSize(header.padded_size); |
| 1259 | const nonceBase = nonceBaseBytes(header); |
| 1260 | let counter = 0; |
| 1261 | let length = null; |
| 1262 | let emitted = 0; |
| 1263 | for (;;) { |
| 1264 | const lengthBytes = await reader.probe(4); |
| 1265 | if (lengthBytes === null) { |
| 1266 | if (counter !== expected) fail('locker.truncated', 'The envelope ends before its last chunk'); |
| 1267 | break; |
| 1268 | } |
| 1269 | if (counter >= expected) fail('locker.bad_envelope', 'The envelope has more chunks than its size says'); |
| 1270 | const cipherLength = readU32be(lengthBytes, 0); |
| 1271 | // The length is checked before it is used: a header that claims a huge chunk must not |
| 1272 | // make the reader wait for it. Every chunk but the last is a whole chunk; the last holds |
| 1273 | // what is left of the padded size. |
| 1274 | const wantedLength = counter === expected - 1 ? lastLength : CHUNK_SIZE; |
| 1275 | if (cipherLength !== wantedLength + TAG_SIZE) { |
| 1276 | fail('locker.bad_envelope', 'A chunk is not the size the envelope says'); |
| 1277 | } |
| 1278 | const flags = (await reader.take(1))[0]; |
| 1279 | const nonce = await reader.take(12); |
| 1280 | const ciphertext = await reader.take(cipherLength); |
| 1281 | const isFinal = (flags & FINAL_FLAG) === FINAL_FLAG; |
| 1282 | if (isFinal !== (counter === expected - 1)) { |
| 1283 | fail('locker.bad_envelope', 'The envelope marks the wrong chunk as the last one'); |
| 1284 | } |
| 1285 | const wanted = chunkNonce(nonceBase, counter); |
| 1286 | for (let i = 0; i < wanted.length; i++) { |
| 1287 | if (nonce[i] !== wanted[i]) fail('locker.bad_envelope', 'A chunk carries the wrong nonce'); |
| 1288 | } |
| 1289 | let plain; |
| 1290 | try { |
| 1291 | plain = new Uint8Array(await subtle().decrypt( |
| 1292 | { name: 'AES-GCM', iv: nonce, additionalData: chunkAad(header, counter, flags) }, |
| 1293 | key, |
| 1294 | ciphertext, |
| 1295 | )); |
| 1296 | } catch (err) { |
| 1297 | if (err && err.code) throw err; |
| 1298 | fail('locker.wrong_key', 'This envelope does not open with this key'); |
| 1299 | } |
| 1300 | if (counter === 0) { |
| 1301 | if (plain.length < LENGTH_PREFIX) fail('locker.bad_envelope', 'The first chunk is too short'); |
| 1302 | length = Number(readU64be(plain, 0)); |
| 1303 | if (length > header.padded_size - LENGTH_PREFIX) { |
| 1304 | fail('locker.size_mismatch', 'The content is longer than the envelope says'); |
| 1305 | } |
| 1306 | } |
| 1307 | const start = counter === 0 ? LENGTH_PREFIX : 0; |
| 1308 | const part = plain.subarray(start); |
| 1309 | const room = length - emitted; |
| 1310 | const out = part.length > room ? part.subarray(0, room) : part; |
| 1311 | emitted += out.length; |
| 1312 | counter++; |
| 1313 | if (out.length) yield out; |
| 1314 | } |
| 1315 | if (emitted !== length) fail('locker.size_mismatch', 'The envelope holds fewer bytes than it says'); |
| 1316 | } |
| 1317 | |
| 1318 | /** |
| 1319 | * Opens an envelope: the content as bytes. |
| 1320 | * |
| 1321 | * open(envelope, { privateKey, subject }) // a people lock, by subject |
| 1322 | * open(envelope, { person }) // a people lock, by key |
| 1323 | * open(envelope, { password }) // a password lock |
| 1324 | * open(envelope, { key }) // a link lock |
| 1325 | */ |
| 1326 | async function open(envelope, credentials = {}) { |
| 1327 | const bytes = envelopeBytes(envelope); |
| 1328 | const { header, chunkOffset } = readHeader(bytes); |
| 1329 | readCore(header); |
| 1330 | const contentKey = await openContentKey(header, credentials); |
| 1331 | const key = await importContentKey(contentKey, ['decrypt']); |
| 1332 | const reader = arrayReader(bytes.subarray(chunkOffset)); |
| 1333 | const parts = []; |
| 1334 | for await (const part of openPieces(reader, header, key)) parts.push(part); |
| 1335 | return concat(...parts); |
| 1336 | } |
| 1337 | |
| 1338 | /** A stream of the bytes of a WHATWG stream (or of any async iterable of bytes). */ |
| 1339 | async function* readChunks(stream) { |
| 1340 | if (stream && typeof stream.getReader === 'function') { |
| 1341 | const reader = stream.getReader(); |
| 1342 | try { |
| 1343 | for (;;) { |
| 1344 | const { value, done } = await reader.read(); |
| 1345 | if (done) return; |
| 1346 | yield value instanceof Uint8Array ? value : new Uint8Array(value); |
| 1347 | } |
| 1348 | } finally { |
| 1349 | reader.releaseLock(); |
| 1350 | } |
| 1351 | return; |
| 1352 | } |
| 1353 | for await (const part of stream) yield part instanceof Uint8Array ? part : new Uint8Array(part); |
| 1354 | } |
| 1355 | |
| 1356 | /** |
| 1357 | * Seals a stream: the envelope comes out piece by piece, and only one chunk is ever in memory. |
| 1358 | * The size of the content has to be known, because the padded size is in the header and the |
| 1359 | * header is written first. |
| 1360 | */ |
| 1361 | function sealStream(stream, options = {}) { |
| 1362 | if (typeof options.size !== 'number' || options.size < 0) { |
| 1363 | fail('locker.invalid', 'A stream seal needs the size of the content'); |
| 1364 | } |
| 1365 | const iterator = sealPieces(readChunks(stream), { ...options, size: options.size })[Symbol.asyncIterator](); |
| 1366 | return new ReadableStream({ |
| 1367 | async pull(controller) { |
| 1368 | const next = await iterator.next(); |
| 1369 | if (next.done) controller.close(); |
| 1370 | else controller.enqueue(next.value.bytes); |
| 1371 | }, |
| 1372 | async cancel(reason) { if (iterator.return) await iterator.return(reason); }, |
| 1373 | }); |
| 1374 | } |
| 1375 | |
| 1376 | /** |
| 1377 | * Opens a stream: the content comes out piece by piece, and only one chunk is ever in memory. |
| 1378 | * |
| 1379 | * The chunks are yielded as they are verified, so a reader sees the earlier ones before the last |
| 1380 | * one is reached. A stream that ends early throws at the end, when the missing chunk shows up: |
| 1381 | * discard what it yielded, because the content was never complete. |
| 1382 | */ |
| 1383 | function openStream(stream, credentials = {}) { |
| 1384 | const iterator = (async function* openStreamPieces() { |
| 1385 | const reader = streamReader(readChunks(stream)[Symbol.asyncIterator]()); |
| 1386 | const head = await reader.probe(8); |
| 1387 | if (head === null) fail('locker.truncated', 'The envelope is too short to hold a header'); |
| 1388 | if (fromUtf8(head.subarray(0, 4)) !== MAGIC) fail('locker.bad_envelope', 'These bytes are not a Locker envelope'); |
| 1389 | const headerLength = readU32be(head, 4); |
| 1390 | if (headerLength === 0 || headerLength > 65536) { |
| 1391 | fail('locker.bad_envelope', `The envelope header is ${headerLength} bytes, which is not one we write`); |
| 1392 | } |
| 1393 | const headerBytes = await reader.take(headerLength); |
| 1394 | let header; |
| 1395 | try { header = JSON.parse(fromUtf8(headerBytes)); } |
| 1396 | catch { fail('locker.bad_envelope', 'The envelope header is not JSON'); } |
| 1397 | readCore(header); |
| 1398 | const contentKey = await openContentKey(header, credentials); |
| 1399 | const key = await importContentKey(contentKey, ['decrypt']); |
| 1400 | yield* openPieces(reader, header, key); |
| 1401 | })()[Symbol.asyncIterator](); |
| 1402 | return new ReadableStream({ |
| 1403 | async pull(controller) { |
| 1404 | const next = await iterator.next(); |
| 1405 | if (next.done) controller.close(); |
| 1406 | else controller.enqueue(next.value); |
| 1407 | }, |
| 1408 | async cancel(reason) { if (iterator.return) await iterator.return(reason); }, |
| 1409 | }); |
| 1410 | } |
| 1411 | |
| 1412 | /** |
| 1413 | * Wraps the content key for other people, without touching the content: the envelope's header |
| 1414 | * changes, the chunks do not, because the slots are not part of what the chunks authenticate. |
| 1415 | * This is how a member is added to a people-locked item: the people who are already on it keep |
| 1416 | * their slots, and the new ones get one of their own. |
| 1417 | * |
| 1418 | * Removing a member wants a fresh content key, not just a slot that is gone: the person who is |
| 1419 | * removed may still hold the old key. So a removal opens the content and seals it again, with a |
| 1420 | * new key and a new nonce base, for the people who are left. Their public keys are needed for |
| 1421 | * that, so the caller gives them in `keys`. |
| 1422 | */ |
| 1423 | async function rewrap(envelope, { |
| 1424 | add = [], remove = [], keys = [], credentials = {}, random = platformRandom, ephemeral, |
| 1425 | } = {}) { |
| 1426 | const bytes = envelopeBytes(envelope); |
| 1427 | const { header, chunkOffset } = readHeader(bytes); |
| 1428 | if (header.lock !== 'people') fail('locker.invalid', 'Only a people-locked envelope can be re-wrapped'); |
| 1429 | readCore(header); |
| 1430 | const gone = new Set(remove.map(who => (typeof who === 'string' ? who : who.id))); |
| 1431 | const staying = (header.slots.people || []).filter(slot => !gone.has(slot.subject.id)); |
| 1432 | |
| 1433 | if (!gone.size) { |
| 1434 | // Adding: the content key and the chunks stay as they are. |
| 1435 | const contentKey = await openContentKey(header, credentials); |
| 1436 | const people = staying.slice(); |
| 1437 | if (add.length) { |
| 1438 | const pair = await resolveEphemeral(ephemeral); |
| 1439 | for (const recipient of add) { |
| 1440 | people.push(await wrapFor(contentKey, recipient.publicKey, { |
| 1441 | subject: recipient.subject, ephemeral: pair, random, |
| 1442 | })); |
| 1443 | } |
| 1444 | } |
| 1445 | if (!people.length) fail('locker.invalid', 'A people-locked envelope needs at least one slot'); |
| 1446 | const newHeader = { ...coreOf(header), slots: { people } }; |
| 1447 | const headerBytes = encodeHeader(newHeader); |
| 1448 | return { |
| 1449 | version: VERSION, |
| 1450 | header: newHeader, |
| 1451 | headerBytes, |
| 1452 | bytes: encodeEnvelope(headerBytes, bytes.subarray(chunkOffset)), |
| 1453 | chunkOffset: 8 + headerBytes.length, |
| 1454 | }; |
| 1455 | } |
| 1456 | |
| 1457 | // Removing: the content is opened and sealed again under a new key, so the key the person |
| 1458 | // who is leaving still holds opens nothing. |
| 1459 | const contentKey = await openContentKey(header, credentials); |
| 1460 | const plain = await open(bytes, credentials); |
| 1461 | const recipients = []; |
| 1462 | for (const slot of staying) { |
| 1463 | const found = keys.find(key => (typeof key.subject === 'string' ? key.subject : key.subject.id) === slot.subject.id); |
| 1464 | if (!found) { |
| 1465 | fail('locker.invalid', `Removing a person needs the public key of ${slot.subject.id}, who stays`); |
| 1466 | } |
| 1467 | recipients.push({ subject: slot.subject, publicKey: found.publicKey }); |
| 1468 | } |
| 1469 | for (const recipient of add) recipients.push({ subject: recipient.subject, publicKey: recipient.publicKey }); |
| 1470 | if (!recipients.length) fail('locker.invalid', 'A people-locked envelope needs at least one slot'); |
| 1471 | return seal(plain, { lock: 'people', keys: recipients, random, ephemeral }); |
| 1472 | } |
| 1473 | |
| 1474 | __ovLockerModule.exports = { |
| 1475 | seal, open, sealStream, openStream, rewrap, |
| 1476 | envelopeBytes, toBytes, openContentKey, readCore, prepare, makeHeader, Chunker, |
| 1477 | publicKeyOf, privateKeyOf, slotForPerson, |
| 1478 | }; |
| 1479 | |
| 1480 | }, |
| 1481 | "server": function (__ovLockerModule, exports, __ovLockerReq) { |
| 1482 | ; |
| 1483 | /** |
| 1484 | * What a service needs to know about an envelope without opening it: the header's shape and the |
| 1485 | * envelope's size. Nothing here decrypts anything, and nothing here needs a key: a service may |
| 1486 | * hold the bytes of an item it can never read. |
| 1487 | * |
| 1488 | * A service that stores an envelope should: |
| 1489 | * - check it with inspectEnvelope() and refuse what does not pass; |
| 1490 | * - keep the header as it is (it holds no key material, only wrapped keys); |
| 1491 | * - skip indexing, previews, thumbnails, transcoding and content AI; |
| 1492 | * - show the lock badge, and serve the bytes only to the people canView allows. |
| 1493 | */ |
| 1494 | const { fromUtf8, fromBase64, readU32be } = __ovLockerReq('./bytes'); |
| 1495 | const { |
| 1496 | MAGIC, VERSION, SUITE, CHUNK_SIZE, TAG_SIZE, NONCE_SIZE, NONCE_BASE_SIZE, MIN_PADDED_SIZE, |
| 1497 | MAX_HEADER_BYTES, chunkCount, lastChunkSize, |
| 1498 | } = __ovLockerReq('./format'); |
| 1499 | |
| 1500 | /** The bytes of a base64 field, or null when it is not base64 of that many bytes. */ |
| 1501 | function bytesOf(text, length) { |
| 1502 | if (typeof text !== 'string' || !text.length) return null; |
| 1503 | if (!/^[A-Za-z0-9+/]+={0,2}$/.test(text)) return null; |
| 1504 | let bytes; |
| 1505 | try { bytes = fromBase64(text); } catch { return null; } |
| 1506 | return bytes.length === length ? bytes : null; |
| 1507 | } |
| 1508 | |
| 1509 | /** A subject: { type, id }, both there. */ |
| 1510 | function subjectOk(subject) { |
| 1511 | return !!subject && typeof subject === 'object' && typeof subject.type === 'string' |
| 1512 | && typeof subject.id === 'string' && subject.type.length > 0 && subject.id.length > 0; |
| 1513 | } |
| 1514 | |
| 1515 | /** A public key as a JWK: P-256, with its two coordinates. */ |
| 1516 | function publicKeyOk(key) { |
| 1517 | return !!key && typeof key === 'object' && key.kty === 'EC' && key.crv === 'P-256' |
| 1518 | && typeof key.x === 'string' && /^[A-Za-z0-9_-]{43}$/.test(key.x) |
| 1519 | && typeof key.y === 'string' && /^[A-Za-z0-9_-]{43}$/.test(key.y); |
| 1520 | } |
| 1521 | |
| 1522 | /** One people slot: whose it is, which key it is for, and the wrapped key with the bytes it must have. */ |
| 1523 | function peopleSlotOk(slot) { |
| 1524 | if (!slot || typeof slot !== 'object') return false; |
| 1525 | if (!subjectOk(slot.subject)) return false; |
| 1526 | if (slot.algorithm !== 'ECDH-P256/HKDF-SHA256/AES-256-GCM') return false; |
| 1527 | // The thumbprint of the recipient's key: the slot names the key it is for. |
| 1528 | if (typeof slot.recipient_key !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(slot.recipient_key)) return false; |
| 1529 | if (!publicKeyOk(slot.ephemeral_public_key)) return false; |
| 1530 | if (!bytesOf(slot.salt, 16)) return false; |
| 1531 | if (!bytesOf(slot.nonce, NONCE_SIZE)) return false; |
| 1532 | return !!bytesOf(slot.wrapped_key, 48); |
| 1533 | } |
| 1534 | |
| 1535 | /** The password slot: the parameters and the wrapped key. */ |
| 1536 | function passwordSlotOk(slot) { |
| 1537 | if (!slot || typeof slot !== 'object') return false; |
| 1538 | if (slot.algorithm !== 'PBKDF2-SHA256/AES-256-GCM') return false; |
| 1539 | if (slot.kdf !== 'PBKDF2-SHA-256') return false; |
| 1540 | if (!Number.isInteger(slot.iterations) || slot.iterations < 600000 || slot.iterations > 10000000) return false; |
| 1541 | if (!bytesOf(slot.salt, 16)) return false; |
| 1542 | if (!bytesOf(slot.nonce, NONCE_SIZE)) return false; |
| 1543 | return !!bytesOf(slot.wrapped_key, 48); |
| 1544 | } |
| 1545 | |
| 1546 | /** |
| 1547 | * The header's shape: what it says, and whether the slots match the lock it names. The problems |
| 1548 | * are returned as words, so a service can log them; nothing is thrown and nothing is decrypted. |
| 1549 | */ |
| 1550 | function checkHeader(header) { |
| 1551 | const errors = []; |
| 1552 | if (!header || typeof header !== 'object' || Array.isArray(header)) { |
| 1553 | return ['the header is not an object']; |
| 1554 | } |
| 1555 | if (header.version !== VERSION) errors.push(`the version is not ${VERSION}`); |
| 1556 | if (header.suite !== SUITE) errors.push(`the suite is not ${SUITE}`); |
| 1557 | if (header.chunk_size !== CHUNK_SIZE) errors.push(`the chunk size is not ${CHUNK_SIZE}`); |
| 1558 | if (!Number.isInteger(header.padded_size) || header.padded_size < MIN_PADDED_SIZE) { |
| 1559 | errors.push(`the padded size is not at least ${MIN_PADDED_SIZE} bytes`); |
| 1560 | } |
| 1561 | if (!bytesOf(header.nonce_base, NONCE_BASE_SIZE)) errors.push('the nonce base is not eight bytes'); |
| 1562 | if (!['people', 'password', 'link'].includes(header.lock)) errors.push('the lock is not one we know'); |
| 1563 | const slots = header.slots; |
| 1564 | if (!slots || typeof slots !== 'object' || Array.isArray(slots)) { |
| 1565 | errors.push('the header has no slots'); |
| 1566 | return errors; |
| 1567 | } |
| 1568 | const kinds = Object.keys(slots); |
| 1569 | const wanted = header.lock === 'people' ? 'people' : header.lock; |
| 1570 | if (kinds.length !== 1 || kinds[0] !== wanted) { |
| 1571 | errors.push(`the slots do not match the ${header.lock} lock`); |
| 1572 | return errors; |
| 1573 | } |
| 1574 | if (wanted === 'people') { |
| 1575 | if (!Array.isArray(slots.people) || !slots.people.length || slots.people.length > 100) { |
| 1576 | errors.push('the people slots are not a list of one to a hundred'); |
| 1577 | } else if (!slots.people.every(peopleSlotOk)) { |
| 1578 | errors.push('a people slot is not the shape it must be'); |
| 1579 | } |
| 1580 | } else if (wanted === 'password') { |
| 1581 | if (!passwordSlotOk(slots.password)) errors.push('the password slot is not the shape it must be'); |
| 1582 | } else if (Object.keys(slots.link).length) { |
| 1583 | errors.push('a link slot stores nothing, and this one does'); |
| 1584 | } |
| 1585 | return errors; |
| 1586 | } |
| 1587 | |
| 1588 | /** |
| 1589 | * Reads an envelope's header and checks its shape and its size. Nothing is decrypted and no key |
| 1590 | * is needed: this is what a service runs on bytes it may never be able to read. |
| 1591 | */ |
| 1592 | function inspectBytes(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}, totalBytes = bytes && bytes.length) { |
| 1593 | const errors = []; |
| 1594 | const data = bytes instanceof Uint8Array ? bytes : null; |
| 1595 | if (!data) return { valid: false, errors: ['the envelope is not bytes'], header: null }; |
| 1596 | if (data.length < 8) return { valid: false, errors: ['the envelope is too short to hold a header'], header: null }; |
| 1597 | if (fromUtf8(data.subarray(0, 4)) !== MAGIC) { |
| 1598 | return { valid: false, errors: ['these bytes are not a Locker envelope'], header: null }; |
| 1599 | } |
| 1600 | const headerLength = readU32be(data, 4); |
| 1601 | if (headerLength === 0 || headerLength > maxHeaderBytes) { |
| 1602 | return { valid: false, errors: [`the envelope header is ${headerLength} bytes, which is not one we write`], header: null }; |
| 1603 | } |
| 1604 | if (data.length < 8 + headerLength) { |
| 1605 | return { valid: false, errors: ['the envelope ends inside its header'], header: null }; |
| 1606 | } |
| 1607 | let header = null; |
| 1608 | try { header = JSON.parse(fromUtf8(data.subarray(8, 8 + headerLength))); } |
| 1609 | catch { return { valid: false, errors: ['the envelope header is not JSON'], header: null }; } |
| 1610 | if (!header || typeof header !== 'object' || Array.isArray(header)) return { valid: false, errors: ['the header is not an object'], header: null }; |
| 1611 | try { errors.push(...checkHeader(header)); } catch { errors.push('the slots are not an object'); } |
| 1612 | const chunkOffset = 8 + headerLength; |
| 1613 | let expectedBytes = null; |
| 1614 | if (Number.isInteger(header.padded_size) && header.padded_size >= MIN_PADDED_SIZE |
| 1615 | && header.chunk_size === CHUNK_SIZE) { |
| 1616 | const chunks = chunkCount(header.padded_size); |
| 1617 | // Every chunk: its length, its flags, its nonce, the ciphertext and the tag. Every chunk |
| 1618 | // but the last is a whole chunk; the last holds what is left of the padded size. |
| 1619 | const frame = 4 + 1 + NONCE_SIZE; |
| 1620 | const whole = (chunks - 1) * (frame + CHUNK_SIZE + TAG_SIZE); |
| 1621 | const last = frame + lastChunkSize(header.padded_size) + TAG_SIZE; |
| 1622 | expectedBytes = chunkOffset + whole + last; |
| 1623 | if (totalBytes < expectedBytes) errors.push('the envelope is shorter than its own header says'); |
| 1624 | } |
| 1625 | return { |
| 1626 | valid: errors.length === 0, |
| 1627 | errors, |
| 1628 | header, |
| 1629 | headerLength, |
| 1630 | chunkOffset, |
| 1631 | lock: header.lock, |
| 1632 | paddedSize: header.padded_size, |
| 1633 | chunkCount: Number.isInteger(header.padded_size) ? chunkCount(header.padded_size) : null, |
| 1634 | expectedBytes, |
| 1635 | }; |
| 1636 | } |
| 1637 | |
| 1638 | function inspectEnvelope(bytes, options) { return inspectBytes(bytes, options); } |
| 1639 | |
| 1640 | /** A streamed upload: inspect just its bounded header against the ACTUAL stored byte count. |
| 1641 | * The caller must have counted and bounded the complete ciphertext first, not trust Content-Length. |
| 1642 | * No chunks are read or authenticated here; as with inspectEnvelope, only a client can do that. |
| 1643 | */ |
| 1644 | function inspectEnvelopePrefix(prefix, totalBytes, options) { |
| 1645 | const info = inspectBytes(prefix, options, totalBytes); |
| 1646 | if (!Number.isSafeInteger(totalBytes) || !Number.isSafeInteger(info.expectedBytes) || totalBytes !== info.expectedBytes) { |
| 1647 | info.errors.push('the stored byte count does not match the envelope'); |
| 1648 | } |
| 1649 | if (info.header && !Number.isSafeInteger(info.header.padded_size)) info.errors.push('the padded size is not a safe integer'); |
| 1650 | info.valid = info.errors.length === 0; |
| 1651 | return info; |
| 1652 | } |
| 1653 | __ovLockerModule.exports = { inspectEnvelope, inspectEnvelopePrefix, checkHeader, peopleSlotOk }; |
| 1654 | |
| 1655 | }, |
| 1656 | "words": function (__ovLockerModule, exports, __ovLockerReq) { |
| 1657 | ; |
| 1658 | /** |
| 1659 | * The 256 words of a fingerprint. A fingerprint is the first eight bytes of SHA-256 over a |
| 1660 | * person's two public keys, read as eight words in two groups of four: eight bits each, so the |
| 1661 | * words say which of the 256 they are. It is short enough to read out loud and compare once, |
| 1662 | * which is the whole point: a person checks that the eight words on the screen are the eight |
| 1663 | * words the other person sees. |
| 1664 | * |
| 1665 | * The order is part of the format: word number n is the nth word here. Changing the list would |
| 1666 | * change every fingerprint, so it is frozen with the version. |
| 1667 | */ |
| 1668 | const WORDS = [ |
| 1669 | 'amber', 'anchor', 'apple', 'arrow', 'aspen', 'autumn', 'badge', 'bamboo', |
| 1670 | 'beacon', 'berry', 'birch', 'bison', 'blossom', 'breeze', 'brick', 'brook', |
| 1671 | 'bucket', 'bugle', 'cabin', 'cactus', 'candle', 'canoe', 'canyon', 'cedar', |
| 1672 | 'cellar', 'chalk', 'cherry', 'cider', 'cinder', 'citrus', 'clay', 'clover', |
| 1673 | 'cobalt', 'cocoa', 'comet', 'coral', 'cosmos', 'cotton', 'cove', 'crane', |
| 1674 | 'crater', 'crayon', 'cricket', 'crimson', 'crystal', 'cymbal', 'daisy', 'dapple', |
| 1675 | 'dawn', 'delta', 'desert', 'diamond', 'dolphin', 'domino', 'donkey', 'dragon', |
| 1676 | 'drift', 'drizzle', 'drum', 'dune', 'dusk', 'eagle', 'ember', 'emerald', |
| 1677 | 'estuary', 'fable', 'falcon', 'feather', 'fern', 'ferry', 'field', 'flame', |
| 1678 | 'flint', 'forest', 'fossil', 'fox', 'frost', 'galaxy', 'garden', 'garnet', |
| 1679 | 'gazelle', 'geyser', 'ginger', 'glacier', 'granite', 'grape', 'gravel', 'grove', |
| 1680 | 'guitar', 'haiku', 'harbor', 'hazel', 'heather', 'hollow', 'honey', 'horizon', |
| 1681 | 'ice', 'indigo', 'island', 'ivory', 'jasper', 'jungle', 'juniper', 'kayak', |
| 1682 | 'kernel', 'kestrel', 'lagoon', 'lantern', 'lattice', 'laurel', 'lemon', 'lichen', |
| 1683 | 'lilac', 'linen', 'lotus', 'lunar', 'magnet', 'mango', 'maple', 'marble', |
| 1684 | 'marsh', 'meadow', 'meteor', 'mint', 'mirror', 'mist', 'monsoon', 'moss', |
| 1685 | 'moth', 'mountain', 'nectar', 'needle', 'nest', 'nettle', 'night', 'oasis', |
| 1686 | 'ocean', 'olive', 'onyx', 'opal', 'orbit', 'orchard', 'orchid', 'osprey', |
| 1687 | 'otter', 'owl', 'oyster', 'palace', 'palm', 'panda', 'papaya', 'paper', |
| 1688 | 'peach', 'pearl', 'pebble', 'pecan', 'penguin', 'peony', 'pepper', 'petal', |
| 1689 | 'pine', 'plateau', 'plum', 'pollen', 'pond', 'poplar', 'poppy', 'prairie', |
| 1690 | 'prism', 'pumpkin', 'quail', 'quarry', 'quartz', 'rabbit', 'radish', 'rain', |
| 1691 | 'raven', 'reef', 'ridge', 'river', 'robin', 'rosemary', 'sage', 'salmon', |
| 1692 | 'sand', 'sapphire', 'satellite', 'scallop', 'shadow', 'shell', 'shore', 'shuttle', |
| 1693 | 'silk', 'silver', 'slate', 'snow', 'solar', 'sparrow', 'spruce', 'star', |
| 1694 | 'stone', 'storm', 'stream', 'sugar', 'summit', 'sunset', 'swan', 'thistle', |
| 1695 | 'thunder', 'tide', 'tiger', 'timber', 'topaz', 'trail', 'tulip', 'tundra', |
| 1696 | 'turtle', 'valley', 'vapor', 'velvet', 'vine', 'violet', 'walnut', 'wave', |
| 1697 | 'whale', 'wheat', 'willow', 'winter', 'wolf', 'wren', 'yacht', 'yarrow', |
| 1698 | 'yew', 'zinc', 'zephyr', 'almond', 'basil', 'cinnamon', 'cypress', 'damson', |
| 1699 | 'elder', 'fennel', 'garlic', 'hibiscus', 'iris', 'jasmine', 'linden', 'melon', |
| 1700 | 'nutmeg', 'sesame', 'sorrel', 'thyme', 'anise', 'chicory', 'dill', 'endive', |
| 1701 | ]; |
| 1702 | |
| 1703 | __ovLockerModule.exports = { WORDS }; |
| 1704 | |
| 1705 | }, |
| 1706 | }; |
| 1707 | const cache = {}; |
| 1708 | function req(name) { |
| 1709 | const key = String(name).replace(/^\.\//, ''); |
| 1710 | if (!defs[key]) throw new Error('ov-locker-core: no module ' + name); |
| 1711 | if (!cache[key]) { const m = { exports: {} }; cache[key] = m; defs[key](m, m.exports, req); } |
| 1712 | return cache[key].exports; |
| 1713 | } |
| 1714 | root.OVLockerCore = Object.assign({}, req('index'), { SDK_VERSION: "0.160.0" }); |
| 1715 | })(typeof globalThis !== 'undefined' ? globalThis : this); |
| 1716 |