@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,671 @@
1
+ import { base64ToBuffer, bufferToBase64, bytesToHex } from './crypto';
2
+ import { parseEscrowEnvelope } from './escrow-crypto';
3
+ import { EscrowEvidenceError } from './escrow-authority.types';
4
+ import type { HistoricalEscrowEvidence } from './escrow-authority.types';
5
+ import type { EscrowAttestationPolicy } from './types';
6
+ import {
7
+ encodeEscrowEnrollmentIntent,
8
+ getEscrowBlobIdentity,
9
+ signEscrowEnrollmentIntent,
10
+ } from './escrow-enrollment-intent';
11
+ import type { EscrowBlobPlaintext, EscrowEnvelope } from './escrow-crypto';
12
+ import type {
13
+ EscrowEnrollmentIntent,
14
+ SignedEscrowEnrollmentIntent,
15
+ } from './escrow-enrollment-intent.types';
16
+ import type {
17
+ EscrowAuthorityRecord,
18
+ EscrowAuthorityState,
19
+ VerifiedEnclaveIdentity,
20
+ VerifiedEscrowMutation,
21
+ } from './escrow-authority.types';
22
+
23
+ const fail = (): never => {
24
+ throw new EscrowEvidenceError();
25
+ };
26
+
27
+ /** Verify a fresh hold-state response using an independently retained hold binding
28
+ * and a newly generated 32-byte nonce. Never reuse the nonce for another read. */
29
+ export const verifyEscrowHoldState = async (
30
+ response: { proof: unknown },
31
+ nonce: string,
32
+ expected: {
33
+ tenant: string;
34
+ authorityGeneration: string;
35
+ did: string;
36
+ holdId: string;
37
+ holdDigest: string;
38
+ },
39
+ identity: VerifiedEnclaveIdentity
40
+ ): Promise<{
41
+ status: 'awaitingConfirmation' | 'pending' | 'released' | 'cancelled';
42
+ confirmedHiMs: number | null;
43
+ releaseWarningSentHiMs: number | null;
44
+ attemptsRemaining: number;
45
+ codeSendStatus: 'unknown' | 'notRequired';
46
+ }> => {
47
+ requireEvidence(/^[0-9a-f]{64}$/.test(nonce));
48
+ const { payload } = await verifyRecord(response.proof, 'snapshot', identity);
49
+ requireEvidence(Array.isArray(payload) && payload.length === 12);
50
+ const p = payload as unknown[];
51
+ requireEvidence(
52
+ p[0] === 'learncard-authority-hold-state-v3' &&
53
+ p[1] === nonce &&
54
+ p[2] === expected.tenant &&
55
+ p[3] === expected.authorityGeneration &&
56
+ p[4] === expected.did &&
57
+ p[5] === expected.holdId &&
58
+ p[6] === expected.holdDigest
59
+ );
60
+ const status = p[7];
61
+ if (
62
+ status !== 'awaitingConfirmation' &&
63
+ status !== 'pending' &&
64
+ status !== 'released' &&
65
+ status !== 'cancelled'
66
+ )
67
+ return fail();
68
+ const confirmedHiMs = p[8] === null ? null : integer(p[8]);
69
+ const releaseWarningSentHiMs = p[9] === null ? null : integer(p[9]);
70
+ const attemptsRemaining = integer(p[10], 0, 5);
71
+ const codeSendStatus = p[11];
72
+ if (codeSendStatus !== 'unknown' && codeSendStatus !== 'notRequired') return fail();
73
+ requireEvidence(
74
+ status !== 'awaitingConfirmation' ||
75
+ (confirmedHiMs === null && releaseWarningSentHiMs === null)
76
+ );
77
+ requireEvidence((status !== 'pending' && status !== 'released') || confirmedHiMs !== null);
78
+ return { status, confirmedHiMs, releaseWarningSentHiMs, attemptsRemaining, codeSendStatus };
79
+ };
80
+
81
+ /** Authenticate advisory send acceptance without changing the immutable hold commitment. */
82
+ export const verifyEscrowCodeDelivery = async (
83
+ delivery: { codeSent: boolean; proof: unknown },
84
+ expected: { tenant: string; authorityGeneration: string; holdDigest: string },
85
+ identity: VerifiedEnclaveIdentity
86
+ ): Promise<boolean> => {
87
+ const { payload } = await verifyRecord(delivery.proof, 'snapshot', identity);
88
+ requireEvidence(Array.isArray(payload) && payload.length === 5);
89
+ const values = payload as unknown[];
90
+ requireEvidence(
91
+ values[0] === 'learncard-authority-code-delivery-v1' &&
92
+ values[1] === expected.tenant &&
93
+ values[2] === expected.authorityGeneration &&
94
+ values[3] === expected.holdDigest &&
95
+ typeof values[4] === 'boolean' &&
96
+ values[4] === delivery.codeSent
97
+ );
98
+ return delivery.codeSent;
99
+ };
100
+ const requireEvidence = (condition: boolean): void => {
101
+ if (!condition) fail();
102
+ };
103
+ const object = (value: unknown): Record<string, unknown> => {
104
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return fail();
105
+ return value as Record<string, unknown>;
106
+ };
107
+ const keys = (value: Record<string, unknown>, names: string[]): void => {
108
+ requireEvidence(
109
+ Object.keys(value).length === names.length &&
110
+ names.every(name => Object.prototype.hasOwnProperty.call(value, name))
111
+ );
112
+ };
113
+ const integer = (value: unknown, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): number => {
114
+ if (
115
+ typeof value !== 'number' ||
116
+ !Number.isSafeInteger(value) ||
117
+ value < minimum ||
118
+ value > maximum
119
+ )
120
+ return fail();
121
+ return value;
122
+ };
123
+ const text = (value: unknown, maximum: number): string => {
124
+ if (typeof value !== 'string' || !value.length || value.length > maximum) return fail();
125
+ return value;
126
+ };
127
+ const bytes = (value: unknown, length: number): Uint8Array<ArrayBuffer> => {
128
+ if (
129
+ !Array.isArray(value) ||
130
+ value.length !== length ||
131
+ !value.every(
132
+ byte => typeof byte === 'number' && Number.isInteger(byte) && byte >= 0 && byte <= 255
133
+ )
134
+ )
135
+ return fail();
136
+ return Uint8Array.from(value);
137
+ };
138
+ const base64 = (value: unknown, maximum: number): Uint8Array<ArrayBuffer> => {
139
+ const encoded = text(value, maximum);
140
+ try {
141
+ const decoded = new Uint8Array(base64ToBuffer(encoded));
142
+ requireEvidence(bufferToBase64(decoded.buffer) === encoded);
143
+ return decoded;
144
+ } catch {
145
+ return fail();
146
+ }
147
+ };
148
+ const frame = (value: Uint8Array): Uint8Array<ArrayBuffer> => {
149
+ const result = new Uint8Array(4 + value.length);
150
+ new DataView(result.buffer).setUint32(0, value.length, false);
151
+ result.set(value, 4);
152
+ return result;
153
+ };
154
+ const join = (...values: Uint8Array[]): Uint8Array<ArrayBuffer> => {
155
+ const result = new Uint8Array(values.reduce((n, v) => n + v.length, 0));
156
+ let offset = 0;
157
+ for (const value of values) {
158
+ result.set(value, offset);
159
+ offset += value.length;
160
+ }
161
+ return result;
162
+ };
163
+ const hash = async (value: Uint8Array): Promise<string> =>
164
+ bytesToHex(new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(value))));
165
+ const utf8 = (value: string): Uint8Array<ArrayBuffer> => new TextEncoder().encode(value);
166
+
167
+ /** Derive historical receipt pins from the same complete PCR tuples used for
168
+ * Nitro attestation. Legacy image-only pins and malformed tuples authorize nothing. */
169
+ export const escrowAuthorityMeasurements = async (
170
+ policy: EscrowAttestationPolicy
171
+ ): Promise<string[]> => {
172
+ if (policy.mode !== 'nitro') return [];
173
+ const hashes: string[] = [];
174
+ for (const pin of policy.pinnedMeasurements) {
175
+ const tuple = [pin.pcr0, pin.pcr1, pin.pcr2];
176
+ if (!tuple.every(pcr => typeof pcr === 'string' && /^[0-9a-f]{96}$/i.test(pcr))) continue;
177
+ if (pin.pcr0 === '00'.repeat(48)) continue;
178
+ const encoded = tuple.join('');
179
+ const decoded = Uint8Array.from({ length: 144 }, (_, i) =>
180
+ parseInt(encoded.slice(i * 2, i * 2 + 2), 16)
181
+ );
182
+ hashes.push(await hash(decoded));
183
+ }
184
+ return hashes;
185
+ };
186
+ const P256_ORDER = BigInt('0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551');
187
+
188
+ /** Verify the exact Rust record bytes and artifact kind before interpreting JSON. */
189
+ const verifyRecord = async (
190
+ input: unknown,
191
+ kind: 'snapshot' | 'commit',
192
+ identity: VerifiedEnclaveIdentity,
193
+ historical?: HistoricalEscrowEvidence
194
+ ): Promise<{ record: EscrowAuthorityRecord; payload: unknown }> => {
195
+ requireEvidence(identity.mode === 'nitro' && !!identity.authority);
196
+ const raw = object(input);
197
+ keys(raw, ['version', 'kind', 'keyId', 'measurement', 'payload', 'signature']);
198
+ requireEvidence(raw.version === 1 && raw.kind === kind);
199
+ const keyId = text(raw.keyId, 128);
200
+ requireEvidence(
201
+ historical
202
+ ? [identity.keyId, ...identity.previousKeyIds].includes(keyId)
203
+ : keyId === identity.keyId
204
+ );
205
+ const measurement = bytes(raw.measurement, 32);
206
+ requireEvidence(
207
+ historical
208
+ ? historical.allowedMeasurements.includes(bytesToHex(measurement))
209
+ : bytesToHex(measurement) === identity.authority?.measurement
210
+ );
211
+ const payloadB64 = text(raw.payload, kind === 'snapshot' ? 49_152 : 8192);
212
+ const signatureB64 = text(raw.signature, 88);
213
+ const payload = base64(payloadB64, kind === 'snapshot' ? 49_152 : 8192);
214
+ const signature = base64(signatureB64, 88);
215
+ const ledgerKey = base64(identity.authority?.ledgerPublicKey, 88);
216
+ requireEvidence(ledgerKey.length === 65 && ledgerKey[0] === 4);
217
+ requireEvidence(signature.length === 64);
218
+ const s = BigInt(`0x${bytesToHex(signature.slice(32))}`);
219
+ requireEvidence(s > 0n && s <= P256_ORDER / 2n);
220
+ const message = join(
221
+ utf8('learncard-authority-signed-record-v1\0'),
222
+ Uint8Array.of(kind === 'snapshot' ? 2 : 1),
223
+ frame(utf8(keyId)),
224
+ measurement,
225
+ frame(payload)
226
+ );
227
+ try {
228
+ const key = await crypto.subtle.importKey(
229
+ 'raw',
230
+ ledgerKey,
231
+ { name: 'ECDSA', namedCurve: 'P-256' },
232
+ false,
233
+ ['verify']
234
+ );
235
+ // WebCrypto hashes the message once; Rust signs SHA-256(message) as a prehash.
236
+ requireEvidence(
237
+ await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, signature, message)
238
+ );
239
+ const decoded: unknown = JSON.parse(
240
+ new TextDecoder('utf-8', { fatal: true }).decode(payload)
241
+ );
242
+ const record: EscrowAuthorityRecord = {
243
+ version: 1,
244
+ kind,
245
+ keyId,
246
+ measurement: Array.from(measurement),
247
+ payload: payloadB64,
248
+ signature: signatureB64,
249
+ };
250
+ return { record, payload: decoded };
251
+ } catch {
252
+ return fail();
253
+ }
254
+ };
255
+
256
+ const envelope = (value: unknown): EscrowEnvelope => {
257
+ try {
258
+ const e = parseEscrowEnvelope(value);
259
+ return {
260
+ version: e.version,
261
+ algorithm: e.algorithm,
262
+ keyId: e.keyId,
263
+ ephemeralPublicKey: e.ephemeralPublicKey,
264
+ salt: e.salt,
265
+ iv: e.iv,
266
+ ciphertext: e.ciphertext,
267
+ };
268
+ } catch {
269
+ return fail();
270
+ }
271
+ };
272
+ const state = (value: unknown): EscrowAuthorityState => {
273
+ const raw = object(value);
274
+ keys(raw, [
275
+ 'tenant',
276
+ 'authorityGeneration',
277
+ 'did',
278
+ 'enrollmentVersion',
279
+ 'enrollmentEpoch',
280
+ 'status',
281
+ 'envelope',
282
+ 'shareVersion',
283
+ 'hasPin',
284
+ 'pinGeneration',
285
+ 'attemptsUsed',
286
+ ]);
287
+ if (raw.status !== 'empty' && raw.status !== 'active' && raw.status !== 'disabled')
288
+ return fail();
289
+ if (typeof raw.hasPin !== 'boolean') return fail();
290
+ const result: EscrowAuthorityState = {
291
+ tenant: text(raw.tenant, 128),
292
+ authorityGeneration: text(raw.authorityGeneration, 128),
293
+ did: text(raw.did, 8192),
294
+ enrollmentVersion: integer(raw.enrollmentVersion),
295
+ enrollmentEpoch: integer(raw.enrollmentEpoch),
296
+ status: raw.status,
297
+ envelope: raw.envelope === null ? null : envelope(raw.envelope),
298
+ shareVersion: raw.shareVersion === null ? null : integer(raw.shareVersion, 1, 0xffffffff),
299
+ hasPin: raw.hasPin,
300
+ pinGeneration: integer(raw.pinGeneration),
301
+ attemptsUsed: integer(raw.attemptsUsed, 0, 10),
302
+ };
303
+ requireEvidence(
304
+ result.pinGeneration <= result.enrollmentVersion &&
305
+ result.enrollmentEpoch <= result.enrollmentVersion &&
306
+ (!result.hasPin || result.pinGeneration > 0) &&
307
+ (result.hasPin || result.attemptsUsed === 0)
308
+ );
309
+ if (result.status === 'empty')
310
+ requireEvidence(
311
+ result.enrollmentVersion === 0 &&
312
+ result.enrollmentEpoch === 0 &&
313
+ !result.envelope &&
314
+ result.shareVersion === null &&
315
+ !result.hasPin
316
+ );
317
+ else requireEvidence(result.enrollmentVersion > 0 && result.enrollmentEpoch > 0);
318
+ if (result.status === 'active')
319
+ requireEvidence(!!result.envelope && result.shareVersion !== null);
320
+ else requireEvidence(result.envelope === null && result.shareVersion === null);
321
+ return result;
322
+ };
323
+
324
+ /** Check challenge, scope and signed state; never trust the unsigned duplicate. */
325
+ export const verifyEscrowAuthorityState = async (
326
+ response: unknown,
327
+ nonce: string,
328
+ tenant: string,
329
+ did: string,
330
+ identity: VerifiedEnclaveIdentity
331
+ ): Promise<EscrowAuthorityState> => {
332
+ requireEvidence(/^[0-9a-f]{64}$/.test(nonce) && nonce.length === 64);
333
+ const raw = object(response);
334
+ keys(raw, ['state', 'proof']);
335
+ const { payload } = await verifyRecord(raw.proof, 'snapshot', identity);
336
+ if (!Array.isArray(payload) || payload.length !== 2 || payload[0] !== nonce) return fail();
337
+ const signed = state(payload[1]);
338
+ requireEvidence(signed.tenant === tenant && signed.did === did);
339
+ requireEvidence(JSON.stringify(signed) === JSON.stringify(state(raw.state)));
340
+ if (signed.envelope) {
341
+ requireEvidence(
342
+ [identity.keyId, ...identity.previousKeyIds].includes(signed.envelope.keyId)
343
+ );
344
+ await getEscrowBlobIdentity(signed.envelope);
345
+ }
346
+ return signed;
347
+ };
348
+
349
+ /** Accept only the committed output of this exact owner intent, including reseals. */
350
+ export const verifyEscrowEnrollmentCommit = async (
351
+ response: unknown,
352
+ intent: EscrowEnrollmentIntent,
353
+ identity: VerifiedEnclaveIdentity
354
+ ): Promise<VerifiedEscrowMutation> => {
355
+ const raw = object(response);
356
+ const { record, payload } = await verifyRecord(raw.receipt, 'commit', identity);
357
+ const data = object(payload);
358
+ keys(data, ['commit', 'eventHash', 'requestDigest', 'resultBlobIdentity', 'enrollmentEpoch']);
359
+ const commit = object(data.commit);
360
+ keys(commit, ['schemaVersion', 'binding', 'executionId', 'revisions', 'transactionDigest']);
361
+ const binding = object(commit.binding);
362
+ keys(binding, ['tenant', 'generation', 'didHash']);
363
+ const revisions = object(commit.revisions);
364
+ keys(revisions, ['operation', 'enrollment']);
365
+ requireEvidence(
366
+ commit.schemaVersion === 1 &&
367
+ binding.tenant === intent.tenant &&
368
+ binding.generation === intent.authorityGeneration
369
+ );
370
+ requireEvidence(bytesToHex(bytes(binding.didHash, 32)) === (await hash(utf8(intent.did))));
371
+ requireEvidence(
372
+ integer(revisions.enrollment, 1) === intent.expectedEnrollmentVersion + 1 &&
373
+ integer(revisions.operation, 1) >= integer(revisions.enrollment, 1)
374
+ );
375
+ bytes(commit.executionId, 16);
376
+ bytes(commit.transactionDigest, 32);
377
+ bytes(data.eventHash, 32);
378
+ requireEvidence(integer(data.enrollmentEpoch, 1) === intent.enrollmentEpoch);
379
+ requireEvidence(
380
+ bytesToHex(bytes(data.requestDigest, 32)) ===
381
+ (await hash(encodeEscrowEnrollmentIntent(intent)))
382
+ );
383
+ if (intent.action === 'disable') {
384
+ requireEvidence(
385
+ (raw.envelope === null || raw.envelope === undefined) &&
386
+ data.resultBlobIdentity === null
387
+ );
388
+ return { envelope: null, receipt: record };
389
+ }
390
+ const output = envelope(raw.envelope);
391
+ requireEvidence(
392
+ output.keyId === identity.keyId &&
393
+ bytesToHex(bytes(data.resultBlobIdentity, 32)) === (await getEscrowBlobIdentity(output))
394
+ );
395
+ return { envelope: output, receipt: record };
396
+ };
397
+
398
+ /** Opaque wire shape of `SignedHoldRecord` (wire.rs `HoldRecord` / policy.rs
399
+ * `SignedHoldRecord`) — exactly the fields `hold_hash` commits to, plus the
400
+ * embedded enclave signature over the hold's own creation. */
401
+ export interface EscrowHoldRecordLike {
402
+ hold: {
403
+ holdId: string;
404
+ did: string;
405
+ shareVersion: number;
406
+ blobHash: string;
407
+ enrollmentEpoch: number;
408
+ releasePolicy: 'hold' | 'pin';
409
+ clientEphemeralPublicKey: string;
410
+ createdLo: number;
411
+ createdHi: number;
412
+ policyVersion: number;
413
+ signature: string;
414
+ };
415
+ holdDurationMs: number;
416
+ ledgerSeq: number;
417
+ }
418
+
419
+ export interface VerifiedEscrowHold {
420
+ receipt: EscrowAuthorityRecord;
421
+ authorityGeneration: string;
422
+ holdDigest: string;
423
+ }
424
+
425
+ /** Mirrors `hold_hash` in escrow-enclave-app's policy.rs exactly: SHA-256 of
426
+ * compact JSON of this fixed-order array. Every later operation on a hold
427
+ * (confirm, release, cancel, and the idempotent-retry snapshot) commits to
428
+ * this same value, so recomputing and comparing it is what actually binds a
429
+ * later receipt to the SAME hold a client started, not merely the same
430
+ * `holdId` string. */
431
+ const holdDigest = async (tenant: string, record: EscrowHoldRecordLike): Promise<string> => {
432
+ const h = record.hold;
433
+ const didHash = await hash(utf8(h.did));
434
+ return hash(
435
+ utf8(
436
+ JSON.stringify([
437
+ 'learncard-hold-v1',
438
+ tenant,
439
+ h.holdId,
440
+ didHash,
441
+ h.shareVersion,
442
+ h.blobHash,
443
+ h.enrollmentEpoch,
444
+ h.releasePolicy,
445
+ h.clientEphemeralPublicKey,
446
+ h.createdLo,
447
+ h.createdHi,
448
+ record.holdDurationMs,
449
+ h.policyVersion,
450
+ record.ledgerSeq,
451
+ ])
452
+ )
453
+ );
454
+ };
455
+
456
+ /** Verify the enclave committed to creating THIS exact hold before trusting
457
+ * a `SignedHoldRecord` returned by lca-api: recipient key, DID, blob
458
+ * identity and hold id all come from the caller's own expectations (never
459
+ * from the record alone), and the embedded signature is checked against the
460
+ * attested ledger key exactly like `verifyEscrowEnrollmentCommit`. Returns
461
+ * the record's own `authorityGeneration` so a later confirmation/warning
462
+ * receipt for the SAME hold can be pinned to it.
463
+ * `historical` explicitly permits an older signed measurement only if it is
464
+ * allowlisted, verifies with the current attested ledger key and an attested
465
+ * current/previous key ID, and matches the retained authority generation.
466
+ * Never use historical verification for newly produced confirmation evidence. */
467
+ export const verifyEscrowHoldRecord = async (
468
+ record: EscrowHoldRecordLike,
469
+ expected: {
470
+ tenant: string;
471
+ did: string;
472
+ clientEphemeralPublicKey: string;
473
+ blobHash: string;
474
+ holdId: string;
475
+ },
476
+ identity: VerifiedEnclaveIdentity,
477
+ historical?: HistoricalEscrowEvidence
478
+ ): Promise<VerifiedEscrowHold> => {
479
+ requireEvidence(
480
+ record.hold.holdId === expected.holdId &&
481
+ record.hold.did === expected.did &&
482
+ record.hold.clientEphemeralPublicKey === expected.clientEphemeralPublicKey &&
483
+ record.hold.blobHash === expected.blobHash
484
+ );
485
+ let signed: unknown;
486
+ try {
487
+ signed = JSON.parse(
488
+ new TextDecoder('utf-8', { fatal: true }).decode(base64(record.hold.signature, 8192))
489
+ );
490
+ } catch {
491
+ return fail();
492
+ }
493
+ const { record: receipt, payload } = await verifyRecord(signed, 'commit', identity, historical);
494
+ const data = object(payload);
495
+ keys(data, ['commit', 'eventHash', 'requestDigest', 'resultBlobIdentity', 'enrollmentEpoch']);
496
+ const commit = object(data.commit);
497
+ keys(commit, ['schemaVersion', 'binding', 'executionId', 'revisions', 'transactionDigest']);
498
+ const binding = object(commit.binding);
499
+ keys(binding, ['tenant', 'generation', 'didHash']);
500
+ requireEvidence(commit.schemaVersion === 1 && binding.tenant === expected.tenant);
501
+ const authorityGeneration = text(binding.generation, 128);
502
+ if (historical) requireEvidence(authorityGeneration === historical.authorityGeneration);
503
+ requireEvidence(bytesToHex(bytes(binding.didHash, 32)) === (await hash(utf8(expected.did))));
504
+ requireEvidence(integer(data.enrollmentEpoch, 1) === record.hold.enrollmentEpoch);
505
+ requireEvidence(bytesToHex(bytes(data.resultBlobIdentity, 32)) === record.hold.blobHash);
506
+ const digest = await holdDigest(expected.tenant, record);
507
+ requireEvidence(bytesToHex(bytes(data.requestDigest, 32)) === digest);
508
+ return { receipt, authorityGeneration, holdDigest: digest };
509
+ };
510
+
511
+ /** Verify a confirm-hold / send-release-warning result commits to THIS exact
512
+ * hold (the one `verifyEscrowHoldRecord` already verified). A fresh
513
+ * `commit`'s `requestDigest` is a secret-keyed, PIN-style hash the client
514
+ * cannot recompute by design (publishing an unkeyed digest here would be an
515
+ * offline oracle — see escrow-enclave-app's ledger "Operation commitments"),
516
+ * so a fresh commit is checked on tenant/generation/DID binding, epoch and
517
+ * blob identity only. The idempotent retry **snapshot** instead embeds
518
+ * `signedHoldDigest` directly in its (unkeyed) payload, so a snapshot
519
+ * additionally recomputes and compares the hold digest — exactly binding a
520
+ * retry to the hold the client itself started, not a different one. A caller
521
+ * receiving a commit MUST also obtain and verify this snapshot before claiming
522
+ * hold-specific confirmation (as createSSSStrategy does). */
523
+ export const verifyEscrowHoldConfirmation = async (
524
+ receipt: unknown,
525
+ record: EscrowHoldRecordLike,
526
+ expected: { tenant: string; did: string; authorityGeneration: string },
527
+ identity: VerifiedEnclaveIdentity
528
+ ): Promise<EscrowAuthorityRecord> => {
529
+ const raw = object(receipt);
530
+ const kind = raw.kind;
531
+ if (kind !== 'commit' && kind !== 'snapshot') return fail();
532
+ const { record: verified, payload } = await verifyRecord(raw, kind, identity);
533
+ if (kind === 'snapshot') {
534
+ if (!Array.isArray(payload) || payload.length !== 7) return fail();
535
+ const [tag, holdId, digestBytes] = payload;
536
+ requireEvidence(
537
+ tag === 'learncard-authority-hold-snapshot-v2' &&
538
+ holdId === record.hold.holdId &&
539
+ payload[6] === expected.authorityGeneration
540
+ );
541
+ requireEvidence(
542
+ bytesToHex(bytes(digestBytes, 32)) === (await holdDigest(expected.tenant, record))
543
+ );
544
+ requireEvidence(payload[3] === 'pending');
545
+ integer(payload[4], 1);
546
+ return verified;
547
+ }
548
+ const data = object(payload);
549
+ keys(data, ['commit', 'eventHash', 'requestDigest', 'resultBlobIdentity', 'enrollmentEpoch']);
550
+ const commit = object(data.commit);
551
+ keys(commit, ['schemaVersion', 'binding', 'executionId', 'revisions', 'transactionDigest']);
552
+ const binding = object(commit.binding);
553
+ keys(binding, ['tenant', 'generation', 'didHash']);
554
+ requireEvidence(
555
+ commit.schemaVersion === 1 &&
556
+ binding.tenant === expected.tenant &&
557
+ binding.generation === expected.authorityGeneration
558
+ );
559
+ requireEvidence(bytesToHex(bytes(binding.didHash, 32)) === (await hash(utf8(expected.did))));
560
+ requireEvidence(integer(data.enrollmentEpoch, 1) === record.hold.enrollmentEpoch);
561
+ requireEvidence(bytesToHex(bytes(data.resultBlobIdentity, 32)) === record.hold.blobHash);
562
+ bytes(data.requestDigest, 32);
563
+ return verified;
564
+ };
565
+
566
+ export type EscrowEnrollmentActionInput = 'automatic' | 'set' | 'clear' | 'disable';
567
+
568
+ /** Pure action resolution, shared between intent-building (here) and blob
569
+ * plaintext construction (sss-strategy.ts's `enrollEscrow`), so both always
570
+ * agree on enroll/replace/carry/disable from the same inputs instead of
571
+ * maintaining two copies of this decision. P9.1 (attack C) / P2-d:
572
+ * `bindsRecoveryContact` is the single source of truth for whether
573
+ * `recoveryEmail`/`recoveryLanguage` may appear in EITHER the signed intent
574
+ * or the target blob plaintext — Rust rejects a Carry target blob whose
575
+ * plaintext already carries either field (the enclave fills them in from
576
+ * the source blob instead), so this must be computed identically in both
577
+ * places or a carry can be built with a blob the enclave will refuse. */
578
+ export const resolveEscrowEnrollmentAction = (
579
+ current: Pick<EscrowAuthorityState, 'status' | 'hasPin'>,
580
+ input: EscrowEnrollmentActionInput
581
+ ): {
582
+ action: EscrowEnrollmentIntent['action'];
583
+ pinAction: EscrowEnrollmentIntent['pinAction'];
584
+ bindsRecoveryContact: boolean;
585
+ } => {
586
+ const disabling = input === 'disable';
587
+ const pinAction = disabling
588
+ ? 'keep'
589
+ : input === 'set'
590
+ ? current.hasPin
591
+ ? 'change'
592
+ : 'set'
593
+ : input === 'clear'
594
+ ? current.hasPin
595
+ ? 'clear'
596
+ : 'keep'
597
+ : 'keep';
598
+ const action = disabling
599
+ ? 'disable'
600
+ : current.status !== 'active'
601
+ ? 'enroll'
602
+ : current.hasPin && pinAction === 'keep'
603
+ ? 'carry'
604
+ : 'replace';
605
+ return { action, pinAction, bindsRecoveryContact: action === 'enroll' || action === 'replace' };
606
+ };
607
+
608
+ /** P2-d cross-language contract: mirrors the enclave's Carry contact-field
609
+ * rejection rule (`apply_enrollment_intent` in authority.rs) — true means
610
+ * the enclave would refuse this target blob. The enclave always fills
611
+ * recoveryEmail/recoveryLanguage in from the source blob on carry, so a
612
+ * target that already claims either field is refused. Shared by
613
+ * `escrow-carry-contact-contract-v1.json`'s TS and Rust tests, and by any
614
+ * caller (e.g. a test fake) that wants to fail the same way the real
615
+ * enclave does instead of accepting a shape it would reject. */
616
+ export const violatesCarryContactRule = (
617
+ action: EscrowEnrollmentIntent['action'],
618
+ target: Pick<EscrowBlobPlaintext, 'recoveryEmail' | 'recoveryLanguage'> | null
619
+ ): boolean =>
620
+ action === 'carry' && !!target && (!!target.recoveryEmail || !!target.recoveryLanguage);
621
+
622
+ /** Owner-only mutation selection. Recovery itself never calls this helper.
623
+ * P9.1 (attack C): `recoveryEmail`/`recoveryLanguage` are always supplied by the
624
+ * caller (the device's authenticated account email + app locale) but are only
625
+ * bound into the intent for enroll/replace; disable/carry null them out here
626
+ * regardless of input, since the enclave carries the prior value forward. */
627
+ export const createEscrowEnrollmentIntent = async (input: {
628
+ state: EscrowAuthorityState;
629
+ target: EscrowEnvelope | null;
630
+ shareVersion: number;
631
+ privateKey: string;
632
+ pinAction: EscrowEnrollmentActionInput;
633
+ recoveryEmail: string;
634
+ recoveryLanguage: string;
635
+ }): Promise<SignedEscrowEnrollmentIntent> => {
636
+ const current = state(input.state);
637
+ const target = input.target === null ? null : envelope(input.target);
638
+ const disabling = input.pinAction === 'disable';
639
+ requireEvidence(
640
+ current.enrollmentVersion < Number.MAX_SAFE_INTEGER &&
641
+ (disabling || current.enrollmentEpoch < Number.MAX_SAFE_INTEGER)
642
+ );
643
+ if (disabling && current.status !== 'active') return fail();
644
+ if (current.status === 'disabled' && current.hasPin && input.pinAction === 'automatic')
645
+ return fail();
646
+ const { action, pinAction, bindsRecoveryContact } = resolveEscrowEnrollmentAction(
647
+ current,
648
+ input.pinAction
649
+ );
650
+ requireEvidence(!disabling || input.target === null);
651
+ requireEvidence(disabling || input.target !== null);
652
+ if (action === 'carry') requireEvidence(input.shareVersion > (current.shareVersion ?? 0));
653
+ const intent: EscrowEnrollmentIntent = {
654
+ version: 2,
655
+ tenant: current.tenant,
656
+ did: current.did,
657
+ authorityGeneration: current.authorityGeneration,
658
+ expectedEnrollmentVersion: current.enrollmentVersion,
659
+ action,
660
+ sourceBlobIdentity: current.envelope ? await getEscrowBlobIdentity(current.envelope) : null,
661
+ targetBlobIdentity: target ? await getEscrowBlobIdentity(target) : null,
662
+ pinAction,
663
+ shareVersion: disabling ? current.shareVersion! : input.shareVersion,
664
+ enrollmentEpoch: current.enrollmentEpoch + (disabling ? 0 : 1),
665
+ requestId: crypto.randomUUID(),
666
+ allowRewrap: !disabling,
667
+ recoveryEmail: bindsRecoveryContact ? input.recoveryEmail : null,
668
+ recoveryLanguage: bindsRecoveryContact ? input.recoveryLanguage : null,
669
+ };
670
+ return signEscrowEnrollmentIntent(intent, input.privateKey);
671
+ };