@learncard/lca-api-service 1.5.2 → 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/CHANGELOG.md +18 -0
- package/dist/app.d.ts +19 -0
- package/dist/index.js +2 -2
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/ai.test.d.ts +1 -0
- package/dist/routes/escrow.d.ts +19 -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 +17 -1
- package/esbuildPlugins.cjs +9 -6
- package/package.json +10 -10
- package/src/routes/ai.test.ts +103 -0
- package/src/routes/ai.ts +2 -1
- package/src/routes/escrow.ts +15 -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 +101 -3
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +182 -2
- package/src/services/escrow-enclave/remoteEnclave.ts +101 -36
- package/src/services/escrow-enclave/types.ts +26 -2
- package/test/keys-escrow.spec.ts +65 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { createServer, type Server } from 'node:https';
|
|
3
3
|
import type { AddressInfo } from 'node:net';
|
|
4
|
-
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
5
|
-
import { createHttpTransport } from './remoteEnclave';
|
|
4
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { createHttpTransport, createRemoteEnclave } from './remoteEnclave';
|
|
6
6
|
import { pinnedEnclaveCa } from './enclaveTrustAnchors';
|
|
7
|
+
import { logEscrowFailure } from './logFailure';
|
|
7
8
|
|
|
8
9
|
const fixture = (name: string): string =>
|
|
9
10
|
readFileSync(new URL(`./__fixtures__/tls/${name}`, import.meta.url), 'utf8');
|
|
@@ -60,3 +61,182 @@ describe('remote enclave TLS trust', () => {
|
|
|
60
61
|
expect(pinnedEnclaveCa('https://enclave-host.internal')).toBeUndefined();
|
|
61
62
|
});
|
|
62
63
|
});
|
|
64
|
+
|
|
65
|
+
describe('remote enclave connection recovery', () => {
|
|
66
|
+
const servers: Server[] = [];
|
|
67
|
+
const config = { headers: { Authorization: 'Bearer test-secret' }, timeout: 1_000 };
|
|
68
|
+
const serve = async (handler: Parameters<typeof createServer>[1]): Promise<string> => {
|
|
69
|
+
const server = createServer(
|
|
70
|
+
{ cert: fixture('pinned-leaf.pem'), key: fixture('pinned-leaf.key') },
|
|
71
|
+
handler
|
|
72
|
+
);
|
|
73
|
+
servers.push(server);
|
|
74
|
+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
75
|
+
return url(server).replace('/v1/attest', '');
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
afterEach(async () => {
|
|
79
|
+
vi.restoreAllMocks();
|
|
80
|
+
await Promise.all(
|
|
81
|
+
servers.splice(0).map(
|
|
82
|
+
server =>
|
|
83
|
+
new Promise<void>(resolve => {
|
|
84
|
+
server.closeAllConnections();
|
|
85
|
+
server.close(() => resolve());
|
|
86
|
+
})
|
|
87
|
+
)
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it.each(['/v1/attest', '/v1/enrollment-state', '/v1/hold-state'])(
|
|
92
|
+
'retries %s once on a pre-response reset, on a fresh TLS connection',
|
|
93
|
+
async path => {
|
|
94
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
95
|
+
const sockets = new Set();
|
|
96
|
+
let requests = 0;
|
|
97
|
+
const origin = await serve((request, response) => {
|
|
98
|
+
sockets.add(request.socket);
|
|
99
|
+
requests += 1;
|
|
100
|
+
if (requests === 1) request.socket.destroy();
|
|
101
|
+
else response.end('{"ok":true}');
|
|
102
|
+
});
|
|
103
|
+
const transport = createHttpTransport(fixture('pinned-ca.pem'));
|
|
104
|
+
await expect(
|
|
105
|
+
transport.post(`${origin}${path}`, { secret: 'private-body' }, config)
|
|
106
|
+
).resolves.toEqual({ status: 200, data: { ok: true } });
|
|
107
|
+
expect(requests).toBe(2);
|
|
108
|
+
expect(sockets.size).toBe(2);
|
|
109
|
+
expect(warn.mock.calls).toEqual([
|
|
110
|
+
['[LCA escrow] retrying enclave read', { code: 'ECONNRESET' }],
|
|
111
|
+
]);
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
it('does not pool successful requests across invocations', async () => {
|
|
116
|
+
const sockets = new Set();
|
|
117
|
+
const connections: (string | undefined)[] = [];
|
|
118
|
+
const origin = await serve((request, response) => {
|
|
119
|
+
sockets.add(request.socket);
|
|
120
|
+
connections.push(request.headers.connection);
|
|
121
|
+
response.end('{"ok":true}');
|
|
122
|
+
});
|
|
123
|
+
const transport = createHttpTransport(fixture('pinned-ca.pem'));
|
|
124
|
+
await transport.post(`${origin}/v1/attest`, {}, config);
|
|
125
|
+
await transport.post(`${origin}/v1/attest`, {}, config);
|
|
126
|
+
expect(sockets.size).toBe(2);
|
|
127
|
+
expect(connections).toEqual(['close', 'close']);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it.each([
|
|
131
|
+
'/v1/create-hold',
|
|
132
|
+
'/v1/confirm-hold',
|
|
133
|
+
'/v1/release',
|
|
134
|
+
'/v1/cancel-hold',
|
|
135
|
+
'/v1/apply-enrollment',
|
|
136
|
+
'/v1/authority-rewrap',
|
|
137
|
+
'/v1/carry-pin-verifier',
|
|
138
|
+
'/v1/resend-code',
|
|
139
|
+
'/v1/send-release-warning',
|
|
140
|
+
'/v1/unknown',
|
|
141
|
+
])('never retries %s even when no response arrived', async path => {
|
|
142
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
143
|
+
let requests = 0;
|
|
144
|
+
const origin = await serve(request => {
|
|
145
|
+
requests += 1;
|
|
146
|
+
request.socket.destroy();
|
|
147
|
+
});
|
|
148
|
+
await expect(
|
|
149
|
+
createHttpTransport(fixture('pinned-ca.pem')).post(`${origin}${path}`, {}, config)
|
|
150
|
+
).rejects.toMatchObject({ code: 'ECONNRESET' });
|
|
151
|
+
expect(requests).toBe(1);
|
|
152
|
+
expect(warn).not.toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it.each([429, 503, 302])('does not retry or follow HTTP %s', async status => {
|
|
156
|
+
let requests = 0;
|
|
157
|
+
const origin = await serve((_request, response) => {
|
|
158
|
+
requests += 1;
|
|
159
|
+
response.writeHead(status, { Location: '/v1/attest' });
|
|
160
|
+
response.end('{}');
|
|
161
|
+
});
|
|
162
|
+
await expect(
|
|
163
|
+
createHttpTransport(fixture('pinned-ca.pem')).post(`${origin}/v1/attest`, {}, config)
|
|
164
|
+
).resolves.toMatchObject({ status });
|
|
165
|
+
expect(requests).toBe(1);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('does not retry a timeout', async () => {
|
|
169
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
170
|
+
let requests = 0;
|
|
171
|
+
const origin = await serve(() => {
|
|
172
|
+
requests += 1;
|
|
173
|
+
});
|
|
174
|
+
await expect(
|
|
175
|
+
createHttpTransport(fixture('pinned-ca.pem')).post(
|
|
176
|
+
`${origin}/v1/attest`,
|
|
177
|
+
{},
|
|
178
|
+
{ ...config, timeout: 100 }
|
|
179
|
+
)
|
|
180
|
+
).rejects.toThrow();
|
|
181
|
+
expect(requests).toBe(1);
|
|
182
|
+
expect(warn).not.toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('does not retry after even partial response headers', async () => {
|
|
186
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
187
|
+
let requests = 0;
|
|
188
|
+
const origin = await serve(request => {
|
|
189
|
+
requests += 1;
|
|
190
|
+
request.socket.write('HTTP/1.1 200 OK\r\n');
|
|
191
|
+
setTimeout(() => request.socket.destroy(), 20);
|
|
192
|
+
});
|
|
193
|
+
await expect(
|
|
194
|
+
createHttpTransport(fixture('pinned-ca.pem')).post(`${origin}/v1/attest`, {}, config)
|
|
195
|
+
).rejects.toThrow();
|
|
196
|
+
expect(requests).toBe(1);
|
|
197
|
+
expect(warn).not.toHaveBeenCalled();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('keeps the second attempt within the original wall-clock budget', async () => {
|
|
201
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
202
|
+
let requests = 0;
|
|
203
|
+
const origin = await serve(request => {
|
|
204
|
+
requests += 1;
|
|
205
|
+
if (requests === 1) setTimeout(() => request.socket.destroy(), 150);
|
|
206
|
+
});
|
|
207
|
+
const start = performance.now();
|
|
208
|
+
await expect(
|
|
209
|
+
createHttpTransport(fixture('pinned-ca.pem')).post(
|
|
210
|
+
`${origin}/v1/attest`,
|
|
211
|
+
{},
|
|
212
|
+
{ ...config, timeout: 300 }
|
|
213
|
+
)
|
|
214
|
+
).rejects.toThrow();
|
|
215
|
+
expect(performance.now() - start).toBeLessThan(430);
|
|
216
|
+
expect(requests).toBe(2);
|
|
217
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('stops after one retry and preserves sanitized transport failure logging', async () => {
|
|
221
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
222
|
+
let requests = 0;
|
|
223
|
+
const origin = await serve(request => {
|
|
224
|
+
requests += 1;
|
|
225
|
+
request.socket.destroy();
|
|
226
|
+
});
|
|
227
|
+
const enclave = createRemoteEnclave({
|
|
228
|
+
baseUrl: origin,
|
|
229
|
+
token: 'private-token',
|
|
230
|
+
timeoutMs: 1_000,
|
|
231
|
+
caPem: fixture('pinned-ca.pem'),
|
|
232
|
+
});
|
|
233
|
+
const error = await enclave.getAttestation().catch(error => error);
|
|
234
|
+
expect(error).toMatchObject({ reason: 'transport' });
|
|
235
|
+
logEscrowFailure(error);
|
|
236
|
+
expect(requests).toBe(2);
|
|
237
|
+
expect(warn.mock.calls).toEqual([
|
|
238
|
+
['[LCA escrow] retrying enclave read', { code: 'ECONNRESET' }],
|
|
239
|
+
['[LCA escrow] enclave operation failed', { reason: 'transport' }],
|
|
240
|
+
]);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Agent } from 'node:https';
|
|
2
|
+
import { Agent as HttpAgent, ClientRequest } from 'node:http';
|
|
2
3
|
import axios from 'axios';
|
|
3
4
|
import { z } from 'zod';
|
|
4
5
|
import {
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
import {
|
|
17
18
|
EscrowBlobError,
|
|
18
19
|
EscrowPinMismatchError,
|
|
20
|
+
EscrowCodeError,
|
|
19
21
|
EscrowPolicyError,
|
|
20
22
|
EscrowUnavailableError,
|
|
21
23
|
type CarryPinVerifierInput,
|
|
@@ -91,15 +93,18 @@ const holdRecordValidator = z
|
|
|
91
93
|
ledgerSeq: z.number().int().nonnegative(),
|
|
92
94
|
})
|
|
93
95
|
.passthrough();
|
|
94
|
-
const validatedHold = (
|
|
95
|
-
|
|
96
|
+
const validatedHold = (
|
|
97
|
+
hold: ReleaseRequest['hold'],
|
|
98
|
+
reason: 'invalid_hold' | 'invalid_response' = 'invalid_hold'
|
|
99
|
+
): ReleaseRequest['hold'] => {
|
|
100
|
+
if (!holdRecordValidator.safeParse(hold).success) throw new EscrowUnavailableError(reason);
|
|
96
101
|
if (hold.hold.policyVersion === 2) {
|
|
97
102
|
try {
|
|
98
103
|
AuthorityCommitValidator.parse(
|
|
99
104
|
JSON.parse(Buffer.from(hold.hold.signature, 'base64').toString('utf8'))
|
|
100
105
|
);
|
|
101
106
|
} catch {
|
|
102
|
-
throw new EscrowUnavailableError();
|
|
107
|
+
throw new EscrowUnavailableError(reason);
|
|
103
108
|
}
|
|
104
109
|
}
|
|
105
110
|
return hold;
|
|
@@ -112,6 +117,9 @@ const releaseResponseValidator = z.object({
|
|
|
112
117
|
const errorCodeValidator = z.enum([
|
|
113
118
|
'policy',
|
|
114
119
|
'pinMismatch',
|
|
120
|
+
'codeMismatch',
|
|
121
|
+
'codeExpired',
|
|
122
|
+
'codeLocked',
|
|
115
123
|
'blob',
|
|
116
124
|
'unavailable',
|
|
117
125
|
'ledger',
|
|
@@ -122,8 +130,10 @@ const errorResponseValidator = z.object({ code: errorCodeValidator, message: z.s
|
|
|
122
130
|
const errorForCode = (code: z.infer<typeof errorCodeValidator>): Error => {
|
|
123
131
|
if (code === 'policy') return new EscrowPolicyError();
|
|
124
132
|
if (code === 'pinMismatch') return new EscrowPinMismatchError();
|
|
133
|
+
if (code === 'codeMismatch' || code === 'codeExpired' || code === 'codeLocked')
|
|
134
|
+
return new EscrowCodeError(code);
|
|
125
135
|
if (code === 'blob') return new EscrowBlobError();
|
|
126
|
-
return new EscrowUnavailableError(); // 'unavailable' | 'ledger' | 'time'
|
|
136
|
+
return new EscrowUnavailableError(code); // 'unavailable' | 'ledger' | 'time'
|
|
127
137
|
};
|
|
128
138
|
|
|
129
139
|
export interface RemoteEnclaveTransportResponse {
|
|
@@ -141,15 +151,57 @@ export interface RemoteEnclaveTransport {
|
|
|
141
151
|
|
|
142
152
|
/** With `caPem`, the connection trusts only that CA (system roots are not consulted). */
|
|
143
153
|
export const createHttpTransport = (caPem?: string): RemoteEnclaveTransport => {
|
|
144
|
-
|
|
154
|
+
// The host enforces a 60s absolute connection lifetime, even for busy sockets.
|
|
155
|
+
// Do not retain sockets across Lambda freezes (or use Node's global pool).
|
|
156
|
+
const httpsAgent = new Agent({ ...(caPem ? { ca: caPem } : {}), keepAlive: false });
|
|
157
|
+
const httpAgent = new HttpAgent({ keepAlive: false });
|
|
145
158
|
return {
|
|
146
159
|
post: async (url, body, config) => {
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
160
|
+
const path = new URL(url).pathname;
|
|
161
|
+
const safeRead = ['/v1/attest', '/v1/enrollment-state', '/v1/hold-state'].includes(
|
|
162
|
+
path
|
|
163
|
+
);
|
|
164
|
+
const deadline = performance.now() + config.timeout;
|
|
165
|
+
const controller = new AbortController();
|
|
166
|
+
// One wall-clock budget covers connection setup and BOTH attempts.
|
|
167
|
+
const timer = setTimeout(() => controller.abort(), config.timeout);
|
|
168
|
+
try {
|
|
169
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
170
|
+
try {
|
|
171
|
+
const response = await axios.post(url, body, {
|
|
172
|
+
...config,
|
|
173
|
+
timeout: Math.max(1, Math.ceil(deadline - performance.now())),
|
|
174
|
+
signal: controller.signal,
|
|
175
|
+
httpsAgent,
|
|
176
|
+
httpAgent,
|
|
177
|
+
// Keep the native ClientRequest observable; never redirect a vault POST.
|
|
178
|
+
maxRedirects: 0,
|
|
179
|
+
validateStatus: () => true,
|
|
180
|
+
});
|
|
181
|
+
return { status: response.status, data: response.data };
|
|
182
|
+
} catch (error) {
|
|
183
|
+
const code = axios.isAxiosError(error) ? error.code : undefined;
|
|
184
|
+
if (
|
|
185
|
+
attempt !== 0 ||
|
|
186
|
+
!safeRead ||
|
|
187
|
+
(code !== 'ECONNRESET' && code !== 'EPIPE') ||
|
|
188
|
+
!axios.isAxiosError(error) ||
|
|
189
|
+
error.response ||
|
|
190
|
+
!(error.request instanceof ClientRequest) ||
|
|
191
|
+
error.request.socket?.bytesRead !== 0 ||
|
|
192
|
+
controller.signal.aborted ||
|
|
193
|
+
performance.now() >= deadline
|
|
194
|
+
)
|
|
195
|
+
throw error;
|
|
196
|
+
// No response does NOT prove the server did not execute the request.
|
|
197
|
+
// Never replay mutations: holds, PIN attempts, delivery and enrollment
|
|
198
|
+
// may already be committed. Only the three read paths above are safe.
|
|
199
|
+
console.warn('[LCA escrow] retrying enclave read', { code });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} finally {
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
}
|
|
153
205
|
},
|
|
154
206
|
};
|
|
155
207
|
};
|
|
@@ -183,21 +235,24 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
|
|
|
183
235
|
let data: unknown;
|
|
184
236
|
try {
|
|
185
237
|
const response = await transport.post(`${config.baseUrl}${path}`, body, {
|
|
186
|
-
headers: {
|
|
238
|
+
headers: {
|
|
239
|
+
Authorization: `Bearer ${config.token}`,
|
|
240
|
+
...(path === '/v1/create-hold' ? { 'X-Escrow-Delivery-Evidence': '1' } : {}),
|
|
241
|
+
},
|
|
187
242
|
timeout: config.timeoutMs,
|
|
188
243
|
});
|
|
189
244
|
status = response.status;
|
|
190
245
|
data = response.data;
|
|
191
246
|
} catch {
|
|
192
|
-
throw new EscrowUnavailableError();
|
|
247
|
+
throw new EscrowUnavailableError('transport');
|
|
193
248
|
}
|
|
194
249
|
if (status < 200 || status >= 300) {
|
|
195
250
|
const parsedError = errorResponseValidator.safeParse(data);
|
|
196
251
|
if (parsedError.success) throw errorForCode(parsedError.data.code);
|
|
197
|
-
throw new EscrowUnavailableError();
|
|
252
|
+
throw new EscrowUnavailableError(status === 429 ? 'rate_limit' : 'http_refusal');
|
|
198
253
|
}
|
|
199
254
|
const parsed = validator.safeParse(data);
|
|
200
|
-
if (!parsed.success) throw new EscrowUnavailableError();
|
|
255
|
+
if (!parsed.success) throw new EscrowUnavailableError('invalid_response');
|
|
201
256
|
return parsed.data;
|
|
202
257
|
};
|
|
203
258
|
|
|
@@ -217,7 +272,7 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
|
|
|
217
272
|
result.enrollmentEpoch !== input.signedIntent.intent.enrollmentEpoch ||
|
|
218
273
|
(result.envelope === null) !== (input.signedIntent.intent.action === 'disable')
|
|
219
274
|
)
|
|
220
|
-
throw new EscrowUnavailableError();
|
|
275
|
+
throw new EscrowUnavailableError('invalid_response');
|
|
221
276
|
return result;
|
|
222
277
|
},
|
|
223
278
|
getAttestation: (nonce?: Uint8Array): Promise<EnclaveAttestation> =>
|
|
@@ -258,26 +313,36 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
|
|
|
258
313
|
z.object({ result: AuthorityMutationValidator }).strict()
|
|
259
314
|
);
|
|
260
315
|
if (!result.envelope || result.enrollmentEpoch !== input.sourceEnrollmentEpoch)
|
|
261
|
-
throw new EscrowUnavailableError();
|
|
316
|
+
throw new EscrowUnavailableError('invalid_response');
|
|
262
317
|
return { envelope: result.envelope, authority: result };
|
|
263
318
|
},
|
|
264
|
-
createHold: async input =>
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
319
|
+
createHold: async input => {
|
|
320
|
+
const result = await call(
|
|
321
|
+
'/v1/create-hold',
|
|
322
|
+
{
|
|
323
|
+
envelope: input.envelope,
|
|
324
|
+
holdId: input.holdId,
|
|
325
|
+
expectedDid: input.expectedDid,
|
|
326
|
+
expectedShareVersion: input.expectedShareVersion,
|
|
327
|
+
enrollmentEpoch: input.enrollmentEpoch,
|
|
328
|
+
releasePolicy: input.releasePolicy,
|
|
329
|
+
clientEphemeralPublicKey: input.clientEphemeralPublicKey,
|
|
330
|
+
},
|
|
331
|
+
holdRecordValidator.extend({
|
|
332
|
+
delivery: z
|
|
333
|
+
.object({
|
|
334
|
+
codeSent: z.boolean(),
|
|
335
|
+
proof: AuthorityHoldReceiptValidator,
|
|
336
|
+
})
|
|
337
|
+
.optional(),
|
|
338
|
+
})
|
|
339
|
+
);
|
|
340
|
+
// Response-only evidence must not be echoed into strict old-image hold DTOs.
|
|
341
|
+
const { delivery, ...holdRecord } = result;
|
|
342
|
+
return { holdRecord: validatedHold(holdRecord, 'invalid_response'), delivery };
|
|
343
|
+
},
|
|
344
|
+
getHoldState: async input =>
|
|
345
|
+
call('/v1/hold-state', input, z.object({ proof: AuthorityHoldReceiptValidator })),
|
|
281
346
|
cancelHold: async input => {
|
|
282
347
|
const result = await call(
|
|
283
348
|
'/v1/cancel-hold',
|
|
@@ -291,7 +356,7 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
|
|
|
291
356
|
z.object({ ok: z.literal(true), receipt: AuthorityCommitValidator.optional() })
|
|
292
357
|
);
|
|
293
358
|
if (input.hold.hold.policyVersion === 2 && !result.receipt)
|
|
294
|
-
throw new EscrowUnavailableError();
|
|
359
|
+
throw new EscrowUnavailableError('invalid_response');
|
|
295
360
|
if (result.receipt) return { receipt: result.receipt };
|
|
296
361
|
return undefined;
|
|
297
362
|
},
|
|
@@ -362,7 +427,7 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
|
|
|
362
427
|
releaseResponseValidator
|
|
363
428
|
);
|
|
364
429
|
if (input.hold.hold.policyVersion === 2 && !result.receipt)
|
|
365
|
-
throw new EscrowUnavailableError();
|
|
430
|
+
throw new EscrowUnavailableError('invalid_response');
|
|
366
431
|
return result;
|
|
367
432
|
},
|
|
368
433
|
};
|
|
@@ -127,7 +127,15 @@ export interface EscrowEnclave {
|
|
|
127
127
|
input: RewrapEscrowBlobInput
|
|
128
128
|
): Promise<{ envelope: EscrowEnvelope; authority?: AuthorityMutationResult }>;
|
|
129
129
|
releaseEscrow(input: ReleaseRequest): Promise<ReleaseResult>;
|
|
130
|
-
createHold(input: EnclaveCreateHoldInput): Promise<{
|
|
130
|
+
createHold(input: EnclaveCreateHoldInput): Promise<{
|
|
131
|
+
holdRecord: EscrowHoldRecord;
|
|
132
|
+
delivery?: { codeSent: boolean; proof: AuthorityHoldReceipt };
|
|
133
|
+
}>;
|
|
134
|
+
getHoldState?(input: {
|
|
135
|
+
expectedDid: string;
|
|
136
|
+
hold: EscrowHoldRecord;
|
|
137
|
+
nonce: string;
|
|
138
|
+
}): Promise<{ proof: AuthorityRecord }>;
|
|
131
139
|
cancelHold(input: CancelHoldRequest): Promise<void | { receipt: AuthorityRecord }>;
|
|
132
140
|
/** P9.1 (attack C): authority (nitro) mode only — software mode never
|
|
133
141
|
* produces an awaiting-confirmation hold, so it has nothing to confirm.
|
|
@@ -150,6 +158,12 @@ export class EscrowPolicyError extends Error {
|
|
|
150
158
|
this.name = 'EscrowPolicyError';
|
|
151
159
|
}
|
|
152
160
|
}
|
|
161
|
+
export class EscrowCodeError extends Error {
|
|
162
|
+
constructor(public readonly reason: 'codeMismatch' | 'codeExpired' | 'codeLocked') {
|
|
163
|
+
super(reason);
|
|
164
|
+
this.name = 'EscrowCodeError';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
153
167
|
export class EscrowPinMismatchError extends Error {
|
|
154
168
|
constructor() {
|
|
155
169
|
super('Incorrect PIN.');
|
|
@@ -163,7 +177,17 @@ export class EscrowBlobError extends Error {
|
|
|
163
177
|
}
|
|
164
178
|
}
|
|
165
179
|
export class EscrowUnavailableError extends Error {
|
|
166
|
-
constructor(
|
|
180
|
+
constructor(
|
|
181
|
+
readonly reason:
|
|
182
|
+
| 'unavailable'
|
|
183
|
+
| 'ledger'
|
|
184
|
+
| 'time'
|
|
185
|
+
| 'transport'
|
|
186
|
+
| 'invalid_response'
|
|
187
|
+
| 'invalid_hold'
|
|
188
|
+
| 'http_refusal'
|
|
189
|
+
| 'rate_limit' = 'unavailable'
|
|
190
|
+
) {
|
|
167
191
|
super('Escrow recovery is not available.');
|
|
168
192
|
this.name = 'EscrowUnavailableError';
|
|
169
193
|
}
|
package/test/keys-escrow.spec.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
getEscrowEnclave,
|
|
49
49
|
getEscrowBlobStaleReason,
|
|
50
50
|
EscrowPolicyError,
|
|
51
|
+
EscrowCodeError,
|
|
51
52
|
EscrowBlobError,
|
|
52
53
|
EscrowPinMismatchError,
|
|
53
54
|
EscrowUnavailableError,
|
|
@@ -419,6 +420,26 @@ describe('authority enrollment persistence', () => {
|
|
|
419
420
|
expect(stored?.requiresConfirmation).toBe(true);
|
|
420
421
|
expect(stored?.startedNotificationPending).toBe(false);
|
|
421
422
|
});
|
|
423
|
+
it('persists an awaiting-confirmation hold even when code delivery failed', async () => {
|
|
424
|
+
await enroll();
|
|
425
|
+
const enclave = getEscrowEnclave();
|
|
426
|
+
const create = enclave.createHold.bind(enclave);
|
|
427
|
+
const delivery = { codeSent: false, proof: { ...receipt, kind: 'snapshot' as const } };
|
|
428
|
+
vi.spyOn(enclave, 'createHold').mockImplementation(async input => ({
|
|
429
|
+
...(await create(input)),
|
|
430
|
+
delivery,
|
|
431
|
+
}));
|
|
432
|
+
__setEscrowEnclaveForTests(
|
|
433
|
+
Object.assign(enclave, { confirmHold: async () => ({ success: true as const }) })
|
|
434
|
+
);
|
|
435
|
+
const started = await start();
|
|
436
|
+
expect(started.codeSent).toBe(false);
|
|
437
|
+
expect(started.delivery).toEqual(delivery);
|
|
438
|
+
const stored = await findEscrowHoldById(started.holdId);
|
|
439
|
+
expect(stored?.holdRecord).toEqual(started.holdRecord);
|
|
440
|
+
expect(stored?.requiresConfirmation).toBe(true);
|
|
441
|
+
expect(stored?.confirmedAt).toBeUndefined();
|
|
442
|
+
});
|
|
422
443
|
it('persists a cancellation commit receipt before clearing the durable pending marker', async () => {
|
|
423
444
|
await enroll();
|
|
424
445
|
const started = await start();
|
|
@@ -1464,6 +1485,27 @@ describe('P2-c confirmRecovery reconciliation', () => {
|
|
|
1464
1485
|
}));
|
|
1465
1486
|
beforeEach(() => idempotentConfirmHold.mockClear());
|
|
1466
1487
|
|
|
1488
|
+
it.each(['codeMismatch', 'codeExpired', 'codeLocked'] as const)(
|
|
1489
|
+
'returns sanitized confirmation reason %s without confirming',
|
|
1490
|
+
async reason => {
|
|
1491
|
+
const { hold, resumeToken } = await createConfirmableHold();
|
|
1492
|
+
const enclave = getEscrowEnclave();
|
|
1493
|
+
enclave.confirmHold = vi.fn().mockRejectedValue(new EscrowCodeError(reason));
|
|
1494
|
+
try {
|
|
1495
|
+
await expect(
|
|
1496
|
+
getClient().escrow.confirmRecovery({
|
|
1497
|
+
holdId: hold._id,
|
|
1498
|
+
resumeToken,
|
|
1499
|
+
code: '12345678',
|
|
1500
|
+
})
|
|
1501
|
+
).rejects.toMatchObject({ code: 'FORBIDDEN', message: reason });
|
|
1502
|
+
expect((await findEscrowHoldById(hold._id))?.confirmedAt).toBeUndefined();
|
|
1503
|
+
} finally {
|
|
1504
|
+
delete enclave.confirmHold;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
);
|
|
1508
|
+
|
|
1467
1509
|
it('allows the SDK commit-to-v2-snapshot readback without a second persistence or deadline shift', async () => {
|
|
1468
1510
|
const { hold, resumeToken } = await createConfirmableHold();
|
|
1469
1511
|
const snapshotReceipt = {
|
|
@@ -1908,10 +1950,14 @@ describe('escrow PIN release', () => {
|
|
|
1908
1950
|
it('burns a mismatched hold; a new hold with the correct PIN succeeds', async () => {
|
|
1909
1951
|
await enrollPin();
|
|
1910
1952
|
const first = await startPin();
|
|
1953
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
1911
1954
|
await expect(completePin(first, wrongProof)).rejects.toMatchObject({
|
|
1912
1955
|
code: 'FORBIDDEN',
|
|
1913
1956
|
message: 'Incorrect PIN. 9 attempts left.',
|
|
1914
1957
|
});
|
|
1958
|
+
expect(warn).toHaveBeenCalledExactlyOnceWith('[LCA escrow] enclave operation failed', {
|
|
1959
|
+
reason: 'pin_mismatch',
|
|
1960
|
+
});
|
|
1915
1961
|
expect(await findEscrowHoldById(first.holdId)).toMatchObject({
|
|
1916
1962
|
status: 'cancelled',
|
|
1917
1963
|
cancelledBy: 'system',
|
|
@@ -2259,6 +2305,25 @@ describe('escrow PIN release', () => {
|
|
|
2259
2305
|
expect((await getClient().escrow.getStatus(auth)).hold?.holdId).toBe(hold.holdId);
|
|
2260
2306
|
});
|
|
2261
2307
|
|
|
2308
|
+
it('logs an unexpected non-PIN mismatch once without refunding a PIN attempt', async () => {
|
|
2309
|
+
await enroll();
|
|
2310
|
+
const hold = await start();
|
|
2311
|
+
advanceToRelease(hold);
|
|
2312
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
2313
|
+
const refund = vi.spyOn(models, 'refundEscrowPinAttempt');
|
|
2314
|
+
vi.spyOn(getEscrowEnclave(), 'releaseEscrow').mockRejectedValueOnce(
|
|
2315
|
+
new EscrowPinMismatchError()
|
|
2316
|
+
);
|
|
2317
|
+
await expect(getClient().escrow.completeRecovery(resume(hold))).rejects.toMatchObject({
|
|
2318
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
2319
|
+
});
|
|
2320
|
+
expect(warn).toHaveBeenCalledExactlyOnceWith('[LCA escrow] enclave operation failed', {
|
|
2321
|
+
reason: 'pin_mismatch',
|
|
2322
|
+
});
|
|
2323
|
+
expect(refund).not.toHaveBeenCalled();
|
|
2324
|
+
expect(await findEscrowHoldById(hold.holdId)).toMatchObject({ status: 'pending' });
|
|
2325
|
+
});
|
|
2326
|
+
|
|
2262
2327
|
it.each([new EscrowUnavailableError(), new EscrowBlobError()])(
|
|
2263
2328
|
'refunds non-mismatch enclave failures: %s',
|
|
2264
2329
|
async error => {
|