Direct messages between peers are now end-to-end encrypted. The
broker only ever sees {nonce, ciphertext} — plaintext lives on the
two endpoints.
apps/cli/src/crypto/envelope.ts:
- encryptDirect(message, recipientPubkeyHex, senderSecretKeyHex)
→ {nonce, ciphertext} via crypto_box_easy, 24-byte fresh nonce
- decryptDirect(envelope, senderPubkeyHex, recipientSecretKeyHex)
→ plaintext or null (null on MAC failure / malformed input)
- ed25519 keys (from Step 17) are converted to X25519 on the fly via
crypto_sign_ed25519_{pk,sk}_to_curve25519 — one signing keypair
covers both signing + encryption roles.
BrokerClient.send():
- if targetSpec is a 64-hex pubkey → encrypt via crypto_box
- else (broadcast "*" or channel "#foo") → base64-wrapped plaintext
(shared-key encryption for channels lands in a later step)
InboundPush now carries:
- plaintext: string | null (decrypted body, null if decryption failed
OR it's a non-direct message)
- kind: "direct" | "broadcast" | "channel" | "unknown"
MCP check_messages formatter reads plaintext directly.
side-fixes pulled in during 18a:
- apps/broker/scripts/seed-test-mesh.ts now generates real ed25519
keypairs (the previous "aaaa…" / "bbbb…" fillers weren't valid
curve points, so crypto_sign_ed25519_pk_to_curve25519 rejected
them). Seed output now includes secretKey for each peer.
- apps/broker/src/broker.ts drainForMember wraps the atomic claim in
a CTE + outer ORDER BY so FIFO ordering is SQL-sourced, not
JS-sorted (Postgres microsecond timestamps collapse to the same
Date.getTime() milliseconds otherwise).
- vitest.config.ts fileParallelism: false — test files share
DB state via cleanupAllTestMeshes afterAll, so running them in
parallel caused one file's cleanup to race another's inserts.
- integration/health.test.ts "returns 200" now uses waitFullyHealthy
(a 200-only waiter) instead of waitHealthyOrAny — prevents a race
with the startup DB ping.
verified live:
- apps/cli/scripts/roundtrip.ts (direct A→B): ciphertext in DB is
opaque bytes (not base64-plaintext), decrypted correctly on arrival
- apps/cli/scripts/join-roundtrip.ts (full join → encrypted send):
PASSED
- 48/48 broker tests green
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
88 lines
2.6 KiB
TypeScript
88 lines
2.6 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* End-to-end round-trip: two BrokerClient instances talking via the
|
|
* broker. Runs against a live broker + seeded DB.
|
|
*
|
|
* Reads /tmp/cli-seed.json (output of broker's scripts/seed-test-mesh.ts),
|
|
* connects peer A and peer B, sends a message from A to B, waits for
|
|
* the push on B, asserts receipt + sender pubkey.
|
|
*/
|
|
|
|
import { readFileSync } from "node:fs";
|
|
import { BrokerClient } from "../src/ws/client";
|
|
import type { JoinedMesh } from "../src/state/config";
|
|
|
|
const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as {
|
|
meshId: string;
|
|
peerA: { memberId: string; pubkey: string; secretKey: string };
|
|
peerB: { memberId: string; pubkey: string; secretKey: string };
|
|
};
|
|
|
|
const brokerUrl = process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws";
|
|
const meshA: JoinedMesh = {
|
|
meshId: seed.meshId,
|
|
memberId: seed.peerA.memberId,
|
|
slug: "rt-a",
|
|
name: "roundtrip-a",
|
|
pubkey: seed.peerA.pubkey,
|
|
secretKey: seed.peerA.secretKey,
|
|
brokerUrl,
|
|
joinedAt: new Date().toISOString(),
|
|
};
|
|
const meshB: JoinedMesh = {
|
|
...meshA,
|
|
memberId: seed.peerB.memberId,
|
|
slug: "rt-b",
|
|
pubkey: seed.peerB.pubkey,
|
|
secretKey: seed.peerB.secretKey,
|
|
};
|
|
|
|
async function main(): Promise<void> {
|
|
const a = new BrokerClient(meshA, { debug: true });
|
|
const b = new BrokerClient(meshB, { debug: true });
|
|
|
|
let received: string | null = null;
|
|
let receivedSender: string | null = null;
|
|
b.onPush((msg) => {
|
|
received = msg.plaintext;
|
|
receivedSender = msg.senderPubkey;
|
|
console.log(`[b] push (kind=${msg.kind}): "${received}" from ${receivedSender?.slice(0, 16)}…`);
|
|
});
|
|
|
|
console.log("[rt] connecting A + B…");
|
|
await Promise.all([a.connect(), b.connect()]);
|
|
console.log(`[rt] A: ${a.status}, B: ${b.status}`);
|
|
|
|
console.log("[rt] A → B …");
|
|
const result = await a.send(seed.peerB.pubkey, "hello from A", "now");
|
|
console.log("[rt] send result:", result);
|
|
|
|
// Wait up to 3s for the push to land.
|
|
for (let i = 0; i < 30 && !received; i++) {
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
}
|
|
|
|
a.close();
|
|
b.close();
|
|
|
|
if (!received) {
|
|
console.error("✗ FAIL: no push received");
|
|
process.exit(1);
|
|
}
|
|
if (received !== "hello from A") {
|
|
console.error(`✗ FAIL: body mismatch: "${received}"`);
|
|
process.exit(1);
|
|
}
|
|
if (receivedSender !== seed.peerA.pubkey) {
|
|
console.error(`✗ FAIL: sender mismatch: "${receivedSender}"`);
|
|
process.exit(1);
|
|
}
|
|
console.log("✓ round-trip PASSED");
|
|
process.exit(0);
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error("✗ FAIL:", e instanceof Error ? e.message : e);
|
|
process.exit(1);
|
|
});
|