@crewhaus/channel-adapter-discord 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/channel-adapter-discord",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Discord channel adapter: Ed25519 signature verification + slash-command/button/modal interaction parsing + interaction-response API (Section 33)",
6
6
  "main": "src/index.ts",
@@ -12,13 +12,13 @@
12
12
  "test": "bun test src"
13
13
  },
14
14
  "dependencies": {
15
- "@crewhaus/errors": "0.1.1"
15
+ "@crewhaus/errors": "0.1.2"
16
16
  },
17
17
  "license": "Apache-2.0",
18
18
  "author": {
19
19
  "name": "Max Meier",
20
- "email": "max@studiomax.io",
21
- "url": "https://studiomax.io"
20
+ "email": "max@crewhaus.ai",
21
+ "url": "https://crewhaus.ai"
22
22
  },
23
23
  "repository": {
24
24
  "type": "git",
@@ -30,12 +30,7 @@
30
30
  "url": "https://github.com/crewhaus/factory/issues"
31
31
  },
32
32
  "publishConfig": {
33
- "access": "restricted"
33
+ "access": "public"
34
34
  },
35
- "files": [
36
- "src",
37
- "README.md",
38
- "LICENSE",
39
- "NOTICE"
40
- ]
35
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
41
36
  }
package/src/index.test.ts CHANGED
@@ -30,7 +30,7 @@ beforeAll(() => {
30
30
  otherPublicKeyHex = generateEd25519Keypair().publicKeyHex;
31
31
  });
32
32
 
