@calimero-network/mero-js 19.22.0 → 19.24.0
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/dist/admin-api/admin-client.d.ts +7 -0
- package/dist/admin-api/admin-client.js +9 -0
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +368 -138
- package/dist/index.cjs.map +4 -4
- package/dist/index.mjs +368 -138
- package/dist/index.mjs.map +4 -4
- package/dist/sealed/index.d.ts +1 -1
- package/dist/sealed/index.js +1 -1
- package/dist/sealed/noise.d.ts +44 -0
- package/dist/sealed/noise.js +137 -0
- package/dist/sealed/sealed.d.ts +60 -40
- package/dist/sealed/sealed.js +270 -126
- package/package.json +1 -1
package/dist/sealed/sealed.js
CHANGED
|
@@ -1,53 +1,64 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Sealed transport: requests encrypted to a TEE node's attested key.
|
|
2
|
+
* Sealed transport: requests encrypted end to end to a TEE node's attested key.
|
|
3
3
|
*
|
|
4
4
|
* A node speaks plain HTTP and any TLS in front of it ends outside the enclave,
|
|
5
5
|
* at whatever proxy or relay the operator runs, so everything crossing that hop
|
|
6
6
|
* (the bearer token, JSON-RPC arguments, results) is readable there. Sealing
|
|
7
|
-
* encrypts
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* encrypts it so that only the attested TD can read it, and only the TD can
|
|
8
|
+
* produce a response the client accepts.
|
|
9
|
+
*
|
|
10
|
+
* The client opens a session with a Noise NK handshake to the X25519 transport
|
|
11
|
+
* key the node's quote commits to ({@link fetchAttestedTransportKey}). The
|
|
12
|
+
* transport key only authenticates the node: the session keys come from both
|
|
13
|
+
* sides' ephemeral keys, so once a session is gone (an hour at most) what was
|
|
14
|
+
* sent in it cannot be opened, not even with the transport key. Each request
|
|
15
|
+
* is sealed under the session; each response streams back in sealed frames, so
|
|
16
|
+
* server-sent events are sealed too. WebSockets cannot be, and the node
|
|
17
|
+
* refuses one inside the envelope with a 501.
|
|
10
18
|
*
|
|
11
19
|
* The node's side, and the wire format this implements byte for byte, are in
|
|
12
20
|
* core's `crates/server/src/sealed.rs`:
|
|
13
21
|
*
|
|
14
22
|
* ```text
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* resp key
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
23
|
+
* handshake Noise_NK_25519_AESGCM_SHA256, prologue "calimero/sealed-http/v2"
|
|
24
|
+
* request POST /sealed/v2/handshake 0x02 || transport_pk || Noise message 1
|
|
25
|
+
* response 0x02 || Noise message 2 (payload = session_id(16) || lifetime secs, u32 BE)
|
|
26
|
+
* keys (req key, resp key) = Split()
|
|
27
|
+
* exchange POST /sealed/v2
|
|
28
|
+
* header 0x02 || session_id(16) || request_id (u64 BE; from 1, each used once)
|
|
29
|
+
* request header || AES-256-GCM(req key, nonce = request_id || 0u32, aad = header, inner)
|
|
30
|
+
* response frames, each u32 BE length || AES-256-GCM(resp key,
|
|
31
|
+
* nonce = request_id || frame index (u32 BE, from 0), aad = header, kind || data)
|
|
32
|
+
* kind 0 = head (JSON), 1 = body bytes, 2 = end; no end frame = cut short
|
|
33
|
+
* inner u32 BE head length || head (JSON) || body
|
|
24
34
|
* ```
|
|
25
35
|
*
|
|
26
|
-
* Use it by
|
|
27
|
-
* {@link createSealedFetch} to `MeroJsConfig.fetch`. Server-sent events and
|
|
28
|
-
* WebSockets are streams and cannot be sealed; the node refuses a sealed SSE
|
|
29
|
-
* request with a 501 rather than hang.
|
|
36
|
+
* Use it by handing {@link createSealedFetch} to `MeroJsConfig.fetch`.
|
|
30
37
|
*/
|
|
31
38
|
import { concat, fromHex, hex } from '../crypto/internal.js';
|
|
39
|
+
import { initiate } from './noise.js';
|
|
40
|
+
/** Where a session is opened, relative to the node's base URL. */
|
|
41
|
+
export const HANDSHAKE_PATH = '/sealed/v2/handshake';
|
|
32
42
|
/** Where sealed requests are posted, relative to the node's base URL. */
|
|
33
|
-
export const SEALED_PATH = '/sealed/
|
|
34
|
-
/** Content type of
|
|
43
|
+
export const SEALED_PATH = '/sealed/v2';
|
|
44
|
+
/** Content type of every sealed body. */
|
|
35
45
|
export const SEALED_CONTENT_TYPE = 'application/vnd.calimero.sealed';
|
|
36
|
-
const VERSION =
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
const
|
|
46
|
+
const VERSION = 2;
|
|
47
|
+
const PROLOGUE = new TextEncoder().encode('calimero/sealed-http/v2');
|
|
48
|
+
const SESSION_ID_LEN = 16;
|
|
49
|
+
const HEADER_LEN = 1 + SESSION_ID_LEN + 8;
|
|
50
|
+
const FRAME_HEAD = 0;
|
|
51
|
+
const FRAME_DATA = 1;
|
|
52
|
+
const FRAME_END = 2;
|
|
53
|
+
/** Open a new session this long before the node would drop the current one. */
|
|
54
|
+
const RENEW_BEFORE_MS = 60000;
|
|
40
55
|
const TRANSPORT_BINDING_DOMAIN = 'calimero.tee-attest.transport-key.v1';
|
|
41
|
-
|
|
42
|
-
const PKCS8_X25519_PREFIX = new Uint8Array([
|
|
43
|
-
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x04,
|
|
44
|
-
0x22, 0x04, 0x20,
|
|
45
|
-
]);
|
|
56
|
+
const NULL_BODY_STATUSES = [101, 204, 205, 304];
|
|
46
57
|
const utf8 = new TextEncoder();
|
|
47
|
-
/** The node no longer holds the key this client
|
|
58
|
+
/** The node no longer holds the transport key this client attested (it restarted). */
|
|
48
59
|
export class StaleTransportKeyError extends Error {
|
|
49
60
|
constructor() {
|
|
50
|
-
super('The node no longer holds the transport key
|
|
61
|
+
super('The node no longer holds the attested transport key; attest again for the current one');
|
|
51
62
|
this.name = 'StaleTransportKeyError';
|
|
52
63
|
}
|
|
53
64
|
}
|
|
@@ -102,8 +113,8 @@ export async function fetchAttestedTransportKey(admin, verify, options = {}) {
|
|
|
102
113
|
return transportKey;
|
|
103
114
|
}
|
|
104
115
|
/**
|
|
105
|
-
* A `fetch` that seals every request to the node's transport key.
|
|
106
|
-
* `MeroJsConfig.fetch`.
|
|
116
|
+
* A `fetch` that seals every request to the node's attested transport key.
|
|
117
|
+
* Pass it as `MeroJsConfig.fetch`.
|
|
107
118
|
*
|
|
108
119
|
* A request for any URL outside `baseUrl` is refused rather than sent in the
|
|
109
120
|
* clear: falling back to plaintext would silently undo the point of sealing.
|
|
@@ -111,12 +122,46 @@ export async function fetchAttestedTransportKey(admin, verify, options = {}) {
|
|
|
111
122
|
export function createSealedFetch(options) {
|
|
112
123
|
const base = new URL(options.baseUrl);
|
|
113
124
|
const basePath = base.pathname.replace(/\/+$/, '');
|
|
114
|
-
const
|
|
115
|
-
const transportKey = options.transportPublicKey;
|
|
116
|
-
if (transportKey.length !== 32) {
|
|
117
|
-
throw new Error('transportPublicKey must be 32 bytes');
|
|
118
|
-
}
|
|
125
|
+
const nodeUrl = `${base.origin}${basePath}`;
|
|
119
126
|
const baseFetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
127
|
+
const attest = typeof options.transportPublicKey === 'function' ? options.transportPublicKey : undefined;
|
|
128
|
+
let transportKey = attest
|
|
129
|
+
? Promise.resolve().then(attest)
|
|
130
|
+
: Promise.resolve(options.transportPublicKey);
|
|
131
|
+
let session;
|
|
132
|
+
const openSession = async () => {
|
|
133
|
+
try {
|
|
134
|
+
return await handshake(baseFetch, nodeUrl, await checkedKey(transportKey));
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (!(error instanceof StaleTransportKeyError) || !attest)
|
|
138
|
+
throw error;
|
|
139
|
+
transportKey = Promise.resolve().then(attest);
|
|
140
|
+
return handshake(baseFetch, nodeUrl, await checkedKey(transportKey));
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
/** The session to use now, and the promise it came from, to name it if it turns out stale. */
|
|
144
|
+
const currentSession = async () => {
|
|
145
|
+
const pending = session ?? renew(undefined);
|
|
146
|
+
const open = await pending;
|
|
147
|
+
if (Date.now() < open.renewAt)
|
|
148
|
+
return [open, pending];
|
|
149
|
+
const renewed = renew(pending);
|
|
150
|
+
return [await renewed, renewed];
|
|
151
|
+
};
|
|
152
|
+
// Replace the session only if it is still the one found stale, so concurrent
|
|
153
|
+
// requests that all find it stale open one new session between them.
|
|
154
|
+
const renew = (stale) => {
|
|
155
|
+
if (session === stale) {
|
|
156
|
+
const opening = openSession();
|
|
157
|
+
session = opening;
|
|
158
|
+
opening.catch(() => {
|
|
159
|
+
if (session === opening)
|
|
160
|
+
session = undefined;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return session;
|
|
164
|
+
};
|
|
120
165
|
return async (input, init) => {
|
|
121
166
|
const request = new Request(input, init);
|
|
122
167
|
const url = new URL(request.url);
|
|
@@ -125,113 +170,212 @@ export function createSealedFetch(options) {
|
|
|
125
170
|
}
|
|
126
171
|
const headers = [];
|
|
127
172
|
request.headers.forEach((value, name) => headers.push([name, value]));
|
|
128
|
-
const head = {
|
|
129
|
-
method: request.method,
|
|
130
|
-
path: url.pathname.slice(basePath.length) + url.search,
|
|
131
|
-
headers,
|
|
132
|
-
ts: Math.floor(Date.now() / 1000),
|
|
133
|
-
};
|
|
173
|
+
const head = { method: request.method, path: url.pathname.slice(basePath.length) + url.search, headers };
|
|
134
174
|
const body = new Uint8Array(await request.arrayBuffer());
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
175
|
+
const send = async (open) => {
|
|
176
|
+
const { requestId, header, envelope } = await sealRequest(open, head, body);
|
|
177
|
+
const response = await baseFetch(`${nodeUrl}${SEALED_PATH}`, {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
headers: { 'content-type': SEALED_CONTENT_TYPE },
|
|
180
|
+
body: envelope,
|
|
181
|
+
signal: request.signal,
|
|
182
|
+
});
|
|
183
|
+
if (response.headers.get('content-type') !== SEALED_CONTENT_TYPE) {
|
|
184
|
+
throw await envelopeRefusal(response);
|
|
185
|
+
}
|
|
186
|
+
return openResponse(response, open.responseKey, header, requestId);
|
|
187
|
+
};
|
|
188
|
+
const [open, pending] = await currentSession();
|
|
189
|
+
try {
|
|
190
|
+
return await send(open);
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
// The node refused the envelope before running anything in it, so the
|
|
194
|
+
// request can be sent again safely under a new session.
|
|
195
|
+
if (!(error instanceof SealedTransportError) || error.code !== 'unknown_session')
|
|
196
|
+
throw error;
|
|
197
|
+
return send(await renew(pending));
|
|
144
198
|
}
|
|
145
|
-
const opened = await exchange.open(new Uint8Array(await response.arrayBuffer()));
|
|
146
|
-
const nullBody = [101, 204, 205, 304].includes(opened.head.status);
|
|
147
|
-
return new Response(nullBody ? null : opened.body, {
|
|
148
|
-
status: opened.head.status,
|
|
149
|
-
headers: opened.head.headers,
|
|
150
|
-
});
|
|
151
199
|
};
|
|
152
200
|
}
|
|
153
|
-
async function
|
|
154
|
-
|
|
155
|
-
|
|
201
|
+
async function checkedKey(key) {
|
|
202
|
+
const bytes = await key;
|
|
203
|
+
if (bytes.length !== 32)
|
|
204
|
+
throw new Error('transportPublicKey must be 32 bytes');
|
|
205
|
+
return bytes;
|
|
206
|
+
}
|
|
207
|
+
async function handshake(baseFetch, nodeUrl, transportKey) {
|
|
208
|
+
const noise = await initiate(transportKey, PROLOGUE);
|
|
209
|
+
const response = await baseFetch(`${nodeUrl}${HANDSHAKE_PATH}`, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: { 'content-type': SEALED_CONTENT_TYPE },
|
|
212
|
+
body: concat(new Uint8Array([VERSION]), transportKey, noise.message1),
|
|
213
|
+
});
|
|
214
|
+
if (response.headers.get('content-type') !== SEALED_CONTENT_TYPE) {
|
|
215
|
+
throw await envelopeRefusal(response);
|
|
216
|
+
}
|
|
217
|
+
const reply = new Uint8Array(await response.arrayBuffer());
|
|
218
|
+
return sessionFrom(noise, reply);
|
|
219
|
+
}
|
|
220
|
+
/** Finish the handshake with the node's reply. Exported for the published vectors. */
|
|
221
|
+
export async function sessionFrom(noise, reply) {
|
|
222
|
+
if (reply[0] !== VERSION)
|
|
223
|
+
throw new Error('The handshake reply is malformed');
|
|
224
|
+
let finished;
|
|
156
225
|
try {
|
|
157
|
-
|
|
158
|
-
code = body?.error?.code;
|
|
159
|
-
if (body?.error?.message)
|
|
160
|
-
message = `Sealed request refused: ${body.error.message}`;
|
|
226
|
+
finished = await noise.finish(reply.slice(1));
|
|
161
227
|
}
|
|
162
228
|
catch {
|
|
163
|
-
|
|
164
|
-
// all that can honestly be reported.
|
|
229
|
+
throw new Error('The handshake reply did not open: it did not come from the attested node');
|
|
165
230
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const { privateKey, publicKey } = await clientKeyPair(fixed?.clientSecret);
|
|
178
|
-
const transport = await crypto.subtle.importKey('raw', transportKey, { name: 'X25519' }, false, []);
|
|
179
|
-
const shared = new Uint8Array(await crypto.subtle.deriveBits({ name: 'X25519', public: transport }, privateKey, 256));
|
|
180
|
-
// A low-order point yields an all-zero secret that anybody can compute.
|
|
181
|
-
if (shared.every((byte) => byte === 0)) {
|
|
182
|
-
throw new Error('The transport key is a low-order point');
|
|
183
|
-
}
|
|
184
|
-
const salt = concat(transportKey, publicKey);
|
|
185
|
-
const requestKey = await exchangeKey(shared, salt, REQUEST_DOMAIN, 'encrypt');
|
|
186
|
-
const responseKey = await exchangeKey(shared, salt, RESPONSE_DOMAIN, 'decrypt');
|
|
187
|
-
const nonce = fixed?.nonce ?? crypto.getRandomValues(new Uint8Array(NONCE_LEN));
|
|
188
|
-
const header = concat(new Uint8Array([VERSION]), transportKey, publicKey, nonce);
|
|
189
|
-
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce, additionalData: header }, requestKey, joinInner(head, body));
|
|
190
|
-
const responseAad = concat(new Uint8Array([VERSION]), salt);
|
|
231
|
+
const { payload, split } = finished;
|
|
232
|
+
if (payload.length !== SESSION_ID_LEN + 4)
|
|
233
|
+
throw new Error('The handshake reply is malformed');
|
|
234
|
+
const sessionId = payload.slice(0, SESSION_ID_LEN);
|
|
235
|
+
const lifetimeSecs = new DataView(payload.buffer, payload.byteOffset).getUint32(SESSION_ID_LEN, false);
|
|
236
|
+
const [requestKey, responseKey] = await Promise.all([
|
|
237
|
+
crypto.subtle.importKey('raw', split[0], 'AES-GCM', false, ['encrypt']),
|
|
238
|
+
crypto.subtle.importKey('raw', split[1], 'AES-GCM', false, ['decrypt']),
|
|
239
|
+
]);
|
|
240
|
+
split[0].fill(0);
|
|
241
|
+
split[1].fill(0);
|
|
191
242
|
return {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
throw new Error('The sealed response did not open: it was not sealed by the attested node');
|
|
203
|
-
}
|
|
204
|
-
const view = new DataView(plaintext.buffer, plaintext.byteOffset, plaintext.byteLength);
|
|
205
|
-
const len = view.getUint32(0, false);
|
|
206
|
-
const head = JSON.parse(new TextDecoder().decode(plaintext.slice(4, 4 + len)));
|
|
207
|
-
return { head, body: plaintext.slice(4 + len) };
|
|
243
|
+
requestKey,
|
|
244
|
+
responseKey,
|
|
245
|
+
nextRequestId: 1,
|
|
246
|
+
renewAt: Date.now() + lifetimeSecs * 1000 - RENEW_BEFORE_MS,
|
|
247
|
+
header(requestId) {
|
|
248
|
+
const header = new Uint8Array(HEADER_LEN);
|
|
249
|
+
header[0] = VERSION;
|
|
250
|
+
header.set(sessionId, 1);
|
|
251
|
+
new DataView(header.buffer).setBigUint64(1 + SESSION_ID_LEN, BigInt(requestId), false);
|
|
252
|
+
return header;
|
|
208
253
|
},
|
|
209
254
|
};
|
|
210
255
|
}
|
|
256
|
+
/** Seal one request under the session's next request id. */
|
|
257
|
+
export async function sealRequest(session, head, body) {
|
|
258
|
+
const requestId = session.nextRequestId;
|
|
259
|
+
session.nextRequestId += 1;
|
|
260
|
+
const header = session.header(requestId);
|
|
261
|
+
const sealed = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce(requestId, 0), additionalData: header }, session.requestKey, joinInner(head, body));
|
|
262
|
+
return { requestId, header, envelope: concat(header, new Uint8Array(sealed)) };
|
|
263
|
+
}
|
|
264
|
+
/** `request_id || index`, as the node numbers them. */
|
|
265
|
+
function nonce(requestId, index) {
|
|
266
|
+
const bytes = new Uint8Array(12);
|
|
267
|
+
const view = new DataView(bytes.buffer);
|
|
268
|
+
view.setBigUint64(0, BigInt(requestId), false);
|
|
269
|
+
view.setUint32(8, index, false);
|
|
270
|
+
return bytes;
|
|
271
|
+
}
|
|
211
272
|
function joinInner(head, body) {
|
|
212
273
|
const encoded = utf8.encode(JSON.stringify(head));
|
|
213
274
|
const len = new Uint8Array(4);
|
|
214
275
|
new DataView(len.buffer).setUint32(0, encoded.length, false);
|
|
215
276
|
return concat(len, encoded, body);
|
|
216
277
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
278
|
+
/**
|
|
279
|
+
* Open a sealed response as it streams: wait for its head, then hand back a
|
|
280
|
+
* `Response` whose body opens each frame as it arrives. A frame that does not
|
|
281
|
+
* open, arrives out of order, or a stream that stops before its end frame
|
|
282
|
+
* errors the body rather than passing anything unauthenticated on.
|
|
283
|
+
*/
|
|
284
|
+
export async function openResponse(response, responseKey, header, requestId) {
|
|
285
|
+
if (!response.body)
|
|
286
|
+
throw new Error('The sealed response has no body');
|
|
287
|
+
const frames = frameReader(response.body.getReader());
|
|
288
|
+
let index = 0;
|
|
289
|
+
const next = async () => {
|
|
290
|
+
const sealed = await frames.next();
|
|
291
|
+
if (!sealed)
|
|
292
|
+
return null;
|
|
293
|
+
let plain;
|
|
294
|
+
try {
|
|
295
|
+
plain = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce(requestId, index), additionalData: header }, responseKey, sealed));
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
throw new Error('A sealed response frame did not open: it was not sealed by the attested node');
|
|
299
|
+
}
|
|
300
|
+
index += 1;
|
|
301
|
+
return { kind: plain[0], data: plain.slice(1) };
|
|
302
|
+
};
|
|
303
|
+
const first = await next();
|
|
304
|
+
if (!first || first.kind !== FRAME_HEAD)
|
|
305
|
+
throw new Error('The sealed response is malformed');
|
|
306
|
+
const head = JSON.parse(new TextDecoder().decode(first.data));
|
|
307
|
+
if (NULL_BODY_STATUSES.includes(head.status)) {
|
|
308
|
+
await frames.cancel();
|
|
309
|
+
return new Response(null, { status: head.status, headers: head.headers });
|
|
228
310
|
}
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
311
|
+
const body = new ReadableStream({
|
|
312
|
+
async pull(controller) {
|
|
313
|
+
try {
|
|
314
|
+
const frame = await next();
|
|
315
|
+
if (!frame) {
|
|
316
|
+
controller.error(new Error('The sealed response was cut short'));
|
|
317
|
+
}
|
|
318
|
+
else if (frame.kind === FRAME_DATA) {
|
|
319
|
+
controller.enqueue(frame.data);
|
|
320
|
+
}
|
|
321
|
+
else if (frame.kind === FRAME_END) {
|
|
322
|
+
controller.close();
|
|
323
|
+
await frames.cancel();
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
controller.error(new Error('The sealed response is malformed'));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
controller.error(error);
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
cancel() {
|
|
334
|
+
return frames.cancel();
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
return new Response(body, { status: head.status, headers: head.headers });
|
|
232
338
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
339
|
+
/** Split a byte stream into length-prefixed frames. */
|
|
340
|
+
function frameReader(reader) {
|
|
341
|
+
let buffer = new Uint8Array();
|
|
342
|
+
const fill = async (length) => {
|
|
343
|
+
while (buffer.length < length) {
|
|
344
|
+
const { done, value } = await reader.read();
|
|
345
|
+
if (done)
|
|
346
|
+
return false;
|
|
347
|
+
buffer = concat(buffer, value);
|
|
348
|
+
}
|
|
349
|
+
return true;
|
|
350
|
+
};
|
|
351
|
+
return {
|
|
352
|
+
async next() {
|
|
353
|
+
if (!(await fill(4)))
|
|
354
|
+
return null;
|
|
355
|
+
const length = new DataView(buffer.buffer, buffer.byteOffset).getUint32(0, false);
|
|
356
|
+
if (!(await fill(4 + length)))
|
|
357
|
+
return null;
|
|
358
|
+
const frame = buffer.slice(4, 4 + length);
|
|
359
|
+
buffer = buffer.slice(4 + length);
|
|
360
|
+
return frame;
|
|
361
|
+
},
|
|
362
|
+
cancel: () => reader.cancel(),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
async function envelopeRefusal(response) {
|
|
366
|
+
let code;
|
|
367
|
+
let message = `Sealed request refused: HTTP ${response.status}`;
|
|
368
|
+
try {
|
|
369
|
+
const body = (await response.json());
|
|
370
|
+
code = body?.error?.code;
|
|
371
|
+
if (body?.error?.message)
|
|
372
|
+
message = `Sealed request refused: ${body.error.message}`;
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
// Not the node's JSON refusal — a proxy's page, most likely. The status is
|
|
376
|
+
// all that can honestly be reported.
|
|
377
|
+
}
|
|
378
|
+
if (code === 'stale_transport_key')
|
|
379
|
+
return new StaleTransportKeyError();
|
|
380
|
+
return new SealedTransportError(response.status, code, message);
|
|
237
381
|
}
|