feat: v0.1.10 — per-session ephemeral keypairs
Some checks failed
Some checks failed
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<void> {
|
||||
const link = args[0];
|
||||
@@ -78,6 +81,16 @@ export async function runJoin(args: string[]): Promise<void> {
|
||||
});
|
||||
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(
|
||||
|
||||
@@ -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<void> {
|
||||
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,
|
||||
|
||||
@@ -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<PushHandler>();
|
||||
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<void>((resolve, reject) => {
|
||||
const onOpen = async (): Promise<void> => {
|
||||
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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "mesh"."presence" ADD COLUMN "session_pubkey" text;
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user