wire-mesh-core 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -67,8 +67,7 @@ function connectionFromByteStream(stream, options = { opened: false }) {
67
67
  },
68
68
  receive: () => frames,
69
69
  close: async () => {
70
- await writer.close();
71
- await reader.cancel();
70
+ await Promise.allSettled([writer.close(), reader.cancel()]);
72
71
  }
73
72
  };
74
73
  }
@@ -1,5 +1,5 @@
1
1
  import { N as Frame } from "../protocol-DCDae3z4.cjs";
2
- import { t as Connection } from "../transport-CfW7fsGK.cjs";
2
+ import { t as Connection } from "../transport-DIXjoQKn.cjs";
3
3
  //#region src/adapters/byte-stream-connection.d.ts
4
4
  /** The largest frame body a peer may announce in a length prefix, matching the 100 MiB the `ws` package applies to one message on the same hub, so a WebSocket peer and a byte-stream peer are bounded alike. Without a bound a peer-controlled 4 GiB prefix makes the reader buffer until memory runs out. */
5
5
  export declare const MAX_FRAME_BYTES: number;
@@ -1,5 +1,5 @@
1
1
  import { N as Frame } from "../protocol-DCDae3z4.mjs";
2
- import { t as Connection } from "../transport-iLBxIkRS.mjs";
2
+ import { t as Connection } from "../transport-B0GywEzG.mjs";
3
3
  //#region src/adapters/byte-stream-connection.d.ts
4
4
  /** The largest frame body a peer may announce in a length prefix, matching the 100 MiB the `ws` package applies to one message on the same hub, so a WebSocket peer and a byte-stream peer are bounded alike. Without a bound a peer-controlled 4 GiB prefix makes the reader buffer until memory runs out. */
5
5
  export declare const MAX_FRAME_BYTES: number;
@@ -66,8 +66,7 @@ function connectionFromByteStream(stream, options = { opened: false }) {
66
66
  },
67
67
  receive: () => frames,
68
68
  close: async () => {
69
- await writer.close();
70
- await reader.cancel();
69
+ await Promise.allSettled([writer.close(), reader.cancel()]);
71
70
  }
72
71
  };
73
72
  }
@@ -1,4 +1,4 @@
1
- import { r as Transport } from "../transport-CfW7fsGK.cjs";
1
+ import { r as Transport } from "../transport-DIXjoQKn.cjs";
2
2
  //#region src/adapters/tcp-transport.d.ts
3
3
  /** A Node net.Socket-based Transport: length-prefixed, CBOR-encoded frames over plain TCP -- matching Cascade's own transport shape, since interop with Cascade nodes is wire-mesh's stated goal. Framing (not TLS) is this adapter's own concern; a TLS-terminated variant is a separate adapter behind the same Transport contract. */
4
4
  export declare function createTcpTransport(): Transport;
@@ -1,4 +1,4 @@
1
- import { r as Transport } from "../transport-iLBxIkRS.mjs";
1
+ import { r as Transport } from "../transport-B0GywEzG.mjs";
2
2
  //#region src/adapters/tcp-transport.d.ts
3
3
  /** A Node net.Socket-based Transport: length-prefixed, CBOR-encoded frames over plain TCP -- matching Cascade's own transport shape, since interop with Cascade nodes is wire-mesh's stated goal. Framing (not TLS) is this adapter's own concern; a TLS-terminated variant is a separate adapter behind the same Transport contract. */
4
4
  export declare function createTcpTransport(): Transport;
@@ -1,4 +1,4 @@
1
- import { r as Transport } from "../transport-CfW7fsGK.cjs";
1
+ import { r as Transport } from "../transport-DIXjoQKn.cjs";
2
2
  //#region src/adapters/tls-transport.d.ts
