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>
29 lines
902 B
TypeScript
29 lines
902 B
TypeScript
/**
|
|
* 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) };
|
|
}
|