@learncard/lca-api-service 1.5.1 → 1.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +2 -2
- package/CHANGELOG.md +32 -0
- package/config/config.dev.json +1 -1
- package/dist/app.d.ts +239 -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 -1
- package/dist/routes/ai.test.d.ts +1 -0
- package/dist/routes/escrow.d.ts +237 -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/logFailure.d.ts +1 -0
- package/dist/services/escrow-enclave/logFailure.test.d.ts +1 -0
- package/dist/services/escrow-enclave/types.d.ts +55 -3
- package/esbuildPlugins.cjs +9 -6
- package/package.json +10 -10
- 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/ai.test.ts +103 -0
- package/src/routes/ai.ts +2 -1
- package/src/routes/escrow.ts +386 -53
- 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/logFailure.test.ts +64 -0
- package/src/services/escrow-enclave/logFailure.ts +29 -0
- package/src/services/escrow-enclave/remoteEnclave.test.ts +315 -4
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +182 -2
- package/src/services/escrow-enclave/remoteEnclave.ts +202 -31
- package/src/services/escrow-enclave/types.ts +77 -5
- package/test/jobs/escrowHoldReminders.spec.ts +195 -3
- package/test/keys-escrow.spec.ts +587 -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.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createRemoteEnclave } from './remoteEnclave';
|
|
3
|
+
import { logEscrowFailure } from './logFailure';
|
|
4
|
+
import { EscrowUnavailableError } from './types';
|
|
5
|
+
|
|
6
|
+
afterEach(() => vi.restoreAllMocks());
|
|
7
|
+
|
|
8
|
+
describe('escrow failure reasons', () => {
|
|
9
|
+
it('logs invalid outbound holds without error details', () => {
|
|
10
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
11
|
+
const error = new EscrowUnavailableError('invalid_hold');
|
|
12
|
+
error.message = 'private hold contents';
|
|
13
|
+
logEscrowFailure(error);
|
|
14
|
+
expect(warn).toHaveBeenCalledExactlyOnceWith('[LCA escrow] enclave operation failed', {
|
|
15
|
+
reason: 'invalid_hold',
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
it.each([
|
|
19
|
+
[403, 'policy', 'policy'],
|
|
20
|
+
[403, 'pinMismatch', 'pin_mismatch'],
|
|
21
|
+
[403, 'codeMismatch', 'code_mismatch'],
|
|
22
|
+
[403, 'codeExpired', 'code_expired'],
|
|
23
|
+
[403, 'codeLocked', 'code_locked'],
|
|
24
|
+
[403, 'blob', 'blob'],
|
|
25
|
+
[503, 'ledger', 'ledger'],
|
|
26
|
+
[503, 'time', 'time'],
|
|
27
|
+
[412, 'unavailable', 'unavailable'],
|
|
28
|
+
[403, 'secret-unknown-code', 'http_refusal'],
|
|
29
|
+
[429, 'unknown', 'rate_limit'],
|
|
30
|
+
[200, 'unknown', 'invalid_response'],
|
|
31
|
+
])('classifies HTTP %s / %s without logging payloads', async (status, code, reason) => {
|
|
32
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
33
|
+
const enclave = createRemoteEnclave({
|
|
34
|
+
baseUrl: 'https://example.invalid',
|
|
35
|
+
token: 'secret-token',
|
|
36
|
+
timeoutMs: 100,
|
|
37
|
+
transport: {
|
|
38
|
+
post: async () => ({ status, data: { code, message: 'secret@example.com' } }),
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
await enclave.getAttestation().catch(logEscrowFailure);
|
|
42
|
+
expect(warn).toHaveBeenCalledExactlyOnceWith('[LCA escrow] enclave operation failed', {
|
|
43
|
+
reason,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('drops transport error details', async () => {
|
|
48
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
49
|
+
const enclave = createRemoteEnclave({
|
|
50
|
+
baseUrl: 'https://example.invalid',
|
|
51
|
+
token: 'secret-token',
|
|
52
|
+
timeoutMs: 100,
|
|
53
|
+
transport: {
|
|
54
|
+
post: async () => {
|
|
55
|
+
throw new Error('secret-token');
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
await enclave.getAttestation().catch(logEscrowFailure);
|
|
60
|
+
expect(warn).toHaveBeenCalledExactlyOnceWith('[LCA escrow] enclave operation failed', {
|
|
61
|
+
reason: 'transport',
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EscrowBlobError,
|
|
3
|
+
EscrowCodeError,
|
|
4
|
+
EscrowPinMismatchError,
|
|
5
|
+
EscrowPolicyError,
|
|
6
|
+
EscrowUnavailableError,
|
|
7
|
+
} from './types';
|
|
8
|
+
|
|
9
|
+
/** Only allowlisted classifications reach logs; never serialize remote errors or payloads. */
|
|
10
|
+
export const logEscrowFailure = (error: unknown): void => {
|
|
11
|
+
const reason =
|
|
12
|
+
error instanceof EscrowCodeError
|
|
13
|
+
? {
|
|
14
|
+
codeMismatch: 'code_mismatch',
|
|
15
|
+
codeExpired: 'code_expired',
|
|
16
|
+
codeLocked: 'code_locked',
|
|
17
|
+
}[error.reason]
|
|
18
|
+
: error instanceof EscrowUnavailableError
|
|
19
|
+
? error.reason
|
|
20
|
+
: error instanceof EscrowPolicyError
|
|
21
|
+
? 'policy'
|
|
22
|
+
: error instanceof EscrowPinMismatchError
|
|
23
|
+
? 'pin_mismatch'
|
|
24
|
+
: error instanceof EscrowBlobError
|
|
25
|
+
? 'blob'
|
|
26
|
+
: 'unexpected';
|
|
27
|
+
// lca-api uses structured console warnings rather than the browser logger.
|
|
28
|
+
console.warn('[LCA escrow] enclave operation failed', { reason });
|
|
29
|
+
};
|
|
@@ -3,6 +3,7 @@ import type { EscrowEnvelope } from '@learncard/sss-key-manager';
|
|
|
3
3
|
import { createRemoteEnclave, type RemoteEnclaveTransportResponse } from './remoteEnclave';
|
|
4
4
|
import {
|
|
5
5
|
EscrowBlobError,
|
|
6
|
+
EscrowCodeError,
|
|
6
7
|
EscrowPinMismatchError,
|
|
7
8
|
EscrowPolicyError,
|
|
8
9
|
EscrowUnavailableError,
|
|
@@ -12,6 +13,35 @@ import {
|
|
|
12
13
|
const baseUrl = 'https://enclave-host.internal';
|
|
13
14
|
const token = 't'.repeat(32);
|
|
14
15
|
const timeoutMs = 10_000;
|
|
16
|
+
const receipt = {
|
|
17
|
+
version: 1 as const,
|
|
18
|
+
kind: 'commit' as const,
|
|
19
|
+
keyId: 'key-1',
|
|
20
|
+
measurement: Array(32).fill(0),
|
|
21
|
+
payload: 'AQ==',
|
|
22
|
+
signature: Buffer.alloc(64).toString('base64'),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Real Rust shape for the idempotent-retry path (`authority_hold_snapshot`,
|
|
26
|
+
// policy/authority.rs): a signed tuple, never an `AuthorityCommitValidator`.
|
|
27
|
+
const snapshotReceipt = {
|
|
28
|
+
version: 1 as const,
|
|
29
|
+
kind: 'snapshot' as const,
|
|
30
|
+
keyId: 'key-1',
|
|
31
|
+
measurement: Array(32).fill(0),
|
|
32
|
+
payload: Buffer.from(
|
|
33
|
+
JSON.stringify([
|
|
34
|
+
'learncard-authority-hold-snapshot-v2',
|
|
35
|
+
'hold-1',
|
|
36
|
+
Array(32).fill(7),
|
|
37
|
+
'pending',
|
|
38
|
+
1700000000000,
|
|
39
|
+
1700086400000,
|
|
40
|
+
'g1',
|
|
41
|
+
])
|
|
42
|
+
).toString('base64'),
|
|
43
|
+
signature: Buffer.alloc(64).toString('base64'),
|
|
44
|
+
};
|
|
15
45
|
|
|
16
46
|
const envelope: EscrowEnvelope = {
|
|
17
47
|
version: 1,
|
|
@@ -85,6 +115,40 @@ describe('createRemoteEnclave', () => {
|
|
|
85
115
|
);
|
|
86
116
|
});
|
|
87
117
|
|
|
118
|
+
it.each(['codeMismatch', 'codeExpired', 'codeLocked'] as const)(
|
|
119
|
+
'maps %s without forwarding diagnostic text',
|
|
120
|
+
async code => {
|
|
121
|
+
const post = vi
|
|
122
|
+
.fn()
|
|
123
|
+
.mockResolvedValue(respond(403, { code, message: 'sensitive diagnostic' }));
|
|
124
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
125
|
+
await expect(enclave.confirmHold!(confirmInput)).rejects.toEqual(
|
|
126
|
+
new EscrowCodeError(code)
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
it('separates delivery evidence from the signed hold before subsequent requests', async () => {
|
|
132
|
+
const delivery = { codeSent: false, proof: snapshotReceipt };
|
|
133
|
+
const post = vi.fn().mockResolvedValue(respond(200, { ...hold, delivery }));
|
|
134
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
135
|
+
await expect(enclave.createHold(createInput)).resolves.toEqual({
|
|
136
|
+
holdRecord: hold,
|
|
137
|
+
delivery,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('forwards fresh hold-state nonces under the existing bearer authentication', async () => {
|
|
142
|
+
const post = vi.fn().mockResolvedValue(respond(200, { proof: snapshotReceipt }));
|
|
143
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
144
|
+
const input = { expectedDid: hold.hold.did, hold, nonce: 'ab'.repeat(32) };
|
|
145
|
+
await expect(enclave.getHoldState!(input)).resolves.toEqual({ proof: snapshotReceipt });
|
|
146
|
+
expect(post).toHaveBeenCalledWith(`${baseUrl}/v1/hold-state`, input, {
|
|
147
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
148
|
+
timeout: timeoutMs,
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
88
152
|
it('sends an empty nonce array when none is given', async () => {
|
|
89
153
|
const post = vi.fn().mockResolvedValue(respond(200, attestationBody));
|
|
90
154
|
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
@@ -223,7 +287,14 @@ describe('createRemoteEnclave', () => {
|
|
|
223
287
|
});
|
|
224
288
|
|
|
225
289
|
it('rewraps an escrow blob sealed under a previous key onto the current key', async () => {
|
|
226
|
-
const
|
|
290
|
+
const authority = {
|
|
291
|
+
envelope,
|
|
292
|
+
receipt,
|
|
293
|
+
authorityGeneration: 'g1',
|
|
294
|
+
enrollmentVersion: 2,
|
|
295
|
+
enrollmentEpoch: 1,
|
|
296
|
+
};
|
|
297
|
+
const post = vi.fn().mockResolvedValue(respond(200, { result: authority }));
|
|
227
298
|
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
228
299
|
const input = {
|
|
229
300
|
envelope,
|
|
@@ -232,10 +303,10 @@ describe('createRemoteEnclave', () => {
|
|
|
232
303
|
sourceEnrollmentEpoch: 1,
|
|
233
304
|
};
|
|
234
305
|
|
|
235
|
-
await expect(enclave.rewrapEscrowBlob(input)).resolves.toEqual({ envelope });
|
|
306
|
+
await expect(enclave.rewrapEscrowBlob(input)).resolves.toEqual({ envelope, authority });
|
|
236
307
|
|
|
237
308
|
expect(post).toHaveBeenCalledWith(
|
|
238
|
-
`${baseUrl}/v1/rewrap
|
|
309
|
+
`${baseUrl}/v1/authority-rewrap`,
|
|
239
310
|
{
|
|
240
311
|
envelope,
|
|
241
312
|
expectedDid: 'did:example:alice',
|
|
@@ -246,6 +317,243 @@ describe('createRemoteEnclave', () => {
|
|
|
246
317
|
);
|
|
247
318
|
});
|
|
248
319
|
|
|
320
|
+
it('preserves v2 cancellation receipts and stable release IDs, refusing absent commit evidence', async () => {
|
|
321
|
+
const v2 = {
|
|
322
|
+
...hold,
|
|
323
|
+
hold: {
|
|
324
|
+
...hold.hold,
|
|
325
|
+
policyVersion: 2,
|
|
326
|
+
signature: Buffer.from(JSON.stringify(receipt)).toString('base64'),
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
const post = vi.fn().mockResolvedValue(respond(200, { ok: true, receipt }));
|
|
330
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
331
|
+
const input = {
|
|
332
|
+
envelope,
|
|
333
|
+
hold: v2,
|
|
334
|
+
expectedDid: v2.hold.did,
|
|
335
|
+
clientEphemeralPublicKey: v2.hold.clientEphemeralPublicKey,
|
|
336
|
+
requestId: 'stable-id',
|
|
337
|
+
};
|
|
338
|
+
await expect(enclave.cancelHold(input)).resolves.toEqual({ receipt });
|
|
339
|
+
post.mockResolvedValue(respond(200, { sealed: envelope, receipt }));
|
|
340
|
+
await expect(enclave.releaseEscrow(input)).resolves.toEqual({ sealed: envelope, receipt });
|
|
341
|
+
expect(post.mock.calls.at(-1)?.[1]).toMatchObject({ requestId: 'stable-id' });
|
|
342
|
+
post.mockResolvedValue(respond(200, { sealed: envelope }));
|
|
343
|
+
await expect(enclave.releaseEscrow(input)).rejects.toMatchObject({
|
|
344
|
+
reason: 'invalid_response',
|
|
345
|
+
});
|
|
346
|
+
post.mockResolvedValue(respond(200, { ok: true }));
|
|
347
|
+
await expect(enclave.cancelHold(input)).rejects.toMatchObject({
|
|
348
|
+
reason: 'invalid_response',
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it.each([
|
|
353
|
+
{ ...hold, ledgerSeq: -1 },
|
|
354
|
+
{ ...hold, hold: { ...hold.hold, policyVersion: 2, signature: 'invalid' } },
|
|
355
|
+
])('distinguishes invalid outbound holds from invalid returned holds', async invalidHold => {
|
|
356
|
+
const post = vi.fn().mockResolvedValue(respond(200, invalidHold));
|
|
357
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
358
|
+
const input = {
|
|
359
|
+
envelope,
|
|
360
|
+
hold: invalidHold,
|
|
361
|
+
expectedDid: hold.hold.did,
|
|
362
|
+
clientEphemeralPublicKey: hold.hold.clientEphemeralPublicKey,
|
|
363
|
+
};
|
|
364
|
+
await expect(enclave.releaseEscrow(input)).rejects.toMatchObject({
|
|
365
|
+
reason: 'invalid_hold',
|
|
366
|
+
});
|
|
367
|
+
await expect(enclave.cancelHold(input)).rejects.toMatchObject({ reason: 'invalid_hold' });
|
|
368
|
+
await expect(enclave.confirmHold!({ ...input, code: '12345678' })).rejects.toMatchObject({
|
|
369
|
+
reason: 'invalid_hold',
|
|
370
|
+
});
|
|
371
|
+
await expect(enclave.resendCode!(input)).rejects.toMatchObject({ reason: 'invalid_hold' });
|
|
372
|
+
expect(post).not.toHaveBeenCalled();
|
|
373
|
+
await expect(
|
|
374
|
+
enclave.createHold({
|
|
375
|
+
envelope,
|
|
376
|
+
...hold.hold,
|
|
377
|
+
expectedDid: hold.hold.did,
|
|
378
|
+
expectedShareVersion: 1,
|
|
379
|
+
})
|
|
380
|
+
).rejects.toMatchObject({ reason: 'invalid_response' });
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it.each([
|
|
384
|
+
{ envelope: null, enrollmentEpoch: 1 },
|
|
385
|
+
{ envelope, enrollmentEpoch: 2 },
|
|
386
|
+
])('classifies invalid rewrap envelope/epoch as a response failure', async result => {
|
|
387
|
+
const post = vi.fn().mockResolvedValue(
|
|
388
|
+
respond(200, {
|
|
389
|
+
result: {
|
|
390
|
+
...result,
|
|
391
|
+
receipt,
|
|
392
|
+
authorityGeneration: 'g1',
|
|
393
|
+
enrollmentVersion: 2,
|
|
394
|
+
},
|
|
395
|
+
})
|
|
396
|
+
);
|
|
397
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
398
|
+
await expect(
|
|
399
|
+
enclave.rewrapEscrowBlob({
|
|
400
|
+
envelope,
|
|
401
|
+
expectedDid: hold.hold.did,
|
|
402
|
+
expectedShareVersion: 1,
|
|
403
|
+
sourceEnrollmentEpoch: 1,
|
|
404
|
+
})
|
|
405
|
+
).rejects.toMatchObject({ reason: 'invalid_response' });
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// [A] confirm-hold/send-release-warning: a fresh call returns a signed
|
|
409
|
+
// `commit`; an idempotent retry (lost response, hourly job re-run)
|
|
410
|
+
// returns a signed `snapshot` instead — both are real response shapes
|
|
411
|
+
// the enclave can return, never a stub that only ever returns `commit`.
|
|
412
|
+
const confirmInput = {
|
|
413
|
+
envelope,
|
|
414
|
+
hold,
|
|
415
|
+
clientEphemeralPublicKey: hold.hold.clientEphemeralPublicKey,
|
|
416
|
+
expectedDid: hold.hold.did,
|
|
417
|
+
code: '12345678',
|
|
418
|
+
};
|
|
419
|
+
it('confirmHold accepts a fresh commit receipt and returns the authoritative confirmation time', async () => {
|
|
420
|
+
const post = vi
|
|
421
|
+
.fn()
|
|
422
|
+
.mockResolvedValue(
|
|
423
|
+
respond(200, { confirmed: true, receipt, confirmedHiMs: 1700000000000 })
|
|
424
|
+
);
|
|
425
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
426
|
+
|
|
427
|
+
await expect(enclave.confirmHold!(confirmInput)).resolves.toEqual({
|
|
428
|
+
receipt,
|
|
429
|
+
confirmedAtMs: 1700000000000,
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
it('confirmHold accepts a retry snapshot receipt (not a commit) and returns the SAME confirmation time', async () => {
|
|
433
|
+
const post = vi.fn().mockResolvedValue(
|
|
434
|
+
respond(200, {
|
|
435
|
+
confirmed: true,
|
|
436
|
+
receipt: snapshotReceipt,
|
|
437
|
+
confirmedHiMs: 1700000000000,
|
|
438
|
+
})
|
|
439
|
+
);
|
|
440
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
441
|
+
|
|
442
|
+
await expect(enclave.confirmHold!(confirmInput)).resolves.toEqual({
|
|
443
|
+
receipt: snapshotReceipt,
|
|
444
|
+
confirmedAtMs: 1700000000000,
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
it('confirmHold fails closed when the receipt is neither a commit nor a snapshot', async () => {
|
|
448
|
+
const post = vi.fn().mockResolvedValue(
|
|
449
|
+
respond(200, {
|
|
450
|
+
confirmed: true,
|
|
451
|
+
receipt: { ...receipt, kind: 'proposal' },
|
|
452
|
+
confirmedHiMs: 1700000000000,
|
|
453
|
+
})
|
|
454
|
+
);
|
|
455
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
456
|
+
|
|
457
|
+
await expect(enclave.confirmHold!(confirmInput)).rejects.toBeInstanceOf(
|
|
458
|
+
EscrowUnavailableError
|
|
459
|
+
);
|
|
460
|
+
});
|
|
461
|
+
it('confirmHold fails closed when confirmedHiMs is missing (pre-fix response shape)', async () => {
|
|
462
|
+
const post = vi.fn().mockResolvedValue(respond(200, { confirmed: true, receipt }));
|
|
463
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
464
|
+
|
|
465
|
+
await expect(enclave.confirmHold!(confirmInput)).rejects.toBeInstanceOf(
|
|
466
|
+
EscrowUnavailableError
|
|
467
|
+
);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
const warningInput = { holdId: hold.hold.holdId, expectedDid: hold.hold.did };
|
|
471
|
+
it('sendReleaseWarning accepts a fresh commit receipt and returns the authoritative send time', async () => {
|
|
472
|
+
const post = vi
|
|
473
|
+
.fn()
|
|
474
|
+
.mockResolvedValue(
|
|
475
|
+
respond(200, { sent: true, receipt, releaseWarningSentHiMs: 1700086400000 })
|
|
476
|
+
);
|
|
477
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
478
|
+
|
|
479
|
+
await expect(enclave.sendReleaseWarning!(warningInput)).resolves.toEqual({
|
|
480
|
+
receipt,
|
|
481
|
+
releaseWarningSentAtMs: 1700086400000,
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
it('sendReleaseWarning accepts a retry snapshot receipt (not a commit) and returns the SAME send time', async () => {
|
|
485
|
+
const post = vi.fn().mockResolvedValue(
|
|
486
|
+
respond(200, {
|
|
487
|
+
sent: true,
|
|
488
|
+
receipt: snapshotReceipt,
|
|
489
|
+
releaseWarningSentHiMs: 1700086400000,
|
|
490
|
+
})
|
|
491
|
+
);
|
|
492
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
493
|
+
|
|
494
|
+
await expect(enclave.sendReleaseWarning!(warningInput)).resolves.toEqual({
|
|
495
|
+
receipt: snapshotReceipt,
|
|
496
|
+
releaseWarningSentAtMs: 1700086400000,
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
it('sendReleaseWarning fails closed when releaseWarningSentHiMs is missing (pre-fix response shape)', async () => {
|
|
500
|
+
const post = vi.fn().mockResolvedValue(respond(200, { sent: true, receipt }));
|
|
501
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
502
|
+
|
|
503
|
+
await expect(enclave.sendReleaseWarning!(warningInput)).rejects.toBeInstanceOf(
|
|
504
|
+
EscrowUnavailableError
|
|
505
|
+
);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('forwards signed enrollment once and rejects a proposal or mismatched result generation', async () => {
|
|
509
|
+
const input = {
|
|
510
|
+
signedIntent: {
|
|
511
|
+
intent: {
|
|
512
|
+
version: 2 as const,
|
|
513
|
+
tenant: 'learncard',
|
|
514
|
+
did: 'did:key:z6MktwupdmLXVVqTzCw4i46r4uGyosGXRnR3XjN4Zq7oMMsw',
|
|
515
|
+
authorityGeneration: 'g1',
|
|
516
|
+
expectedEnrollmentVersion: 0,
|
|
517
|
+
action: 'enroll' as const,
|
|
518
|
+
sourceBlobIdentity: null,
|
|
519
|
+
targetBlobIdentity: 'ab'.repeat(32),
|
|
520
|
+
pinAction: 'keep' as const,
|
|
521
|
+
shareVersion: 1,
|
|
522
|
+
enrollmentEpoch: 1,
|
|
523
|
+
requestId: 'request',
|
|
524
|
+
allowRewrap: true,
|
|
525
|
+
recoveryEmail: 'owner@example.com',
|
|
526
|
+
recoveryLanguage: 'en',
|
|
527
|
+
},
|
|
528
|
+
signature: Buffer.alloc(64).toString('base64'),
|
|
529
|
+
},
|
|
530
|
+
sourceEnvelope: null,
|
|
531
|
+
targetEnvelope: envelope,
|
|
532
|
+
};
|
|
533
|
+
const result = {
|
|
534
|
+
envelope,
|
|
535
|
+
receipt,
|
|
536
|
+
authorityGeneration: 'g1',
|
|
537
|
+
enrollmentVersion: 1,
|
|
538
|
+
enrollmentEpoch: 1,
|
|
539
|
+
};
|
|
540
|
+
const post = vi.fn().mockResolvedValue(respond(200, { result }));
|
|
541
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
542
|
+
await expect(enclave.applyEnrollmentIntent!(input)).resolves.toEqual(result);
|
|
543
|
+
expect(post).toHaveBeenCalledTimes(1);
|
|
544
|
+
expect(post.mock.calls[0]?.slice(0, 2)).toEqual([`${baseUrl}/v1/apply-enrollment`, input]);
|
|
545
|
+
post.mockResolvedValue(respond(200, { result: { ...result, authorityGeneration: 'g2' } }));
|
|
546
|
+
await expect(enclave.applyEnrollmentIntent!(input)).rejects.toBeInstanceOf(
|
|
547
|
+
EscrowUnavailableError
|
|
548
|
+
);
|
|
549
|
+
post.mockResolvedValue(
|
|
550
|
+
respond(200, { result: { ...result, receipt: { ...receipt, kind: 'proposal' } } })
|
|
551
|
+
);
|
|
552
|
+
await expect(enclave.applyEnrollmentIntent!(input)).rejects.toBeInstanceOf(
|
|
553
|
+
EscrowUnavailableError
|
|
554
|
+
);
|
|
555
|
+
});
|
|
556
|
+
|
|
249
557
|
it.each([
|
|
250
558
|
['policy', EscrowPolicyError],
|
|
251
559
|
['pinMismatch', EscrowPinMismatchError],
|
|
@@ -412,7 +720,10 @@ describe('createRemoteEnclave', () => {
|
|
|
412
720
|
const created = await enclave.createHold(createInput);
|
|
413
721
|
expect(created).toEqual({ holdRecord: opaque });
|
|
414
722
|
const config = { headers: { Authorization: `Bearer ${token}` }, timeout: timeoutMs };
|
|
415
|
-
expect(post).toHaveBeenNthCalledWith(1, `${baseUrl}/v1/create-hold`, createInput,
|
|
723
|
+
expect(post).toHaveBeenNthCalledWith(1, `${baseUrl}/v1/create-hold`, createInput, {
|
|
724
|
+
...config,
|
|
725
|
+
headers: { ...config.headers, 'X-Escrow-Delivery-Evidence': '1' },
|
|
726
|
+
});
|
|
416
727
|
await expect(
|
|
417
728
|
enclave.cancelHold({ ...cancelInput, hold: created.holdRecord })
|
|
418
729
|
).resolves.toBeUndefined();
|