@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/LICENSE +21 -0
- package/README.md +224 -0
- package/dist/index.d.ts +914 -0
- package/dist/index.js +1213 -0
- package/dist/node.d.ts +78 -0
- package/dist/node.js +112 -0
- package/package.json +43 -0
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 };
|