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>
This commit is contained in:
Alejandro Gutiérrez
2026-04-04 22:53:40 +01:00
parent bde83cc757
commit 9d3dbcecaf
8 changed files with 355 additions and 29 deletions

79
apps/broker/src/crypto.ts Normal file
View File

@@ -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<typeof sodium> {
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" };
}
}

View File

@@ -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" });

View File

@@ -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. */