Files
claudemesh/apps/cli/scripts/join-roundtrip.ts
Alejandro Gutiérrez 81a8d0714b feat(crypto): client-side direct-message encryption with crypto_box
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>
2026-04-04 22:48:33 +01:00

118 lines
3.6 KiB
TypeScript

#!/usr/bin/env bun
/**
* Full join → connect → send round-trip.
*
* Uses a mesh already seeded in the DB (reads /tmp/cli-seed.json).
* Creates a fresh invite link, runs the join command, connects with
* the newly-generated member identity, sends a message to peer B,
* asserts receipt.
*/
// Run this script with CLAUDEMESH_CONFIG_DIR=/tmp/... set in env —
// ESM imports hoist above statements, so we can't set process.env
// after the `import { env }` side effect has already run.
import { readFileSync } from "node:fs";
import { execSync } from "node:child_process";
import { BrokerClient } from "../src/ws/client";
import type { JoinedMesh } from "../src/state/config";
import { loadConfig, getConfigPath } from "../src/state/config";
if (!process.env.CLAUDEMESH_CONFIG_DIR) {
console.error(
"Run with: CLAUDEMESH_CONFIG_DIR=/tmp/claudemesh-join-test-rt bun scripts/join-roundtrip.ts",
);
process.exit(1);
}
execSync(`rm -rf "${process.env.CLAUDEMESH_CONFIG_DIR}"`, {
stdio: "ignore",
});
const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as {
meshId: string;
peerB: { memberId: string; pubkey: string; secretKey: string };
};
async function main(): Promise<void> {
// 1. Build invite.
const link = execSync("bun scripts/make-invite.ts").toString().trim();
console.log("[rt] invite:", link.slice(0, 60) + "…");
// 2. Run `claudemesh join` with the same CONFIG_DIR.
const joinOut = execSync(`bun src/index.ts join "${link}"`, {
env: {
...process.env,
CLAUDEMESH_CONFIG_DIR: "/tmp/claudemesh-join-test-rt",
},
}).toString();
console.log("[rt] join output (tail):");
console.log(
joinOut
.split("\n")
.slice(-7)
.map((l) => " " + l)
.join("\n"),
);
// 3. Load the fresh config and connect as the new peer.
console.log(`[rt] loading config from: ${getConfigPath()}`);
const config = loadConfig();
console.log(`[rt] loaded ${config.meshes.length} mesh(es)`);
const joined = config.meshes.find((m) => m.slug === "rt-join");
if (!joined) throw new Error("rt-join mesh not found in config");
const joinedMesh: JoinedMesh = joined;
console.log(
`[rt] joined member_id=${joinedMesh.memberId} pubkey=${joinedMesh.pubkey.slice(0, 16)}`,
);
// 4. Connect also as peer-B (the target) so we can observe receipt.
// Uses the real keypair from the seed (needed for crypto_box decrypt).
const targetMesh: JoinedMesh = {
...joinedMesh,
memberId: seed.peerB.memberId,
slug: "rt-join-b",
pubkey: seed.peerB.pubkey,
secretKey: seed.peerB.secretKey,
};
const joiner = new BrokerClient(joinedMesh);
const target = new BrokerClient(targetMesh);
let received = "";
target.onPush((m) => {
received = m.plaintext ?? "";
console.log(`[rt] target got: "${received}"`);
});
await Promise.all([joiner.connect(), target.connect()]);
console.log(`[rt] joiner=${joiner.status} target=${target.status}`);
const res = await joiner.send(
seed.peerB.pubkey,
"sent-by-newly-joined-peer",
"now",
);
console.log("[rt] send result:", res);
for (let i = 0; i < 30 && !received; i++) {
await new Promise((r) => setTimeout(r, 100));
}
joiner.close();
target.close();
if (!res.ok) {
console.error("✗ FAIL: send did not ack");
process.exit(1);
}
if (received !== "sent-by-newly-joined-peer") {
console.error(`✗ FAIL: receive mismatch: "${received}"`);
process.exit(1);
}
console.log("✓ join → connect → send → receive FLOW PASSED");
process.exit(0);
}
main().catch((e) => {
console.error("✗ FAIL:", e instanceof Error ? e.message : e);
process.exit(1);
});