Skip to content

Commit 52bf2d7

Browse files
Baudbotbenvinegar
authored andcommitted
security: fix 9 review findings — auth code reuse, OAuth hijack, crypto, token encryption
Critical fixes: 1. Auth code reuse: clear auth_code_hash after activation, reject re-registration of already-active workspaces (409 Conflict) 2. Re-OAuth overwrites active workspace: check workspace status in OAuth callback, reject re-install when server is active 3. Sealed box nonce: switch from SHA-512 to libsodium-wrappers-sumo with native crypto_box_seal (BLAKE2B nonce, spec-compliant) Important fixes: 4. Pipe delimiter injection: validate workspace_id matches /^T[A-Z0-9]+$/ 5. Unsalted hash: switch hashAuthCode from SHA-256 to HMAC-SHA256 keyed with BROKER_PRIVATE_KEY 6. Bot token plaintext in KV: encrypt with nacl.secretbox using key derived from BROKER_PRIVATE_KEY, decrypt on read 7. HTTPS enforcement: add protocol check in forwardEvent() 8. Rate limiting: add TODO comment noting Phase 3 requirement 9. zeroBytes docs: clarify best-effort cleanup, JS string limitation Tests: 54 → 64 (10 new tests covering all security changes)
1 parent 7159b8f commit 52bf2d7

13 files changed

Lines changed: 298 additions & 85 deletions

File tree

slack-broker/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ Supported actions: `chat.postMessage`, `reactions.add`, `chat.update`.
113113

114114
| Primitive | Use | Library |
115115
|-----------|-----|---------|
116-
| `crypto_box_seal` (X25519 + XSalsa20-Poly1305) | Inbound: Slack → server | tweetnacl |
116+
| `crypto_box_seal` (X25519 + XSalsa20-Poly1305) | Inbound: Slack → server | libsodium-wrappers-sumo |
117117
| `crypto_box` (X25519 + XSalsa20-Poly1305) | Outbound: server → Slack | tweetnacl |
118118
| Ed25519 | Envelope signatures | tweetnacl |
119119
| HMAC-SHA256 | Slack request verification | Web Crypto API |
@@ -133,13 +133,15 @@ Supported actions: `chat.postMessage`, `reactions.add`, `chat.update`.
133133
- ✅ Server authenticates broker (broker signs envelopes)
134134
- ✅ Broker authenticates server (server signs outbound requests)
135135
- ✅ Replay protection (timestamps + nonces on all messages)
136-
- ✅ Auth code verification for server registration
136+
- ✅ Auth code verification for server registration (one-time use, HMAC-SHA256)
137+
- ✅ Bot tokens encrypted at rest in KV (nacl.secretbox)
137138
- ❌ Perfect forward secrecy (would need session keys — future enhancement)
139+
- ❌ Rate limiting (Phase 3 — pre-production requirement)
138140

139141
### What the Broker Can See
140142

141143
- Routing metadata: workspace_id, channel, thread_ts, timestamps
142-
- Outbound message content: **transiently** (decrypted in memory to post to Slack, then zeroed)
144+
- Outbound message content: **transiently** (decrypted in memory to post to Slack, then best-effort zeroed — JS strings from JSON.parse cannot be deterministically zeroed, only the underlying Uint8Array buffer is cleared)
143145

144146
### What the Broker Cannot See
145147

slack-broker/package-lock.json

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

