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