wire-mesh-core 2.1.0 → 3.0.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.
Files changed (85) hide show
  1. package/README.md +1 -0
  2. package/dist/adapters/frame-codec.d.cts +1 -1
  3. package/dist/adapters/frame-codec.d.mts +1 -1
  4. package/dist/adapters/node-identity.d.cts +2 -2
  5. package/dist/adapters/node-identity.d.mts +2 -2
  6. package/dist/adapters/tcp-transport.d.cts +1 -1
  7. package/dist/adapters/tcp-transport.d.mts +1 -1
  8. package/dist/adapters/threshold-identity.d.cts +2 -2
  9. package/dist/adapters/threshold-identity.d.mts +2 -2
  10. package/dist/adapters/threshold-wasm.d.cts +1 -1
  11. package/dist/adapters/threshold-wasm.d.mts +1 -1
  12. package/dist/adapters/tls-transport.d.cts +1 -1
  13. package/dist/adapters/tls-transport.d.mts +1 -1
  14. package/dist/domain/advert-extension-policy.d.cts +1 -1
  15. package/dist/domain/advert-extension-policy.d.mts +1 -1
  16. package/dist/domain/bulk.d.cts +2 -2
  17. package/dist/domain/bulk.d.mts +2 -2
  18. package/dist/domain/capability-grant.d.cts +3 -3
  19. package/dist/domain/capability-grant.d.mts +3 -3
  20. package/dist/domain/capability-request.d.cts +2 -2
  21. package/dist/domain/capability-request.d.mts +2 -2
  22. package/dist/domain/data-sync.d.cts +2 -2
  23. package/dist/domain/data-sync.d.mts +2 -2
  24. package/dist/domain/device-id.d.cts +1 -1
  25. package/dist/domain/device-id.d.mts +1 -1
  26. package/dist/domain/direct-manage-request.d.cts +2 -2
  27. package/dist/domain/direct-manage-request.d.mts +2 -2
  28. package/dist/domain/gossip-expansion.d.cts +1 -1
  29. package/dist/domain/gossip-expansion.d.mts +1 -1
  30. package/dist/domain/grant-candidates.d.cts +1 -1
  31. package/dist/domain/grant-candidates.d.mts +1 -1
  32. package/dist/domain/handshake.d.cts +1 -1
  33. package/dist/domain/handshake.d.mts +1 -1
  34. package/dist/domain/mesh-session.cjs +325 -23
  35. package/dist/domain/mesh-session.d.cts +4 -4
  36. package/dist/domain/mesh-session.d.mts +4 -4
  37. package/dist/domain/mesh-session.mjs +326 -24
  38. package/dist/domain/notice-board.d.cts +3 -3
  39. package/dist/domain/notice-board.d.mts +3 -3
  40. package/dist/domain/path-trace.d.cts +1 -1
  41. package/dist/domain/path-trace.d.mts +1 -1
  42. package/dist/domain/peer-advert.d.cts +2 -2
  43. package/dist/domain/peer-advert.d.mts +2 -2
  44. package/dist/domain/relay-hub.d.cts +2 -2
  45. package/dist/domain/relay-hub.d.mts +2 -2
  46. package/dist/domain/relay-use-gate.d.cts +2 -2
  47. package/dist/domain/relay-use-gate.d.mts +2 -2
  48. package/dist/domain/revocation-view.d.cts +2 -2
  49. package/dist/domain/revocation-view.d.mts +2 -2
  50. package/dist/domain/room-rekey.d.cts +3 -3
  51. package/dist/domain/room-rekey.d.mts +3 -3
  52. package/dist/domain/room-token-verification.d.cts +1 -1
  53. package/dist/domain/room-token-verification.d.mts +1 -1
  54. package/dist/domain/room.d.cts +4 -4
  55. package/dist/domain/room.d.mts +4 -4
  56. package/dist/domain/shard-manifest.d.cts +1 -1
  57. package/dist/domain/shard-manifest.d.mts +1 -1
  58. package/dist/domain/tokens.d.cts +1 -1
  59. package/dist/domain/tokens.d.mts +1 -1
  60. package/dist/domain/topology-snapshot.d.cts +1 -1
  61. package/dist/domain/topology-snapshot.d.mts +1 -1
  62. package/dist/domain/topology.d.cts +1 -1
  63. package/dist/domain/topology.d.mts +1 -1
  64. package/dist/domain/webrtc-signaling.d.cts +2 -2
  65. package/dist/domain/webrtc-signaling.d.mts +2 -2
  66. package/dist/generated/protocol.cjs +18 -1
  67. package/dist/generated/protocol.d.cts +2 -2
  68. package/dist/generated/protocol.d.mts +2 -2
  69. package/dist/generated/protocol.mjs +17 -2
  70. package/dist/{identity-DImeXZcq.d.cts → identity-DVCNuFLG.d.cts} +1 -1
  71. package/dist/{identity-Dc4xmwp3.d.mts → identity-DcaYgjXT.d.mts} +1 -1
  72. package/dist/ports/identity.d.cts +1 -1
  73. package/dist/ports/identity.d.mts +1 -1
  74. package/dist/ports/transport.d.cts +1 -1
  75. package/dist/ports/transport.d.mts +1 -1
  76. package/dist/{protocol-Bi2fzBCc.d.cts → protocol-DCDae3z4.d.cts} +47 -1
  77. package/dist/{protocol-Bi2fzBCc.d.mts → protocol-DCDae3z4.d.mts} +47 -1
  78. package/dist/{room-token-verification-CDXkm_Tg.d.cts → room-token-verification-CkGnFuQc.d.mts} +2 -2
  79. package/dist/{room-token-verification-CnHNaA8J.d.mts → room-token-verification-PSv4eQ1x.d.cts} +2 -2
  80. package/dist/{tokens-0r9ipK1r.d.mts → tokens-CBiSYuzp.d.mts} +2 -2
  81. package/dist/{tokens-BDVxEvl2.d.cts → tokens-DqFu7iMX.d.cts} +2 -2
  82. package/dist/{transport-Cq_odHy3.d.cts → transport-CfW7fsGK.d.cts} +1 -1
  83. package/dist/{transport-GcJ4LYVE.d.mts → transport-iLBxIkRS.d.mts} +1 -1
  84. package/package.json +1 -1
  85. package/wasm-dist/wire_mesh_threshold_wasm_bg.wasm +0 -0
