openvibers/openvibe.watch Commits
A Discord application can wake a watch (agent plan A3) (#195)
Changes
@@@ -203,9 +203,9 @@ product (`server/webhook-sources.js`, `server/carriers/webhook.js`).- **Signing.** `X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the exact body with the secret>` (GitHub's form),`Authorization: Bearer <secret>` for a sender that cannot sign, or Telegram's `X-Telegram-Bot-Api-Secret-Token`(give the secret as `setWebhook`'s `secret_token`). Compared in constant time.--- **A sender's own secret (agent plan A3).** Slack and Stripe sign with a secret they make. Paste it on the watch's++- **A sender's own secret (agent plan A3).** Slack, Stripe and Discord sign with a secret or key they make. Paste it on the watch'spage ("Signed by Slack or Stripe?") or send `POST /api/v1/watches/:id/webhook-secret` with-- `{ "provider": "slack" | "stripe", "secret": "…" }`: that scheme then checks every delivery, and the secret is kept++ `{ "provider": "slack" | "stripe" | "discord", "secret": "…" }`: that scheme then checks every delivery, and the secret is kept(never shown again) instead of Watch's own. A bad provider or secret answers 422 and changes nothing; a new secret ofWatch's own (the same call with no body) replaces it.- `slack`: `X-Slack-Signature: v0=<hex HMAC-SHA256 of "v0:<X-Slack-Request-Timestamp>:<body>">`, the timestamp within@@@ -213,6 +213,11 @@ product (`server/webhook-sources.js`, `server/carriers/webhook.js`).acknowledged without checking twice.- `stripe`: `Stripe-Signature: t=<time>,v1=<hex HMAC-SHA256 of "<time>.<body>">` (any of several `v1`), the timewithin five minutes.++ - `discord`: `X-Signature-Ed25519` over `X-Signature-Timestamp` + body, checked with the application's public key++ (64 hex characters from the Developer Portal). Paste the address as the Interactions Endpoint URL. Discord wants++ its reply within three seconds: its ping gets `{ "type": 1 }`, and any other interaction a quiet `{ "type": 4 }`++ ("Got it") at once, with the check run right after on the interaction (`value_path: "data.name"` reads a slash++ command's name). A bad signature answers 401, as Discord checks.- Cases: `test/webhook-providers.test.js`.- **What the extraction reads.** A JSON delivery is parsed and becomes the document (`value_path: "order.total"`); anyother is read as text (`text`, `regex`). A delivery that says JSON but is not records `parse_error`.@@@ -892,9 +892,9 @@ ${testRun ? testCheckNotice(testRun, testObs, v) : ''}<dl class="facts"><dt>Status</dt><dd>${badge(unarmed ? 'Not armed yet' : W.statusWords(v.status), unarmed ? 'warn' : W.statusKind(v.status))}</dd>${v.source.kind === 'webhook'-- ? html`<dt>Webhook address</dt><dd><code>${config.baseUrl}/hooks/${v.id}</code> <form class="inline" method="post" action="/watches/${v.id}/webhook-secret"><button class="btn" type="submit">Make a new secret</button></form><br><span class="muted small">${hookProvider ? `Signed by ${hookProvider === 'slack' ? 'Slack' : 'Stripe'} with the signing secret you gave. A new secret of Watch's own replaces it.` : 'The secret was shown once, when the watch was made. A new one replaces it at once.'}</span>--<details class="hook-provider"><summary>Signed by Slack or Stripe?</summary><p class="muted small">They sign with a secret of their own: paste it here and Watch checks their signature instead of its own. It is kept to check each delivery and never shown again.</p>--<form method="post" action="/watches/${v.id}/webhook-secret"><label>Sender <select name="provider"><option value="slack">Slack (signing secret)</option><option value="stripe">Stripe (endpoint secret, whsec_…)</option></select></label> <label>Its signing secret <input name="secret" type="password" autocomplete="off" required maxlength="220"></label> <button class="btn" type="submit">Use it</button></form></details></dd>`++ ? html`<dt>Webhook address</dt><dd><code>${config.baseUrl}/hooks/${v.id}</code> <form class="inline" method="post" action="/watches/${v.id}/webhook-secret"><button class="btn" type="submit">Make a new secret</button></form><br><span class="muted small">${hookProvider ? `Signed by ${({ slack: 'Slack', stripe: 'Stripe', discord: 'Discord' })[hookProvider] || hookProvider} with the key you gave. A new secret of Watch's own replaces it.` : 'The secret was shown once, when the watch was made. A new one replaces it at once.'}</span>++<details class="hook-provider"><summary>Signed by Slack, Stripe or Discord?</summary><p class="muted small">They sign with a key of their own: paste it here and Watch checks their signature instead of its own. It is kept to check each delivery and never shown again.</p>++<form method="post" action="/watches/${v.id}/webhook-secret"><label>Sender <select name="provider"><option value="slack">Slack (signing secret)</option><option value="stripe">Stripe (endpoint secret, whsec_…)</option><option value="discord">Discord (application public key)</option></select></label> <label>Its signing secret <input name="secret" type="password" autocomplete="off" required maxlength="220"></label> <button class="btn" type="submit">Use it</button></form></details></dd>`: html`<dt>Source</dt><dd><code>${v.source.kind}</code> · <code>${v.source.url || (v.source.pattern ? v.source.pattern : '(none)')}</code></dd>`}<dt>Cadence</dt><dd>${v.cadence ? W.everyWords(v.cadence.every_sec) : (v.source.kind === 'webhook' ? 'when a delivery arrives' : (v.source.kind === 'event' ? 'when its event arrives' : 'not polled'))}</dd><dt>Actions</dt><dd>${configuredActions}</dd>@@@ -1000,15 +1000,17 @@ ${table(['When', 'State', 'HTTP', 'Detail', 'Read', 'Fired'], runRows, { empty:return page(req, res, {title: `${v.name}: webhook address`, private: true, crumbs: [{ label: 'Your watches', href: '/watches' }, { label: v.name, href: `/watches/${v.id}` }],body: hook.provider ? html`<h1>Your webhook is ready</h1>--${notice(`Deliveries to this address are checked with the ${hook.provider === 'slack' ? 'Slack' : 'Stripe'} signing secret you gave. It is not shown again.`, 'ok')}++${notice(`Deliveries to this address are checked with the ${({ slack: 'Slack', stripe: 'Stripe', discord: 'Discord' })[hook.provider]} key you gave. It is not shown again.`, 'ok')}<dl class="facts"><dt>Address</dt><dd><code class="copyable">${hook.url}</code></dd><dt>Signed by</dt><dd>${hook.signature}</dd></dl>--<h2>In ${hook.provider === 'slack' ? 'Slack' : 'Stripe'}</h2>++<h2>In ${({ slack: 'Slack', stripe: 'Stripe', discord: 'Discord' })[hook.provider]}</h2><ul class="facts">${hook.provider === 'slack'? html`<li>In your app's <strong>Event Subscriptions</strong> (or Interactivity, or a slash command), paste the address as the Request URL. Slack sends a challenge first; Watch answers it.</li><li>A retry Slack sends because the first took more than three seconds is acknowledged without checking twice.</li>`-- : html`<li>In <strong>Developers → Webhooks</strong>, add the address as an endpoint and choose its events. The endpoint's signing secret is the one you pasted here.</li>`}++ : hook.provider === 'discord'++ ? html`<li>In the Developer Portal, under your application's <strong>General Information</strong>, paste the address as the <strong>Interactions Endpoint URL</strong>. Discord sends a ping first; Watch answers it.</li><li>Each slash command or button press gets a quiet "Got it" at once, and the watch checks right after.</li>`++ : html`<li>In <strong>Developers → Webhooks</strong>, add the address as an endpoint and choose its events. The endpoint's signing secret is the one you pasted here.</li>`}<li>Deliveries older than five minutes are refused, so a copied request cannot be replayed later.</li></ul><p><a class="btn" href="/watches/${v.id}">Back to the watch</a></p>` : html`<h1>Your webhook is ready</h1>${notice('Copy the secret now: it is not shown again. Making a new one replaces it.', 'warn')}@@@ -19,6 +19,11 @@* challenge, and a retry (X-Slack-Retry-Num) is acknowledged without a second check.* stripe Stripe-Signature: t=<time>,v1=<hex HMAC-SHA256(secret, time + "." + body)> (any v1 of* several), the time within 5 minutes.++ * discord X-Signature-Ed25519 over X-Signature-Timestamp + body, checked with the application's++ * public key (64 hex characters, from the Developer Portal; it is public, kept like a++ * secret anyway). Discord wants an interaction reply within 3 seconds: a PING gets++ * { type: 1 }, anything else { type: 4, ephemeral "Got it" } at once, and the check runs++ * right after, with the interaction as its input.* Answers 200 { state, triggered } after the check; 404 for an unknown id or a watch that is not a webhook* watch; 401 for a missing or wrong signature (the same answer either way); 409 for a paused watch;* 429 over the watch's hourly budget (WATCH_WEBHOOK_MAX_PER_HOUR, default 60) with Retry-After;@@@ -32,6 +37,7 @@ const TOLERANCE_SEC = 300;const PROVIDERS = Object.freeze({slack: { name: 'Slack', secret: /^[0-9a-f]{32}$/, how: 'Slack signs each request with your app\'s signing secret (X-Slack-Signature, v0).' },stripe: { name: 'Stripe', secret: /^whsec_[A-Za-z0-9+/=_-]{16,200}$/, how: 'Stripe signs each event with the endpoint\'s signing secret (Stripe-Signature, v1).' },++ discord: { name: 'Discord', secret: /^[0-9a-f]{64}$/, how: 'Discord signs each interaction with your application\'s key (X-Signature-Ed25519); paste its public key.' },});const MAX_BODY = 256 * 1024;@@@ -64,6 +70,20 @@ function stripeVerified(secret, raw, headers, nowMs) {return parts.filter(([k]) => k === 'v1').some(([, v]) => safeEqual(v || '', want));}++// The DER prefix of an Ed25519 public key (SubjectPublicKeyInfo), so the 32 raw bytes Discord shows become a key object.++const ED25519_SPKI = Buffer.from('302a300506032b6570032100', 'hex');++++/** Discord's interaction signing: Ed25519 over "<timestamp><body>" with the application's public key. */++function discordVerified(publicKeyHex, raw, headers, nowMs) {++ const ts = String(headers['x-signature-timestamp'] || '');++ const sig = String(headers['x-signature-ed25519'] || '');++ if (!fresh(ts, nowMs) || !/^[0-9a-f]{128}$/i.test(sig) || !/^[0-9a-f]{64}$/i.test(publicKeyHex)) return false;++ try {++ const key = crypto.createPublicKey({ key: Buffer.concat([ED25519_SPKI, Buffer.from(publicKeyHex, 'hex')]), format: 'der', type: 'spki' });++ return crypto.verify(null, Buffer.concat([Buffer.from(ts), raw]), key, Buffer.from(sig, 'hex'));++ } catch { return false; }++}++/*** Does the delivery carry the watch's secret? With a sender's own secret (`provider`), only that sender's scheme* counts; with Watch's own, a matching body signature, the secret as a bearer token, or Telegram's secret token.@@@ -72,6 +92,7 @@ function verified(secret, raw, headers, { provider = null, now = Date.now() } =if (!secret) return false;if (provider === 'slack') return slackVerified(secret, raw, headers, now);if (provider === 'stripe') return stripeVerified(secret, raw, headers, now);++ if (provider === 'discord') return discordVerified(secret, raw, headers, now);if (provider) return false;const tg = headers['x-telegram-bot-api-secret-token'];if (tg != null && safeEqual(String(tg), secret)) return true;@@@ -166,6 +187,20 @@ function createWebhookSources({ db, registry, check, config, now = () => Date.noif (msg && msg.type === 'url_verification' && typeof msg.challenge === 'string') return res.json({ challenge: msg.challenge.slice(0, 200) });if (req.headers['x-slack-retry-num'] != null) return res.json({ watch_id: id, retry: true, state: null, triggered: false });}++ if (provider === 'discord') {++ // Discord wants its reply within three seconds and in its own shape, so the reply goes first and the check++ // runs right after, with the interaction as its input.++ let msg = null;++ try { msg = JSON.parse(raw.toString('utf8')); } catch { /* not an interaction */ }++ res.setHeader('Cache-Control', 'no-store');++ if (msg && msg.type === 1) return res.json({ type: 1 });++ if (row.status !== 'active') return res.json({ type: 4, data: { content: `This watch is ${row.status}.`, flags: 64 } });++ if (!underBudget(id)) return res.json({ type: 4, data: { content: 'Too many at once: try again in a while.', flags: 64 } });++ res.json({ type: 4, data: { content: 'Got it: the watch is checking.', flags: 64 } });++ check.run(id, { trigger: 'webhook', input: { body: raw, content_type: 'application/json' } })++ .catch((err) => log.error(`[webhook-sources] ${id}: ${err.message}`));++ return undefined;++ }if (row.status !== 'active') return problem(res, 409, 'watch.disabled', `the watch is ${row.status}`);if (!underBudget(id)) return problem(res, 429, 'watch.rate_limited', `at most ${maxPerHour} deliveries an hour wake one watch`, { 'Retry-After': String(retryAfter()) });let out;@@@ -42,7 +42,7 @@ t('the helper: Slack v0, Stripe v1 (any of several), each in time; Telegram withassert.strictEqual(verified('whk_x', body, { 'x-telegram-bot-api-secret-token': 'whk_x' }), true, 'Telegram\'s secret_token');assert.strictEqual(verified('whk_x', body, { 'x-telegram-bot-api-secret-token': 'whk_y' }), false);assert.strictEqual(verified(SLACK, body, slack, { provider: 'github' }), false, 'an unknown provider checks nothing');-- assert.deepStrictEqual(Object.keys(PROVIDERS), ['slack', 'stripe']);++ assert.deepStrictEqual(Object.keys(PROVIDERS), ['slack', 'stripe', 'discord']);});t('the owner pastes Slack\'s signing secret; it is kept, never echoed; a bad one changes nothing', async () => {@@@ -93,6 +93,30 @@ t('Stripe: a signed event runs the check; the wrong secret is refused', async ()assert.strictEqual((await deliver(stripeWatch.id, body, { 'Stripe-Signature': `t=${ts},v1=${hmac('whsec_other', `${ts}.${body}`)}` })).status, 401);});++t('Discord: the ping gets { type: 1 }, a command a quiet reply at once and a check right after; a bad signature 401', async () => {++ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');++ const pub = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('hex');++ const made = await api('POST', '/api/v1/watches', { token: ME, body: { ...def('discord commands'), extraction: { kind: 'json', value_path: 'data.name' }, condition: { op: 'contains', value: 'deploy' } } });++ const w = made.body.watch;++ assert.strictEqual((await api('POST', `/api/v1/watches/${w.id}/webhook-secret`, { token: ME, body: { provider: 'discord', secret: 'not-a-key' } })).status, 422);++ const set = await api('POST', `/api/v1/watches/${w.id}/webhook-secret`, { token: ME, body: { provider: 'discord', secret: pub } });++ assert.strictEqual(set.body.webhook.provider, 'discord');++ const sign = (body, ts = String(nowSec())) => ({ 'X-Signature-Timestamp': ts, 'X-Signature-Ed25519': crypto.sign(null, Buffer.concat([Buffer.from(ts), Buffer.from(body)]), privateKey).toString('hex') });++ const ping = JSON.stringify({ type: 1, id: 'i1' });++ const p = await deliver(w.id, ping, sign(ping));++ assert.deepStrictEqual(await p.json(), { type: 1 });++ assert.strictEqual((await deliver(w.id, ping, { ...sign(ping), 'X-Signature-Ed25519': '0'.repeat(128) })).status, 401, 'Discord checks that a bad signature is refused');++ assert.strictEqual((await deliver(w.id, ping, sign(ping, String(nowSec() - 600)))).status, 401, 'an old one too');++ const cmd = JSON.stringify({ type: 2, id: 'i2', data: { name: 'deploy' } });++ const c = await deliver(w.id, cmd, sign(cmd));++ const reply = await c.json();++ assert.deepStrictEqual([reply.type, reply.data.flags], [4, 64], 'an ephemeral reply at once');++ let got = [];++ for (let i = 0; i < 50 && !got.length; i++) { got = await runs(w.id); if (!got.length) await new Promise((r) => setTimeout(r, 50)); }++ assert.strictEqual(got.length, 1, 'the check ran right after');++ assert.strictEqual(got[0].trigger, 'webhook');++});++t('a new secret of Watch\'s own replaces a provider\'s, and its forms work again', async () => {const rotated = await api('POST', `/api/v1/watches/${stripeWatch.id}/webhook-secret`, { token: ME });assert.match(rotated.body.webhook.secret, /^whk_/);
@@ -203,9 +203,9 @@ product (`server/webhook-sources.js`, `server/carriers/webhook.js`).
@@ -203,9 +203,9 @@ product (`server/webhook-sources.js`, `server/carriers/webhook.js`).
- **Signing.** `X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the exact body with the secret>` (GitHub's form),
- **Signing.** `X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the exact body with the secret>` (GitHub's form),
`Authorization: Bearer <secret>` for a sender that cannot sign, or Telegram's `X-Telegram-Bot-Api-Secret-Token`
`Authorization: Bearer <secret>` for a sender that cannot sign, or Telegram's `X-Telegram-Bot-Api-Secret-Token`
(give the secret as `setWebhook`'s `secret_token`). Compared in constant time.
(give the secret as `setWebhook`'s `secret_token`). Compared in constant time.
-- **A sender's own secret (agent plan A3).** Slack and Stripe sign with a secret they make. Paste it on the watch's
+- **A sender's own secret (agent plan A3).** Slack, Stripe and Discord sign with a secret or key they make. Paste it on the watch's
page ("Signed by Slack or Stripe?") or send `POST /api/v1/watches/:id/webhook-secret` with
page ("Signed by Slack or Stripe?") or send `POST /api/v1/watches/:id/webhook-secret` with
- `{ "provider": "slack" | "stripe", "secret": "…" }`: that scheme then checks every delivery, and the secret is kept
+ `{ "provider": "slack" | "stripe" | "discord", "secret": "…" }`: that scheme then checks every delivery, and the secret is kept
(never shown again) instead of Watch's own. A bad provider or secret answers 422 and changes nothing; a new secret of
(never shown again) instead of Watch's own. A bad provider or secret answers 422 and changes nothing; a new secret of
Watch's own (the same call with no body) replaces it.
Watch's own (the same call with no body) replaces it.
- `slack`: `X-Slack-Signature: v0=<hex HMAC-SHA256 of "v0:<X-Slack-Request-Timestamp>:<body>">`, the timestamp within
- `slack`: `X-Slack-Signature: v0=<hex HMAC-SHA256 of "v0:<X-Slack-Request-Timestamp>:<body>">`, the timestamp within
@@ -213,6 +213,11 @@ product (`server/webhook-sources.js`, `server/carriers/webhook.js`).
@@ -213,6 +213,11 @@ product (`server/webhook-sources.js`, `server/carriers/webhook.js`).
acknowledged without checking twice.
acknowledged without checking twice.
- `stripe`: `Stripe-Signature: t=<time>,v1=<hex HMAC-SHA256 of "<time>.<body>">` (any of several `v1`), the time
- `stripe`: `Stripe-Signature: t=<time>,v1=<hex HMAC-SHA256 of "<time>.<body>">` (any of several `v1`), the time
within five minutes.
within five minutes.
+ - `discord`: `X-Signature-Ed25519` over `X-Signature-Timestamp` + body, checked with the application's public key
+ (64 hex characters from the Developer Portal). Paste the address as the Interactions Endpoint URL. Discord wants
+ its reply within three seconds: its ping gets `{ "type": 1 }`, and any other interaction a quiet `{ "type": 4 }`
+ ("Got it") at once, with the check run right after on the interaction (`value_path: "data.name"` reads a slash
+ command's name). A bad signature answers 401, as Discord checks.
- Cases: `test/webhook-providers.test.js`.
- Cases: `test/webhook-providers.test.js`.
- **What the extraction reads.** A JSON delivery is parsed and becomes the document (`value_path: "order.total"`); any
- **What the extraction reads.** A JSON delivery is parsed and becomes the document (`value_path: "order.total"`); any
other is read as text (`text`, `regex`). A delivery that says JSON but is not records `parse_error`.
other is read as text (`text`, `regex`). A delivery that says JSON but is not records `parse_error`.
@@ -892,9 +892,9 @@ ${testRun ? testCheckNotice(testRun, testObs, v) : ''}
@@ -892,9 +892,9 @@ ${testRun ? testCheckNotice(testRun, testObs, v) : ''}
<dl class="facts">
<dl class="facts">
<dt>Status</dt><dd>${badge(unarmed ? 'Not armed yet' : W.statusWords(v.status), unarmed ? 'warn' : W.statusKind(v.status))}</dd>
<dt>Status</dt><dd>${badge(unarmed ? 'Not armed yet' : W.statusWords(v.status), unarmed ? 'warn' : W.statusKind(v.status))}</dd>
${v.source.kind === 'webhook'
${v.source.kind === 'webhook'
- ? html`<dt>Webhook address</dt><dd><code>${config.baseUrl}/hooks/${v.id}</code> <form class="inline" method="post" action="/watches/${v.id}/webhook-secret"><button class="btn" type="submit">Make a new secret</button></form><br><span class="muted small">${hookProvider ? `Signed by ${hookProvider === 'slack' ? 'Slack' : 'Stripe'} with the signing secret you gave. A new secret of Watch's own replaces it.` : 'The secret was shown once, when the watch was made. A new one replaces it at once.'}</span>
-<details class="hook-provider"><summary>Signed by Slack or Stripe?</summary><p class="muted small">They sign with a secret of their own: paste it here and Watch checks their signature instead of its own. It is kept to check each delivery and never shown again.</p>
-<form method="post" action="/watches/${v.id}/webhook-secret"><label>Sender <select name="provider"><option value="slack">Slack (signing secret)</option><option value="stripe">Stripe (endpoint secret, whsec_…)</option></select></label> <label>Its signing secret <input name="secret" type="password" autocomplete="off" required maxlength="220"></label> <button class="btn" type="submit">Use it</button></form></details></dd>`
+ ? html`<dt>Webhook address</dt><dd><code>${config.baseUrl}/hooks/${v.id}</code> <form class="inline" method="post" action="/watches/${v.id}/webhook-secret"><button class="btn" type="submit">Make a new secret</button></form><br><span class="muted small">${hookProvider ? `Signed by ${({ slack: 'Slack', stripe: 'Stripe', discord: 'Discord' })[hookProvider] || hookProvider} with the key you gave. A new secret of Watch's own replaces it.` : 'The secret was shown once, when the watch was made. A new one replaces it at once.'}</span>
+<details class="hook-provider"><summary>Signed by Slack, Stripe or Discord?</summary><p class="muted small">They sign with a key of their own: paste it here and Watch checks their signature instead of its own. It is kept to check each delivery and never shown again.</p>
+<form method="post" action="/watches/${v.id}/webhook-secret"><label>Sender <select name="provider"><option value="slack">Slack (signing secret)</option><option value="stripe">Stripe (endpoint secret, whsec_…)</option><option value="discord">Discord (application public key)</option></select></label> <label>Its signing secret <input name="secret" type="password" autocomplete="off" required maxlength="220"></label> <button class="btn" type="submit">Use it</button></form></details></dd>`
: html`<dt>Source</dt><dd><code>${v.source.kind}</code> · <code>${v.source.url || (v.source.pattern ? v.source.pattern : '(none)')}</code></dd>`}
: html`<dt>Source</dt><dd><code>${v.source.kind}</code> · <code>${v.source.url || (v.source.pattern ? v.source.pattern : '(none)')}</code></dd>`}
<dt>Cadence</dt><dd>${v.cadence ? W.everyWords(v.cadence.every_sec) : (v.source.kind === 'webhook' ? 'when a delivery arrives' : (v.source.kind === 'event' ? 'when its event arrives' : 'not polled'))}</dd>
<dt>Cadence</dt><dd>${v.cadence ? W.everyWords(v.cadence.every_sec) : (v.source.kind === 'webhook' ? 'when a delivery arrives' : (v.source.kind === 'event' ? 'when its event arrives' : 'not polled'))}</dd>
<dt>Actions</dt><dd>${configuredActions}</dd>
<dt>Actions</dt><dd>${configuredActions}</dd>
@@ -1000,15 +1000,17 @@ ${table(['When', 'State', 'HTTP', 'Detail', 'Read', 'Fired'], runRows, { empty:
@@ -1000,15 +1000,17 @@ ${table(['When', 'State', 'HTTP', 'Detail', 'Read', 'Fired'], runRows, { empty:
return page(req, res, {
return page(req, res, {
title: `${v.name}: webhook address`, private: true, crumbs: [{ label: 'Your watches', href: '/watches' }, { label: v.name, href: `/watches/${v.id}` }],
title: `${v.name}: webhook address`, private: true, crumbs: [{ label: 'Your watches', href: '/watches' }, { label: v.name, href: `/watches/${v.id}` }],
body: hook.provider ? html`<h1>Your webhook is ready</h1>
body: hook.provider ? html`<h1>Your webhook is ready</h1>
-${notice(`Deliveries to this address are checked with the ${hook.provider === 'slack' ? 'Slack' : 'Stripe'} signing secret you gave. It is not shown again.`, 'ok')}
+${notice(`Deliveries to this address are checked with the ${({ slack: 'Slack', stripe: 'Stripe', discord: 'Discord' })[hook.provider]} key you gave. It is not shown again.`, 'ok')}
<dl class="facts">
<dl class="facts">
<dt>Address</dt><dd><code class="copyable">${hook.url}</code></dd>
<dt>Address</dt><dd><code class="copyable">${hook.url}</code></dd>
<dt>Signed by</dt><dd>${hook.signature}</dd>
<dt>Signed by</dt><dd>${hook.signature}</dd>
</dl>
</dl>
-<h2>In ${hook.provider === 'slack' ? 'Slack' : 'Stripe'}</h2>
+<h2>In ${({ slack: 'Slack', stripe: 'Stripe', discord: 'Discord' })[hook.provider]}</h2>
<ul class="facts">${hook.provider === 'slack'
<ul class="facts">${hook.provider === 'slack'
? html`<li>In your app's <strong>Event Subscriptions</strong> (or Interactivity, or a slash command), paste the address as the Request URL. Slack sends a challenge first; Watch answers it.</li><li>A retry Slack sends because the first took more than three seconds is acknowledged without checking twice.</li>`
? html`<li>In your app's <strong>Event Subscriptions</strong> (or Interactivity, or a slash command), paste the address as the Request URL. Slack sends a challenge first; Watch answers it.</li><li>A retry Slack sends because the first took more than three seconds is acknowledged without checking twice.</li>`
- : html`<li>In <strong>Developers → Webhooks</strong>, add the address as an endpoint and choose its events. The endpoint's signing secret is the one you pasted here.</li>`}
+ : hook.provider === 'discord'
+ ? html`<li>In the Developer Portal, under your application's <strong>General Information</strong>, paste the address as the <strong>Interactions Endpoint URL</strong>. Discord sends a ping first; Watch answers it.</li><li>Each slash command or button press gets a quiet "Got it" at once, and the watch checks right after.</li>`
+ : html`<li>In <strong>Developers → Webhooks</strong>, add the address as an endpoint and choose its events. The endpoint's signing secret is the one you pasted here.</li>`}
<li>Deliveries older than five minutes are refused, so a copied request cannot be replayed later.</li></ul>
<li>Deliveries older than five minutes are refused, so a copied request cannot be replayed later.</li></ul>
<p><a class="btn" href="/watches/${v.id}">Back to the watch</a></p>` : html`<h1>Your webhook is ready</h1>
<p><a class="btn" href="/watches/${v.id}">Back to the watch</a></p>` : html`<h1>Your webhook is ready</h1>
${notice('Copy the secret now: it is not shown again. Making a new one replaces it.', 'warn')}
${notice('Copy the secret now: it is not shown again. Making a new one replaces it.', 'warn')}
@@ -19,6 +19,11 @@
@@ -19,6 +19,11 @@
* challenge, and a retry (X-Slack-Retry-Num) is acknowledged without a second check.
* challenge, and a retry (X-Slack-Retry-Num) is acknowledged without a second check.
* stripe Stripe-Signature: t=<time>,v1=<hex HMAC-SHA256(secret, time + "." + body)> (any v1 of
* stripe Stripe-Signature: t=<time>,v1=<hex HMAC-SHA256(secret, time + "." + body)> (any v1 of
* several), the time within 5 minutes.
* several), the time within 5 minutes.
+ * discord X-Signature-Ed25519 over X-Signature-Timestamp + body, checked with the application's
+ * public key (64 hex characters, from the Developer Portal; it is public, kept like a
+ * secret anyway). Discord wants an interaction reply within 3 seconds: a PING gets
+ * { type: 1 }, anything else { type: 4, ephemeral "Got it" } at once, and the check runs
+ * right after, with the interaction as its input.
* Answers 200 { state, triggered } after the check; 404 for an unknown id or a watch that is not a webhook
* Answers 200 { state, triggered } after the check; 404 for an unknown id or a watch that is not a webhook
* watch; 401 for a missing or wrong signature (the same answer either way); 409 for a paused watch;
* watch; 401 for a missing or wrong signature (the same answer either way); 409 for a paused watch;
* 429 over the watch's hourly budget (WATCH_WEBHOOK_MAX_PER_HOUR, default 60) with Retry-After;
* 429 over the watch's hourly budget (WATCH_WEBHOOK_MAX_PER_HOUR, default 60) with Retry-After;
@@ -32,6 +37,7 @@ const TOLERANCE_SEC = 300;
@@ -32,6 +37,7 @@ const TOLERANCE_SEC = 300;
const PROVIDERS = Object.freeze({
const PROVIDERS = Object.freeze({
slack: { name: 'Slack', secret: /^[0-9a-f]{32}$/, how: 'Slack signs each request with your app\'s signing secret (X-Slack-Signature, v0).' },
slack: { name: 'Slack', secret: /^[0-9a-f]{32}$/, how: 'Slack signs each request with your app\'s signing secret (X-Slack-Signature, v0).' },
stripe: { name: 'Stripe', secret: /^whsec_[A-Za-z0-9+/=_-]{16,200}$/, how: 'Stripe signs each event with the endpoint\'s signing secret (Stripe-Signature, v1).' },
stripe: { name: 'Stripe', secret: /^whsec_[A-Za-z0-9+/=_-]{16,200}$/, how: 'Stripe signs each event with the endpoint\'s signing secret (Stripe-Signature, v1).' },
+ discord: { name: 'Discord', secret: /^[0-9a-f]{64}$/, how: 'Discord signs each interaction with your application\'s key (X-Signature-Ed25519); paste its public key.' },
});
});
const MAX_BODY = 256 * 1024;
const MAX_BODY = 256 * 1024;
@@ -64,6 +70,20 @@ function stripeVerified(secret, raw, headers, nowMs) {
@@ -64,6 +70,20 @@ function stripeVerified(secret, raw, headers, nowMs) {
return parts.filter(([k]) => k === 'v1').some(([, v]) => safeEqual(v || '', want));
return parts.filter(([k]) => k === 'v1').some(([, v]) => safeEqual(v || '', want));
}
}
+// The DER prefix of an Ed25519 public key (SubjectPublicKeyInfo), so the 32 raw bytes Discord shows become a key object.
+const ED25519_SPKI = Buffer.from('302a300506032b6570032100', 'hex');
+
+/** Discord's interaction signing: Ed25519 over "<timestamp><body>" with the application's public key. */
+function discordVerified(publicKeyHex, raw, headers, nowMs) {
+ const ts = String(headers['x-signature-timestamp'] || '');
+ const sig = String(headers['x-signature-ed25519'] || '');
+ if (!fresh(ts, nowMs) || !/^[0-9a-f]{128}$/i.test(sig) || !/^[0-9a-f]{64}$/i.test(publicKeyHex)) return false;
+ try {
+ const key = crypto.createPublicKey({ key: Buffer.concat([ED25519_SPKI, Buffer.from(publicKeyHex, 'hex')]), format: 'der', type: 'spki' });
+ return crypto.verify(null, Buffer.concat([Buffer.from(ts), raw]), key, Buffer.from(sig, 'hex'));
+ } catch { return false; }
+}
+
/**
/**
* Does the delivery carry the watch's secret? With a sender's own secret (`provider`), only that sender's scheme
* Does the delivery carry the watch's secret? With a sender's own secret (`provider`), only that sender's scheme
* counts; with Watch's own, a matching body signature, the secret as a bearer token, or Telegram's secret token.
* counts; with Watch's own, a matching body signature, the secret as a bearer token, or Telegram's secret token.
@@ -72,6 +92,7 @@ function verified(secret, raw, headers, { provider = null, now = Date.now() } =
@@ -72,6 +92,7 @@ function verified(secret, raw, headers, { provider = null, now = Date.now() } =
if (!secret) return false;
if (!secret) return false;
if (provider === 'slack') return slackVerified(secret, raw, headers, now);
if (provider === 'slack') return slackVerified(secret, raw, headers, now);
if (provider === 'stripe') return stripeVerified(secret, raw, headers, now);
if (provider === 'stripe') return stripeVerified(secret, raw, headers, now);
+ if (provider === 'discord') return discordVerified(secret, raw, headers, now);
if (provider) return false;
if (provider) return false;
const tg = headers['x-telegram-bot-api-secret-token'];
const tg = headers['x-telegram-bot-api-secret-token'];
if (tg != null && safeEqual(String(tg), secret)) return true;
if (tg != null && safeEqual(String(tg), secret)) return true;
@@ -166,6 +187,20 @@ function createWebhookSources({ db, registry, check, config, now = () => Date.no
@@ -166,6 +187,20 @@ function createWebhookSources({ db, registry, check, config, now = () => Date.no
if (msg && msg.type === 'url_verification' && typeof msg.challenge === 'string') return res.json({ challenge: msg.challenge.slice(0, 200) });
if (msg && msg.type === 'url_verification' && typeof msg.challenge === 'string') return res.json({ challenge: msg.challenge.slice(0, 200) });
if (req.headers['x-slack-retry-num'] != null) return res.json({ watch_id: id, retry: true, state: null, triggered: false });
if (req.headers['x-slack-retry-num'] != null) return res.json({ watch_id: id, retry: true, state: null, triggered: false });
}
}
+ if (provider === 'discord') {
+ // Discord wants its reply within three seconds and in its own shape, so the reply goes first and the check
+ // runs right after, with the interaction as its input.
+ let msg = null;
+ try { msg = JSON.parse(raw.toString('utf8')); } catch { /* not an interaction */ }
+ res.setHeader('Cache-Control', 'no-store');
+ if (msg && msg.type === 1) return res.json({ type: 1 });
+ if (row.status !== 'active') return res.json({ type: 4, data: { content: `This watch is ${row.status}.`, flags: 64 } });
+ if (!underBudget(id)) return res.json({ type: 4, data: { content: 'Too many at once: try again in a while.', flags: 64 } });
+ res.json({ type: 4, data: { content: 'Got it: the watch is checking.', flags: 64 } });
+ check.run(id, { trigger: 'webhook', input: { body: raw, content_type: 'application/json' } })
+ .catch((err) => log.error(`[webhook-sources] ${id}: ${err.message}`));
+ return undefined;
+ }
if (row.status !== 'active') return problem(res, 409, 'watch.disabled', `the watch is ${row.status}`);
if (row.status !== 'active') return problem(res, 409, 'watch.disabled', `the watch is ${row.status}`);
if (!underBudget(id)) return problem(res, 429, 'watch.rate_limited', `at most ${maxPerHour} deliveries an hour wake one watch`, { 'Retry-After': String(retryAfter()) });
if (!underBudget(id)) return problem(res, 429, 'watch.rate_limited', `at most ${maxPerHour} deliveries an hour wake one watch`, { 'Retry-After': String(retryAfter()) });
let out;
let out;
@@ -42,7 +42,7 @@ t('the helper: Slack v0, Stripe v1 (any of several), each in time; Telegram with
@@ -42,7 +42,7 @@ t('the helper: Slack v0, Stripe v1 (any of several), each in time; Telegram with
assert.strictEqual(verified('whk_x', body, { 'x-telegram-bot-api-secret-token': 'whk_x' }), true, 'Telegram\'s secret_token');
assert.strictEqual(verified('whk_x', body, { 'x-telegram-bot-api-secret-token': 'whk_x' }), true, 'Telegram\'s secret_token');
assert.strictEqual(verified('whk_x', body, { 'x-telegram-bot-api-secret-token': 'whk_y' }), false);
assert.strictEqual(verified('whk_x', body, { 'x-telegram-bot-api-secret-token': 'whk_y' }), false);
assert.strictEqual(verified(SLACK, body, slack, { provider: 'github' }), false, 'an unknown provider checks nothing');
assert.strictEqual(verified(SLACK, body, slack, { provider: 'github' }), false, 'an unknown provider checks nothing');
- assert.deepStrictEqual(Object.keys(PROVIDERS), ['slack', 'stripe']);
+ assert.deepStrictEqual(Object.keys(PROVIDERS), ['slack', 'stripe', 'discord']);
});
});
t('the owner pastes Slack\'s signing secret; it is kept, never echoed; a bad one changes nothing', async () => {
t('the owner pastes Slack\'s signing secret; it is kept, never echoed; a bad one changes nothing', async () => {
@@ -93,6 +93,30 @@ t('Stripe: a signed event runs the check; the wrong secret is refused', async ()
@@ -93,6 +93,30 @@ t('Stripe: a signed event runs the check; the wrong secret is refused', async ()
assert.strictEqual((await deliver(stripeWatch.id, body, { 'Stripe-Signature': `t=${ts},v1=${hmac('whsec_other', `${ts}.${body}`)}` })).status, 401);
assert.strictEqual((await deliver(stripeWatch.id, body, { 'Stripe-Signature': `t=${ts},v1=${hmac('whsec_other', `${ts}.${body}`)}` })).status, 401);
});
});
+t('Discord: the ping gets { type: 1 }, a command a quiet reply at once and a check right after; a bad signature 401', async () => {
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
+ const pub = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('hex');
+ const made = await api('POST', '/api/v1/watches', { token: ME, body: { ...def('discord commands'), extraction: { kind: 'json', value_path: 'data.name' }, condition: { op: 'contains', value: 'deploy' } } });
+ const w = made.body.watch;
+ assert.strictEqual((await api('POST', `/api/v1/watches/${w.id}/webhook-secret`, { token: ME, body: { provider: 'discord', secret: 'not-a-key' } })).status, 422);
+ const set = await api('POST', `/api/v1/watches/${w.id}/webhook-secret`, { token: ME, body: { provider: 'discord', secret: pub } });
+ assert.strictEqual(set.body.webhook.provider, 'discord');
+ const sign = (body, ts = String(nowSec())) => ({ 'X-Signature-Timestamp': ts, 'X-Signature-Ed25519': crypto.sign(null, Buffer.concat([Buffer.from(ts), Buffer.from(body)]), privateKey).toString('hex') });
+ const ping = JSON.stringify({ type: 1, id: 'i1' });
+ const p = await deliver(w.id, ping, sign(ping));
+ assert.deepStrictEqual(await p.json(), { type: 1 });
+ assert.strictEqual((await deliver(w.id, ping, { ...sign(ping), 'X-Signature-Ed25519': '0'.repeat(128) })).status, 401, 'Discord checks that a bad signature is refused');
+ assert.strictEqual((await deliver(w.id, ping, sign(ping, String(nowSec() - 600)))).status, 401, 'an old one too');
+ const cmd = JSON.stringify({ type: 2, id: 'i2', data: { name: 'deploy' } });
+ const c = await deliver(w.id, cmd, sign(cmd));
+ const reply = await c.json();
+ assert.deepStrictEqual([reply.type, reply.data.flags], [4, 64], 'an ephemeral reply at once');
+ let got = [];
+ for (let i = 0; i < 50 && !got.length; i++) { got = await runs(w.id); if (!got.length) await new Promise((r) => setTimeout(r, 50)); }
+ assert.strictEqual(got.length, 1, 'the check ran right after');
+ assert.strictEqual(got[0].trigger, 'webhook');
+});
+
t('a new secret of Watch\'s own replaces a provider\'s, and its forms work again', async () => {
t('a new secret of Watch\'s own replaces a provider\'s, and its forms work again', async () => {
const rotated = await api('POST', `/api/v1/watches/${stripeWatch.id}/webhook-secret`, { token: ME });
const rotated = await api('POST', `/api/v1/watches/${stripeWatch.id}/webhook-secret`, { token: ME });
assert.match(rotated.body.webhook.secret, /^whk_/);
assert.match(rotated.body.webhook.secret, /^whk_/);