wire-mesh-core 2.0.2 → 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 (97) 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.cjs +20 -0
  15. package/dist/domain/advert-extension-policy.d.cts +21 -0
  16. package/dist/domain/advert-extension-policy.d.mts +21 -0
  17. package/dist/domain/advert-extension-policy.mjs +19 -0
  18. package/dist/domain/bulk.d.cts +2 -2
  19. package/dist/domain/bulk.d.mts +2 -2
  20. package/dist/domain/capability-grant.d.cts +3 -3
  21. package/dist/domain/capability-grant.d.mts +3 -3
  22. package/dist/domain/capability-request.d.cts +2 -2
  23. package/dist/domain/capability-request.d.mts +2 -2
  24. package/dist/domain/data-sync.d.cts +2 -2
  25. package/dist/domain/data-sync.d.mts +2 -2
  26. package/dist/domain/device-id.d.cts +1 -1
  27. package/dist/domain/device-id.d.mts +1 -1
  28. package/dist/domain/direct-manage-request.d.cts +2 -2
  29. package/dist/domain/direct-manage-request.d.mts +2 -2
  30. package/dist/domain/gossip-expansion.d.cts +1 -1
  31. package/dist/domain/gossip-expansion.d.mts +1 -1
  32. package/dist/domain/gossip-extensions.cjs +2 -1
  33. package/dist/domain/gossip-extensions.d.cts +2 -0
  34. package/dist/domain/gossip-extensions.d.mts +2 -0
  35. package/dist/domain/gossip-extensions.mjs +2 -2
  36. package/dist/domain/grant-candidates.d.cts +1 -1
  37. package/dist/domain/grant-candidates.d.mts +1 -1
  38. package/dist/domain/handshake.d.cts +1 -1
  39. package/dist/domain/handshake.d.mts +1 -1
  40. package/dist/domain/mesh-session.cjs +327 -25
  41. package/dist/domain/mesh-session.d.cts +4 -4
  42. package/dist/domain/mesh-session.d.mts +4 -4
  43. package/dist/domain/mesh-session.mjs +328 -26
  44. package/dist/domain/notice-board.d.cts +3 -3
  45. package/dist/domain/notice-board.d.mts +3 -3
  46. package/dist/domain/path-trace.d.cts +1 -1
  47. package/dist/domain/path-trace.d.mts +1 -1
  48. package/dist/domain/peer-advert.d.cts +2 -2
  49. package/dist/domain/peer-advert.d.mts +2 -2
  50. package/dist/domain/relay-hub.cjs +11 -0
  51. package/dist/domain/relay-hub.d.cts +15 -2
  52. package/dist/domain/relay-hub.d.mts +15 -2
  53. package/dist/domain/relay-hub.mjs +11 -0
  54. package/dist/domain/relay-use-gate.d.cts +2 -2
  55. package/dist/domain/relay-use-gate.d.mts +2 -2
  56. package/dist/domain/revocation-view.d.cts +2 -2
  57. package/dist/domain/revocation-view.d.mts +2 -2
  58. package/dist/domain/room-rekey.d.cts +3 -3
  59. package/dist/domain/room-rekey.d.mts +3 -3
  60. package/dist/domain/room-token-verification.d.cts +1 -1
  61. package/dist/domain/room-token-verification.d.mts +1 -1
  62. package/dist/domain/room.d.cts +4 -4
  63. package/dist/domain/room.d.mts +4 -4
  64. package/dist/domain/shard-manifest.d.cts +1 -1
  65. package/dist/domain/shard-manifest.d.mts +1 -1
  66. package/dist/domain/tokens.d.cts +1 -1
  67. package/dist/domain/tokens.d.mts +1 -1
  68. package/dist/domain/topology-snapshot.d.cts +1 -1
  69. package/dist/domain/topology-snapshot.d.mts +1 -1
  70. package/dist/domain/topology.cjs +1 -1
  71. package/dist/domain/topology.d.cts +1 -1
  72. package/dist/domain/topology.d.mts +1 -1
  73. package/dist/domain/topology.mjs +1 -1
  74. package/dist/domain/webrtc-signaling.d.cts +2 -2
  75. package/dist/domain/webrtc-signaling.d.mts +2 -2
  76. package/dist/generated/protocol.cjs +18 -1
  77. package/dist/generated/protocol.d.cts +2 -2
  78. package/dist/generated/protocol.d.mts +2 -2
  79. package/dist/generated/protocol.mjs +17 -2
  80. package/dist/{gossip-extensions-BNiI44sU.mjs → gossip-extensions-B6WJjkAr.mjs} +1 -1
  81. package/dist/{gossip-extensions-DRVTHKnR.cjs → gossip-extensions-BBYgMZ2N.cjs} +6 -0
  82. package/dist/{identity-DImeXZcq.d.cts → identity-DVCNuFLG.d.cts} +1 -1
  83. package/dist/{identity-Dc4xmwp3.d.mts → identity-DcaYgjXT.d.mts} +1 -1
  84. package/dist/ports/identity.d.cts +1 -1
  85. package/dist/ports/identity.d.mts +1 -1
  86. package/dist/ports/transport.d.cts +1 -1
  87. package/dist/ports/transport.d.mts +1 -1
  88. package/dist/{protocol-Bi2fzBCc.d.cts → protocol-DCDae3z4.d.cts} +47 -1
  89. package/dist/{protocol-Bi2fzBCc.d.mts → protocol-DCDae3z4.d.mts} +47 -1
  90. package/dist/{room-token-verification-CnHNaA8J.d.mts → room-token-verification-CkGnFuQc.d.mts} +2 -2
  91. package/dist/{room-token-verification-CDXkm_Tg.d.cts → room-token-verification-PSv4eQ1x.d.cts} +2 -2
  92. package/dist/{tokens-0r9ipK1r.d.mts → tokens-CBiSYuzp.d.mts} +2 -2
  93. package/dist/{tokens-BDVxEvl2.d.cts → tokens-DqFu7iMX.d.cts} +2 -2
  94. package/dist/{transport-Cq_odHy3.d.cts → transport-CfW7fsGK.d.cts} +1 -1
  95. package/dist/{transport-GcJ4LYVE.d.mts → transport-iLBxIkRS.d.mts} +1 -1
  96. package/package.json +5 -1
  97. package/wasm-dist/wire_mesh_threshold_wasm_bg.wasm +0 -0
