Files
claudemesh/apps/broker/scripts/peer-b.ts
Alejandro Gutiérrez 9d3dbcecaf feat(broker): verify ed25519 hello signature against member pubkey
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) <noreply@anthropic.com>
2026-04-04 22:53:40 +01:00

90 lines
2.4 KiB
TypeScript

#!/usr/bin/env bun
/**
* Smoke-test peer B (receiver).
*
* Connects, sends hello, then waits up to 5s for a push from peer A.
* Exits 0 on successful receive with matching senderPubkey, 1 on
* timeout or mismatch.
*/
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; secretKey: string };
peerB: { memberId: string; pubkey: string; secretKey: string };
};
const BROKER = process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws";
const ws = new WebSocket(BROKER);
let received = false;
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",
meshId: seed.meshId,
memberId: seed.peerB.memberId,
pubkey: seed.peerB.pubkey,
sessionId: "peer-b-session",
pid: process.pid,
cwd: "/tmp/peer-b",
timestamp,
signature,
}),
);
});
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString()) as {
type: string;
senderPubkey?: string;
ciphertext?: string;
code?: string;
message?: string;
};
console.log("[peer-b] recv:", JSON.stringify(msg));
if (msg.type === "push") {
if (msg.senderPubkey === seed.peerA.pubkey) {
console.log("[peer-b] ✓ got expected push from peer-a");
received = true;
ws.close();
process.exit(0);
} else {
console.error(
`[peer-b] ✗ wrong senderPubkey: got ${msg.senderPubkey}, expected ${seed.peerA.pubkey}`,
);
ws.close();
process.exit(1);
}
}
if (msg.type === "error") {
console.error(`[peer-b] ✗ broker error: ${msg.code} ${msg.message}`);
ws.close();
process.exit(1);
}
});
ws.on("error", (e) => console.error("[peer-b] ws error:", e.message));
ws.on("close", () => console.log("[peer-b] closed"));
setTimeout(() => {
if (!received) {
console.error("[peer-b] ✗ timeout waiting for push (5s)");
process.exit(1);
}
}, 5000);