@learncard/sss-key-manager 0.2.1 → 0.2.2

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 (44) hide show
  1. package/dist/escrow-attestation.d.ts +2 -9
  2. package/dist/escrow-attestation.d.ts.map +1 -1
  3. package/dist/escrow-authority.d.ts +112 -0
  4. package/dist/escrow-authority.d.ts.map +1 -0
  5. package/dist/escrow-authority.types.d.ts +51 -0
  6. package/dist/escrow-authority.types.d.ts.map +1 -0
  7. package/dist/escrow-crypto.d.ts +9 -1
  8. package/dist/escrow-crypto.d.ts.map +1 -1
  9. package/dist/escrow-enrollment-intent.d.ts +13 -0
  10. package/dist/escrow-enrollment-intent.d.ts.map +1 -0
  11. package/dist/escrow-enrollment-intent.types.d.ts +28 -0
  12. package/dist/escrow-enrollment-intent.types.d.ts.map +1 -0
  13. package/dist/escrow-nitro-attestation.d.ts +2 -0
  14. package/dist/escrow-nitro-attestation.d.ts.map +1 -1
  15. package/dist/index.d.ts +8 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/sss-key-manager.cjs.development.js +1592 -213
  18. package/dist/sss-key-manager.cjs.development.js.map +4 -4
  19. package/dist/sss-key-manager.cjs.production.min.js +28 -23
  20. package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
  21. package/dist/sss-key-manager.esm.js +1107 -163
  22. package/dist/sss-key-manager.esm.js.map +4 -4
  23. package/dist/sss-strategy.d.ts.map +1 -1
  24. package/dist/types.d.ts +16 -1
  25. package/dist/types.d.ts.map +1 -1
  26. package/package.json +2 -2
  27. package/src/__fixtures__/escrow-authority-v1.json +96 -0
  28. package/src/__fixtures__/escrow-carry-contact-contract-v1.json +33 -0
  29. package/src/__fixtures__/escrow-enrollment-intent-v1.json +26 -0
  30. package/src/__fixtures__/nitro-attestation-real/staging-nsm-document.json +16 -0
  31. package/src/escrow-attestation.ts +10 -9
  32. package/src/escrow-authority.test.ts +643 -0
  33. package/src/escrow-authority.ts +597 -0
  34. package/src/escrow-authority.types.ts +56 -0
  35. package/src/escrow-crypto.ts +39 -1
  36. package/src/escrow-enrollment-intent.test.ts +224 -0
  37. package/src/escrow-enrollment-intent.ts +236 -0
  38. package/src/escrow-enrollment-intent.types.ts +28 -0
  39. package/src/escrow-nitro-attestation.test.ts +72 -0
  40. package/src/escrow-nitro-attestation.ts +34 -3
  41. package/src/index.ts +27 -0
  42. package/src/sss-authority.test.ts +889 -0
  43. package/src/sss-strategy.ts +479 -38
  44. package/src/types.ts +22 -3