@@ -1,11 +1,12 @@
1
+ import { a as OWN_VERSION, i as CORE_VERSION_GOSSIP_KEY, n as TOPOLOGY_PEERS_GOSSIP_KEY, o as isVersionGetCommand, r as validateGossipExtensions, s as versionGetOutcome } from "../gossip-extensions-B6WJjkAr.mjs";
2
+ import { t as bytesEqual } from "../token-scope-Z4bmci4M.mjs";
3
+ import { signPeerAdvert, verifyPeerAdvert } from "./peer-advert.mjs";
1
4
  import { deviceIdToHex } from "./device-id.mjs";
2
5
  import { negotiate } from "./handshake.mjs";
3
6
  import { createAsyncQueue } from "./async-queue.mjs";
4
7
  import { createPingRoundTrips } from "./ping-round-trips.mjs";
5
8
  import { createTopologySnapshotTracker } from "./topology-snapshot.mjs";
6
- import { a as isVersionGetCommand, i as OWN_VERSION, n as validateGossipExtensions, o as versionGetOutcome, r as CORE_VERSION_GOSSIP_KEY, t as TOPOLOGY_PEERS_GOSSIP_KEY } from "../gossip-extensions-BNiI44sU.mjs";
7
- import { signPeerAdvert, verifyPeerAdvert } from "./peer-advert.mjs";
8
- import { tryDecodeFrame, wrapRelayData } from "../adapters/frame-codec.mjs";
9
+ import { messageFromFrame, tryDecodeFrame, wrapRelayData } from "../adapters/frame-codec.mjs";
9
10
  //#region src/domain/relay-pairing.ts
10
11
  function createRelayPairings() {
11
12
  const established = /* @__PURE__ */ new Map();
@@ -21,6 +22,260 @@ function createRelayPairings() {
21
22
  };
22
23
  }
23
24
  //#endregion