3
3
  export interface TlsIdentity {
4
4
  certificatePem: string;
@@ -1,4 +1,4 @@
1
- import { r as Transport } from "../transport-iLBxIkRS.mjs";
1
+ import { r as Transport } from "../transport-B0GywEzG.mjs";
2
2
  //#region src/adapters/tls-transport.d.ts
3
3
  export interface TlsIdentity {
4
4
  certificatePem: string;
@@ -1,5 +1,5 @@
1
1
  import { D as DeviceId, N as Frame, V as ManageCommand, f as CapabilityScope, p as CapabilityToken } from "../protocol-DCDae3z4.cjs";
2
- import { t as Connection } from "../transport-CfW7fsGK.cjs";
2
+ import { t as Connection } from "../transport-DIXjoQKn.cjs";
3
3
  import { t as KeyValueStorage } from "../storage-B403CRyu.cjs";
4
4
  import { IncomingManageRequest, MeshSession } from "./mesh-session.cjs";
5
5
  //#region src/domain/bulk.d.ts
@@ -1,5 +1,5 @@
1
1
  import { D as DeviceId, N as Frame, V as ManageCommand, f as CapabilityScope, p as CapabilityToken } from "../protocol-DCDae3z4.mjs";
2
- import { t as Connection } from "../transport-iLBxIkRS.mjs";
2
+ import { t as Connection } from "../transport-B0GywEzG.mjs";
3
3
  import { t as KeyValueStorage } from "../storage-B403CRyu.mjs";
4
4
  import { IncomingManageRequest, MeshSession } from "./mesh-session.mjs";
5
5
  //#region src/domain/bulk.d.ts
@@ -1,5 +1,5 @@
1
1
  import { D as DeviceId, V as ManageCommand, f as CapabilityScope, p as CapabilityToken } from "../protocol-DCDae3z4.cjs";
2
- import { r as Transport, t as Connection } from "../transport-CfW7fsGK.cjs";
2
+ import { r as Transport, t as Connection } from "../transport-DIXjoQKn.cjs";
3
3
  import { IncomingManageRequest, ManageOutcome } from "./mesh-session.cjs";
4
4
  //#region src/domain/direct-manage-request.d.ts
5
5
  /** Reads exactly one frame off a freshly-accepted, bare connection and, if it's a manage-request, returns an IncomingManageRequest ready to hand to the same application-level dispatch logic session.incomingManageRequests already feeds elsewhere -- no handshake or gossip is ever read or sent on this connection (wire-mesh#38: the manage-request/manage-response exchange has no dependency on handshake state at the dispatch layer, confirmed against mesh-session.ts's own applyManageRequest/applyFrame). respond() sends the manage-response directly and closes the connection, since a one-off request/response is this connection's entire purpose -- unlike a real MeshSession, there is nothing further to do with it afterwards. Resolves null, without closing the connection (that's the caller's call), for any other first frame or if the connection ends before one arrives: interpreting either case is a Transport.listen() caller's own business, e.g. peeking the first frame to route between this path and acceptMeshSession's own handshake path. */
@@ -1,5 +1,5 @@
1
1
  import { D as DeviceId, V as ManageCommand, f as CapabilityScope, p as CapabilityToken } from "../protocol-DCDae3z4.mjs";
2
- import { r as Transport, t as Connection } from "../transport-iLBxIkRS.mjs";
2
+ import { r as Transport, t as Connection } from "../transport-B0GywEzG.mjs";
3
3
  import { IncomingManageRequest, ManageOutcome } from "./mesh-session.mjs";
4
4
  //#region src/domain/direct-manage-request.d.ts
5
5
  /** Reads exactly one frame off a freshly-accepted, bare connection and, if it's a manage-request, returns an IncomingManageRequest ready to hand to the same application-level dispatch logic session.incomingManageRequests already feeds elsewhere -- no handshake or gossip is ever read or sent on this connection (wire-mesh#38: the manage-request/manage-response exchange has no dependency on handshake state at the dispatch layer, confirmed against mesh-session.ts's own applyManageRequest/applyFrame). respond() sends the manage-response directly and closes the connection, since a one-off request/response is this connection's entire purpose -- unlike a real MeshSession, there is nothing further to do with it afterwards. Resolves null, without closing the connection (that's the caller's call), for any other first frame or if the connection ends before one arrives: interpreting either case is a Transport.listen() caller's own business, e.g. peeking the first frame to route between this path and acceptMeshSession's own handshake path. */
@@ -19,6 +19,9 @@ function createRelayPairings() {
19
19
  remove: (device) => {
20
20
  established.delete(require_domain_device_id.deviceIdToHex(device));
21
21
  },
22
+ clear: () => {
23
+ established.clear();
24
+ },
22
25
  list: () => [...established.values()]
23
26
  };
24
27
  }
@@ -501,6 +504,10 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
501
504
  handshake
502
505
  };
