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:
Alejandro Gutiérrez
2026-04-04 22:48:33 +01:00
parent 9dd5face01
commit 81a8d0714b
9 changed files with 257 additions and 76 deletions

View File

@@ -15,6 +15,11 @@
import WebSocket from "ws";
import { randomBytes } from "node:crypto";
import type { JoinedMesh } from "../state/config";
import {
decryptDirect,
encryptDirect,
isDirectTarget,
} from "../crypto/envelope";
export type Priority = "now" | "next" | "low";
export type ConnStatus = "connecting" | "open" | "closed" | "reconnecting";
@@ -28,6 +33,12 @@ export interface InboundPush {
ciphertext: string;
createdAt: string;
receivedAt: string;
/** Decrypted plaintext (if encryption succeeded). null = broadcast
* or channel (no per-recipient crypto yet), or decryption failed. */
plaintext: string | null;
/** Hint for UI: "direct" (crypto_box), "channel"/"broadcast"
* (plaintext for now). */
kind: "direct" | "broadcast" | "channel" | "unknown";
}
type PushHandler = (msg: InboundPush) => void;
@@ -157,8 +168,22 @@ export class BrokerClient {
priority: Priority = "next",
): Promise<{ ok: boolean; messageId?: string; error?: string }> {
const id = randomId();
const nonce = randomNonce();
const ciphertext = Buffer.from(message, "utf-8").toString("base64");
// Direct messages get crypto_box encryption; broadcasts + channels
// still pass through as base64 plaintext until channel crypto lands.
let nonce: string;
let ciphertext: string;
if (isDirectTarget(targetSpec)) {
const env = await encryptDirect(
message,
targetSpec,
this.mesh.secretKey,
);
nonce = env.nonce;
ciphertext = env.ciphertext;
} else {
nonce = randomNonce();
ciphertext = Buffer.from(message, "utf-8").toString("base64");
}
return new Promise((resolve) => {
if (this.pendingSends.size >= MAX_QUEUED) {
@@ -254,26 +279,55 @@ export class BrokerClient {
return;
}
if (msg.type === "push") {
const push: InboundPush = {
messageId: String(msg.messageId ?? ""),
meshId: String(msg.meshId ?? ""),
senderPubkey: String(msg.senderPubkey ?? ""),
priority: (msg.priority as Priority) ?? "next",
nonce: String(msg.nonce ?? ""),
ciphertext: String(msg.ciphertext ?? ""),
createdAt: String(msg.createdAt ?? ""),
receivedAt: new Date().toISOString(),
};
this.pushBuffer.push(push);
// Cap buffer at 500 entries to avoid unbounded growth.
if (this.pushBuffer.length > 500) this.pushBuffer.shift();
for (const h of this.pushHandlers) {
try {
h(push);
} catch {
/* handler errors are not the transport's problem */
const nonce = String(msg.nonce ?? "");
const ciphertext = String(msg.ciphertext ?? "");
const senderPubkey = String(msg.senderPubkey ?? "");
// Decrypt asynchronously, then enqueue. Ordering within the
// buffer is preserved by awaiting before push.
void (async (): Promise<void> => {
const kind: InboundPush["kind"] = senderPubkey
? "direct"
: "unknown";
let plaintext: string | null = null;
if (senderPubkey && nonce && ciphertext) {
plaintext = await decryptDirect(
{ nonce, ciphertext },
senderPubkey,
this.mesh.secretKey,
);
}
}
// If decryption failed, fall back to base64 UTF-8 unwrap —
// this covers the legacy plaintext path for broadcasts/channels
// until channel crypto lands.
if (plaintext === null && ciphertext) {
try {
plaintext = Buffer.from(ciphertext, "base64").toString("utf-8");
} catch {
plaintext = null;
}
}
const push: InboundPush = {
messageId: String(msg.messageId ?? ""),
meshId: String(msg.meshId ?? ""),
senderPubkey,
priority: (msg.priority as Priority) ?? "next",
nonce,
ciphertext,
createdAt: String(msg.createdAt ?? ""),
receivedAt: new Date().toISOString(),
plaintext,
kind,
};
this.pushBuffer.push(push);
if (this.pushBuffer.length > 500) this.pushBuffer.shift();
for (const h of this.pushHandlers) {
try {
h(push);
} catch {
/* handler errors are not the transport's problem */
}
}
})();
return;
}
if (msg.type === "error") {