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