5 Commits
Author SHA1 Message Date
mesh-admin 10e8191717 Merge pull request 'The runtime hears on its own inbox' (#17) from fix/the-runtime-hears-on-its-own-inbox into main 2026-09-28 02:30:24 +00:00
jschoubben d703cebff4 The runtime hears on its own inbox
Every user's inbox is private to it and the grant names it; a reply space the client invented was
refused, and with it every pull for the next message and every answer to a tool call.
2026-09-28 04:30:22 +02:00
mesh-admin 46b56d53a6 Merge pull request 'The pin is the only check: the runtime stops verifying the bus's name' (#16) from fix/the-pin-is-the-only-check into main 2026-09-28 02:03:46 +00:00
jschoubben d4a2802342 The pin is the only check: the runtime stops verifying the bus's name
Every module on the new runtime reached the handshake and failed on 'does not match
certificate's altnames': the bus's certificate names the seat, not the address a machine dials
it by, and pinning the exact certificate already decides everything a name check could. The
client's transport spreads the TLS options into Node's tls.connect, so the hostname check is
replaced with one that passes and the pinned certificate is the one authority accepted.
2026-09-28 04:03:43 +02:00
mesh-admin 8acfa7a07d Merge pull request 'One bus: the runtime pins the certificate after the server speaks, and the old transport goes' (#15) from feat/one-bus into main 2026-09-28 01:09:01 +00:00
+16 -9
View File
@@ -19,7 +19,7 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import net from "node:net"; import net from "node:net";
import tls from "node:tls"; import tls from "node:tls";
import { connect as natsConnect, headers as natsHeaders, StringCodec, type JsMsg, type Subscription } from "nats"; import { connect as natsConnect, headers as natsHeaders, StringCodec, type JsMsg, type Subscription, type TlsOptions } from "nats";
import type { Broker, Envelope, EventHeaders } from "@novox/mesh-sdk/messaging"; import type { Broker, Envelope, EventHeaders } from "@novox/mesh-sdk/messaging";
const sc = StringCodec(); const sc = StringCodec();
@@ -85,6 +85,10 @@ export async function connectNats(
pass: cred.password, pass: cred.password,
name: `${cred.node ?? "?"}.${self}`, name: `${cred.node ?? "?"}.${self}`,
tls: cred.fingerprint ? await pinnedTls(cred.url, cred.fingerprint) : undefined, tls: cred.fingerprint ? await pinnedTls(cred.url, cred.fingerprint) : undefined,
// Its own inbox, not a random one: every user's inbox is private to it (design 25 §4), and the
// grant names `_INBOX.<user>.>` — a reply space the client invented would be refused, and with
// it every pull for the next message and every answer to a tool call.
inboxPrefix: cred.user ? `_INBOX.${cred.user}` : undefined,
// Reconnect forever: the bus being restarted is an upgrade, not a reason for every module on // Reconnect forever: the bus being restarted is an upgrade, not a reason for every module on
// the mesh to exit. `close()` stays the only thing that ends the connection. // the mesh to exit. `close()` stays the only thing that ends the connection.
maxReconnectAttempts: -1, maxReconnectAttempts: -1,
@@ -298,14 +302,15 @@ function normalizeFingerprint(fingerprint: string): string {
* A certificate authority is not consulted: the mesh issued this and knows its fingerprint, * A certificate authority is not consulted: the mesh issued this and knows its fingerprint,
* which is stronger than trusting whoever a machine's trust store happens to contain. * which is stronger than trusting whoever a machine's trust store happens to contain.
* *
* **A constraint on the mesh, not a detail of this file.** Pinning the exact certificate makes * **The pin is the only check.** What comes back is handed to the client as its TLS options, and
* hostname verification redundant in principle, but the NATS client exposes no hook to replace * the client's transport spreads them into Node's own `tls.connect` — so the pinned certificate
* it — its TLS options are file paths and PEM strings, with no verify callback. So the * is the one authority the handshake accepts, and the hostname check beside it is replaced with
* certificate the mesh issues the bus **must carry a subject-alternative name matching the * one that always passes. Pinning the exact certificate makes verifying its name redundant, and
* address nodes dial it by**. The fingerprint check below still happens and is still the real * the bus's certificate names the seat (`mesh-broker`), not the address a machine happens to
* guarantee; what cannot be switched off is the check *beside* it. * dial it by: every module on the mesh met "does not match certificate's altnames" the first time
* it reached the handshake (2026-09-28).
*/ */
async function pinnedTls(rawUrl: string, fingerprint: string): Promise<{ ca: string }> { async function pinnedTls(rawUrl: string, fingerprint: string): Promise<TlsOptions> {
const url = new URL(rawUrl.includes("://") ? rawUrl : `nats://${rawUrl}`); const url = new URL(rawUrl.includes("://") ? rawUrl : `nats://${rawUrl}`);
const port = url.port ? Number(url.port) : 4222; const port = url.port ? Number(url.port) : 4222;
// **The bus speaks first, in the clear.** A NATS server sends its INFO line before TLS begins, // **The bus speaks first, in the clear.** A NATS server sends its INFO line before TLS begins,
@@ -342,7 +347,9 @@ async function pinnedTls(rawUrl: string, fingerprint: string): Promise<{ ca: str
); );
} }
const pem = `-----BEGIN CERTIFICATE-----\n${certificate.raw.toString("base64").replace(/(.{64})/g, "$1\n")}\n-----END CERTIFICATE-----\n`; const pem = `-----BEGIN CERTIFICATE-----\n${certificate.raw.toString("base64").replace(/(.{64})/g, "$1\n")}\n-----END CERTIFICATE-----\n`;
return { ca: pem }; // Node's option, not the client's: the transport passes the whole object on. `undefined` from
// checkServerIdentity is "the name is fine"; the pin above already decided the rest.
return { ca: pem, checkServerIdentity: () => undefined } as TlsOptions;
} }
/** The mesh's topic matching: `*` is one token, `#` the rest. This is the module's vocabulary — /** The mesh's topic matching: `*` is one token, `#` the rest. This is the module's vocabulary —