diff --git a/package.json b/package.json index cab48ac..72d27c6 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,8 @@ "test": "node --test --test-concurrency=1 dist/test/coordinator-socket-error.integration.test.js dist/test/identity-store.test.js dist/test/identity-cert.test.js dist/test/broadcast-window.integration.test.js dist/test/identity-restart.integration.test.js dist/test/mesh-e2e.integration.test.js dist/test/ws-broadcast-window.integration.test.js dist/test/tls-transport.integration.test.js dist/test/state-sync-convergence.test.js dist/test/downtime-replay.test.js dist/test/downtime-replay.integration.test.js", "test:visibility": "node --test dist/test/visibility.integration.test.js", "test:delivery": "node dist/test/delivery-receipt.runner.js", - "test:all": "pnpm test && pnpm test:delivery", + "test:federation": "node dist/test/federation.integration.test.js", + "test:all": "pnpm test && pnpm test:delivery && pnpm test:federation", "test:frontend": "tsx --test src/bridges/user/web/frontend/test/**/*.unit.test.ts", "test:e2e": "playwright test", "typecheck": "tsc --noEmit" diff --git a/src/core/comms-store.ts b/src/core/comms-store.ts index ad101e4..130629c 100644 --- a/src/core/comms-store.ts +++ b/src/core/comms-store.ts @@ -105,6 +105,18 @@ export interface CommsStore { fedConnect(host: string, port: number, name?: string): Promise; fedDisconnect(linkId: string): Promise; fedLinks(): FedLink[]; + /** This instance's own federation TLS fingerprint, to hand to an operator on the other side to pin. */ + getFederationFingerprint(): string; + /** Pin a remote mesh's certificate fingerprint as trusted for federation, inbound or outbound. */ + fedTrust(fingerprint: string): Promise; + /** Remove a previously pinned federation fingerprint. */ + fedUntrust(fingerprint: string): Promise; + /** List currently trusted federation fingerprints. */ + fedTrustedFingerprints(): string[]; + /** Start accepting inbound federation links on host:port. Rejects any connection whose certificate isn't pinned via fedTrust(). */ + fedListen(host: string, port: number): Promise; + /** Stop accepting new inbound federation connections. Existing links are unaffected. */ + fedStopListening(): Promise; // -- Connection approval -- acceptConnection(connectionId: string): Promise; rejectConnection(connectionId: string, reason: string): Promise; diff --git a/src/core/federation.ts b/src/core/federation.ts index db8284d..b0562a8 100644 --- a/src/core/federation.ts +++ b/src/core/federation.ts @@ -16,7 +16,7 @@ import { encode, isMeshMessage, MessageBuffer } from "./wire-protocol.js"; import type { MeshMessage } from "./wire-protocol.js"; import type { AgentIdentity, RoomMessage } from "./types.js"; import { nanoid } from "./nanoid.js"; -import { generateIdentity } from "./identity.js"; +import { fingerprintDer, generateIdentity } from "./identity.js"; import type { PeerIdentity } from "./identity.js"; // --------------------------------------------------------------------------- @@ -78,6 +78,11 @@ export class FederationManager { private pingTimers = new Map>(); private shutDown = false; private pendingPongs = new Map>(); + /** + * Certificate fingerprints this instance will federate with, inbound or outbound. Empty by default — federation trusts nobody until an operator explicitly pins a remote mesh's fingerprint, the same no-CA, pin-the-key trust model ordinary peer connections already use. + */ + private trustedFingerprints = new Set(); + private listener: tls.Server | undefined; constructor(meshId: string, meshName: string, callbacks: FedCallbacks) { this.meshId = meshId; @@ -91,6 +96,45 @@ export class FederationManager { return this.identity; } + // ----------------------------------------------------------------------- + // Trust — which remote mesh fingerprints this instance will federate with + // ----------------------------------------------------------------------- + + /** Pin a remote mesh's certificate fingerprint as trusted for federation. */ + addTrustedFingerprint(fingerprint: string): void { + this.trustedFingerprints.add(fingerprint); + } + + /** Remove a previously pinned fingerprint. Existing links using it are not torn down. */ + removeTrustedFingerprint(fingerprint: string): void { + this.trustedFingerprints.delete(fingerprint); + } + + /** List currently trusted fingerprints. */ + listTrustedFingerprints(): string[] { + return [...this.trustedFingerprints]; + } + + /** + * Verify the certificate a connected TLS socket presented against the trusted-fingerprint allowlist. Returns the presented fingerprint when trusted, `undefined` (and destroys the socket) otherwise. + * + * This is the check that was missing entirely before: the socket was accepted with `rejectUnauthorized: false` (required, since these are self-signed certs with no CA) but nothing then verified *which* self-signed cert was presented, so any certificate was accepted as a valid federation peer. + */ + private verifyPeerOrDestroy(socket: tls.TLSSocket): string | undefined { + const cert = socket.getPeerCertificate(); + // Node's types declare every PeerCertificate field non-optional, but the documented runtime behaviour when the peer presents no certificate at all is an empty object — not null/undefined, and not a Buffer-typed `raw`. Detect that real shape rather than trusting the declared type. + if (Object.keys(cert).length === 0) { + socket.destroy(); + return undefined; + } + const fingerprint = fingerprintDer(cert.raw); + if (!this.trustedFingerprints.has(fingerprint)) { + socket.destroy(); + return undefined; + } + return fingerprint; + } + // ----------------------------------------------------------------------- // Outbound links // ----------------------------------------------------------------------- @@ -105,6 +149,13 @@ export class FederationManager { const linkId = nanoid(8); const socket = await this.tlsConnect(host, port); + if (this.verifyPeerOrDestroy(socket) === undefined) { + throw new Error( + `Federation connection to ${host}:${String(port)} rejected: ` + + "the presented certificate is not in the trusted-fingerprint allowlist. " + + "Call addTrustedFingerprint() with the remote mesh's fingerprint first.", + ); + } const link: FedLink = { id: linkId, remoteMeshId: "", @@ -147,6 +198,13 @@ export class FederationManager { throw new Error("FederationManager is shut down"); } + if (this.verifyPeerOrDestroy(socket) === undefined) { + throw new Error( + "Inbound federation connection rejected: the presented certificate " + + "is not in the trusted-fingerprint allowlist.", + ); + } + const linkId = nanoid(8); const link: FedLink = { id: linkId, @@ -235,12 +293,62 @@ export class FederationManager { await this.broadcastToReady(msg); } + // ----------------------------------------------------------------------- + // Inbound listener — accepts federation links from remote coordinators + // ----------------------------------------------------------------------- + + /** + * Start listening for inbound federation connections. Every accepted connection is routed through `handleInbound()`, which enforces the trusted-fingerprint check before a link is ever created — nothing here bypasses that check. + * + * Previously nothing in the shipped product called `handleInbound()` at all: it existed only as a function the integration test invoked directly against a hand-rolled `tls.createServer`. This is that server, promoted to real code. + */ + listen(host: string, port: number): Promise { + if (this.listener) { + throw new Error("FederationManager is already listening"); + } + return new Promise((resolve, reject) => { + const server = tls.createServer( + { + key: this.identity.privateKey, + cert: this.identity.certificate, + // Same as the outbound side: no CA, so we don't ask Node to verify the chain. requestCert is what makes the connecting peer's own certificate available to verifyPeerOrDestroy() inside handleInbound() — without it there is nothing to check. + rejectUnauthorized: false, + requestCert: true, + }, + (socket) => { + this.handleInbound(socket).catch(() => { + // Rejected (untrusted fingerprint, or shutting down) — the socket is already destroyed inside handleInbound/verifyPeerOrDestroy. + }); + }, + ); + + server.listen(port, host, () => { + this.listener = server; + resolve(); + }); + server.on("error", reject); + }); + } + + /** Stop accepting new inbound federation connections. Existing links are unaffected. */ + stopListening(): Promise { + const server = this.listener; + if (!server) return Promise.resolve(); + this.listener = undefined; + return new Promise((resolve) => { + server.close(() => { + resolve(); + }); + }); + } + // ----------------------------------------------------------------------- // Shutdown // ----------------------------------------------------------------------- async shutdown(): Promise { this.shutDown = true; + await this.stopListening(); for (const linkId of [...this.links.keys()]) { await this.disconnect(linkId); } diff --git a/src/core/identity.ts b/src/core/identity.ts index 2b0cc30..8b2b27e 100644 --- a/src/core/identity.ts +++ b/src/core/identity.ts @@ -321,6 +321,13 @@ export function generateIdentity(): PeerIdentity { */ export function getCertificateFingerprint(certificate: string): string { const der = pemToDer(certificate); + return fingerprintDer(der); +} + +/** + * Compute the SHA-256 fingerprint of a certificate already presented as raw DER bytes — the shape `tls.TLSSocket.getPeerCertificate().raw` returns for a live connection. Same hashing and formatting as `getCertificateFingerprint`, so a value pinned from a PEM certificate compares equal to the fingerprint of that same certificate presented live over a socket. + */ +export function fingerprintDer(der: Buffer): string { const hex = createHash("sha256").update(der).digest("hex").toUpperCase(); const matched = hex.match(/.{2}/g); if (matched === null) return ""; diff --git a/src/core/mesh-store.ts b/src/core/mesh-store.ts index 121beb5..268a60f 100644 --- a/src/core/mesh-store.ts +++ b/src/core/mesh-store.ts @@ -22,6 +22,7 @@ import { MdnsDiscoveryBackend } from "./discovery-mdns.js"; import { TailscaleDiscoveryBackend } from "./discovery-tailscale.js"; import { FederationManager } from "./federation.js"; import type { FedLink } from "./federation.js"; +import { getCertificateFingerprint } from "./identity.js"; import type { MeshMessage, MeshStatePatch, PeerInfo } from "./wire-protocol.js"; import type { ConnectionHandle, @@ -1644,6 +1645,32 @@ export class MeshStore implements CommsStore { return this.federation.listLinks(); } + getFederationFingerprint(): string { + return getCertificateFingerprint(this.federation.tlsIdentity.certificate); + } + + fedTrust(fingerprint: string): Promise { + this.federation.addTrustedFingerprint(fingerprint); + return Promise.resolve(); + } + + fedUntrust(fingerprint: string): Promise { + this.federation.removeTrustedFingerprint(fingerprint); + return Promise.resolve(); + } + + fedTrustedFingerprints(): string[] { + return this.federation.listTrustedFingerprints(); + } + + fedListen(host: string, port: number): Promise { + return this.federation.listen(host, port); + } + + fedStopListening(): Promise { + return this.federation.stopListening(); + } + // ----------------------------------------------------------------------- // Federation callbacks (inbound from remote meshes) // ----------------------------------------------------------------------- diff --git a/src/core/store.ts b/src/core/store.ts index 00df400..a9574bf 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -691,6 +691,39 @@ export class FileStore implements CommsStore { fedLinks(): FedLink[] { return []; } + + getFederationFingerprint(): string { + return ""; + } + + fedTrust(): Promise { + throw new CommsError( + "FileStore does not support federation", + "NOT_SUPPORTED", + ); + } + + fedUntrust(): Promise { + throw new CommsError( + "FileStore does not support federation", + "NOT_SUPPORTED", + ); + } + + fedTrustedFingerprints(): string[] { + return []; + } + + fedListen(): Promise { + throw new CommsError( + "FileStore does not support federation", + "NOT_SUPPORTED", + ); + } + + fedStopListening(): Promise { + return Promise.resolve(); + } // Connection approval — not supported by FileStore // ----------------------------------------------------------------------- diff --git a/src/core/tool.ts b/src/core/tool.ts index 4efb693..e7ff167 100644 --- a/src/core/tool.ts +++ b/src/core/tool.ts @@ -108,6 +108,18 @@ export class CommsTool { return await this.meshFedDisconnect(ctx, action); case "mesh_fed_links": return this.meshFedLinks(ctx); + case "mesh_fed_fingerprint": + return this.meshFedFingerprint(ctx); + case "mesh_fed_trust": + return await this.meshFedTrust(ctx, action); + case "mesh_fed_untrust": + return await this.meshFedUntrust(ctx, action); + case "mesh_fed_trusted": + return this.meshFedTrusted(ctx); + case "mesh_fed_listen": + return await this.meshFedListen(ctx, action); + case "mesh_fed_stop_listening": + return await this.meshFedStopListening(ctx); default: return { content: `Unknown action: ${JSON.stringify(action).slice(0, 100)}`, @@ -609,6 +621,93 @@ export class CommsTool { }; } + private meshFedFingerprint(_ctx: CommsContext): CommsResult { + const fingerprint = this.store.getFederationFingerprint(); + return { + content: `This mesh's federation fingerprint: ${fingerprint}\nHand this to the operator on the other side so they can run mesh_fed_trust with it — and do the same in reverse before either side connects.`, + isError: false, + }; + } + + private async meshFedTrust( + _ctx: CommsContext, + action: CommsAction & { action: "mesh_fed_trust" }, + ): Promise { + try { + await this.store.fedTrust(action.fingerprint); + return { + content: `Trusted federation fingerprint: ${action.fingerprint}`, + isError: false, + }; + } catch (err) { + return { + content: `Failed to trust fingerprint: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + } + + private async meshFedUntrust( + _ctx: CommsContext, + action: CommsAction & { action: "mesh_fed_untrust" }, + ): Promise { + try { + await this.store.fedUntrust(action.fingerprint); + return { + content: `Untrusted federation fingerprint: ${action.fingerprint}`, + isError: false, + }; + } catch (err) { + return { + content: `Failed to untrust fingerprint: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + } + + private meshFedTrusted(_ctx: CommsContext): CommsResult { + const fingerprints = this.store.fedTrustedFingerprints(); + if (fingerprints.length === 0) + return { content: "No trusted federation fingerprints.", isError: false }; + return { + content: `Trusted federation fingerprints:\n${fingerprints.map((f) => ` ${f}`).join("\n")}`, + isError: false, + }; + } + + private async meshFedListen( + _ctx: CommsContext, + action: CommsAction & { action: "mesh_fed_listen" }, + ): Promise { + try { + await this.store.fedListen(action.host, action.port); + return { + content: `Listening for inbound federation links on ${action.host}:${String(action.port)}. Only connections presenting a trusted fingerprint (mesh_fed_trust) will be accepted.`, + isError: false, + }; + } catch (err) { + return { + content: `Failed to start federation listener: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + } + + private async meshFedStopListening(_ctx: CommsContext): Promise { + try { + await this.store.fedStopListening(); + return { + content: "Stopped accepting inbound federation connections.", + isError: false, + }; + } catch (err) { + return { + content: `Failed to stop federation listener: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + } + private async meshReject( _ctx: CommsContext, action: CommsAction & { action: "mesh_reject" }, diff --git a/src/core/types.ts b/src/core/types.ts index d76e24a..7e176b3 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -384,6 +384,22 @@ export const CommsActionSchema = defineSchema( linkId: z.string(), }), z.object({ action: z.literal("mesh_fed_links") }), + z.object({ action: z.literal("mesh_fed_fingerprint") }), + z.object({ + action: z.literal("mesh_fed_trust"), + fingerprint: z.string(), + }), + z.object({ + action: z.literal("mesh_fed_untrust"), + fingerprint: z.string(), + }), + z.object({ action: z.literal("mesh_fed_trusted") }), + z.object({ + action: z.literal("mesh_fed_listen"), + host: z.string(), + port: z.number(), + }), + z.object({ action: z.literal("mesh_fed_stop_listening") }), ]), ); export type CommsAction = z.infer; diff --git a/src/test/federation.integration.test.ts b/src/test/federation.integration.test.ts index 3d85ee9..2fb5e64 100644 --- a/src/test/federation.integration.test.ts +++ b/src/test/federation.integration.test.ts @@ -1,13 +1,14 @@ /** - * Federation integration test — verifies that two MeshStore instances on - * different "machines" (simulated via separate TCP meshes) can federate - * through coordinator-to-coordinator TLS links. + * Federation integration test — verifies that two MeshStore instances on different "machines" (simulated via separate TCP meshes) can federate through coordinator-to-coordinator TLS links, and that an inbound link presenting an untrusted certificate is rejected outright. * * Tests: - * 1. Establish federation link between two meshes - * 2. Agent presence propagates across federation - * 3. Messages in federated rooms propagate across federation - * 4. Non-federated rooms are isolated (messages never cross) + * 0. An inbound connection with no pinned fingerprint is rejected + * 1. Establish federation link between two meshes once both fingerprints are trusted + * 2. Agent presence propagates across federation + * 3. Messages in federated rooms propagate across federation + * 4. Non-federated rooms are isolated (messages never cross) + * 5. Federation link listing + * 6. Disconnect federation link * * Run: node dist/test/federation.integration.test.js */ @@ -20,7 +21,6 @@ import * as net from "node:net"; // Use high ports to avoid collisions with real meshes const MESH_A_PORT = 28876; const MESH_B_PORT = 28877; -const FED_PORT_A = 28878; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -30,10 +30,6 @@ function sleep(ms: number): Promise { // Helpers // --------------------------------------------------------------------------- -/** - * Create a mesh store that also listens on a second port for federation - * inbound connections. Returns the store and the federation listener port. - */ async function createMesh( name: string, coordinatorPort: number, @@ -60,9 +56,7 @@ async function createMesh( return { store, deliveries }; } -/** - * Find a free port on localhost. - */ +/** Find a free port on localhost. */ function findFreePort(): Promise { return new Promise((resolve, reject) => { const server = net.createServer(); @@ -86,7 +80,6 @@ function findFreePort(): Promise { async function main(): Promise { console.log("=== Federation Integration Tests ===\n"); - // Create two separate meshes (simulating two machines) console.log("Creating mesh A (coordinator)..."); const a = await createMesh("mesh-a-agent", MESH_A_PORT); await sleep(100); @@ -95,20 +88,39 @@ async function main(): Promise { const b = await createMesh("mesh-b-agent", MESH_B_PORT); await sleep(100); - // Find a free port for the federation server const fedPort = await findFreePort(); console.log(`Using federation port ${String(fedPort)}`); - // Start a TLS federation server on mesh A that the FederationManager - // can accept connections on. We use the federation manager's TLS identity. - const fedServer = await startFedServer( - a.store.federation.tlsIdentity, - fedPort, - a.store, - ); + // Start A's real production federation listener (fedListen -> FederationManager.listen -> handleInbound, the same path a deployed coordinator uses — not a hand-rolled test-only TLS server). + await a.store.fedListen("127.0.0.1", fedPort); await sleep(100); - // --- Test 1: Establish federation link --- + // --- Test 0: untrusted inbound connection is rejected --- + console.log("\nTest 0: untrusted connection is rejected..."); + await assert.rejects( + () => b.store.fedConnect("127.0.0.1", fedPort), + /rejected|not in the trusted-fingerprint allowlist/i, + "Connecting before either side has pinned the other's fingerprint should be rejected", + ); + assert.strictEqual( + b.store.fedLinks().length, + 0, + "B should have no federation links after a rejected attempt", + ); + console.log(" Rejected as expected — no link was created."); + + // --- Pin fingerprints on both sides, mirroring what an operator does out of band --- + console.log("\nPinning fingerprints on both sides..."); + const fingerprintA = a.store.getFederationFingerprint(); + const fingerprintB = b.store.getFederationFingerprint(); + assert.ok(fingerprintA.length > 0, "A should report its own fingerprint"); + assert.ok(fingerprintB.length > 0, "B should report its own fingerprint"); + await a.store.fedTrust(fingerprintB); + await b.store.fedTrust(fingerprintA); + assert.deepStrictEqual(a.store.fedTrustedFingerprints(), [fingerprintB]); + assert.deepStrictEqual(b.store.fedTrustedFingerprints(), [fingerprintA]); + + // --- Test 1: Establish federation link now that both sides trust each other --- console.log("\nTest 1: Establish federation link..."); const linkId = await b.store.fedConnect("127.0.0.1", fedPort); console.log(` Link established: ${linkId}`); @@ -124,7 +136,6 @@ async function main(): Promise { // --- Test 2: Agent presence propagates --- console.log("Test 2: Agent presence propagates..."); - // After federation, B should see A's agent as a federated agent const agentsB = await b.store.listAgents(b.store.peerId); console.log(` B sees ${String(agentsB.length)} agent(s)`); const fedAgentsB = agentsB.filter((ag) => ag.tags.includes("federated")); @@ -133,7 +144,6 @@ async function main(): Promise { "B should see at least 1 federated agent from A", ); - // A should see B's agent as a federated agent const agentsA = await a.store.listAgents(a.store.peerId); console.log(` A sees ${String(agentsA.length)} agent(s)`); const fedAgentsA = agentsA.filter((ag) => ag.tags.includes("federated")); @@ -145,7 +155,6 @@ async function main(): Promise { // --- Test 3: Federated room messages propagate --- console.log("Test 3: Federated room messages propagate..."); - // Create a federated room on mesh A const fedRoomId = `fed-room-${String(Date.now())}`; const fedRoom = await a.store.createRoom({ name: fedRoomId, @@ -157,7 +166,6 @@ async function main(): Promise { console.log(` Created federated room: ${fedRoom.id}`); await sleep(200); - // Create the same federated room on mesh B (same ID) const fedRoomB = await b.store.createRoom({ name: fedRoomId, type: "public", @@ -168,11 +176,9 @@ async function main(): Promise { console.log(` Created matching federated room on B: ${fedRoomB.id}`); await sleep(200); - // Clear deliveries a.deliveries.length = 0; b.deliveries.length = 0; - // Send a message from A's agent in the federated room const msg = await a.store.sendRoomMessage( fedRoom.id, a.store.peerId, @@ -181,7 +187,6 @@ async function main(): Promise { console.log(` A sent: "${msg.content}"`); await sleep(500); - // B should receive the federated message const fedMsgs = b.deliveries.filter( (e) => e.type === "room_message" && e.message.content === "Hello from mesh A!", @@ -192,7 +197,6 @@ async function main(): Promise { // --- Test 4: Non-federated rooms are isolated --- console.log("Test 4: Non-federated rooms are isolated..."); - // Create a non-federated room on mesh A const localRoomId = `local-room-${String(Date.now())}`; console.log(` Creating non-federated room: ${localRoomId}`); const localRoom = await a.store.createRoom({ @@ -207,10 +211,8 @@ async function main(): Promise { ); await sleep(100); - // Clear deliveries b.deliveries.length = 0; - // Send a message in the non-federated room console.log(" Sending message in non-federated room..."); await a.store.sendRoomMessage( localRoom.id, @@ -220,7 +222,6 @@ async function main(): Promise { console.log(" Message sent."); await sleep(100); - // B should NOT receive this message const leakedMsgs = b.deliveries.filter( (e) => e.type === "room_message" && e.message.content === "Secret local message", @@ -252,56 +253,13 @@ async function main(): Promise { // --- Cleanup --- console.log("\nCleaning up..."); - fedServer.close(); + await a.store.fedStopListening(); await a.store.shutdown(); await b.store.shutdown(); console.log("\n✓ All federation tests passed!"); } -// --------------------------------------------------------------------------- -// Federation TLS server (simulates coordinator-to-coordinator link) -// --------------------------------------------------------------------------- - -import * as tls from "node:tls"; -import type { PeerIdentity } from "../core/identity.js"; -import { encode, isMeshMessage, MessageBuffer } from "../core/wire-protocol.js"; -import type { MeshMessage } from "../core/wire-protocol.js"; - -/** - * Start a simple TLS server that accepts federation connections and - * delegates them to the FederationManager on the given store. - */ -function startFedServer( - identity: PeerIdentity, - port: number, - store: MeshStore, -): Promise { - return new Promise((resolve, reject) => { - const server = tls.createServer( - { - key: identity.privateKey, - cert: identity.certificate, - rejectUnauthorized: false, - requestCert: true, - }, - (socket) => { - // Delegate to the FederationManager's inbound handler - void store.federation.handleInbound(socket).catch((err: unknown) => { - console.error("Fed inbound error:", err); - socket.destroy(); - }); - }, - ); - - server.listen(port, "127.0.0.1", () => { - resolve(server); - }); - - server.on("error", reject); - }); -} - main().catch((err: unknown) => { console.error("Test failed:", err); process.exit(1);