@learncard/lca-api-service 1.5.0 → 1.5.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/.env.example +2 -2
- package/CHANGELOG.md +23 -0
- package/bundle.mjs +9 -1
- package/config/config.dev.json +1 -1
- package/dist/app.d.ts +220 -1
- package/dist/index.js +2 -2
- package/dist/models/EscrowHold.d.ts +30 -2
- package/dist/models/UserKey.d.ts +89 -5
- package/dist/publish-metafile.json +1 -0
- package/dist/routes/escrow.d.ts +218 -0
- package/dist/routes/index.d.ts +4 -4
- package/dist/routes/keys.d.ts +2 -1
- package/dist/services/escrow-enclave/authorityContract.d.ts +170 -0
- package/dist/services/escrow-enclave/types.d.ts +38 -2
- package/package.json +9 -10
- package/project.json +8 -1
- package/serverless.yml +2 -0
- package/src/config/environment.ts +1 -1
- package/src/config/escrowDeployment.test.ts +8 -0
- package/src/jobs/escrowBlobRewrap.ts +45 -2
- package/src/jobs/escrowHoldReminders.ts +50 -1
- package/src/models/EscrowHold.ts +128 -10
- package/src/models/UserKey.ts +59 -8
- package/src/routes/escrow.ts +371 -51
- package/src/routes/index.ts +1 -1
- package/src/routes/keys.ts +5 -0
- package/src/services/escrow-enclave/README.md +31 -0
- package/src/services/escrow-enclave/authorityContract.ts +126 -0
- package/src/services/escrow-enclave/cancellation.ts +2 -2
- package/src/services/escrow-enclave/remoteEnclave.test.ts +216 -3
- package/src/services/escrow-enclave/remoteEnclave.ts +129 -23
- package/src/services/escrow-enclave/types.ts +51 -3
- package/test/jobs/escrowHoldReminders.spec.ts +195 -3
- package/test/keys-escrow.spec.ts +522 -11
- package/test/models/escrow.spec.ts +13 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import {
|
|
3
|
+
encodeEscrowEnrollmentIntent,
|
|
4
|
+
ESCROW_ALGORITHM,
|
|
5
|
+
ESCROW_ENVELOPE_VERSION,
|
|
6
|
+
} from '@learncard/sss-key-manager';
|
|
7
|
+
|
|
8
|
+
export const safeInteger = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
|
|
9
|
+
const label = z
|
|
10
|
+
.string()
|
|
11
|
+
.min(1)
|
|
12
|
+
.max(128)
|
|
13
|
+
.regex(/^[A-Za-z0-9_-]+$/);
|
|
14
|
+
export const AuthorityEnvelopeValidator = z
|
|
15
|
+
.object({
|
|
16
|
+
version: z.literal(ESCROW_ENVELOPE_VERSION),
|
|
17
|
+
algorithm: z.literal(ESCROW_ALGORITHM),
|
|
18
|
+
keyId: z.string().min(1).max(128),
|
|
19
|
+
ephemeralPublicKey: z.string().max(256),
|
|
20
|
+
salt: z.string().max(128),
|
|
21
|
+
iv: z.string().max(64),
|
|
22
|
+
ciphertext: z.string().max(16_384),
|
|
23
|
+
})
|
|
24
|
+
.strict();
|
|
25
|
+
export const AuthorityRecordValidator = z
|
|
26
|
+
.object({
|
|
27
|
+
version: z.literal(1),
|
|
28
|
+
kind: z.enum(['proposal', 'commit', 'snapshot']),
|
|
29
|
+
keyId: z.string().min(1).max(128),
|
|
30
|
+
measurement: z.array(z.number().int().min(0).max(255)).length(32),
|
|
31
|
+
payload: z.string().min(1).max(49_152),
|
|
32
|
+
signature: z.string().length(88),
|
|
33
|
+
})
|
|
34
|
+
.strict();
|
|
35
|
+
export const AuthorityCommitValidator = AuthorityRecordValidator.extend({
|
|
36
|
+
kind: z.literal('commit'),
|
|
37
|
+
payload: z.string().min(1).max(8192),
|
|
38
|
+
});
|
|
39
|
+
// Idempotent-retry attestation for confirm-hold / send-release-warning (see
|
|
40
|
+
// `authority_hold_snapshot` in policy/authority.rs): a fresh call returns a
|
|
41
|
+
// `commit`; a retry of an already-applied operation returns this instead,
|
|
42
|
+
// signed over the hold's current fields rather than a new ledger transition.
|
|
43
|
+
export const AuthorityHoldSnapshotValidator = AuthorityRecordValidator.extend({
|
|
44
|
+
kind: z.literal('snapshot'),
|
|
45
|
+
});
|
|
46
|
+
// confirm-hold and send-release-warning responses carry whichever shape the
|
|
47
|
+
// enclave actually returned; callers must not assume `commit`.
|
|
48
|
+
export const AuthorityHoldReceiptValidator = z.union([
|
|
49
|
+
AuthorityCommitValidator,
|
|
50
|
+
AuthorityHoldSnapshotValidator,
|
|
51
|
+
]);
|
|
52
|
+
export const SignedEnrollmentIntentValidator = z
|
|
53
|
+
.object({
|
|
54
|
+
intent: z
|
|
55
|
+
.object({
|
|
56
|
+
version: z.literal(2),
|
|
57
|
+
tenant: label,
|
|
58
|
+
did: z.string().length(56),
|
|
59
|
+
authorityGeneration: label,
|
|
60
|
+
expectedEnrollmentVersion: safeInteger,
|
|
61
|
+
action: z.enum(['enroll', 'replace', 'disable', 'carry']),
|
|
62
|
+
sourceBlobIdentity: z
|
|
63
|
+
.string()
|
|
64
|
+
.regex(/^[0-9a-f]{64}$/)
|
|
65
|
+
.nullable(),
|
|
66
|
+
targetBlobIdentity: z
|
|
67
|
+
.string()
|
|
68
|
+
.regex(/^[0-9a-f]{64}$/)
|
|
69
|
+
.nullable(),
|
|
70
|
+
pinAction: z.enum(['set', 'change', 'clear', 'keep']),
|
|
71
|
+
shareVersion: z.number().int().positive().max(0xffffffff),
|
|
72
|
+
enrollmentEpoch: safeInteger.positive(),
|
|
73
|
+
requestId: z.string().min(1).max(112),
|
|
74
|
+
allowRewrap: z.boolean(),
|
|
75
|
+
// P9.1 (attack C): shape only — `encodeEscrowEnrollmentIntent` below
|
|
76
|
+
// enforces normalization, the paired requirement, and the language
|
|
77
|
+
// allow-list; lca-api never re-derives or overrides these values.
|
|
78
|
+
recoveryEmail: z.string().min(3).max(254).nullable(),
|
|
79
|
+
recoveryLanguage: z.string().length(2).nullable(),
|
|
80
|
+
})
|
|
81
|
+
.strict()
|
|
82
|
+
.refine(intent => {
|
|
83
|
+
try {
|
|
84
|
+
encodeEscrowEnrollmentIntent(intent);
|
|
85
|
+
return true;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}, 'Invalid enrollment intent'),
|
|
90
|
+
signature: z.string().length(88),
|
|
91
|
+
})
|
|
92
|
+
.strict();
|
|
93
|
+
export const AuthorityStateValidator = z
|
|
94
|
+
.object({
|
|
95
|
+
tenant: label,
|
|
96
|
+
authorityGeneration: label,
|
|
97
|
+
did: z.string().min(1).max(8192),
|
|
98
|
+
enrollmentVersion: safeInteger,
|
|
99
|
+
enrollmentEpoch: safeInteger,
|
|
100
|
+
status: z.enum(['empty', 'active', 'disabled']),
|
|
101
|
+
envelope: AuthorityEnvelopeValidator.nullable(),
|
|
102
|
+
shareVersion: z.number().int().positive().max(0xffffffff).nullable(),
|
|
103
|
+
hasPin: z.boolean(),
|
|
104
|
+
pinGeneration: safeInteger,
|
|
105
|
+
attemptsUsed: z.number().int().min(0).max(10),
|
|
106
|
+
})
|
|
107
|
+
.strict();
|
|
108
|
+
export const AuthorityStateResponseValidator = z
|
|
109
|
+
.object({
|
|
110
|
+
state: AuthorityStateValidator,
|
|
111
|
+
proof: AuthorityRecordValidator.extend({ kind: z.literal('snapshot') }),
|
|
112
|
+
})
|
|
113
|
+
.strict();
|
|
114
|
+
export const AuthorityMutationValidator = z
|
|
115
|
+
.object({
|
|
116
|
+
envelope: AuthorityEnvelopeValidator.nullable(),
|
|
117
|
+
receipt: AuthorityCommitValidator,
|
|
118
|
+
authorityGeneration: label,
|
|
119
|
+
enrollmentVersion: safeInteger.positive(),
|
|
120
|
+
enrollmentEpoch: safeInteger.positive(),
|
|
121
|
+
})
|
|
122
|
+
.strict();
|
|
123
|
+
export type AuthorityRecord = z.infer<typeof AuthorityRecordValidator>;
|
|
124
|
+
export type AuthorityHoldReceipt = z.infer<typeof AuthorityHoldReceiptValidator>;
|
|
125
|
+
export type AuthorityMutationResult = z.infer<typeof AuthorityMutationValidator>;
|
|
126
|
+
export type AuthorityStateResponse = z.infer<typeof AuthorityStateResponseValidator>;
|
|
@@ -40,13 +40,13 @@ export const cancelHoldInEnclave = async (
|
|
|
40
40
|
) {
|
|
41
41
|
throw new Error('Cancellation material unavailable');
|
|
42
42
|
}
|
|
43
|
-
await getEscrowEnclave().cancelHold({
|
|
43
|
+
const result = await getEscrowEnclave().cancelHold({
|
|
44
44
|
envelope,
|
|
45
45
|
hold: hold.holdRecord,
|
|
46
46
|
clientEphemeralPublicKey: hold.clientEphemeralPublicKey,
|
|
47
47
|
expectedDid: hold.primaryDid,
|
|
48
48
|
});
|
|
49
|
-
await confirmEscrowEnclaveCancellation(hold._id);
|
|
49
|
+
await confirmEscrowEnclaveCancellation(hold._id, result?.receipt);
|
|
50
50
|
return true;
|
|
51
51
|
} catch {
|
|
52
52
|
// Policy/unknown/terminal errors are ambiguous, not proof of cancellation.
|
|
@@ -12,6 +12,35 @@ import {
|
|
|
12
12
|
const baseUrl = 'https://enclave-host.internal';
|
|
13
13
|
const token = 't'.repeat(32);
|
|
14
14
|
const timeoutMs = 10_000;
|
|
15
|
+
const receipt = {
|
|
16
|
+
version: 1 as const,
|
|
17
|
+
kind: 'commit' as const,
|
|
18
|
+
keyId: 'key-1',
|
|
19
|
+
measurement: Array(32).fill(0),
|
|
20
|
+
payload: 'AQ==',
|
|
21
|
+
signature: Buffer.alloc(64).toString('base64'),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Real Rust shape for the idempotent-retry path (`authority_hold_snapshot`,
|
|
25
|
+
// policy/authority.rs): a signed tuple, never an `AuthorityCommitValidator`.
|
|
26
|
+
const snapshotReceipt = {
|
|
27
|
+
version: 1 as const,
|
|
28
|
+
kind: 'snapshot' as const,
|
|
29
|
+
keyId: 'key-1',
|
|
30
|
+
measurement: Array(32).fill(0),
|
|
31
|
+
payload: Buffer.from(
|
|
32
|
+
JSON.stringify([
|
|
33
|
+
'learncard-authority-hold-snapshot-v2',
|
|
34
|
+
'hold-1',
|
|
35
|
+
Array(32).fill(7),
|
|
36
|
+
'pending',
|
|
37
|
+
1700000000000,
|
|
38
|
+
1700086400000,
|
|
39
|
+
'g1',
|
|
40
|
+
])
|
|
41
|
+
).toString('base64'),
|
|
42
|
+
signature: Buffer.alloc(64).toString('base64'),
|
|
43
|
+
};
|
|
15
44
|
|
|
16
45
|
const envelope: EscrowEnvelope = {
|
|
17
46
|
version: 1,
|
|
@@ -223,7 +252,14 @@ describe('createRemoteEnclave', () => {
|
|
|
223
252
|
});
|
|
224
253
|
|
|
225
254
|
it('rewraps an escrow blob sealed under a previous key onto the current key', async () => {
|
|
226
|
-
const
|
|
255
|
+
const authority = {
|
|
256
|
+
envelope,
|
|
257
|
+
receipt,
|
|
258
|
+
authorityGeneration: 'g1',
|
|
259
|
+
enrollmentVersion: 2,
|
|
260
|
+
enrollmentEpoch: 1,
|
|
261
|
+
};
|
|
262
|
+
const post = vi.fn().mockResolvedValue(respond(200, { result: authority }));
|
|
227
263
|
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
228
264
|
const input = {
|
|
229
265
|
envelope,
|
|
@@ -232,10 +268,10 @@ describe('createRemoteEnclave', () => {
|
|
|
232
268
|
sourceEnrollmentEpoch: 1,
|
|
233
269
|
};
|
|
234
270
|
|
|
235
|
-
await expect(enclave.rewrapEscrowBlob(input)).resolves.toEqual({ envelope });
|
|
271
|
+
await expect(enclave.rewrapEscrowBlob(input)).resolves.toEqual({ envelope, authority });
|
|
236
272
|
|
|
237
273
|
expect(post).toHaveBeenCalledWith(
|
|
238
|
-
`${baseUrl}/v1/rewrap
|
|
274
|
+
`${baseUrl}/v1/authority-rewrap`,
|
|
239
275
|
{
|
|
240
276
|
envelope,
|
|
241
277
|
expectedDid: 'did:example:alice',
|
|
@@ -246,6 +282,183 @@ describe('createRemoteEnclave', () => {
|
|
|
246
282
|
);
|
|
247
283
|
});
|
|
248
284
|
|
|
285
|
+
it('preserves v2 cancellation receipts and stable release IDs, refusing absent commit evidence', async () => {
|
|
286
|
+
const v2 = {
|
|
287
|
+
...hold,
|
|
288
|
+
hold: {
|
|
289
|
+
...hold.hold,
|
|
290
|
+
policyVersion: 2,
|
|
291
|
+
signature: Buffer.from(JSON.stringify(receipt)).toString('base64'),
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
const post = vi.fn().mockResolvedValue(respond(200, { ok: true, receipt }));
|
|
295
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
296
|
+
const input = {
|
|
297
|
+
envelope,
|
|
298
|
+
hold: v2,
|
|
299
|
+
expectedDid: v2.hold.did,
|
|
300
|
+
clientEphemeralPublicKey: v2.hold.clientEphemeralPublicKey,
|
|
301
|
+
requestId: 'stable-id',
|
|
302
|
+
};
|
|
303
|
+
await expect(enclave.cancelHold(input)).resolves.toEqual({ receipt });
|
|
304
|
+
post.mockResolvedValue(respond(200, { sealed: envelope, receipt }));
|
|
305
|
+
await expect(enclave.releaseEscrow(input)).resolves.toEqual({ sealed: envelope, receipt });
|
|
306
|
+
expect(post.mock.calls.at(-1)?.[1]).toMatchObject({ requestId: 'stable-id' });
|
|
307
|
+
post.mockResolvedValue(respond(200, { sealed: envelope }));
|
|
308
|
+
await expect(enclave.releaseEscrow(input)).rejects.toBeInstanceOf(EscrowUnavailableError);
|
|
309
|
+
post.mockResolvedValue(respond(200, { ok: true }));
|
|
310
|
+
await expect(enclave.cancelHold(input)).rejects.toBeInstanceOf(EscrowUnavailableError);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// [A] confirm-hold/send-release-warning: a fresh call returns a signed
|
|
314
|
+
// `commit`; an idempotent retry (lost response, hourly job re-run)
|
|
315
|
+
// returns a signed `snapshot` instead — both are real response shapes
|
|
316
|
+
// the enclave can return, never a stub that only ever returns `commit`.
|
|
317
|
+
const confirmInput = {
|
|
318
|
+
envelope,
|
|
319
|
+
hold,
|
|
320
|
+
clientEphemeralPublicKey: hold.hold.clientEphemeralPublicKey,
|
|
321
|
+
expectedDid: hold.hold.did,
|
|
322
|
+
code: '12345678',
|
|
323
|
+
};
|
|
324
|
+
it('confirmHold accepts a fresh commit receipt and returns the authoritative confirmation time', async () => {
|
|
325
|
+
const post = vi
|
|
326
|
+
.fn()
|
|
327
|
+
.mockResolvedValue(
|
|
328
|
+
respond(200, { confirmed: true, receipt, confirmedHiMs: 1700000000000 })
|
|
329
|
+
);
|
|
330
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
331
|
+
|
|
332
|
+
await expect(enclave.confirmHold!(confirmInput)).resolves.toEqual({
|
|
333
|
+
receipt,
|
|
334
|
+
confirmedAtMs: 1700000000000,
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
it('confirmHold accepts a retry snapshot receipt (not a commit) and returns the SAME confirmation time', async () => {
|
|
338
|
+
const post = vi.fn().mockResolvedValue(
|
|
339
|
+
respond(200, {
|
|
340
|
+
confirmed: true,
|
|
341
|
+
receipt: snapshotReceipt,
|
|
342
|
+
confirmedHiMs: 1700000000000,
|
|
343
|
+
})
|
|
344
|
+
);
|
|
345
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
346
|
+
|
|
347
|
+
await expect(enclave.confirmHold!(confirmInput)).resolves.toEqual({
|
|
348
|
+
receipt: snapshotReceipt,
|
|
349
|
+
confirmedAtMs: 1700000000000,
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
it('confirmHold fails closed when the receipt is neither a commit nor a snapshot', async () => {
|
|
353
|
+
const post = vi.fn().mockResolvedValue(
|
|
354
|
+
respond(200, {
|
|
355
|
+
confirmed: true,
|
|
356
|
+
receipt: { ...receipt, kind: 'proposal' },
|
|
357
|
+
confirmedHiMs: 1700000000000,
|
|
358
|
+
})
|
|
359
|
+
);
|
|
360
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
361
|
+
|
|
362
|
+
await expect(enclave.confirmHold!(confirmInput)).rejects.toBeInstanceOf(
|
|
363
|
+
EscrowUnavailableError
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
it('confirmHold fails closed when confirmedHiMs is missing (pre-fix response shape)', async () => {
|
|
367
|
+
const post = vi.fn().mockResolvedValue(respond(200, { confirmed: true, receipt }));
|
|
368
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
369
|
+
|
|
370
|
+
await expect(enclave.confirmHold!(confirmInput)).rejects.toBeInstanceOf(
|
|
371
|
+
EscrowUnavailableError
|
|
372
|
+
);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const warningInput = { holdId: hold.hold.holdId, expectedDid: hold.hold.did };
|
|
376
|
+
it('sendReleaseWarning accepts a fresh commit receipt and returns the authoritative send time', async () => {
|
|
377
|
+
const post = vi
|
|
378
|
+
.fn()
|
|
379
|
+
.mockResolvedValue(
|
|
380
|
+
respond(200, { sent: true, receipt, releaseWarningSentHiMs: 1700086400000 })
|
|
381
|
+
);
|
|
382
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
383
|
+
|
|
384
|
+
await expect(enclave.sendReleaseWarning!(warningInput)).resolves.toEqual({
|
|
385
|
+
receipt,
|
|
386
|
+
releaseWarningSentAtMs: 1700086400000,
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
it('sendReleaseWarning accepts a retry snapshot receipt (not a commit) and returns the SAME send time', async () => {
|
|
390
|
+
const post = vi.fn().mockResolvedValue(
|
|
391
|
+
respond(200, {
|
|
392
|
+
sent: true,
|
|
393
|
+
receipt: snapshotReceipt,
|
|
394
|
+
releaseWarningSentHiMs: 1700086400000,
|
|
395
|
+
})
|
|
396
|
+
);
|
|
397
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
398
|
+
|
|
399
|
+
await expect(enclave.sendReleaseWarning!(warningInput)).resolves.toEqual({
|
|
400
|
+
receipt: snapshotReceipt,
|
|
401
|
+
releaseWarningSentAtMs: 1700086400000,
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
it('sendReleaseWarning fails closed when releaseWarningSentHiMs is missing (pre-fix response shape)', async () => {
|
|
405
|
+
const post = vi.fn().mockResolvedValue(respond(200, { sent: true, receipt }));
|
|
406
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
407
|
+
|
|
408
|
+
await expect(enclave.sendReleaseWarning!(warningInput)).rejects.toBeInstanceOf(
|
|
409
|
+
EscrowUnavailableError
|
|
410
|
+
);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('forwards signed enrollment once and rejects a proposal or mismatched result generation', async () => {
|
|
414
|
+
const input = {
|
|
415
|
+
signedIntent: {
|
|
416
|
+
intent: {
|
|
417
|
+
version: 2 as const,
|
|
418
|
+
tenant: 'learncard',
|
|
419
|
+
did: 'did:key:z6MktwupdmLXVVqTzCw4i46r4uGyosGXRnR3XjN4Zq7oMMsw',
|
|
420
|
+
authorityGeneration: 'g1',
|
|
421
|
+
expectedEnrollmentVersion: 0,
|
|
422
|
+
action: 'enroll' as const,
|
|
423
|
+
sourceBlobIdentity: null,
|
|
424
|
+
targetBlobIdentity: 'ab'.repeat(32),
|
|
425
|
+
pinAction: 'keep' as const,
|
|
426
|
+
shareVersion: 1,
|
|
427
|
+
enrollmentEpoch: 1,
|
|
428
|
+
requestId: 'request',
|
|
429
|
+
allowRewrap: true,
|
|
430
|
+
recoveryEmail: 'owner@example.com',
|
|
431
|
+
recoveryLanguage: 'en',
|
|
432
|
+
},
|
|
433
|
+
signature: Buffer.alloc(64).toString('base64'),
|
|
434
|
+
},
|
|
435
|
+
sourceEnvelope: null,
|
|
436
|
+
targetEnvelope: envelope,
|
|
437
|
+
};
|
|
438
|
+
const result = {
|
|
439
|
+
envelope,
|
|
440
|
+
receipt,
|
|
441
|
+
authorityGeneration: 'g1',
|
|
442
|
+
enrollmentVersion: 1,
|
|
443
|
+
enrollmentEpoch: 1,
|
|
444
|
+
};
|
|
445
|
+
const post = vi.fn().mockResolvedValue(respond(200, { result }));
|
|
446
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
447
|
+
await expect(enclave.applyEnrollmentIntent!(input)).resolves.toEqual(result);
|
|
448
|
+
expect(post).toHaveBeenCalledTimes(1);
|
|
449
|
+
expect(post.mock.calls[0]?.slice(0, 2)).toEqual([`${baseUrl}/v1/apply-enrollment`, input]);
|
|
450
|
+
post.mockResolvedValue(respond(200, { result: { ...result, authorityGeneration: 'g2' } }));
|
|
451
|
+
await expect(enclave.applyEnrollmentIntent!(input)).rejects.toBeInstanceOf(
|
|
452
|
+
EscrowUnavailableError
|
|
453
|
+
);
|
|
454
|
+
post.mockResolvedValue(
|
|
455
|
+
respond(200, { result: { ...result, receipt: { ...receipt, kind: 'proposal' } } })
|
|
456
|
+
);
|
|
457
|
+
await expect(enclave.applyEnrollmentIntent!(input)).rejects.toBeInstanceOf(
|
|
458
|
+
EscrowUnavailableError
|
|
459
|
+
);
|
|
460
|
+
});
|
|
461
|
+
|
|
249
462
|
it.each([
|
|
250
463
|
['policy', EscrowPolicyError],
|
|
251
464
|
['pinMismatch', EscrowPinMismatchError],
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { Agent } from 'node:https';
|
|
2
2
|
import axios from 'axios';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
+
import {
|
|
5
|
+
AuthorityCommitValidator,
|
|
6
|
+
AuthorityHoldReceiptValidator,
|
|
7
|
+
AuthorityMutationValidator,
|
|
8
|
+
AuthorityStateResponseValidator,
|
|
9
|
+
safeInteger,
|
|
10
|
+
} from './authorityContract';
|
|
4
11
|
import {
|
|
5
12
|
ESCROW_ALGORITHM,
|
|
6
13
|
ESCROW_ENVELOPE_VERSION,
|
|
@@ -12,11 +19,14 @@ import {
|
|
|
12
19
|
EscrowPolicyError,
|
|
13
20
|
EscrowUnavailableError,
|
|
14
21
|
type CarryPinVerifierInput,
|
|
22
|
+
type ConfirmHoldRequest,
|
|
15
23
|
type EnclaveAttestation,
|
|
16
24
|
type EscrowEnclave,
|
|
17
25
|
type ReleaseRequest,
|
|
18
26
|
type ReleaseResult,
|
|
27
|
+
type ResendCodeRequest,
|
|
19
28
|
type RewrapEscrowBlobInput,
|
|
29
|
+
type SendReleaseWarningRequest,
|
|
20
30
|
type VerifyEscrowBlobInput,
|
|
21
31
|
type VerifyEscrowBlobResult,
|
|
22
32
|
} from './types';
|
|
@@ -58,7 +68,6 @@ const verifyBlobResponseValidator = z.union([
|
|
|
58
68
|
]);
|
|
59
69
|
|
|
60
70
|
const carryPinVerifierResponseValidator = z.object({ envelope: envelopeValidator });
|
|
61
|
-
const rewrapEscrowBlobResponseValidator = z.object({ envelope: envelopeValidator });
|
|
62
71
|
|
|
63
72
|
// Opaque SignedHoldRecord JSON: validate shape while preserving signed extensions.
|
|
64
73
|
const holdRecordValidator = z
|
|
@@ -74,8 +83,8 @@ const holdRecordValidator = z
|
|
|
74
83
|
clientEphemeralPublicKey: z.string().min(1),
|
|
75
84
|
createdLo: z.number().int().nonnegative(),
|
|
76
85
|
createdHi: z.number().int().nonnegative(),
|
|
77
|
-
policyVersion: z.
|
|
78
|
-
signature: z.string().min(1),
|
|
86
|
+
policyVersion: z.union([z.literal(1), z.literal(2)]),
|
|
87
|
+
signature: z.string().min(1).max(8192),
|
|
79
88
|
})
|
|
80
89
|
.passthrough(),
|
|
81
90
|
holdDurationMs: z.number().int().nonnegative(),
|
|
@@ -84,9 +93,21 @@ const holdRecordValidator = z
|
|
|
84
93
|
.passthrough();
|
|
85
94
|
const validatedHold = (hold: ReleaseRequest['hold']): ReleaseRequest['hold'] => {
|
|
86
95
|
if (!holdRecordValidator.safeParse(hold).success) throw new EscrowUnavailableError();
|
|
96
|
+
if (hold.hold.policyVersion === 2) {
|
|
97
|
+
try {
|
|
98
|
+
AuthorityCommitValidator.parse(
|
|
99
|
+
JSON.parse(Buffer.from(hold.hold.signature, 'base64').toString('utf8'))
|
|
100
|
+
);
|
|
101
|
+
} catch {
|
|
102
|
+
throw new EscrowUnavailableError();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
87
105
|
return hold;
|
|
88
106
|
};
|
|
89
|
-
const releaseResponseValidator = z.object({
|
|
107
|
+
const releaseResponseValidator = z.object({
|
|
108
|
+
sealed: envelopeValidator,
|
|
109
|
+
receipt: AuthorityCommitValidator.optional(),
|
|
110
|
+
});
|
|
90
111
|
|
|
91
112
|
const errorCodeValidator = z.enum([
|
|
92
113
|
'policy',
|
|
@@ -181,6 +202,24 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
|
|
|
181
202
|
};
|
|
182
203
|
|
|
183
204
|
return {
|
|
205
|
+
getEnrollmentState: input =>
|
|
206
|
+
call('/v1/enrollment-state', input, AuthorityStateResponseValidator),
|
|
207
|
+
applyEnrollmentIntent: async input => {
|
|
208
|
+
const { result } = await call(
|
|
209
|
+
'/v1/apply-enrollment',
|
|
210
|
+
input,
|
|
211
|
+
z.object({ result: AuthorityMutationValidator }).strict()
|
|
212
|
+
);
|
|
213
|
+
if (
|
|
214
|
+
result.authorityGeneration !== input.signedIntent.intent.authorityGeneration ||
|
|
215
|
+
result.enrollmentVersion !==
|
|
216
|
+
input.signedIntent.intent.expectedEnrollmentVersion + 1 ||
|
|
217
|
+
result.enrollmentEpoch !== input.signedIntent.intent.enrollmentEpoch ||
|
|
218
|
+
(result.envelope === null) !== (input.signedIntent.intent.action === 'disable')
|
|
219
|
+
)
|
|
220
|
+
throw new EscrowUnavailableError();
|
|
221
|
+
return result;
|
|
222
|
+
},
|
|
184
223
|
getAttestation: (nonce?: Uint8Array): Promise<EnclaveAttestation> =>
|
|
185
224
|
call('/v1/attest', { nonce: Array.from(nonce ?? []) }, attestResponseValidator),
|
|
186
225
|
verifyEscrowBlob: (input: VerifyEscrowBlobInput): Promise<VerifyEscrowBlobResult> =>
|
|
@@ -207,47 +246,110 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
|
|
|
207
246
|
},
|
|
208
247
|
carryPinVerifierResponseValidator
|
|
209
248
|
),
|
|
210
|
-
rewrapEscrowBlob: (input: RewrapEscrowBlobInput)
|
|
211
|
-
call(
|
|
212
|
-
'/v1/rewrap
|
|
249
|
+
rewrapEscrowBlob: async (input: RewrapEscrowBlobInput) => {
|
|
250
|
+
const { result } = await call(
|
|
251
|
+
'/v1/authority-rewrap',
|
|
213
252
|
{
|
|
214
253
|
envelope: input.envelope,
|
|
215
254
|
expectedDid: input.expectedDid,
|
|
216
255
|
expectedShareVersion: input.expectedShareVersion,
|
|
217
256
|
sourceEnrollmentEpoch: input.sourceEnrollmentEpoch,
|
|
218
257
|
},
|
|
219
|
-
|
|
220
|
-
)
|
|
258
|
+
z.object({ result: AuthorityMutationValidator }).strict()
|
|
259
|
+
);
|
|
260
|
+
if (!result.envelope || result.enrollmentEpoch !== input.sourceEnrollmentEpoch)
|
|
261
|
+
throw new EscrowUnavailableError();
|
|
262
|
+
return { envelope: result.envelope, authority: result };
|
|
263
|
+
},
|
|
221
264
|
createHold: async input => ({
|
|
222
|
-
holdRecord:
|
|
223
|
-
|
|
265
|
+
holdRecord: validatedHold(
|
|
266
|
+
await call(
|
|
267
|
+
'/v1/create-hold',
|
|
268
|
+
{
|
|
269
|
+
envelope: input.envelope,
|
|
270
|
+
holdId: input.holdId,
|
|
271
|
+
expectedDid: input.expectedDid,
|
|
272
|
+
expectedShareVersion: input.expectedShareVersion,
|
|
273
|
+
enrollmentEpoch: input.enrollmentEpoch,
|
|
274
|
+
releasePolicy: input.releasePolicy,
|
|
275
|
+
clientEphemeralPublicKey: input.clientEphemeralPublicKey,
|
|
276
|
+
},
|
|
277
|
+
holdRecordValidator
|
|
278
|
+
)
|
|
279
|
+
),
|
|
280
|
+
}),
|
|
281
|
+
cancelHold: async input => {
|
|
282
|
+
const result = await call(
|
|
283
|
+
'/v1/cancel-hold',
|
|
224
284
|
{
|
|
225
285
|
envelope: input.envelope,
|
|
226
|
-
|
|
286
|
+
hold: validatedHold(input.hold),
|
|
287
|
+
clientEphemeralPublicKey: input.clientEphemeralPublicKey,
|
|
227
288
|
expectedDid: input.expectedDid,
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
289
|
+
...(input.requestId ? { requestId: input.requestId } : {}),
|
|
290
|
+
},
|
|
291
|
+
z.object({ ok: z.literal(true), receipt: AuthorityCommitValidator.optional() })
|
|
292
|
+
);
|
|
293
|
+
if (input.hold.hold.policyVersion === 2 && !result.receipt)
|
|
294
|
+
throw new EscrowUnavailableError();
|
|
295
|
+
if (result.receipt) return { receipt: result.receipt };
|
|
296
|
+
return undefined;
|
|
297
|
+
},
|
|
298
|
+
confirmHold: async (input: ConfirmHoldRequest) => {
|
|
299
|
+
const result = await call(
|
|
300
|
+
'/v1/confirm-hold',
|
|
301
|
+
{
|
|
302
|
+
envelope: input.envelope,
|
|
303
|
+
hold: validatedHold(input.hold),
|
|
231
304
|
clientEphemeralPublicKey: input.clientEphemeralPublicKey,
|
|
305
|
+
expectedDid: input.expectedDid,
|
|
306
|
+
code: input.code,
|
|
307
|
+
...(input.requestId ? { requestId: input.requestId } : {}),
|
|
232
308
|
},
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
309
|
+
z.object({
|
|
310
|
+
confirmed: z.literal(true),
|
|
311
|
+
receipt: AuthorityHoldReceiptValidator,
|
|
312
|
+
confirmedHiMs: safeInteger,
|
|
313
|
+
})
|
|
314
|
+
);
|
|
315
|
+
return { receipt: result.receipt, confirmedAtMs: result.confirmedHiMs };
|
|
316
|
+
},
|
|
317
|
+
resendCode: async (input: ResendCodeRequest) => {
|
|
318
|
+
const result = await call(
|
|
319
|
+
'/v1/resend-code',
|
|
239
320
|
{
|
|
240
321
|
envelope: input.envelope,
|
|
241
322
|
hold: validatedHold(input.hold),
|
|
242
323
|
clientEphemeralPublicKey: input.clientEphemeralPublicKey,
|
|
243
324
|
expectedDid: input.expectedDid,
|
|
325
|
+
...(input.requestId ? { requestId: input.requestId } : {}),
|
|
326
|
+
},
|
|
327
|
+
z.object({ sent: z.literal(true), receipt: AuthorityCommitValidator })
|
|
328
|
+
);
|
|
329
|
+
return { receipt: result.receipt };
|
|
330
|
+
},
|
|
331
|
+
sendReleaseWarning: async (input: SendReleaseWarningRequest) => {
|
|
332
|
+
const result = await call(
|
|
333
|
+
'/v1/send-release-warning',
|
|
334
|
+
{
|
|
335
|
+
holdId: input.holdId,
|
|
336
|
+
expectedDid: input.expectedDid,
|
|
337
|
+
...(input.requestId ? { requestId: input.requestId } : {}),
|
|
244
338
|
},
|
|
245
|
-
z.object({
|
|
339
|
+
z.object({
|
|
340
|
+
sent: z.literal(true),
|
|
341
|
+
receipt: AuthorityHoldReceiptValidator,
|
|
342
|
+
releaseWarningSentHiMs: safeInteger,
|
|
343
|
+
})
|
|
246
344
|
);
|
|
345
|
+
return {
|
|
346
|
+
receipt: result.receipt,
|
|
347
|
+
releaseWarningSentAtMs: result.releaseWarningSentHiMs,
|
|
348
|
+
};
|
|
247
349
|
},
|
|
248
350
|
releaseEscrow: async (input: ReleaseRequest): Promise<ReleaseResult> => {
|
|
249
351
|
validatedHold(input.hold);
|
|
250
|
-
|
|
352
|
+
const result = await call(
|
|
251
353
|
'/v1/release',
|
|
252
354
|
{
|
|
253
355
|
envelope: input.envelope,
|
|
@@ -255,9 +357,13 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
|
|
|
255
357
|
clientEphemeralPublicKey: input.clientEphemeralPublicKey,
|
|
256
358
|
expectedDid: input.expectedDid,
|
|
257
359
|
...(input.pinProof !== undefined ? { pinProof: input.pinProof } : {}),
|
|
360
|
+
...(input.requestId ? { requestId: input.requestId } : {}),
|
|
258
361
|
},
|
|
259
362
|
releaseResponseValidator
|
|
260
363
|
);
|
|
364
|
+
if (input.hold.hold.policyVersion === 2 && !result.receipt)
|
|
365
|
+
throw new EscrowUnavailableError();
|
|
366
|
+
return result;
|
|
261
367
|
},
|
|
262
368
|
};
|
|
263
369
|
};
|