@learncard/sss-key-manager 0.2.0 → 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.
- package/README.md +13 -0
- package/dist/escrow-attestation.d.ts +2 -5
- package/dist/escrow-attestation.d.ts.map +1 -1
- package/dist/escrow-authority.d.ts +112 -0
- package/dist/escrow-authority.d.ts.map +1 -0
- package/dist/escrow-authority.types.d.ts +51 -0
- package/dist/escrow-authority.types.d.ts.map +1 -0
- package/dist/escrow-crypto.d.ts +9 -1
- package/dist/escrow-crypto.d.ts.map +1 -1
- package/dist/escrow-enrollment-intent.d.ts +13 -0
- package/dist/escrow-enrollment-intent.d.ts.map +1 -0
- package/dist/escrow-enrollment-intent.types.d.ts +28 -0
- package/dist/escrow-enrollment-intent.types.d.ts.map +1 -0
- package/dist/escrow-nitro-attestation.d.ts +34 -0
- package/dist/escrow-nitro-attestation.d.ts.map +1 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/sss-key-manager.cjs.development.js +4478 -424
- package/dist/sss-key-manager.cjs.development.js.map +4 -4
- package/dist/sss-key-manager.cjs.production.min.js +37 -32
- package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
- package/dist/sss-key-manager.esm.js +1813 -265
- package/dist/sss-key-manager.esm.js.map +4 -4
- package/dist/sss-strategy.d.ts.map +1 -1
- package/dist/types.d.ts +31 -5
- package/dist/types.d.ts.map +1 -1
- package/package.json +7 -3
- package/src/__fixtures__/escrow-authority-v1.json +96 -0
- package/src/__fixtures__/escrow-carry-contact-contract-v1.json +33 -0
- package/src/__fixtures__/escrow-enrollment-intent-v1.json +26 -0
- package/src/__fixtures__/escrow-vectors-rust.json +82 -0
- package/src/__fixtures__/escrow-vectors.json +103 -0
- package/src/__fixtures__/nitro-attestation/bad-signature.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/debug-zero-pcrs.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/legacy-user-data.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/manifest.json +60 -0
- package/src/__fixtures__/nitro-attestation/nonce-mismatch.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/root.pem +12 -0
- package/src/__fixtures__/nitro-attestation/stale-timestamp.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/tampered-previous-key-ids.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/truncated-chain.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/valid.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/wrong-pcr0.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/wrong-root.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation-real/staging-nsm-document.json +16 -0
- package/src/escrow-attestation.test.ts +7 -2
- package/src/escrow-attestation.ts +72 -10
- package/src/escrow-authority.test.ts +643 -0
- package/src/escrow-authority.ts +597 -0
- package/src/escrow-authority.types.ts +56 -0
- package/src/escrow-crypto.ts +39 -1
- package/src/escrow-enrollment-intent.test.ts +224 -0
- package/src/escrow-enrollment-intent.ts +236 -0
- package/src/escrow-enrollment-intent.types.ts +28 -0
- package/src/escrow-nitro-attestation.test.ts +276 -0
- package/src/escrow-nitro-attestation.ts +317 -0
- package/src/escrow-vectors.test.ts +198 -0
- package/src/index.ts +35 -1
- package/src/sss-authority.test.ts +889 -0
- package/src/sss-session-writes.test.ts +2 -2
- package/src/sss-strategy.test.ts +95 -2
- package/src/sss-strategy.ts +596 -115
- package/src/types.ts +34 -4
|
@@ -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
|
+
});
|