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:
79
apps/broker/src/crypto.ts
Normal file
79
apps/broker/src/crypto.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user