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>
This commit is contained in:
@@ -26,7 +26,6 @@ async function waitHealthyOrAny(port: number, maxMs = 5000): Promise<void> {
|
||||
const r = await fetch(`http://localhost:${port}/health`, {
|
||||
signal: AbortSignal.timeout(500),
|
||||
});
|
||||
// Any response (even 503) means the HTTP server is up.
|
||||
if (r.status === 200 || r.status === 503) return;
|
||||
} catch {
|
||||
/* not yet */
|
||||
@@ -36,6 +35,23 @@ async function waitHealthyOrAny(port: number, maxMs = 5000): Promise<void> {
|
||||
throw new Error(`broker on :${port} did not come up`);
|
||||
}
|
||||
|
||||
/** Wait until /health returns 200 (HTTP + DB ping both completed). */
|
||||
async function waitFullyHealthy(port: number, maxMs = 5000): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < maxMs) {
|
||||
try {
|
||||
const r = await fetch(`http://localhost:${port}/health`, {
|
||||
signal: AbortSignal.timeout(500),
|
||||
});
|
||||
if (r.status === 200) return;
|
||||
} catch {
|
||||
/* not yet */
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
throw new Error(`broker on :${port} did not become fully healthy`);
|
||||
}
|
||||
|
||||
function spawnBroker(env: Record<string, string>): BrokerProc {
|
||||
const port = 18000 + Math.floor(Math.random() * 1000);
|
||||
const brokerEntry = join(
|
||||
@@ -73,7 +89,7 @@ describe("/health endpoint", () => {
|
||||
process.env.DATABASE_URL ??
|
||||
"postgresql://turbostarter:turbostarter@127.0.0.1:5440/claudemesh_test",
|
||||
});
|
||||
await waitHealthyOrAny(broker.port);
|
||||
await waitFullyHealthy(broker.port);
|
||||
});
|
||||
afterAll(() => broker?.kill());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user