@@ -1,5 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_gossip_extensions = require("../gossip-extensions-BBYgMZ2N.cjs");
3
+ const require_token_scope = require("../token-scope-CxHXTT3u.cjs");
3
4
  const require_domain_peer_advert = require("./peer-advert.cjs");
4
5
  const require_domain_device_id = require("./device-id.cjs");
5
6
  const require_domain_handshake = require("./handshake.cjs");
@@ -22,6 +23,260 @@ function createRelayPairings() {
22
23
  };
23
24
  }
24
25
  //#endregion
26
+ //#region src/domain/secure-channel.ts
27
+ /**
28
+ * The end-to-end channel two devices open between themselves inside a relay pairing (spec/secure-channel.cddl). A relay only forwards the bytes of a `relay-data` frame, and without this it could read them, alter them, and say who sent them. Here the two devices authenticate each other's identity keys over a fresh ECDH exchange, derive one AES-256-GCM key per direction, and seal every frame they exchange so the relay sees ciphertext and every receiver learns the sender's identity from the handshake and nothing a relay asserts.
29
+ *
30
+ * Pure crypto and framing, with no connection or session in it: beginHandshake and verifyHello and establishChannel are the three steps of the handshake, and a SecureChannel then seals and opens frames. mesh-session.ts owns when to do each, and where the results travel.
31
+ */
32
+ const NONCE_BYTE_LENGTH = 16;
33
+ const AES_KEY_BIT_LENGTH = 256;
34
+ const ECDH_SECRET_BIT_LENGTH = 256;
35
+ const GCM_IV_BYTE_LENGTH = 12;
36
+ /** The counter fills the last eight bytes of the IV; the four before it stay zero. */
37
+ const IV_COUNTER_OFFSET = 4;
38
+ const COUNTER_BYTE_LENGTH = 8;
39
+ const encoder = new TextEncoder();
40
+ const HELLO_CONTEXT = encoder.encode("wire-mesh/secure-hello/v1\0");
41
+ const CHANNEL_CONTEXT = encoder.encode("wire-mesh/secure-channel/v1\0");
42
+ const DATA_CONTEXT = encoder.encode("wire-mesh/secure-data/v1\0");
43
+ const LABEL_LOW_TO_HIGH = encoder.encode("lo-to-hi");
44
+ const LABEL_HIGH_TO_LOW = encoder.encode("hi-to-lo");
45
+ const ECDH_PARAMS = {
46
+ name: "ECDH",
47
+ namedCurve: "P-256"
48
+ };
49
+ function concat(...parts) {
50
+ const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
51
+ let offset = 0;
52
+ for (const part of parts) {
53
+ out.set(part, offset);
54
+ offset += part.length;
55
+ }
56
+ return out;
57
+ }
58
+ /** Bytewise order of two device-ids: negative when a sorts first. */
59
+ function compareBytes(a, b) {
60
+ for (let i = 0; i < Math.min(a.length, b.length); i += 1) {
61
+ const difference = (a[i] ?? 0) - (b[i] ?? 0);
62
+ if (difference !== 0) return difference;
63
+ }
64
+ return a.length - b.length;
65
+ }
66
+ function counterBytes(counter) {
67
+ const bytes = new Uint8Array(COUNTER_BYTE_LENGTH);
68
+ new DataView(bytes.buffer).setBigUint64(0, BigInt(counter), false);
69
+ return bytes;
70
+ }
71
+ /** Exactly what a hello's signature covers: see spec/secure-channel.cddl. */
72
+ function helloSigningInput(ephemeralKey, nonce, sender, recipient) {
73
+ return concat(HELLO_CONTEXT, ephemeralKey, nonce, sender, recipient);
74
+ }
75
+ /** Generates this side's ephemeral key pair and nonce and signs a hello for peer. */
76
+ async function beginHandshake(identity, peer) {
77
+ const pair = await crypto.subtle.generateKey(ECDH_PARAMS, false, ["deriveBits"]);
78
+ const ephemeralKey = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
79
+ const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTE_LENGTH));
80
+ const signature = await identity.sign(helloSigningInput(ephemeralKey, nonce, identity.deviceId, peer));
81
+ return {
82
+ peer,
83
+ privateKey: pair.privateKey,
84
+ hello: {
85
+ type: "secure-hello",
86
+ "ephemeral-key": ephemeralKey,
87
+ nonce,
88
+ "identity-key": identity.identityKey,
89
+ "to-device": peer,
90
+ signature
91
+ }
92
+ };
93
+ }
94
+ /**
95
+ * Checks a received hello: its identity key must hash to a device-id (which is then the peer), it must be addressed to this device, and its signature must verify under that key over the input spec/secure-channel.cddl defines. Never throws: a hostile hello gets a verdict.
96
+ */
97
+ async function verifyHello(identity, hello) {
98
+ const key = hello["identity-key"];
99
+ try {
100
+ const sender = await identity.deriveDeviceId(key["public-key"]);
101
+ if (!require_token_scope.bytesEqual(hello["to-device"], identity.deviceId)) return {
102
+ ok: false,
103
+ reason: "wrong_recipient"
104
+ };
105
+ return await identity.verify(key, helloSigningInput(hello["ephemeral-key"], hello.nonce, sender, identity.deviceId), hello.signature) ? {
106
+ ok: true,
107
+ peer: {
108
+ deviceId: sender,
109
+ identityKey: key
110
+ }
111
+ } : {
112
+ ok: false,
113
+ reason: "bad_signature"
114
+ };
115
+ } catch {
116
+ return {
117
+ ok: false,
118
+ reason: "identity_mismatch"
119
+ };
120
+ }
121
+ }
122
+ function ivFor(counter) {
123
+ const iv = new Uint8Array(GCM_IV_BYTE_LENGTH);
124
+ iv.set(counterBytes(counter), IV_COUNTER_OFFSET);
125
+ return iv;
126
+ }
127
+ function associatedData(sender, recipient, counter) {
128
+ return concat(DATA_CONTEXT, sender, recipient, counterBytes(counter));
129
+ }
130
+ async function deriveDirectionKey(secret, salt, low, high, label) {
131
+ const material = await crypto.subtle.importKey("raw", Uint8Array.from(secret), "HKDF", false, ["deriveKey"]);
132
+ return crypto.subtle.deriveKey({
133
+ name: "HKDF",
134
+ hash: "SHA-256",
135
+ salt: Uint8Array.from(salt),
136
+ info: concat(CHANNEL_CONTEXT, low, high, label)
137
+ }, material, {
138
+ name: "AES-GCM",
139
+ length: AES_KEY_BIT_LENGTH
140
+ }, false, ["encrypt", "decrypt"]);
141
+ }
142
+ /**
143
+ * Completes a handshake once the peer's hello has verified: derives the two directional keys from the ECDH result of the ephemeral keys and returns the channel. Returns undefined when the peer's ephemeral key is not a valid P-256 point, which only a hostile or broken peer sends.
144
+ *
145
+ * `peer` is the device verifyHello authenticated for `remote`, and `pending` is this side's own half, whether the handshake was begun here or in answer to that hello.
146
+ */
147
+ async function establishChannel(identity, pending, remote, peer) {
148
+ let secret;
149
+ try {
150
+ const remoteKey = await crypto.subtle.importKey("raw", Uint8Array.from(remote["ephemeral-key"]), ECDH_PARAMS, false, []);
151
+ secret = new Uint8Array(await crypto.subtle.deriveBits({
152
+ name: "ECDH",
153
+ public: remoteKey
154
+ }, pending.privateKey, ECDH_SECRET_BIT_LENGTH));
155
+ } catch {
156
+ return;
157
+ }
158
+ const localIsLow = compareBytes(identity.deviceId, peer.deviceId) < 0;
159
+ const low = localIsLow ? identity.deviceId : peer.deviceId;
160
+ const high = localIsLow ? peer.deviceId : identity.deviceId;
161
+ const salt = concat(localIsLow ? pending.hello.nonce : remote.nonce, localIsLow ? remote.nonce : pending.hello.nonce);
162
+ const lowToHigh = await deriveDirectionKey(secret, salt, low, high, LABEL_LOW_TO_HIGH);
163
+ const highToLow = await deriveDirectionKey(secret, salt, low, high, LABEL_HIGH_TO_LOW);
164
+ const sendKey = localIsLow ? lowToHigh : highToLow;
165
+ const receiveKey = localIsLow ? highToLow : lowToHigh;
166
+ let nextSendCounter = 0;
167
+ let highestReceived = -1;
168
+ let sealing = Promise.resolve();
169
+ let opening = Promise.resolve();
170
+ async function sealAt(frame, counter) {
171
+ return {
172
+ type: "secure-data",
173
+ counter,
174
+ ciphertext: new Uint8Array(await crypto.subtle.encrypt({
175
+ name: "AES-GCM",
176
+ iv: ivFor(counter),
177
+ additionalData: associatedData(identity.deviceId, peer.deviceId, counter)
178
+ }, sendKey, require_adapters_frame_codec.messageFromFrame(frame)))
179
+ };
180
+ }
181
+ async function openOne(data) {
182
+ if (data.counter <= highestReceived) return void 0;
183
+ let plaintext;
184
+ try {
185
+ plaintext = new Uint8Array(await crypto.subtle.decrypt({
186
+ name: "AES-GCM",
187
+ iv: ivFor(data.counter),
188
+ additionalData: associatedData(peer.deviceId, identity.deviceId, data.counter)
189
+ }, receiveKey, Uint8Array.from(data.ciphertext)));
190
+ } catch {
191
+ return;
192
+ }
193
+ highestReceived = data.counter;
194
+ return require_adapters_frame_codec.tryDecodeFrame(plaintext) ?? void 0;
195
+ }
196
+ return {
197
+ peer,
198
+ seal: async (frame) => {
199
+ const counter = nextSendCounter;
200
+ nextSendCounter += 1;
201
+ const sealed = sealing.then(async () => sealAt(frame, counter));
202
+ sealing = sealed.catch(() => void 0);
203
+ return sealed;
204
+ },
205
+ open: async (data) => {
206
+ const opened = opening.then(async () => openOne(data));
207
+ opening = opened.catch(() => void 0);
208
+ return opened;
209
+ }
210
+ };
211
+ }
212
+ //#endregion
213
+ //#region src/domain/relay-channels.ts
214
+ function createRelayChannels(identity, sendHello) {
215
+ const channels = /* @__PURE__ */ new Map();
216
+ /** Registers the peer as awaiting at once, so a hello arriving meanwhile completes this handshake instead of starting a second, then sends this side's hello. */
217
+ function start(peer) {
218
+ const key = require_domain_device_id.deviceIdToHex(peer);
219
+ const settle = Promise.withResolvers();
220
+ const pending = beginHandshake(identity, peer);
221
+ const entry = {
222
+ status: "awaiting",
223
+ pending,
224
+ settle
225
+ };
226
+ channels.set(key, entry);
227
+ pending.then(async ({ hello }) => sendHello(peer, hello)).catch((error) => {
228
+ settle.reject(error instanceof Error ? error : new Error(String(error)));
229
+ if (channels.get(key) === entry) channels.delete(key);
230
+ });
231
+ return entry;
232
+ }
233
+ return {
234
+ ensure: async (peer) => {
235
+ const existing = channels.get(require_domain_device_id.deviceIdToHex(peer));
236
+ if (existing?.status === "established") return existing.channel;
237
+ return (existing ?? start(peer)).settle.promise;
238
+ },
239
+ applyHello: async (hello) => {
240
+ const verdict = await verifyHello(identity, hello);
241
+ if (!verdict.ok) return;
242
+ const key = require_domain_device_id.deviceIdToHex(verdict.peer.deviceId);
243
+ const current = channels.get(key);
244
+ const entry = current?.status === "awaiting" ? current : start(verdict.peer.deviceId);
245
+ const channel = await establishChannel(identity, await entry.pending, hello, verdict.peer);
246
+ if (channel === void 0) {
247
+ entry.settle.reject(/* @__PURE__ */ new Error("the peer sent an unusable ephemeral key"));
248
+ if (channels.get(key) === entry) channels.delete(key);
249
+ return;
250
+ }
251
+ channels.set(key, {
252
+ status: "established",
253
+ channel
254
+ });
255
+ entry.settle.resolve(channel);
256
+ },
257
+ open: async (data, claimedFrom) => {
258
+ if (claimedFrom === void 0) return;
259
+ const entry = channels.get(require_domain_device_id.deviceIdToHex(claimedFrom));
260
+ if (entry?.status !== "established") return;
261
+ const frame = await entry.channel.open(data);
262
+ return frame === void 0 ? void 0 : {
263
+ frame,
264
+ from: entry.channel.peer.deviceId
265
+ };
266
+ },
267
+ reset: (reason) => {
268
+ for (const entry of channels.values()) if (entry.status === "awaiting") entry.settle.reject(new Error(reason));
269
+ channels.clear();
270
+ },
271
+ forget: (peer) => {
272
+ const key = require_domain_device_id.deviceIdToHex(peer);
273
+ const entry = channels.get(key);
274
+ if (entry?.status === "awaiting") entry.settle.reject(/* @__PURE__ */ new Error("the relay pairing was lost"));
275
+ channels.delete(key);
276
+ }
277
+ };
278
+ }
279
+ //#endregion
25
280
  //#region src/domain/mesh-session.ts
