@arkstack/encryption 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1213 @@
1
+ //#region src/support/codec.ts
2
+ const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
3
+ const HEX_PATTERN = /^[0-9a-f]*$/i;
4
+ /**
5
+ * Runtime agnostic binary/text conversion helpers.
6
+ *
7
+ * Everything here is implemented against `Uint8Array`, `TextEncoder` and
8
+ * `TextDecoder` so the exact same code path runs in Node, Deno, Bun, browsers
9
+ * and workers. No `Buffer`, no `node:crypto`.
10
+ */
11
+ var Codec = class {
12
+ static encoder = new TextEncoder();
13
+ static decoder = new TextDecoder();
14
+ /**
15
+ * Encode a UTF-8 string to bytes.
16
+ *
17
+ * @param value
18
+ * @returns
19
+ */
20
+ static encodeUtf8(value) {
21
+ return this.encoder.encode(value);
22
+ }
23
+ /**
24
+ * Decode bytes back to a UTF-8 string.
25
+ *
26
+ * @param bytes
27
+ * @returns
28
+ */
29
+ static decodeUtf8(bytes) {
30
+ return this.decoder.decode(bytes);
31
+ }
32
+ /**
33
+ * Encode bytes as standard (padded) base64.
34
+ *
35
+ * @param bytes
36
+ * @returns
37
+ */
38
+ static encodeBase64(bytes) {
39
+ let binary = "";
40
+ for (let index = 0; index < bytes.length; index += 1) binary += String.fromCharCode(bytes[index]);
41
+ if (typeof globalThis.btoa === "function") return globalThis.btoa(binary);
42
+ return this.fallbackEncodeBase64(bytes);
43
+ }
44
+ /**
45
+ * Decode standard (padded or unpadded) base64 to bytes.
46
+ *
47
+ * @param value
48
+ * @returns
49
+ */
50
+ static decodeBase64(value) {
51
+ const normalized = value.replace(/\s+/g, "");
52
+ if (typeof globalThis.atob === "function") {
53
+ const binary = globalThis.atob(this.pad(normalized));
54
+ const bytes = new Uint8Array(binary.length);
55
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
56
+ return bytes;
57
+ }
58
+ return this.fallbackDecodeBase64(this.pad(normalized));
59
+ }
60
+ /**
61
+ * Encode bytes as unpadded base64url, the wire format used by every
62
+ * Arkstack encryption payload.
63
+ *
64
+ * @param bytes
65
+ * @returns
66
+ */
67
+ static encodeBase64Url(bytes) {
68
+ return this.encodeBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
69
+ }
70
+ /**
71
+ * Decode a base64url string to bytes.
72
+ *
73
+ * @param value
74
+ * @returns
75
+ */
76
+ static decodeBase64Url(value) {
77
+ return this.decodeBase64(value.replace(/-/g, "+").replace(/_/g, "/"));
78
+ }
79
+ /**
80
+ * Encode bytes as lowercase hex.
81
+ *
82
+ * @param bytes
83
+ * @returns
84
+ */
85
+ static encodeHex(bytes) {
86
+ let hex = "";
87
+ for (let index = 0; index < bytes.length; index += 1) hex += bytes[index].toString(16).padStart(2, "0");
88
+ return hex;
89
+ }
90
+ /**
91
+ * Decode a hex string to bytes.
92
+ *
93
+ * @param value
94
+ * @returns
95
+ */
96
+ static decodeHex(value) {
97
+ if (value.length % 2 !== 0 || !HEX_PATTERN.test(value)) throw new TypeError("Invalid hex string");
98
+ const bytes = new Uint8Array(value.length / 2);
99
+ for (let index = 0; index < bytes.length; index += 1) bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
100
+ return bytes;
101
+ }
102
+ /**
103
+ * Concatenate byte sequences into a single buffer.
104
+ *
105
+ * @param parts
106
+ * @returns
107
+ */
108
+ static concat(...parts) {
109
+ const total = parts.reduce((size, part) => size + part.length, 0);
110
+ const output = new Uint8Array(total);
111
+ let offset = 0;
112
+ for (const part of parts) {
113
+ output.set(part, offset);
114
+ offset += part.length;
115
+ }
116
+ return output;
117
+ }
118
+ /**
119
+ * Compare two byte sequences without leaking their contents through timing.
120
+ *
121
+ * The length check is intentionally not constant time; key and digest
122
+ * lengths are public information.
123
+ *
124
+ * @param left
125
+ * @param right
126
+ * @returns
127
+ */
128
+ static equals(left, right) {
129
+ if (left.length !== right.length) return false;
130
+ let difference = 0;
131
+ for (let index = 0; index < left.length; index += 1) difference |= left[index] ^ right[index];
132
+ return difference === 0;
133
+ }
134
+ /**
135
+ * Normalize a `Uint8Array`, `ArrayBuffer` or `ArrayBufferView` to bytes.
136
+ *
137
+ * @param value
138
+ * @returns
139
+ */
140
+ static toBytes(value) {
141
+ if (value instanceof Uint8Array) return value;
142
+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
143
+ return new Uint8Array(value);
144
+ }
145
+ /**
146
+ * Restore base64 padding stripped by the base64url encoding.
147
+ *
148
+ * @param value
149
+ * @returns
150
+ */
151
+ static pad(value) {
152
+ const remainder = value.length % 4;
153
+ return remainder === 0 ? value : value + "=".repeat(4 - remainder);
154
+ }
155
+ /**
156
+ * Pure JS base64 encoder used when `btoa` is unavailable.
157
+ *
158
+ * @param bytes
159
+ * @returns
160
+ */
161
+ static fallbackEncodeBase64(bytes) {
162
+ let output = "";
163
+ for (let index = 0; index < bytes.length; index += 3) {
164
+ const chunk = bytes[index] << 16 | (bytes[index + 1] ?? 0) << 8 | (bytes[index + 2] ?? 0);
165
+ const available = bytes.length - index;
166
+ output += BASE64_ALPHABET[chunk >> 18 & 63];
167
+ output += BASE64_ALPHABET[chunk >> 12 & 63];
168
+ output += available > 1 ? BASE64_ALPHABET[chunk >> 6 & 63] : "=";
169
+ output += available > 2 ? BASE64_ALPHABET[chunk & 63] : "=";
170
+ }
171
+ return output;
172
+ }
173
+ /**
174
+ * Pure JS base64 decoder used when `atob` is unavailable.
175
+ *
176
+ * @param value
177
+ * @returns
178
+ */
179
+ static fallbackDecodeBase64(value) {
180
+ const clean = value.replace(/=+$/, "");
181
+ const bytes = new Uint8Array(clean.length * 3 >> 2);
182
+ let buffer = 0;
183
+ let bits = 0;
184
+ let offset = 0;
185
+ for (const character of clean) {
186
+ const index = BASE64_ALPHABET.indexOf(character);
187
+ if (index < 0) throw new TypeError("Invalid base64 string");
188
+ buffer = buffer << 6 | index;
189
+ bits += 6;
190
+ if (bits >= 8) {
191
+ bits -= 8;
192
+ bytes[offset++] = buffer >> bits & 255;
193
+ }
194
+ }
195
+ return bytes;
196
+ }
197
+ };
198
+ //#endregion
199
+ //#region src/support/subtle.ts
200
+ /**
201
+ * Resolve the ambient Web Crypto implementation.
202
+ *
203
+ * Node exposes it as `globalThis.crypto` from v19 (and behind
204
+ * `node:crypto`'s `webcrypto` export from v15), browsers and workers expose it
205
+ * on `window`/`self`. Secure contexts are required in browsers, hence the
206
+ * explicit error message.
207
+ *
208
+ * @returns
209
+ */
210
+ const webCrypto = () => {
211
+ const candidate = globalThis.crypto;
212
+ if (!candidate?.subtle) throw new Error("The Web Crypto API is unavailable. @arkstack/encryption requires Node 19+ (or Node 18 with `globalThis.crypto` enabled) and a secure context (https or localhost) in browsers.");
213
+ return candidate;
214
+ };
215
+ /**
216
+ * Resolve `crypto.subtle`.
217
+ *
218
+ * @returns
219
+ */
220
+ const subtle = () => webCrypto().subtle;
221
+ /**
222
+ * Fill a buffer with cryptographically secure random bytes.
223
+ *
224
+ * @param length
225
+ * @returns
226
+ */
227
+ const randomBytes = (length) => {
228
+ if (!Number.isInteger(length) || length < 1) throw new RangeError("Random byte length must be a positive integer");
229
+ return webCrypto().getRandomValues(new Uint8Array(length));
230
+ };
231
+ /**
232
+ * SHA digest helper returning bytes instead of an `ArrayBuffer`.
233
+ *
234
+ * @param data
235
+ * @param algorithm
236
+ * @returns
237
+ */
238
+ const digest = async (data, algorithm = "SHA-256") => {
239
+ return new Uint8Array(await subtle().digest(algorithm, data));
240
+ };
241
+ //#endregion
242
+ //#region src/EncryptionKey.ts
243
+ const DEFAULT_ITERATIONS = 21e4;
244
+ const DEFAULT_LENGTH = 32;
245
+ /**
246
+ * A symmetric key, held as raw bytes and convertible to every representation
247
+ * the rest of the library (and the wire) needs.
248
+ *
249
+ * Keys are values: two keys with the same bytes are equal regardless of how
250
+ * they were produced, and comparison is constant time.
251
+ */
252
+ var EncryptionKey = class EncryptionKey {
253
+ bytes;
254
+ /**
255
+ * @param bytes Raw key material.
256
+ */
257
+ constructor(bytes) {
258
+ this.bytes = bytes;
259
+ if (bytes.length === 0) throw new RangeError("An encryption key cannot be empty");
260
+ }
261
+ /**
262
+ * Generate a random key.
263
+ *
264
+ * @param length Key length in bytes, defaults to 32 (AES-256).
265
+ * @returns
266
+ */
267
+ static generate(length = DEFAULT_LENGTH) {
268
+ return new EncryptionKey(randomBytes(length));
269
+ }
270
+ /**
271
+ * Derive a key from an arbitrary secret by hashing it with SHA-256.
272
+ *
273
+ * This mirrors how Arkstack turns `APP_KEY` into a cipher key, so a value
274
+ * encrypted on the server with the app key can be decrypted in the browser
275
+ * from the same secret.
276
+ *
277
+ * @param secret
278
+ * @returns
279
+ */
280
+ static async fromSecret(secret) {
281
+ return new EncryptionKey(await digest(Codec.encodeUtf8(secret)));
282
+ }
283
+ /**
284
+ * Restore a key from its base64url representation.
285
+ *
286
+ * @param value
287
+ * @returns
288
+ */
289
+ static fromBase64Url(value) {
290
+ return new EncryptionKey(Codec.decodeBase64Url(value));
291
+ }
292
+ /**
293
+ * Restore a key from its hex representation.
294
+ *
295
+ * @param value
296
+ * @returns
297
+ */
298
+ static fromHex(value) {
299
+ return new EncryptionKey(Codec.decodeHex(value));
300
+ }
301
+ /**
302
+ * Stretch a password into a key using PBKDF2-HMAC-SHA256.
303
+ *
304
+ * Prefer this over {@link fromSecret} for anything a human typed; the
305
+ * returned salt and iteration count must be stored alongside the
306
+ * ciphertext to reproduce the key later.
307
+ *
308
+ * @param password
309
+ * @param options
310
+ * @returns
311
+ */
312
+ static async derive(password, options = {}) {
313
+ const iterations = options.iterations ?? DEFAULT_ITERATIONS;
314
+ const length = options.length ?? DEFAULT_LENGTH;
315
+ const salt = typeof options.salt === "string" ? Codec.decodeBase64Url(options.salt) : options.salt ?? randomBytes(16);
316
+ const material = await subtle().importKey("raw", Codec.encodeUtf8(password), "PBKDF2", false, ["deriveBits"]);
317
+ const bits = await subtle().deriveBits({
318
+ name: "PBKDF2",
319
+ salt,
320
+ iterations,
321
+ hash: "SHA-256"
322
+ }, material, length * 8);
323
+ return {
324
+ key: new EncryptionKey(new Uint8Array(bits)),
325
+ salt: Codec.encodeBase64Url(salt),
326
+ iterations
327
+ };
328
+ }
329
+ /**
330
+ * Expand shared secret material into a key using HKDF-SHA256.
331
+ *
332
+ * Used internally by the ECDH channel and sealed box helpers, and exposed
333
+ * because deriving sub-keys from one root key is a common need.
334
+ *
335
+ * @param material
336
+ * @param salt
337
+ * @param info
338
+ * @param length
339
+ * @returns
340
+ */
341
+ static async expand(material, salt, info, length = DEFAULT_LENGTH) {
342
+ const base = await subtle().importKey("raw", material, "HKDF", false, ["deriveBits"]);
343
+ const bits = await subtle().deriveBits({
344
+ name: "HKDF",
345
+ hash: "SHA-256",
346
+ salt,
347
+ info: Codec.encodeUtf8(info)
348
+ }, base, length * 8);
349
+ return new EncryptionKey(new Uint8Array(bits));
350
+ }
351
+ /**
352
+ * Coerce any accepted key representation into an `EncryptionKey`.
353
+ *
354
+ * A string of exactly `length` bytes once base64url decoded is treated as
355
+ * raw key material; anything else is treated as a passphrase and hashed.
356
+ *
357
+ * @param input
358
+ * @param length Expected key length in bytes.
359
+ * @returns
360
+ */
361
+ static async resolve(input, length = DEFAULT_LENGTH) {
362
+ if (input instanceof EncryptionKey) return input;
363
+ if (input instanceof Uint8Array) return new EncryptionKey(input);
364
+ if (typeof input === "string") {
365
+ if (/^[A-Za-z0-9_-]+$/.test(input)) try {
366
+ const decoded = Codec.decodeBase64Url(input);
367
+ if (decoded.length === length) return new EncryptionKey(decoded);
368
+ } catch {}
369
+ return await this.fromSecret(input);
370
+ }
371
+ const exported = await subtle().exportKey("raw", input);
372
+ return new EncryptionKey(new Uint8Array(exported));
373
+ }
374
+ /**
375
+ * Constant time comparison of two keys, in any representation that does not
376
+ * require asynchronous work.
377
+ *
378
+ * @param left
379
+ * @param right
380
+ * @returns
381
+ */
382
+ static compare(left, right) {
383
+ return Codec.equals(this.materialize(left), this.materialize(right));
384
+ }
385
+ /**
386
+ * Import this key into Web Crypto for the given algorithm.
387
+ *
388
+ * @param algorithm
389
+ * @param usages
390
+ * @returns
391
+ */
392
+ async cryptoKey(algorithm = { name: "AES-GCM" }, usages = ["encrypt", "decrypt"]) {
393
+ return await subtle().importKey("raw", this.bytes, algorithm, false, usages);
394
+ }
395
+ /**
396
+ * A stable, shareable digest of this key. Safe to log or display; it does
397
+ * not reveal the key itself.
398
+ *
399
+ * @param options
400
+ * @returns
401
+ */
402
+ async fingerprint(options = {}) {
403
+ const bytes = (await digest(this.bytes)).slice(0, options.length ?? 32);
404
+ const rendered = options.encoding === "base64url" ? Codec.encodeBase64Url(bytes) : Codec.encodeHex(bytes);
405
+ if (!options.group) return rendered;
406
+ return rendered.match(new RegExp(`.{1,${options.group}}`, "g"))?.join(" ") ?? rendered;
407
+ }
408
+ /**
409
+ * Constant time comparison against another key.
410
+ *
411
+ * @param other
412
+ * @returns
413
+ */
414
+ equals(other) {
415
+ return EncryptionKey.compare(this, other);
416
+ }
417
+ /**
418
+ * Key length in bytes.
419
+ *
420
+ * @returns
421
+ */
422
+ get length() {
423
+ return this.bytes.length;
424
+ }
425
+ /**
426
+ * Base64url representation, the format used to persist and transport keys.
427
+ *
428
+ * @returns
429
+ */
430
+ toBase64Url() {
431
+ return Codec.encodeBase64Url(this.bytes);
432
+ }
433
+ /**
434
+ * Hex representation.
435
+ *
436
+ * @returns
437
+ */
438
+ toHex() {
439
+ return Codec.encodeHex(this.bytes);
440
+ }
441
+ /**
442
+ * Base64url representation.
443
+ *
444
+ * @returns
445
+ */
446
+ toString() {
447
+ return this.toBase64Url();
448
+ }
449
+ /**
450
+ * Keep keys out of accidental `JSON.stringify` output of surrounding
451
+ * objects by requiring an explicit `toBase64Url()` call.
452
+ *
453
+ * @returns
454
+ */
455
+ toJSON() {
456
+ return "[EncryptionKey]";
457
+ }
458
+ /**
459
+ * Reduce a comparable key representation to bytes.
460
+ *
461
+ * @param value
462
+ * @returns
463
+ */
464
+ static materialize(value) {
465
+ if (value instanceof EncryptionKey) return value.bytes;
466
+ if (value instanceof Uint8Array) return value;
467
+ return Codec.decodeBase64Url(value);
468
+ }
469
+ };
470
+ //#endregion
471
+ //#region src/Cipher.ts
472
+ const IV_LENGTH = 12;
473
+ const TAG_LENGTH = 16;
474
+ const PAYLOAD_PATTERN = /^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+:[A-Za-z0-9_-]*$/;
475
+ /**
476
+ * AES-256-GCM symmetric encryption built on the Web Crypto API.
477
+ *
478
+ * Payloads are colon delimited base64url triples — `<iv>:<authTag>:<ciphertext>`
479
+ * — which is byte for byte the format Arkstack has always written. A value
480
+ * encrypted by a Node server decrypts in the browser and vice versa, provided
481
+ * both sides hold the same key.
482
+ */
483
+ var Cipher = class Cipher {
484
+ key;
485
+ /** Initialisation vector length in bytes. */
486
+ static ivLength = IV_LENGTH;
487
+ /** GCM authentication tag length in bytes. */
488
+ static tagLength = TAG_LENGTH;
489
+ /**
490
+ * @param key The symmetric key this cipher operates with.
491
+ */
492
+ constructor(key) {
493
+ this.key = key;
494
+ }
495
+ /**
496
+ * Build a cipher from any accepted key representation.
497
+ *
498
+ * @param key
499
+ * @returns
500
+ */
501
+ static async from(key) {
502
+ return new Cipher(await EncryptionKey.resolve(key));
503
+ }
504
+ /**
505
+ * Build a cipher backed by a freshly generated random key.
506
+ *
507
+ * @returns
508
+ */
509
+ static create() {
510
+ return new Cipher(EncryptionKey.generate());
511
+ }
512
+ /**
513
+ * Encrypt a string.
514
+ *
515
+ * @param value
516
+ * @param key
517
+ * @param options
518
+ * @returns
519
+ */
520
+ static async encrypt(value, key, options = {}) {
521
+ return await (await this.from(key)).encrypt(value, options);
522
+ }
523
+ /**
524
+ * Decrypt a payload produced by {@link encrypt}.
525
+ *
526
+ * @param payload
527
+ * @param key
528
+ * @param options
529
+ * @returns
530
+ */
531
+ static async decrypt(payload, key, options = {}) {
532
+ return await (await this.from(key)).decrypt(payload, options);
533
+ }
534
+ /**
535
+ * Whether a string is shaped like a cipher payload. A cheap structural
536
+ * check, not an authenticity check.
537
+ *
538
+ * @param value
539
+ * @returns
540
+ */
541
+ static looksLikePayload(value) {
542
+ return typeof value === "string" && PAYLOAD_PATTERN.test(value);
543
+ }
544
+ /**
545
+ * Encrypt a UTF-8 string.
546
+ *
547
+ * @param value
548
+ * @param options
549
+ * @returns
550
+ */
551
+ async encrypt(value, options = {}) {
552
+ return await this.encryptBytes(Codec.encodeUtf8(value), options);
553
+ }
554
+ /**
555
+ * Decrypt a payload back into a UTF-8 string.
556
+ *
557
+ * @param payload
558
+ * @param options
559
+ * @returns
560
+ */
561
+ async decrypt(payload, options = {}) {
562
+ return Codec.decodeUtf8(await this.decryptBytes(payload, options));
563
+ }
564
+ /**
565
+ * Encrypt arbitrary bytes.
566
+ *
567
+ * @param bytes
568
+ * @param options
569
+ * @returns
570
+ */
571
+ async encryptBytes(bytes, options = {}) {
572
+ const iv = randomBytes(IV_LENGTH);
573
+ const sealed = new Uint8Array(await subtle().encrypt(this.parameters(iv, options), await this.cryptoKey(), bytes));
574
+ const boundary = sealed.length - TAG_LENGTH;
575
+ return [
576
+ iv,
577
+ sealed.slice(boundary),
578
+ sealed.slice(0, boundary)
579
+ ].map((part) => Codec.encodeBase64Url(part)).join(":");
580
+ }
581
+ /**
582
+ * Decrypt a payload back into raw bytes.
583
+ *
584
+ * @param payload
585
+ * @param options
586
+ * @returns
587
+ */
588
+ async decryptBytes(payload, options = {}) {
589
+ const [iv, authTag, ciphertext] = payload.split(":");
590
+ if (!iv || !authTag || ciphertext === void 0) throw new Error("Invalid encrypted payload format");
591
+ const sealed = Codec.concat(Codec.decodeBase64Url(ciphertext), Codec.decodeBase64Url(authTag));
592
+ try {
593
+ const plaintext = await subtle().decrypt(this.parameters(Codec.decodeBase64Url(iv), options), await this.cryptoKey(), sealed);
594
+ return new Uint8Array(plaintext);
595
+ } catch {
596
+ throw new Error("Unable to decrypt payload: the key is wrong or the ciphertext was tampered with");
597
+ }
598
+ }
599
+ /**
600
+ * Import the key once per cipher instance.
601
+ *
602
+ * @returns
603
+ */
604
+ async cryptoKey() {
605
+ this.imported ??= this.key.cryptoKey({ name: "AES-GCM" }, ["encrypt", "decrypt"]);
606
+ return await this.imported;
607
+ }
608
+ /**
609
+ * Build the AES-GCM parameters for a single operation.
610
+ *
611
+ * @param iv
612
+ * @param options
613
+ * @returns
614
+ */
615
+ parameters(iv, options) {
616
+ const aad = typeof options.aad === "string" ? Codec.encodeUtf8(options.aad) : options.aad;
617
+ return {
618
+ name: "AES-GCM",
619
+ iv,
620
+ tagLength: 128,
621
+ ...aad ? { additionalData: aad } : {}
622
+ };
623
+ }
624
+ imported;
625
+ };
626
+ //#endregion
627
+ //#region src/KeyPair.ts
628
+ const ALGORITHM = {
629
+ name: "ECDH",
630
+ namedCurve: "P-256"
631
+ };
632
+ /**
633
+ * An ECDH P-256 key pair — the identity half of end-to-end encryption.
634
+ *
635
+ * P-256 is the curve every mainstream Web Crypto implementation supports, so a
636
+ * key pair generated in Node imports cleanly in the browser and vice versa.
637
+ * Keys serialise to base64url DER (SPKI for public, PKCS#8 for private), which
638
+ * survives JSON, headers, query strings and database columns unchanged.
639
+ */
640
+ var KeyPair = class KeyPair {
641
+ publicKey;
642
+ privateKey;
643
+ /**
644
+ * @param publicKey
645
+ * @param privateKey Absent for peer key pairs, where only the public half is known.
646
+ */
647
+ constructor(publicKey, privateKey) {
648
+ this.publicKey = publicKey;
649
+ this.privateKey = privateKey;
650
+ }
651
+ /**
652
+ * Generate a new key pair.
653
+ *
654
+ * @returns
655
+ */
656
+ static async generate() {
657
+ const pair = await subtle().generateKey(ALGORITHM, true, ["deriveBits"]);
658
+ return new KeyPair(pair.publicKey, pair.privateKey);
659
+ }
660
+ /**
661
+ * Restore a key pair from its serialised form.
662
+ *
663
+ * @param serialized
664
+ * @returns
665
+ */
666
+ static async import(serialized) {
667
+ return new KeyPair(await this.importPublicKey(serialized.publicKey), await this.importPrivateKey(serialized.privateKey));
668
+ }
669
+ /**
670
+ * Restore a full key pair from the private half alone; the public key is
671
+ * recovered from the private key's curve point.
672
+ *
673
+ * @param privateKey
674
+ * @returns
675
+ */
676
+ static async fromPrivateKey(privateKey) {
677
+ const imported = typeof privateKey === "string" ? await this.importPrivateKey(privateKey) : privateKey;
678
+ const jwk = await subtle().exportKey("jwk", imported);
679
+ delete jwk.d;
680
+ jwk.key_ops = [];
681
+ const publicKey = await subtle().importKey("jwk", jwk, ALGORITHM, true, []);
682
+ return new KeyPair(publicKey, imported);
683
+ }
684
+ /**
685
+ * Wrap a peer's public key. The result can verify fingerprints and receive
686
+ * sealed messages, but cannot derive shared secrets on its own.
687
+ *
688
+ * @param publicKey
689
+ * @returns
690
+ */
691
+ static async fromPublicKey(publicKey) {
692
+ return new KeyPair(typeof publicKey === "string" ? await this.importPublicKey(publicKey) : publicKey);
693
+ }
694
+ /**
695
+ * Import a base64url SPKI public key.
696
+ *
697
+ * @param publicKey
698
+ * @returns
699
+ */
700
+ static async importPublicKey(publicKey) {
701
+ return await subtle().importKey("spki", Codec.decodeBase64Url(publicKey), ALGORITHM, true, []);
702
+ }
703
+ /**
704
+ * Import a base64url PKCS#8 private key.
705
+ *
706
+ * @param privateKey
707
+ * @returns
708
+ */
709
+ static async importPrivateKey(privateKey) {
710
+ return await subtle().importKey("pkcs8", Codec.decodeBase64Url(privateKey), ALGORITHM, true, ["deriveBits"]);
711
+ }
712
+ /**
713
+ * Export a public key to its base64url SPKI form.
714
+ *
715
+ * @param publicKey
716
+ * @returns
717
+ */
718
+ static async exportPublicKey(publicKey) {
719
+ return Codec.encodeBase64Url(new Uint8Array(await subtle().exportKey("spki", publicKey)));
720
+ }
721
+ /**
722
+ * Derive raw ECDH shared bits between a private key and a peer public key.
723
+ *
724
+ * The result is the raw curve point and must be stretched with a KDF before
725
+ * use as a cipher key — {@link SecureChannel} does that for you.
726
+ *
727
+ * @param privateKey
728
+ * @param peerPublicKey
729
+ * @param length Output length in bits, defaults to the P-256 field size.
730
+ * @returns
731
+ */
732
+ static async sharedBits(privateKey, peerPublicKey, length = 256) {
733
+ const bits = await subtle().deriveBits({
734
+ name: "ECDH",
735
+ public: peerPublicKey
736
+ }, privateKey, length);
737
+ return new Uint8Array(bits);
738
+ }
739
+ /**
740
+ * A human comparable digest of a public key. Two peers reading the same
741
+ * fingerprint aloud are holding the same key.
742
+ *
743
+ * @param publicKey
744
+ * @param options
745
+ * @returns
746
+ */
747
+ static async fingerprintOf(publicKey, options = {}) {
748
+ const exported = typeof publicKey === "string" ? publicKey : await this.exportPublicKey(publicKey);
749
+ return await new EncryptionKey(Codec.decodeBase64Url(exported)).fingerprint({
750
+ group: 8,
751
+ ...options
752
+ });
753
+ }
754
+ /**
755
+ * The digest of both participants' public keys, ordered deterministically
756
+ * so each side computes the same value. Rendered as five digit groups in
757
+ * the style of a messaging app's safety number.
758
+ *
759
+ * @param first
760
+ * @param second
761
+ * @param groups How many five digit groups to render, defaults to 12.
762
+ * @returns
763
+ */
764
+ static async safetyNumber(first, second, groups = 12) {
765
+ const bytes = await digest(Codec.encodeUtf8(this.order(first, second).join("|")));
766
+ const blocks = [];
767
+ for (let index = 0; index < groups; index += 1) {
768
+ const offset = index * 3 % (bytes.length - 3);
769
+ const chunk = bytes[offset] << 16 | bytes[offset + 1] << 8 | bytes[offset + 2];
770
+ blocks.push(String(chunk % 1e5).padStart(5, "0"));
771
+ }
772
+ return blocks.join(" ");
773
+ }
774
+ /**
775
+ * Order two public keys deterministically so both peers derive identical
776
+ * salts and safety numbers regardless of who initiated.
777
+ *
778
+ * @param first
779
+ * @param second
780
+ * @returns
781
+ */
782
+ static order(first, second) {
783
+ return first <= second ? [first, second] : [second, first];
784
+ }
785
+ /**
786
+ * Whether the private half is available.
787
+ *
788
+ * @returns
789
+ */
790
+ get isComplete() {
791
+ return this.privateKey !== void 0;
792
+ }
793
+ /**
794
+ * Serialise both halves. Throws when the private key is missing.
795
+ *
796
+ * @returns
797
+ */
798
+ async export() {
799
+ if (!this.privateKey) throw new Error("Cannot export a key pair without its private key");
800
+ return {
801
+ publicKey: await this.exportPublicKey(),
802
+ privateKey: Codec.encodeBase64Url(new Uint8Array(await subtle().exportKey("pkcs8", this.privateKey)))
803
+ };
804
+ }
805
+ /**
806
+ * The base64url SPKI public key, safe to publish.
807
+ *
808
+ * @returns
809
+ */
810
+ async exportPublicKey() {
811
+ return await KeyPair.exportPublicKey(this.publicKey);
812
+ }
813
+ /**
814
+ * Derive the raw ECDH shared bits with a peer.
815
+ *
816
+ * @param peerPublicKey
817
+ * @returns
818
+ */
819
+ async sharedBits(peerPublicKey) {
820
+ if (!this.privateKey) throw new Error("Cannot derive a shared secret without a private key");
821
+ return await KeyPair.sharedBits(this.privateKey, await KeyPair.resolvePublic(peerPublicKey));
822
+ }
823
+ /**
824
+ * A comparable digest of this key pair's public key.
825
+ *
826
+ * @param options
827
+ * @returns
828
+ */
829
+ async fingerprint(options = {}) {
830
+ return await KeyPair.fingerprintOf(this.publicKey, options);
831
+ }
832
+ /**
833
+ * Normalise anything that can stand in for a public key.
834
+ *
835
+ * @param value
836
+ * @returns
837
+ */
838
+ static async resolvePublic(value) {
839
+ if (value instanceof KeyPair) return value.publicKey;
840
+ return typeof value === "string" ? await this.importPublicKey(value) : value;
841
+ }
842
+ };
843
+ //#endregion
844
+ //#region src/Keys.ts
845
+ /**
846
+ * Key generation and comparison helpers.
847
+ *
848
+ * Generating keys is easy to get wrong quietly and comparing them is easy to
849
+ * get wrong dangerously, so both live here: every comparison in this class runs
850
+ * in constant time, and every generator draws from the platform CSPRNG.
851
+ */
852
+ var Keys = class {
853
+ /**
854
+ * Generate a random symmetric key.
855
+ *
856
+ * @param length Key length in bytes, defaults to 32 (AES-256).
857
+ * @returns
858
+ */
859
+ static generate(length = 32) {
860
+ return EncryptionKey.generate(length);
861
+ }
862
+ /**
863
+ * Generate a random symmetric key as a base64url string, ready to store in
864
+ * an environment variable or a database column.
865
+ *
866
+ * @param length
867
+ * @returns
868
+ */
869
+ static generateString(length = 32) {
870
+ return this.generate(length).toBase64Url();
871
+ }
872
+ /**
873
+ * Generate a random, URL safe token. Not a key — use it for invites,
874
+ * one-time links and other opaque identifiers.
875
+ *
876
+ * @param bytes
877
+ * @returns
878
+ */
879
+ static token(bytes = 32) {
880
+ return Codec.encodeBase64Url(randomBytes(bytes));
881
+ }
882
+ /**
883
+ * Generate an end-to-end encryption identity: an ECDH key pair whose public
884
+ * half is published and whose private half never leaves its owner.
885
+ *
886
+ * @returns
887
+ */
888
+ static async generatePair() {
889
+ return await KeyPair.generate();
890
+ }
891
+ /**
892
+ * Generate an identity and return it already serialised for storage or
893
+ * transport.
894
+ *
895
+ * @returns
896
+ */
897
+ static async generateSerializedPair() {
898
+ return await (await KeyPair.generate()).export();
899
+ }
900
+ /**
901
+ * Hash an arbitrary secret into a key with SHA-256, the same way Arkstack
902
+ * turns `APP_KEY` into a cipher key.
903
+ *
904
+ * @param secret
905
+ * @returns
906
+ */
907
+ static async fromSecret(secret) {
908
+ return await EncryptionKey.fromSecret(secret);
909
+ }
910
+ /**
911
+ * Stretch a user supplied password into a key with PBKDF2-HMAC-SHA256.
912
+ *
913
+ * @param password
914
+ * @param options
915
+ * @returns
916
+ */
917
+ static async derive(password, options = {}) {
918
+ return await EncryptionKey.derive(password, options);
919
+ }
920
+ /**
921
+ * Constant time comparison of two keys already in key form.
922
+ *
923
+ * @param left
924
+ * @param right
925
+ * @returns
926
+ */
927
+ static compare(left, right) {
928
+ try {
929
+ return EncryptionKey.compare(left, right);
930
+ } catch {
931
+ return false;
932
+ }
933
+ }
934
+ /**
935
+ * Constant time comparison that first resolves both sides through the same
936
+ * rules the ciphers use, so a passphrase can be checked against the key it
937
+ * produces.
938
+ *
939
+ * @param left
940
+ * @param right
941
+ * @param length Expected key length in bytes.
942
+ * @returns
943
+ */
944
+ static async matches(left, right, length = 32) {
945
+ try {
946
+ return EncryptionKey.compare(await EncryptionKey.resolve(left, length), await EncryptionKey.resolve(right, length));
947
+ } catch {
948
+ return false;
949
+ }
950
+ }
951
+ /**
952
+ * A displayable digest of a symmetric key.
953
+ *
954
+ * @param key
955
+ * @param options
956
+ * @returns
957
+ */
958
+ static async fingerprint(key, options = {}) {
959
+ return await (await EncryptionKey.resolve(key)).fingerprint({
960
+ length: 16,
961
+ group: 8,
962
+ ...options
963
+ });
964
+ }
965
+ /**
966
+ * A displayable digest of a public key, for comparing identities.
967
+ *
968
+ * @param publicKey
969
+ * @param options
970
+ * @returns
971
+ */
972
+ static async fingerprintPublicKey(publicKey, options = {}) {
973
+ return await KeyPair.fingerprintOf(publicKey, {
974
+ length: 16,
975
+ group: 8,
976
+ ...options
977
+ });
978
+ }
979
+ /**
980
+ * The safety number for a conversation between two public keys. Both peers
981
+ * compute the same string; showing it side by side proves no third party
982
+ * substituted a key in transit.
983
+ *
984
+ * @param first
985
+ * @param second
986
+ * @param groups
987
+ * @returns
988
+ */
989
+ static async safetyNumber(first, second, groups = 12) {
990
+ return await KeyPair.safetyNumber(first, second, groups);
991
+ }
992
+ /**
993
+ * Confirm a safety number a user read out or scanned, in constant time.
994
+ *
995
+ * @param first
996
+ * @param second
997
+ * @param expected
998
+ * @returns
999
+ */
1000
+ static async confirmSafetyNumber(first, second, expected) {
1001
+ const normalize = (value) => Codec.encodeUtf8(value.replace(/\s+/g, ""));
1002
+ return Codec.equals(normalize(await this.safetyNumber(first, second)), normalize(expected));
1003
+ }
1004
+ /**
1005
+ * Whether two public keys refer to the same identity.
1006
+ *
1007
+ * @param left
1008
+ * @param right
1009
+ * @returns
1010
+ */
1011
+ static async samePublicKey(left, right) {
1012
+ const exported = async (value) => {
1013
+ if (typeof value === "string") return value;
1014
+ return await KeyPair.exportPublicKey(await KeyPair.resolvePublic(value));
1015
+ };
1016
+ return Codec.equals(Codec.decodeBase64Url(await exported(left)), Codec.decodeBase64Url(await exported(right)));
1017
+ }
1018
+ };
1019
+ //#endregion
1020
+ //#region src/SecureChannel.ts
1021
+ const CONTEXT$1 = "arkstack/e2ee/v1";
1022
+ /**
1023
+ * A two party end-to-end encrypted channel.
1024
+ *
1025
+ * Each side combines its own private key with the other side's public key over
1026
+ * ECDH, stretches the result with HKDF-SHA256, and ends up holding the exact
1027
+ * same AES-256-GCM key without that key ever crossing the wire. Messages
1028
+ * encrypted by either peer — in Node or in a browser — decrypt on the other.
1029
+ *
1030
+ * ```ts
1031
+ * const alice = await KeyPair.generate()
1032
+ * const bob = await KeyPair.generate()
1033
+ *
1034
+ * const outbound = await SecureChannel.between(alice, await bob.exportPublicKey())
1035
+ * const inbound = await SecureChannel.between(bob, await alice.exportPublicKey())
1036
+ *
1037
+ * await inbound.decrypt(await outbound.encrypt('hey')) // 'hey'
1038
+ * ```
1039
+ */
1040
+ var SecureChannel = class SecureChannel {
1041
+ cipher;
1042
+ localPublicKey;
1043
+ remotePublicKey;
1044
+ /**
1045
+ * @param cipher The cipher bound to the derived shared key.
1046
+ * @param localPublicKey This side's public key, base64url.
1047
+ * @param remotePublicKey The peer's public key, base64url.
1048
+ */
1049
+ constructor(cipher, localPublicKey, remotePublicKey) {
1050
+ this.cipher = cipher;
1051
+ this.localPublicKey = localPublicKey;
1052
+ this.remotePublicKey = remotePublicKey;
1053
+ }
1054
+ /**
1055
+ * Open a channel between a local key pair (or private key) and a peer's
1056
+ * public key.
1057
+ *
1058
+ * @param local
1059
+ * @param peerPublicKey
1060
+ * @param options
1061
+ * @returns
1062
+ */
1063
+ static async between(local, peerPublicKey, options = {}) {
1064
+ const pair = local instanceof KeyPair ? local : await KeyPair.fromPrivateKey(local);
1065
+ if (!pair.isComplete) throw new Error("A secure channel requires the local private key");
1066
+ const remote = await KeyPair.resolvePublic(peerPublicKey);
1067
+ const localPublicKey = await pair.exportPublicKey();
1068
+ const remotePublicKey = await KeyPair.exportPublicKey(remote);
1069
+ const key = await EncryptionKey.expand(await pair.sharedBits(remote), await this.salt(localPublicKey, remotePublicKey), options.info ? `${CONTEXT$1}:${options.info}` : CONTEXT$1);
1070
+ return new SecureChannel(new Cipher(key), localPublicKey, remotePublicKey);
1071
+ }
1072
+ /**
1073
+ * The HKDF salt for a pair of participants: a digest over both public keys
1074
+ * in a deterministic order, so both sides compute the same value.
1075
+ *
1076
+ * @param first
1077
+ * @param second
1078
+ * @returns
1079
+ */
1080
+ static async salt(first, second) {
1081
+ return await digest(Codec.encodeUtf8(KeyPair.order(first, second).join("|")));
1082
+ }
1083
+ /**
1084
+ * The shared key both peers derived. Persist it only if you intend to skip
1085
+ * the handshake later; it is as sensitive as the messages themselves.
1086
+ *
1087
+ * @returns
1088
+ */
1089
+ get key() {
1090
+ return this.cipher.key;
1091
+ }
1092
+ /**
1093
+ * Encrypt a message for the peer.
1094
+ *
1095
+ * @param message
1096
+ * @param options
1097
+ * @returns
1098
+ */
1099
+ async encrypt(message, options = {}) {
1100
+ return await this.cipher.encrypt(message, options);
1101
+ }
1102
+ /**
1103
+ * Decrypt a message from the peer.
1104
+ *
1105
+ * @param payload
1106
+ * @param options
1107
+ * @returns
1108
+ */
1109
+ async decrypt(payload, options = {}) {
1110
+ return await this.cipher.decrypt(payload, options);
1111
+ }
1112
+ /**
1113
+ * Fingerprint of the derived shared key. Identical on both sides, and the
1114
+ * cheapest way to assert two peers really did agree on the same secret.
1115
+ *
1116
+ * @param options
1117
+ * @returns
1118
+ */
1119
+ async fingerprint(options = {}) {
1120
+ return await this.key.fingerprint({
1121
+ length: 16,
1122
+ group: 8,
1123
+ ...options
1124
+ });
1125
+ }
1126
+ /**
1127
+ * The conversation's safety number: show it to both participants so they
1128
+ * can confirm out of band that nobody is sitting in the middle.
1129
+ *
1130
+ * @param groups
1131
+ * @returns
1132
+ */
1133
+ async safetyNumber(groups = 12) {
1134
+ return await KeyPair.safetyNumber(this.localPublicKey, this.remotePublicKey, groups);
1135
+ }
1136
+ };
1137
+ //#endregion
1138
+ //#region src/SealedBox.ts
1139
+ const PREFIX = "ark1";
1140
+ const CONTEXT = "arkstack/sealed/v1";
1141
+ /**
1142
+ * Anonymous encryption to a public key.
1143
+ *
1144
+ * The sender needs no identity of their own: a throwaway key pair is generated
1145
+ * per message, agreed with the recipient's public key over ECDH, and its public
1146
+ * half is carried in the payload so the recipient can reproduce the secret.
1147
+ * Only the holder of the matching private key can open the result — including
1148
+ * the sender, who cannot decrypt their own message afterwards.
1149
+ *
1150
+ * Payloads look like `ark1:<ephemeralPublicKey>:<iv>:<authTag>:<ciphertext>`.
1151
+ */
1152
+ var SealedBox = class {
1153
+ /** Payload discriminator. */
1154
+ static prefix = PREFIX;
1155
+ /**
1156
+ * Encrypt a message to a recipient's public key.
1157
+ *
1158
+ * @param message
1159
+ * @param recipientPublicKey
1160
+ * @param options
1161
+ * @returns
1162
+ */
1163
+ static async seal(message, recipientPublicKey, options = {}) {
1164
+ const ephemeral = await KeyPair.generate();
1165
+ const recipient = await KeyPair.resolvePublic(recipientPublicKey);
1166
+ const ephemeralPublicKey = await ephemeral.exportPublicKey();
1167
+ const key = await this.derive(ephemeral, recipient, ephemeralPublicKey, await KeyPair.exportPublicKey(recipient));
1168
+ return [
1169
+ PREFIX,
1170
+ ephemeralPublicKey,
1171
+ await new Cipher(key).encrypt(message, options)
1172
+ ].join(":");
1173
+ }
1174
+ /**
1175
+ * Open a sealed payload with the recipient's private key.
1176
+ *
1177
+ * @param payload
1178
+ * @param recipientPrivateKey
1179
+ * @param options
1180
+ * @returns
1181
+ */
1182
+ static async open(payload, recipientPrivateKey, options = {}) {
1183
+ const [prefix, ephemeralPublicKey, ...rest] = payload.split(":");
1184
+ if (prefix !== PREFIX || !ephemeralPublicKey || rest.length !== 3) throw new Error("Invalid sealed payload format");
1185
+ const recipient = recipientPrivateKey instanceof KeyPair ? recipientPrivateKey : await KeyPair.fromPrivateKey(recipientPrivateKey);
1186
+ if (!recipient.isComplete) throw new Error("Opening a sealed payload requires the recipient private key");
1187
+ return await new Cipher(await this.derive(recipient, await KeyPair.importPublicKey(ephemeralPublicKey), ephemeralPublicKey, await recipient.exportPublicKey())).decrypt(rest.join(":"), options);
1188
+ }
1189
+ /**
1190
+ * Whether a string is shaped like a sealed payload.
1191
+ *
1192
+ * @param value
1193
+ * @returns
1194
+ */
1195
+ static looksLikePayload(value) {
1196
+ return typeof value === "string" && value.startsWith(`${PREFIX}:`) && value.split(":").length === 5;
1197
+ }
1198
+ /**
1199
+ * Derive the one-off message key. Both sides feed the same ordered pair of
1200
+ * public keys into the salt, so sender and recipient agree.
1201
+ *
1202
+ * @param owner The side holding a private key.
1203
+ * @param peer The other side's public key.
1204
+ * @param ephemeralPublicKey
1205
+ * @param recipientPublicKey
1206
+ * @returns
1207
+ */
1208
+ static async derive(owner, peer, ephemeralPublicKey, recipientPublicKey) {
1209
+ return await EncryptionKey.expand(await owner.sharedBits(peer), await SecureChannel.salt(ephemeralPublicKey, recipientPublicKey), CONTEXT);
1210
+ }
1211
+ };
1212
+ //#endregion
1213
+ export { Cipher, Codec, EncryptionKey, KeyPair, Keys, SealedBox, SecureChannel, digest, randomBytes, subtle, webCrypto };