@layervai/qurl 0.3.1 → 0.4.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/README.md +187 -7
- package/dist/cjs/client.d.ts +17 -10
- package/dist/cjs/client.js +649 -96
- package/dist/cjs/errors.d.ts +11 -11
- package/dist/cjs/errors.js +25 -20
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/internal.d.ts +4 -0
- package/dist/cjs/internal.js +16 -0
- package/dist/cjs/node/deployment.d.ts +44 -0
- package/dist/cjs/node/deployment.js +167 -0
- package/dist/cjs/node/native-udp.d.ts +37 -0
- package/dist/cjs/node/native-udp.js +294 -0
- package/dist/cjs/node/nhp-wire.d.ts +35 -0
- package/dist/cjs/node/nhp-wire.js +261 -0
- package/dist/cjs/node/portal-opener.d.ts +83 -0
- package/dist/cjs/node/portal-opener.js +767 -0
- package/dist/cjs/node/qv2.d.ts +38 -0
- package/dist/cjs/node/qv2.js +292 -0
- package/dist/cjs/node/strict-json.d.ts +12 -0
- package/dist/cjs/node/strict-json.js +174 -0
- package/dist/cjs/node.d.ts +3 -0
- package/dist/cjs/node.js +11 -0
- package/dist/cjs/types.d.ts +7 -18
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/client.d.ts +17 -10
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +647 -94
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/errors.d.ts +11 -11
- package/dist/esm/errors.d.ts.map +1 -1
- package/dist/esm/errors.js +25 -20
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/internal.d.ts +5 -0
- package/dist/esm/internal.d.ts.map +1 -0
- package/dist/esm/internal.js +14 -0
- package/dist/esm/internal.js.map +1 -0
- package/dist/esm/node/deployment.d.ts +45 -0
- package/dist/esm/node/deployment.d.ts.map +1 -0
- package/dist/esm/node/deployment.js +163 -0
- package/dist/esm/node/deployment.js.map +1 -0
- package/dist/esm/node/native-udp.d.ts +38 -0
- package/dist/esm/node/native-udp.d.ts.map +1 -0
- package/dist/esm/node/native-udp.js +291 -0
- package/dist/esm/node/native-udp.js.map +1 -0
- package/dist/esm/node/nhp-wire.d.ts +36 -0
- package/dist/esm/node/nhp-wire.d.ts.map +1 -0
- package/dist/esm/node/nhp-wire.js +257 -0
- package/dist/esm/node/nhp-wire.js.map +1 -0
- package/dist/esm/node/portal-opener.d.ts +84 -0
- package/dist/esm/node/portal-opener.d.ts.map +1 -0
- package/dist/esm/node/portal-opener.js +757 -0
- package/dist/esm/node/portal-opener.js.map +1 -0
- package/dist/esm/node/qv2.d.ts +39 -0
- package/dist/esm/node/qv2.d.ts.map +1 -0
- package/dist/esm/node/qv2.js +286 -0
- package/dist/esm/node/qv2.js.map +1 -0
- package/dist/esm/node/strict-json.d.ts +13 -0
- package/dist/esm/node/strict-json.d.ts.map +1 -0
- package/dist/esm/node/strict-json.js +171 -0
- package/dist/esm/node/strict-json.js.map +1 -0
- package/dist/esm/node.d.ts +4 -0
- package/dist/esm/node.d.ts.map +1 -0
- package/dist/esm/node.js +2 -0
- package/dist/esm/node.js.map +1 -0
- package/dist/esm/types.d.ts +7 -18
- package/dist/esm/types.d.ts.map +1 -1
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +25 -6
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { NHP_MAX_BODY_SIZE, NHP_TYPE_ACK, NHP_TYPE_COOKIE } from "./nhp-wire.js";
|
|
3
|
+
import { fingerprintKey, loadPortalDeployment, } from "./deployment.js";
|
|
4
|
+
import { nativeKnock, NATIVE_DEFAULT_MAX_ADDRESSES, NATIVE_DEFAULT_TIMEOUT_MS, } from "./native-udp.js";
|
|
5
|
+
import { isStrictJsonObject, parseStrictJson } from "./strict-json.js";
|
|
6
|
+
import { verifyQv2Link } from "./qv2.js";
|
|
7
|
+
const SESSION_COOKIE = "qurl_vsession";
|
|
8
|
+
const MAX_REDIRECT_REQUESTS = 10;
|
|
9
|
+
const RENEWAL_NUMERATOR = 3;
|
|
10
|
+
const RENEWAL_DENOMINATOR = 4;
|
|
11
|
+
const MAX_BACKGROUND_RENEWAL_ATTEMPTS = 4;
|
|
12
|
+
const MIN_BACKGROUND_RETRY_MS = 100;
|
|
13
|
+
// qurl-go bounds the full portal open at 15 seconds. Larger caller-selected
|
|
14
|
+
// per-address budgets remain usable, but every DNS plus UDP sequence is finite.
|
|
15
|
+
const DEFAULT_OPEN_TIMEOUT_MS = 15_000;
|
|
16
|
+
// Node changes a larger setTimeout delay to 1 ms. Clamp long grants so an
|
|
17
|
+
// authenticated but unexpected lifetime cannot create a hot renewal loop.
|
|
18
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
19
|
+
const MAX_ACK_BODY_BYTES = 4_096;
|
|
20
|
+
const UINT64_MAX = (1n << 64n) - 1n;
|
|
21
|
+
export class PortalConfigurationError extends Error {
|
|
22
|
+
constructor(message, options) {
|
|
23
|
+
super(message, options);
|
|
24
|
+
this.name = "PortalConfigurationError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export class PortalVerificationError extends Error {
|
|
28
|
+
constructor(message, options) {
|
|
29
|
+
super(message, options);
|
|
30
|
+
this.name = "PortalVerificationError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export class PortalDenyError extends Error {
|
|
34
|
+
errCode;
|
|
35
|
+
constructor(errCode) {
|
|
36
|
+
super(`qURL platform denied native access (errCode=${JSON.stringify(errCode)})`);
|
|
37
|
+
this.name = "PortalDenyError";
|
|
38
|
+
this.errCode = errCode;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export class PortalStateError extends Error {
|
|
42
|
+
constructor(message, options) {
|
|
43
|
+
super(message, options);
|
|
44
|
+
this.name = "PortalStateError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export class PortalBusyError extends PortalStateError {
|
|
48
|
+
constructor() {
|
|
49
|
+
super("qURL platform is busy; retry start later");
|
|
50
|
+
this.name = "PortalBusyError";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export class PortalInvalidReplyError extends PortalStateError {
|
|
54
|
+
constructor(message, options) {
|
|
55
|
+
super(message, options);
|
|
56
|
+
this.name = "PortalInvalidReplyError";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const defaultRuntime = {
|
|
60
|
+
knock: nativeKnock,
|
|
61
|
+
fetch: globalThis.fetch.bind(globalThis),
|
|
62
|
+
nowNanos: () => process.hrtime.bigint(),
|
|
63
|
+
randomFraction: () => randomBytes(2).readUInt16BE(0) / 0xffff,
|
|
64
|
+
setTimer: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
65
|
+
clearTimer: (timer) => clearTimeout(timer),
|
|
66
|
+
setDeadlineTimer: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
67
|
+
clearDeadlineTimer: (timer) => clearTimeout(timer),
|
|
68
|
+
};
|
|
69
|
+
export function createPortalOpener(options) {
|
|
70
|
+
return createPortalOpenerWithRuntime(options, defaultRuntime);
|
|
71
|
+
}
|
|
72
|
+
export function createPortalOpenerWithRuntime(options, runtime) {
|
|
73
|
+
if (!options || typeof options !== "object") {
|
|
74
|
+
throw new PortalConfigurationError("native portal opener options are required");
|
|
75
|
+
}
|
|
76
|
+
if (options.transport !== "native-only") {
|
|
77
|
+
throw new PortalConfigurationError("native portal opener transport must be native-only");
|
|
78
|
+
}
|
|
79
|
+
if (typeof options.qurl !== "string" || options.qurl === "") {
|
|
80
|
+
throw new PortalConfigurationError("native portal opener qurl must be a non-empty string");
|
|
81
|
+
}
|
|
82
|
+
if (options.timeoutMs !== undefined &&
|
|
83
|
+
(!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > 60_000)) {
|
|
84
|
+
throw new PortalConfigurationError("native portal opener timeoutMs must be from 1 to 60000");
|
|
85
|
+
}
|
|
86
|
+
if (options.maxAddresses !== undefined &&
|
|
87
|
+
(!Number.isInteger(options.maxAddresses) ||
|
|
88
|
+
options.maxAddresses < 1 ||
|
|
89
|
+
options.maxAddresses > 16)) {
|
|
90
|
+
throw new PortalConfigurationError("native portal opener maxAddresses must be from 1 to 16");
|
|
91
|
+
}
|
|
92
|
+
let deployment;
|
|
93
|
+
try {
|
|
94
|
+
deployment = loadPortalDeployment(options.deployment);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
throw new PortalConfigurationError("native qURL deployment trust is invalid", {
|
|
98
|
+
cause: error,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
let link;
|
|
102
|
+
try {
|
|
103
|
+
link = verifyQv2Link(options.qurl, deployment.issuers);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
throw new PortalVerificationError("native qURL credential validation failed", {
|
|
107
|
+
cause: error,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return constructVerifiedPortalOpener(options, link, deployment, runtime);
|
|
111
|
+
}
|
|
112
|
+
function constructVerifiedPortalOpener(options, link, deployment, runtime) {
|
|
113
|
+
let retainDevicePrivateKey = false;
|
|
114
|
+
try {
|
|
115
|
+
const cell = deployment.cells.get(fingerprintKey(link.claims.cellPublicKey));
|
|
116
|
+
if (!cell) {
|
|
117
|
+
throw new PortalConfigurationError("verified qURL names a cell outside the native catalog");
|
|
118
|
+
}
|
|
119
|
+
// The 64-bit fingerprint is only an efficient map index. The signed cell
|
|
120
|
+
// identity must still match the exact deployment key used by Noise.
|
|
121
|
+
if (!timingSafeEqual(cell.serverPublicKey, link.claims.cellPublicKey)) {
|
|
122
|
+
throw new PortalConfigurationError("deployment cell key does not match the signed cell key");
|
|
123
|
+
}
|
|
124
|
+
const opener = new NativePortalOpener(options, link, cell, runtime);
|
|
125
|
+
retainDevicePrivateKey = true;
|
|
126
|
+
return opener;
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
if (!retainDevicePrivateKey)
|
|
130
|
+
link.devicePrivateKey.fill(0);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
class NativePortalOpener {
|
|
134
|
+
#link;
|
|
135
|
+
#cell;
|
|
136
|
+
#runtime;
|
|
137
|
+
#exchangeOptions;
|
|
138
|
+
#openTimeoutMs;
|
|
139
|
+
#sessionSecret;
|
|
140
|
+
#grant;
|
|
141
|
+
#boundResourceUrl;
|
|
142
|
+
#openPromise;
|
|
143
|
+
#openController;
|
|
144
|
+
#closePromise;
|
|
145
|
+
#renewalTimer;
|
|
146
|
+
#renewalError;
|
|
147
|
+
#renewalFailure;
|
|
148
|
+
#backgroundAttempts = 0;
|
|
149
|
+
#renewing = false;
|
|
150
|
+
#closed = false;
|
|
151
|
+
constructor(options, link, cell, runtime) {
|
|
152
|
+
this.#link = link;
|
|
153
|
+
this.#cell = cell;
|
|
154
|
+
this.#runtime = runtime;
|
|
155
|
+
this.#exchangeOptions = { timeoutMs: options.timeoutMs, maxAddresses: options.maxAddresses };
|
|
156
|
+
const attemptTimeoutMs = options.timeoutMs ?? NATIVE_DEFAULT_TIMEOUT_MS;
|
|
157
|
+
const maximumAddresses = options.maxAddresses ?? NATIVE_DEFAULT_MAX_ADDRESSES;
|
|
158
|
+
this.#openTimeoutMs = Math.max(DEFAULT_OPEN_TIMEOUT_MS, attemptTimeoutMs * (maximumAddresses + 1));
|
|
159
|
+
this.#sessionSecret = randomBytes(32);
|
|
160
|
+
try {
|
|
161
|
+
// A locally valid qv2 credential can still be too large for one native
|
|
162
|
+
// NHP packet. Reject it before start or DNS and wipe the sizing copy.
|
|
163
|
+
const probe = this.#knockBody();
|
|
164
|
+
probe.fill(0);
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
this.#sessionSecret.fill(0);
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async start(options = {}) {
|
|
172
|
+
this.#requireOpen();
|
|
173
|
+
if (this.#grant &&
|
|
174
|
+
this.#runtime.nowNanos() < this.#grant.expiresAtNanos &&
|
|
175
|
+
!this.#renewalFailure) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (options.signal?.aborted) {
|
|
179
|
+
throw options.signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
180
|
+
}
|
|
181
|
+
const sharedOpen = this.#openPromise !== undefined;
|
|
182
|
+
if (!sharedOpen && this.#renewalTimer) {
|
|
183
|
+
// Explicit lifecycle recovery takes ownership from the pending retry.
|
|
184
|
+
// Cancel it before resetting the bounded background-attempt counter.
|
|
185
|
+
this.#runtime.clearTimer(this.#renewalTimer);
|
|
186
|
+
this.#renewalTimer = undefined;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
await this.#openSingleFlight(options.signal);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
// A caller waiting on an existing background open observes that open's
|
|
193
|
+
// own health update. An explicit recovery owner replaces stale
|
|
194
|
+
// diagnostics and re-arms bounded background recovery while the old
|
|
195
|
+
// grant is usable. The timer is asynchronous; start never sleeps.
|
|
196
|
+
if (!sharedOpen && this.#grant && !this.#closed) {
|
|
197
|
+
this.#backgroundAttempts = 0;
|
|
198
|
+
this.#handleRenewalFailure(error);
|
|
199
|
+
}
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
async fetch(input = {}, options = {}) {
|
|
204
|
+
this.#requireOpen();
|
|
205
|
+
if (options.redirects !== undefined &&
|
|
206
|
+
options.redirects !== "follow" &&
|
|
207
|
+
options.redirects !== "error") {
|
|
208
|
+
throw new PortalStateError("portal fetch redirects must be follow or error");
|
|
209
|
+
}
|
|
210
|
+
const grant = this.#grant;
|
|
211
|
+
if (!grant)
|
|
212
|
+
throw new PortalStateError("portal opener must be started before fetch");
|
|
213
|
+
if (this.#runtime.nowNanos() >= grant.expiresAtNanos) {
|
|
214
|
+
throw new PortalStateError("portal admission expired; call start to open it again", {
|
|
215
|
+
cause: this.#renewalError,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
// A successful renewal or close wipes the mutable grant token. Keep the
|
|
219
|
+
// unavoidable request string local to this fetch so every redirect leg has
|
|
220
|
+
// one stable credential snapshot.
|
|
221
|
+
const sessionToken = grant.token.toString("ascii");
|
|
222
|
+
const init = typeof input === "function" ? input(new URL(grant.resourceUrl)) : input;
|
|
223
|
+
if (!init || typeof init !== "object") {
|
|
224
|
+
throw new PortalStateError("portal request builder must return RequestInit");
|
|
225
|
+
}
|
|
226
|
+
if (Object.prototype.hasOwnProperty.call(init, "redirect") && init.redirect !== undefined) {
|
|
227
|
+
throw new PortalStateError("portal fetch owns redirect handling; redirect overrides are not allowed");
|
|
228
|
+
}
|
|
229
|
+
let currentUrl = grant.resourceUrl;
|
|
230
|
+
let method = (init.method ?? "GET").toUpperCase();
|
|
231
|
+
let body = init.body;
|
|
232
|
+
let headers = new Headers(init.headers);
|
|
233
|
+
for (let requestCount = 1;; requestCount++) {
|
|
234
|
+
this.#requireOpen();
|
|
235
|
+
const requestHeaders = authorizeHeaders(headers, sessionToken);
|
|
236
|
+
const response = await this.#runtime.fetch(currentUrl, {
|
|
237
|
+
...init,
|
|
238
|
+
method,
|
|
239
|
+
body,
|
|
240
|
+
headers: requestHeaders,
|
|
241
|
+
redirect: "manual",
|
|
242
|
+
});
|
|
243
|
+
if (response.redirected === true || !responseMatchesRequestUrl(response.url, currentUrl)) {
|
|
244
|
+
await discardResponseBody(response);
|
|
245
|
+
throw new PortalStateError("portal fetch refused a response that bypassed its manual redirect policy");
|
|
246
|
+
}
|
|
247
|
+
if (![301, 302, 303, 307, 308].includes(response.status))
|
|
248
|
+
return response;
|
|
249
|
+
if (options.redirects === "error") {
|
|
250
|
+
await discardResponseBody(response);
|
|
251
|
+
throw new PortalStateError("portal fetch refused a redirect for a fixed signed request");
|
|
252
|
+
}
|
|
253
|
+
const location = response.headers.get("location");
|
|
254
|
+
if (!location)
|
|
255
|
+
return response;
|
|
256
|
+
if (requestCount >= MAX_REDIRECT_REQUESTS) {
|
|
257
|
+
await discardResponseBody(response);
|
|
258
|
+
throw new PortalStateError("portal fetch stopped at the 10-request redirect limit");
|
|
259
|
+
}
|
|
260
|
+
let next;
|
|
261
|
+
try {
|
|
262
|
+
next = new URL(location, currentUrl);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
await discardResponseBody(response);
|
|
266
|
+
throw new PortalStateError("portal fetch refused an invalid redirect target");
|
|
267
|
+
}
|
|
268
|
+
let nextOrigin;
|
|
269
|
+
try {
|
|
270
|
+
nextOrigin = normalizedHttpsOrigin(next);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
await discardResponseBody(response);
|
|
274
|
+
throw new PortalStateError("portal fetch refused an invalid redirect target");
|
|
275
|
+
}
|
|
276
|
+
if (nextOrigin !== grant.origin) {
|
|
277
|
+
await discardResponseBody(response);
|
|
278
|
+
throw new PortalStateError("portal fetch refused a redirect outside the authenticated origin");
|
|
279
|
+
}
|
|
280
|
+
if ([301, 302, 303].includes(response.status) && method !== "GET" && method !== "HEAD") {
|
|
281
|
+
method = "GET";
|
|
282
|
+
body = undefined;
|
|
283
|
+
headers = new Headers(headers);
|
|
284
|
+
headers.delete("content-length");
|
|
285
|
+
headers.delete("content-type");
|
|
286
|
+
}
|
|
287
|
+
else if ((response.status === 307 || response.status === 308) && body !== undefined) {
|
|
288
|
+
if (!isReplayableBody(body))
|
|
289
|
+
return response;
|
|
290
|
+
}
|
|
291
|
+
await discardResponseBody(response);
|
|
292
|
+
currentUrl = next.href;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
health() {
|
|
296
|
+
if (this.#closed)
|
|
297
|
+
return { state: "closed" };
|
|
298
|
+
if (this.#openPromise && !this.#grant)
|
|
299
|
+
return { state: "starting" };
|
|
300
|
+
const grant = this.#grant;
|
|
301
|
+
if (!grant)
|
|
302
|
+
return { state: "idle" };
|
|
303
|
+
const remainingNanos = grant.expiresAtNanos - this.#runtime.nowNanos();
|
|
304
|
+
if (remainingNanos <= 0n) {
|
|
305
|
+
return {
|
|
306
|
+
state: "expired",
|
|
307
|
+
expiresInMs: 0,
|
|
308
|
+
backgroundAttempts: this.#backgroundAttempts,
|
|
309
|
+
renewalFailure: this.#renewalFailure,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
const expiresInMs = Number((remainingNanos + 999999n) / 1000000n);
|
|
313
|
+
return {
|
|
314
|
+
state: this.#renewing ? "renewing" : this.#renewalFailure ? "degraded" : "healthy",
|
|
315
|
+
expiresInMs,
|
|
316
|
+
backgroundAttempts: this.#backgroundAttempts,
|
|
317
|
+
renewalFailure: this.#renewalFailure,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
close() {
|
|
321
|
+
if (this.#closePromise)
|
|
322
|
+
return this.#closePromise;
|
|
323
|
+
this.#closed = true;
|
|
324
|
+
if (this.#renewalTimer)
|
|
325
|
+
this.#runtime.clearTimer(this.#renewalTimer);
|
|
326
|
+
this.#renewalTimer = undefined;
|
|
327
|
+
const active = this.#openPromise;
|
|
328
|
+
this.#openController?.abort(new PortalStateError("portal opener closed while a native open was in progress"));
|
|
329
|
+
const closing = (async () => {
|
|
330
|
+
try {
|
|
331
|
+
await active;
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
// The initiating caller receives this error. close is idempotent cleanup.
|
|
335
|
+
}
|
|
336
|
+
finally {
|
|
337
|
+
this.#grant?.token.fill(0);
|
|
338
|
+
this.#grant = undefined;
|
|
339
|
+
this.#boundResourceUrl = undefined;
|
|
340
|
+
this.#link.devicePrivateKey.fill(0);
|
|
341
|
+
this.#sessionSecret.fill(0);
|
|
342
|
+
this.#renewalError = undefined;
|
|
343
|
+
this.#renewalFailure = undefined;
|
|
344
|
+
this.#backgroundAttempts = 0;
|
|
345
|
+
this.#renewing = false;
|
|
346
|
+
}
|
|
347
|
+
})();
|
|
348
|
+
this.#closePromise = closing;
|
|
349
|
+
return closing;
|
|
350
|
+
}
|
|
351
|
+
#openSingleFlight(signal) {
|
|
352
|
+
this.#requireOpen();
|
|
353
|
+
const active = this.#openPromise;
|
|
354
|
+
if (active)
|
|
355
|
+
return waitForPromise(active, signal);
|
|
356
|
+
const controller = new AbortController();
|
|
357
|
+
const removeAbortListener = linkAbortSignal(signal, controller);
|
|
358
|
+
const deadline = this.#runtime.setDeadlineTimer(() => {
|
|
359
|
+
controller.abort(new Error("native NHP open exceeded its overall deadline"));
|
|
360
|
+
}, this.#openTimeoutMs);
|
|
361
|
+
deadline.unref?.();
|
|
362
|
+
const current = this.#performOpen(controller.signal).finally(() => {
|
|
363
|
+
this.#runtime.clearDeadlineTimer(deadline);
|
|
364
|
+
removeAbortListener();
|
|
365
|
+
if (this.#openPromise === current) {
|
|
366
|
+
this.#openPromise = undefined;
|
|
367
|
+
this.#openController = undefined;
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
this.#openController = controller;
|
|
371
|
+
this.#openPromise = current;
|
|
372
|
+
return current;
|
|
373
|
+
}
|
|
374
|
+
async #performOpen(signal) {
|
|
375
|
+
this.#requireOpen();
|
|
376
|
+
const body = this.#knockBody();
|
|
377
|
+
try {
|
|
378
|
+
// Start the local validity bound before DNS and UDP I/O. The cell starts
|
|
379
|
+
// its grant no earlier than this, so this client never assumes extra RTT.
|
|
380
|
+
const openedAtNanos = this.#runtime.nowNanos();
|
|
381
|
+
const reply = await this.#runtime.knock(this.#cell, this.#link.devicePrivateKey, body, {
|
|
382
|
+
...this.#exchangeOptions,
|
|
383
|
+
signal,
|
|
384
|
+
});
|
|
385
|
+
try {
|
|
386
|
+
if (this.#closed) {
|
|
387
|
+
throw new PortalStateError("portal opener was closed while start was in progress");
|
|
388
|
+
}
|
|
389
|
+
if (reply.type === NHP_TYPE_COOKIE) {
|
|
390
|
+
throw new PortalBusyError();
|
|
391
|
+
}
|
|
392
|
+
if (reply.type !== NHP_TYPE_ACK) {
|
|
393
|
+
throw new PortalInvalidReplyError("native NHP returned an unexpected reply type");
|
|
394
|
+
}
|
|
395
|
+
const grant = parseGrant(reply.body, openedAtNanos);
|
|
396
|
+
if (this.#boundResourceUrl !== undefined && grant.resourceUrl !== this.#boundResourceUrl) {
|
|
397
|
+
grant.token.fill(0);
|
|
398
|
+
throw new PortalInvalidReplyError("native NHP renewal changed the authenticated resource URL");
|
|
399
|
+
}
|
|
400
|
+
if (this.#runtime.nowNanos() >= grant.expiresAtNanos) {
|
|
401
|
+
grant.token.fill(0);
|
|
402
|
+
throw new PortalStateError("native NHP admission expired before it became usable");
|
|
403
|
+
}
|
|
404
|
+
const old = this.#grant;
|
|
405
|
+
this.#grant = grant;
|
|
406
|
+
this.#boundResourceUrl ??= grant.resourceUrl;
|
|
407
|
+
old?.token.fill(0);
|
|
408
|
+
this.#renewalError = undefined;
|
|
409
|
+
this.#renewalFailure = undefined;
|
|
410
|
+
this.#backgroundAttempts = 0;
|
|
411
|
+
this.#renewing = false;
|
|
412
|
+
this.#scheduleRenewal(grant);
|
|
413
|
+
}
|
|
414
|
+
finally {
|
|
415
|
+
// ACK and deny bodies can contain a bearer. Wipe them on every parse path.
|
|
416
|
+
reply.body.fill(0);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
finally {
|
|
420
|
+
body.fill(0);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
#scheduleRenewal(grant) {
|
|
424
|
+
if (this.#renewalTimer)
|
|
425
|
+
this.#runtime.clearTimer(this.#renewalTimer);
|
|
426
|
+
// The grant lifetime starts before DNS and UDP I/O. Base renewal on the
|
|
427
|
+
// actual remaining lifetime so exchange latency cannot move renewal past
|
|
428
|
+
// the conservative local expiry boundary.
|
|
429
|
+
const remainingNanos = grant.expiresAtNanos - this.#runtime.nowNanos();
|
|
430
|
+
const remainingMs = remainingNanos <= 0n ? 0 : Number(remainingNanos / 1000000n);
|
|
431
|
+
const delayMs = Math.min(MAX_TIMER_DELAY_MS, Math.max(1, Math.floor((remainingMs * RENEWAL_NUMERATOR) / RENEWAL_DENOMINATOR)));
|
|
432
|
+
this.#renewalTimer = this.#runtime.setTimer(() => {
|
|
433
|
+
this.#renewalTimer = undefined;
|
|
434
|
+
if (this.#closed)
|
|
435
|
+
return;
|
|
436
|
+
this.#renewing = true;
|
|
437
|
+
this.#backgroundAttempts++;
|
|
438
|
+
void this.#openSingleFlight().catch((error) => this.#handleRenewalFailure(error));
|
|
439
|
+
}, delayMs);
|
|
440
|
+
this.#renewalTimer.unref?.();
|
|
441
|
+
}
|
|
442
|
+
#handleRenewalFailure(error) {
|
|
443
|
+
if (this.#closed)
|
|
444
|
+
return;
|
|
445
|
+
this.#recordRenewalFailure(error);
|
|
446
|
+
if (this.#backgroundAttempts >= MAX_BACKGROUND_RENEWAL_ATTEMPTS)
|
|
447
|
+
return;
|
|
448
|
+
const grant = this.#grant;
|
|
449
|
+
if (!grant)
|
|
450
|
+
return;
|
|
451
|
+
const delayMs = backgroundRetryDelay(grant.expiresAtNanos - this.#runtime.nowNanos(), MAX_BACKGROUND_RENEWAL_ATTEMPTS - this.#backgroundAttempts, this.#runtime.randomFraction());
|
|
452
|
+
if (delayMs === undefined)
|
|
453
|
+
return;
|
|
454
|
+
this.#renewalTimer = this.#runtime.setTimer(() => {
|
|
455
|
+
this.#renewalTimer = undefined;
|
|
456
|
+
if (this.#closed)
|
|
457
|
+
return;
|
|
458
|
+
this.#renewing = true;
|
|
459
|
+
this.#backgroundAttempts++;
|
|
460
|
+
void this.#openSingleFlight().catch((next) => this.#handleRenewalFailure(next));
|
|
461
|
+
}, delayMs);
|
|
462
|
+
this.#renewalTimer.unref?.();
|
|
463
|
+
}
|
|
464
|
+
#recordRenewalFailure(error) {
|
|
465
|
+
this.#renewing = false;
|
|
466
|
+
this.#renewalError = error;
|
|
467
|
+
this.#renewalFailure = classifyRenewalFailure(error);
|
|
468
|
+
}
|
|
469
|
+
#knockBody() {
|
|
470
|
+
// Immutable JS strings cannot be zeroized. Keep their lifetime to this one
|
|
471
|
+
// serialization and retain private capability material only in mutable
|
|
472
|
+
// buffers between opens.
|
|
473
|
+
const encoded = JSON.stringify({
|
|
474
|
+
headerType: 1,
|
|
475
|
+
aspId: "qurl",
|
|
476
|
+
resId: this.#link.claims.resourcePublicKeyB64,
|
|
477
|
+
usrData: {
|
|
478
|
+
qurl_claims_b64: this.#link.claimsB64,
|
|
479
|
+
qurl_issuer_sig_b64: this.#link.signatureB64,
|
|
480
|
+
qurl_session_secret: this.#sessionSecret.toString("base64url"),
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
const body = Buffer.from(encoded, "utf8");
|
|
484
|
+
if (body.byteLength > NHP_MAX_BODY_SIZE) {
|
|
485
|
+
body.fill(0);
|
|
486
|
+
throw new PortalVerificationError("verified qURL cannot fit the native NHP knock envelope");
|
|
487
|
+
}
|
|
488
|
+
return body;
|
|
489
|
+
}
|
|
490
|
+
#requireOpen() {
|
|
491
|
+
if (this.#closed)
|
|
492
|
+
throw new PortalStateError("portal opener is closed");
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
function parseGrant(body, nowNanos) {
|
|
496
|
+
let value;
|
|
497
|
+
try {
|
|
498
|
+
value = parseStrictJson(body, MAX_ACK_BODY_BYTES);
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
throw new PortalInvalidReplyError("native NHP ACK body is malformed", { cause: error });
|
|
502
|
+
}
|
|
503
|
+
if (!isStrictJsonObject(value))
|
|
504
|
+
throw new PortalInvalidReplyError("native NHP ACK must be an object");
|
|
505
|
+
const errCode = value.errCode === undefined ? "" : requirePossiblyEmptyString(value.errCode, "ACK errCode");
|
|
506
|
+
if (errCode !== "" && errCode !== "0") {
|
|
507
|
+
// Match qurl-go's canonical knock-deny grammar: decimal digits, no leading
|
|
508
|
+
// zero, and no allowlist that could hide a new authenticated server code.
|
|
509
|
+
if (!/^[1-9][0-9]*$/.test(errCode))
|
|
510
|
+
throw new PortalInvalidReplyError("native NHP deny code is not canonical decimal");
|
|
511
|
+
// The native opener's deny profile deliberately requires an explicit
|
|
512
|
+
// canonical opnTime:0 and forbids the Go-defined success capabilities.
|
|
513
|
+
if ("sessId" in value) {
|
|
514
|
+
throw new PortalInvalidReplyError("native NHP deny ACK contains success capability fields");
|
|
515
|
+
}
|
|
516
|
+
if (value.opnTime !== 0n) {
|
|
517
|
+
throw new PortalInvalidReplyError("native NHP deny ACK opnTime must be canonical zero");
|
|
518
|
+
}
|
|
519
|
+
if (value.aspToken !== undefined && value.aspToken !== "") {
|
|
520
|
+
throw new PortalInvalidReplyError("native NHP deny ACK contains success capability fields");
|
|
521
|
+
}
|
|
522
|
+
throw new PortalDenyError(errCode);
|
|
523
|
+
}
|
|
524
|
+
const sessionId = requireUnsignedInteger(value.sessId, "ACK session id", UINT64_MAX);
|
|
525
|
+
if (sessionId === 0n)
|
|
526
|
+
throw new PortalInvalidReplyError("native NHP ACK session id must be positive");
|
|
527
|
+
const openSecondsBig = requireUnsignedInteger(value.opnTime, "ACK open time", 0xffffffffn);
|
|
528
|
+
if (openSecondsBig === 0n)
|
|
529
|
+
throw new PortalInvalidReplyError("native NHP ACK open time must be positive");
|
|
530
|
+
const openSeconds = Number(openSecondsBig);
|
|
531
|
+
const resourceUrl = requireString(value.redirectUrl, "ACK resource URL");
|
|
532
|
+
let parsed;
|
|
533
|
+
try {
|
|
534
|
+
parsed = new URL(resourceUrl);
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
throw new PortalInvalidReplyError("ACK resource URL is invalid");
|
|
538
|
+
}
|
|
539
|
+
if (parsed.hash !== "")
|
|
540
|
+
throw new PortalInvalidReplyError("ACK resource URL must not contain a fragment");
|
|
541
|
+
let origin;
|
|
542
|
+
try {
|
|
543
|
+
origin = normalizedHttpsOrigin(parsed);
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
throw new PortalInvalidReplyError(error instanceof Error ? error.message : "ACK resource URL has an invalid HTTPS origin", { cause: error });
|
|
547
|
+
}
|
|
548
|
+
const tokenString = requireString(value.aspToken, "ACK application token");
|
|
549
|
+
validateSessionToken(tokenString);
|
|
550
|
+
const token = Buffer.from(tokenString, "ascii");
|
|
551
|
+
return {
|
|
552
|
+
resourceUrl,
|
|
553
|
+
origin,
|
|
554
|
+
expiresAtNanos: nowNanos + BigInt(openSeconds) * 1000000000n,
|
|
555
|
+
token,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
function requireString(value, name) {
|
|
559
|
+
if (typeof value !== "string" || value === "")
|
|
560
|
+
throw new PortalInvalidReplyError(`${name} is missing or invalid`);
|
|
561
|
+
return value;
|
|
562
|
+
}
|
|
563
|
+
function requirePossiblyEmptyString(value, name) {
|
|
564
|
+
if (typeof value !== "string")
|
|
565
|
+
throw new PortalInvalidReplyError(`${name} is missing or invalid`);
|
|
566
|
+
return value;
|
|
567
|
+
}
|
|
568
|
+
function requireUnsignedInteger(value, name, maximum) {
|
|
569
|
+
if (typeof value !== "bigint") {
|
|
570
|
+
throw new PortalInvalidReplyError(`${name} must be an unsigned decimal integer`);
|
|
571
|
+
}
|
|
572
|
+
if (value < 0n || value > maximum)
|
|
573
|
+
throw new PortalInvalidReplyError(`${name} is outside its range`);
|
|
574
|
+
return value;
|
|
575
|
+
}
|
|
576
|
+
function normalizedHttpsOrigin(url) {
|
|
577
|
+
if (url.protocol !== "https:" ||
|
|
578
|
+
url.hostname === "" ||
|
|
579
|
+
url.username !== "" ||
|
|
580
|
+
url.password !== "") {
|
|
581
|
+
throw new PortalStateError("ACK resource URL must have a valid HTTPS origin");
|
|
582
|
+
}
|
|
583
|
+
const port = url.port === "" ? "443" : url.port;
|
|
584
|
+
if (!/^[0-9]+$/.test(port) || Number(port) < 1 || Number(port) > 65_535) {
|
|
585
|
+
throw new PortalStateError("ACK resource URL has an invalid port");
|
|
586
|
+
}
|
|
587
|
+
return `https://${url.hostname.toLowerCase()}:${Number(port)}`;
|
|
588
|
+
}
|
|
589
|
+
function validateSessionToken(value, decode = (part) => Buffer.from(part, "base64url")) {
|
|
590
|
+
if (value.length > 4_096 || value.trim() !== value || hasUnsafeTokenByte(value)) {
|
|
591
|
+
throw new PortalInvalidReplyError("ACK application token has an invalid shape");
|
|
592
|
+
}
|
|
593
|
+
const parts = value.split(".");
|
|
594
|
+
if (parts.length !== 2 || parts.some((part) => part === "" || !/^[A-Za-z0-9_-]+$/.test(part))) {
|
|
595
|
+
throw new PortalInvalidReplyError("ACK application token has an invalid shape");
|
|
596
|
+
}
|
|
597
|
+
let signatureLength = 0;
|
|
598
|
+
for (let index = 0; index < parts.length; index++) {
|
|
599
|
+
const part = parts[index];
|
|
600
|
+
const decoded = decode(part);
|
|
601
|
+
try {
|
|
602
|
+
if (decoded.toString("base64url") !== part) {
|
|
603
|
+
throw new PortalInvalidReplyError("ACK application token is not canonical base64url");
|
|
604
|
+
}
|
|
605
|
+
if (index === 1)
|
|
606
|
+
signatureLength = decoded.byteLength;
|
|
607
|
+
}
|
|
608
|
+
finally {
|
|
609
|
+
// The token is a bearer capability. Wipe mutable validation copies even
|
|
610
|
+
// though its unavoidable parsed string cannot be zeroized in JavaScript.
|
|
611
|
+
decoded.fill(0);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
if (signatureLength !== 32) {
|
|
615
|
+
throw new PortalInvalidReplyError("ACK application token signature has an invalid length");
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
function hasUnsafeTokenByte(value) {
|
|
619
|
+
for (let index = 0; index < value.length; index++) {
|
|
620
|
+
const code = value.charCodeAt(index);
|
|
621
|
+
if (code <= 0x20 || code === 0x7f || code > 0x7f)
|
|
622
|
+
return true;
|
|
623
|
+
}
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
function authorizeHeaders(input, token) {
|
|
627
|
+
const headers = new Headers(input);
|
|
628
|
+
const preserved = [];
|
|
629
|
+
for (const raw of (headers.get("cookie") ?? "").split(";")) {
|
|
630
|
+
const item = raw.trim();
|
|
631
|
+
if (item === "")
|
|
632
|
+
continue;
|
|
633
|
+
const equals = item.indexOf("=");
|
|
634
|
+
if (equals <= 0)
|
|
635
|
+
continue;
|
|
636
|
+
const name = item.slice(0, equals).trim();
|
|
637
|
+
const value = item.slice(equals + 1).trim();
|
|
638
|
+
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name))
|
|
639
|
+
continue;
|
|
640
|
+
if (name === SESSION_COOKIE)
|
|
641
|
+
continue;
|
|
642
|
+
if (!isValidCookieValue(value))
|
|
643
|
+
continue;
|
|
644
|
+
preserved.push(`${name}=${value}`);
|
|
645
|
+
}
|
|
646
|
+
preserved.push(`${SESSION_COOKIE}=${token}`);
|
|
647
|
+
headers.set("cookie", preserved.join("; "));
|
|
648
|
+
return headers;
|
|
649
|
+
}
|
|
650
|
+
function isValidCookieValue(value) {
|
|
651
|
+
const unquoted = value.length >= 2 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
|
|
652
|
+
for (let index = 0; index < unquoted.length; index++) {
|
|
653
|
+
const code = unquoted.charCodeAt(index);
|
|
654
|
+
if (code < 0x21 ||
|
|
655
|
+
code > 0x7e ||
|
|
656
|
+
code === 0x22 ||
|
|
657
|
+
code === 0x2c ||
|
|
658
|
+
code === 0x3b ||
|
|
659
|
+
code === 0x5c) {
|
|
660
|
+
return false;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return true;
|
|
664
|
+
}
|
|
665
|
+
function isReplayableBody(body) {
|
|
666
|
+
return (body === null ||
|
|
667
|
+
typeof body === "string" ||
|
|
668
|
+
body instanceof URLSearchParams ||
|
|
669
|
+
body instanceof ArrayBuffer ||
|
|
670
|
+
ArrayBuffer.isView(body) ||
|
|
671
|
+
body instanceof Blob ||
|
|
672
|
+
(typeof FormData !== "undefined" && body instanceof FormData));
|
|
673
|
+
}
|
|
674
|
+
async function discardResponseBody(response) {
|
|
675
|
+
try {
|
|
676
|
+
await response.body?.cancel();
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
// Redirect refusal must keep its stable error even for a broken body shim.
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
function responseMatchesRequestUrl(responseUrl, requestUrl) {
|
|
683
|
+
if (responseUrl === "" || responseUrl === requestUrl)
|
|
684
|
+
return true;
|
|
685
|
+
try {
|
|
686
|
+
return new URL(responseUrl).href === new URL(requestUrl).href;
|
|
687
|
+
}
|
|
688
|
+
catch {
|
|
689
|
+
return false;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
function linkAbortSignal(signal, controller) {
|
|
693
|
+
if (!signal)
|
|
694
|
+
return () => undefined;
|
|
695
|
+
if (signal.aborted) {
|
|
696
|
+
controller.abort(signal.reason);
|
|
697
|
+
return () => undefined;
|
|
698
|
+
}
|
|
699
|
+
const abort = () => controller.abort(signal.reason);
|
|
700
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
701
|
+
return () => signal.removeEventListener("abort", abort);
|
|
702
|
+
}
|
|
703
|
+
function waitForPromise(promise, signal) {
|
|
704
|
+
if (!signal)
|
|
705
|
+
return promise;
|
|
706
|
+
if (signal.aborted)
|
|
707
|
+
return Promise.reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
|
|
708
|
+
return new Promise((resolve, reject) => {
|
|
709
|
+
const abort = () => {
|
|
710
|
+
cleanup();
|
|
711
|
+
reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
|
|
712
|
+
};
|
|
713
|
+
const cleanup = () => signal.removeEventListener("abort", abort);
|
|
714
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
715
|
+
promise.then(() => {
|
|
716
|
+
cleanup();
|
|
717
|
+
resolve();
|
|
718
|
+
}, (error) => {
|
|
719
|
+
cleanup();
|
|
720
|
+
reject(error);
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
function classifyRenewalFailure(error) {
|
|
725
|
+
if (error instanceof PortalDenyError)
|
|
726
|
+
return { kind: "denied" };
|
|
727
|
+
if (error instanceof PortalBusyError)
|
|
728
|
+
return { kind: "busy" };
|
|
729
|
+
if (error instanceof PortalInvalidReplyError)
|
|
730
|
+
return { kind: "invalid_reply" };
|
|
731
|
+
if (error instanceof PortalStateError ||
|
|
732
|
+
error instanceof PortalConfigurationError ||
|
|
733
|
+
error instanceof PortalVerificationError)
|
|
734
|
+
return { kind: "local_state" };
|
|
735
|
+
return { kind: "transport" };
|
|
736
|
+
}
|
|
737
|
+
function backgroundRetryDelay(remainingNanos, remainingAttempts, randomFraction) {
|
|
738
|
+
if (remainingNanos <= 0n || remainingAttempts < 1)
|
|
739
|
+
return undefined;
|
|
740
|
+
const remainingMs = Number(remainingNanos / 1000000n);
|
|
741
|
+
if (remainingMs < 1)
|
|
742
|
+
return undefined;
|
|
743
|
+
// Divide the current remaining lifetime into one slot per possible attempt
|
|
744
|
+
// plus a final expiry reserve. Recompute after every failed exchange so long
|
|
745
|
+
// grants retry over minutes while short grants retain several useful tries.
|
|
746
|
+
const slotMs = Math.floor(remainingMs / (remainingAttempts + 1));
|
|
747
|
+
if (slotMs < 1)
|
|
748
|
+
return undefined;
|
|
749
|
+
const boundedRandom = Math.max(0, Math.min(1, randomFraction));
|
|
750
|
+
const jittered = Math.floor(slotMs * (0.75 + boundedRandom * 0.25));
|
|
751
|
+
return Math.min(MAX_TIMER_DELAY_MS, slotMs, Math.max(Math.min(MIN_BACKGROUND_RETRY_MS, slotMs), jittered));
|
|
752
|
+
}
|
|
753
|
+
export const portalOpenerTesting = {
|
|
754
|
+
constructVerifiedPortalOpener,
|
|
755
|
+
validateSessionToken,
|
|
756
|
+
};
|
|
757
|
+
//# sourceMappingURL=portal-opener.js.map
|