@learncard/sss-key-manager 0.2.1 → 0.2.3

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 (46) 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 +138 -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 +1652 -214
  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 +1167 -164
  22. package/dist/sss-key-manager.esm.js.map +4 -4
  23. package/dist/sss-strategy.d.ts +2 -1
  24. package/dist/sss-strategy.d.ts.map +1 -1
  25. package/dist/types.d.ts +21 -1
  26. package/dist/types.d.ts.map +1 -1
  27. package/package.json +2 -2
  28. package/src/__fixtures__/escrow-authority-v1.json +96 -0
  29. package/src/__fixtures__/escrow-carry-contact-contract-v1.json +33 -0
  30. package/src/__fixtures__/escrow-enrollment-intent-v1.json +26 -0
  31. package/src/__fixtures__/nitro-attestation-real/staging-nsm-document.json +16 -0
  32. package/src/escrow-attestation.ts +10 -9
  33. package/src/escrow-authority.test.ts +757 -0
  34. package/src/escrow-authority.ts +671 -0
  35. package/src/escrow-authority.types.ts +56 -0
  36. package/src/escrow-crypto.ts +39 -1
  37. package/src/escrow-enrollment-intent.test.ts +224 -0
  38. package/src/escrow-enrollment-intent.ts +236 -0
  39. package/src/escrow-enrollment-intent.types.ts +28 -0
  40. package/src/escrow-nitro-attestation.test.ts +72 -0
  41. package/src/escrow-nitro-attestation.ts +34 -3
  42. package/src/index.ts +29 -0
  43. package/src/sss-authority.test.ts +889 -0
  44. package/src/sss-strategy.test.ts +32 -0
  45. package/src/sss-strategy.ts +503 -39
  46. package/src/types.ts +27 -3
