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:
96
apps/cli/src/crypto/envelope.ts
Normal file
96
apps/cli/src/crypto/envelope.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Direct-message encryption via libsodium crypto_box.
|
||||
*
|
||||
* Keys: our peers hold ed25519 signing keypairs (from Step 17).
|
||||
* crypto_box uses X25519 (curve25519) keys, so we convert on the fly
|
||||
* via crypto_sign_ed25519_{pk,sk}_to_curve25519. One signing keypair
|
||||
* serves both purposes cleanly.
|
||||
*
|
||||
* Wire format: {nonce, ciphertext} both base64. Nonce is 24 bytes
|
||||
* (crypto_box_NONCEBYTES), fresh-random per message.
|
||||
*
|
||||
* Broadcasts ("*") and channels ("#foo") are NOT encrypted here —
|
||||
* they need a shared key (mesh_root_key) and land in a later step.
|
||||
*/
|
||||
|
||||
import { ensureSodium } from "./keypair";
|
||||
|
||||
export interface Envelope {
|
||||
nonce: string; // base64
|
||||
ciphertext: string; // base64
|
||||
}
|
||||
|
||||
const HEX_PUBKEY = /^[0-9a-f]{64}$/;
|
||||
|
||||
/** Does this targetSpec look like a direct-message pubkey? */
|
||||
export function isDirectTarget(targetSpec: string): boolean {
|
||||
return HEX_PUBKEY.test(targetSpec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a plaintext message addressed to a single recipient.
|
||||
* Recipient's ed25519 pubkey (64 hex chars) is converted to X25519
|
||||
* on the fly. Sender's full ed25519 secret key (128 hex chars) is
|
||||
* also converted.
|
||||
*/
|
||||
export async function encryptDirect(
|
||||
message: string,
|
||||
recipientPubkeyHex: string,
|
||||
senderSecretKeyHex: string,
|
||||
): Promise<Envelope> {
|
||||
const sodium = await ensureSodium();
|
||||
const recipientPub = sodium.crypto_sign_ed25519_pk_to_curve25519(
|
||||
sodium.from_hex(recipientPubkeyHex),
|
||||
);
|
||||
const senderSec = sodium.crypto_sign_ed25519_sk_to_curve25519(
|
||||
sodium.from_hex(senderSecretKeyHex),
|
||||
);
|
||||
const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES);
|
||||
const ciphertext = sodium.crypto_box_easy(
|
||||
sodium.from_string(message),
|
||||
nonce,
|
||||
recipientPub,
|
||||
senderSec,
|
||||
);
|
||||
return {
|
||||
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
|
||||
ciphertext: sodium.to_base64(ciphertext, sodium.base64_variants.ORIGINAL),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt an inbound envelope from a known sender. Returns null if
|
||||
* decryption fails (wrong keys, tampered ciphertext, malformed input).
|
||||
*/
|
||||
export async function decryptDirect(
|
||||
envelope: Envelope,
|
||||
senderPubkeyHex: string,
|
||||
recipientSecretKeyHex: string,
|
||||
): Promise<string | null> {
|
||||
const sodium = await ensureSodium();
|
||||
try {
|
||||
const senderPub = sodium.crypto_sign_ed25519_pk_to_curve25519(
|
||||
sodium.from_hex(senderPubkeyHex),
|
||||
);
|
||||
const recipientSec = sodium.crypto_sign_ed25519_sk_to_curve25519(
|
||||
sodium.from_hex(recipientSecretKeyHex),
|
||||
);
|
||||
const nonce = sodium.from_base64(
|
||||
envelope.nonce,
|
||||
sodium.base64_variants.ORIGINAL,
|
||||
);
|
||||
const ciphertext = sodium.from_base64(
|
||||
envelope.ciphertext,
|
||||
sodium.base64_variants.ORIGINAL,
|
||||
);
|
||||
const plain = sodium.crypto_box_open_easy(
|
||||
ciphertext,
|
||||
nonce,
|
||||
senderPub,
|
||||
recipientSec,
|
||||
);
|
||||
return sodium.to_string(plain);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -74,13 +74,7 @@ function resolveClient(to: string): {
|
||||
}
|
||||
|
||||
function formatPush(p: InboundPush, meshSlug: string): string {
|
||||
const body = (() => {
|
||||
try {
|
||||
return Buffer.from(p.ciphertext, "base64").toString("utf-8");
|
||||
} catch {
|
||||
return "(invalid base64 ciphertext)";
|
||||
}
|
||||
})();
|
||||
const body = p.plaintext ?? "(decryption failed)";
|
||||
return `[${meshSlug}] from ${p.senderPubkey.slice(0, 12)}… (${p.priority}, ${p.createdAt}):\n${body}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user