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>
106 lines
2.8 KiB
TypeScript
106 lines
2.8 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* Seed a minimal "smoke-test" mesh with two members.
|
|
*
|
|
* Idempotent: safe to run repeatedly. Re-creates members by
|
|
* deleting any prior "smoke-test" mesh and its cascaded rows first.
|
|
*
|
|
* Outputs the meshId + both memberIds + both pubkeys as JSON (stdout)
|
|
* so peer-a.ts and peer-b.ts can read them before connecting.
|
|
*/
|
|
|
|
import { eq } from "drizzle-orm";
|
|
import sodium from "libsodium-wrappers";
|
|
import { db } from "../src/db";
|
|
import { mesh, meshMember } from "@turbostarter/db/schema/mesh";
|
|
import { user } from "@turbostarter/db/schema/auth";
|
|
|
|
const USER_ID = "test-user-smoke";
|
|
const MESH_SLUG = "smoke-test";
|
|
|
|
async function main() {
|
|
// Generate real ed25519 keypairs so crypto_box (via ed25519→X25519
|
|
// conversion) works in Step 18+ round-trip tests.
|
|
await sodium.ready;
|
|
const kpA = sodium.crypto_sign_keypair();
|
|
const kpB = sodium.crypto_sign_keypair();
|
|
const PEER_A_PUBKEY = sodium.to_hex(kpA.publicKey);
|
|
const PEER_A_SECRET = sodium.to_hex(kpA.privateKey);
|
|
const PEER_B_PUBKEY = sodium.to_hex(kpB.publicKey);
|
|
const PEER_B_SECRET = sodium.to_hex(kpB.privateKey);
|
|
|
|
// Ensure the test user exists (re-usable across runs).
|
|
const [existingUser] = await db
|
|
.select({ id: user.id })
|
|
.from(user)
|
|
.where(eq(user.id, USER_ID));
|
|
if (!existingUser) {
|
|
await db.insert(user).values({
|
|
id: USER_ID,
|
|
name: "Smoke Test User",
|
|
email: "smoke@claudemesh.test",
|
|
emailVerified: true,
|
|
});
|
|
}
|
|
|
|
// Drop any prior mesh with this slug (cascades to members).
|
|
await db.delete(mesh).where(eq(mesh.slug, MESH_SLUG));
|
|
|
|
// Fresh mesh + 2 members.
|
|
const [m] = await db
|
|
.insert(mesh)
|
|
.values({
|
|
name: "Smoke Test",
|
|
slug: MESH_SLUG,
|
|
ownerUserId: USER_ID,
|
|
visibility: "private",
|
|
transport: "managed",
|
|
tier: "free",
|
|
})
|
|
.returning({ id: mesh.id });
|
|
if (!m) throw new Error("mesh insert failed");
|
|
|
|
const [peerA] = await db
|
|
.insert(meshMember)
|
|
.values({
|
|
meshId: m.id,
|
|
userId: USER_ID,
|
|
peerPubkey: PEER_A_PUBKEY,
|
|
displayName: "peer-a",
|
|
role: "admin",
|
|
})
|
|
.returning({ id: meshMember.id });
|
|
const [peerB] = await db
|
|
.insert(meshMember)
|
|
.values({
|
|
meshId: m.id,
|
|
userId: USER_ID,
|
|
peerPubkey: PEER_B_PUBKEY,
|
|
displayName: "peer-b",
|
|
role: "member",
|
|
})
|
|
.returning({ id: meshMember.id });
|
|
if (!peerA || !peerB) throw new Error("member insert failed");
|
|
|
|
const seed = {
|
|
meshId: m.id,
|
|
peerA: {
|
|
memberId: peerA.id,
|
|
pubkey: PEER_A_PUBKEY,
|
|
secretKey: PEER_A_SECRET,
|
|
},
|
|
peerB: {
|
|
memberId: peerB.id,
|
|
pubkey: PEER_B_PUBKEY,
|
|
secretKey: PEER_B_SECRET,
|
|
},
|
|
};
|
|
console.log(JSON.stringify(seed, null, 2));
|
|
process.exit(0);
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error("[seed] error:", e instanceof Error ? e.message : e);
|
|
process.exit(1);
|
|
});
|