@@ -0,0 +1,757 @@
1
+ // @vitest-environment node
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { decode, encode } from 'cborg';
4
+ import { verifyEnclaveAttestation } from './escrow-attestation';
5
+ import {
6
+ verifyEscrowAuthorityState,
7
+ verifyEscrowEnrollmentCommit,
8
+ verifyEscrowHoldRecord,
9
+ verifyEscrowCodeDelivery,
10
+ verifyEscrowHoldState,
11
+ verifyEscrowHoldConfirmation,
12
+ createEscrowEnrollmentIntent,
13
+ resolveEscrowEnrollmentAction,
14
+ violatesCarryContactRule,
15
+ type EscrowHoldRecordLike,
16
+ } from './escrow-authority';
17
+ import type { EscrowEnrollmentIntent } from './escrow-enrollment-intent.types';
18
+ import type { VerifiedEnclaveIdentity } from './escrow-authority.types';
19
+ import type { EscrowAttestationPolicy } from './types';
20
+ import { parseEscrowEnvelope } from './escrow-crypto';
21
+ import golden from './__fixtures__/escrow-authority-v1.json';
22
+ import owner from './__fixtures__/escrow-enrollment-intent-v1.json';
23
+ import carryContactContract from './__fixtures__/escrow-carry-contact-contract-v1.json';
24
+
25
+ const intent = golden.intent as EscrowEnrollmentIntent;
26
+ const policy: EscrowAttestationPolicy = {
27
+ mode: 'nitro',
28
+ rootCertificateSha256: golden.rootSha256,
29
+ pinnedMeasurements: [
30
+ { pcr0: golden.pcrs['0'], pcr1: golden.pcrs['1'], pcr2: golden.pcrs['2'] },
31
+ ],
32
+ };
33
+ const identity = async () => {
34
+ vi.spyOn(Date, 'now').mockReturnValue(golden.nowMs);
35
+ return verifyEnclaveAttestation(
36
+ golden.attestation,
37
+ policy,
38
+ new Uint8Array(Buffer.from(golden.nonce, 'hex'))
39
+ );
40
+ };
41
+
42
+ afterEach(() => vi.restoreAllMocks());
43
+ describe('attested authority artifacts', () => {
44
+ it('verifies Rust-produced NSM evidence, Snapshot and Commit without trusting wrapper keys', async () => {
45
+ const key = await identity();
46
+ expect(key.authority).toEqual({
47
+ ledgerPublicKey: golden.ledgerPublicKey,
48
+ measurement: golden.measurement,
49
+ });
50
+ const state = await verifyEscrowAuthorityState(
51
+ golden.snapshot,
52
+ golden.nonce,
53
+ 'learncard',
54
+ intent.did,
55
+ key
56
+ );
57
+ expect(state).toEqual(golden.snapshot.state);
58
+ expect(await verifyEscrowEnrollmentCommit(golden.committed, intent, key)).toEqual(
59
+ golden.committed
60
+ );
61
+ await expect(
62
+ verifyEnclaveAttestation(
63
+ { ...golden.attestation, ledgerPublicKey: 'attacker' },
64
+ policy,
65
+ Buffer.from(golden.nonce, 'hex')
66
+ )
67
+ ).rejects.toThrow();
68
+ const cose: unknown[] = decode(Buffer.from(golden.attestation.document, 'base64'), {
69
+ useMaps: true,
70
+ });
71
+ const payload: Map<string, unknown> = decode(cose[2] as Uint8Array, { useMaps: true });
72
+ payload.set('public_key', new Uint8Array(65));
73
+ cose[2] = encode(payload);
74
+ await expect(
75
+ verifyEnclaveAttestation(
76
+ { ...golden.attestation, document: Buffer.from(encode(cose)).toString('base64') },
77
+ policy,
78
+ Buffer.from(golden.nonce, 'hex')
79
+ )
80
+ ).rejects.toThrow();
81
+ });
82
+ it('rejects nonce reuse across challenges, scope substitution, unsigned state changes and missing anchors', async () => {
83
+ const key = await identity();
84
+ for (const [nonce, tenant, did] of [
85
+ ['11'.repeat(32), 'learncard', intent.did],
86
+ [golden.nonce, 'other', intent.did],
87
+ [golden.nonce, 'learncard', 'did:key:other'],
88
+ ]) {
89
+ await expect(
90
+ verifyEscrowAuthorityState(golden.snapshot, nonce, tenant, did, key)
91
+ ).rejects.toThrow();
92
+ }
93
+ await expect(
94
+ verifyEscrowAuthorityState(
95
+ { ...golden.snapshot, state: { ...golden.snapshot.state, hasPin: false } },
96
+ golden.nonce,
97
+ 'learncard',
98
+ intent.did,
99
+ key
100
+ )
101
+ ).rejects.toThrow();
102
+ await expect(
103
+ verifyEscrowAuthorityState(golden.snapshot, golden.nonce, 'learncard', intent.did, {
104
+ ...key,
105
+ authority: undefined,
106
+ })
107
+ ).rejects.toThrow();
108
+ await expect(
109
+ verifyEscrowAuthorityState(golden.snapshot, golden.nonce, 'learncard', intent.did, {
110
+ ...key,
111
+ mode: 'software',
112
+ })
113
+ ).rejects.toThrow();
114
+ });
115
+ it.each(['kind', 'keyId', 'measurement', 'payload', 'signature', 'version'])(
116
+ 'binds record %s and does not accept proposals as commits',
117
+ async field => {
118
+ const key = await identity();
119
+ const receipt: Record<string, unknown> = { ...golden.committed.receipt };
120
+ receipt[field] = (
121
+ {
122
+ kind: 'proposal',
123
+ keyId: 'old-key',
124
+ measurement: Array.from({ length: 32 }, () => 0),
125
+ payload: 'AQ==',
126
+ signature: Buffer.alloc(64).toString('base64'),
127
+ version: 2,
128
+ } as Record<string, unknown>
129
+ )[field];
130
+ await expect(
131
+ verifyEscrowEnrollmentCommit({ ...golden.committed, receipt }, intent, key)
132
+ ).rejects.toThrow();
133
+ }
134
+ );
135
+ it('rejects replay against another request, generation, target output or PIN semantics', async () => {
136
+ const key = await identity();
137
+ for (const changed of [
138
+ { requestId: 'other' },
139
+ { authorityGeneration: 'other' },
140
+ { expectedEnrollmentVersion: 8 },
141
+ { action: 'replace' as const, pinAction: 'clear' as const },
142
+ ]) {
143
+ await expect(
144
+ verifyEscrowEnrollmentCommit(golden.committed, { ...intent, ...changed }, key)
145
+ ).rejects.toThrow();
146
+ }
147
+ await expect(
148
+ verifyEscrowEnrollmentCommit(
149
+ { ...golden.committed, envelope: golden.snapshot.state.envelope },
150
+ intent,
151
+ key
152
+ )
153
+ ).rejects.toThrow();
154
+ const sig = Buffer.from(golden.committed.receipt.signature, 'base64');
155
+ const order = BigInt('0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551');
156
+ Buffer.from(
157
+ (order - BigInt(`0x${sig.subarray(32).toString('hex')}`))
158
+ .toString(16)
159
+ .padStart(64, '0'),
160
+ 'hex'
161
+ ).copy(sig, 32);
162
+ await expect(
163
+ verifyEscrowEnrollmentCommit(
164
+ {
165
+ ...golden.committed,
166
+ receipt: { ...golden.committed.receipt, signature: sig.toString('base64') },
167
+ },
168
+ intent,
169
+ key
170
+ )
171
+ ).rejects.toThrow();
172
+ });
173
+ it('returns captured verified receipt bytes despite caller mutation during crypto awaits', async () => {
174
+ const key = await identity();
175
+ const response = structuredClone(golden.committed);
176
+ const pending = verifyEscrowEnrollmentCommit(response, intent, key);
177
+ response.receipt.signature = Buffer.alloc(64).toString('base64');
178
+ expect((await pending).receipt.signature).toBe(golden.committed.receipt.signature);
179
+ });
180
+ it('selects enroll, carry, PIN set/change/clear and disable from verified state', async () => {
181
+ const key = await identity();
182
+ const current = await verifyEscrowAuthorityState(
183
+ golden.snapshot,
184
+ golden.nonce,
185
+ 'learncard',
186
+ intent.did,
187
+ key
188
+ );
189
+ const target = parseEscrowEnvelope(golden.committed.envelope);
190
+ const create = (pinAction: 'automatic' | 'set' | 'clear' | 'disable', state = current) =>
191
+ createEscrowEnrollmentIntent({
192
+ state,
193
+ target: pinAction === 'disable' ? null : target,
194
+ shareVersion: 42,
195
+ privateKey: owner.seedHex,
196
+ pinAction,
197
+ // P9.1: ignored by createEscrowEnrollmentIntent whenever the
198
+ // resolved action is disable/carry; required for enroll/replace.
199
+ recoveryEmail: 'owner@example.com',
200
+ recoveryLanguage: 'en',
201
+ });
202
+ expect((await create('automatic')).intent).toMatchObject({
203
+ action: 'carry',
204
+ pinAction: 'keep',
205
+ expectedEnrollmentVersion: 7,
206
+ enrollmentEpoch: 4,
207
+ });
208
+ expect((await create('set')).intent.pinAction).toBe('change');
209
+ expect((await create('clear')).intent.pinAction).toBe('clear');
210
+ expect((await create('disable')).intent).toMatchObject({
211
+ action: 'disable',
212
+ targetBlobIdentity: null,
213
+ enrollmentEpoch: 3,
214
+ allowRewrap: false,
215
+ });
216
+ expect(
217
+ (await create('set', { ...current, hasPin: false, attemptsUsed: 0 })).intent.pinAction
218
+ ).toBe('set');
219
+ const empty = {
220
+ ...current,
221
+ status: 'empty' as const,
222
+ enrollmentVersion: 0,
223
+ enrollmentEpoch: 0,
224
+ envelope: null,
225
+ shareVersion: null,
226
+ hasPin: false,
227
+ pinGeneration: 0,
228
+ attemptsUsed: 0,
229
+ };
230
+ expect((await create('automatic', empty)).intent).toMatchObject({
231
+ action: 'enroll',
232
+ pinAction: 'keep',
233
+ expectedEnrollmentVersion: 0,
234
+ enrollmentEpoch: 1,
235
+ });
236
+ await expect(
237
+ create('automatic', {
238
+ ...current,
239
+ status: 'disabled',
240
+ envelope: null,
241
+ shareVersion: null,
242
+ })
243
+ ).rejects.toThrow();
244
+ });
245
+ });
246
+
247
+ describe('resolveEscrowEnrollmentAction', () => {
248
+ it('resolves enroll/replace/carry/disable the same way createEscrowEnrollmentIntent does', () => {
249
+ expect(
250
+ resolveEscrowEnrollmentAction({ status: 'empty', hasPin: false }, 'automatic')
251
+ ).toEqual({ action: 'enroll', pinAction: 'keep', bindsRecoveryContact: true });
252
+ expect(
253
+ resolveEscrowEnrollmentAction({ status: 'active', hasPin: true }, 'automatic')
254
+ ).toEqual({ action: 'carry', pinAction: 'keep', bindsRecoveryContact: false });
255
+ expect(
256
+ resolveEscrowEnrollmentAction({ status: 'active', hasPin: false }, 'automatic')
257
+ ).toEqual({ action: 'replace', pinAction: 'keep', bindsRecoveryContact: true });
258
+ expect(resolveEscrowEnrollmentAction({ status: 'active', hasPin: true }, 'set')).toEqual({
259
+ action: 'replace',
260
+ pinAction: 'change',
261
+ bindsRecoveryContact: true,
262
+ });
263
+ expect(resolveEscrowEnrollmentAction({ status: 'active', hasPin: false }, 'set')).toEqual({
264
+ action: 'replace',
265
+ pinAction: 'set',
266
+ bindsRecoveryContact: true,
267
+ });
268
+ expect(resolveEscrowEnrollmentAction({ status: 'active', hasPin: true }, 'clear')).toEqual({
269
+ action: 'replace',
270
+ pinAction: 'clear',
271
+ bindsRecoveryContact: true,
272
+ });
273
+ expect(
274
+ resolveEscrowEnrollmentAction({ status: 'active', hasPin: true }, 'disable')
275
+ ).toEqual({
276
+ action: 'disable',
277
+ pinAction: 'keep',
278
+ bindsRecoveryContact: false,
279
+ });
280
+ });
281
+ });
282
+
283
+ describe('violatesCarryContactRule (P2-d cross-language contract)', () => {
284
+ it.each(
285
+ (
286
+ carryContactContract.cases as {
287
+ name: string;
288
+ action: 'enroll' | 'replace' | 'disable' | 'carry';
289
+ blobRecoveryEmail: string | null;
290
+ blobRecoveryLanguage: string | null;
291
+ expected: 'accept' | 'reject';
292
+ }[]
293
+ ).map(c => [c.name, c] as const)
294
+ )('%s', (_name, testCase) => {
295
+ const violates = violatesCarryContactRule(testCase.action, {
296
+ recoveryEmail: testCase.blobRecoveryEmail ?? undefined,
297
+ recoveryLanguage: testCase.blobRecoveryLanguage ?? undefined,
298
+ });
299
+ expect(violates).toBe(testCase.expected === 'reject');
300
+ });
301
+ });
302
+
303
+ // [D] Client-boundary recipient/hold binding. Signs synthetic records with a
304
+ // fresh test-only P-256 key using the exact byte scheme
305
+ // `SignedAuthorityRecord::signing_hash()`/`.verify()` use in
306
+ // escrow-enclave-app's record.rs, so these exercise the real signature
307
+ // verification path end to end rather than trusting an unsigned fixture.
308
+ describe('verifyEscrowHoldRecord / verifyEscrowHoldConfirmation (client-boundary binding)', () => {
309
+ const P256_ORDER = BigInt('0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551');
310
+ const frame = (value: Uint8Array): Uint8Array => {
311
+ const result = new Uint8Array(4 + value.length);
312
+ new DataView(result.buffer).setUint32(0, value.length, false);
313
+ result.set(value, 4);
314
+ return result;
315
+ };
316
+ const join = (...values: Uint8Array[]): Uint8Array => {
317
+ const result = new Uint8Array(values.reduce((n, v) => n + v.length, 0));
318
+ let offset = 0;
319
+ for (const value of values) {
320
+ result.set(value, offset);
321
+ offset += value.length;
322
+ }
323
+ return result;
324
+ };
325
+ const utf8 = (value: string): Uint8Array => new TextEncoder().encode(value);
326
+ const toByteArray = (buf: ArrayBuffer | Uint8Array): number[] =>
327
+ Array.from(new Uint8Array(buf));
328
+ // ECDSA signatures are malleable: (r, s) and (r, n-s) both verify for the
329
+ // same message. Rust's ledger rejects the non-canonical (high-S) form
330
+ // outright rather than normalizing it, and WebCrypto does not guarantee
331
+ // low-S output, so tests must pick the canonical form themselves.
332
+ const toLowS = (sig: Uint8Array): Uint8Array => {
333
+ const r = sig.slice(0, 32);
334
+ let s = BigInt(`0x${Buffer.from(sig.slice(32)).toString('hex')}`);
335
+ if (s > P256_ORDER / 2n) s = P256_ORDER - s;
336
+ const sBytes = Buffer.from(s.toString(16).padStart(64, '0'), 'hex');
337
+ return new Uint8Array(Buffer.concat([Buffer.from(r), sBytes]));
338
+ };
339
+
340
+ const tenant = 'learncard';
341
+ const did = 'did:key:ztestrecoveryclient';
342
+ const recipientKey = 'recipient-ephemeral-key';
343
+ const keyId = 'escrow-key-1';
344
+ const measurement = new Uint8Array(32).fill(9);
345
+
346
+ /** Mirrors `hold_hash` in escrow-enclave-app's policy.rs: SHA-256 of
347
+ * compact JSON of this fixed-order array. Written independently of
348
+ * escrow-authority.ts's own mirror so a bug in either one is likely to
349
+ * surface as a test failure instead of being silently duplicated. */
350
+ const testHoldDigest = async (record: EscrowHoldRecordLike): Promise<number[]> => {
351
+ const h = record.hold;
352
+ const didHashHex = Buffer.from(await crypto.subtle.digest('SHA-256', utf8(h.did))).toString(
353
+ 'hex'
354
+ );
355
+ const bytes = utf8(
356
+ JSON.stringify([
357
+ 'learncard-hold-v1',
358
+ tenant,
359
+ h.holdId,
360
+ didHashHex,
361
+ h.shareVersion,
362
+ h.blobHash,
363
+ h.enrollmentEpoch,
364
+ h.releasePolicy,
365
+ h.clientEphemeralPublicKey,
366
+ h.createdLo,
367
+ h.createdHi,
368
+ record.holdDurationMs,
369
+ h.policyVersion,
370
+ record.ledgerSeq,
371
+ ])
372
+ );
373
+ return toByteArray(await crypto.subtle.digest('SHA-256', bytes));
374
+ };
375
+
376
+ const makeSigner = async () => {
377
+ const keyPair = await crypto.subtle.generateKey(
378
+ { name: 'ECDSA', namedCurve: 'P-256' },
379
+ true,
380
+ ['sign', 'verify']
381
+ );
382
+ const sign = async (kind: 'commit' | 'snapshot', payloadValue: unknown) => {
383
+ const payload = utf8(JSON.stringify(payloadValue));
384
+ const message = join(
385
+ utf8('learncard-authority-signed-record-v1\0'),
386
+ Uint8Array.of(kind === 'snapshot' ? 2 : 1),
387
+ frame(utf8(keyId)),
388
+ measurement,
389
+ frame(payload)
390
+ );
391
+ const raw = new Uint8Array(
392
+ await crypto.subtle.sign(
393
+ { name: 'ECDSA', hash: 'SHA-256' },
394
+ keyPair.privateKey,
395
+ message
396
+ )
397
+ );
398
+ return {
399
+ version: 1 as const,
400
+ kind,
401
+ keyId,
402
+ measurement: Array.from(measurement),
403
+ payload: Buffer.from(payload).toString('base64'),
404
+ signature: Buffer.from(toLowS(raw)).toString('base64'),
405
+ };
406
+ };
407
+ const ledgerPublicKey = Buffer.from(
408
+ toByteArray(await crypto.subtle.exportKey('raw', keyPair.publicKey))
409
+ ).toString('base64');
410
+ const identity: VerifiedEnclaveIdentity = {
411
+ publicKey: 'escrow-public-key-unused-here',
412
+ keyId,
413
+ previousKeyIds: [],
414
+ mode: 'nitro',
415
+ authority: {
416
+ ledgerPublicKey,
417
+ measurement: Buffer.from(measurement).toString('hex'),
418
+ },
419
+ };
420
+ return { sign, identity };
421
+ };
422
+
423
+ const baseHoldRecord = (): EscrowHoldRecordLike => ({
424
+ hold: {
425
+ holdId: 'hold-1',
426
+ did,
427
+ shareVersion: 1,
428
+ blobHash: 'ab'.repeat(32),
429
+ enrollmentEpoch: 1,
430
+ releasePolicy: 'hold',
431
+ clientEphemeralPublicKey: recipientKey,
432
+ createdLo: 1_700_000_000_000,
433
+ createdHi: 1_700_000_000_000,
434
+ policyVersion: 2,
435
+ signature: '',
436
+ },
437
+ holdDurationMs: 604_800_000,
438
+ ledgerSeq: 0,
439
+ });
440
+
441
+ it('authenticates delivery failure and rejects transplanted or altered delivery evidence', async () => {
442
+ const { sign, identity } = await makeSigner();
443
+ const expected = {
444
+ tenant: 'learncard',
445
+ authorityGeneration: 'g1',
446
+ holdDigest: 'ab'.repeat(32),
447
+ };
448
+ const proof = await sign('snapshot', [
449
+ 'learncard-authority-code-delivery-v1',
450
+ expected.tenant,
451
+ expected.authorityGeneration,
452
+ expected.holdDigest,
453
+ false,
454
+ ]);
455
+ await expect(
456
+ verifyEscrowCodeDelivery({ codeSent: false, proof }, expected, identity)
457
+ ).resolves.toBe(false);
458
+ await expect(
459
+ verifyEscrowCodeDelivery({ codeSent: true, proof }, expected, identity)
460
+ ).rejects.toThrow();
461
+ for (const changed of [
462
+ { tenant: 'other' },
463
+ { authorityGeneration: 'g2' },
464
+ { holdDigest: '00'.repeat(32) },
465
+ ]) {
466
+ await expect(
467
+ verifyEscrowCodeDelivery(
468
+ { codeSent: false, proof },
469
+ { ...expected, ...changed },
470
+ identity
471
+ )
472
+ ).rejects.toThrow();
473
+ }
474
+ });
475
+
476
+ it('verifies nonce-bound hold reads and rejects replay, scope substitution and malformed state', async () => {
477
+ const { sign, identity } = await makeSigner();
478
+ const nonce = 'ab'.repeat(32);
479
+ const expected = {
480
+ tenant: 'learncard',
481
+ authorityGeneration: 'g1',
482
+ did,
483
+ holdId: 'hold-1',
484
+ holdDigest: 'cd'.repeat(32),
485
+ };
486
+ const payload = [
487
+ 'learncard-authority-hold-state-v3',
488
+ nonce,
489
+ expected.tenant,
490
+ expected.authorityGeneration,
491
+ did,
492
+ expected.holdId,
493
+ expected.holdDigest,
494
+ 'pending',
495
+ 1000,
496
+ 2000,
497
+ 4,
498
+ 'unknown',
499
+ ];
500
+ const response = { proof: await sign('snapshot', payload) };
501
+ await expect(verifyEscrowHoldState(response, nonce, expected, identity)).resolves.toEqual({
502
+ status: 'pending',
503
+ confirmedHiMs: 1000,
504
+ releaseWarningSentHiMs: 2000,
505
+ attemptsRemaining: 4,
506
+ codeSendStatus: 'unknown',
507
+ });
508
+ await expect(
509
+ verifyEscrowHoldState(response, 'ff'.repeat(32), expected, identity)
510
+ ).rejects.toThrow();
511
+ for (const changed of [
512
+ { tenant: 'other' },
513
+ { authorityGeneration: 'g2' },
514
+ { did: 'other' },
515
+ { holdId: 'other' },
516
+ { holdDigest: '00'.repeat(32) },
517
+ ]) {
518
+ await expect(
519
+ verifyEscrowHoldState(response, nonce, { ...expected, ...changed }, identity)
520
+ ).rejects.toThrow();
521
+ }
522
+ for (const [index, value] of [
523
+ [7, 'invalid'],
524
+ [8, -1],
525
+ [10, 6],
526
+ [11, 'sent'],
527
+ ] as const) {
528
+ const malformed = [...payload];
529
+ malformed[index] = value;
530
+ await expect(
531
+ verifyEscrowHoldState(
532
+ { proof: await sign('snapshot', malformed) },
533
+ nonce,
534
+ expected,
535
+ identity
536
+ )
537
+ ).rejects.toThrow();
538
+ }
539
+ await expect(
540
+ verifyEscrowHoldState(
541
+ { proof: await sign('commit', payload) },
542
+ nonce,
543
+ expected,
544
+ identity
545
+ )
546
+ ).rejects.toThrow();
547
+ const other = await makeSigner();
548
+ await expect(
549
+ verifyEscrowHoldState(response, nonce, expected, other.identity)
550
+ ).rejects.toThrow();
551
+ });
552
+
553
+ const signedHoldRecord = async (
554
+ sign: Awaited<ReturnType<typeof makeSigner>>['sign'],
555
+ record: EscrowHoldRecordLike
556
+ ): Promise<EscrowHoldRecordLike> => {
557
+ const requestDigest = await testHoldDigest(record);
558
+ const didHash = toByteArray(await crypto.subtle.digest('SHA-256', utf8(record.hold.did)));
559
+ const blobHash = Array.from(Buffer.from(record.hold.blobHash, 'hex'));
560
+ const receipt = await sign('commit', {
561
+ commit: {
562
+ schemaVersion: 1,
563
+ binding: { tenant, generation: 'g1', didHash },
564
+ executionId: Array.from({ length: 16 }, () => 0),
565
+ revisions: { operation: 1, enrollment: 1 },
566
+ transactionDigest: Array.from({ length: 32 }, () => 0),
567
+ },
568
+ eventHash: Array.from({ length: 32 }, () => 0),
569
+ requestDigest,
570
+ resultBlobIdentity: blobHash,
571
+ enrollmentEpoch: record.hold.enrollmentEpoch,
572
+ });
573
+ return {
574
+ ...record,
575
+ hold: {
576
+ ...record.hold,
577
+ signature: Buffer.from(JSON.stringify(receipt)).toString('base64'),
578
+ },
579
+ };
580
+ };
581
+
582
+ const expectation = (record: EscrowHoldRecordLike) => ({
583
+ tenant,
584
+ holdId: record.hold.holdId,
585
+ did: record.hold.did,
586
+ clientEphemeralPublicKey: record.hold.clientEphemeralPublicKey,
587
+ blobHash: record.hold.blobHash,
588
+ });
589
+
590
+ it('accepts a correctly signed hold record bound to the expected recipient/DID/blob', async () => {
591
+ const { sign, identity } = await makeSigner();
592
+ const record = await signedHoldRecord(sign, baseHoldRecord());
593
+ const result = await verifyEscrowHoldRecord(record, expectation(record), identity);
594
+ expect(result.authorityGeneration).toBe('g1');
595
+ });
596
+
597
+ it('rejects a wrong recipient (client ephemeral) key', async () => {
598
+ const { sign, identity } = await makeSigner();
599
+ const record = await signedHoldRecord(sign, baseHoldRecord());
600
+ await expect(
601
+ verifyEscrowHoldRecord(
602
+ record,
603
+ { ...expectation(record), clientEphemeralPublicKey: 'attacker-key' },
604
+ identity
605
+ )
606
+ ).rejects.toThrow();
607
+ });
608
+
609
+ it('rejects a hold whose id/digest was tampered after signing', async () => {
610
+ const { sign, identity } = await makeSigner();
611
+ const record = await signedHoldRecord(sign, baseHoldRecord());
612
+ const tampered: EscrowHoldRecordLike = {
613
+ ...record,
614
+ hold: { ...record.hold, holdId: 'attacker-chosen-hold-id' },
615
+ };
616
+ await expect(
617
+ verifyEscrowHoldRecord(tampered, expectation(tampered), identity)
618
+ ).rejects.toThrow();
619
+ });
620
+
621
+ it('rejects a wrong DID', async () => {
622
+ const { sign, identity } = await makeSigner();
623
+ const record = await signedHoldRecord(sign, baseHoldRecord());
624
+ await expect(
625
+ verifyEscrowHoldRecord(
626
+ record,
627
+ { ...expectation(record), did: 'did:key:someoneelse' },
628
+ identity
629
+ )
630
+ ).rejects.toThrow();
631
+ });
632
+
633
+ it('rejects an unsigned/forged record', async () => {
634
+ const { sign, identity } = await makeSigner();
635
+ const record = await signedHoldRecord(sign, baseHoldRecord());
636
+ const decoded = JSON.parse(Buffer.from(record.hold.signature, 'base64').toString('utf8'));
637
+ const forgedSignature = Buffer.from(decoded.signature, 'base64');
638
+ forgedSignature[0] ^= 0xff;
639
+ decoded.signature = forgedSignature.toString('base64');
640
+ const forged: EscrowHoldRecordLike = {
641
+ ...record,
642
+ hold: {
643
+ ...record.hold,
644
+ signature: Buffer.from(JSON.stringify(decoded)).toString('base64'),
645
+ },
646
+ };
647
+ await expect(
648
+ verifyEscrowHoldRecord(forged, expectation(forged), identity)
649
+ ).rejects.toThrow();
650
+ });
651
+
652
+ it('accepts a fresh commit confirmation receipt bound to the started hold', async () => {
653
+ const { sign, identity } = await makeSigner();
654
+ const record = await signedHoldRecord(sign, baseHoldRecord());
655
+ const verified = await verifyEscrowHoldRecord(record, expectation(record), identity);
656
+ const didHash = toByteArray(await crypto.subtle.digest('SHA-256', utf8(did)));
657
+ const receipt = await sign('commit', {
658
+ commit: {
659
+ schemaVersion: 1,
660
+ binding: { tenant, generation: 'g1', didHash },
661
+ executionId: Array.from({ length: 16 }, () => 1),
662
+ revisions: { operation: 2, enrollment: 1 },
663
+ transactionDigest: Array.from({ length: 32 }, () => 1),
664
+ },
665
+ eventHash: Array.from({ length: 32 }, () => 1),
666
+ // Secret-keyed PIN-style hash in the real protocol; its exact
667
+ // value is intentionally not re-checked here (see production
668
+ // code comment) — any 32 bytes exercise the structural check.
669
+ requestDigest: Array.from({ length: 32 }, () => 2),
670
+ resultBlobIdentity: Array.from(Buffer.from(record.hold.blobHash, 'hex')),
671
+ enrollmentEpoch: record.hold.enrollmentEpoch,
672
+ });
673
+ await expect(
674
+ verifyEscrowHoldConfirmation(
675
+ receipt,
676
+ record,
677
+ { tenant, did, authorityGeneration: verified.authorityGeneration },
678
+ identity
679
+ )
680
+ ).resolves.toMatchObject({ kind: 'commit' });
681
+ });
682
+
683
+ it('accepts an idempotent-retry snapshot receipt bound to the started hold', async () => {
684
+ const { sign, identity } = await makeSigner();
685
+ const record = await signedHoldRecord(sign, baseHoldRecord());
686
+ const verified = await verifyEscrowHoldRecord(record, expectation(record), identity);
687
+ const digest = await testHoldDigest(record);
688
+ const receipt = await sign('snapshot', [
689
+ 'learncard-authority-hold-snapshot-v2',
690
+ record.hold.holdId,
691
+ digest,
692
+ 'pending',
693
+ 1_700_000_100_000,
694
+ null,
695
+ 'g1',
696
+ ]);
697
+ await expect(
698
+ verifyEscrowHoldConfirmation(
699
+ receipt,
700
+ record,
701
+ { tenant, did, authorityGeneration: verified.authorityGeneration },
702
+ identity
703
+ )
704
+ ).resolves.toMatchObject({ kind: 'snapshot' });
705
+ });
706
+
707
+ it('rejects a snapshot receipt for a different hold (wrong holdId)', async () => {
708
+ const { sign, identity } = await makeSigner();
709
+ const record = await signedHoldRecord(sign, baseHoldRecord());
710
+ const verified = await verifyEscrowHoldRecord(record, expectation(record), identity);
711
+ const digest = await testHoldDigest(record);
712
+ const receipt = await sign('snapshot', [
713
+ 'learncard-authority-hold-snapshot-v2',
714
+ 'a-completely-different-hold-id',
715
+ digest,
716
+ 'pending',
717
+ 1_700_000_100_000,
718
+ null,
719
+ 'g1',
720
+ ]);
721
+ await expect(
722
+ verifyEscrowHoldConfirmation(
723
+ receipt,
724
+ record,
725
+ { tenant, did, authorityGeneration: verified.authorityGeneration },
726
+ identity
727
+ )
728
+ ).rejects.toThrow();
729
+ });
730
+
731
+ it('rejects a snapshot receipt whose embedded digest belongs to a different hold', async () => {
732
+ const { sign, identity } = await makeSigner();
733
+ const record = await signedHoldRecord(sign, baseHoldRecord());
734
+ const verified = await verifyEscrowHoldRecord(record, expectation(record), identity);
735
+ const otherHoldDigest = await testHoldDigest({
736
+ ...record,
737
+ hold: { ...record.hold, holdId: 'a-different-hold' },
738
+ });
739
+ const receipt = await sign('snapshot', [
740
+ 'learncard-authority-hold-snapshot-v2',
741
+ record.hold.holdId,
742
+ otherHoldDigest,
743
+ 'pending',
744
+ 1_700_000_100_000,
745
+ null,
746
+ 'g1',
747
+ ]);
748
+ await expect(
749
+ verifyEscrowHoldConfirmation(
750
+ receipt,
751
+ record,
752
+ { tenant, did, authorityGeneration: verified.authorityGeneration },
753
+ identity
754
+ )
755
+ ).rejects.toThrow();
756
+ });
757
+ });