slack-broker/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"typecheck": "tsc --noEmit"
1212
},
1313
"dependencies": {
14+
"libsodium-wrappers-sumo": "^0.8.2",
1415
"tweetnacl": "^1.0.3"
1516
},
1617
"devDependencies": {

slack-broker/src/api/register.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ export async function handleRegister(
7373
return jsonResponse({ ok: false, error: "missing required fields" }, 400);
7474
}
7575

76+
// Validate workspace_id matches Slack team ID format to prevent
77+
// pipe-delimiter injection in canonicalized signatures.
78+
if (!/^T[A-Z0-9]+$/.test(body.workspace_id)) {
79+
return jsonResponse({ ok: false, error: "invalid workspace_id format" }, 400);
80+
}
81+
7682
// Validate callback URL
7783
try {
7884
const url = new URL(body.server_callback_url);
@@ -89,8 +95,18 @@ export async function handleRegister(
8995
return jsonResponse({ ok: false, error: "workspace not found — complete OAuth install first" }, 404);
9096
}
9197

92-
// Verify auth code
93-
const providedHash = await hashAuthCode(body.auth_code);
98+
// Reject re-registration of already-active workspaces.
99+
// The current server must unregister first (DELETE /api/register).
100+
if (workspace.status === "active") {
101+
return jsonResponse({ ok: false, error: "workspace already active — unregister the current server first" }, 409);
102+
}
103+
104+
// Verify auth code (must not be empty — cleared after first successful registration)
105+
if (!workspace.auth_code_hash) {
106+
return jsonResponse({ ok: false, error: "auth code already consumed — re-install the Slack app to generate a new one" }, 403);
107+
}
108+
109+
const providedHash = await hashAuthCode(body.auth_code, env.BROKER_PRIVATE_KEY);
94110
if (providedHash !== workspace.auth_code_hash) {
95111
return jsonResponse({ ok: false, error: "invalid auth code" }, 403);
96112
}

slack-broker/src/api/send.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
import { boxDecrypt, zeroBytes } from "../crypto/box.js";
3030
import { verify, canonicalizeOutbound } from "../crypto/verify.js";
3131
import { decodeBase64, decodeUTF8 } from "../util/encoding.js";
32-
import { getWorkspace } from "../routing/registry.js";
32+
import { getWorkspace, decryptBotToken } from "../routing/registry.js";
3333
import { postMessage, addReaction, updateMessage } from "../slack/api.js";
3434
import type { Env } from "../index.js";
3535

@@ -140,9 +140,12 @@ export async function handleSend(
140140

141141
// Execute the Slack API call
142142
try {
143+
// Decrypt the bot token (encrypted at rest in KV)
144+
const botToken = decryptBotToken(workspace.bot_token, env.BROKER_PRIVATE_KEY);
145+
143146
const result = await executeSlackAction(
144147
body.action as SlackAction,
145-
workspace.bot_token,
148+
botToken,
146149
body.routing,
147150
decryptedBody,
148151
);

slack-broker/src/crypto/box.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,13 @@ export function boxDecrypt(
7474
}
7575

7676
/**
77-
* Zero out a Uint8Array to minimize plaintext residence in memory.
78-
* Call this after posting the decrypted content to Slack.
77+
* Best-effort memory cleanup — zeroes a Uint8Array to reduce plaintext
78+
* residence in memory. Call this after posting the decrypted content to Slack.
79+
*
80+
* NOTE: This only zeroes the Uint8Array buffer. JS strings derived from the
81+
* buffer (e.g. via TextDecoder or JSON.parse) are immutable and cannot be
82+
* deterministically zeroed — they remain in memory until garbage collected.
83+
* This is a limitation of the JS runtime, not a bug.
7984
*/
8085
export function zeroBytes(arr: Uint8Array): void {
8186
arr.fill(0);

slack-broker/src/crypto/seal.ts

Lines changed: 23 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -4,63 +4,44 @@
44
* The broker encrypts with the server's public key. Only the server's
55
* private key can decrypt. The broker CANNOT decrypt sealed boxes.
66
*
7-
* Uses tweetnacl's box.keyPair + secretbox under the hood to implement
8-
* the libsodium crypto_box_seal pattern:
9-
* 1. Generate an ephemeral X25519 keypair
10-
* 2. Compute shared secret: ECDH(ephemeral_sk, recipient_pk)
11-
* 3. Derive nonce from ephemeral_pk + recipient_pk
12-
* 4. Encrypt payload with crypto_box using the shared secret
13-
* 5. Output: ephemeral_pk || ciphertext
7+
* Uses libsodium's native crypto_box_seal / crypto_box_seal_open for
8+
* interoperability with standard libsodium implementations on the server.
9+
* The nonce is derived using BLAKE2B(ephemeral_pk || recipient_pk) as
10+
* per the libsodium spec.
1411
*/
1512

16-
import nacl from "tweetnacl";
17-
import { decodeBase64, encodeBase64 } from "../util/encoding.js";
13+
import _sodium from "libsodium-wrappers-sumo";
14+
import { encodeBase64, decodeBase64 } from "../util/encoding.js";
1815

1916
/** Length of an X25519 public key in bytes. */
2017
const PUBLIC_KEY_BYTES = 32;
2118

22-
/**
23-
* Derive a nonce from the ephemeral public key and recipient public key.
24-
* Uses the first 24 bytes of SHA-512(ephemeral_pk || recipient_pk).
25-
*/
26-
async function deriveNonce(
27-
ephemeralPk: Uint8Array,
28-
recipientPk: Uint8Array,
29-
): Promise<Uint8Array> {
30-
const input = new Uint8Array(PUBLIC_KEY_BYTES * 2);
31-
input.set(ephemeralPk, 0);
32-
input.set(recipientPk, PUBLIC_KEY_BYTES);
33-
const hash = await crypto.subtle.digest("SHA-512", input);
34-
return new Uint8Array(hash).slice(0, nacl.box.nonceLength);
19+
/** Ensure libsodium is initialized before use. */
20+
async function sodium(): Promise<typeof _sodium> {
21+
await _sodium.ready;
22+
return _sodium;
3523
}
3624

3725
/**
38-
* Encrypt a message using a sealed box (crypto_box_seal equivalent).
26+
* Encrypt a message using a sealed box (crypto_box_seal).
3927
*
40-
* Returns base64-encoded ciphertext: ephemeral_pk (32 bytes) || box output.
28+
* Returns base64-encoded ciphertext (ephemeral_pk || box output).
4129
* Only the holder of `recipientPublicKey`'s corresponding private key can decrypt.
30+
*
31+
* Uses libsodium's native implementation with BLAKE2B nonce derivation
32+
* for interoperability with standard libsodium on the server side.
4233
*/
4334
export async function sealedBoxEncrypt(
4435
plaintext: Uint8Array,
4536
recipientPublicKey: Uint8Array,
4637
): Promise<string> {
47-
const ephemeral = nacl.box.keyPair();
48-
const nonce = await deriveNonce(ephemeral.publicKey, recipientPublicKey);
49-
const ciphertext = nacl.box(plaintext, nonce, recipientPublicKey, ephemeral.secretKey);
50-
51-
if (!ciphertext) {
52-
throw new Error("sealedBoxEncrypt: encryption failed");
53-
}
54-
55-
// Output: ephemeral_pk || ciphertext
56-
const sealed = new Uint8Array(PUBLIC_KEY_BYTES + ciphertext.length);
57-
sealed.set(ephemeral.publicKey, 0);
58-
sealed.set(ciphertext, PUBLIC_KEY_BYTES);
38+
const s = await sodium();
39+
const sealed = s.crypto_box_seal(plaintext, recipientPublicKey);
5940
return encodeBase64(sealed);
6041
}
6142

6243
/**
63-
* Decrypt a sealed box (crypto_box_seal_open equivalent).
44+
* Decrypt a sealed box (crypto_box_seal_open).
6445
*
6546
* Used on the SERVER side (not in the broker for inbound messages).
6647
* Included here for testing and for potential future use.
@@ -70,20 +51,16 @@ export async function sealedBoxDecrypt(
7051
recipientPublicKey: Uint8Array,
7152
recipientSecretKey: Uint8Array,
7253
): Promise<Uint8Array> {
54+
const s = await sodium();
7355
const sealed = decodeBase64(sealedBase64);
7456

75-
if (sealed.length < PUBLIC_KEY_BYTES + nacl.box.overheadLength) {
57+
if (sealed.length < PUBLIC_KEY_BYTES + s.crypto_box_MACBYTES) {
7658
throw new Error("sealedBoxDecrypt: ciphertext too short");
7759
}
7860

79-
const ephemeralPk = sealed.slice(0, PUBLIC_KEY_BYTES);
80-
const ciphertext = sealed.slice(PUBLIC_KEY_BYTES);
81-
const nonce = await deriveNonce(ephemeralPk, recipientPublicKey);
82-
const plaintext = nacl.box.open(ciphertext, nonce, ephemeralPk, recipientSecretKey);
83-
84-
if (!plaintext) {
61+
try {
62+
return s.crypto_box_seal_open(sealed, recipientPublicKey, recipientSecretKey);
63+
} catch {
8564
throw new Error("sealedBoxDecrypt: decryption failed — invalid key or corrupted data");
8665
}
87-
88-
return plaintext;
8966
}

slack-broker/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ export default {
8484
_ctx: ctx,
8585
};
8686

87+
// TODO(Phase 3): Add rate limiting before production deployment.
88+
// Per-workspace and per-IP limits on /api/send, /api/register, and /slack/events.
89+
// Cloudflare Rate Limiting rules or a KV-based token bucket are both viable.
90+
8791
// Route requests
8892
try {
8993
// Health check

slack-broker/src/routing/forward.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@ export async function forwardEvent(
5050
return { ok: false, error: "workspace missing server configuration" };
5151
}
5252

53+
// Enforce HTTPS for all server callback URLs
54+
try {
55+
const url = new URL(workspace.server_url);
56+
if (url.protocol !== "https:") {
57+
return { ok: false, error: "server URL must use HTTPS" };
58+
}
59+
} catch {
60+
return { ok: false, error: "invalid server URL" };
61+
}
62+
5363
// Serialize and encrypt
5464
const plaintext = encodeUTF8(JSON.stringify(event));
5565
const serverPubkey = decodeBase64(workspace.server_pubkey);

0 commit comments

Comments
 (0)