Files
claudemesh/apps/cli/tests/unit/inbound-undecryptable.test.ts
Alejandro Gutiérrez b5bb19c59c fix(cli): never inject undecryptable ciphertext into a session (bug2)
A sealed DM (nonce + senderPubkey) that no key opened fell through to a
base64 "plaintext" fallback. Buffer#toString never throws — invalid bytes
become U+FFFD — so the raw ciphertext reached Claude as
«21e425db: ��a.�tH�/…» (flexicar report 2026-09-26).

- daemon/inbound: the base64 fallback only applies to unsealed v1
  broadcasts, decoded with a fatal TextDecoder + renderable-text check.
  An undecryptable topic post renders an honest placeholder pointing at
  `topic tail`; anything else is dropped: logged `inbound_decrypt_failed`,
  acked (stops redelivery), no inbox row, no bus event.
- mcp/server: second guard — a body with U+FFFD or C0 controls is never
  emitted as a channel notification.
- utils/text: isRenderableText + decodeBase64Utf8.

Test fails 3/6 on the old inbound.ts, passes on the new one.
Spec: .artifacts/specs/2026-09-26-session-identity-and-routing.md §1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 21:29:03 +01:00

110 lines
4.2 KiB
TypeScript
Raw Permalink Blame History

/**
* Spec 2026-09-26 §1 (bug2): an undecryptable push must never reach a
* Claude session as text. Before 1.38.0 a sealed DM that no key opened
* was base64-decoded and rendered as raw binary («21e425db: <20><>a.<2E>tH<74>/…»).
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomBytes } from "node:crypto";
import { openSqlite, type SqliteDb } from "~/daemon/db/sqlite.js";
import { migrateInbox } from "~/daemon/db/inbox.js";
import { EventBus } from "~/daemon/events.js";
import { handleBrokerPush, type InboundContext } from "~/daemon/inbound.js";
import { encryptDirect, generateKeypair } from "~/services/crypto/facade.js";
import { decodeBase64Utf8, isRenderableText } from "~/utils/text.js";
describe("inbound: undecryptable payloads", () => {
let dir: string;
let db: SqliteDb;
let events: Array<Record<string, unknown>>;
let logs: string[];
let acks: string[];
let ctx: InboundContext;
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), "cmh-inbound-"));
db = await openSqlite(join(dir, "inbox.db"));
migrateInbox(db);
events = [];
logs = [];
acks = [];
const bus = new EventBus();
bus.subscribe((e) => { if (e.kind === "message") events.push(e.data); });
const me = await generateKeypair();
ctx = {
db, bus, meshSlug: "test",
recipientSecretKeyHex: me.secretKey,
ackClientMessage: (cid) => acks.push(cid),
log: (_l, msg) => logs.push(msg),
};
});
afterEach(() => {
try { db.close(); } catch { /* ignore */ }
rmSync(dir, { recursive: true, force: true });
});
it("drops a sealed DM no key opens: no event, no row, logged, acked", async () => {
const sender = await generateKeypair();
const stranger = await generateKeypair();
// Sealed for somebody else — our key can't open it.
const env = await encryptDirect("secret", stranger.publicKey, sender.secretKey);
await handleBrokerPush({
type: "push", messageId: "m1", senderPubkey: sender.publicKey,
nonce: env.nonce, ciphertext: env.ciphertext,
}, ctx);
expect(events).toHaveLength(0);
expect(db.prepare("SELECT COUNT(*) AS n FROM inbox").get()).toEqual({ n: 0 });
expect(logs).toContain("inbound_decrypt_failed");
expect(acks).toEqual(["m1"]);
});
it("renders a placeholder for a topic post it can't open", async () => {
const sender = await generateKeypair();
await handleBrokerPush({
type: "push", messageId: "m2", senderPubkey: sender.publicKey, topic: "compras-orq",
nonce: Buffer.from(randomBytes(24)).toString("base64"),
ciphertext: Buffer.from(randomBytes(64)).toString("base64"),
}, ctx);
expect(events).toHaveLength(1);
expect(String(events[0]!.body)).toContain("claudemesh topic tail compras-orq");
});
it("still decrypts a DM sealed for us", async () => {
const sender = await generateKeypair();
const me = await generateKeypair();
ctx.recipientSecretKeyHex = me.secretKey;
const env = await encryptDirect("hola", me.publicKey, sender.secretKey);
await handleBrokerPush({
type: "push", messageId: "m3", senderPubkey: sender.publicKey,
nonce: env.nonce, ciphertext: env.ciphertext,
}, ctx);
expect(events.map((e) => e.body)).toEqual(["hola"]);
});
it("keeps the v1 base64 broadcast path for valid text", async () => {
await handleBrokerPush({
type: "push", messageId: "m4", ciphertext: Buffer.from("hello mesh").toString("base64"),
}, ctx);
expect(events.map((e) => e.body)).toEqual(["hello mesh"]);
});
it("drops a v1 broadcast whose bytes aren't text", async () => {
await handleBrokerPush({
type: "push", messageId: "m5", ciphertext: Buffer.from([0xff, 0xfe, 0x00, 0x9c]).toString("base64"),
}, ctx);
expect(events).toHaveLength(0);
});
it("text helpers", () => {
expect(isRenderableText("línea\ncon\ttab")).toBe(true);
expect(isRenderableText("bad <20>")).toBe(false);
expect(isRenderableText("bell \u0007")).toBe(false);
expect(decodeBase64Utf8(Buffer.from([0xc3, 0x28]).toString("base64"))).toBeNull();
});
});