@learncard/sss-key-manager 0.1.23 → 0.2.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.
Files changed (56) hide show
  1. package/README.md +12 -95
  2. package/dist/api-client.d.ts +44 -7
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/atomic-operations.d.ts +10 -2
  5. package/dist/atomic-operations.d.ts.map +1 -1
  6. package/dist/email-relay-crypto.d.ts +49 -0
  7. package/dist/email-relay-crypto.d.ts.map +1 -0
  8. package/dist/escrow-attestation.d.ts +8 -0
  9. package/dist/escrow-attestation.d.ts.map +1 -0
  10. package/dist/escrow-crypto.d.ts +52 -0
  11. package/dist/escrow-crypto.d.ts.map +1 -0
  12. package/dist/escrow-pin.d.ts +22 -0
  13. package/dist/escrow-pin.d.ts.map +1 -0
  14. package/dist/index.d.ts +13 -4
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/key-manager.d.ts +2 -0
  17. package/dist/key-manager.d.ts.map +1 -1
  18. package/dist/qr-login.d.ts.map +1 -1
  19. package/dist/recovery-phrase.d.ts +6 -0
  20. package/dist/recovery-phrase.d.ts.map +1 -1
  21. package/dist/sss-key-manager.cjs.development.js +3163 -440
  22. package/dist/sss-key-manager.cjs.development.js.map +4 -4
  23. package/dist/sss-key-manager.cjs.production.min.js +33 -33
  24. package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
  25. package/dist/sss-key-manager.esm.js +3163 -440
  26. package/dist/sss-key-manager.esm.js.map +4 -4
  27. package/dist/sss-strategy.d.ts +74 -4
  28. package/dist/sss-strategy.d.ts.map +1 -1
  29. package/dist/storage.d.ts +1 -0
  30. package/dist/storage.d.ts.map +1 -1
  31. package/dist/types.d.ts +87 -4
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +2 -2
  34. package/src/api-client.test.ts +122 -0
  35. package/src/api-client.ts +107 -116
  36. package/src/atomic-operations.test.ts +56 -16
  37. package/src/atomic-operations.ts +55 -19
  38. package/src/critical-paths.test.ts +51 -1
  39. package/src/email-relay-crypto.test.ts +87 -0
  40. package/src/email-relay-crypto.ts +293 -0
  41. package/src/escrow-attestation.test.ts +85 -0
  42. package/src/escrow-attestation.ts +64 -0
  43. package/src/escrow-crypto.test.ts +381 -0
  44. package/src/escrow-crypto.ts +319 -0
  45. package/src/escrow-pin.test.ts +119 -0
  46. package/src/escrow-pin.ts +83 -0
  47. package/src/index.ts +63 -1
  48. package/src/key-manager.ts +4 -9
  49. package/src/qr-login.ts +22 -15
  50. package/src/recovery-phrase.ts +40 -0
  51. package/src/sss-session-writes.test.ts +109 -0
  52. package/src/sss-strategy.test.ts +2642 -110
  53. package/src/sss-strategy.ts +2186 -224
  54. package/src/storage.test.ts +66 -8
  55. package/src/storage.ts +41 -29
  56. package/src/types.ts +107 -6
