openvibers/openvibe.watch Commits

A Discord application can wake a watch (agent plan A3) (#195)

OP OpenVibers committed 10 days ago · 1b8fb9a983 · 4 files, +75 −9

Discord signs interactions with Ed25519 and the application's public key, and wants its reply within three seconds
in its own shape. A webhook watch now takes provider 'discord' with that key: the ping gets { type: 1 }, any other
interaction a quiet 'Got it' at once, and the check runs right after with the interaction as input. A bad or old
signature answers 401, as Discord checks.

Changes

ddiff --git a/README.md b/README.md
iindex 247b440..4ce8583 100644
---- a/README.md
++++ b/README.md
@@@ -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's
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
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
@@@ -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 time
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`.
- **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`.
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 9c68bca..b341738 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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')}
ddiff --git a/server/webhook-sources.js b/server/webhook-sources.js
iindex 24edcd8..cdc519e 100644
---- a/server/webhook-sources.js
++++ b/server/webhook-sources.js
@@@ -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.no
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 (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;
ddiff --git a/test/webhook-providers.test.js b/test/webhook-providers.test.js
iindex 187c9fd..20edf51 100644
---- a/test/webhook-providers.test.js
++++ b/test/webhook-providers.test.js
@@@ -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_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_/);