Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 12 additions & 0 deletions src/core/comms-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,18 @@ export interface CommsStore {
fedConnect(host: string, port: number, name?: string): Promise<string>;
fedDisconnect(linkId: string): Promise<void>;
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<void>;
/** Remove a previously pinned federation fingerprint. */
fedUntrust(fingerprint: string): Promise<void>;
/** 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<void>;
/** Stop accepting new inbound federation connections. Existing links are unaffected. */
fedStopListening(): Promise<void>;
// -- Connection approval --
acceptConnection(connectionId: string): Promise<void>;
rejectConnection(connectionId: string, reason: string): Promise<void>;
Expand Down
110 changes: 109 additions & 1 deletion src/core/federation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -78,6 +78,11 @@ export class FederationManager {
private pingTimers = new Map<string, ReturnType<typeof setInterval>>();
private shutDown = false;
private pendingPongs = new Map<string, ReturnType<typeof setTimeout>>();
/**
* 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<string>();
private listener: tls.Server | undefined;

constructor(meshId: string, meshName: string, callbacks: FedCallbacks) {
this.meshId = meshId;
Expand All @@ -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
// -----------------------------------------------------------------------
Expand All @@ -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: "",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<void> {
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<void> {
const server = this.listener;
if (!server) return Promise.resolve();
this.listener = undefined;
return new Promise((resolve) => {
server.close(() => {
resolve();
});
});
}

// -----------------------------------------------------------------------
// Shutdown
// -----------------------------------------------------------------------

async shutdown(): Promise<void> {
this.shutDown = true;
await this.stopListening();
for (const linkId of [...this.links.keys()]) {
await this.disconnect(linkId);
}
Expand Down
7 changes: 7 additions & 0 deletions src/core/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "";
Expand Down
27 changes: 27 additions & 0 deletions src/core/mesh-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void> {
this.federation.addTrustedFingerprint(fingerprint);
return Promise.resolve();
}

fedUntrust(fingerprint: string): Promise<void> {
this.federation.removeTrustedFingerprint(fingerprint);
return Promise.resolve();
}

fedTrustedFingerprints(): string[] {
return this.federation.listTrustedFingerprints();
}

fedListen(host: string, port: number): Promise<void> {
return this.federation.listen(host, port);
}

fedStopListening(): Promise<void> {
return this.federation.stopListening();
}

// -----------------------------------------------------------------------
// Federation callbacks (inbound from remote meshes)
// -----------------------------------------------------------------------
Expand Down
33 changes: 33 additions & 0 deletions src/core/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,39 @@ export class FileStore implements CommsStore {
fedLinks(): FedLink[] {
return [];
}

getFederationFingerprint(): string {
return "";
}

fedTrust(): Promise<void> {
throw new CommsError(
"FileStore does not support federation",
"NOT_SUPPORTED",
);
}

fedUntrust(): Promise<void> {
throw new CommsError(
"FileStore does not support federation",
"NOT_SUPPORTED",
);
}

fedTrustedFingerprints(): string[] {
return [];
}

fedListen(): Promise<void> {
throw new CommsError(
"FileStore does not support federation",
"NOT_SUPPORTED",
);
}

fedStopListening(): Promise<void> {
return Promise.resolve();
}
// Connection approval — not supported by FileStore
// -----------------------------------------------------------------------

Expand Down
99 changes: 99 additions & 0 deletions src/core/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`,
Expand Down Expand Up @@ -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<CommsResult> {
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<CommsResult> {
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<CommsResult> {
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<CommsResult> {
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" },
Expand Down
Loading