@@ -0,0 +1,381 @@
1
+ import { beforeAll, describe, expect, it } from 'vitest';
2
+
3
+ import { base64ToBuffer, bufferToBase64 } from './crypto';
4
+ import {
5
+ ESCROW_ALGORITHM,
6
+ ESCROW_BLOB_INFO,
7
+ ESCROW_CLIENT_KEY_ID,
8
+ ESCROW_ENVELOPE_VERSION,
9
+ ESCROW_RELEASE_INFO,
10
+ decryptEscrowBlob,
11
+ encryptEscrowBlob,
12
+ generateEscrowKeyPair,
13
+ openEscrowRelease,
14
+ parseEscrowBlobPlaintext,
15
+ parseEscrowEnvelope,
16
+ parseEscrowReleasePlaintext,
17
+ sealEscrowRelease,
18
+ } from './escrow-crypto';
19
+ import type { EscrowEnvelope } from './escrow-crypto';
20
+
21
+ const blob = {
22
+ recoveryShare: `0001${'ab'.repeat(48)}`,
23
+ did: 'did:key:zTestEscrow',
24
+ shareVersion: 1,
25
+ };
26
+ const release = { ...blob, holdId: 'hold-1.test_2' };
27
+ const plaintext = { ...blob, version: 1 };
28
+ const releasePlaintext = { ...release, version: 1 };
29
+
30
+ describe('escrow encryption', () => {
31
+ let publicKey = '';
32
+ let privateKey = '';
33
+ let wrongPrivateKey = '';
34
+ let blobEnvelope: EscrowEnvelope;
35
+ let releaseEnvelope: EscrowEnvelope;
36
+
37
+ beforeAll(async (): Promise<void> => {
38
+ ({ publicKey, privateKey } = await generateEscrowKeyPair());
39
+ wrongPrivateKey = (await generateEscrowKeyPair()).privateKey;
40
+ blobEnvelope = await encryptEscrowBlob(blob, publicKey, 'enclave-1');
41
+ releaseEnvelope = await sealEscrowRelease(release, publicKey);
42
+ });
43
+
44
+ it('exports an importable SPKI/PKCS#8 P-256 key pair', async (): Promise<void> => {
45
+ const importedPublic = await crypto.subtle.importKey(
46
+ 'spki',
47
+ base64ToBuffer(publicKey),
48
+ { name: 'ECDH', namedCurve: 'P-256' },
49
+ true,
50
+ []
51
+ );
52
+ const importedPrivate = await crypto.subtle.importKey(
53
+ 'pkcs8',
54
+ base64ToBuffer(privateKey),
55
+ { name: 'ECDH', namedCurve: 'P-256' },
56
+ true,
57
+ ['deriveBits']
58
+ );
59
+ expect(importedPublic.type).toBe('public');
60
+ expect(importedPrivate.type).toBe('private');
61
+ const publicJwk = await crypto.subtle.exportKey('jwk', importedPublic);
62
+ const privateJwk = await crypto.subtle.exportKey('jwk', importedPrivate);
63
+ expect(privateJwk).toMatchObject({ crv: 'P-256', x: publicJwk.x, y: publicJwk.y });
64
+ });
65
+
66
+ it('round-trips blobs without exposing the recovery share', async (): Promise<void> => {
67
+ expect(JSON.stringify(blobEnvelope)).not.toContain(blob.recoveryShare);
68
+ expect(blobEnvelope).toMatchObject({
69
+ version: ESCROW_ENVELOPE_VERSION,
70
+ algorithm: ESCROW_ALGORITHM,
71
+ keyId: 'enclave-1',
72
+ });
73
+ const point = base64ToBuffer(blobEnvelope.ephemeralPublicKey);
74
+ expect(point.length).toBe(65);
75
+ expect(point[0]).toBe(4);
76
+ expect(base64ToBuffer(blobEnvelope.salt).length).toBe(32);
77
+ expect(base64ToBuffer(blobEnvelope.iv).length).toBe(12);
78
+ await expect(decryptEscrowBlob(blobEnvelope, privateKey)).resolves.toEqual(plaintext);
79
+ });
80
+
81
+ it('round-trips releases with the client key ID', async (): Promise<void> => {
82
+ expect(releaseEnvelope.keyId).toBe(ESCROW_CLIENT_KEY_ID);
83
+ expect(JSON.stringify(releaseEnvelope)).not.toContain(release.recoveryShare);
84
+ await expect(openEscrowRelease(releaseEnvelope, privateKey)).resolves.toEqual(
85
+ releasePlaintext
86
+ );
87
+ });
88
+
89
+ it('uses fresh ephemeral keys, salts, and IVs', async (): Promise<void> => {
90
+ const other = await encryptEscrowBlob(blob, publicKey, 'enclave-1');
91
+ for (const field of ['ephemeralPublicKey', 'salt', 'iv', 'ciphertext'] as const) {
92
+ expect(other[field]).not.toBe(blobEnvelope[field]);
93
+ }
94
+ });
95
+
96
+ it('round-trips a PIN verifier through the blob and release without changing version', async (): Promise<void> => {
97
+ const pinVerifier = 'AB'.repeat(32);
98
+ const envelope = await encryptEscrowBlob({ ...blob, pinVerifier }, publicKey, 'enclave-1');
99
+ expect(envelope.version).toBe(1);
100
+ expect(JSON.stringify(envelope)).not.toContain(pinVerifier.toLowerCase());
101
+ const decrypted = await decryptEscrowBlob(envelope, privateKey);
102
+ expect(decrypted).toEqual({ ...plaintext, pinVerifier: pinVerifier.toLowerCase() });
103
+ const sealed = await sealEscrowRelease({ ...decrypted, holdId: release.holdId }, publicKey);
104
+ expect(sealed.version).toBe(1);
105
+ await expect(openEscrowRelease(sealed, privateKey)).resolves.toEqual({
106
+ ...releasePlaintext,
107
+ pinVerifier: pinVerifier.toLowerCase(),
108
+ });
109
+ });
110
+
111
+ it.each(['', 'a'.repeat(63), 'a'.repeat(65), 'g'.repeat(64)])(
112
+ 'rejects invalid PIN verifier before encryption %j',
113
+ async (pinVerifier): Promise<void> => {
114
+ await expect(
115
+ encryptEscrowBlob({ ...blob, pinVerifier }, publicKey, 'enclave-1')
116
+ ).rejects.toThrow('pinVerifier');
117
+ await expect(sealEscrowRelease({ ...release, pinVerifier }, publicKey)).rejects.toThrow(
118
+ 'pinVerifier'
119
+ );
120
+ }
121
+ );
122
+
123
+ it.each(['blob', 'release'] as const)(
124
+ 'rejects wrong keys and tampered %s envelopes',
125
+ async (kind): Promise<void> => {
126
+ const envelope = kind === 'blob' ? blobEnvelope : releaseEnvelope;
127
+ const decrypt = kind === 'blob' ? decryptEscrowBlob : openEscrowRelease;
128
+ await expect(decrypt(envelope, wrongPrivateKey)).rejects.toThrow();
129
+ await expect(
130
+ decrypt({ ...envelope, keyId: 'other-key' }, privateKey)
131
+ ).rejects.toThrow();
132
+
133
+ for (const field of ['ciphertext', 'salt', 'iv'] as const) {
134
+ const bytes = base64ToBuffer(envelope[field]);
135
+ bytes[0] = (bytes[0] ?? 0) ^ 1;
136
+ await expect(
137
+ decrypt({ ...envelope, [field]: bufferToBase64(bytes.buffer) }, privateKey)
138
+ ).rejects.toThrow();
139
+ }
140
+ for (const change of [{ version: 2 }, { algorithm: 'AES-GCM' }]) {
141
+ expect(() => parseEscrowEnvelope({ ...envelope, ...change })).toThrow(
142
+ 'Unsupported'
143
+ );
144
+ await expect(decrypt({ ...envelope, ...change }, privateKey)).rejects.toThrow(
145
+ 'Unsupported'
146
+ );
147
+ }
148
+ }
149
+ );
150
+
151
+ it('domain-separates blobs and releases even with identical recipient keys and key IDs', async (): Promise<void> => {
152
+ const envelope = await encryptEscrowBlob(blob, publicKey, ESCROW_CLIENT_KEY_ID);
153
+ await expect(openEscrowRelease(envelope, privateKey)).rejects.toMatchObject({
154
+ name: 'OperationError',
155
+ });
156
+ await expect(decryptEscrowBlob(releaseEnvelope, privateKey)).rejects.toMatchObject({
157
+ name: 'OperationError',
158
+ });
159
+ });
160
+
161
+ it.each(['', ' ', 'not-base64!', 'YWJj'])(
162
+ 'rejects invalid public key %j',
163
+ async (key): Promise<void> => {
164
+ await expect(encryptEscrowBlob(blob, key, 'enclave-1')).rejects.toThrow();
165
+ await expect(sealEscrowRelease(release, key)).rejects.toThrow();
166
+ }
167
+ );
168
+
169
+ it.each(['', 'bad key', ' key ', 'bad|key', 'bad/key', 'a'.repeat(129)])(
170
+ 'rejects invalid encryption key ID %j',
171
+ async (keyId): Promise<void> => {
172
+ await expect(encryptEscrowBlob(blob, publicKey, keyId)).rejects.toThrow('keyId');
173
+ }
174
+ );
175
+
176
+ it('validates plaintext before importing a recipient key', async (): Promise<void> => {
177
+ await expect(encryptEscrowBlob({ ...blob, shareVersion: 0 }, '', '')).rejects.toThrow(
178
+ 'shareVersion'
179
+ );
180
+ await expect(sealEscrowRelease({ ...release, holdId: '' }, '')).rejects.toThrow('holdId');
181
+ });
182
+
183
+ it.each([
184
+ {
185
+ info: ESCROW_BLOB_INFO,
186
+ input: { ...plaintext, shareVersion: 0 },
187
+ decrypt: decryptEscrowBlob,
188
+ error: 'shareVersion',
189
+ },
190
+ {
191
+ info: ESCROW_BLOB_INFO,
192
+ input: { ...plaintext, pinVerifier: 'a'.repeat(63) },
193
+ decrypt: decryptEscrowBlob,
194
+ error: 'pinVerifier',
195
+ },
196
+ {
197
+ info: ESCROW_RELEASE_INFO,
198
+ input: { ...releasePlaintext, pinVerifier: 'a'.repeat(65) },
199
+ decrypt: openEscrowRelease,
200
+ error: 'pinVerifier',
201
+ },
202
+ {
203
+ info: ESCROW_RELEASE_INFO,
204
+ input: { ...releasePlaintext, holdId: '' },
205
+ decrypt: openEscrowRelease,
206
+ error: 'holdId',
207
+ },
208
+ ])(
209
+ 'validates plaintext after authenticated decryption ($info)',
210
+ async ({ info, input, decrypt, error }): Promise<void> => {
211
+ // Independently construct a valid wire envelope carrying invalid JSON fields.
212
+ const recipient = await crypto.subtle.importKey(
213
+ 'spki',
214
+ base64ToBuffer(publicKey),
215
+ { name: 'ECDH', namedCurve: 'P-256' },
216
+ false,
217
+ []
218
+ );
219
+ const ephemeral = await crypto.subtle.generateKey(
220
+ { name: 'ECDH', namedCurve: 'P-256' },
221
+ true,
222
+ ['deriveBits']
223
+ );
224
+ const secret = await crypto.subtle.deriveBits(
225
+ { name: 'ECDH', public: recipient },
226
+ ephemeral.privateKey,
227
+ 256
228
+ );
229
+ const material = await crypto.subtle.importKey('raw', secret, 'HKDF', false, [
230
+ 'deriveKey',
231
+ ]);
232
+ const salt = crypto.getRandomValues(new Uint8Array(32));
233
+ const iv = crypto.getRandomValues(new Uint8Array(12));
234
+ const key = await crypto.subtle.deriveKey(
235
+ { name: 'HKDF', hash: 'SHA-256', salt, info: new TextEncoder().encode(info) },
236
+ material,
237
+ { name: 'AES-GCM', length: 256 },
238
+ false,
239
+ ['encrypt']
240
+ );
241
+ const ciphertext = await crypto.subtle.encrypt(
242
+ {
243
+ name: 'AES-GCM',
244
+ iv,
245
+ additionalData: new TextEncoder().encode(`1|${ESCROW_ALGORITHM}|client`),
246
+ },
247
+ key,
248
+ new TextEncoder().encode(JSON.stringify(input))
249
+ );
250
+ await expect(
251
+ decrypt(
252
+ {
253
+ version: 1,
254
+ algorithm: ESCROW_ALGORITHM,
255
+ keyId: 'client',
256
+ ephemeralPublicKey: bufferToBase64(
257
+ await crypto.subtle.exportKey('raw', ephemeral.publicKey)
258
+ ),
259
+ salt: bufferToBase64(salt.buffer),
260
+ iv: bufferToBase64(iv.buffer),
261
+ ciphertext: bufferToBase64(ciphertext),
262
+ },
263
+ privateKey
264
+ )
265
+ ).rejects.toThrow(error);
266
+ }
267
+ );
268
+ });
269
+
270
+ describe('escrow parsing', () => {
271
+ it('accepts legacy plaintext without a PIN verifier', (): void => {
272
+ expect(parseEscrowBlobPlaintext(plaintext)).toEqual(plaintext);
273
+ expect(parseEscrowBlobPlaintext(plaintext)).not.toHaveProperty('pinVerifier');
274
+ expect(parseEscrowReleasePlaintext(releasePlaintext)).toEqual(releasePlaintext);
275
+ });
276
+
277
+ const envelope = {
278
+ version: 1,
279
+ algorithm: ESCROW_ALGORITHM,
280
+ keyId: 'enclave-1',
281
+ ephemeralPublicKey: 'point',
282
+ salt: 'salt',
283
+ iv: 'iv',
284
+ ciphertext: 'ciphertext',
285
+ };
286
+
287
+ it('copies only recognized fields', (): void => {
288
+ expect(parseEscrowEnvelope({ ...envelope, extra: true })).toEqual(envelope);
289
+ expect(parseEscrowBlobPlaintext({ ...releasePlaintext, extra: true })).toEqual(plaintext);
290
+ expect(parseEscrowReleasePlaintext({ ...releasePlaintext, extra: true })).toEqual(
291
+ releasePlaintext
292
+ );
293
+ });
294
+
295
+ it.each([null, undefined, [], 'value', 1])('rejects non-record input %j', (value): void => {
296
+ expect(() => parseEscrowEnvelope(value)).toThrow();
297
+ expect(() => parseEscrowBlobPlaintext(value)).toThrow();
298
+ expect(() => parseEscrowReleasePlaintext(value)).toThrow();
299
+ });
300
+
301
+ it.each([
302
+ ['keyId', 128],
303
+ ['ephemeralPublicKey', 256],
304
+ ['salt', 128],
305
+ ['iv', 64],
306
+ ['ciphertext', 16_384],
307
+ ] as const)('enforces envelope %s bounds and required type', (field, max): void => {
308
+ for (const value of [undefined, '', 123, 'a'.repeat(max + 1)]) {
309
+ expect(() => parseEscrowEnvelope({ ...envelope, [field]: value })).toThrow(field);
310
+ }
311
+ expect(parseEscrowEnvelope({ ...envelope, [field]: 'a'.repeat(max) })[field]).toHaveLength(
312
+ max
313
+ );
314
+ });
315
+
316
+ it.each(['bad key', 'bad|key', 'bad/key'])('rejects bad parsed key ID %j', (keyId): void => {
317
+ expect(() => parseEscrowEnvelope({ ...envelope, keyId })).toThrow('keyId');
318
+ });
319
+
320
+ it.each([undefined, 0, 2, '1'])('rejects unsupported versions %j', (version): void => {
321
+ expect(() => parseEscrowEnvelope({ ...envelope, version })).toThrow('Unsupported');
322
+ expect(() => parseEscrowBlobPlaintext({ ...plaintext, version })).toThrow();
323
+ expect(() => parseEscrowReleasePlaintext({ ...releasePlaintext, version })).toThrow();
324
+ });
325
+
326
+ it.each([undefined, '', 'other'])('rejects unsupported algorithms %j', (algorithm): void => {
327
+ expect(() => parseEscrowEnvelope({ ...envelope, algorithm })).toThrow('Unsupported');
328
+ });
329
+
330
+ it.each([
331
+ ['recoveryShare', undefined],
332
+ ['recoveryShare', 'abcd'],
333
+ ['recoveryShare', 'abcgh'],
334
+ ['recoveryShare', 'a'.repeat(4_097)],
335
+ ['recoveryShare', 12345],
336
+ ['did', undefined],
337
+ ['did', ''],
338
+ ['did', 'not:did'],
339
+ ['did', `did:${'a'.repeat(2_045)}`],
340
+ ['shareVersion', undefined],
341
+ ['shareVersion', 0],
342
+ ['shareVersion', -1],
343
+ ['shareVersion', 1.5],
344
+ ['shareVersion', '1'],
345
+ ['shareVersion', NaN],
346
+ ['shareVersion', Infinity],
347
+ ['pinVerifier', null],
348
+ ['pinVerifier', 123],
349
+ ['pinVerifier', ''],
350
+ ['pinVerifier', 'a'.repeat(63)],
351
+ ['pinVerifier', 'a'.repeat(65)],
352
+ ['pinVerifier', 'g'.repeat(64)],
353
+ ])('rejects invalid plaintext %s = %j', (field, value): void => {
354
+ expect(() => parseEscrowBlobPlaintext({ ...plaintext, [field]: value })).toThrow(field);
355
+ expect(() => parseEscrowReleasePlaintext({ ...releasePlaintext, [field]: value })).toThrow(
356
+ field
357
+ );
358
+ });
359
+
360
+ it.each([undefined, '', 'bad hold', 'bad|hold', 'a'.repeat(129), 123])(
361
+ 'rejects invalid hold ID %j',
362
+ (holdId): void => {
363
+ expect(() => parseEscrowReleasePlaintext({ ...releasePlaintext, holdId })).toThrow(
364
+ 'holdId'
365
+ );
366
+ }
367
+ );
368
+
369
+ it('accepts bounds and normalizes uppercase hex', (): void => {
370
+ expect(
371
+ parseEscrowBlobPlaintext({ ...plaintext, recoveryShare: 'ABCDE' }).recoveryShare
372
+ ).toBe('abcde');
373
+ const maximum = {
374
+ ...releasePlaintext,
375
+ recoveryShare: 'a'.repeat(4_096),
376
+ did: `did:${'a'.repeat(2_044)}`,
377
+ holdId: 'a'.repeat(128),
378
+ };
379
+ expect(parseEscrowReleasePlaintext(maximum)).toEqual(maximum);
380
+ });
381
+ });
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Shared escrow wire format: ephemeral P-256 ECDH → HKDF-SHA-256 → AES-256-GCM.
3
+ * Recipient keys use SPKI/PKCS#8; envelope keys are raw uncompressed P-256 points.
4
+ * All binary fields use standard base64. Ciphertext includes the 128-bit GCM tag.
5
+ * Blob and release envelopes use distinct HKDF info strings for domain separation.
6
+ */
7
+ import { base64ToBuffer, bufferToBase64 } from './crypto';
8
+
9
+ /** Supported escrow wire-format version. */
10
+ export const ESCROW_ENVELOPE_VERSION = 1 as const;
11
+ /** Cryptographic suite used by escrow envelopes. */
12
+ export const ESCROW_ALGORITHM = 'P-256-HKDF-SHA256-AES-256-GCM' as const;
13
+ /** HKDF info for client-to-enclave escrow blobs. */
14
+ export const ESCROW_BLOB_INFO = 'learncard-escrow-blob-v1';
15
+ /** HKDF info for enclave-to-client release envelopes. */
16
+ export const ESCROW_RELEASE_INFO = 'learncard-escrow-release-v1';
17
+ /** Recipient key identifier used when sealing a release. */
18
+ export const ESCROW_CLIENT_KEY_ID = 'client' as const;
19
+
20
+ /** Encrypted escrow payload with authenticated version, algorithm, and key ID. */
21
+ export interface EscrowEnvelope {
22
+ version: typeof ESCROW_ENVELOPE_VERSION;
23
+ algorithm: typeof ESCROW_ALGORITHM;
24
+ keyId: string;
25
+ ephemeralPublicKey: string;
26
+ salt: string;
27
+ iv: string;
28
+ ciphertext: string;
29
+ }
30
+
31
+ /** Recovery share bound to an identity and a positive share version. */
32
+ export interface EscrowBlobPlaintext {
33
+ version: typeof ESCROW_ENVELOPE_VERSION;
34
+ recoveryShare: string;
35
+ did: string;
36
+ shareVersion: number;
37
+ pinVerifier?: string;
38
+ }
39
+
40
+ /** Released recovery share additionally bound to its recovery hold. */
41
+ export interface EscrowReleasePlaintext extends EscrowBlobPlaintext {
42
+ holdId: string;
43
+ }
44
+
45
+ const encodeUtf8 = (value: string): Uint8Array<ArrayBuffer> => {
46
+ const bytes = new TextEncoder().encode(value);
47
+ const copy = new Uint8Array(new ArrayBuffer(bytes.byteLength));
48
+ copy.set(bytes);
49
+ return copy;
50
+ };
51
+
52
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
53
+ typeof value === 'object' && value !== null && !Array.isArray(value);
54
+
55
+ const requireString = (
56
+ record: Record<string, unknown>,
57
+ key: string,
58
+ options: { min?: number; max: number; pattern?: RegExp }
59
+ ): string => {
60
+ const value = record[key];
61
+
62
+ if (
63
+ typeof value !== 'string' ||
64
+ value.length < (options.min ?? 1) ||
65
+ value.length > options.max ||
66
+ (options.pattern && !options.pattern.test(value))
67
+ ) {
68
+ throw new Error(`Invalid escrow ${key}`);
69
+ }
70
+
71
+ return value;
72
+ };
73
+
74
+ /** Validate an untrusted envelope before decryption, copying only recognized fields. */
75
+ export const parseEscrowEnvelope = (value: unknown): EscrowEnvelope => {
76
+ if (!isRecord(value)) throw new Error('Invalid escrow envelope');
77
+ if (value.version !== ESCROW_ENVELOPE_VERSION) {
78
+ throw new Error('Unsupported escrow envelope version');
79
+ }
80
+ if (value.algorithm !== ESCROW_ALGORITHM) {
81
+ throw new Error('Unsupported escrow algorithm');
82
+ }
83
+
84
+ return {
85
+ version: ESCROW_ENVELOPE_VERSION,
86
+ algorithm: ESCROW_ALGORITHM,
87
+ keyId: requireString(value, 'keyId', { max: 128, pattern: /^[A-Za-z0-9._-]+$/ }),
88
+ ephemeralPublicKey: requireString(value, 'ephemeralPublicKey', { max: 256 }),
89
+ salt: requireString(value, 'salt', { max: 128 }),
90
+ iv: requireString(value, 'iv', { max: 64 }),
91
+ ciphertext: requireString(value, 'ciphertext', { max: 16_384 }),
92
+ };
93
+ };
94
+
95
+ /** Validate blob plaintext, normalize hex to lowercase, and omit unknown fields. */
96
+ export const parseEscrowBlobPlaintext = (value: unknown): EscrowBlobPlaintext => {
97
+ if (!isRecord(value) || value.version !== ESCROW_ENVELOPE_VERSION) {
98
+ throw new Error('Invalid escrow plaintext');
99
+ }
100
+ if (
101
+ typeof value.shareVersion !== 'number' ||
102
+ !Number.isInteger(value.shareVersion) ||
103
+ value.shareVersion <= 0
104
+ ) {
105
+ throw new Error('Invalid escrow shareVersion');
106
+ }
107
+
108
+ return {
109
+ version: ESCROW_ENVELOPE_VERSION,
110
+ recoveryShare: requireString(value, 'recoveryShare', {
111
+ min: 5,
112
+ max: 4_096,
113
+ pattern: /^[0-9a-f]+$/i,
114
+ }).toLowerCase(),
115
+ did: requireString(value, 'did', { max: 2_048, pattern: /^did:/ }),
116
+ shareVersion: value.shareVersion,
117
+ ...(value.pinVerifier !== undefined
118
+ ? {
119
+ pinVerifier: requireString(value, 'pinVerifier', {
120
+ min: 64,
121
+ max: 64,
122
+ pattern: /^[0-9a-f]{64}$/i,
123
+ }).toLowerCase(),
124
+ }
125
+ : {}),
126
+ };
127
+ };
128
+
129
+ /** Validate release plaintext, including its hold ID, and omit unknown fields. */
130
+ export const parseEscrowReleasePlaintext = (value: unknown): EscrowReleasePlaintext => {
131
+ const plaintext = parseEscrowBlobPlaintext(value);
132
+ if (!isRecord(value)) throw new Error('Invalid escrow plaintext');
133
+
134
+ return {
135
+ ...plaintext,
136
+ holdId: requireString(value, 'holdId', { max: 128, pattern: /^[A-Za-z0-9._-]+$/ }),
137
+ };
138
+ };
139
+
140
+ const getAad = (
141
+ envelope: Pick<EscrowEnvelope, 'version' | 'algorithm' | 'keyId'>
142
+ ): Uint8Array<ArrayBuffer> =>
143
+ encodeUtf8(`${envelope.version}|${envelope.algorithm}|${envelope.keyId}`);
144
+
145
+ const deriveAesKey = async (
146
+ sharedSecret: ArrayBuffer,
147
+ salt: Uint8Array<ArrayBuffer>,
148
+ usage: KeyUsage,
149
+ info: string
150
+ ): Promise<CryptoKey> => {
151
+ const keyMaterial = await crypto.subtle.importKey('raw', sharedSecret, 'HKDF', false, [
152
+ 'deriveKey',
153
+ ]);
154
+
155
+ return crypto.subtle.deriveKey(
156
+ { name: 'HKDF', hash: 'SHA-256', salt, info: encodeUtf8(info) },
157
+ keyMaterial,
158
+ { name: 'AES-GCM', length: 256 },
159
+ false,
160
+ [usage]
161
+ );
162
+ };
163
+
164
+ /** Generate an extractable P-256 ECDH pair as base64 SPKI and PKCS#8 keys. */
165
+ export const generateEscrowKeyPair = async (): Promise<{
166
+ publicKey: string;
167
+ privateKey: string;
168
+ }> => {
169
+ const keyPair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [
170
+ 'deriveBits',
171
+ ]);
172
+ const [publicKey, privateKey] = await Promise.all([
173
+ crypto.subtle.exportKey('spki', keyPair.publicKey),
174
+ crypto.subtle.exportKey('pkcs8', keyPair.privateKey),
175
+ ]);
176
+
177
+ return { publicKey: bufferToBase64(publicKey), privateKey: bufferToBase64(privateKey) };
178
+ };
179
+
180
+ const encryptToRecipient = async (
181
+ plaintextJson: string,
182
+ spki: string,
183
+ keyId: string,
184
+ info: string
185
+ ): Promise<EscrowEnvelope> => {
186
+ if (typeof spki !== 'string' || !spki.trim()) {
187
+ throw new Error('Escrow public key is not configured');
188
+ }
189
+ const recipientKeyId = requireString({ keyId }, 'keyId', {
190
+ max: 128,
191
+ pattern: /^[A-Za-z0-9._-]+$/,
192
+ });
193
+ const recipientPublicKey = await crypto.subtle.importKey(
194
+ 'spki',
195
+ base64ToBuffer(spki.trim()),
196
+ { name: 'ECDH', namedCurve: 'P-256' },
197
+ false,
198
+ []
199
+ );
200
+ const ephemeralKeyPair = await crypto.subtle.generateKey(
201
+ { name: 'ECDH', namedCurve: 'P-256' },
202
+ true,
203
+ ['deriveBits']
204
+ );
205
+ const sharedSecret = await crypto.subtle.deriveBits(
206
+ { name: 'ECDH', public: recipientPublicKey },
207
+ ephemeralKeyPair.privateKey,
208
+ 256
209
+ );
210
+ const salt = crypto.getRandomValues(new Uint8Array(32));
211
+ const iv = crypto.getRandomValues(new Uint8Array(12));
212
+ const aesKey = await deriveAesKey(sharedSecret, salt, 'encrypt', info);
213
+ const metadata = {
214
+ version: ESCROW_ENVELOPE_VERSION,
215
+ algorithm: ESCROW_ALGORITHM,
216
+ keyId: recipientKeyId,
217
+ };
218
+ const ciphertext = await crypto.subtle.encrypt(
219
+ { name: 'AES-GCM', iv, additionalData: getAad(metadata) },
220
+ aesKey,
221
+ encodeUtf8(plaintextJson)
222
+ );
223
+ const ephemeralPublicKey = await crypto.subtle.exportKey('raw', ephemeralKeyPair.publicKey);
224
+
225
+ return {
226
+ ...metadata,
227
+ ephemeralPublicKey: bufferToBase64(ephemeralPublicKey),
228
+ salt: bufferToBase64(salt.buffer),
229
+ iv: bufferToBase64(iv.buffer),
230
+ ciphertext: bufferToBase64(ciphertext),
231
+ };
232
+ };
233
+
234
+ const decryptFromEnvelope = async (
235
+ value: unknown,
236
+ pkcs8: string,
237
+ info: string
238
+ ): Promise<unknown> => {
239
+ const envelope = parseEscrowEnvelope(value);
240
+ const privateKey = await crypto.subtle.importKey(
241
+ 'pkcs8',
242
+ base64ToBuffer(pkcs8.trim()),
243
+ { name: 'ECDH', namedCurve: 'P-256' },
244
+ false,
245
+ ['deriveBits']
246
+ );
247
+ const ephemeralPublicKey = await crypto.subtle.importKey(
248
+ 'raw',
249
+ base64ToBuffer(envelope.ephemeralPublicKey),
250
+ { name: 'ECDH', namedCurve: 'P-256' },
251
+ false,
252
+ []
253
+ );
254
+ const sharedSecret = await crypto.subtle.deriveBits(
255
+ { name: 'ECDH', public: ephemeralPublicKey },
256
+ privateKey,
257
+ 256
258
+ );
259
+ const salt = base64ToBuffer(envelope.salt);
260
+ const iv = base64ToBuffer(envelope.iv);
261
+ const aesKey = await deriveAesKey(sharedSecret, salt, 'decrypt', info);
262
+ const decrypted = await crypto.subtle.decrypt(
263
+ { name: 'AES-GCM', iv, additionalData: getAad(envelope) },
264
+ aesKey,
265
+ base64ToBuffer(envelope.ciphertext)
266
+ );
267
+ const parsed: unknown = JSON.parse(new TextDecoder().decode(decrypted));
268
+
269
+ return parsed;
270
+ };
271
+
272
+ /** Validate and encrypt a recovery share to the enclave's SPKI public key. */
273
+ export const encryptEscrowBlob = async (
274
+ input: Omit<EscrowBlobPlaintext, 'version'>,
275
+ enclavePublicKeySpkiB64: string,
276
+ keyId: string
277
+ ): Promise<EscrowEnvelope> => {
278
+ const plaintext = parseEscrowBlobPlaintext({ ...input, version: ESCROW_ENVELOPE_VERSION });
279
+
280
+ return encryptToRecipient(
281
+ JSON.stringify(plaintext),
282
+ enclavePublicKeySpkiB64,
283
+ keyId,
284
+ ESCROW_BLOB_INFO
285
+ );
286
+ };
287
+
288
+ /** Decrypt and validate a blob using the enclave's PKCS#8 private key. */
289
+ export const decryptEscrowBlob = async (
290
+ envelope: unknown,
291
+ enclavePrivateKeyPkcs8B64: string
292
+ ): Promise<EscrowBlobPlaintext> =>
293
+ parseEscrowBlobPlaintext(
294
+ await decryptFromEnvelope(envelope, enclavePrivateKeyPkcs8B64, ESCROW_BLOB_INFO)
295
+ );
296
+
297
+ /** Validate and seal a release to the recovering client's ephemeral SPKI public key. */
298
+ export const sealEscrowRelease = async (
299
+ input: Omit<EscrowReleasePlaintext, 'version'>,
300
+ clientEphemeralPublicKeySpkiB64: string
301
+ ): Promise<EscrowEnvelope> => {
302
+ const plaintext = parseEscrowReleasePlaintext({ ...input, version: ESCROW_ENVELOPE_VERSION });
303
+
304
+ return encryptToRecipient(
305
+ JSON.stringify(plaintext),
306
+ clientEphemeralPublicKeySpkiB64,
307
+ ESCROW_CLIENT_KEY_ID,
308
+ ESCROW_RELEASE_INFO
309
+ );
310
+ };
311
+
312
+ /** Open and validate a release using the recovering client's ephemeral PKCS#8 private key. */
313
+ export const openEscrowRelease = async (
314
+ envelope: unknown,
315
+ clientEphemeralPrivateKeyPkcs8B64: string
316
+ ): Promise<EscrowReleasePlaintext> =>
317
+ parseEscrowReleasePlaintext(
318
+ await decryptFromEnvelope(envelope, clientEphemeralPrivateKeyPkcs8B64, ESCROW_RELEASE_INFO)
319
+ );