wire-mesh-core 4.1.5 → 4.2.1
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 +2 -1
- package/dist/adapters/tls-transport.cjs +2 -0
- package/dist/adapters/tls-transport.mjs +2 -0
- package/dist/domain/async-queue.cjs +48 -12
- package/dist/domain/async-queue.d.cts +1 -1
- package/dist/domain/async-queue.d.mts +1 -1
- package/dist/domain/async-queue.mjs +48 -12
- package/dist/domain/bulk.cjs +50 -10
- package/dist/domain/bulk.d.cts +5 -1
- package/dist/domain/bulk.d.mts +5 -1
- package/dist/domain/bulk.mjs +50 -11
- package/dist/domain/capability-grant.d.cts +1 -1
- package/dist/domain/capability-grant.d.mts +1 -1
- package/dist/domain/capability-request.cjs +2 -5
- package/dist/domain/capability-request.d.cts +1 -1
- package/dist/domain/capability-request.d.mts +1 -1
- package/dist/domain/capability-request.mjs +1 -4
- package/dist/domain/consume-incoming.cjs +96 -0
- package/dist/domain/consume-incoming.d.cts +27 -0
- package/dist/domain/consume-incoming.d.mts +27 -0
- package/dist/domain/consume-incoming.mjs +93 -0
- package/dist/domain/direct-manage-request.cjs +25 -12
- package/dist/domain/direct-manage-request.d.cts +1 -1
- package/dist/domain/direct-manage-request.d.mts +1 -1
- package/dist/domain/direct-manage-request.mjs +25 -12
- package/dist/domain/fault-report.cjs +136 -0
- package/dist/domain/fault-report.d.cts +25 -0
- package/dist/domain/fault-report.d.mts +25 -0
- package/dist/domain/fault-report.mjs +129 -0
- package/dist/domain/gossip-expansion.cjs +1 -1
- package/dist/domain/gossip-expansion.d.cts +1 -1
- package/dist/domain/gossip-expansion.d.mts +1 -1
- package/dist/domain/gossip-expansion.mjs +1 -1
- package/dist/domain/grant-candidates.d.cts +1 -1
- package/dist/domain/grant-candidates.d.mts +1 -1
- package/dist/domain/mesh-session.cjs +373 -126
- package/dist/domain/mesh-session.d.cts +2 -162
- package/dist/domain/mesh-session.d.mts +2 -162
- package/dist/domain/mesh-session.mjs +373 -126
- package/dist/domain/path-trace.d.cts +1 -1
- package/dist/domain/path-trace.d.mts +1 -1
- package/dist/domain/ping-round-trips.cjs +21 -8
- package/dist/domain/ping-round-trips.mjs +21 -8
- package/dist/domain/room-rekey.d.cts +1 -1
- package/dist/domain/room-rekey.d.mts +1 -1
- package/dist/domain/topology.d.cts +1 -1
- package/dist/domain/topology.d.mts +1 -1
- package/dist/domain/webrtc-signaling.d.cts +1 -1
- package/dist/domain/webrtc-signaling.d.mts +1 -1
- package/dist/mesh-session-By06Mnu-.d.cts +174 -0
- package/dist/mesh-session-DzcZYdhe.d.mts +174 -0
- package/dist/timers-DTJNuBa8.cjs +12 -0
- package/dist/timers-Q1_kDTyY.mjs +7 -0
- package/package.json +11 -2
|
@@ -7,6 +7,7 @@ const require_gossip_extensions = require("../gossip-extensions-BBYgMZ2N.cjs");
|
|
|
7
7
|
const require_domain_peer_advert = require("./peer-advert.cjs");
|
|
8
8
|
const require_domain_async_queue = require("./async-queue.cjs");
|
|
9
9
|
const require_domain_channel_exhausted_error = require("./channel-exhausted-error.cjs");
|
|
10
|
+
const require_domain_fault_report = require("./fault-report.cjs");
|
|
10
11
|
const require_domain_handshake = require("./handshake.cjs");
|
|
11
12
|
const require_domain_hub_mailbox = require("./hub-mailbox.cjs");
|
|
12
13
|
const require_domain_ping_round_trips = require("./ping-round-trips.cjs");
|
|
@@ -139,6 +140,15 @@ async function verifyHello(identity, hello) {
|
|
|
139
140
|
};
|
|
140
141
|
}
|
|
141
142
|
}
|
|
143
|
+
/** True when `key` is a usable P-256 ECDH public key, so a channel can be built from a hello carrying it. Cheap enough to run before any key of this side's own is generated. */
|
|
144
|
+
async function isUsableEphemeralKey(key) {
|
|
145
|
+
try {
|
|
146
|
+
await crypto.subtle.importKey("raw", Uint8Array.from(key), ECDH_PARAMS, false, []);
|
|
147
|
+
return true;
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
142
152
|
/** The largest counter a channel uses: the counter is an unsigned 64-bit integer on the wire, and this implementation holds it as a JavaScript number, exact up to this value. A frame above it is refused at decoding, never truncated, and sealing refuses to go past it, never wraps. */
|
|
143
153
|
const MAX_COUNTER = Number.MAX_SAFE_INTEGER;
|
|
144
154
|
/** Throws a RangeError unless `counter` is a counter a channel can start from: a non-negative safe integer. */
|
|
@@ -277,148 +287,382 @@ async function establishChannelFrom(identity, { pending, remote, peer, firstSend
|
|
|
277
287
|
}
|
|
278
288
|
//#endregion
|
|
279
289
|
//#region src/domain/relay-channels.ts
|
|
290
|
+
/** How long a peer may take to answer a hello, and the window every other rule here is measured in: a handshake is outstanding for this long, a replaced channel may still open frames that were in flight for this long, and an entry younger than this is never reused. Whoever holds a hello for longer than this gains nothing, since the handshake it belongs to is over. */
|
|
291
|
+
const HANDSHAKE_WINDOW_MS = 3e3;
|
|
292
|
+
/** Everything held for one peer. The fields only change through the methods, so each transition has one place. */
|
|
293
|
+
var PeerChannels = class {
|
|
294
|
+
peer;
|
|
295
|
+
chosen = false;
|
|
296
|
+
lastUsed;
|
|
297
|
+
/** The current channel: sealing goes through it and only it. */
|
|
298
|
+
current;
|
|
299
|
+
/** The channel the current one replaced, which may still open frames that were in flight but never seals. */
|
|
300
|
+
replaced;
|
|
301
|
+
hs;
|
|
302
|
+
/** The candidates of a handshake that ended without confirming either of two, which together serve as the channel until a frame opens under one. */
|
|
303
|
+
pool = [];
|
|
304
|
+
waiters;
|
|
305
|
+
opening = Promise.resolve();
|
|
306
|
+
constructor(peer, now) {
|
|
307
|
+
this.peer = peer;
|
|
308
|
+
this.lastUsed = now;
|
|
309
|
+
}
|
|
310
|
+
get key() {
|
|
311
|
+
return require_domain_device_id.deviceIdToHex(this.peer);
|
|
312
|
+
}
|
|
313
|
+
touch(now) {
|
|
314
|
+
this.lastUsed = now;
|
|
315
|
+
}
|
|
316
|
+
choose() {
|
|
317
|
+
this.chosen = true;
|
|
318
|
+
}
|
|
319
|
+
/** True when `key` is the remote ephemeral key of anything held for the peer. */
|
|
320
|
+
holdsKey(key) {
|
|
321
|
+
return [
|
|
322
|
+
this.current?.remoteKey,
|
|
323
|
+
this.replaced?.remoteKey,
|
|
324
|
+
...(this.hs?.cands ?? []).map((cand) => cand.remoteKey),
|
|
325
|
+
...this.pool.map((cand) => cand.remoteKey)
|
|
326
|
+
].some((held) => held !== void 0 && require_domain_token_scope.bytesEqual(held, key));
|
|
327
|
+
}
|
|
328
|
+
/** Whether this side's handshake with the peer is outstanding: sent less than a window ago, and neither confirmed nor ended. */
|
|
329
|
+
outstanding(now) {
|
|
330
|
+
return this.hs !== void 0 && !this.hs.expired && now < this.hs.sentAt + 3e3;
|
|
331
|
+
}
|
|
332
|
+
/** Applies whatever the passing of time has ended: the replaced channel after its window, and a handshake after its window. Checked on every event, so no timer is needed. */
|
|
333
|
+
expire(now) {
|
|
334
|
+
if (this.replaced !== void 0 && now >= this.replaced.until) this.replaced = void 0;
|
|
335
|
+
const { hs } = this;
|
|
336
|
+
if (hs === void 0 || hs.expired || now < hs.sentAt + 3e3) return;
|
|
337
|
+
const [only, ...rest] = hs.cands;
|
|
338
|
+
if (this.current !== void 0) this.hs = void 0;
|
|
339
|
+
else if (only === void 0) hs.expired = true;
|
|
340
|
+
else if (rest.length === 0) {
|
|
341
|
+
this.current = {
|
|
342
|
+
...only,
|
|
343
|
+
confirmed: false
|
|
344
|
+
};
|
|
345
|
+
this.hs = void 0;
|
|
346
|
+
} else {
|
|
347
|
+
this.pool = hs.cands;
|
|
348
|
+
this.hs = void 0;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/** The candidates a received frame is tried against, in order. */
|
|
352
|
+
candidates() {
|
|
353
|
+
return [...this.hs?.cands ?? [], ...this.pool];
|
|
354
|
+
}
|
|
355
|
+
begin(hs) {
|
|
356
|
+
this.hs = hs;
|
|
357
|
+
}
|
|
358
|
+
/** Ends `hs` if it is still this peer's handshake. */
|
|
359
|
+
end(hs) {
|
|
360
|
+
if (this.hs !== hs) return false;
|
|
361
|
+
this.hs = void 0;
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
confirmCurrent() {
|
|
365
|
+
if (this.current !== void 0) this.current.confirmed = true;
|
|
366
|
+
}
|
|
367
|
+
/** A frame opened under `cand`: it becomes the current channel, the channel it replaces may open in-flight frames for a window, and every other candidate goes. */
|
|
368
|
+
confirm(cand, now) {
|
|
369
|
+
if (this.current !== void 0) this.replaced = {
|
|
370
|
+
channel: this.current.channel,
|
|
371
|
+
remoteKey: this.current.remoteKey,
|
|
372
|
+
until: now + HANDSHAKE_WINDOW_MS
|
|
373
|
+
};
|
|
374
|
+
this.current = {
|
|
375
|
+
...cand,
|
|
376
|
+
confirmed: true
|
|
377
|
+
};
|
|
378
|
+
this.hs = void 0;
|
|
379
|
+
this.pool = [];
|
|
380
|
+
}
|
|
381
|
+
/** Forgets the confirmed channel and the one it replaced, keeping every candidate. */
|
|
382
|
+
dropConfirmed() {
|
|
383
|
+
this.current = void 0;
|
|
384
|
+
this.replaced = void 0;
|
|
385
|
+
}
|
|
386
|
+
/** Forgets the current channel when it is the one that is spent. */
|
|
387
|
+
clearCurrent(channel) {
|
|
388
|
+
if (this.current?.channel === channel) this.current = void 0;
|
|
389
|
+
}
|
|
390
|
+
isEmpty() {
|
|
391
|
+
return this.current === void 0 && this.replaced === void 0 && this.hs === void 0 && this.pool.length === 0;
|
|
392
|
+
}
|
|
393
|
+
/** Resolves once some candidate exists. */
|
|
394
|
+
async waitForChannel() {
|
|
395
|
+
this.waiters ??= (() => {
|
|
396
|
+
const waiting = Promise.withResolvers();
|
|
397
|
+
waiting.promise.catch(() => void 0);
|
|
398
|
+
return waiting;
|
|
399
|
+
})();
|
|
400
|
+
return this.waiters.promise;
|
|
401
|
+
}
|
|
402
|
+
releaseWaiters() {
|
|
403
|
+
this.waiters?.resolve(void 0);
|
|
404
|
+
this.waiters = void 0;
|
|
405
|
+
}
|
|
406
|
+
rejectWaiters(error) {
|
|
407
|
+
this.waiters?.reject(error);
|
|
408
|
+
this.waiters = void 0;
|
|
409
|
+
}
|
|
410
|
+
/** Runs `task` after every earlier one for this peer: two frames opening at once must not both be tried before either has settled what it confirms. */
|
|
411
|
+
async serialise(task) {
|
|
412
|
+
const run = this.opening.then(task);
|
|
413
|
+
this.opening = run.catch(() => void 0);
|
|
414
|
+
return run;
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
function authentic(outcome) {
|
|
418
|
+
return outcome.ok || outcome.reason === "undecodable";
|
|
419
|
+
}
|
|
420
|
+
async function openFirst(cands, data) {
|
|
421
|
+
const [first, ...rest] = cands;
|
|
422
|
+
if (first === void 0) return;
|
|
423
|
+
const outcome = await first.channel.openOutcome(data);
|
|
424
|
+
return authentic(outcome) ? {
|
|
425
|
+
cand: first,
|
|
426
|
+
outcome
|
|
427
|
+
} : openFirst(rest, data);
|
|
428
|
+
}
|
|
429
|
+
/** A clock that never goes backwards: milliseconds from an arbitrary origin, as `performance.now()` gives them. What `createMeshSession` gives its channels. */
|
|
430
|
+
const monotonicClock = { now: () => performance.now() };
|
|
280
431
|
function createRelayChannels(identity, sendHello, options = {}) {
|
|
281
432
|
if (options.firstSendCounter !== void 0) assertFirstSendCounter(options.firstSendCounter);
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
433
|
+
const newHandshake = options.newHandshake ?? (async (peer) => beginHandshake(identity, peer));
|
|
434
|
+
let latest = Number.NEGATIVE_INFINITY;
|
|
435
|
+
const source = options.clock ?? monotonicClock;
|
|
436
|
+
const clock = { now: () => {
|
|
437
|
+
latest = Math.max(latest, source.now());
|
|
438
|
+
return latest;
|
|
439
|
+
} };
|
|
440
|
+
const peers = /* @__PURE__ */ new Map();
|
|
441
|
+
const reporter = require_domain_fault_report.createFaultReporter(() => clock.now());
|
|
442
|
+
/** Tells the owner of a failure that is local to this side, through the shared fault reporter: at most one report per peer and interval, with the number withheld, and the callback's own failure contained there. */
|
|
443
|
+
function reportFault(error, peer) {
|
|
444
|
+
reporter(peer === void 0 ? "" : require_domain_device_id.deviceIdToHex(peer), async (suppressed) => options.onFault?.(error, {
|
|
445
|
+
peer,
|
|
446
|
+
suppressed
|
|
447
|
+
}));
|
|
294
448
|
}
|
|
295
|
-
|
|
296
|
-
|
|
449
|
+
function prune(entry) {
|
|
450
|
+
if (entry.isEmpty() && !entry.chosen && peers.get(entry.key) === entry) peers.delete(entry.key);
|
|
451
|
+
}
|
|
452
|
+
/** Ends this side's handshake `hs` with the peer, leaving every channel as it is, and tells whoever waits for a channel when there is none to wait for. */
|
|
453
|
+
function dropHandshake(entry, hs, error) {
|
|
454
|
+
if (!entry.end(hs)) return;
|
|
297
455
|
const failure = error instanceof Error ? error : new Error(String(error));
|
|
298
|
-
|
|
299
|
-
entry.
|
|
300
|
-
entry
|
|
301
|
-
}
|
|
302
|
-
/**
|
|
303
|
-
function
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
456
|
+
hs.sent.reject(failure);
|
|
457
|
+
if (entry.current === void 0 && entry.pool.length === 0) entry.rejectWaiters(failure);
|
|
458
|
+
prune(entry);
|
|
459
|
+
}
|
|
460
|
+
/** Drops every handshake that is waiting for its first answer beyond MAX_PENDING_HANDSHAKES, oldest first. */
|
|
461
|
+
function enforcePendingBound(keep) {
|
|
462
|
+
const pending = [...peers.values()].filter((entry) => entry.hs?.origin === "initiated" && entry.hs.cands.length === 0 && entry.current === void 0);
|
|
463
|
+
const excess = pending.filter((entry) => entry !== keep).sort((a, b) => (a.hs?.sentAt ?? 0) - (b.hs?.sentAt ?? 0)).slice(0, Math.max(0, pending.length - 256));
|
|
464
|
+
for (const entry of excess) if (entry.hs !== void 0) dropHandshake(entry, entry.hs, /* @__PURE__ */ new Error("the handshake was dropped to make room for others"));
|
|
465
|
+
}
|
|
466
|
+
/** Begins this side's own hello to the entry's peer and registers it at once, so a hello arriving meanwhile completes it instead of starting another. */
|
|
467
|
+
function beginOwn(entry, origin) {
|
|
468
|
+
const mine = newHandshake(entry.peer);
|
|
469
|
+
const hs = {
|
|
470
|
+
mine,
|
|
471
|
+
origin,
|
|
472
|
+
sentAt: clock.now(),
|
|
473
|
+
cands: [],
|
|
474
|
+
sent: Promise.withResolvers(),
|
|
475
|
+
expired: false
|
|
315
476
|
};
|
|
316
|
-
|
|
317
|
-
entry.
|
|
318
|
-
|
|
477
|
+
hs.sent.promise.catch(() => void 0);
|
|
478
|
+
entry.begin(hs);
|
|
479
|
+
mine.then(async ({ hello }) => {
|
|
319
480
|
try {
|
|
320
|
-
await sendHello(peer, hello);
|
|
481
|
+
await sendHello(entry.peer, hello);
|
|
321
482
|
} catch (error) {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const held = channels.get(key);
|
|
325
|
-
if (held?.status === "established" && held.channel === channel) channels.delete(key);
|
|
326
|
-
}, () => void 0);
|
|
327
|
-
if (answering && track) reportFault(error);
|
|
483
|
+
dropHandshake(entry, hs, error);
|
|
484
|
+
if (origin === "answered") reportFault(error, entry.peer);
|
|
328
485
|
return;
|
|
329
486
|
}
|
|
330
|
-
|
|
487
|
+
hs.sent.resolve(void 0);
|
|
331
488
|
}, (error) => {
|
|
332
|
-
|
|
489
|
+
dropHandshake(entry, hs, error);
|
|
333
490
|
});
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
const established = await establishChannelFrom(identity, {
|
|
341
|
-
pending: await entry.pending,
|
|
491
|
+
return hs;
|
|
492
|
+
}
|
|
493
|
+
/** Builds the channel `hello` makes with this side's handshake `hs`, and holds it as a candidate. */
|
|
494
|
+
async function addCandidate(entry, hs, hello, peer) {
|
|
495
|
+
const channel = await establishChannelFrom(identity, {
|
|
496
|
+
pending: await hs.mine,
|
|
342
497
|
remote: hello,
|
|
343
498
|
peer,
|
|
344
499
|
...options.firstSendCounter === void 0 ? {} : { firstSendCounter: options.firstSendCounter }
|
|
345
500
|
});
|
|
346
|
-
if (
|
|
347
|
-
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
if (replaces !== void 0) await entry.sent.promise;
|
|
351
|
-
if (channels.get(key) !== (replaces ?? entry)) return;
|
|
352
|
-
const channel = {
|
|
353
|
-
...established,
|
|
354
|
-
seal: async (frame) => {
|
|
355
|
-
try {
|
|
356
|
-
return await established.seal(frame);
|
|
357
|
-
} catch (error) {
|
|
358
|
-
const held = channels.get(key);
|
|
359
|
-
if (error instanceof require_domain_channel_exhausted_error.ChannelExhaustedError && held?.status === "established" && held.channel === channel) channels.delete(key);
|
|
360
|
-
throw error;
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
};
|
|
364
|
-
channels.set(key, {
|
|
365
|
-
status: "established",
|
|
501
|
+
if (channel === void 0 || entry.hs !== hs || peers.get(entry.key) !== entry || hs.cands.length >= 2 || entry.holdsKey(hello["ephemeral-key"])) return;
|
|
502
|
+
hs.cands.push({
|
|
366
503
|
channel,
|
|
367
|
-
|
|
504
|
+
remoteKey: hello["ephemeral-key"]
|
|
368
505
|
});
|
|
369
|
-
entry.
|
|
506
|
+
entry.releaseWaiters();
|
|
507
|
+
}
|
|
508
|
+
/** The entry of a peer this side never sent anything to that may be reused now, or undefined when every slot is free or none is old enough, which is told by `slotIsFree`. An entry is reused only once it is older than the window, so answers cannot outpace the turnover of slots. */
|
|
509
|
+
function reusableEntry(now) {
|
|
510
|
+
const [victim] = [...peers.values()].filter((entry) => !entry.chosen && now - entry.lastUsed >= 3e3).sort((a, b) => a.lastUsed - b.lastUsed);
|
|
511
|
+
return victim;
|
|
512
|
+
}
|
|
513
|
+
function slotIsFree() {
|
|
514
|
+
return [...peers.values()].filter((entry) => !entry.chosen).length < 64;
|
|
515
|
+
}
|
|
516
|
+
/** Whether a peer this side never sent anything to could be given an entry now, changing nothing. */
|
|
517
|
+
function slotAvailable(now) {
|
|
518
|
+
return slotIsFree() || reusableEntry(now) !== void 0;
|
|
519
|
+
}
|
|
520
|
+
/** Makes room for a peer this side never sent anything to. */
|
|
521
|
+
function reserveUnsolicitedSlot(now) {
|
|
522
|
+
if (slotIsFree()) return true;
|
|
523
|
+
const victim = reusableEntry(now);
|
|
524
|
+
if (victim === void 0) return false;
|
|
525
|
+
discard(victim, /* @__PURE__ */ new Error("the entry was dropped to make room for others"));
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
function discard(entry, error) {
|
|
529
|
+
peers.delete(entry.key);
|
|
530
|
+
entry.hs?.sent.reject(error);
|
|
531
|
+
entry.rejectWaiters(error);
|
|
532
|
+
}
|
|
533
|
+
async function sealUnder(entry, channel, frame) {
|
|
534
|
+
try {
|
|
535
|
+
return await channel.seal(frame);
|
|
536
|
+
} catch (error) {
|
|
537
|
+
if (error instanceof require_domain_channel_exhausted_error.ChannelExhaustedError) entry.clearCurrent(channel);
|
|
538
|
+
throw error;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
async function sealFor(entry, frame) {
|
|
542
|
+
entry.expire(clock.now());
|
|
543
|
+
entry.touch(clock.now());
|
|
544
|
+
const { current, hs } = entry;
|
|
545
|
+
if (current !== void 0) return [await sealUnder(entry, current.channel, frame)];
|
|
546
|
+
if (entry.pool.length > 0) return Promise.all(entry.pool.map(async (cand) => sealUnder(entry, cand.channel, frame)));
|
|
547
|
+
if (hs === void 0) {
|
|
548
|
+
beginOwn(entry, "initiated");
|
|
549
|
+
enforcePendingBound(entry);
|
|
550
|
+
return sealFor(entry, frame);
|
|
551
|
+
}
|
|
552
|
+
if (hs.cands.length > 0) {
|
|
553
|
+
await hs.sent.promise;
|
|
554
|
+
return Promise.all(hs.cands.map(async (cand) => sealUnder(entry, cand.channel, frame)));
|
|
555
|
+
}
|
|
556
|
+
await entry.waitForChannel();
|
|
557
|
+
return sealFor(entry, frame);
|
|
558
|
+
}
|
|
559
|
+
/** Tries a frame against the peer's channels, in order: the current one, then the candidates, then the replaced one while its window lasts. */
|
|
560
|
+
async function openUnder(entry, data) {
|
|
561
|
+
entry.expire(clock.now());
|
|
562
|
+
const { current } = entry;
|
|
563
|
+
if (current !== void 0) {
|
|
564
|
+
const outcome = await current.channel.openOutcome(data);
|
|
565
|
+
if (authentic(outcome)) {
|
|
566
|
+
entry.confirmCurrent();
|
|
567
|
+
entry.touch(clock.now());
|
|
568
|
+
return outcome.ok ? {
|
|
569
|
+
frame: outcome.frame,
|
|
570
|
+
from: current.channel.peer.deviceId
|
|
571
|
+
} : void 0;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const found = await openFirst(entry.candidates(), data);
|
|
575
|
+
if (found !== void 0) {
|
|
576
|
+
if (peers.get(entry.key) !== entry) return;
|
|
577
|
+
entry.confirm(found.cand, clock.now());
|
|
578
|
+
entry.touch(clock.now());
|
|
579
|
+
return found.outcome.ok ? {
|
|
580
|
+
frame: found.outcome.frame,
|
|
581
|
+
from: found.cand.channel.peer.deviceId
|
|
582
|
+
} : void 0;
|
|
583
|
+
}
|
|
584
|
+
const { replaced } = entry;
|
|
585
|
+
if (replaced !== void 0 && clock.now() < replaced.until) {
|
|
586
|
+
const outcome = await replaced.channel.openOutcome(data);
|
|
587
|
+
return outcome.ok ? {
|
|
588
|
+
frame: outcome.frame,
|
|
589
|
+
from: replaced.channel.peer.deviceId
|
|
590
|
+
} : void 0;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
async function acceptHello(hello, sender) {
|
|
594
|
+
const key = require_domain_device_id.deviceIdToHex(sender);
|
|
595
|
+
const verdict = await verifyHello(identity, hello);
|
|
596
|
+
if (!verdict.ok || !await isUsableEphemeralKey(hello["ephemeral-key"])) return;
|
|
597
|
+
const now = clock.now();
|
|
598
|
+
let entry = peers.get(key);
|
|
599
|
+
entry?.expire(now);
|
|
600
|
+
if (entry?.holdsKey(hello["ephemeral-key"]) === true) return;
|
|
601
|
+
if (entry?.hs !== void 0 && entry.outstanding(now)) {
|
|
602
|
+
if (entry.hs.cands.length < 2) await addCandidate(entry, entry.hs, hello, verdict.peer);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (entry === void 0) {
|
|
606
|
+
if (!reserveUnsolicitedSlot(now)) return;
|
|
607
|
+
entry = new PeerChannels(sender, now);
|
|
608
|
+
peers.set(key, entry);
|
|
609
|
+
}
|
|
610
|
+
const hs = beginOwn(entry, "answered");
|
|
611
|
+
entry.touch(now);
|
|
612
|
+
try {
|
|
613
|
+
await addCandidate(entry, hs, hello, verdict.peer);
|
|
614
|
+
} catch (error) {
|
|
615
|
+
dropHandshake(entry, hs, error);
|
|
616
|
+
throw error;
|
|
617
|
+
}
|
|
370
618
|
}
|
|
371
619
|
return {
|
|
372
|
-
|
|
620
|
+
seal: async (peer, frame, purpose) => {
|
|
373
621
|
const key = require_domain_device_id.deviceIdToHex(peer);
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
622
|
+
const now = clock.now();
|
|
623
|
+
let entry = peers.get(key);
|
|
624
|
+
if (entry === void 0) {
|
|
625
|
+
if (purpose === "reply" && !reserveUnsolicitedSlot(now)) throw new Error("there is no channel with this peer");
|
|
626
|
+
entry = new PeerChannels(peer, now);
|
|
627
|
+
peers.set(key, entry);
|
|
628
|
+
}
|
|
629
|
+
if (purpose === "request") entry.choose();
|
|
630
|
+
return sealFor(entry, frame);
|
|
377
631
|
},
|
|
378
|
-
applyHello: async (hello) => {
|
|
379
|
-
let
|
|
632
|
+
applyHello: async (hello, claimedFrom) => {
|
|
633
|
+
let sender;
|
|
380
634
|
try {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
await complete({
|
|
392
|
-
entry,
|
|
393
|
-
hello,
|
|
394
|
-
peer: verdict.peer,
|
|
395
|
-
replaces
|
|
396
|
-
});
|
|
635
|
+
if (claimedFrom === void 0) return;
|
|
636
|
+
sender = await identity.deriveDeviceId(hello["identity-key"]["public-key"]);
|
|
637
|
+
if (!require_domain_token_scope.bytesEqual(sender, claimedFrom)) return;
|
|
638
|
+
const held = peers.get(require_domain_device_id.deviceIdToHex(sender));
|
|
639
|
+
const now = clock.now();
|
|
640
|
+
held?.expire(now);
|
|
641
|
+
if (held?.holdsKey(hello["ephemeral-key"]) === true) return;
|
|
642
|
+
if (held?.hs !== void 0 && held.outstanding(now) && held.hs.cands.length >= 2) return;
|
|
643
|
+
if (held === void 0 && !slotAvailable(now)) return;
|
|
644
|
+
await acceptHello(hello, sender);
|
|
397
645
|
} catch (error) {
|
|
398
|
-
|
|
399
|
-
reportFault(error);
|
|
646
|
+
reportFault(error, sender);
|
|
400
647
|
}
|
|
401
648
|
},
|
|
402
649
|
open: async (data, claimedFrom) => {
|
|
403
650
|
if (claimedFrom === void 0) return;
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
const frame = await entry.channel.open(data);
|
|
408
|
-
return frame === void 0 ? void 0 : {
|
|
409
|
-
frame,
|
|
410
|
-
from: entry.channel.peer.deviceId
|
|
411
|
-
};
|
|
651
|
+
const entry = peers.get(require_domain_device_id.deviceIdToHex(claimedFrom));
|
|
652
|
+
if (entry === void 0) return;
|
|
653
|
+
return entry.serialise(async () => openUnder(entry, data));
|
|
412
654
|
},
|
|
413
655
|
reset: (reason) => {
|
|
414
|
-
for (const entry of [...
|
|
415
|
-
channels.clear();
|
|
656
|
+
for (const entry of [...peers.values()]) discard(entry, new Error(reason));
|
|
416
657
|
},
|
|
417
658
|
forget: (peer) => {
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
if (entry?.
|
|
421
|
-
|
|
659
|
+
const entry = peers.get(require_domain_device_id.deviceIdToHex(peer));
|
|
660
|
+
if (entry === void 0) return;
|
|
661
|
+
if ((entry.hs?.cands.length ?? 0) + entry.pool.length === 0) {
|
|
662
|
+
discard(entry, /* @__PURE__ */ new Error("the relay pairing was lost"));
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
entry.dropConfirmed();
|
|
422
666
|
}
|
|
423
667
|
};
|
|
424
668
|
}
|
|
@@ -503,19 +747,19 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
|
|
|
503
747
|
}
|
|
504
748
|
/** The "not connected" guard every send* method on the public session needs, deduplicated into one place rather than repeated inline at each call site: throws unless there is a live connection AND the session's own state machine agrees it is "connected" (transmit's own connection===null check alone doesn't cover the latter). Returns the connection itself, narrowed non-null, for a caller that needs to send directly on it (sendPing/sendPingMeasureRtt); a caller that only needs the throwing side effect (sendManageRequest and friends, which go through transmit instead) calls this and discards the result. */
|
|
505
749
|
function requireConnectedLink() {
|
|
506
|
-
if (connection === null || state.status !== "connected") throw new Error("not connected");
|
|
750
|
+
if (feedCancelled || connection === null || state.status !== "connected") throw new Error("not connected");
|
|
507
751
|
return connection;
|
|
508
752
|
}
|
|
509
753
|
/** Sends a frame. Through a relay it is sealed for toDevice on their secure channel first (spec/secure-channel.cddl) and only then wrapped as relay-data, so the hub carries ciphertext. This is the single choke point every outbound manage-request and manage-response passes through, so a consumer of sendManageRequest or respond never needs its own relay logic. */
|
|
510
|
-
async function transmit(frame,
|
|
754
|
+
async function transmit(frame, toDevice, purpose = "request") {
|
|
511
755
|
if (connection === null) throw new Error("not connected");
|
|
512
|
-
if (
|
|
756
|
+
if (toDevice === void 0) {
|
|
513
757
|
await connection.send(frame);
|
|
514
758
|
return;
|
|
515
759
|
}
|
|
516
|
-
|
|
517
|
-
const sealed = await
|
|
518
|
-
await
|
|
760
|
+
const link = connection;
|
|
761
|
+
const sealed = await relayChannels.seal(toDevice, frame, purpose);
|
|
762
|
+
await Promise.all(sealed.map(async (copy) => link.send(require_adapters_frame_codec.wrapRelayData(copy, toDevice))));
|
|
519
763
|
}
|
|
520
764
|
/** `from` is the device whose secure channel the response arrived through, absent for one that arrived directly. A pending request only accepts an answer from where it was sent: a peer must not be able to answer a request meant for another by guessing its request-id. */
|
|
521
765
|
function applyManageResponse(frame, from) {
|
|
@@ -549,7 +793,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
|
|
|
549
793
|
direction: "sent",
|
|
550
794
|
frame: response
|
|
551
795
|
});
|
|
552
|
-
await transmit(response,
|
|
796
|
+
await transmit(response, fromDevice, "reply");
|
|
553
797
|
emit();
|
|
554
798
|
}
|
|
555
799
|
};
|
|
@@ -567,7 +811,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
|
|
|
567
811
|
direction: "received",
|
|
568
812
|
frame: inner
|
|
569
813
|
});
|
|
570
|
-
await relayChannels.applyHello(inner);
|
|
814
|
+
await relayChannels.applyHello(inner, frame["from-device"]);
|
|
571
815
|
return;
|
|
572
816
|
}
|
|
573
817
|
if (inner?.type === "secure-data") {
|
|
@@ -834,7 +1078,10 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
|
|
|
834
1078
|
},
|
|
835
1079
|
async sendManageRequest(command, scope, targetDevice, token, timeoutMs) {
|
|
836
1080
|
requireConnectedLink();
|
|
837
|
-
if (targetDevice !== void 0)
|
|
1081
|
+
if (targetDevice !== void 0) {
|
|
1082
|
+
await ensureRelayPairing(targetDevice);
|
|
1083
|
+
requireConnectedLink();
|
|
1084
|
+
}
|
|
838
1085
|
const frame = buildManageRequest(command, scope, token);
|
|
839
1086
|
const requestId = frame["request-id"];
|
|
840
1087
|
const outcome = new Promise((resolve, reject) => {
|
|
@@ -844,16 +1091,16 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
|
|
|
844
1091
|
expectedFrom: targetDevice
|
|
845
1092
|
});
|
|
846
1093
|
});
|
|
1094
|
+
outcome.catch(() => void 0);
|
|
847
1095
|
recordFrame({
|
|
848
1096
|
direction: "sent",
|
|
849
1097
|
frame
|
|
850
1098
|
});
|
|
851
1099
|
const exchange = (async () => {
|
|
852
1100
|
try {
|
|
853
|
-
await transmit(frame, targetDevice
|
|
1101
|
+
await transmit(frame, targetDevice);
|
|
854
1102
|
} catch (error) {
|
|
855
1103
|
pendingManageRequests.delete(requestId);
|
|
856
|
-
outcome.catch(() => void 0);
|
|
857
1104
|
throw error;
|
|
858
1105
|
}
|
|
859
1106
|
emit();
|
|
@@ -888,7 +1135,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
|
|
|
888
1135
|
direction: "sent",
|
|
889
1136
|
frame
|
|
890
1137
|
});
|
|
891
|
-
await transmit(frame
|
|
1138
|
+
await transmit(frame);
|
|
892
1139
|
emit();
|
|
893
1140
|
},
|
|
894
1141
|
async sendCoordinatorClaim(frame) {
|
|
@@ -898,7 +1145,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
|
|
|
898
1145
|
direction: "sent",
|
|
899
1146
|
frame: claim
|
|
900
1147
|
});
|
|
901
|
-
await transmit(claim
|
|
1148
|
+
await transmit(claim);
|
|
902
1149
|
emit();
|
|
903
1150
|
},
|
|
904
1151
|
async sendGossipUpdate(extensions) {
|
|
@@ -908,7 +1155,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
|
|
|
908
1155
|
direction: "sent",
|
|
909
1156
|
frame
|
|
910
1157
|
});
|
|
911
|
-
await transmit(frame
|
|
1158
|
+
await transmit(frame);
|
|
912
1159
|
emit();
|
|
913
1160
|
},
|
|
914
1161
|
getTopologyPeers() {
|
|
@@ -921,7 +1168,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
|
|
|
921
1168
|
direction: "sent",
|
|
922
1169
|
frame
|
|
923
1170
|
});
|
|
924
|
-
await transmit(frame, targetDevice
|
|
1171
|
+
await transmit(frame, targetDevice);
|
|
925
1172
|
emit();
|
|
926
1173
|
},
|
|
927
1174
|
async close() {
|