@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 +6 -11
- package/src/index.test.ts +45 -1
- package/src/index.ts +23 -12
- package/src/verify.crypto-throw.test.ts +61 -0
- package/src/verify.ts +29 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/channel-adapter-discord",
|
|
3
|
-
"version": "0.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.
|
|
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@
|
|
21
|
-
"url": "https://
|
|
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": "
|
|
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 =
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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>;
|