25
+ //#region src/domain/secure-channel.ts
26
+ /**
27
+ * 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.
28
+ *
29
+ * 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.
30
+ */
31
+ const NONCE_BYTE_LENGTH = 16;
32
+ const AES_KEY_BIT_LENGTH = 256;
33
+ const ECDH_SECRET_BIT_LENGTH = 256;
34
+ const GCM_IV_BYTE_LENGTH = 12;
35
+ /** The counter fills the last eight bytes of the IV; the four before it stay zero. */
36
+ const IV_COUNTER_OFFSET = 4;
37
+ const COUNTER_BYTE_LENGTH = 8;
38
+ const encoder = new TextEncoder();
39
+ const HELLO_CONTEXT = encoder.encode("wire-mesh/secure-hello/v1\0");
40
+ const CHANNEL_CONTEXT = encoder.encode("wire-mesh/secure-channel/v1\0");
41
+ const DATA_CONTEXT = encoder.encode("wire-mesh/secure-data/v1\0");
42
+ const LABEL_LOW_TO_HIGH = encoder.encode("lo-to-hi");
43
+ const LABEL_HIGH_TO_LOW = encoder.encode("hi-to-lo");
44
+ const ECDH_PARAMS = {
45
+ name: "ECDH",
46
+ namedCurve: "P-256"
47
+ };
48
+ function concat(...parts) {
49
+ const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
50
+ let offset = 0;
51
+ for (const part of parts) {
52
+ out.set(part, offset);
53
+ offset += part.length;
54
+ }
55
+ return out;
56
+ }
57
+ /** Bytewise order of two device-ids: negative when a sorts first. */
58
+ function compareBytes(a, b) {
59
+ for (let i = 0; i < Math.min(a.length, b.length); i += 1) {
60
+ const difference = (a[i] ?? 0) - (b[i] ?? 0);
61
+ if (difference !== 0) return difference;
62
+ }
63
+ return a.length - b.length;
64
+ }
65
+ function counterBytes(counter) {
66
+ const bytes = new Uint8Array(COUNTER_BYTE_LENGTH);
67
+ new DataView(bytes.buffer).setBigUint64(0, BigInt(counter), false);
68
+ return bytes;
69
+ }
70
+ /** Exactly what a hello's signature covers: see spec/secure-channel.cddl. */
71
+ function helloSigningInput(ephemeralKey, nonce, sender, recipient) {
72
+ return concat(HELLO_CONTEXT, ephemeralKey, nonce, sender, recipient);
73
+ }
74
+ /** Generates this side's ephemeral key pair and nonce and signs a hello for peer. */
75
+ async function beginHandshake(identity, peer) {
76
+ const pair = await crypto.subtle.generateKey(ECDH_PARAMS, false, ["deriveBits"]);
77
+ const ephemeralKey = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
78
+ const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTE_LENGTH));
79
+ const signature = await identity.sign(helloSigningInput(ephemeralKey, nonce, identity.deviceId, peer));
80
+ return {
81
+ peer,
82
+ privateKey: pair.privateKey,
83
+ hello: {
84
+ type: "secure-hello",
85
+ "ephemeral-key": ephemeralKey,
86
+ nonce,
87
+ "identity-key": identity.identityKey,
88
+ "to-device": peer,
89
+ signature
90
+ }
91
+ };
92
+ }
93
+ /**
94
+ * 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.
95
+ */
96
+ async function verifyHello(identity, hello) {
97
+ const key = hello["identity-key"];
98
+ try {
99
+ const sender = await identity.deriveDeviceId(key["public-key"]);
100
+ if (!bytesEqual(hello["to-device"], identity.deviceId)) return {
101
+ ok: false,
102
+ reason: "wrong_recipient"
103
+ };
104
+ return await identity.verify(key, helloSigningInput(hello["ephemeral-key"], hello.nonce, sender, identity.deviceId), hello.signature) ? {
105
+ ok: true,
106
+ peer: {
107
+ deviceId: sender,
108
+ identityKey: key
109
+ }
110
+ } : {
111
+ ok: false,
112
+ reason: "bad_signature"
113
+ };
114
+ } catch {
115
+ return {
116
+ ok: false,
117
+ reason: "identity_mismatch"
118
+ };
119
+ }
120
+ }
121
+ function ivFor(counter) {
122
+ const iv = new Uint8Array(GCM_IV_BYTE_LENGTH);
123
+ iv.set(counterBytes(counter), IV_COUNTER_OFFSET);
124
+ return iv;
125
+ }
126
+ function associatedData(sender, recipient, counter) {
127
+ return concat(DATA_CONTEXT, sender, recipient, counterBytes(counter));
128
+ }
129
+ async function deriveDirectionKey(secret, salt, low, high, label) {
130
+ const material = await crypto.subtle.importKey("raw", Uint8Array.from(secret), "HKDF", false, ["deriveKey"]);
131
+ return crypto.subtle.deriveKey({
132
+ name: "HKDF",
133
+ hash: "SHA-256",
134
+ salt: Uint8Array.from(salt),
135
+ info: concat(CHANNEL_CONTEXT, low, high, label)
136
+ }, material, {
137
+ name: "AES-GCM",
138
+ length: AES_KEY_BIT_LENGTH
139
+ }, false, ["encrypt", "decrypt"]);
140
+ }
141
+ /**
142
+ * 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.
143
+ *
144
+ * `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.
145
+ */
146
+ async function establishChannel(identity, pending, remote, peer) {
147
+ let secret;
148
+ try {
149
+ const remoteKey = await crypto.subtle.importKey("raw", Uint8Array.from(remote["ephemeral-key"]), ECDH_PARAMS, false, []);
150
+ secret = new Uint8Array(await crypto.subtle.deriveBits({
151
+ name: "ECDH",
152
+ public: remoteKey
153
+ }, pending.privateKey, ECDH_SECRET_BIT_LENGTH));
154
+ } catch {
155
+ return;
156
+ }
157
+ const localIsLow = compareBytes(identity.deviceId, peer.deviceId) < 0;
158
+ const low = localIsLow ? identity.deviceId : peer.deviceId;
159
+ const high = localIsLow ? peer.deviceId : identity.deviceId;
160
+ const salt = concat(localIsLow ? pending.hello.nonce : remote.nonce, localIsLow ? remote.nonce : pending.hello.nonce);
161
+ const lowToHigh = await deriveDirectionKey(secret, salt, low, high, LABEL_LOW_TO_HIGH);
162
+ const highToLow = await deriveDirectionKey(secret, salt, low, high, LABEL_HIGH_TO_LOW);
163
+ const sendKey = localIsLow ? lowToHigh : highToLow;
164
+ const receiveKey = localIsLow ? highToLow : lowToHigh;
165
+ let nextSendCounter = 0;
166
+ let highestReceived = -1;
167
+ let sealing = Promise.resolve();
168
+ let opening = Promise.resolve();
169
+ async function sealAt(frame, counter) {
170
+ return {
171
+ type: "secure-data",
172
+ counter,
173
+ ciphertext: new Uint8Array(await crypto.subtle.encrypt({
174
+ name: "AES-GCM",
175
+ iv: ivFor(counter),
176
+ additionalData: associatedData(identity.deviceId, peer.deviceId, counter)
177
+ }, sendKey, messageFromFrame(frame)))
178
+ };
179
+ }
180
+ async function openOne(data) {
181
+ if (data.counter <= highestReceived) return void 0;
182
+ let plaintext;
183
+ try {
184
+ plaintext = new Uint8Array(await crypto.subtle.decrypt({
185
+ name: "AES-GCM",
186
+ iv: ivFor(data.counter),
187
+ additionalData: associatedData(peer.deviceId, identity.deviceId, data.counter)
188
+ }, receiveKey, Uint8Array.from(data.ciphertext)));
189
+ } catch {
190
+ return;
191
+ }
192
+ highestReceived = data.counter;
193
+ return tryDecodeFrame(plaintext) ?? void 0;
194
+ }
195
+ return {
196
+ peer,
197
+ seal: async (frame) => {
198
+ const counter = nextSendCounter;
199
+ nextSendCounter += 1;
200
+ const sealed = sealing.then(async () => sealAt(frame, counter));
201
+ sealing = sealed.catch(() => void 0);
202
+ return sealed;
203
+ },
204
+ open: async (data) => {
205
+ const opened = opening.then(async () => openOne(data));
206
+ opening = opened.catch(() => void 0);
207
+ return opened;
208
+ }
209
+ };
210
+ }
211
+ //#endregion
212
+ //#region src/domain/relay-channels.ts
213
+ function createRelayChannels(identity, sendHello) {
214
+ const channels = /* @__PURE__ */ new Map();
215
+ /** 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. */
216
+ function start(peer) {
217
+ const key = deviceIdToHex(peer);
218
+ const settle = Promise.withResolvers();
219
+ const pending = beginHandshake(identity, peer);
220
+ const entry = {
221
+ status: "awaiting",
222
+ pending,
223
+ settle
224
+ };
225
+ channels.set(key, entry);
226
+ pending.then(async ({ hello }) => sendHello(peer, hello)).catch((error) => {
227
+ settle.reject(error instanceof Error ? error : new Error(String(error)));
228
+ if (channels.get(key) === entry) channels.delete(key);
229
+ });
230
+ return entry;
231
+ }
232
+ return {
233
+ ensure: async (peer) => {
234
+ const existing = channels.get(deviceIdToHex(peer));
235
+ if (existing?.status === "established") return existing.channel;
236
+ return (existing ?? start(peer)).settle.promise;
237
+ },
238
+ applyHello: async (hello) => {
239
+ const verdict = await verifyHello(identity, hello);
240
+ if (!verdict.ok) return;
241
+ const key = deviceIdToHex(verdict.peer.deviceId);
242
+ const current = channels.get(key);
243
+ const entry = current?.status === "awaiting" ? current : start(verdict.peer.deviceId);
244
+ const channel = await establishChannel(identity, await entry.pending, hello, verdict.peer);
245
+ if (channel === void 0) {
246
+ entry.settle.reject(/* @__PURE__ */ new Error("the peer sent an unusable ephemeral key"));
247
+ if (channels.get(key) === entry) channels.delete(key);
248
+ return;
249
+ }
250
+ channels.set(key, {
251
+ status: "established",
252
+ channel
253
+ });
254
+ entry.settle.resolve(channel);
255
+ },
256
+ open: async (data, claimedFrom) => {
257
+ if (claimedFrom === void 0) return;
258
+ const entry = channels.get(deviceIdToHex(claimedFrom));
259
+ if (entry?.status !== "established") return;
260
+ const frame = await entry.channel.open(data);
261
+ return frame === void 0 ? void 0 : {
262
+ frame,
263
+ from: entry.channel.peer.deviceId
264
+ };
265
+ },
266
+ reset: (reason) => {
267
+ for (const entry of channels.values()) if (entry.status === "awaiting") entry.settle.reject(new Error(reason));
268
+ channels.clear();
269
+ },
270
+ forget: (peer) => {
271
+ const key = deviceIdToHex(peer);
272
+ const entry = channels.get(key);
273
+ if (entry?.status === "awaiting") entry.settle.reject(/* @__PURE__ */ new Error("the relay pairing was lost"));
274
+ channels.delete(key);
275
+ }
276
+ };
277
+ }
278
+ //#endregion
24
279
  //#region src/domain/mesh-session.ts
