From af8f8ed1f947f5ec7a0f6034291ae6676702b614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:14:33 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20v0.1.10=20=E2=80=94=20per-session=20eph?= =?UTF-8?q?emeral=20keypairs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each WS connection generates its own ed25519 keypair (sessionPubkey) sent in the hello handshake. The broker stores it on the presence row and uses it for message routing + list_peers. This gives every `claudemesh launch` a unique crypto identity without burning invite uses — member auth stays permanent, session identity is ephemeral. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/broker/src/broker.ts | 12 ++++++++---- apps/broker/src/index.ts | 8 +++++++- apps/broker/src/types.ts | 1 + apps/cli/package.json | 2 +- apps/cli/src/commands/join.ts | 15 ++++++++++++++- apps/cli/src/commands/launch.ts | 11 ++++------- apps/cli/src/ws/client.ts | 15 ++++++++++++--- .../0005_add-presence-session-pubkey.sql | 1 + packages/db/src/schema/mesh.ts | 1 + 9 files changed, 49 insertions(+), 17 deletions(-) create mode 100644 packages/db/migrations/0005_add-presence-session-pubkey.sql diff --git a/apps/broker/src/broker.ts b/apps/broker/src/broker.ts index 55fea27..098c65b 100644 --- a/apps/broker/src/broker.ts +++ b/apps/broker/src/broker.ts @@ -307,6 +307,7 @@ export async function refreshStatusFromJsonl( export interface ConnectParams { memberId: string; sessionId: string; + sessionPubkey?: string; displayName?: string; pid: number; cwd: string; @@ -322,6 +323,7 @@ export async function connectPresence( .values({ memberId: params.memberId, sessionId: params.sessionId, + sessionPubkey: params.sessionPubkey ?? null, displayName: params.displayName ?? null, pid: params.pid, cwd: params.cwd, @@ -371,7 +373,8 @@ export async function listPeersInMesh( > { const rows = await db .select({ - pubkey: memberTable.peerPubkey, + memberPubkey: memberTable.peerPubkey, + sessionPubkey: presence.sessionPubkey, memberDisplayName: memberTable.displayName, presenceDisplayName: presence.displayName, status: presence.status, @@ -388,9 +391,9 @@ export async function listPeersInMesh( ), ) .orderBy(asc(presence.connectedAt)); - // Prefer per-session displayName over member-level displayName. + // Prefer session pubkey for routing, session displayName for display. return rows.map((r) => ({ - pubkey: r.pubkey, + pubkey: r.sessionPubkey || r.memberPubkey, displayName: r.presenceDisplayName || r.memberDisplayName, status: r.status, summary: r.summary, @@ -469,6 +472,7 @@ export async function drainForMember( _memberId: string, memberPubkey: string, status: PeerStatus, + sessionPubkey?: string, ): Promise< Array<{ id: string; @@ -509,7 +513,7 @@ export async function drainForMember( WHERE mesh_id = ${meshId} AND delivered_at IS NULL AND priority::text IN (${priorityList}) - AND (target_spec = ${memberPubkey} OR target_spec = '*') + AND (target_spec = ${memberPubkey} OR target_spec = '*'${sessionPubkey ? sql` OR target_spec = ${sessionPubkey}` : sql``}) ORDER BY created_at ASC, id ASC FOR UPDATE SKIP LOCKED ) diff --git a/apps/broker/src/index.ts b/apps/broker/src/index.ts index 91a9b31..9bfa2ec 100644 --- a/apps/broker/src/index.ts +++ b/apps/broker/src/index.ts @@ -56,6 +56,7 @@ interface PeerConn { meshId: string; memberId: string; memberPubkey: string; + sessionPubkey: string | null; cwd: string; } @@ -93,6 +94,7 @@ async function maybePushQueuedMessages(presenceId: string): Promise { conn.memberId, conn.memberPubkey, status, + conn.sessionPubkey ?? undefined, ); for (const m of messages) { const push: WSPushMessage = { @@ -400,6 +402,7 @@ async function handleHello( const presenceId = await connectPresence({ memberId: member.id, sessionId: hello.sessionId, + sessionPubkey: hello.sessionPubkey, displayName: hello.displayName, pid: hello.pid, cwd: hello.cwd, @@ -409,6 +412,7 @@ async function handleHello( meshId: hello.meshId, memberId: member.id, memberPubkey: hello.pubkey, + sessionPubkey: hello.sessionPubkey ?? null, cwd: hello.cwd, }); incMeshCount(hello.meshId); @@ -450,7 +454,9 @@ async function handleSend( // Fan-out over connected peers in the same mesh. for (const [pid, peer] of connections) { if (peer.meshId !== conn.meshId) continue; - if (msg.targetSpec !== "*" && peer.memberPubkey !== msg.targetSpec) + if (msg.targetSpec !== "*" + && peer.memberPubkey !== msg.targetSpec + && peer.sessionPubkey !== msg.targetSpec) continue; void maybePushQueuedMessages(pid); } diff --git a/apps/broker/src/types.ts b/apps/broker/src/types.ts index b8b6bd1..02a1a11 100644 --- a/apps/broker/src/types.ts +++ b/apps/broker/src/types.ts @@ -52,6 +52,7 @@ export interface WSHelloMessage { meshId: string; memberId: string; pubkey: string; // must match mesh.member.peerPubkey + sessionPubkey?: string; // ephemeral per-launch pubkey for message routing displayName?: string; // optional override for this session sessionId: string; pid: number; diff --git a/apps/cli/package.json b/apps/cli/package.json index 1dd187a..2c3fc15 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "0.1.9", + "version": "0.1.10", "description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.", "keywords": [ "claude-code", diff --git a/apps/cli/src/commands/join.ts b/apps/cli/src/commands/join.ts index cfd9a50..96ebec5 100644 --- a/apps/cli/src/commands/join.ts +++ b/apps/cli/src/commands/join.ts @@ -14,7 +14,10 @@ import { parseInviteLink } from "../invite/parse"; import { enrollWithBroker } from "../invite/enroll"; import { generateKeypair } from "../crypto/keypair"; import { loadConfig, saveConfig, getConfigPath } from "../state/config"; -import { hostname } from "node:os"; +import { writeFileSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { homedir, hostname } from "node:os"; +import { env } from "../env"; export async function runJoin(args: string[]): Promise { const link = args[0]; @@ -78,6 +81,16 @@ export async function runJoin(args: string[]): Promise { }); saveConfig(config); + // 4b. Store invite token for per-session re-enrollment (launch --name). + const configDir = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh"); + const inviteFile = join(configDir, `invite-${payload.mesh_slug}.txt`); + try { + mkdirSync(dirname(inviteFile), { recursive: true }); + writeFileSync(inviteFile, link, "utf-8"); + } catch { + // Non-fatal — launch will fall back to shared identity. + } + // 5. Report. console.log(""); console.log( diff --git a/apps/cli/src/commands/launch.ts b/apps/cli/src/commands/launch.ts index b4fffae..027cb97 100644 --- a/apps/cli/src/commands/launch.ts +++ b/apps/cli/src/commands/launch.ts @@ -17,9 +17,6 @@ import { join } from "node:path"; import { createInterface } from "node:readline"; import { loadConfig, getConfigPath } from "../state/config"; import type { Config, JoinedMesh } from "../state/config"; -import { generateKeypair } from "../crypto/keypair"; -import { enrollWithBroker } from "../invite/enroll"; -import { parseInviteLink } from "../invite/parse"; // --- Arg parsing --- @@ -174,12 +171,12 @@ export async function runLaunch(extraArgs: string[]): Promise { mesh = await pickMesh(config.meshes); } - // 3. Set display name. Uses existing member identity — the broker - // creates a separate presence row per session (sessionId + pid) - // and stores the per-session displayName override. + // 3. Session identity. The WS client auto-generates a per-session + // ephemeral keypair on connect (sent in hello as sessionPubkey). + // We just set the display name via env var. const displayName = args.name ?? `${hostname()}-${process.pid}`; - // 4. Write session config to tmpdir (same mesh, same keypair). + // 4. Write session config to tmpdir (isolates mesh selection). const tmpDir = mkdtempSync(join(tmpdir(), "claudemesh-")); const sessionConfig: Config = { version: 1, diff --git a/apps/cli/src/ws/client.ts b/apps/cli/src/ws/client.ts index 2ddaa3b..10ab5ab 100644 --- a/apps/cli/src/ws/client.ts +++ b/apps/cli/src/ws/client.ts @@ -21,6 +21,7 @@ import { isDirectTarget, } from "../crypto/envelope"; import { signHello } from "../crypto/hello-sig"; +import { generateKeypair } from "../crypto/keypair"; export type Priority = "now" | "next" | "low"; export type ConnStatus = "connecting" | "open" | "closed" | "reconnecting"; @@ -74,6 +75,8 @@ export class BrokerClient { private pushHandlers = new Set(); private pushBuffer: InboundPush[] = []; private listPeersResolvers: Array<(peers: PeerInfo[]) => void> = []; + private sessionPubkey: string | null = null; + private sessionSecretKey: string | null = null; private closed = false; private reconnectAttempt = 0; private helloTimer: NodeJS.Timeout | null = null; @@ -109,8 +112,13 @@ export class BrokerClient { return new Promise((resolve, reject) => { const onOpen = async (): Promise => { - this.debug("ws open → signing + sending hello"); + this.debug("ws open → generating session keypair + signing hello"); try { + // Generate per-session ephemeral keypair for message routing. + const sessionKP = await generateKeypair(); + this.sessionPubkey = sessionKP.publicKey; + this.sessionSecretKey = sessionKP.secretKey; + const { timestamp, signature } = await signHello( this.mesh.meshId, this.mesh.memberId, @@ -123,6 +131,7 @@ export class BrokerClient { meshId: this.mesh.meshId, memberId: this.mesh.memberId, pubkey: this.mesh.pubkey, + sessionPubkey: this.sessionPubkey, displayName: process.env.CLAUDEMESH_DISPLAY_NAME || undefined, sessionId: `${process.pid}-${Date.now()}`, pid: process.pid, @@ -203,7 +212,7 @@ export class BrokerClient { const env = await encryptDirect( message, targetSpec, - this.mesh.secretKey, + this.sessionSecretKey ?? this.mesh.secretKey, ); nonce = env.nonce; ciphertext = env.ciphertext; @@ -349,7 +358,7 @@ export class BrokerClient { plaintext = await decryptDirect( { nonce, ciphertext }, senderPubkey, - this.mesh.secretKey, + this.sessionSecretKey ?? this.mesh.secretKey, ); } // Legacy/broadcast path: no senderPubkey means the message diff --git a/packages/db/migrations/0005_add-presence-session-pubkey.sql b/packages/db/migrations/0005_add-presence-session-pubkey.sql new file mode 100644 index 0000000..95eea1e --- /dev/null +++ b/packages/db/migrations/0005_add-presence-session-pubkey.sql @@ -0,0 +1 @@ +ALTER TABLE "mesh"."presence" ADD COLUMN "session_pubkey" text; \ No newline at end of file diff --git a/packages/db/src/schema/mesh.ts b/packages/db/src/schema/mesh.ts index d21346f..d0a7649 100644 --- a/packages/db/src/schema/mesh.ts +++ b/packages/db/src/schema/mesh.ts @@ -192,6 +192,7 @@ export const presence = meshSchema.table("presence", { .references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" }) .notNull(), sessionId: text().notNull(), + sessionPubkey: text(), displayName: text(), pid: integer().notNull(), cwd: text().notNull(),