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>
84 lines
2.3 KiB
TypeScript
84 lines
2.3 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* Smoke-test peer A (sender).
|
|
*
|
|
* Reads the seed JSON from /tmp/smoke-seed.json, connects to the
|
|
* broker, sends hello, then sends one direct message to peer B.
|
|
* Exits after 5s whether or not it gets anything back.
|
|
*/
|
|
|
|
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 helloAcked = false;
|
|
|
|
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",
|
|
meshId: seed.meshId,
|
|
memberId: seed.peerA.memberId,
|
|
pubkey: seed.peerA.pubkey,
|
|
sessionId: "peer-a-session",
|
|
pid: process.pid,
|
|
cwd: "/tmp/peer-a",
|
|
timestamp,
|
|
signature,
|
|
}),
|
|
);
|
|
});
|
|
|
|
ws.on("message", (raw) => {
|
|
const msg = JSON.parse(raw.toString());
|
|
console.log("[peer-a] recv:", JSON.stringify(msg));
|
|
if (!helloAcked) {
|
|
// Broker doesn't currently ack hello explicitly; first message we
|
|
// get is a push OR error. Assume success and fire our send after
|
|
// a short delay.
|
|
}
|
|
});
|
|
|
|
ws.on("error", (e) => console.error("[peer-a] error:", e.message));
|
|
ws.on("close", () => console.log("[peer-a] closed"));
|
|
|
|
// After a short delay to let hello complete, send the test message.
|
|
setTimeout(() => {
|
|
console.log("[peer-a] sending direct message to peer-b");
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "send",
|
|
targetSpec: seed.peerB.pubkey,
|
|
priority: "now",
|
|
nonce: "fake-nonce-aaa",
|
|
ciphertext: "hello-from-a",
|
|
id: "msg-1",
|
|
}),
|
|
);
|
|
}, 500);
|
|
|
|
setTimeout(() => {
|
|
console.log("[peer-a] done, closing");
|
|
ws.close();
|
|
process.exit(0);
|
|
}, 5000);
|