@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,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
|
+
}
|