From f144e0485a7acface8a3a269c878310ae6b360fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:22:33 +0100 Subject: [PATCH] fix(cli): no base64 fallback on direct-message decrypt failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The push handler previously fell through to base64-decoding the raw ciphertext whenever decryptDirect() returned null. For direct (crypto_box) messages that produces garbage binary which surfaces as garbled bytes in Claude's reminder. Limit the base64 fallback to legacy broadcast/channel messages (no senderPubkey), and emit a clearer "⚠ message from failed to decrypt" warning when direct decryption fails. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/cli/src/mcp/server.ts | 11 ++++++++--- apps/cli/src/ws/client.ts | 12 ++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/mcp/server.ts b/apps/cli/src/mcp/server.ts index ab1469a..d9d885c 100644 --- a/apps/cli/src/mcp/server.ts +++ b/apps/cli/src/mcp/server.ts @@ -73,8 +73,13 @@ function resolveClient(to: string): { }; } +function decryptFailedWarning(senderPubkey: string): string { + const who = senderPubkey ? senderPubkey.slice(0, 12) + "…" : "unknown sender"; + return `⚠ message from ${who} failed to decrypt (tampered or wrong keypair)`; +} + function formatPush(p: InboundPush, meshSlug: string): string { - const body = p.plaintext ?? "(decryption failed)"; + const body = p.plaintext ?? decryptFailedWarning(p.senderPubkey); return `[${meshSlug}] from ${p.senderPubkey.slice(0, 12)}… (${p.priority}, ${p.createdAt}):\n${body}`; } @@ -82,7 +87,7 @@ export async function startMcpServer(): Promise { const config = loadConfig(); const server = new Server( - { name: "claudemesh", version: "0.1.1" }, + { name: "claudemesh", version: "0.1.2" }, { capabilities: { experimental: { "claude/channel": {} }, @@ -215,7 +220,7 @@ If you have multiple joined meshes, prefix the \`to\` argument of send_message w const fromName = fromPubkey ? `peer-${fromPubkey.slice(0, 8)}` : "unknown"; - const content = msg.plaintext ?? "(decryption failed)"; + const content = msg.plaintext ?? decryptFailedWarning(fromPubkey); try { await server.notification({ method: "notifications/claude/channel", diff --git a/apps/cli/src/ws/client.ts b/apps/cli/src/ws/client.ts index a6e8d0f..9e15540 100644 --- a/apps/cli/src/ws/client.ts +++ b/apps/cli/src/ws/client.ts @@ -312,10 +312,14 @@ export class BrokerClient { 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) { + // Legacy/broadcast path: no senderPubkey means the message + // was not crypto_box'd, so base64 UTF-8 unwrap is correct. + // For direct messages (senderPubkey present) we MUST NOT + // base64-decode the ciphertext on decrypt failure — that + // produces garbage binary that surfaces as garbled bytes + // to Claude. Leave plaintext=null and let consumers emit + // a clear "failed to decrypt" warning. + if (plaintext === null && ciphertext && !senderPubkey) { try { plaintext = Buffer.from(ciphertext, "base64").toString("utf-8"); } catch {