503
506
  }
507
+ /** The address to dial after `link` ended: the one the adapter says reaches the same peer now, if it says anything, else the one this session dialled. */
508
+ function redialAddress(link, dialled) {
509
+ return link.redialAddress?.() ?? dialled;
510
+ }
504
511
  function handleDisconnect(reason, address, localDomains) {
505
512
  if (feedCancelled) return;
506
513
  rejectPendingManageRequests("disconnected before a response arrived");
@@ -531,14 +538,19 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
531
538
  emit();
532
539
  }
533
540
  async function consume(link, address, localDomains) {
541
+ let heardFromPeer = false;
534
542
  for await (const frame of link.receive()) {
535
543
  if (feedCancelled) return;
544
+ if (!heardFromPeer) {
545
+ heardFromPeer = true;
546
+ attempt = 0;
547
+ }
536
548
  await applyFrame(frame);
537
549
  await onFrame?.(link, frame);
538
550
  emit();
539
551
  }
540
552
  onSessionEnd?.(link);
541
- if (state.status === "connected") handleDisconnect("node closed the connection", address, localDomains);
553
+ if (state.status === "connected") handleDisconnect("node closed the connection", redialAddress(link, address), localDomains);
542
554
  }
543
555
  /** Builds this side's own self-advert and signs it with this node's own key (wire-mesh#225), which is what makes it usable by a receiver that learned of it through a hub or a gateway rather than directly from here. this node's own directly-reachable addresses (wire-mesh#38) are advertised, or none for a caller with nothing to offer (a browser client, which cannot accept inbound connections) -- either is an honest advert, not a stopgap. extensions merge onto peer-advert's own open `* tstr => any` tail -- the mechanism sendGossipUpdate uses to keep a gossiped fact (presence status, an accept/refuse policy, or any future domain's own) live over the connection's lifetime, and every such key is covered by the signature. Extensions, CORE_VERSION_GOSSIP_KEY, and topology/peers are all spread before the mandatory fields (never after) so a caller-supplied key of the same name can never shadow them on the wire -- validateGossipExtensions already rejects every such collision loudly, but the field order is kept safe in its own right rather than relying solely on the guard staying in sync. topology/peers (wire-mesh#180) is recomputed fresh on every call, the same "always current, never cached" treatment snapshot-seconds already gets, since a session's own connection identity and relay pairings can change between one self-advert and the next. */
544
556
  async function buildSelfAdvert(extensions) {
@@ -560,6 +572,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
560
572
  async function wireUpConnection(link, address, localDomains) {
561
573
  connection = link;
562
574
  relayChannels.reset("the connection changed before the channel was ready");
575
+ relayPairings.clear();
563
576
  localHandshakeSent = localHandshake(localDomains);
564
577
  handshake = { status: "pending" };
565
578
  state = {
@@ -592,7 +605,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
592
605
  }
593
606
  }, HANDSHAKE_TIMEOUT_MS);
594
607
  consume(connection, address, localDomains).catch((error) => {
595
- if (state.status === "connected") handleDisconnect(error instanceof Error ? error.message : String(error), address, localDomains);
608
+ if (state.status === "connected") handleDisconnect(error instanceof Error ? error.message : String(error), redialAddress(link, address), localDomains);
596
609
  });
597
610
  }
598
611
  async function doConnect(address, localDomains) {
@@ -1,5 +1,5 @@
1
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 { r as Transport, t as Connection } from "../transport-CfW7fsGK.cjs";
2
+ import { r as Transport, t as Connection } from "../transport-DIXjoQKn.cjs";
3
3
  import { t as IdentityPort } from "../identity-DVCNuFLG.cjs";
4
4
  import { t as Clock } from "../clock-DiSx-WKM.cjs";
5
5
  //#region src/domain/mesh-session.d.ts
@@ -1,5 +1,5 @@
1
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 { r as Transport, t as Connection } from "../transport-iLBxIkRS.mjs";
2
+ import { r as Transport, t as Connection } from "../transport-B0GywEzG.mjs";
3
3
  import { t as IdentityPort } from "../identity-DcaYgjXT.mjs";
4
4
  import { t as Clock } from "../clock-DiSx-WKM.mjs";
5
5
  //#region src/domain/mesh-session.d.ts
@@ -18,6 +18,9 @@ function createRelayPairings() {
18
18
  remove: (device) => {
19
19
  established.delete(deviceIdToHex(device));
20
20
  },
21
+ clear: () => {
22
+ established.clear();
23
+ },
21
24
  list: () => [...established.values()]
22
25
  };
23
26
  }
