Skip to content

Commit f60ad4f

Browse files
baudbot-agentBaudbot
andauthored
Fix: Handle libsodium decryption errors as poison messages (#177)
Co-authored-by: Baudbot <hornet@agentmail.to>
1 parent 84f6d93 commit f60ad4f

2 files changed

Lines changed: 145 additions & 5 deletions

File tree

slack-bridge/broker-bridge.mjs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -786,11 +786,18 @@ function verifyBrokerEnvelope(message) {
786786
}
787787

788788
function decryptEnvelope(message) {
789-
const plaintext = sodium.crypto_box_seal_open(
790-
fromBase64(message.encrypted),
791-
cryptoState.serverBoxPublicKey,
792-
cryptoState.serverBoxSecretKey,
793-
);
789+
let plaintext;
790+
try {
791+
plaintext = sodium.crypto_box_seal_open(
792+
fromBase64(message.encrypted),
793+
cryptoState.serverBoxPublicKey,
794+
cryptoState.serverBoxSecretKey,
795+
);
796+
} catch (err) {
797+
// Wrap libsodium errors (e.g., "incorrect key pair for the given ciphertext")
798+
// into a format that isPoisonMessageError() can detect
799+
throw new Error(`failed to decrypt broker envelope (message_id: ${message.message_id || "unknown"})`);
800+
}
794801
if (!plaintext) {
795802
throw new Error(`failed to decrypt broker envelope (message_id: ${message.message_id || "unknown"})`);
796803
}

test/broker-bridge.integration.test.mjs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,139 @@ describe("broker pull bridge semi-integration", () => {
308308
expect(valid).toBe(true);
309309
});
310310

311+
it("acks poison messages with decryption failures (wrong keys)", async () => {
312+
await sodium.ready;
313+
314+
let pullCount = 0;
315+
let ackPayload = null;
316+
317+
// Generate valid encryption with mismatched keys to trigger "incorrect key pair"
318+
const wrongBoxKeypair = sodium.crypto_box_keypair();
319+
const serverBoxKeypair = sodium.crypto_box_seed_keypair(new Uint8Array(Buffer.alloc(32, 11)));
320+
const brokerSignKeypair = sodium.crypto_sign_seed_keypair(new Uint8Array(Buffer.alloc(32, 15)));
321+
322+
const payload = JSON.stringify({ source: "slack", type: "message", payload: { text: "test" }, broker_timestamp: 123 });
323+
// Encrypt with WRONG key (wrongBoxKeypair) but bridge will try to decrypt with serverBoxKeypair
324+
const encrypted = sodium.crypto_box_seal(new TextEncoder().encode(payload), wrongBoxKeypair.publicKey);
325+
const encryptedB64 = toBase64(encrypted);
326+
327+
const broker = createServer(async (req, res) => {
328+
if (req.method === "POST" && req.url === "/api/inbox/pull") {
329+
pullCount += 1;
330+
const brokerTimestamp = Math.floor(Date.now() / 1000);
331+
332+
const canonical = canonicalizeEnvelope("T123BROKER", brokerTimestamp, encryptedB64);
333+
const signature = sodium.crypto_sign_detached(canonical, brokerSignKeypair.privateKey);
334+
335+
const messages = pullCount === 1
336+
? [{
337+
message_id: "m-decrypt-fail-1",
338+
workspace_id: "T123BROKER",
339+
encrypted: encryptedB64,
340+
broker_timestamp: brokerTimestamp,
341+
broker_signature: toBase64(signature),
342+
}]
343+
: [];
344+
345+
res.writeHead(200, { "Content-Type": "application/json" });
346+
res.end(JSON.stringify({ ok: true, messages }));
347+
return;
348+
}
349+
350+
if (req.method === "POST" && req.url === "/api/inbox/ack") {
351+
let raw = "";
352+
for await (const chunk of req) raw += chunk;
353+
ackPayload = JSON.parse(raw);
354+
355+
res.writeHead(200, { "Content-Type": "application/json" });
356+
res.end(JSON.stringify({ ok: true, acked: ackPayload.message_ids?.length ?? 0 }));
357+
return;
358+
}
359+
360+
if (req.method === "POST" && req.url === "/api/send") {
361+
res.writeHead(200, { "Content-Type": "application/json" });
362+
res.end(JSON.stringify({ ok: true, ts: "1234.5678" }));
363+
return;
364+
}
365+
366+
res.writeHead(404, { "Content-Type": "application/json" });
367+
res.end(JSON.stringify({ ok: false, error: "not found" }));
368+
});
369+
370+
await new Promise((resolve) => broker.listen(0, "127.0.0.1", resolve));
371+
servers.push(broker);
372+
373+
const address = broker.address();
374+
if (!address || typeof address === "string") {
375+
throw new Error("failed to get broker test server address");
376+
}
377+
const brokerUrl = `http://127.0.0.1:${address.port}`;
378+
379+
const testFileDir = path.dirname(fileURLToPath(import.meta.url));
380+
const repoRoot = path.dirname(testFileDir);
381+
const bridgePath = path.join(repoRoot, "slack-bridge", "broker-bridge.mjs");
382+
const bridgeCwd = path.join(repoRoot, "slack-bridge");
383+
384+
let bridgeStdout = "";
385+
let bridgeStderr = "";
386+
let bridgeExit = null;
387+
388+
const bridge = spawn("node", [bridgePath], {
389+
cwd: bridgeCwd,
390+
env: {
391+
...cleanEnv(),
392+
SLACK_BROKER_URL: brokerUrl,
393+
SLACK_BROKER_WORKSPACE_ID: "T123BROKER",
394+
SLACK_BROKER_SERVER_PRIVATE_KEY: toBase64(serverBoxKeypair.privateKey),
395+
SLACK_BROKER_SERVER_PUBLIC_KEY: toBase64(serverBoxKeypair.publicKey),
396+
SLACK_BROKER_SERVER_SIGNING_PRIVATE_KEY: b64(32, 13),
397+
SLACK_BROKER_PUBLIC_KEY: b64(32, 14),
398+
SLACK_BROKER_SIGNING_PUBLIC_KEY: toBase64(brokerSignKeypair.publicKey),
399+
SLACK_BROKER_ACCESS_TOKEN: "test-broker-token",
400+
SLACK_ALLOWED_USERS: "U_ALLOWED",
401+
SLACK_BROKER_POLL_INTERVAL_MS: "50",
402+
BRIDGE_API_PORT: "0",
403+
},
404+
stdio: ["ignore", "pipe", "pipe"],
405+
});
406+
407+
bridge.stdout.on("data", (chunk) => {
408+
bridgeStdout += chunk.toString();
409+
});
410+
bridge.stderr.on("data", (chunk) => {
411+
bridgeStderr += chunk.toString();
412+
});
413+
const bridgeExited = new Promise((_, reject) => {
414+
bridge.on("error", (err) => {
415+
if (ackPayload !== null) return;
416+
reject(new Error(`bridge spawn error: ${err.message}; stdout=${bridgeStdout}; stderr=${bridgeStderr}`));
417+
});
418+
bridge.on("exit", (code, signal) => {
419+
bridgeExit = { code, signal };
420+
if (ackPayload !== null) return;
421+
reject(new Error(`bridge exited early: code=${code} signal=${signal}; stdout=${bridgeStdout}; stderr=${bridgeStderr}`));
422+
});
423+
});
424+
425+
children.push(bridge);
426+
427+
const ackWait = waitFor(
428+
() => ackPayload !== null,
429+
10_000,
430+
50,
431+
`timeout waiting for ack; pullCount=${pullCount}; exit=${JSON.stringify(bridgeExit)}; stdout=${bridgeStdout}; stderr=${bridgeStderr}`,
432+
);
433+
434+
await Promise.race([ackWait, bridgeExited]);
435+
436+
expect(ackPayload.workspace_id).toBe("T123BROKER");
437+
expect(ackPayload.protocol_version).toBe("2026-02-1");
438+
expect(ackPayload.message_ids).toContain("m-decrypt-fail-1");
439+
440+
// Verify that the bridge logged a decryption error before acking
441+
expect(bridgeStderr).toContain("failed to decrypt");
442+
});
443+
311444
it("forwards user messages to agent in fire-and-forget mode without get_message/turn_end RPCs", async () => {
312445
await sodium.ready;
313446

0 commit comments

Comments
 (0)