From 9d3dbcecafd5495fa3d86d5ffe01819a7a35c784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:53:40 +0100 Subject: [PATCH] feat(broker): verify ed25519 hello signature against member pubkey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS handshake is now authenticated end-to-end. The broker proves that every connected peer actually holds the secret key for the pubkey they claim as identity — not just that they know the pubkey. wire format change: {type:"hello", meshId, memberId, pubkey, sessionId, pid, cwd, timestamp, signature} where signature = ed25519_sign(canonical, secretKey) and canonical = `${meshId}|${memberId}|${pubkey}|${timestamp}` broker verifies on every hello: 1. timestamp within ±60s of broker clock → else close(1008, timestamp_skew) 2. pubkey is 64 hex chars, signature is 128 hex chars → else malformed 3. crypto_sign_verify_detached(signature, canonical, pubkey) → else bad_signature 4. (existing) mesh.member row exists for (meshId, pubkey) → else unauthorized All rejection paths close the WS with code 1008 + structured error message + metrics counter increment (connections_rejected_total by reason). new modules: - apps/broker/src/crypto.ts: canonicalHello, verifyHelloSignature, HELLO_SKEW_MS constant - apps/cli/src/crypto/hello-sig.ts: matching signHello helper clients updated: - apps/cli/src/ws/client.ts: signs hello before send - apps/broker/scripts/{peer-a,peer-b}.ts (smoke-test): sign hellos with seed-provided secret keys new regression tests — tests/hello-signature.test.ts (7): - valid signature accepted - bad signature (signed with wrong key) rejected - timestamp too old rejected (>60s) - timestamp too far in future rejected (>60s) - tampered canonical field (different meshId at verify time) rejected - malformed hex pubkey rejected - malformed signature length rejected verified live: - apps/broker/scripts/smoke-test.sh: full hello+ack+send+push flow - apps/cli/scripts/roundtrip.ts: signed hello + encrypted message - 55/55 tests pass Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/broker/scripts/peer-a.ts | 22 ++- apps/broker/scripts/peer-b.ts | 22 ++- apps/broker/src/crypto.ts | 79 +++++++++++ apps/broker/src/index.ts | 21 +++ apps/broker/src/types.ts | 7 +- apps/broker/tests/hello-signature.test.ts | 159 ++++++++++++++++++++++ apps/cli/src/crypto/hello-sig.ts | 28 ++++ apps/cli/src/ws/client.ts | 46 +++++-- 8 files changed, 355 insertions(+), 29 deletions(-) create mode 100644 apps/broker/src/crypto.ts create mode 100644 apps/broker/tests/hello-signature.test.ts create mode 100644 apps/cli/src/crypto/hello-sig.ts diff --git a/apps/broker/scripts/peer-a.ts b/apps/broker/scripts/peer-a.ts index 5c00314..4aa3d56 100644 --- a/apps/broker/scripts/peer-a.ts +++ b/apps/broker/scripts/peer-a.ts @@ -8,12 +8,13 @@ */ import { readFileSync } from "node:fs"; +import sodium from "libsodium-wrappers"; import WebSocket from "ws"; const seed = JSON.parse(readFileSync("/tmp/smoke-seed.json", "utf-8")) as { meshId: string; - peerA: { memberId: string; pubkey: string }; - peerB: { memberId: string; pubkey: string }; + peerA: { memberId: string; pubkey: string; secretKey: string }; + peerB: { memberId: string; pubkey: string; secretKey: string }; }; const BROKER = process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws"; @@ -21,8 +22,17 @@ const ws = new WebSocket(BROKER); let helloAcked = false; -ws.on("open", () => { - console.log("[peer-a] connected, sending hello"); +ws.on("open", async () => { + await sodium.ready; + const timestamp = Date.now(); + const canonical = `${seed.meshId}|${seed.peerA.memberId}|${seed.peerA.pubkey}|${timestamp}`; + const signature = sodium.to_hex( + sodium.crypto_sign_detached( + sodium.from_string(canonical), + sodium.from_hex(seed.peerA.secretKey), + ), + ); + console.log("[peer-a] connected, sending signed hello"); ws.send( JSON.stringify({ type: "hello", @@ -32,8 +42,8 @@ ws.on("open", () => { sessionId: "peer-a-session", pid: process.pid, cwd: "/tmp/peer-a", - signature: "stub", - nonce: "stub", + timestamp, + signature, }), ); }); diff --git a/apps/broker/scripts/peer-b.ts b/apps/broker/scripts/peer-b.ts index ede2018..f195f96 100644 --- a/apps/broker/scripts/peer-b.ts +++ b/apps/broker/scripts/peer-b.ts @@ -8,12 +8,13 @@ */ import { readFileSync } from "node:fs"; +import sodium from "libsodium-wrappers"; import WebSocket from "ws"; const seed = JSON.parse(readFileSync("/tmp/smoke-seed.json", "utf-8")) as { meshId: string; - peerA: { memberId: string; pubkey: string }; - peerB: { memberId: string; pubkey: string }; + peerA: { memberId: string; pubkey: string; secretKey: string }; + peerB: { memberId: string; pubkey: string; secretKey: string }; }; const BROKER = process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws"; @@ -21,8 +22,17 @@ const ws = new WebSocket(BROKER); let received = false; -ws.on("open", () => { - console.log("[peer-b] connected, sending hello"); +ws.on("open", async () => { + await sodium.ready; + const timestamp = Date.now(); + const canonical = `${seed.meshId}|${seed.peerB.memberId}|${seed.peerB.pubkey}|${timestamp}`; + const signature = sodium.to_hex( + sodium.crypto_sign_detached( + sodium.from_string(canonical), + sodium.from_hex(seed.peerB.secretKey), + ), + ); + console.log("[peer-b] connected, sending signed hello"); ws.send( JSON.stringify({ type: "hello", @@ -32,8 +42,8 @@ ws.on("open", () => { sessionId: "peer-b-session", pid: process.pid, cwd: "/tmp/peer-b", - signature: "stub", - nonce: "stub", + timestamp, + signature, }), ); }); diff --git a/apps/broker/src/crypto.ts b/apps/broker/src/crypto.ts new file mode 100644 index 0000000..81932bb --- /dev/null +++ b/apps/broker/src/crypto.ts @@ -0,0 +1,79 @@ +/** + * Broker-side ed25519 verification helpers. + * + * Used to authenticate the WS hello handshake: clients sign a canonical + * byte string with their mesh.member.peerPubkey's secret key, broker + * verifies with the claimed pubkey, then cross-checks the pubkey is a + * current member of the claimed mesh. + */ + +import sodium from "libsodium-wrappers"; + +let ready = false; +async function ensureSodium(): Promise { + if (!ready) { + await sodium.ready; + ready = true; + } + return sodium; +} + +/** Canonical hello bytes: clients sign this, broker verifies this. */ +export function canonicalHello( + meshId: string, + memberId: string, + pubkey: string, + timestamp: number, +): string { + return `${meshId}|${memberId}|${pubkey}|${timestamp}`; +} + +export const HELLO_SKEW_MS = 60_000; + +/** + * Verify a hello's ed25519 signature + timestamp skew. + * Returns { ok: true } on success, or { ok: false, reason } describing + * which check failed (for structured error response). + */ +export async function verifyHelloSignature(args: { + meshId: string; + memberId: string; + pubkey: string; + timestamp: number; + signature: string; + now?: number; +}): Promise< + | { ok: true } + | { ok: false; reason: "timestamp_skew" | "bad_signature" | "malformed" } +> { + const now = args.now ?? Date.now(); + if ( + !Number.isFinite(args.timestamp) || + Math.abs(now - args.timestamp) > HELLO_SKEW_MS + ) { + return { ok: false, reason: "timestamp_skew" }; + } + if ( + !/^[0-9a-f]{64}$/i.test(args.pubkey) || + !/^[0-9a-f]{128}$/i.test(args.signature) + ) { + return { ok: false, reason: "malformed" }; + } + const s = await ensureSodium(); + try { + const canonical = canonicalHello( + args.meshId, + args.memberId, + args.pubkey, + args.timestamp, + ); + const ok = s.crypto_sign_verify_detached( + s.from_hex(args.signature), + s.from_string(canonical), + s.from_hex(args.pubkey), + ); + return ok ? { ok: true } : { ok: false, reason: "bad_signature" }; + } catch { + return { ok: false, reason: "malformed" }; + } +} diff --git a/apps/broker/src/index.ts b/apps/broker/src/index.ts index 176fb18..660dc7e 100644 --- a/apps/broker/src/index.ts +++ b/apps/broker/src/index.ts @@ -42,6 +42,7 @@ import { metrics, metricsToText } from "./metrics"; import { TokenBucket } from "./rate-limit"; import { isDbHealthy, startDbHealth, stopDbHealth } from "./db-health"; import { buildInfo } from "./build-info"; +import { verifyHelloSignature } from "./crypto"; const PORT = env.BROKER_PORT; const WS_PATH = "/ws"; @@ -364,6 +365,26 @@ async function handleHello( ws.close(1008, "capacity"); return null; } + // Signature + skew check. Proves the client holds the secret key + // for the pubkey they're claiming as identity. + const sig = await verifyHelloSignature({ + meshId: hello.meshId, + memberId: hello.memberId, + pubkey: hello.pubkey, + timestamp: hello.timestamp, + signature: hello.signature, + }); + if (!sig.ok) { + metrics.connectionsRejected.inc({ reason: sig.reason }); + log.warn("hello sig rejected", { + reason: sig.reason, + mesh_id: hello.meshId, + pubkey: hello.pubkey?.slice(0, 12), + }); + sendError(ws, sig.reason, `hello rejected: ${sig.reason}`); + ws.close(1008, sig.reason); + return null; + } const member = await findMemberByPubkey(hello.meshId, hello.pubkey); if (!member) { metrics.connectionsRejected.inc({ reason: "unauthorized" }); diff --git a/apps/broker/src/types.ts b/apps/broker/src/types.ts index ccac062..b5f32de 100644 --- a/apps/broker/src/types.ts +++ b/apps/broker/src/types.ts @@ -55,8 +55,11 @@ export interface WSHelloMessage { sessionId: string; pid: number; cwd: string; - signature: string; // ed25519 over (meshId||memberId||sessionId||nonce) - nonce: string; + /** ms epoch; broker rejects if outside ±60s of its own clock. */ + timestamp: number; + /** ed25519 signature (hex) over the canonical hello bytes: + * `${meshId}|${memberId}|${pubkey}|${timestamp}` */ + signature: string; } /** Client → broker: send an E2E-encrypted envelope to a target. */ diff --git a/apps/broker/tests/hello-signature.test.ts b/apps/broker/tests/hello-signature.test.ts new file mode 100644 index 0000000..17f7621 --- /dev/null +++ b/apps/broker/tests/hello-signature.test.ts @@ -0,0 +1,159 @@ +/** + * Hello signature verification — unit tests on the verifyHelloSignature + * function directly. Covers valid signature, bad signature, timestamp + * skew, and cross-member attacks (signing with wrong key). + * + * Integration WS-level testing happens implicitly via the smoke-test + * scripts (apps/broker/scripts/smoke-test.sh, apps/cli/scripts/ + * roundtrip.ts), which exercise the full hello handshake. + */ + +import { beforeAll, describe, expect, test } from "vitest"; +import sodium from "libsodium-wrappers"; +import { + canonicalHello, + verifyHelloSignature, + HELLO_SKEW_MS, +} from "../src/crypto"; + +interface Keypair { + publicKey: string; + secretKey: string; +} + +async function makeKeypair(): Promise { + await sodium.ready; + const kp = sodium.crypto_sign_keypair(); + return { + publicKey: sodium.to_hex(kp.publicKey), + secretKey: sodium.to_hex(kp.privateKey), + }; +} + +function sign(canonical: string, secretKeyHex: string): string { + return sodium.to_hex( + sodium.crypto_sign_detached( + sodium.from_string(canonical), + sodium.from_hex(secretKeyHex), + ), + ); +} + +describe("verifyHelloSignature", () => { + let kp: Keypair; + beforeAll(async () => { + kp = await makeKeypair(); + }); + + test("valid signature accepted", async () => { + const meshId = "mesh-x"; + const memberId = "member-y"; + const timestamp = Date.now(); + const canonical = canonicalHello(meshId, memberId, kp.publicKey, timestamp); + const signature = sign(canonical, kp.secretKey); + const result = await verifyHelloSignature({ + meshId, + memberId, + pubkey: kp.publicKey, + timestamp, + signature, + }); + expect(result.ok).toBe(true); + }); + + test("bad signature rejected", async () => { + const meshId = "mesh-x"; + const memberId = "member-y"; + const timestamp = Date.now(); + // Sign with a DIFFERENT key than the one we claim + const otherKp = await makeKeypair(); + const canonical = canonicalHello(meshId, memberId, kp.publicKey, timestamp); + const signature = sign(canonical, otherKp.secretKey); + const result = await verifyHelloSignature({ + meshId, + memberId, + pubkey: kp.publicKey, // claim kp's identity + timestamp, + signature, // but signed with otherKp — mismatch + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("bad_signature"); + }); + + test("timestamp too old rejected", async () => { + const timestamp = Date.now() - HELLO_SKEW_MS - 1000; + const canonical = canonicalHello("m", "mem", kp.publicKey, timestamp); + const signature = sign(canonical, kp.secretKey); + const result = await verifyHelloSignature({ + meshId: "m", + memberId: "mem", + pubkey: kp.publicKey, + timestamp, + signature, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("timestamp_skew"); + }); + + test("timestamp too far in future rejected", async () => { + const timestamp = Date.now() + HELLO_SKEW_MS + 1000; + const canonical = canonicalHello("m", "mem", kp.publicKey, timestamp); + const signature = sign(canonical, kp.secretKey); + const result = await verifyHelloSignature({ + meshId: "m", + memberId: "mem", + pubkey: kp.publicKey, + timestamp, + signature, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("timestamp_skew"); + }); + + test("tampered canonical field fails verification", async () => { + const timestamp = Date.now(); + // Sign over one meshId, claim a different one at verify time + const canonical = canonicalHello( + "original-mesh", + "mem", + kp.publicKey, + timestamp, + ); + const signature = sign(canonical, kp.secretKey); + const result = await verifyHelloSignature({ + meshId: "different-mesh", + memberId: "mem", + pubkey: kp.publicKey, + timestamp, + signature, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("bad_signature"); + }); + + test("malformed hex pubkey rejected", async () => { + const timestamp = Date.now(); + const result = await verifyHelloSignature({ + meshId: "m", + memberId: "mem", + pubkey: "not-hex", + timestamp, + signature: "a".repeat(128), + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("malformed"); + }); + + test("malformed signature length rejected", async () => { + const timestamp = Date.now(); + const result = await verifyHelloSignature({ + meshId: "m", + memberId: "mem", + pubkey: kp.publicKey, + timestamp, + signature: "abc123", // wrong length + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("malformed"); + }); +}); diff --git a/apps/cli/src/crypto/hello-sig.ts b/apps/cli/src/crypto/hello-sig.ts new file mode 100644 index 0000000..85f285d --- /dev/null +++ b/apps/cli/src/crypto/hello-sig.ts @@ -0,0 +1,28 @@ +/** + * Client-side signing of the WS hello handshake. + * + * Canonical bytes: `${meshId}|${memberId}|${pubkey}|${timestamp}` — + * MUST match the broker's `canonicalHello()` exactly. Any mismatch + * (delimiter, field order, whitespace) produces a bad_signature reject. + * + * Uses the full ed25519 secret key (64 bytes) that libsodium returns + * from crypto_sign_keypair — seed || pubkey layout. + */ + +import { ensureSodium } from "./keypair"; + +export async function signHello( + meshId: string, + memberId: string, + pubkey: string, + secretKeyHex: string, +): Promise<{ timestamp: number; signature: string }> { + const s = await ensureSodium(); + const timestamp = Date.now(); + const canonical = `${meshId}|${memberId}|${pubkey}|${timestamp}`; + const sig = s.crypto_sign_detached( + s.from_string(canonical), + s.from_hex(secretKeyHex), + ); + return { timestamp, signature: s.to_hex(sig) }; +} diff --git a/apps/cli/src/ws/client.ts b/apps/cli/src/ws/client.ts index 4497d4d..a6e8d0f 100644 --- a/apps/cli/src/ws/client.ts +++ b/apps/cli/src/ws/client.ts @@ -20,6 +20,7 @@ import { encryptDirect, isDirectTarget, } from "../crypto/envelope"; +import { signHello } from "../crypto/hello-sig"; export type Priority = "now" | "next" | "low"; export type ConnStatus = "connecting" | "open" | "closed" | "reconnecting"; @@ -97,21 +98,36 @@ export class BrokerClient { this.ws = ws; return new Promise((resolve, reject) => { - const onOpen = (): void => { - this.debug("ws open → sending hello"); - ws.send( - JSON.stringify({ - type: "hello", - meshId: this.mesh.meshId, - memberId: this.mesh.memberId, - pubkey: this.mesh.pubkey, - sessionId: `${process.pid}-${Date.now()}`, - pid: process.pid, - cwd: process.cwd(), - signature: "stub", // libsodium sign_detached lands in Step 18 - nonce: randomNonce(), - }), - ); + const onOpen = async (): Promise => { + this.debug("ws open → signing + sending hello"); + try { + const { timestamp, signature } = await signHello( + this.mesh.meshId, + this.mesh.memberId, + this.mesh.pubkey, + this.mesh.secretKey, + ); + ws.send( + JSON.stringify({ + type: "hello", + meshId: this.mesh.meshId, + memberId: this.mesh.memberId, + pubkey: this.mesh.pubkey, + sessionId: `${process.pid}-${Date.now()}`, + pid: process.pid, + cwd: process.cwd(), + timestamp, + signature, + }), + ); + } catch (e) { + reject( + new Error( + `hello sign failed: ${e instanceof Error ? e.message : e}`, + ), + ); + return; + } // Arm the hello_ack timeout. this.helloTimer = setTimeout(() => { this.debug("hello_ack timeout");