@@ -500,6 +503,10 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
500
503
  handshake
501
504
  };
502
505
  }
506
+ /** The address to dial after `link` ended: the one the adapter says reaches the same peer now, if it says anything, else the one this session dialled. */
507
+ function redialAddress(link, dialled) {
508
+ return link.redialAddress?.() ?? dialled;
509
+ }
503
510
  function handleDisconnect(reason, address, localDomains) {
504
511
  if (feedCancelled) return;
505
512
  rejectPendingManageRequests("disconnected before a response arrived");
@@ -530,14 +537,19 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
530
537
  emit();
531
538
  }
532
539
  async function consume(link, address, localDomains) {
540
+ let heardFromPeer = false;
533
541
  for await (const frame of link.receive()) {
534
542
  if (feedCancelled) return;
543
+ if (!heardFromPeer) {
544
+ heardFromPeer = true;
545
+ attempt = 0;
546
+ }
535
547
  await applyFrame(frame);
536
548
  await onFrame?.(link, frame);
537
549
  emit();
538
550
  }
539
551
  onSessionEnd?.(link);
540
- if (state.status === "connected") handleDisconnect("node closed the connection", address, localDomains);
552
+ if (state.status === "connected") handleDisconnect("node closed the connection", redialAddress(link, address), localDomains);
541
553
  }
542
554
  /** Builds this side's own self-advert and signs it with this node's own key (wire-mesh#225), which is what makes it usable by a receiver that learned of it through a hub or a gateway rather than directly from here. this node's own directly-reachable addresses (wire-mesh#38) are advertised, or none for a caller with nothing to offer (a browser client, which cannot accept inbound connections) -- either is an honest advert, not a stopgap. extensions merge onto peer-advert's own open `* tstr => any` tail -- the mechanism sendGossipUpdate uses to keep a gossiped fact (presence status, an accept/refuse policy, or any future domain's own) live over the connection's lifetime, and every such key is covered by the signature. Extensions, CORE_VERSION_GOSSIP_KEY, and topology/peers are all spread before the mandatory fields (never after) so a caller-supplied key of the same name can never shadow them on the wire -- validateGossipExtensions already rejects every such collision loudly, but the field order is kept safe in its own right rather than relying solely on the guard staying in sync. topology/peers (wire-mesh#180) is recomputed fresh on every call, the same "always current, never cached" treatment snapshot-seconds already gets, since a session's own connection identity and relay pairings can change between one self-advert and the next. */
543
555
  async function buildSelfAdvert(extensions) {
@@ -559,6 +571,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
559
571
  async function wireUpConnection(link, address, localDomains) {
560
572
  connection = link;
561
573
  relayChannels.reset("the connection changed before the channel was ready");
574
+ relayPairings.clear();
562
575
  localHandshakeSent = localHandshake(localDomains);
563
576
  handshake = { status: "pending" };
564
577
  state = {
@@ -591,7 +604,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
591
604
  }
592
605
  }, HANDSHAKE_TIMEOUT_MS);
593
606
  consume(connection, address, localDomains).catch((error) => {
594
- if (state.status === "connected") handleDisconnect(error instanceof Error ? error.message : String(error), address, localDomains);
607
+ if (state.status === "connected") handleDisconnect(error instanceof Error ? error.message : String(error), redialAddress(link, address), localDomains);
595
608
  });
596
609
  }
