@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
|
@@ -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,6 +1,14 @@
|
|
|
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';
|
|
5
|
+
import {
|
|
6
|
+
AuthorityCommitValidator,
|
|
7
|
+
AuthorityHoldReceiptValidator,
|
|
8
|
+
AuthorityMutationValidator,
|
|
9
|
+
AuthorityStateResponseValidator,
|
|
10
|
+
safeInteger,
|
|
11
|
+
} from './authorityContract';
|
|
4
12
|
import {
|
|
5
13
|
ESCROW_ALGORITHM,
|
|
6
14
|
ESCROW_ENVELOPE_VERSION,
|
|
@@ -9,14 +17,18 @@ import {
|
|
|
9
17
|
import {
|
|
10
18
|
EscrowBlobError,
|
|
11
19
|
EscrowPinMismatchError,
|
|
20
|
+
EscrowCodeError,
|
|
12
21
|
EscrowPolicyError,
|
|
13
22
|
EscrowUnavailableError,
|
|
14
23
|
type CarryPinVerifierInput,
|
|
24
|
+
type ConfirmHoldRequest,
|
|
15
25
|
type EnclaveAttestation,
|
|
16
26
|
type EscrowEnclave,
|
|
17
27
|
type ReleaseRequest,
|
|
18
28
|
type ReleaseResult,
|
|
29
|
+
type ResendCodeRequest,
|
|
19
30
|
type RewrapEscrowBlobInput,
|
|
31
|
+
type SendReleaseWarningRequest,
|
|
20
32
|
type VerifyEscrowBlobInput,
|
|
21
33
|
type VerifyEscrowBlobResult,
|
|
22
34
|
} from './types';
|
|
@@ -58,7 +70,6 @@ const verifyBlobResponseValidator = z.union([
|
|
|
58
70
|
]);
|
|
59
71
|
|
|
60
72
|
const carryPinVerifierResponseValidator = z.object({ envelope: envelopeValidator });
|
|
61
|
-
const rewrapEscrowBlobResponseValidator = z.object({ envelope: envelopeValidator });
|
|
62
73
|
|
|
63
74
|
// Opaque SignedHoldRecord JSON: validate shape while preserving signed extensions.
|
|
64
75
|
const holdRecordValidator = z
|
|
@@ -74,23 +85,41 @@ const holdRecordValidator = z
|
|
|
74
85
|
clientEphemeralPublicKey: z.string().min(1),
|
|
75
86
|
createdLo: z.number().int().nonnegative(),
|
|
76
87
|
createdHi: z.number().int().nonnegative(),
|
|
77
|
-
policyVersion: z.
|
|
78
|
-
signature: z.string().min(1),
|
|
88
|
+
policyVersion: z.union([z.literal(1), z.literal(2)]),
|
|
89
|
+
signature: z.string().min(1).max(8192),
|
|
79
90
|
})
|
|
80
91
|
.passthrough(),
|
|
81
92
|
holdDurationMs: z.number().int().nonnegative(),
|
|
82
93
|
ledgerSeq: z.number().int().nonnegative(),
|
|
83
94
|
})
|
|
84
95
|
.passthrough();
|
|
85
|
-
const validatedHold = (
|
|
86
|
-
|
|
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);
|
|
101
|
+
if (hold.hold.policyVersion === 2) {
|
|
102
|
+
try {
|
|
103
|
+
AuthorityCommitValidator.parse(
|
|
104
|
+
JSON.parse(Buffer.from(hold.hold.signature, 'base64').toString('utf8'))
|
|
105
|
+
);
|
|
106
|
+
} catch {
|
|
107
|
+
throw new EscrowUnavailableError(reason);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
87
110
|
return hold;
|
|
88
111
|
};
|
|
89
|
-
const releaseResponseValidator = z.object({
|
|
112
|
+
const releaseResponseValidator = z.object({
|
|
113
|
+
sealed: envelopeValidator,
|
|
114
|
+
receipt: AuthorityCommitValidator.optional(),
|
|
115
|
+
});
|
|
90
116
|
|
|
91
117
|
const errorCodeValidator = z.enum([
|
|
92
118
|
'policy',
|
|
93
119
|
'pinMismatch',
|
|
120
|
+
'codeMismatch',
|
|
121
|
+
'codeExpired',
|
|
122
|
+
'codeLocked',
|
|
94
123
|
'blob',
|
|
95
124
|
'unavailable',
|
|
96
125
|
'ledger',
|
|
@@ -101,8 +130,10 @@ const errorResponseValidator = z.object({ code: errorCodeValidator, message: z.s
|
|
|
101
130
|
const errorForCode = (code: z.infer<typeof errorCodeValidator>): Error => {
|
|
102
131
|
if (code === 'policy') return new EscrowPolicyError();
|
|
103
132
|
if (code === 'pinMismatch') return new EscrowPinMismatchError();
|
|
133
|
+
if (code === 'codeMismatch' || code === 'codeExpired' || code === 'codeLocked')
|
|
134
|
+
return new EscrowCodeError(code);
|
|
104
135
|
if (code === 'blob') return new EscrowBlobError();
|
|
105
|
-
return new EscrowUnavailableError(); // 'unavailable' | 'ledger' | 'time'
|
|
136
|
+
return new EscrowUnavailableError(code); // 'unavailable' | 'ledger' | 'time'
|
|
106
137
|
};
|
|
107
138
|
|
|
108
139
|
export interface RemoteEnclaveTransportResponse {
|
|
@@ -120,15 +151,57 @@ export interface RemoteEnclaveTransport {
|
|
|
120
151
|
|
|
121
152
|
/** With `caPem`, the connection trusts only that CA (system roots are not consulted). */
|
|
122
153
|
export const createHttpTransport = (caPem?: string): RemoteEnclaveTransport => {
|
|
123
|
-
|
|
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 });
|
|
124
158
|
return {
|
|
125
159
|
post: async (url, body, config) => {
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
+
}
|
|
132
205
|
},
|
|
133
206
|
};
|
|
134
207
|
};
|
|
@@ -162,25 +235,46 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
|
|
|
162
235
|
let data: unknown;
|
|
163
236
|
try {
|
|
164
237
|
const response = await transport.post(`${config.baseUrl}${path}`, body, {
|
|
165
|
-
headers: {
|
|
238
|
+
headers: {
|
|
239
|
+
Authorization: `Bearer ${config.token}`,
|
|
240
|
+
...(path === '/v1/create-hold' ? { 'X-Escrow-Delivery-Evidence': '1' } : {}),
|
|
241
|
+
},
|
|
166
242
|
timeout: config.timeoutMs,
|
|
167
243
|
});
|
|
168
244
|
status = response.status;
|
|
169
245
|
data = response.data;
|
|
170
246
|
} catch {
|
|
171
|
-
throw new EscrowUnavailableError();
|
|
247
|
+
throw new EscrowUnavailableError('transport');
|
|
172
248
|
}
|
|
173
249
|
if (status < 200 || status >= 300) {
|
|
174
250
|
const parsedError = errorResponseValidator.safeParse(data);
|
|
175
251
|
if (parsedError.success) throw errorForCode(parsedError.data.code);
|
|
176
|
-
throw new EscrowUnavailableError();
|
|
252
|
+
throw new EscrowUnavailableError(status === 429 ? 'rate_limit' : 'http_refusal');
|
|
177
253
|
}
|
|
178
254
|
const parsed = validator.safeParse(data);
|
|
179
|
-
if (!parsed.success) throw new EscrowUnavailableError();
|
|
255
|
+
if (!parsed.success) throw new EscrowUnavailableError('invalid_response');
|
|
180
256
|
return parsed.data;
|
|
181
257
|
};
|
|
182
258
|
|
|
183
259
|
return {
|
|
260
|
+
getEnrollmentState: input =>
|
|
261
|
+
call('/v1/enrollment-state', input, AuthorityStateResponseValidator),
|
|
262
|
+
applyEnrollmentIntent: async input => {
|
|
263
|
+
const { result } = await call(
|
|
264
|
+
'/v1/apply-enrollment',
|
|
265
|
+
input,
|
|
266
|
+
z.object({ result: AuthorityMutationValidator }).strict()
|
|
267
|
+
);
|
|
268
|
+
if (
|
|
269
|
+
result.authorityGeneration !== input.signedIntent.intent.authorityGeneration ||
|
|
270
|
+
result.enrollmentVersion !==
|
|
271
|
+
input.signedIntent.intent.expectedEnrollmentVersion + 1 ||
|
|
272
|
+
result.enrollmentEpoch !== input.signedIntent.intent.enrollmentEpoch ||
|
|
273
|
+
(result.envelope === null) !== (input.signedIntent.intent.action === 'disable')
|
|
274
|
+
)
|
|
275
|
+
throw new EscrowUnavailableError('invalid_response');
|
|
276
|
+
return result;
|
|
277
|
+
},
|
|
184
278
|
getAttestation: (nonce?: Uint8Array): Promise<EnclaveAttestation> =>
|
|
185
279
|
call('/v1/attest', { nonce: Array.from(nonce ?? []) }, attestResponseValidator),
|
|
186
280
|
verifyEscrowBlob: (input: VerifyEscrowBlobInput): Promise<VerifyEscrowBlobResult> =>
|
|
@@ -207,19 +301,23 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
|
|
|
207
301
|
},
|
|
208
302
|
carryPinVerifierResponseValidator
|
|
209
303
|
),
|
|
210
|
-
rewrapEscrowBlob: (input: RewrapEscrowBlobInput)
|
|
211
|
-
call(
|
|
212
|
-
'/v1/rewrap
|
|
304
|
+
rewrapEscrowBlob: async (input: RewrapEscrowBlobInput) => {
|
|
305
|
+
const { result } = await call(
|
|
306
|
+
'/v1/authority-rewrap',
|
|
213
307
|
{
|
|
214
308
|
envelope: input.envelope,
|
|
215
309
|
expectedDid: input.expectedDid,
|
|
216
310
|
expectedShareVersion: input.expectedShareVersion,
|
|
217
311
|
sourceEnrollmentEpoch: input.sourceEnrollmentEpoch,
|
|
218
312
|
},
|
|
219
|
-
|
|
220
|
-
)
|
|
221
|
-
|
|
222
|
-
|
|
313
|
+
z.object({ result: AuthorityMutationValidator }).strict()
|
|
314
|
+
);
|
|
315
|
+
if (!result.envelope || result.enrollmentEpoch !== input.sourceEnrollmentEpoch)
|
|
316
|
+
throw new EscrowUnavailableError('invalid_response');
|
|
317
|
+
return { envelope: result.envelope, authority: result };
|
|
318
|
+
},
|
|
319
|
+
createHold: async input => {
|
|
320
|
+
const result = await call(
|
|
223
321
|
'/v1/create-hold',
|
|
224
322
|
{
|
|
225
323
|
envelope: input.envelope,
|
|
@@ -230,24 +328,93 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
|
|
|
230
328
|
releasePolicy: input.releasePolicy,
|
|
231
329
|
clientEphemeralPublicKey: input.clientEphemeralPublicKey,
|
|
232
330
|
},
|
|
233
|
-
holdRecordValidator
|
|
234
|
-
|
|
235
|
-
|
|
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 })),
|
|
236
346
|
cancelHold: async input => {
|
|
237
|
-
await call(
|
|
347
|
+
const result = await call(
|
|
238
348
|
'/v1/cancel-hold',
|
|
239
349
|
{
|
|
240
350
|
envelope: input.envelope,
|
|
241
351
|
hold: validatedHold(input.hold),
|
|
242
352
|
clientEphemeralPublicKey: input.clientEphemeralPublicKey,
|
|
243
353
|
expectedDid: input.expectedDid,
|
|
354
|
+
...(input.requestId ? { requestId: input.requestId } : {}),
|
|
355
|
+
},
|
|
356
|
+
z.object({ ok: z.literal(true), receipt: AuthorityCommitValidator.optional() })
|
|
357
|
+
);
|
|
358
|
+
if (input.hold.hold.policyVersion === 2 && !result.receipt)
|
|
359
|
+
throw new EscrowUnavailableError('invalid_response');
|
|
360
|
+
if (result.receipt) return { receipt: result.receipt };
|
|
361
|
+
return undefined;
|
|
362
|
+
},
|
|
363
|
+
confirmHold: async (input: ConfirmHoldRequest) => {
|
|
364
|
+
const result = await call(
|
|
365
|
+
'/v1/confirm-hold',
|
|
366
|
+
{
|
|
367
|
+
envelope: input.envelope,
|
|
368
|
+
hold: validatedHold(input.hold),
|
|
369
|
+
clientEphemeralPublicKey: input.clientEphemeralPublicKey,
|
|
370
|
+
expectedDid: input.expectedDid,
|
|
371
|
+
code: input.code,
|
|
372
|
+
...(input.requestId ? { requestId: input.requestId } : {}),
|
|
373
|
+
},
|
|
374
|
+
z.object({
|
|
375
|
+
confirmed: z.literal(true),
|
|
376
|
+
receipt: AuthorityHoldReceiptValidator,
|
|
377
|
+
confirmedHiMs: safeInteger,
|
|
378
|
+
})
|
|
379
|
+
);
|
|
380
|
+
return { receipt: result.receipt, confirmedAtMs: result.confirmedHiMs };
|
|
381
|
+
},
|
|
382
|
+
resendCode: async (input: ResendCodeRequest) => {
|
|
383
|
+
const result = await call(
|
|
384
|
+
'/v1/resend-code',
|
|
385
|
+
{
|
|
386
|
+
envelope: input.envelope,
|
|
387
|
+
hold: validatedHold(input.hold),
|
|
388
|
+
clientEphemeralPublicKey: input.clientEphemeralPublicKey,
|
|
389
|
+
expectedDid: input.expectedDid,
|
|
390
|
+
...(input.requestId ? { requestId: input.requestId } : {}),
|
|
391
|
+
},
|
|
392
|
+
z.object({ sent: z.literal(true), receipt: AuthorityCommitValidator })
|
|
393
|
+
);
|
|
394
|
+
return { receipt: result.receipt };
|
|
395
|
+
},
|
|
396
|
+
sendReleaseWarning: async (input: SendReleaseWarningRequest) => {
|
|
397
|
+
const result = await call(
|
|
398
|
+
'/v1/send-release-warning',
|
|
399
|
+
{
|
|
400
|
+
holdId: input.holdId,
|
|
401
|
+
expectedDid: input.expectedDid,
|
|
402
|
+
...(input.requestId ? { requestId: input.requestId } : {}),
|
|
244
403
|
},
|
|
245
|
-
z.object({
|
|
404
|
+
z.object({
|
|
405
|
+
sent: z.literal(true),
|
|
406
|
+
receipt: AuthorityHoldReceiptValidator,
|
|
407
|
+
releaseWarningSentHiMs: safeInteger,
|
|
408
|
+
})
|
|
246
409
|
);
|
|
410
|
+
return {
|
|
411
|
+
receipt: result.receipt,
|
|
412
|
+
releaseWarningSentAtMs: result.releaseWarningSentHiMs,
|
|
413
|
+
};
|
|
247
414
|
},
|
|
248
415
|
releaseEscrow: async (input: ReleaseRequest): Promise<ReleaseResult> => {
|
|
249
416
|
validatedHold(input.hold);
|
|
250
|
-
|
|
417
|
+
const result = await call(
|
|
251
418
|
'/v1/release',
|
|
252
419
|
{
|
|
253
420
|
envelope: input.envelope,
|
|
@@ -255,9 +422,13 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
|
|
|
255
422
|
clientEphemeralPublicKey: input.clientEphemeralPublicKey,
|
|
256
423
|
expectedDid: input.expectedDid,
|
|
257
424
|
...(input.pinProof !== undefined ? { pinProof: input.pinProof } : {}),
|
|
425
|
+
...(input.requestId ? { requestId: input.requestId } : {}),
|
|
258
426
|
},
|
|
259
427
|
releaseResponseValidator
|
|
260
428
|
);
|
|
429
|
+
if (input.hold.hold.policyVersion === 2 && !result.receipt)
|
|
430
|
+
throw new EscrowUnavailableError('invalid_response');
|
|
431
|
+
return result;
|
|
261
432
|
},
|
|
262
433
|
};
|
|
263
434
|
};
|