25
280
  const MS_PER_SECOND = 1e3;
26
281
  /** 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. */
@@ -55,6 +310,15 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
55
310
  getRelayedDevices: () => relayPairings.list()
56
311
  });
57
312
  const pendingManageRequests = /* @__PURE__ */ new Map();
313
+ const relayChannels = createRelayChannels(identity, async (peer, hello) => {
314
+ if (connection === null) throw new Error("not connected");
315
+ frameLog.push({
316
+ direction: "sent",
317
+ frame: hello
318
+ });
319
+ await connection.send(wrapRelayData(hello, peer));
320
+ emit();
321
+ });
58
322
  const incomingQueue = createAsyncQueue();
59
323
  const revocationQueue = createAsyncQueue();
60
324
  const pingRoundTrips = createPingRoundTrips();
@@ -89,28 +353,32 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
89
353
  if (connection === null || state.status !== "connected") throw new Error("not connected");
90
354
  return connection;
91
355
  }
92
- /** 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. */
356
+ /** 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. */
93
357
  async function transmit(frame, viaRelay, toDevice) {
94
358
  if (connection === null) throw new Error("not connected");
95
- if (viaRelay) {
96
- await connection.send(wrapRelayData(frame, toDevice));
359
+ if (!viaRelay) {
360
+ await connection.send(frame);
97
361
  return;
98
362
  }
99
- await connection.send(frame);
363
+ if (toDevice === void 0) throw new Error("a relayed frame needs the device it is for");
364
+ const sealed = await (await relayChannels.ensure(toDevice)).seal(frame);
365
+ await connection.send(wrapRelayData(sealed, toDevice));
100
366
  }
101
- function applyManageResponse(frame) {
367
+ /** `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. */
368
+ function applyManageResponse(frame, from) {
102
369
  const requestId = frame["request-id"];
103
370
  const pending = pendingManageRequests.get(requestId);
104
- if (pending !== void 0) {
105
- pendingManageRequests.delete(requestId);
106
- pending.resolve(frame.outcome);
107
- }
371
+ if (pending === void 0) return;
372
+ const expected = pending.expectedFrom;
373
+ if (!(expected === void 0 ? from === void 0 : from !== void 0 && deviceIdToHex(from) === deviceIdToHex(expected))) return;
374
+ pendingManageRequests.delete(requestId);
375
+ pending.resolve(frame.outcome);
108
376
  }
109
- /** 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. */
110
- function applyManageRequest(frame, relayFrame) {
377
+ /** `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. */
378
+ function applyManageRequest(frame, origin) {
111
379
  const requestId = frame["request-id"];
112
- const fromDevice = relayFrame?.["from-device"];
113
- const toDevice = relayFrame?.["to-device"];
380
+ const fromDevice = origin?.fromDevice;
381
+ const toDevice = origin?.toDevice;
114
382
  const incoming = {
115
383
  requestId,
116
384
  command: frame.command,
@@ -128,7 +396,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
128
396
  direction: "sent",
129
397
  frame: response
130
398
  });
131
- await transmit(response, relayFrame !== void 0, fromDevice);
399
+ await transmit(response, origin !== void 0, fromDevice);
132
400
  emit();
133
401
  }
134
402
  };
@@ -141,13 +409,32 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
141
409
  async function applyFrame(frame) {
142
410
  if (frame.type === "relay-data") {
143
411
  const inner = tryDecodeFrame(frame.payload);
144
- if (inner !== null && (inner.type === "manage-request" || inner.type === "manage-response")) {
412
+ if (inner?.type === "secure-hello") {
145
413
  frameLog.push({
146
414
  direction: "received",
147
415
  frame: inner
148
416
  });
149
- if (inner.type === "manage-response") applyManageResponse(inner);
150
- else applyManageRequest(inner, frame);
417
+ await relayChannels.applyHello(inner);
418
+ return;
419
+ }
420
+ if (inner?.type === "secure-data") {
421
+ const opened = await relayChannels.open(inner, frame["from-device"]);
422
+ if (opened?.frame.type === "manage-response") {
423
+ frameLog.push({
424
+ direction: "received",
425
+ frame: opened.frame
426
+ });
427
+ applyManageResponse(opened.frame, opened.from);
428
+ } else if (opened?.frame.type === "manage-request") {
429
+ frameLog.push({
430
+ direction: "received",
431
+ frame: opened.frame
432
+ });
433
+ applyManageRequest(opened.frame, {
434
+ fromDevice: opened.from,
435
+ ...frame["to-device"] !== void 0 ? { toDevice: frame["to-device"] } : {}
436
+ });
437
+ }
151
438
  return;
152
439
  }
153
440
  frameLog.push({
@@ -271,6 +558,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
271
558
  /** 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. */
272
559
  async function wireUpConnection(link, address, localDomains) {
273
560
  connection = link;
561
+ relayChannels.reset("the connection changed before the channel was ready");
274
562
  localHandshakeSent = localHandshake(localDomains);
275
563
  handshake = { status: "pending" };
276
564
  state = {
@@ -368,20 +656,33 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
368
656
  const outcome = new Promise((resolve, reject) => {
369
657
  pendingManageRequests.set(requestId, {
370
658
  resolve,
371
- reject
659
+ reject,
660
+ expectedFrom: targetDevice
372
661
  });
373
662
  });
374
663
  frameLog.push({
375
664
  direction: "sent",
376
665
  frame
377
666
  });
378
- await transmit(frame, targetDevice !== void 0, targetDevice);
379
- emit();
380
- if (timeoutMs === void 0) return outcome;
381
- return Promise.race([outcome, new Promise((resolve) => {
667
+ const exchange = (async () => {
668
+ try {
669
+ await transmit(frame, targetDevice !== void 0, targetDevice);
670
+ } catch (error) {
671
+ pendingManageRequests.delete(requestId);
672
+ outcome.catch(() => void 0);
673
+ throw error;
674
+ }
675
+ emit();
676
+ return outcome;
677
+ })();
678
+ if (timeoutMs === void 0) return exchange;
679
+ return Promise.race([exchange, new Promise((resolve) => {
382
680
  setTimeout(() => {
383
681
  if (pendingManageRequests.delete(requestId)) {
384
- if (targetDevice !== void 0) relayPairings.remove(targetDevice);
682
+ if (targetDevice !== void 0) {
683
+ relayPairings.remove(targetDevice);
684
+ relayChannels.forget(targetDevice);
685
+ }
385
686
  resolve({
386
687
  result: "error",
387
688
  code: "timeout"
@@ -436,6 +737,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
436
737
  reconnectTimer = null;
437
738
  }
438
739
  rejectPendingManageRequests("connection closed before a response arrived");
740
+ relayChannels.reset("the session closed before the channel was ready");
439
741
  pingRoundTrips.rejectAll("connection closed before a pong arrived");
440
742
  if (connection !== null) await connection.close();
441
743
  if (state.status === "connected" || state.status === "connecting" || state.status === "reconnecting") state = {
@@ -1,8 +1,8 @@
1
- import { D as DeviceId, Ot as RoomNotice, T as DataHaveFrame, kt as RoomNoticeClaims, p as CapabilityToken } from "../protocol-Bi2fzBCc.cjs";
1
+ import { D as DeviceId, Ot as RoomNotice, T as DataHaveFrame, kt as RoomNoticeClaims, p as CapabilityToken } from "../protocol-DCDae3z4.cjs";
2
2
  import { KeyValueStorage } from "../ports/storage.cjs";
3
- import { t as IdentityPort } from "../identity-DImeXZcq.cjs";
3
+ import { t as IdentityPort } from "../identity-DVCNuFLG.cjs";
4
4
  import { t as Clock } from "../clock-DiSx-WKM.cjs";
5
- import { a as RevocationCheck } from "../tokens-BDVxEvl2.cjs";
5
+ import { a as RevocationCheck } from "../tokens-DqFu7iMX.cjs";
6
6
  import { RoomNoticeVerdictReason } from "./room.cjs";
7
7
  //#region src/domain/notice-board.d.ts
8
8
  /**
@@ -1,8 +1,8 @@
1
- import { D as DeviceId, Ot as RoomNotice, T as DataHaveFrame, kt as RoomNoticeClaims, p as CapabilityToken } from "../protocol-Bi2fzBCc.mjs";
1
+ import { D as DeviceId, Ot as RoomNotice, T as DataHaveFrame, kt as RoomNoticeClaims, p as CapabilityToken } from "../protocol-DCDae3z4.mjs";
2
2
  import { KeyValueStorage } from "../ports/storage.mjs";
3
- import { t as IdentityPort } from "../identity-Dc4xmwp3.mjs";
3
+ import { t as IdentityPort } from "../identity-DcaYgjXT.mjs";
4
4
  import { t as Clock } from "../clock-DiSx-WKM.mjs";
5
- import { a as RevocationCheck } from "../tokens-0r9ipK1r.mjs";
5
+ import { a as RevocationCheck } from "../tokens-CBiSYuzp.mjs";
6
6
  import { RoomNoticeVerdictReason } from "./room.mjs";
7
7
  //#region src/domain/notice-board.d.ts
8
8
  /**
@@ -1,4 +1,4 @@
1
- import { D as DeviceId, H as ManageCommandParams, Q as PathTrace, V as ManageCommand, f as CapabilityScope, p as CapabilityToken } from "../protocol-Bi2fzBCc.cjs";
1
+ import { D as DeviceId, H as ManageCommandParams, Q as PathTrace, V as ManageCommand, f as CapabilityScope, p as CapabilityToken } from "../protocol-DCDae3z4.cjs";
2
2
  import { t as Clock } from "../clock-DiSx-WKM.cjs";
3
3
  import { IncomingManageRequest, ManageOutcome, MeshSession } from "./mesh-session.cjs";
4
4
  //#region src/domain/path-trace.d.ts
@@ -1,4 +1,4 @@
1
- import { D as DeviceId, H as ManageCommandParams, Q as PathTrace, V as ManageCommand, f as CapabilityScope, p as CapabilityToken } from "../protocol-Bi2fzBCc.mjs";
1
+ import { D as DeviceId, H as ManageCommandParams, Q as PathTrace, V as ManageCommand, f as CapabilityScope, p as CapabilityToken } from "../protocol-DCDae3z4.mjs";
2
2
  import { t as Clock } from "../clock-DiSx-WKM.mjs";
3
3
  import { IncomingManageRequest, ManageOutcome, MeshSession } from "./mesh-session.mjs";
4
4
  //#region src/domain/path-trace.d.ts
@@ -1,5 +1,5 @@
1
- import { B as IdentityKey, D as DeviceId, et as PeerAdvert } from "../protocol-Bi2fzBCc.cjs";
2
- import { t as IdentityPort } from "../identity-DImeXZcq.cjs";
1
+ import { B as IdentityKey, D as DeviceId, et as PeerAdvert } from "../protocol-DCDae3z4.cjs";
2
+ import { t as IdentityPort } from "../identity-DVCNuFLG.cjs";
3
3
  //#region src/domain/peer-advert.d.ts
4
4
  /** peer-advert's own signature field, the one entry the signature does not cover, named once here so the signing input and the reserved-extension-key guard cannot drift apart. */
5
5
  export declare const PEER_ADVERT_SIGNATURE_KEY = "signature";
@@ -1,5 +1,5 @@
1
- import { B as IdentityKey, D as DeviceId, et as PeerAdvert } from "../protocol-Bi2fzBCc.mjs";
2
- import { t as IdentityPort } from "../identity-Dc4xmwp3.mjs";
1
+ import { B as IdentityKey, D as DeviceId, et as PeerAdvert } from "../protocol-DCDae3z4.mjs";
2
+ import { t as IdentityPort } from "../identity-DcaYgjXT.mjs";
3
3
  //#region src/domain/peer-advert.d.ts
4
4
  /** peer-advert's own signature field, the one entry the signature does not cover, named once here so the signing input and the reserved-extension-key guard cannot drift apart. */
5
5
  export declare const PEER_ADVERT_SIGNATURE_KEY = "signature";
@@ -1,5 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_domain_peer_advert = require("./peer-advert.cjs");
3
+ const require_domain_advert_extension_policy = require("./advert-extension-policy.cjs");
3
4
  //#region src/domain/relay-hub.ts
4
5
  const HEX_RADIX = 16;
5
6
  const HEX_DIGITS_PER_BYTE = 2;
@@ -115,6 +116,16 @@ function createRelayHub(options) {
115
116
  const accepted = [];
116
117
  for (const advert of frame.peers) {
117
118
  if (!await require_domain_peer_advert.verifyPeerAdvert(options.identity, advert)) continue;
119
+ if (options.extensionPolicy !== void 0) {
120
+ const verdict = require_domain_advert_extension_policy.checkAdvertExtensions(advert, options.extensionPolicy);
121
+ if (!verdict.ok) {
122
+ options.onAdvertRefused?.({
123
+ device: advert.device,
124
+ key: verdict.key
125
+ });
126
+ continue;
127
+ }
128
+ }
118
129
  const key = deviceKey(advert.device);
119
130
  const previous = devices.get(key);
120
131
  if (previous !== void 0 && !supersedes(previous, advert, connection)) continue;
@@ -1,5 +1,6 @@
1
- import { D as DeviceId, N as Frame, et as PeerAdvert } from "../protocol-Bi2fzBCc.cjs";
2
- import { t as Connection } from "../transport-Cq_odHy3.cjs";
1
+ import { D as DeviceId, N as Frame, et as PeerAdvert } from "../protocol-DCDae3z4.cjs";
2
+ import { t as Connection } from "../transport-CfW7fsGK.cjs";
3
+ import { AdvertExtensionPolicy } from "./advert-extension-policy.cjs";
3
4
  import { PeerAdvertVerifier } from "./peer-advert.cjs";
4
5
  //#region src/domain/relay-hub.d.ts
5
6
  /**
@@ -20,9 +21,21 @@ export interface RelayConnectionRestore {
20
21
  readonly connection: Readonly<Connection>;
21
22
  readonly state: RelayConnectionState;
22
23
  }
24
+ /** An advert the hub refused to carry because of what its extension tail holds. */
25
+ export interface AdvertRefusal {
26
+ readonly device: DeviceId;
27
+ /** The extension key that was not allowed, or whose value failed its validator. */
28
+ readonly key: string;
29
+ }
23
30
  export interface RelayHubOptions {
24
31
  /** The crypto primitives this hub verifies every gossiped advert with (wire-mesh#225). Narrowed to verify/deriveDeviceId rather than a full IdentityPort because an advert is self-certifying: a hub checks one against the key the advert itself carries, and never needs a signing identity of its own to do it. */
25
32
  readonly identity: Readonly<PeerAdvertVerifier>;
33
+ /**
34
+ * Which extension entries of an advert this hub will carry. Absent, every extension is carried, which is right for a hub whose every client belongs to one mesh. A hub open to anyone should set it: an advert is forwarded whole to every client, so this is the only control the hub has over what an application publishes to strangers.
35
+ */
36
+ readonly extensionPolicy?: AdvertExtensionPolicy;
37
+ /** Called with each advert refused by extensionPolicy, so the host can report it; the advert's sender is given no reply, since the transport spec defines none. */
38
+ readonly onAdvertRefused?: (refusal: Readonly<AdvertRefusal>) => void;
26
39
  /** Called whenever the value exportConnection returns for a connection changes: it gossiped an advert, gained or lost a pairing, or had one of its adverts taken over by another connection. A host whose own instance can be torn down while its connections stay open (the Cloudflare hub's hibernating Durable Object) reads the new value back with exportConnection, persists it against the connection, and hands it to a later instance through restoreConnections. */
27
40
  readonly onConnectionStateChanged?: (connection: Readonly<Connection>) => void;
28
41
  }
@@ -1,5 +1,6 @@
1
- import { D as DeviceId, N as Frame, et as PeerAdvert } from "../protocol-Bi2fzBCc.mjs";
2
- import { t as Connection } from "../transport-GcJ4LYVE.mjs";
1
+ import { D as DeviceId, N as Frame, et as PeerAdvert } from "../protocol-DCDae3z4.mjs";
2
+ import { t as Connection } from "../transport-iLBxIkRS.mjs";
3
+ import { AdvertExtensionPolicy } from "./advert-extension-policy.mjs";
3
4
  import { PeerAdvertVerifier } from "./peer-advert.mjs";
4
5
  //#region src/domain/relay-hub.d.ts
5
6
  /**
@@ -20,9 +21,21 @@ export interface RelayConnectionRestore {
20
21
  readonly connection: Readonly<Connection>;
21
22
  readonly state: RelayConnectionState;
22
23
  }
24
+ /** An advert the hub refused to carry because of what its extension tail holds. */
25
+ export interface AdvertRefusal {
26
+ readonly device: DeviceId;
27
+ /** The extension key that was not allowed, or whose value failed its validator. */
28
+ readonly key: string;
29
+ }
23
30
  export interface RelayHubOptions {
24
31
  /** The crypto primitives this hub verifies every gossiped advert with (wire-mesh#225). Narrowed to verify/deriveDeviceId rather than a full IdentityPort because an advert is self-certifying: a hub checks one against the key the advert itself carries, and never needs a signing identity of its own to do it. */
25
32
  readonly identity: Readonly<PeerAdvertVerifier>;
33
+ /**
34
+ * Which extension entries of an advert this hub will carry. Absent, every extension is carried, which is right for a hub whose every client belongs to one mesh. A hub open to anyone should set it: an advert is forwarded whole to every client, so this is the only control the hub has over what an application publishes to strangers.
35
+ */
36
+ readonly extensionPolicy?: AdvertExtensionPolicy;
37
+ /** Called with each advert refused by extensionPolicy, so the host can report it; the advert's sender is given no reply, since the transport spec defines none. */
38
+ readonly onAdvertRefused?: (refusal: Readonly<AdvertRefusal>) => void;
26
39
  /** Called whenever the value exportConnection returns for a connection changes: it gossiped an advert, gained or lost a pairing, or had one of its adverts taken over by another connection. A host whose own instance can be torn down while its connections stay open (the Cloudflare hub's hibernating Durable Object) reads the new value back with exportConnection, persists it against the connection, and hands it to a later instance through restoreConnections. */
27
40
  readonly onConnectionStateChanged?: (connection: Readonly<Connection>) => void;
28
41
  }
@@ -1,4 +1,5 @@
1
1
  import { verifyPeerAdvert } from "./peer-advert.mjs";
2
+ import { checkAdvertExtensions } from "./advert-extension-policy.mjs";
2
3
  //#region src/domain/relay-hub.ts
3
4
  const HEX_RADIX = 16;
4
5
  const HEX_DIGITS_PER_BYTE = 2;
@@ -114,6 +115,16 @@ function createRelayHub(options) {
114
115
  const accepted = [];
115
116
  for (const advert of frame.peers) {
116
117
  if (!await verifyPeerAdvert(options.identity, advert)) continue;
118
+ if (options.extensionPolicy !== void 0) {
119
+ const verdict = checkAdvertExtensions(advert, options.extensionPolicy);
120
+ if (!verdict.ok) {
121
+ options.onAdvertRefused?.({
122
+ device: advert.device,
123
+ key: verdict.key
124
+ });
125
+ continue;
126
+ }
127
+ }
117
128
  const key = deviceKey(advert.device);
118
129
  const previous = devices.get(key);
119
130
  if (previous !== void 0 && !supersedes(previous, advert, connection)) continue;
@@ -1,5 +1,5 @@
1
- import { N as Frame } from "../protocol-Bi2fzBCc.cjs";
2
- import { t as Connection } from "../transport-Cq_odHy3.cjs";
1
+ import { N as Frame } from "../protocol-DCDae3z4.cjs";
2
+ import { t as Connection } from "../transport-CfW7fsGK.cjs";
3
3
  //#region src/domain/relay-use-gate.d.ts
4
4
  export interface RelayUseAuthorizationTracker {
5
5
  /** True once authorize() has been called for this exact connection and revoke() has not been called since. */
@@ -1,5 +1,5 @@
1
- import { N as Frame } from "../protocol-Bi2fzBCc.mjs";
2
- import { t as Connection } from "../transport-GcJ4LYVE.mjs";
1
+ import { N as Frame } from "../protocol-DCDae3z4.mjs";
2
+ import { t as Connection } from "../transport-iLBxIkRS.mjs";
3
3
  //#region src/domain/relay-use-gate.d.ts
4
4
  export interface RelayUseAuthorizationTracker {
5
5
  /** True once authorize() has been called for this exact connection and revoke() has not been called since. */
@@ -1,5 +1,5 @@
1
- import { bt as RevocationEntry } from "../protocol-Bi2fzBCc.cjs";
2
- import { a as RevocationCheck, d as VerifyRevocationEntryOptions, o as RevocationEntryVerdict } from "../tokens-BDVxEvl2.cjs";
1
+ import { bt as RevocationEntry } from "../protocol-DCDae3z4.cjs";
2
+ import { a as RevocationCheck, d as VerifyRevocationEntryOptions, o as RevocationEntryVerdict } from "../tokens-DqFu7iMX.cjs";
3
3
  //#region src/domain/revocation-view.d.ts
4
4
  /**
5
5
  * An in-memory RevocationCheck fed by ingested revocation-announce frames. Keyed by token-id alone, per management.cddl's contract: a token-id can legitimately carry multiple recorded entries from different issuers (the token's own issuer, and/or any number of parties holding a delegated manage:revoke authorization over it) -- entriesFor returns every one of them unfiltered, since checking which actually apply (issuer-match, or a verified authorization) is verifyTokenChain's own obligation, not this store's.
@@ -1,5 +1,5 @@
1
- import { bt as RevocationEntry } from "../protocol-Bi2fzBCc.mjs";
2
- import { a as RevocationCheck, d as VerifyRevocationEntryOptions, o as RevocationEntryVerdict } from "../tokens-0r9ipK1r.mjs";
1
+ import { bt as RevocationEntry } from "../protocol-DCDae3z4.mjs";
2
+ import { a as RevocationCheck, d as VerifyRevocationEntryOptions, o as RevocationEntryVerdict } from "../tokens-CBiSYuzp.mjs";
3
3
  //#region src/domain/revocation-view.d.ts
4
4
  /**
5
5
  * An in-memory RevocationCheck fed by ingested revocation-announce frames. Keyed by token-id alone, per management.cddl's contract: a token-id can legitimately carry multiple recorded entries from different issuers (the token's own issuer, and/or any number of parties holding a delegated manage:revoke authorization over it) -- entriesFor returns every one of them unfiltered, since checking which actually apply (issuer-match, or a verified authorization) is verifyTokenChain's own obligation, not this store's.