597
610
  async function doConnect(address, localDomains) {
@@ -1,8 +1,10 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let cbor2 = require("cbor2");
2
3
  //#region src/domain/pinned-address.ts
3
4
  const PIN_FRAGMENT = "#sha256=";
4
5
  const HASH_SEPARATOR = ",";
5
6
  const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
7
+ const HEX_RADIX = 16;
6
8
  function hexToBytes(hex) {
7
9
  const bytes = new Uint8Array(hex.length / 2);
8
10
  for (let index = 0; index < bytes.length; index++) bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
@@ -34,7 +36,56 @@ function parsePinnedAddress(address) {
34
36
  sha256: hashes.map(hexToBytes)
35
37
  };
36
38
  }
39
+ /** The bytes in a SHA-256 hash. */
40
+ const SHA256_BYTES = 32;
41
+ /** The most hashes a node may announce at once. A browser is asked to accept any of them, and a longer list is a node misbehaving rather than one advertising a schedule. */
42
+ const MAX_ANNOUNCED_HASHES = 8;
43
+ /**
44
+ * The message a node sends a client, on a stream it opens to that client, to say which certificates it serves now and will serve next: a CBOR map with one key, `sha256`, holding an array of 32-byte hashes in the order they take over. It is authentic because it arrives on the session the client pinned, so no signature is needed.
45
+ */
46
+ function encodeCertificateHashes(sha256Hex) {
47
+ return (0, cbor2.encode)({ sha256: sha256Hex.map(hexToBytes) }, cbor2.cdeEncodeOptions);
48
+ }
49
+ /**
50
+ * Reads a message made by `encodeCertificateHashes`.
51
+ * @throws Error when it is not a map with a `sha256` array of one to MAX_ANNOUNCED_HASHES byte strings of 32 bytes each.
52
+ */
53
+ function decodeCertificateHashes(bytes) {
54
+ const decoded = (0, cbor2.decode)(bytes, cbor2.cdeDecodeOptions);
55
+ if (typeof decoded !== "object" || decoded === null || !("sha256" in decoded) || !Array.isArray(decoded.sha256)) throw new Error("expected a map with a sha256 array");
56
+ const announced = decoded.sha256;
57
+ if (announced.length === 0 || announced.length > 8) throw new Error(`expected 1 to ${String(8)} hashes, got ${String(announced.length)}`);
58
+ const hashes = [];
59
+ for (const hash of announced) {
60
+ if (!(hash instanceof Uint8Array) || hash.length !== SHA256_BYTES) throw new Error(`each hash must be ${String(SHA256_BYTES)} bytes`);
61
+ hashes.push(new Uint8Array(hash));
62
+ }
63
+ return hashes;
64
+ }
65
+ /** `hashes` followed by those of `more` that are not already in it, so a client can pin what it was given and what it has learned since. */
66
+ function mergeHashes(hashes, more) {
67
+ const known = new Set(hashes.map(bytesToHex));
68
+ const merged = [...hashes];
69
+ for (const hash of more) if (!known.has(bytesToHex(hash))) {
70
+ known.add(bytesToHex(hash));
71
+ merged.push(hash);
72
+ }
73
+ return merged;
74
+ }
75
+ function bytesToHex(bytes) {
76
+ return Array.from(bytes, (byte) => byte.toString(HEX_RADIX).padStart(2, "0")).join("");
77
+ }
78
+ /** `address` with its pinned hashes replaced by `sha256`. */
79
+ function withPinnedHashes(address, sha256) {
80
+ const { url } = parsePinnedAddress(address);
81
+ return formatPinnedAddress(new URL(url).host, sha256.map(bytesToHex));
82
+ }
37
83
  //#endregion
84
+ exports.MAX_ANNOUNCED_HASHES = MAX_ANNOUNCED_HASHES;
85
+ exports.decodeCertificateHashes = decodeCertificateHashes;
86
+ exports.encodeCertificateHashes = encodeCertificateHashes;
38
87
  exports.formatPinnedAddress = formatPinnedAddress;
39
88
  exports.isPinnedAddress = isPinnedAddress;
89
+ exports.mergeHashes = mergeHashes;
40
90
  exports.parsePinnedAddress = parsePinnedAddress;
91
+ exports.withPinnedHashes = withPinnedHashes;
@@ -19,4 +19,19 @@ export declare function isPinnedAddress(address: string): boolean;
19
19
  * @throws Error when the address is not `https://` or does not carry one or more 64-digit lowercase hex hashes.
20
20
  */
21
21
  export declare function parsePinnedAddress(address: string): PinnedAddress;
22
+ /** The most hashes a node may announce at once. A browser is asked to accept any of them, and a longer list is a node misbehaving rather than one advertising a schedule. */
23
+ export declare const MAX_ANNOUNCED_HASHES = 8;
24
+ /**
25
+ * The message a node sends a client, on a stream it opens to that client, to say which certificates it serves now and will serve next: a CBOR map with one key, `sha256`, holding an array of 32-byte hashes in the order they take over. It is authentic because it arrives on the session the client pinned, so no signature is needed.
26
+ */
27
+ export declare function encodeCertificateHashes(sha256Hex: readonly string[]): Uint8Array;
28
+ /**
29
+ * Reads a message made by `encodeCertificateHashes`.
30
+ * @throws Error when it is not a map with a `sha256` array of one to MAX_ANNOUNCED_HASHES byte strings of 32 bytes each.
31
+ */
32
+ export declare function decodeCertificateHashes(bytes: Readonly<Uint8Array>): Uint8Array<ArrayBuffer>[];
33
+ /** `hashes` followed by those of `more` that are not already in it, so a client can pin what it was given and what it has learned since. */
34
+ export declare function mergeHashes(hashes: readonly Uint8Array<ArrayBuffer>[], more: readonly Uint8Array<ArrayBuffer>[]): Uint8Array<ArrayBuffer>[];
35
+ /** `address` with its pinned hashes replaced by `sha256`. */
36
+ export declare function withPinnedHashes(address: string, sha256: readonly Uint8Array<ArrayBuffer>[]): string;
22
37
  //#endregion
@@ -19,4 +19,19 @@ export declare function isPinnedAddress(address: string): boolean;
19
19
  * @throws Error when the address is not `https://` or does not carry one or more 64-digit lowercase hex hashes.
20
20
  */
21
21
  export declare function parsePinnedAddress(address: string): PinnedAddress;
22
+ /** The most hashes a node may announce at once. A browser is asked to accept any of them, and a longer list is a node misbehaving rather than one advertising a schedule. */
23
+ export declare const MAX_ANNOUNCED_HASHES = 8;
24
+ /**
25
+ * The message a node sends a client, on a stream it opens to that client, to say which certificates it serves now and will serve next: a CBOR map with one key, `sha256`, holding an array of 32-byte hashes in the order they take over. It is authentic because it arrives on the session the client pinned, so no signature is needed.
26
+ */
27
+ export declare function encodeCertificateHashes(sha256Hex: readonly string[]): Uint8Array;
28
+ /**
29
+ * Reads a message made by `encodeCertificateHashes`.
30
+ * @throws Error when it is not a map with a `sha256` array of one to MAX_ANNOUNCED_HASHES byte strings of 32 bytes each.
31
+ */
32
+ export declare function decodeCertificateHashes(bytes: Readonly<Uint8Array>): Uint8Array<ArrayBuffer>[];
33
+ /** `hashes` followed by those of `more` that are not already in it, so a client can pin what it was given and what it has learned since. */
34
+ export declare function mergeHashes(hashes: readonly Uint8Array<ArrayBuffer>[], more: readonly Uint8Array<ArrayBuffer>[]): Uint8Array<ArrayBuffer>[];
35
+ /** `address` with its pinned hashes replaced by `sha256`. */
36
+ export declare function withPinnedHashes(address: string, sha256: readonly Uint8Array<ArrayBuffer>[]): string;
22
37
  //#endregion
@@ -1,7 +1,9 @@
1
+ import { cdeDecodeOptions, cdeEncodeOptions, decode, encode } from "cbor2";
1
2
  //#region src/domain/pinned-address.ts
2
3
  const PIN_FRAGMENT = "#sha256=";
3
4
  const HASH_SEPARATOR = ",";
4
5
  const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
6
+ const HEX_RADIX = 16;
5
7
  function hexToBytes(hex) {
6
8
  const bytes = new Uint8Array(hex.length / 2);
7
9
  for (let index = 0; index < bytes.length; index++) bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
@@ -33,5 +35,49 @@ function parsePinnedAddress(address) {
33
35
  sha256: hashes.map(hexToBytes)
34
36
  };
35
37
  }
38
+ /** The bytes in a SHA-256 hash. */
39
+ const SHA256_BYTES = 32;
40
+ /** The most hashes a node may announce at once. A browser is asked to accept any of them, and a longer list is a node misbehaving rather than one advertising a schedule. */
41
+ const MAX_ANNOUNCED_HASHES = 8;
42
+ /**
43
+ * The message a node sends a client, on a stream it opens to that client, to say which certificates it serves now and will serve next: a CBOR map with one key, `sha256`, holding an array of 32-byte hashes in the order they take over. It is authentic because it arrives on the session the client pinned, so no signature is needed.
44
+ */
45
+ function encodeCertificateHashes(sha256Hex) {
46
+ return encode({ sha256: sha256Hex.map(hexToBytes) }, cdeEncodeOptions);
47
+ }
48
+ /**
49
+ * Reads a message made by `encodeCertificateHashes`.
50
+ * @throws Error when it is not a map with a `sha256` array of one to MAX_ANNOUNCED_HASHES byte strings of 32 bytes each.
51
+ */
52
+ function decodeCertificateHashes(bytes) {
53
+ const decoded = decode(bytes, cdeDecodeOptions);
54
+ if (typeof decoded !== "object" || decoded === null || !("sha256" in decoded) || !Array.isArray(decoded.sha256)) throw new Error("expected a map with a sha256 array");
55
+ const announced = decoded.sha256;
56
+ if (announced.length === 0 || announced.length > 8) throw new Error(`expected 1 to ${String(8)} hashes, got ${String(announced.length)}`);
57
+ const hashes = [];
58
+ for (const hash of announced) {
59
+ if (!(hash instanceof Uint8Array) || hash.length !== SHA256_BYTES) throw new Error(`each hash must be ${String(SHA256_BYTES)} bytes`);
60
+ hashes.push(new Uint8Array(hash));
61
+ }
62
+ return hashes;
63
+ }
64
+ /** `hashes` followed by those of `more` that are not already in it, so a client can pin what it was given and what it has learned since. */
65
+ function mergeHashes(hashes, more) {
66
+ const known = new Set(hashes.map(bytesToHex));
67
+ const merged = [...hashes];
68
+ for (const hash of more) if (!known.has(bytesToHex(hash))) {
69
+ known.add(bytesToHex(hash));
70
+ merged.push(hash);
71
+ }
72
+ return merged;
73
+ }
74
+ function bytesToHex(bytes) {
75
+ return Array.from(bytes, (byte) => byte.toString(HEX_RADIX).padStart(2, "0")).join("");
76
+ }
77
+ /** `address` with its pinned hashes replaced by `sha256`. */
78
+ function withPinnedHashes(address, sha256) {
79
+ const { url } = parsePinnedAddress(address);
80
+ return formatPinnedAddress(new URL(url).host, sha256.map(bytesToHex));
81
+ }
36
82
  //#endregion
37
- export { formatPinnedAddress, isPinnedAddress, parsePinnedAddress };
83
+ export { MAX_ANNOUNCED_HASHES, decodeCertificateHashes, encodeCertificateHashes, formatPinnedAddress, isPinnedAddress, mergeHashes, parsePinnedAddress, withPinnedHashes };
@@ -1,5 +1,5 @@
1
1
  import { D as DeviceId, N as Frame, et as PeerAdvert } from "../protocol-DCDae3z4.cjs";
2
- import { t as Connection } from "../transport-CfW7fsGK.cjs";
2
+ import { t as Connection } from "../transport-DIXjoQKn.cjs";
3
3
  import { AdvertExtensionPolicy } from "./advert-extension-policy.cjs";
4
4
  import { Mailbox, MailboxRefusalReason } from "./hub-mailbox.cjs";
5
5
  import { PeerAdvertVerifier } from "./peer-advert.cjs";
@@ -1,5 +1,5 @@
1
1
  import { D as DeviceId, N as Frame, et as PeerAdvert } from "../protocol-DCDae3z4.mjs";
2
- import { t as Connection } from "../transport-iLBxIkRS.mjs";
2
+ import { t as Connection } from "../transport-B0GywEzG.mjs";
3
3
  import { AdvertExtensionPolicy } from "./advert-extension-policy.mjs";
4
4
  import { Mailbox, MailboxRefusalReason } from "./hub-mailbox.mjs";
5
5
  import { PeerAdvertVerifier } from "./peer-advert.mjs";
@@ -1,5 +1,5 @@
1
1
  import { N as Frame } from "../protocol-DCDae3z4.cjs";
2
- import { t as Connection } from "../transport-CfW7fsGK.cjs";
2
+ import { t as Connection } from "../transport-DIXjoQKn.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
1
  import { N as Frame } from "../protocol-DCDae3z4.mjs";
2
- import { t as Connection } from "../transport-iLBxIkRS.mjs";
2
+ import { t as Connection } from "../transport-B0GywEzG.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,2 +1,2 @@
1
- import { n as Listener, r as Transport, t as Connection } from "../transport-CfW7fsGK.cjs";
1
+ import { n as Listener, r as Transport, t as Connection } from "../transport-DIXjoQKn.cjs";
2
2
  export { Connection, Listener, Transport };
@@ -1,2 +1,2 @@
1
- import { n as Listener, r as Transport, t as Connection } from "../transport-iLBxIkRS.mjs";
1
+ import { n as Listener, r as Transport, t as Connection } from "../transport-B0GywEzG.mjs";
2
2
  export { Connection, Listener, Transport };
@@ -10,6 +10,8 @@ interface Connection {
10
10
  close: () => Promise<void>;
11
11
  /** The peer's device-id, cryptographically authenticated by the adapter itself (never a value read off the wire) -- absent when the adapter has nothing to authenticate against (createTcpTransport) or the peer presented no credential to check (an accepting createTlsTransport connection whose peer sent no certificate). A caller that later receives an application-level claim of who the peer is (e.g. an `introduce`-style message) MUST treat a mismatch against this field as a spoofing attempt and refuse the claim, exactly the same "reject and destroy" obligation self-certifying claims elsewhere in this spec already carry. */
12
12
  readonly peerDeviceId?: DeviceId;
13
+ /** The address to dial to reach this same peer again, when the adapter has learned a better one than the address this connection was dialled with: a node that rotates the certificate a browser pins tells each connection the certificates it will serve next, so a dial made after the address the user was given has aged still succeeds. Absent when the adapter learned nothing, in which case the dialled address stands. */
14
+ readonly redialAddress?: () => string;
13
15
  /** Lets the event loop exit while this connection is still open and functioning -- a caller that wants I/O to keep working without keeping the process alive (matching Node's own `net.Socket.unref()`) calls this once. Adapters with no underlying handle to unref (an in-memory test double, a browser WebSocket) simply omit it. */
14
16
  unref?: () => void;
15
17
  }
@@ -10,6 +10,8 @@ interface Connection {
10
10
  close: () => Promise<void>;
11
11
  /** The peer's device-id, cryptographically authenticated by the adapter itself (never a value read off the wire) -- absent when the adapter has nothing to authenticate against (createTcpTransport) or the peer presented no credential to check (an accepting createTlsTransport connection whose peer sent no certificate). A caller that later receives an application-level claim of who the peer is (e.g. an `introduce`-style message) MUST treat a mismatch against this field as a spoofing attempt and refuse the claim, exactly the same "reject and destroy" obligation self-certifying claims elsewhere in this spec already carry. */
12
12
  readonly peerDeviceId?: DeviceId;
13
+ /** The address to dial to reach this same peer again, when the adapter has learned a better one than the address this connection was dialled with: a node that rotates the certificate a browser pins tells each connection the certificates it will serve next, so a dial made after the address the user was given has aged still succeeds. Absent when the adapter learned nothing, in which case the dialled address stands. */
14
+ readonly redialAddress?: () => string;
13
15
  /** Lets the event loop exit while this connection is still open and functioning -- a caller that wants I/O to keep working without keeping the process alive (matching Node's own `net.Socket.unref()`) calls this once. Adapters with no underlying handle to unref (an in-memory test double, a browser WebSocket) simply omit it. */
14
16
  unref?: () => void;
15
17
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wire-mesh-core",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "dependencies": {
5
5
  "cbor2": "2.3.0",
6
6
  "cddl.js": "1.0.1",