@@ -0,0 +1,889 @@
1
+ // @vitest-environment node
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { getPublicKeyAsync, verifyAsync } from '@noble/ed25519';
4
+ import {
5
+ createSSSStrategy,
6
+ type SSSStorageFunctions,
7
+ type SSSStrategyConfig,
8
+ } from './sss-strategy';
9
+ import { escrowAuthorityMeasurements, type EscrowHoldRecordLike } from './escrow-authority';
10
+ import type { EscrowRecoveryStart } from './types';
11
+ import { verifyEnclaveAttestation } from './escrow-attestation';
12
+ import {
13
+ encryptEscrowBlob,
14
+ decryptEscrowBlob,
15
+ generateEscrowKeyPair,
16
+ parseEscrowEnvelope,
17
+ sealEscrowRelease,
18
+ } from './escrow-crypto';
19
+ import type { EscrowEnvelope } from './escrow-crypto';
20
+ import { encodeEscrowEnrollmentIntent, getEscrowBlobIdentity } from './escrow-enrollment-intent';
21
+ import { splitPrivateKey } from './sss';
22
+ import type {
23
+ EscrowAuthorityRecord,
24
+ EscrowAuthorityState,
25
+ VerifiedEnclaveIdentity,
26
+ } from './escrow-authority.types';
27
+ import type { SignedEscrowEnrollmentIntent } from './escrow-enrollment-intent.types';
28
+ import owner from './__fixtures__/escrow-enrollment-intent-v1.json';
29
+
30
+ vi.mock('./escrow-attestation', () => ({ verifyEnclaveAttestation: vi.fn() }));
31
+ const order = BigInt('0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551');
32
+ const digest = async (input: Uint8Array): Promise<number[]> =>
33
+ Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(input))));
34
+ // [D] Mirrors `hold_hash` in escrow-enclave-app's policy.rs: SHA-256 of
35
+ // compact JSON of this fixed-order array, which the client independently
36
+ // recomputes to bind a SignedHoldRecord to its own request.
37
+ const holdTupleDigest = async (holdRecord: {
38
+ hold: {
39
+ holdId: string;
40
+ did: string;
41
+ shareVersion: number;
42
+ blobHash: string;
43
+ enrollmentEpoch: number;
44
+ releasePolicy: string;
45
+ clientEphemeralPublicKey: string;
46
+ createdLo: number;
47
+ createdHi: number;
48
+ policyVersion: number;
49
+ };
50
+ holdDurationMs: number;
51
+ ledgerSeq: number;
52
+ }): Promise<number[]> => {
53
+ const h = holdRecord.hold;
54
+ const didHashHex = Buffer.from(await digest(Buffer.from(h.did))).toString('hex');
55
+ return digest(
56
+ Buffer.from(
57
+ JSON.stringify([
58
+ 'learncard-hold-v1',
59
+ 'learncard',
60
+ h.holdId,
61
+ didHashHex,
62
+ h.shareVersion,
63
+ h.blobHash,
64
+ h.enrollmentEpoch,
65
+ h.releasePolicy,
66
+ h.clientEphemeralPublicKey,
67
+ h.createdLo,
68
+ h.createdHi,
69
+ holdRecord.holdDurationMs,
70
+ h.policyVersion,
71
+ holdRecord.ledgerSeq,
72
+ ])
73
+ )
74
+ );
75
+ };
76
+ const frame = (value: Buffer): Buffer => {
77
+ const size = Buffer.alloc(4);
78
+ size.writeUInt32BE(value.length);
79
+ return Buffer.concat([size, value]);
80
+ };
81
+ const response = (value: unknown): Response => new Response(JSON.stringify(value), { status: 200 });
82
+
83
+ const setup = async (legacyEnrolled = false) => {
84
+ const pins = [
85
+ { pcr0: '01'.repeat(48), pcr1: '02'.repeat(48), pcr2: '03'.repeat(48) },
86
+ { pcr0: '04'.repeat(48), pcr1: '02'.repeat(48), pcr2: '03'.repeat(48) },
87
+ ];
88
+ const measurements = await escrowAuthorityMeasurements({
89
+ mode: 'nitro',
90
+ pinnedMeasurements: pins,
91
+ });
92
+ const keys = await generateEscrowKeyPair();
93
+ const ledger = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
94
+ 'sign',
95
+ 'verify',
96
+ ]);
97
+ const identity: VerifiedEnclaveIdentity = {
98
+ publicKey: keys.publicKey,
99
+ keyId: 'enclave',
100
+ previousKeyIds: [],
101
+ mode: 'nitro',
102
+ authority: {
103
+ ledgerPublicKey: Buffer.from(
104
+ await crypto.subtle.exportKey('raw', ledger.publicKey)
105
+ ).toString('base64'),
106
+ measurement: measurements[0]!,
107
+ },
108
+ };
109
+ vi.mocked(verifyEnclaveAttestation).mockResolvedValue(identity);
110
+ const sign = async (
111
+ kind: 'snapshot' | 'commit',
112
+ value: unknown,
113
+ measurement = identity.authority!.measurement
114
+ ): Promise<EscrowAuthorityRecord> => {
115
+ const payload = Buffer.from(JSON.stringify(value));
116
+ const message = Buffer.concat([
117
+ Buffer.from('learncard-authority-signed-record-v1\0'),
118
+ Buffer.from([kind === 'snapshot' ? 2 : 1]),
119
+ frame(Buffer.from(identity.keyId)),
120
+ Buffer.from(measurement, 'hex'),
121
+ frame(payload),
122
+ ]);
123
+ const sig = Buffer.from(
124
+ await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, ledger.privateKey, message)
125
+ );
126
+ const s = BigInt(`0x${sig.subarray(32).toString('hex')}`);
127
+ if (s > order / 2n)
128
+ Buffer.from((order - s).toString(16).padStart(64, '0'), 'hex').copy(sig, 32);
129
+ return {
130
+ version: 1,
131
+ kind,
132
+ keyId: identity.keyId,
133
+ measurement: Array.from(Buffer.from(measurement, 'hex')),
134
+ payload: payload.toString('base64'),
135
+ signature: sig.toString('base64'),
136
+ };
137
+ };
138
+ let state: EscrowAuthorityState = {
139
+ tenant: 'learncard',
140
+ authorityGeneration: 'fresh-g2',
141
+ did: owner.intent.did,
142
+ enrollmentVersion: 0,
143
+ enrollmentEpoch: 0,
144
+ status: 'empty',
145
+ envelope: null,
146
+ shareVersion: null,
147
+ hasPin: false,
148
+ pinGeneration: 0,
149
+ attemptsUsed: 0,
150
+ };
151
+ let version = 1;
152
+ const recoveryShares = await splitPrivateKey(owner.seedHex);
153
+ let authShare = recoveryShares.authShare;
154
+ let pendingHold: EscrowHoldRecordLike | undefined;
155
+ let confirmed = false;
156
+ let enrolled = legacyEnrolled;
157
+ let device: string | null = null;
158
+ let localVersion: number | null = null;
159
+ const storage: SSSStorageFunctions = {
160
+ getDeviceShare: async () => device,
161
+ hasDeviceShare: async () => device !== null,
162
+ storeDeviceShare: async share => {
163
+ device = share;
164
+ },
165
+ clearAllShares: async () => {
166
+ device = null;
167
+ localVersion = null;
168
+ },
169
+ deleteDeviceShare: async () => {
170
+ device = null;
171
+ localVersion = null;
172
+ },
173
+ getShareVersion: async () => localVersion,
174
+ storeShareVersion: async v => {
175
+ localVersion = v;
176
+ },
177
+ };
178
+ const calls: string[] = [];
179
+ const nonces: string[] = [];
180
+ const intents: SignedEscrowEnrollmentIntent[] = [];
181
+ const faults: {
182
+ snapshotNonce?: boolean;
183
+ returnedEnvelope?: boolean;
184
+ beforeSnapshot?: () => Promise<void>;
185
+ startHold?: Partial<EscrowHoldRecordLike['hold']>;
186
+ statusHold?: Partial<EscrowHoldRecordLike['hold']>;
187
+ confirmationHold?: Partial<EscrowHoldRecordLike['hold']>;
188
+ freshMeasurement?: string;
189
+ generation?: string;
190
+ confirmationGeneration?: string;
191
+ snapshotOnly?: boolean;
192
+ legacySnapshot?: boolean;
193
+ releaseDid?: string;
194
+ } = {};
195
+ const signHold = async (record: EscrowHoldRecordLike): Promise<EscrowHoldRecordLike> => {
196
+ const h = record.hold;
197
+ const receipt = await sign('commit', {
198
+ commit: {
199
+ schemaVersion: 1,
200
+ binding: {
201
+ tenant: 'learncard',
202
+ generation: faults.generation ?? state.authorityGeneration,
203
+ didHash: await digest(Buffer.from(h.did)),
204
+ },
205
+ executionId: Array.from({ length: 16 }, () => 1),
206
+ revisions: { operation: 1, enrollment: 1 },
207
+ transactionDigest: Array.from({ length: 32 }, () => 2),
208
+ },
209
+ eventHash: Array.from({ length: 32 }, () => 3),
210
+ requestDigest: await holdTupleDigest(record),
211
+ resultBlobIdentity: Array.from(Buffer.from(h.blobHash, 'hex')),
212
+ enrollmentEpoch: h.enrollmentEpoch,
213
+ });
214
+ return {
215
+ ...record,
216
+ hold: { ...h, signature: Buffer.from(JSON.stringify(receipt)).toString('base64') },
217
+ };
218
+ };
219
+ vi.stubGlobal(
220
+ 'fetch',
221
+ vi.fn(async (url: string, init?: RequestInit) => {
222
+ const path = new URL(url).pathname;
223
+ calls.push(`${init?.method}:${path}`);
224
+ const body: Record<string, unknown> = JSON.parse(String(init?.body ?? '{}'));
225
+ if (path === '/keys/challenge') return response({ challenge: crypto.randomUUID() });
226
+ if (path === '/keys/auth-share') {
227
+ if (init?.method === 'PUT') {
228
+ authShare = (body.authShare as { encryptedData: string }).encryptedData;
229
+ version++;
230
+ enrolled = false;
231
+ return response({ shareVersion: version, expectedShareVersionChecked: true });
232
+ }
233
+ return response({
234
+ primaryDid: owner.intent.did,
235
+ shareVersion: version,
236
+ authShare: { encryptedData: authShare, encryptedDek: '', iv: '' },
237
+ recoveryMethods: enrolled
238
+ ? [
239
+ {
240
+ type: 'escrow',
241
+ shareVersion: version,
242
+ confirmedAt: new Date().toISOString(),
243
+ },
244
+ ]
245
+ : [],
246
+ });
247
+ }
248
+ if (path === '/keys/escrow/attestation') return response({ attestation: {} });
249
+ if (path === '/keys/escrow/authority-state') {
250
+ const nonce = String(body.nonce);
251
+ nonces.push(nonce);
252
+ await faults.beforeSnapshot?.();
253
+ return response({
254
+ state,
255
+ proof: await sign('snapshot', [
256
+ faults.snapshotNonce ? '00'.repeat(32) : nonce,
257
+ state,
258
+ ]),
259
+ });
260
+ }
261
+ if (path === '/keys/escrow' || path === '/keys/escrow/disable') {
262
+ expect(init?.method).toBe('POST');
263
+ const signed = body.signedIntent as SignedEscrowEnrollmentIntent;
264
+ expect(
265
+ await verifyAsync(
266
+ Buffer.from(signed.signature, 'base64'),
267
+ encodeEscrowEnrollmentIntent(signed.intent),
268
+ await getPublicKeyAsync(owner.seedHex),
269
+ { zip215: false }
270
+ )
271
+ ).toBe(true);
272
+ expect(signed.intent.expectedEnrollmentVersion).toBe(state.enrollmentVersion);
273
+ intents.push(signed);
274
+ let output: EscrowEnvelope | null = null;
275
+ if (path === '/keys/escrow') {
276
+ const plaintext = await decryptEscrowBlob(
277
+ parseEscrowEnvelope(body.envelope),
278
+ keys.privateKey
279
+ );
280
+ if (signed.intent.action === 'carry')
281
+ plaintext.pinVerifier = (
282
+ await decryptEscrowBlob(state.envelope!, keys.privateKey)
283
+ ).pinVerifier;
284
+ output = await encryptEscrowBlob(plaintext, keys.publicKey, identity.keyId);
285
+ }
286
+ const pinChange = signed.intent.pinAction !== 'keep';
287
+ state = {
288
+ ...state,
289
+ enrollmentVersion: state.enrollmentVersion + 1,
290
+ enrollmentEpoch: signed.intent.enrollmentEpoch,
291
+ status: output ? 'active' : 'disabled',
292
+ envelope: output,
293
+ shareVersion: output ? version : null,
294
+ hasPin: pinChange ? signed.intent.pinAction !== 'clear' : state.hasPin,
295
+ pinGeneration: state.pinGeneration + Number(pinChange),
296
+ attemptsUsed: pinChange ? 0 : state.attemptsUsed,
297
+ };
298
+ enrolled = !!output;
299
+ const receipt = await sign('commit', {
300
+ commit: {
301
+ schemaVersion: 1,
302
+ binding: {
303
+ tenant: state.tenant,
304
+ generation: state.authorityGeneration,
305
+ didHash: await digest(Buffer.from(state.did)),
306
+ },
307
+ executionId: Array.from({ length: 16 }, () => 1),
308
+ revisions: {
309
+ operation: state.enrollmentVersion,
310
+ enrollment: state.enrollmentVersion,
311
+ },
312
+ transactionDigest: Array.from({ length: 32 }, () => 2),
313
+ },
314
+ eventHash: Array.from({ length: 32 }, () => 3),
315
+ requestDigest: await digest(encodeEscrowEnrollmentIntent(signed.intent)),
316
+ resultBlobIdentity: output
317
+ ? Array.from(Buffer.from(await getEscrowBlobIdentity(output), 'hex'))
318
+ : null,
319
+ enrollmentEpoch: state.enrollmentEpoch,
320
+ });
321
+ return response({
322
+ success: true,
323
+ shareVersion: version,
324
+ envelope: faults.returnedEnvelope ? body.envelope : output,
325
+ receipt,
326
+ });
327
+ }
328
+ if (path === '/keys/escrow/recover') {
329
+ // [D] A real nitro-mode hold is always an enclave-signed
330
+ // SignedHoldRecord bound to the caller's own recipient key —
331
+ // construct one with the same ledger key `sign()` uses above
332
+ // so startEscrowRecovery's client-side verification passes.
333
+ const holdRecord: EscrowHoldRecordLike = {
334
+ hold: {
335
+ holdId: 'hold',
336
+ did: owner.intent.did,
337
+ shareVersion: version,
338
+ blobHash: 'ab'.repeat(32),
339
+ enrollmentEpoch: 1,
340
+ releasePolicy: (body.releasePolicy as 'hold' | 'pin' | undefined) ?? 'hold',
341
+ clientEphemeralPublicKey: String(body.clientEphemeralPublicKey),
342
+ createdLo: Date.now(),
343
+ createdHi: Date.now(),
344
+ policyVersion: 2,
345
+ signature: '',
346
+ ...faults.startHold,
347
+ },
348
+ holdDurationMs: 604800000,
349
+ ledgerSeq: 0,
350
+ };
351
+ const h = holdRecord.hold;
352
+ const receipt = await sign('commit', {
353
+ commit: {
354
+ schemaVersion: 1,
355
+ binding: {
356
+ tenant: 'learncard',
357
+ generation: state.authorityGeneration,
358
+ didHash: await digest(Buffer.from(h.did)),
359
+ },
360
+ executionId: Array.from({ length: 16 }, () => 1),
361
+ revisions: { operation: 1, enrollment: 1 },
362
+ transactionDigest: Array.from({ length: 32 }, () => 2),
363
+ },
364
+ eventHash: Array.from({ length: 32 }, () => 3),
365
+ requestDigest: await holdTupleDigest(holdRecord),
366
+ resultBlobIdentity: Array.from(Buffer.from(h.blobHash, 'hex')),
367
+ enrollmentEpoch: h.enrollmentEpoch,
368
+ });
369
+ holdRecord.hold.signature = Buffer.from(JSON.stringify(receipt)).toString('base64');
370
+ pendingHold = holdRecord;
371
+ return response({
372
+ holdId: 'hold',
373
+ resumeToken: 'resume',
374
+ status: 'pending',
375
+ requestedAt: new Date().toISOString(),
376
+ releaseAfter: new Date(Date.now() + 604800000).toISOString(),
377
+ holdRecord,
378
+ });
379
+ }
380
+ if (path === '/keys/escrow/status') {
381
+ if (!pendingHold) throw new Error('No test hold');
382
+ const holdRecord =
383
+ faults.statusHold || faults.generation
384
+ ? await signHold({
385
+ ...pendingHold,
386
+ hold: { ...pendingHold.hold, ...faults.statusHold },
387
+ })
388
+ : pendingHold;
389
+ return response({
390
+ hold: {
391
+ holdId: 'hold',
392
+ status: 'pending',
393
+ releasePolicy: 'hold',
394
+ requestedAt: new Date().toISOString(),
395
+ releaseAfter: new Date().toISOString(),
396
+ requiresConfirmation: true,
397
+ confirmed,
398
+ holdRecord,
399
+ },
400
+ });
401
+ }
402
+ if (path === '/keys/escrow/confirm') {
403
+ if (!pendingHold) throw new Error('No test hold');
404
+ const record = {
405
+ ...pendingHold,
406
+ hold: { ...pendingHold.hold, ...faults.confirmationHold },
407
+ };
408
+ const h = record.hold;
409
+ if (confirmed || faults.snapshotOnly)
410
+ return response({
411
+ success: true,
412
+ receipt: await sign(
413
+ 'snapshot',
414
+ [
415
+ faults.legacySnapshot
416
+ ? 'learncard-authority-hold-snapshot-v1'
417
+ : 'learncard-authority-hold-snapshot-v2',
418
+ h.holdId,
419
+ await holdTupleDigest(record),
420
+ 'pending',
421
+ Date.now(),
422
+ null,
423
+ ...(faults.legacySnapshot
424
+ ? []
425
+ : [faults.confirmationGeneration ?? state.authorityGeneration]),
426
+ ],
427
+ faults.freshMeasurement
428
+ ),
429
+ });
430
+ confirmed = true;
431
+ return response({
432
+ success: true,
433
+ receipt: await sign(
434
+ 'commit',
435
+ {
436
+ commit: {
437
+ schemaVersion: 1,
438
+ binding: {
439
+ tenant: 'learncard',
440
+ generation:
441
+ faults.confirmationGeneration ?? state.authorityGeneration,
442
+ didHash: await digest(Buffer.from(h.did)),
443
+ },
444
+ executionId: Array.from({ length: 16 }, () => 1),
445
+ revisions: { operation: 2, enrollment: 1 },
446
+ transactionDigest: Array.from({ length: 32 }, () => 2),
447
+ },
448
+ eventHash: Array.from({ length: 32 }, () => 3),
449
+ requestDigest: Array.from({ length: 32 }, () => 4),
450
+ resultBlobIdentity: Array.from(Buffer.from(h.blobHash, 'hex')),
451
+ enrollmentEpoch: h.enrollmentEpoch,
452
+ },
453
+ faults.freshMeasurement
454
+ ),
455
+ });
456
+ }
457
+ if (path === '/keys/escrow/complete') {
458
+ if (!pendingHold) throw new Error('No test hold');
459
+ return response({
460
+ sealedShare: await sealEscrowRelease(
461
+ {
462
+ did: faults.releaseDid ?? owner.intent.did,
463
+ holdId: pendingHold.hold.holdId,
464
+ shareVersion: version,
465
+ recoveryShare: recoveryShares.recoveryShare,
466
+ },
467
+ pendingHold.hold.clientEphemeralPublicKey
468
+ ),
469
+ primaryDid: faults.releaseDid ?? owner.intent.did,
470
+ shareVersion: version,
471
+ authShare: { encryptedData: authShare, encryptedDek: '', iv: '' },
472
+ rebindSessionToken: 'rebind',
473
+ });
474
+ }
475
+ if (path === '/keys/escrow/opt-in') return response({ success: true });
476
+ throw new Error('Unexpected test request');
477
+ })
478
+ );
479
+ const config: SSSStrategyConfig = {
480
+ serverUrl: 'https://example.test',
481
+ storage,
482
+ tenantId: 'learncard',
483
+ escrow: {
484
+ enabled: true,
485
+ attestation: {
486
+ mode: 'nitro',
487
+ pinnedMeasurements: pins,
488
+ },
489
+ },
490
+ };
491
+ const strategy = createSSSStrategy(config);
492
+ const params = {
493
+ token: 'token',
494
+ providerType: 'firebase' as const,
495
+ privateKey: owner.seedHex,
496
+ signDidAuthVp: async () => 'vp',
497
+ // P9.1 (attack C): nitro/authority-mode enroll/replace now requires both.
498
+ options: { recoveryEmail: 'owner@example.com', recoveryLanguage: 'en' },
499
+ };
500
+ return {
501
+ strategy,
502
+ params,
503
+ calls,
504
+ nonces,
505
+ intents,
506
+ faults,
507
+ config,
508
+ identity,
509
+ measurements,
510
+ reload: () => createSSSStrategy(config),
511
+ state: () => state,
512
+ advance: () => {
513
+ version++;
514
+ enrolled = false;
515
+ },
516
+ spend: () => {
517
+ state.attemptsUsed = 4;
518
+ },
519
+ };
520
+ };
521
+
522
+ afterEach(() => {
523
+ vi.unstubAllGlobals();
524
+ vi.restoreAllMocks();
525
+ });
526
+
527
+ describe('Nitro recovery initiating-client bindings and measurement rollover', () => {
528
+ const start = (f: Awaited<ReturnType<typeof setup>>) =>
529
+ f.strategy.startEscrowRecovery!({
530
+ token: 'token',
531
+ providerType: 'firebase',
532
+ expectedDid: owner.intent.did,
533
+ });
534
+ const persisted = (value: Awaited<ReturnType<typeof start>>): EscrowRecoveryStart =>
535
+ JSON.parse(JSON.stringify(value));
536
+ const proof = (value: EscrowRecoveryStart) => ({
537
+ holdId: value.holdId,
538
+ resumeToken: value.resumeToken!,
539
+ expectations: value.expectations,
540
+ });
541
+ const confirm = (f: Awaited<ReturnType<typeof setup>>, value: EscrowRecoveryStart) =>
542
+ f.reload().confirmEscrowRecovery!({ ...proof(value), code: '123456' });
543
+ const restore = (f: Awaited<ReturnType<typeof setup>>, value: EscrowRecoveryStart) =>
544
+ f.reload().executeRecovery({
545
+ token: '',
546
+ providerType: 'firebase',
547
+ input: {
548
+ method: 'escrow',
549
+ ...proof(value),
550
+ clientEphemeralPrivateKey: value.clientEphemeralPrivateKey,
551
+ },
552
+ didFromPrivateKey: async () => owner.intent.did,
553
+ });
554
+
555
+ it('starts, confirms, persists/reloads, checks status and restores with retained bindings', async () => {
556
+ const f = await setup();
557
+ const saved = persisted(await start(f));
558
+ await expect(confirm(f, saved)).resolves.toEqual({ success: true });
559
+ expect(f.calls.filter(c => c === 'POST:/keys/escrow/confirm')).toHaveLength(2);
560
+ await expect(f.reload().getEscrowRecoveryStatus!(proof(saved))).resolves.toMatchObject({
561
+ holdId: saved.holdId,
562
+ });
563
+ await expect(restore(f, saved)).resolves.toEqual({
564
+ privateKey: owner.seedHex,
565
+ did: owner.intent.did,
566
+ });
567
+ });
568
+
569
+ it.each([
570
+ ['hold ID', { holdId: 'other' }],
571
+ ['recipient key', { clientEphemeralPublicKey: 'other' }],
572
+ ['DID', { did: 'did:key:other' }],
573
+ ] as const)('rejects a validly signed start for another %s', async (_, substitution) => {
574
+ const f = await setup();
575
+ f.faults.startHold = substitution;
576
+ await expect(start(f)).rejects.toMatchObject({ name: 'EscrowEvidenceError' });
577
+ expect(f.calls).not.toContain('POST:/keys/escrow/confirm');
578
+ expect(f.calls).not.toContain('POST:/keys/escrow/complete');
579
+ });
580
+
581
+ it.each([
582
+ ['hold ID', { holdId: 'other' }],
583
+ ['recipient key', { clientEphemeralPublicKey: 'other' }],
584
+ ['DID', { did: 'did:key:other' }],
585
+ ['blob identity', { blobHash: 'cd'.repeat(32) }],
586
+ ['hold revision', { createdHi: 42 }],
587
+ ] as const)(
588
+ 'rejects substituted %s on confirm, status and restore after reload',
589
+ async (_, substitution) => {
590
+ const f = await setup();
591
+ const saved = persisted(await start(f));
592
+ f.faults.statusHold = substitution;
593
+ // Simulates persisted server-display data being replaced without changing
594
+ // the separate client expectations saved together with the private key.
595
+ if (saved.holdRecord) Object.assign(saved.holdRecord.hold, substitution);
596
+ await expect(confirm(f, saved)).rejects.toMatchObject({ name: 'EscrowEvidenceError' });
597
+ await expect(f.reload().getEscrowRecoveryStatus!(proof(saved))).rejects.toMatchObject({
598
+ name: 'EscrowEvidenceError',
599
+ });
600
+ await expect(restore(f, saved)).rejects.toMatchObject({ name: 'EscrowEvidenceError' });
601
+ expect(f.calls).not.toContain('POST:/keys/escrow/confirm');
602
+ expect(f.calls).not.toContain('POST:/keys/escrow/complete');
603
+ }
604
+ );
605
+
606
+ it.each([
607
+ ['hold ID', { holdId: 'other' }],
608
+ ['recipient key', { clientEphemeralPublicKey: 'other' }],
609
+ ] as const)('rejects confirmation readback bound to another %s', async (_, substitution) => {
610
+ const f = await setup();
611
+ const saved = persisted(await start(f));
612
+ f.faults.confirmationHold = substitution;
613
+ await expect(confirm(f, saved)).rejects.toMatchObject({ name: 'EscrowEvidenceError' });
614
+ expect(f.calls).not.toContain('POST:/keys/escrow/complete');
615
+ });
616
+
617
+ it('rejects missing initiating DID or missing persisted expectations without network calls', async () => {
618
+ const f = await setup();
619
+ await expect(
620
+ f.strategy.startEscrowRecovery!({ token: 'token', providerType: 'firebase' })
621
+ ).rejects.toMatchObject({ name: 'EscrowEvidenceError' });
622
+ expect(f.calls).toHaveLength(0);
623
+ const saved = persisted(await start(f));
624
+ saved.expectations = undefined;
625
+ f.calls.length = 0;
626
+ await expect(confirm(f, saved)).rejects.toMatchObject({ name: 'EscrowEvidenceError' });
627
+ await expect(restore(f, saved)).rejects.toMatchObject({ name: 'EscrowEvidenceError' });
628
+ expect(f.calls).toHaveLength(0);
629
+ });
630
+
631
+ it('rejects replacing the requested hold ID while retaining original expectations', async () => {
632
+ const f = await setup();
633
+ const saved = persisted(await start(f));
634
+ saved.holdId = 'other';
635
+ f.calls.length = 0;
636
+ await expect(confirm(f, saved)).rejects.toMatchObject({ name: 'EscrowEvidenceError' });
637
+ expect(f.calls).toHaveLength(0);
638
+ });
639
+
640
+ it('starts under M1 and confirms/restores after M2 upgrade with both measurements allowed', async () => {
641
+ const f = await setup();
642
+ const saved = persisted(await start(f));
643
+ f.identity.authority!.measurement = f.measurements[1]!;
644
+ await expect(confirm(f, saved)).resolves.toEqual({ success: true });
645
+ await expect(f.reload().getEscrowRecoveryStatus!(proof(saved))).resolves.toBeTruthy();
646
+ await expect(restore(f, saved)).resolves.toMatchObject({ did: owner.intent.did });
647
+ });
648
+
649
+ it('rejects historical M1 when the configured allowlist contains only M2', async () => {
650
+ const f = await setup();
651
+ const saved = persisted(await start(f));
652
+ f.identity.authority!.measurement = f.measurements[1]!;
653
+ if (f.config.escrow?.attestation.mode === 'nitro')
654
+ f.config.escrow.attestation.pinnedMeasurements.shift();
655
+ await expect(confirm(f, saved)).rejects.toMatchObject({ name: 'EscrowEvidenceError' });
656
+ expect(f.calls).not.toContain('POST:/keys/escrow/confirm');
657
+ });
658
+
659
+ it('rejects fresh M1 confirmation while current attestation is M2 even with both allowed', async () => {
660
+ const f = await setup();
661
+ const saved = persisted(await start(f));
662
+ f.identity.authority!.measurement = f.measurements[1]!;
663
+ f.faults.freshMeasurement = f.measurements[0]!;
664
+ await expect(confirm(f, saved)).rejects.toMatchObject({ name: 'EscrowEvidenceError' });
665
+ expect(f.calls.filter(c => c === 'POST:/keys/escrow/confirm')).toHaveLength(1);
666
+ expect(f.calls).not.toContain('POST:/keys/escrow/complete');
667
+ });
668
+
669
+ it('rejects a different current ledger signing key', async () => {
670
+ const f = await setup();
671
+ const saved = persisted(await start(f));
672
+ const other = await crypto.subtle.generateKey(
673
+ { name: 'ECDSA', namedCurve: 'P-256' },
674
+ true,
675
+ ['sign', 'verify']
676
+ );
677
+ f.identity.authority!.ledgerPublicKey = Buffer.from(
678
+ await crypto.subtle.exportKey('raw', other.publicKey)
679
+ ).toString('base64');
680
+ await expect(confirm(f, saved)).rejects.toMatchObject({ name: 'EscrowEvidenceError' });
681
+ expect(f.calls).not.toContain('POST:/keys/escrow/confirm');
682
+ });
683
+
684
+ it('accepts an attested previous key ID only while the ledger signing key is unchanged', async () => {
685
+ const f = await setup();
686
+ const saved = persisted(await start(f));
687
+ f.identity.previousKeyIds = [f.identity.keyId];
688
+ f.identity.keyId = 'renamed-key';
689
+ f.identity.authority!.measurement = f.measurements[1]!;
690
+ await expect(confirm(f, saved)).resolves.toEqual({ success: true });
691
+ });
692
+
693
+ it('rejects an original key ID absent from current and attested previous IDs', async () => {
694
+ const f = await setup();
695
+ const saved = persisted(await start(f));
696
+ f.identity.keyId = 'other-key';
697
+ await expect(confirm(f, saved)).rejects.toMatchObject({ name: 'EscrowEvidenceError' });
698
+ expect(f.calls).not.toContain('POST:/keys/escrow/confirm');
699
+ });
700
+
701
+ it('rejects a changed authority generation under the same key and permitted measurement', async () => {
702
+ const f = await setup();
703
+ const saved = persisted(await start(f));
704
+ f.faults.generation = 'other-generation';
705
+ await expect(confirm(f, saved)).rejects.toMatchObject({ name: 'EscrowEvidenceError' });
706
+ expect(f.calls).not.toContain('POST:/keys/escrow/confirm');
707
+ });
708
+
709
+ it('checks restored DID against retained expectations, not the release response', async () => {
710
+ const f = await setup();
711
+ const saved = persisted(await start(f));
712
+ f.faults.releaseDid = 'did:key:other';
713
+ await expect(restore(f, saved)).rejects.toMatchObject({ name: 'EscrowEvidenceError' });
714
+ expect(f.calls.some(c => c.includes('rebind'))).toBe(false);
715
+ });
716
+
717
+ it.each([false, true])(
718
+ 'rejects a different fresh confirmation generation (snapshot-only: %s)',
719
+ async snapshotOnly => {
720
+ const f = await setup();
721
+ const saved = persisted(await start(f));
722
+ f.faults.snapshotOnly = snapshotOnly;
723
+ f.faults.confirmationGeneration = 'other-generation';
724
+ await expect(confirm(f, saved)).rejects.toMatchObject({ name: 'EscrowEvidenceError' });
725
+ expect(f.calls.filter(c => c === 'POST:/keys/escrow/confirm')).toHaveLength(1);
726
+ expect(f.calls).not.toContain('POST:/keys/escrow/complete');
727
+ }
728
+ );
729
+
730
+ it('rejects legacy snapshots without a signed authority generation', async () => {
731
+ const f = await setup();
732
+ const saved = persisted(await start(f));
733
+ f.faults.snapshotOnly = true;
734
+ f.faults.legacySnapshot = true;
735
+ await expect(confirm(f, saved)).rejects.toMatchObject({ name: 'EscrowEvidenceError' });
736
+ });
737
+
738
+ it('accepts generation-bound snapshot-only confirmation retries after reload', async () => {
739
+ const f = await setup();
740
+ const saved = persisted(await start(f));
741
+ await confirm(f, saved);
742
+ await expect(confirm(f, saved)).resolves.toEqual({ success: true });
743
+ expect(f.calls.filter(c => c === 'POST:/keys/escrow/confirm')).toHaveLength(3);
744
+ });
745
+ });
746
+ describe('Nitro owner enrollment flow', () => {
747
+ it('reenrolls disposable staging state despite Mongo claiming enrollment, using fresh nonces and the persisted output', async () => {
748
+ const f = await setup(true);
749
+ await expect(f.strategy.ensureEscrowEnrollment!(f.params)).resolves.toMatchObject({
750
+ enrolled: true,
751
+ changed: true,
752
+ });
753
+ expect(f.intents[0]?.intent).toMatchObject({
754
+ action: 'enroll',
755
+ pinAction: 'keep',
756
+ authorityGeneration: 'fresh-g2',
757
+ expectedEnrollmentVersion: 0,
758
+ enrollmentEpoch: 1,
759
+ });
760
+ expect(f.strategy.getVerifiedEscrowEnvelope!()).toEqual(f.state().envelope);
761
+ const writes = f.calls.filter(c => c === 'PUT:/keys/auth-share').length;
762
+ await expect(f.strategy.ensureEscrowEnrollment!(f.params)).resolves.toEqual({
763
+ enrolled: true,
764
+ changed: false,
765
+ });
766
+ expect(f.calls.filter(c => c === 'PUT:/keys/auth-share')).toHaveLength(writes);
767
+ expect(new Set(f.nonces).size).toBe(f.nonces.length);
768
+ expect(f.nonces.length).toBeGreaterThan(1);
769
+ });
770
+ it('signs PIN set/change/clear, carry and disable without replenishing kept budgets', async () => {
771
+ const f = await setup();
772
+ await f.strategy.setEscrowPin!({ ...f.params, pin: '593827' });
773
+ expect(f.intents.at(-1)?.intent.pinAction).toBe('set');
774
+ f.spend();
775
+ f.advance();
776
+ await f.strategy.ensureEscrowEnrollment!(f.params);
777
+ expect(f.intents.at(-1)?.intent).toMatchObject({ action: 'carry', pinAction: 'keep' });
778
+ expect(f.state().attemptsUsed).toBe(4);
779
+ await f.strategy.setEscrowPin!({ ...f.params, pin: '824957' });
780
+ expect(f.intents.at(-1)?.intent.pinAction).toBe('change');
781
+ await f.strategy.clearEscrowPin!(f.params);
782
+ expect(f.intents.at(-1)?.intent.pinAction).toBe('clear');
783
+ await f.strategy.disableEscrowRecovery!(f.params);
784
+ expect(f.intents.at(-1)?.intent).toMatchObject({
785
+ action: 'disable',
786
+ pinAction: 'keep',
787
+ allowRewrap: false,
788
+ });
789
+ expect(f.calls).toContain('POST:/keys/escrow/disable');
790
+ expect(f.calls.some(c => c.startsWith('DELETE:'))).toBe(false);
791
+ expect(f.strategy.getVerifiedEscrowEnvelope!()).toBeNull();
792
+ });
793
+ it('rejects a stale signed Snapshot before rotating shares or issuing intent', async () => {
794
+ const f = await setup();
795
+ f.faults.snapshotNonce = true;
796
+ await expect(f.strategy.ensureEscrowEnrollment!(f.params)).rejects.toThrow();
797
+ expect(f.calls).not.toContain('PUT:/keys/auth-share');
798
+ expect(f.intents).toHaveLength(0);
799
+ });
800
+ it('refuses Nitro without an attested ledger key rather than downgrading to unsigned enrollment', async () => {
801
+ const f = await setup();
802
+ const verified = vi.mocked(verifyEnclaveAttestation);
803
+ const key = await verified({}, { mode: 'nitro', pinnedMeasurements: [] });
804
+ verified.mockResolvedValue({ ...key, authority: undefined });
805
+ await expect(f.strategy.ensureEscrowEnrollment!(f.params)).rejects.toThrow();
806
+ expect(f.calls).not.toContain('PUT:/keys/auth-share');
807
+ expect(f.intents).toHaveLength(0);
808
+ });
809
+ it('does not automatically resurrect disabled authority even if Mongo loses opt-out, and explicit enable signs the PIN clear', async () => {
810
+ const f = await setup();
811
+ await f.strategy.setEscrowPin!({ ...f.params, pin: '593827' });
812
+ await f.strategy.disableEscrowRecovery!(f.params);
813
+ expect(await f.strategy.ensureEscrowEnrollment!(f.params)).toEqual({
814
+ enrolled: false,
815
+ reason: 'opted-out',
816
+ });
817
+ expect(f.intents).toHaveLength(2);
818
+ await f.strategy.disableEscrowRecovery!(f.params);
819
+ expect(f.intents).toHaveLength(2);
820
+ await f.strategy.enableEscrowRecovery!(f.params);
821
+ expect(f.intents.at(-1)?.intent).toMatchObject({ action: 'enroll', pinAction: 'clear' });
822
+ });
823
+ it('does not retain an unverified output and reconciles from a later verified Snapshot without another write', async () => {
824
+ const f = await setup();
825
+ f.faults.returnedEnvelope = true;
826
+ await expect(f.strategy.ensureEscrowEnrollment!(f.params)).rejects.toThrow();
827
+ expect(f.strategy.getVerifiedEscrowEnvelope!()).toBeNull();
828
+ f.faults.returnedEnvelope = false;
829
+ await expect(f.strategy.ensureEscrowEnrollment!(f.params)).resolves.toEqual({
830
+ enrolled: true,
831
+ changed: false,
832
+ });
833
+ expect(f.strategy.getVerifiedEscrowEnvelope!()).toEqual(f.state().envelope);
834
+ expect(f.intents).toHaveLength(1);
835
+ });
836
+ it('does not request the lost key or signed authority state to start recovery', async () => {
837
+ const f = await setup();
838
+ await f.strategy.startEscrowRecovery!({
839
+ expectedDid: owner.intent.did,
840
+ token: f.params.token,
841
+ providerType: f.params.providerType,
842
+ });
843
+ expect(f.nonces).toHaveLength(0);
844
+ expect(f.intents).toHaveLength(0);
845
+ expect(f.calls).toContain('POST:/keys/escrow/recover');
846
+ });
847
+ // Regression for the P1 finding: the no-provider identity-recovery flow
848
+ // (email verification, no signed-in device) authenticates to /recover with
849
+ // a recoverySessionToken instead of token/providerType, but Nitro mode
850
+ // still requires the independently-known expectedDid binding.
851
+ it('rejects a no-provider recovery-session start missing the independently-known DID', async () => {
852
+ const f = await setup();
853
+ await expect(
854
+ f.strategy.startEscrowRecovery!({ recoverySessionToken: 'session-token' })
855
+ ).rejects.toMatchObject({ name: 'EscrowEvidenceError' });
856
+ expect(f.calls).toHaveLength(0);
857
+ });
858
+ it('starts a Nitro hold via a no-provider recovery-session token bound to the verified DID', async () => {
859
+ const f = await setup();
860
+ const result = await f.strategy.startEscrowRecovery!({
861
+ recoverySessionToken: 'session-token',
862
+ expectedDid: owner.intent.did,
863
+ });
864
+ expect(f.calls).toContain('POST:/keys/escrow/recover');
865
+ expect(result.expectations).toMatchObject({ did: owner.intent.did, holdId: 'hold' });
866
+ });
867
+ it('aborts before mutation when the device is forgotten during state verification', async () => {
868
+ const f = await setup();
869
+ let entered!: () => void;
870
+ let resume!: () => void;
871
+ const started = new Promise<void>(resolve => {
872
+ entered = resolve;
873
+ });
874
+ const paused = new Promise<void>(resolve => {
875
+ resume = resolve;
876
+ });
877
+ f.faults.beforeSnapshot = async () => {
878
+ entered();
879
+ await paused;
880
+ };
881
+ const pending = f.strategy.ensureEscrowEnrollment!(f.params);
882
+ await started;
883
+ await f.strategy.clearLocalKeys();
884
+ resume();
885
+ await expect(pending).rejects.toThrow('cancelled');
886
+ expect(f.calls).not.toContain('PUT:/keys/auth-share');
887
+ expect(f.intents).toHaveLength(0);
888
+ });
889
+ });