26
281
  const MS_PER_SECOND = 1e3;
27
282
  /** How long to wait for the node's handshake before calling it unanswered. A relay-only node never sends one; that is a state to display, not an error. */
@@ -56,6 +311,15 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
56
311
  getRelayedDevices: () => relayPairings.list()
57
312
  });
58
313
  const pendingManageRequests = /* @__PURE__ */ new Map();
314
+ const relayChannels = createRelayChannels(identity, async (peer, hello) => {
315
+ if (connection === null) throw new Error("not connected");
316
+ frameLog.push({
317
+ direction: "sent",
318
+ frame: hello
319
+ });
320
+ await connection.send(require_adapters_frame_codec.wrapRelayData(hello, peer));
321
+ emit();
322
+ });
59
323
  const incomingQueue = require_domain_async_queue.createAsyncQueue();
60
324
  const revocationQueue = require_domain_async_queue.createAsyncQueue();
61
325
  const pingRoundTrips = require_domain_ping_round_trips.createPingRoundTrips();
@@ -90,28 +354,32 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
90
354
  if (connection === null || state.status !== "connected") throw new Error("not connected");
91
355
  return connection;
92
356
  }
93
- /** Sends a frame, wrapping it as relay-data first when viaRelay is set -- the single choke point every outbound manage-request/manage-response passes through, so a consumer of sendManageRequest/respond never needs its own relay-wrapping logic. When relaying, toDevice is stamped onto the outer relay-data-frame's own `to-device` field so the hub addresses it to the correct pairing directly (wire-mesh#30) rather than falling back to whichever pairing it last saw -- the one case this is omitted is a response to a request that itself arrived with no from-device to echo back, which is left to that same hub fallback exactly as an unaddressed relay-data always has been. */
357
+ /** 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. */
94
358
  async function transmit(frame, viaRelay, toDevice) {
95
359
  if (connection === null) throw new Error("not connected");
96
- if (viaRelay) {
97
- await connection.send(require_adapters_frame_codec.wrapRelayData(frame, toDevice));
360
+ if (!viaRelay) {
361
+ await connection.send(frame);
98
362
  return;
99
363
  }
100
- await connection.send(frame);
364
+ if (toDevice === void 0) throw new Error("a relayed frame needs the device it is for");
365
+ const sealed = await (await relayChannels.ensure(toDevice)).seal(frame);
366
+ await connection.send(require_adapters_frame_codec.wrapRelayData(sealed, toDevice));
101
367
  }
102
- function applyManageResponse(frame) {
368
+ /** `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. */
369
+ function applyManageResponse(frame, from) {
103
370
  const requestId = frame["request-id"];
104
371
  const pending = pendingManageRequests.get(requestId);
105
- if (pending !== void 0) {
106
- pendingManageRequests.delete(requestId);
107
- pending.resolve(frame.outcome);
108
- }
372
+ if (pending === void 0) return;
373
+ const expected = pending.expectedFrom;
374
+ if (!(expected === void 0 ? from === void 0 : from !== void 0 && require_domain_device_id.deviceIdToHex(from) === require_domain_device_id.deviceIdToHex(expected))) return;
375
+ pendingManageRequests.delete(requestId);
376
+ pending.resolve(frame.outcome);
109
377
  }
110
- /** relayFrame is present only for a manage-request that arrived wrapped in relay-data, and is that same outer relay-data-frame -- its own to-device/from-device fields carry whatever addressing it received. See IncomingManageRequest's own fromDevice/toDevice doc comments for what each means and why neither is ever guessed from pairing state. A version.get request (VERSION_GET_VERB) is answered directly, right here, rather than ever reaching incomingManageRequests -- deliberately ungated (version.cddl), so no application needs to remember to register its own handler for it the way every other verb requires. */
111
- function applyManageRequest(frame, relayFrame) {
378
+ /** `origin` is present only for a manage-request that arrived through a relay pairing: who sent it, as their secure channel authenticated them, and the device the relay-data frame was addressed to, if it named one. See IncomingManageRequest's own fromDevice/toDevice doc comments for what each means and why neither is ever guessed from pairing state. */
379
+ function applyManageRequest(frame, origin) {
112
380
  const requestId = frame["request-id"];
113
- const fromDevice = relayFrame?.["from-device"];
114
- const toDevice = relayFrame?.["to-device"];
381
+ const fromDevice = origin?.fromDevice;
382
+ const toDevice = origin?.toDevice;
115
383
  const incoming = {
116
384
  requestId,
117
385
  command: frame.command,
@@ -129,7 +397,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
129
397
  direction: "sent",
130
398
  frame: response
131
399
  });
132
- await transmit(response, relayFrame !== void 0, fromDevice);
400
+ await transmit(response, origin !== void 0, fromDevice);
133
401
  emit();
134
402
  }
135
403
  };
@@ -142,13 +410,32 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
142
410
  async function applyFrame(frame) {
143
411
  if (frame.type === "relay-data") {
144
412
  const inner = require_adapters_frame_codec.tryDecodeFrame(frame.payload);
145
- if (inner !== null && (inner.type === "manage-request" || inner.type === "manage-response")) {
413
+ if (inner?.type === "secure-hello") {
146
414
  frameLog.push({
147
415
  direction: "received",
148
416
  frame: inner
149
417
  });
150
- if (inner.type === "manage-response") applyManageResponse(inner);
151
- else applyManageRequest(inner, frame);
418
+ await relayChannels.applyHello(inner);
419
+ return;
420
+ }
421
+ if (inner?.type === "secure-data") {
422
+ const opened = await relayChannels.open(inner, frame["from-device"]);
423
+ if (opened?.frame.type === "manage-response") {
424
+ frameLog.push({
425
+ direction: "received",
426
+ frame: opened.frame
427
+ });
428
+ applyManageResponse(opened.frame, opened.from);
429
+ } else if (opened?.frame.type === "manage-request") {
430
+ frameLog.push({
431
+ direction: "received",
432
+ frame: opened.frame
433
+ });
434
+ applyManageRequest(opened.frame, {
435
+ fromDevice: opened.from,
436
+ ...frame["to-device"] !== void 0 ? { toDevice: frame["to-device"] } : {}
437
+ });
438
+ }
152
439
  return;
153
440
  }
154
441
  frameLog.push({
@@ -272,6 +559,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
272
559
  /** Everything a connection needs once it exists, regardless of whether it was dialled (createMeshSession's own doConnect, below) or handed over already established (acceptMeshSession): send this side's handshake and self-advert, arm the handshake timeout, and start consuming frames. The two entry points differ only in how link itself came to exist and what address means for it -- a real dial target for one, a caller-chosen label for the other, since the Connection/Transport ports expose no remote-address concept of their own for an accepted connection. */
273
560
  async function wireUpConnection(link, address, localDomains) {
274
561
  connection = link;
562
+ relayChannels.reset("the connection changed before the channel was ready");
275
563
  localHandshakeSent = localHandshake(localDomains);
276
564
  handshake = { status: "pending" };
277
565
  state = {
@@ -369,20 +657,33 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
369
657
  const outcome = new Promise((resolve, reject) => {
370
658
  pendingManageRequests.set(requestId, {
371
659
  resolve,
372
- reject
660
+ reject,
661
+ expectedFrom: targetDevice
373
662
  });
374
663
  });
375
664
  frameLog.push({
376
665
  direction: "sent",
377
666
  frame
378
667
  });
379
- await transmit(frame, targetDevice !== void 0, targetDevice);
380
- emit();
381
- if (timeoutMs === void 0) return outcome;
382
- return Promise.race([outcome, new Promise((resolve) => {
668
+ const exchange = (async () => {
669
+ try {
670
+ await transmit(frame, targetDevice !== void 0, targetDevice);
671
+ } catch (error) {
672
+ pendingManageRequests.delete(requestId);
673
+ outcome.catch(() => void 0);
674
+ throw error;
675
+ }
676
+ emit();
677
+ return outcome;
678
+ })();
679
+ if (timeoutMs === void 0) return exchange;
680
+ return Promise.race([exchange, new Promise((resolve) => {
383
681
  setTimeout(() => {
384
682
  if (pendingManageRequests.delete(requestId)) {
385
- if (targetDevice !== void 0) relayPairings.remove(targetDevice);
683
+ if (targetDevice !== void 0) {
684
+ relayPairings.remove(targetDevice);
685
+ relayChannels.forget(targetDevice);
686
+ }
386
687
  resolve({
387
688
  result: "error",
388
689
  code: "timeout"
@@ -437,6 +738,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
437
738
  reconnectTimer = null;
438
739
  }
439
740
  rejectPendingManageRequests("connection closed before a response arrived");
741
+ relayChannels.reset("the session closed before the channel was ready");
440
742
  pingRoundTrips.rejectAll("connection closed before a pong arrived");
441
743
  if (connection !== null) await connection.close();
442
744
  if (state.status === "connected" || state.status === "connecting" || state.status === "reconnecting") state = {
@@ -1,7 +1,7 @@
1
- import { D as DeviceId, E as DataRequestFrame, N as Frame, T as DataHaveFrame, U as ManageError, V as ManageCommand, W as ManageOk, bt as RevocationEntry, en as TopologyPeers, et as PeerAdvert, f as CapabilityScope, lt as ProtocolVersion, p as CapabilityToken, w as DataEntriesFrame } from "../protocol-Bi2fzBCc.cjs";
2
- import { t as IdentityPort } from "../identity-DImeXZcq.cjs";
1
+ import { D as DeviceId, E as DataRequestFrame, N as Frame, T as DataHaveFrame, U as ManageError, V as ManageCommand, W as ManageOk, bt as RevocationEntry, et as PeerAdvert, f as CapabilityScope, lt as ProtocolVersion, nn as TopologyPeers, p as CapabilityToken, w as DataEntriesFrame } from "../protocol-DCDae3z4.cjs";
2
+ import { t as IdentityPort } from "../identity-DVCNuFLG.cjs";
3
3
  import { t as Clock } from "../clock-DiSx-WKM.cjs";
4
- import { r as Transport, t as Connection } from "../transport-Cq_odHy3.cjs";
4
+ import { r as Transport, t as Connection } from "../transport-CfW7fsGK.cjs";
5
5
  //#region src/domain/mesh-session.d.ts
6
6
  /** How long to wait for the node's handshake before calling it unanswered. A relay-only node never sends one; that is a state to display, not an error. */
7
7
  export declare const HANDSHAKE_TIMEOUT_MS = 3000;
@@ -38,7 +38,7 @@ export interface IncomingManageRequest {
38
38
  command: ManageCommand;
39
39
  scope: CapabilityScope;
40
40
  token?: CapabilityToken;
41
- /** The device-id of the peer this request was relayed on behalf of, read directly from the enclosing relay-data-frame's own `from-device` field (stamped by the hub on every frame it forwards, wire-mesh#30) -- present only when the request arrived wrapped in a relay-data frame that carried one. A caller that needs to address a further request back to the same peer (one not sent via respond(), which already routes back correctly on its own) passes this as sendManageRequest's targetDevice. Never inferred from which relay pairing happens to be most recently established: a connection can hold several concurrent pairings (wire-mesh#30's own multiplexed adjacency map), so only the frame's own per-message addressing can say who actually sent it. */
41
+ /** The device-id of the peer this request was relayed on behalf of, as the end-to-end secure channel it arrived through authenticated it (spec/secure-channel.cddl): the identity that channel's handshake proved, never the `from-device` a hub stamps on relay-data, which a hub can set to anything. Present only when the request arrived through a relay pairing. A caller that needs to address a further request back to the same peer (one not sent via respond(), which already routes back correctly on its own) passes this as sendManageRequest's targetDevice. */
42
42
  fromDevice?: DeviceId;
43
43
  /** The device-id this request's relay-data frame was explicitly addressed to, read from its own `to-device` field -- present only when the request arrived relay-wrapped and the frame carried one. A caller fronting more than one locally-addressable device behind a single hub connection (a gateway advertising several local peers through the same relay pairing) uses this to decide whether the request is for this device or should be routed on to a different local peer it also advertises; this session has no such routing logic of its own, since it represents exactly one identity. */
44
44
  toDevice?: DeviceId;
@@ -1,7 +1,7 @@
1
- import { D as DeviceId, E as DataRequestFrame, N as Frame, T as DataHaveFrame, U as ManageError, V as ManageCommand, W as ManageOk, bt as RevocationEntry, en as TopologyPeers, et as PeerAdvert, f as CapabilityScope, lt as ProtocolVersion, p as CapabilityToken, w as DataEntriesFrame } from "../protocol-Bi2fzBCc.mjs";
2
- import { t as IdentityPort } from "../identity-Dc4xmwp3.mjs";
1
+ import { D as DeviceId, E as DataRequestFrame, N as Frame, T as DataHaveFrame, U as ManageError, V as ManageCommand, W as ManageOk, bt as RevocationEntry, et as PeerAdvert, f as CapabilityScope, lt as ProtocolVersion, nn as TopologyPeers, p as CapabilityToken, w as DataEntriesFrame } from "../protocol-DCDae3z4.mjs";
2
+ import { t as IdentityPort } from "../identity-DcaYgjXT.mjs";
3
3
  import { t as Clock } from "../clock-DiSx-WKM.mjs";
4
- import { r as Transport, t as Connection } from "../transport-GcJ4LYVE.mjs";
4
+ import { r as Transport, t as Connection } from "../transport-iLBxIkRS.mjs";
5
5
  //#region src/domain/mesh-session.d.ts
6
6
  /** How long to wait for the node's handshake before calling it unanswered. A relay-only node never sends one; that is a state to display, not an error. */
7
7
  export declare const HANDSHAKE_TIMEOUT_MS = 3000;
@@ -38,7 +38,7 @@ export interface IncomingManageRequest {
38
38
  command: ManageCommand;
39
39
  scope: CapabilityScope;
40
40
  token?: CapabilityToken;
41
- /** The device-id of the peer this request was relayed on behalf of, read directly from the enclosing relay-data-frame's own `from-device` field (stamped by the hub on every frame it forwards, wire-mesh#30) -- present only when the request arrived wrapped in a relay-data frame that carried one. A caller that needs to address a further request back to the same peer (one not sent via respond(), which already routes back correctly on its own) passes this as sendManageRequest's targetDevice. Never inferred from which relay pairing happens to be most recently established: a connection can hold several concurrent pairings (wire-mesh#30's own multiplexed adjacency map), so only the frame's own per-message addressing can say who actually sent it. */
41
+ /** The device-id of the peer this request was relayed on behalf of, as the end-to-end secure channel it arrived through authenticated it (spec/secure-channel.cddl): the identity that channel's handshake proved, never the `from-device` a hub stamps on relay-data, which a hub can set to anything. Present only when the request arrived through a relay pairing. A caller that needs to address a further request back to the same peer (one not sent via respond(), which already routes back correctly on its own) passes this as sendManageRequest's targetDevice. */
42
42
  fromDevice?: DeviceId;
43
43
  /** The device-id this request's relay-data frame was explicitly addressed to, read from its own `to-device` field -- present only when the request arrived relay-wrapped and the frame carried one. A caller fronting more than one locally-addressable device behind a single hub connection (a gateway advertising several local peers through the same relay pairing) uses this to decide whether the request is for this device or should be routed on to a different local peer it also advertises; this session has no such routing logic of its own, since it represents exactly one identity. */
44
44
  toDevice?: DeviceId;