33
- function signedHeaders(body: string, ts: string | number = "1700000000"): Headers {
33
+ function signedHeaders(body: string, ts: string | number = Math.floor(Date.now() / 1000)): Headers {
34
34
  const sig = signDiscordBody({ body, timestamp: ts, privateKeyPem });
35
35
  const h = new Headers();
36
36
  h.set("X-Signature-Ed25519", sig);
@@ -117,6 +117,34 @@ describe("verifyDiscordSignature (T8)", () => {
117
117
  expect(verifyDiscordSignature({ headers, body, publicKeyHex })).toBe(false);
118
118
  });
119
119
 
120
+ // SECURITY: the timestamp is signed but was never checked against the clock,
121
+ // so a captured interaction replayed indefinitely (after a daemon restart or
122
+ // LRU dedup eviction). Reject stale/future timestamps, mirroring Slack.
123
+ test("rejects an expired timestamp (replay window)", () => {
124
+ const body = fixture("ping");
125
+ const oldTs = Math.floor(Date.now() / 1000) - 10 * 60; // 10 minutes ago
126
+ expect(
127
+ verifyDiscordSignature({ headers: signedHeaders(body, oldTs), body, publicKeyHex }),
128
+ ).toBe(false);
129
+ });
130
+
131
+ test("rejects a future timestamp", () => {
132
+ const body = fixture("ping");
133
+ const futureTs = Math.floor(Date.now() / 1000) + 10 * 60;
134
+ expect(
135
+ verifyDiscordSignature({ headers: signedHeaders(body, futureTs), body, publicKeyHex }),
136
+ ).toBe(false);
137
+ });
138
+
139
+ test("a stale-but-validly-signed triple verifies when now() is pinned (gate is freshness, not signature)", () => {
140
+ const body = fixture("ping");
141
+ const ts = 1700000000;
142
+ const headers = signedHeaders(body, ts);
143
+ expect(verifyDiscordSignature({ headers, body, publicKeyHex }, { now: () => ts * 1000 })).toBe(
144
+ true,
145
+ );
146
+ });
147
+
120
148
  test("rejects malformed publicKeyHex", () => {
121
149
  const body = fixture("ping");
122
150
  expect(
@@ -287,6 +315,22 @@ describe("sendReply / setTyping (T3)", () => {
287
315
  expect(calls.length).toBe(1);
288
316
  expect(calls[0]?.url).toBe("https://test.discord.local/channels/300000000000000001/typing");
289
317
  });
318
+
319
+ test("setTyping swallows network rejection (best-effort)", async () => {
320
+ // A failed typing indicator must never reject and break the reply flow.
321
+ const f = (async () => {
322
+ throw new Error("network down");
323
+ }) as unknown as typeof fetch;
324
+ const a = adapter({ fetch: f });
325
+ await expect(a.setTyping({ event })).resolves.toBeUndefined();
326
+ });
327
+
328
+ test("setTyping ignores non-2xx responses (best-effort)", async () => {
329
+ const f = (async () =>
330
+ new Response("nope", { status: 500, statusText: "Internal" })) as unknown as typeof fetch;
331
+ const a = adapter({ fetch: f });
332
+ await expect(a.setTyping({ event })).resolves.toBeUndefined();
333
+ });
290
334
  });
291
335
 
292
336
  describe("createDiscordAdapter.verify()", () => {
package/src/index.ts CHANGED
@@ -94,6 +94,8 @@ export type DiscordAdapterConfig = {
94
94
  export type DiscordAdapterOptions = {
95
95
  readonly apiBaseUrl?: string;
96
96
  readonly fetch?: typeof fetch;
97
+ /** Clock injection for the signature replay-window check (tests). */
98
+ readonly now?: () => number;
97
99
  };
98
100
 
99
101
  const DEFAULT_API_BASE_URL = "https://discord.com/api/v10";
@@ -110,11 +112,14 @@ export function createDiscordAdapter(
110
112
  id: "discord",
111
113
 
112
114
  verify(req: RawRequest): boolean {
113
- return verifyDiscordSignature({
114
- headers: req.headers,
115
- body: req.body,
116
- publicKeyHex: config.publicKeyHex,
117
- });
115
+ return verifyDiscordSignature(
116
+ {
117
+ headers: req.headers,
118
+ body: req.body,
119
+ publicKeyHex: config.publicKeyHex,
120
+ },
121
+ opts.now !== undefined ? { now: opts.now } : {},
122
+ );
118
123
  },
119
124
 
120
125
  parseInbound(req: RawRequest): ParsedInbound {
@@ -192,13 +197,19 @@ export function createDiscordAdapter(
192
197
 
193
198
  async setTyping(args: { event: InboundEvent }): Promise<void> {
194
199
  const url = `${apiBaseUrl}/channels/${args.event.channelId}/typing`;
195
- await doFetch(url, {
196
- method: "POST",
197
- headers: {
198
- Authorization: `Bot ${config.botToken}`,
199
- },
200
- });
201
- // Best-effort — do not surface failures.
200
+ // Best-effort — do not surface failures. A failed typing indicator must
201
+ // never reject and break the caller's reply flow, so we swallow both
202
+ // network rejections (DNS/abort/connection) and non-2xx responses.
203
+ try {
204
+ await doFetch(url, {
205
+ method: "POST",
206
+ headers: {
207
+ Authorization: `Bot ${config.botToken}`,
208
+ },
209
+ });
210
+ } catch {
211
+ // ignore — typing indicator is non-essential.
212
+ }
202
213
  },
203
214
  };
204
215
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Isolated test for the defensive `catch` around Node's `crypto.verify` in
3
+ * `verifyDiscordSignature`.
4
+ *
5
+ * With a valid Ed25519 key and a 64-byte signature, OpenSSL's `verify` returns
6
+ * `false` rather than throwing, so the catch is unreachable through ordinary
7
+ * inputs. It is still legitimate defensive code: `crypto.verify(null, …)` can
8
+ * throw `ERR_OSSL_*` for unexpected key/state combinations. We exercise it by
9
+ * stubbing `node:crypto.verify` to throw and asserting the function degrades to
10
+ * `false` instead of propagating.
11
+ *
12
+ * `mock.module` mutates the module registry for the REST OF THE PROCESS —
13
+ * `bun test` runs every test file in one process and does NOT give each file
14
+ * a fresh module graph, and file execution order is nondeterministic. If this
15
+ * file runs before `index.test.ts` without the `afterAll` restore below, the
16
+ * throwing stub leaks into it and every valid-signature test fails (observed
17
+ * intermittently in CI). The restore is load-bearing; keep it.
18
+ */
19
+ import { afterAll, expect, mock, test } from "bun:test";
20
+
21
+ const realCrypto = require("node:crypto") as typeof import("node:crypto");
22
+
23
+ mock.module("node:crypto", () => ({
24
+ ...realCrypto,
25
+ // Force the verification path to throw the way OpenSSL can under
26
+ // unexpected conditions (e.g. ERR_OSSL_NO_DEFAULT_DIGEST).
27
+ verify: () => {
28
+ throw new Error("ERR_OSSL synthetic failure");
29
+ },
30
+ }));
31
+
32
+ afterAll(() => {
33
+ // Bun has no mock.module restore API; re-mocking with the real exports is
34
+ // the documented way to undo a module mock. This updates live bindings in
35
+ // already-loaded importers (verify.ts), so files that run after this one
36
+ // see the real `crypto.verify` again.
37
+ mock.module("node:crypto", () => realCrypto);
38
+ });
39
+
40
+ // Import the module under test *after* the stub is registered so its
41
+ // `import { verify } from "node:crypto"` binding resolves to the stub.
42
+ const { verifyDiscordSignature, generateEd25519Keypair, signDiscordBody } = await import(
43
+ "./verify.js"
44
+ );
45
+
46
+ test("verifyDiscordSignature returns false when crypto.verify throws", () => {
47
+ // Keypair generation + signing still use the real crypto (spread above).
48
+ const { publicKeyHex, privateKeyPem } = generateEd25519Keypair();
49
+ const body = JSON.stringify({ type: 1 });
50
+ const timestamp = "1700000000";
51
+ const sig = signDiscordBody({ body, timestamp, privateKeyPem });
52
+
53
+ const headers = new Headers();
54
+ headers.set("X-Signature-Ed25519", sig);
55
+ headers.set("X-Signature-Timestamp", timestamp);
56
+
57
+ // Every guard (hex regex, timestamp regex, 64-byte length, public-key
58
+ // construction) passes, so control reaches the `verify(...)` call — which
59
+ // now throws — and the catch must convert it to a clean `false`.
60
+ expect(verifyDiscordSignature({ headers, body, publicKeyHex })).toBe(false);
61
+ });
package/src/verify.ts CHANGED
@@ -25,19 +25,41 @@ export type DiscordVerifyArgs = {
25
25
  readonly publicKeyHex: string;
26
26
  };
27
27
 
28
- export function verifyDiscordSignature(args: DiscordVerifyArgs): boolean {
28
+ export type DiscordVerifyOptions = {
29
+ readonly now?: () => number;
30
+ readonly toleranceMs?: number;
31
+ };
32
+
33
+ // Discord recommends rejecting interactions whose timestamp is too far from
34
+ // now; 5 minutes matches the Slack adapter's replay-window cap.
35
+ const DEFAULT_TOLERANCE_MS = 5 * 60 * 1000;
36
+
37
+ export function verifyDiscordSignature(
38
+ args: DiscordVerifyArgs,
39
+ opts: DiscordVerifyOptions = {},
40
+ ): boolean {
41
+ const now = opts.now ?? (() => Date.now());
42
+ const tolerance = opts.toleranceMs ?? DEFAULT_TOLERANCE_MS;
43
+
29
44
  const sigHex = args.headers.get("x-signature-ed25519");
30
45
  const timestamp = args.headers.get("x-signature-timestamp");
31
46
  if (!sigHex || !timestamp) return false;
32
47
  if (!/^[0-9a-f]{128}$/i.test(sigHex)) return false;
33
48
  if (!/^\d+$/.test(timestamp)) return false;
34
49
 
35
- let signature: Buffer;
36
- try {
37
- signature = Buffer.from(sigHex, "hex");
38
- } catch {
39
- return false;
40
- }
50
+ // Replay-window: the timestamp is folded into the signed payload, so a
51
+ // captured (timestamp, body, signature) triple verifies indefinitely
52
+ // without this freshness bound. Reject stale/future timestamps (seconds
53
+ // since epoch), mirroring the Slack adapter.
54
+ const tsNum = Number.parseInt(timestamp, 10);
55
+ if (!Number.isFinite(tsNum)) return false;
56
+ if (Math.abs(now() - tsNum * 1000) > tolerance) return false;
57
+
58
+ // `sigHex` is regex-validated above to be exactly 128 hex chars, so
59
+ // `Buffer.from(_, "hex")` always yields exactly 64 bytes and never throws
60
+ // (`Buffer.from` silently drops invalid hex rather than throwing). The
61
+ // length re-check below is a cheap, defensive belt for that invariant.
62
+ const signature = Buffer.from(sigHex, "hex");
41
63
  if (signature.length !== 64) return false;
42
64
 
43
65
  let publicKey: ReturnType<typeof ed25519PublicKeyFromHex>;