openvibers/openvibe.watch Commits

A webhook watch can be signed by Slack or Stripe, and Telegram's header works (agent plan A3) (#194)

OP OpenVibers committed 10 days ago · 9edcd8af0e · 6 files, +234 −20

A webhook watch took GitHub's signature or a bearer token, both with Watch's own secret. Slack and Stripe sign with a
secret they make, so they could not wake a watch, and so could not wake an agent. The person now pastes that secret
(the watch's page, or POST .../webhook-secret { provider, secret }) and that scheme checks each delivery: Slack v0
with its challenge answered and retries acknowledged, Stripe v1, both within five minutes. Telegram's secret-token
header is accepted with Watch's own secret. Migration 0015 adds webhook_secrets.provider.

Changes

ddiff --git a/README.md b/README.md
iindex f9208ee..247b440 100644
---- a/README.md
++++ b/README.md
@@@ -200,8 +200,20 @@ product (`server/webhook-sources.js`, `server/carriers/webhook.js`).
(`whk_…`) is never shown again; `POST /api/v1/watches/:id/webhook-secret` replaces it (the old one stops working at
once). It is kept in full because checking a signature needs it (`webhook_secrets`), and it goes with its watch:
deleting the watch or the account removes it.
--- **Signing.** `X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the exact body with the secret>` (GitHub's form), or
-- `Authorization: Bearer <secret>` for a sender that cannot sign. Compared in constant time.
++- **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
++ 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
++ (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
++ five minutes. Slack's `url_verification` is answered with its `challenge`, and a retry (`X-Slack-Retry-Num`) is
++ 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.
++ - 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`.
- **Answers.** 200 `{ watch_id, state, triggered }` after the check. 401 for a missing or wrong signature. 404 for an
ddiff --git a/migrations/0015_webhook_provider.sql b/migrations/0015_webhook_provider.sql
nnew file mode 100644
iindex 0000000..7957beb
---- /dev/null
++++ b/migrations/0015_webhook_provider.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++-- OpenVibe.Watch: a webhook watch may be signed by the sender's own secret (agent plan A3, triggers v1). Slack signs
++-- with the app's signing secret and Stripe with the endpoint's secret, both made by them, not by Watch: the person
++-- pastes it, and `provider` says which scheme checks it (server/webhook-sources.js). NULL is Watch's own whk_ secret,
++-- as before. The secret is needed in full to check a signature, so it is stored as Watch's own is.
++ALTER TABLE webhook_secrets ADD COLUMN IF NOT EXISTS provider text;
ddiff --git a/server/api/watches.js b/server/api/watches.js
iindex bd3cf7a..02915ec 100644
---- a/server/api/watches.js
++++ b/server/api/watches.js
@@@ -100,7 +100,11 @@ function watchesRouter({ registry, auth, limits, webhookSources = null }) {
if (!webhookSources || !view.source || view.source.kind !== 'webhook') {
return res.status(409).type('application/problem+json').send(JSON.stringify({ type: 'about:blank', title: 'watch.not_webhook', status: 409, code: 'watch.not_webhook', detail: 'only a webhook watch has a secret' }));
}
-- res.json({ watch_id: row.id, webhook: await webhookSources.issue(row.id) });
++ // { provider, secret }: the sender's own signing secret (Slack, Stripe) instead of a new one of Watch's.
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const hook = await webhookSources.issue(row.id, { provider: b.provider || null, secret: b.secret || null });
++ if (hook.error) return res.status(422).type('application/problem+json').send(JSON.stringify({ type: 'about:blank', title: 'watch.invalid', status: 422, code: 'watch.invalid', detail: hook.error }));
++ res.json({ watch_id: row.id, webhook: hook });
} catch (err) { fail(res, req, err); }
});
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex be44e81..9c68bca 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -825,6 +825,7 @@ ${watchForm({ template, fields, errors, action: '/watches/new', submit: CREATE_S
const obs = await observations.list(row.id, { before: ridBefore(req.query.obs_before), limit: obsLimit(req.query.obs_limit) });
const runs = await check.list(row.id, { before: ridBefore(req.query.checks_before), limit: obsLimit(req.query.checks_limit) });
const acts = actions ? await actions.recent(row.id, 10) : [];
++ const hookProvider = v.source.kind === 'webhook' && ctx.webhookSources && ctx.webhookSources.providerOf ? await ctx.webhookSources.providerOf(row.id) : null;
const configuredActions = (v.action || []).map((a) => a.kind === 'actor-agent'
? html`<div>Run agent <a href="https://openvibe.actor/me/agents/${a.agent_id}"><code>${a.agent_id}</code></a>${a.note ? html` — ${a.note}` : ''}</div>`
: a.kind === 'codes-run'
@@@ -891,7 +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">The secret was shown once, when the watch was made. A new one replaces it at once.</span></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 ${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>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>
@@@ -983,7 +986,11 @@ ${table(['When', 'State', 'HTTP', 'Detail', 'Read', 'Fired'], runRows, { empty:
if (!row) return notFoundPage(req, res);
const v = registry.view(row);
if (v.source.kind !== 'webhook' || !ctx.webhookSources) return detailNotice(req, res, row, 409, 'Only a webhook watch has a secret.');
-- return secretPage(req, res, v, await ctx.webhookSources.issue(row.id), 200);
++ // A sender's own signing secret (Slack, Stripe), or a new secret of Watch's own.
++ const b = req.body || {};
++ const hook = await ctx.webhookSources.issue(row.id, { provider: b.provider || null, secret: b.secret || null });
++ if (hook.error) return detailNotice(req, res, row, 422, `Not changed: ${hook.error}.`);
++ return secretPage(req, res, v, hook, 200);
});
/** The address and secret of a webhook watch, shown once: no cache, no index, and never again after this page. */
@@@ -992,7 +999,18 @@ ${table(['When', 'State', 'HTTP', 'Detail', 'Read', 'Fired'], runRows, { empty:
res.set('Referrer-Policy', 'no-referrer');
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: 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')}
++<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>
++<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>`}
++<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')}
<dl class="facts">
<dt>Address</dt><dd><code class="copyable">${hook.url}</code></dd>
ddiff --git a/server/webhook-sources.js b/server/webhook-sources.js
iindex 28ddf3b..24edcd8 100644
---- a/server/webhook-sources.js
++++ b/server/webhook-sources.js
@@@ -9,7 +9,16 @@
* POST /api/v1/watches/:id/webhook-secret (which replaces it). It is needed in full to check a
* signature, so it is stored as it is (webhook_secrets), and it goes with its watch.
* Signing X-Hub-Signature-256: sha256=<hex HMAC-SHA256(secret, raw body)> (GitHub's form; many senders use
-- * it), or Authorization: Bearer <secret> for a sender that cannot sign. Compared in constant time.
++ * it), Authorization: Bearer <secret> for a sender that cannot sign, or Telegram's
++ * X-Telegram-Bot-Api-Secret-Token (setWebhook's secret_token: the watch's secret is a valid one).
++ * Compared in constant time.
++ * A sender's own secret (agent plan A3, triggers v1): Slack and Stripe sign with a secret they make, so the person
++ * pastes it (POST …/webhook-secret { provider, secret }) and that scheme checks the delivery instead:
++ * slack X-Slack-Signature: v0=<hex HMAC-SHA256(secret, "v0:" + X-Slack-Request-Timestamp + ":" +
++ * body)>, the timestamp within 5 minutes; Slack's url_verification is answered with its
++ * 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.
* 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;
@@@ -18,6 +27,12 @@
const crypto = require('crypto');
const SECRET_RE = /^whk_[A-Za-z0-9_-]{43}$/;
++const TOLERANCE_SEC = 300;
++// A sender's own secret: what it looks like, and how it is described where the person pastes it.
++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).' },
++});
const MAX_BODY = 256 * 1024;
const newSecret = () => `whk_${crypto.randomBytes(32).toString('base64url')}`;
@@@ -29,9 +44,37 @@ function safeEqual(a, b) {
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
--/** Does the delivery carry the watch's secret: a matching body signature, or the secret as a bearer token? */
--function verified(secret, raw, headers) {
++const hmac = (secret, data) => crypto.createHmac('sha256', secret).update(data).digest('hex');
++const fresh = (ts, nowMs) => /^\d{1,12}$/.test(String(ts)) && Math.abs(Math.floor(nowMs / 1000) - Number(ts)) <= TOLERANCE_SEC;
++
++/** Slack's request signing: v0 over "v0:<timestamp>:<body>", the timestamp within five minutes. */
++function slackVerified(secret, raw, headers, nowMs) {
++ const ts = String(headers['x-slack-request-timestamp'] || '');
++ const sig = String(headers['x-slack-signature'] || '');
++ if (!fresh(ts, nowMs) || !sig.startsWith('v0=')) return false;
++ return safeEqual(sig, `v0=${hmac(secret, Buffer.concat([Buffer.from(`v0:${ts}:`), raw]))}`);
++}
++
++/** Stripe's webhook signing: v1 over "<t>.<body>" (any of several v1), t within five minutes. */
++function stripeVerified(secret, raw, headers, nowMs) {
++ const parts = String(headers['stripe-signature'] || '').split(',').map((x) => x.trim().split('='));
++ const t = (parts.find(([k]) => k === 't') || [])[1];
++ if (!fresh(t, nowMs)) return false;
++ const want = hmac(secret, Buffer.concat([Buffer.from(`${t}.`), raw]));
++ return parts.filter(([k]) => k === 'v1').some(([, v]) => safeEqual(v || '', want));
++}
++
++/**
++ * 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.
++ */
++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) return false;
++ const tg = headers['x-telegram-bot-api-secret-token'];
++ if (tg != null && safeEqual(String(tg), secret)) return true;
const sig = String(headers['x-hub-signature-256'] || '');
if (sig.startsWith('sha256=')) {
const want = `sha256=${crypto.createHmac('sha256', secret).update(raw).digest('hex')}`;
@@@ -47,20 +90,35 @@ function createWebhookSources({ db, registry, check, config, now = () => Date.no
? config.webhookSources.maxPerWatchPerHour : 60;
const counts = new Map(); // watch id → { hour, n }
const st = {
-- set: db.prepare(`INSERT INTO webhook_secrets (watch_id, secret, created_at) VALUES (?, ?, ?)
-- ON CONFLICT (watch_id) DO UPDATE SET secret = excluded.secret, created_at = excluded.created_at`),
-- get: db.prepare('SELECT secret FROM webhook_secrets WHERE watch_id = ?'),
++ set: db.prepare(`INSERT INTO webhook_secrets (watch_id, secret, created_at, provider) VALUES (?, ?, ?, ?)
++ ON CONFLICT (watch_id) DO UPDATE SET secret = excluded.secret, created_at = excluded.created_at, provider = excluded.provider`),
++ get: db.prepare('SELECT secret, provider FROM webhook_secrets WHERE watch_id = ?'),
drop: db.prepare('DELETE FROM webhook_secrets WHERE watch_id = ?'),
watch: db.prepare('SELECT * FROM watches WHERE id = ? AND deleted_at IS NULL'),
};
-- /** The public address and a fresh secret for a webhook watch (replacing any it had). Shown once. */
-- async function issue(watchId) {
++ /**
++ * The public address and a fresh secret for a webhook watch (replacing any it had), shown once. With a sender's own
++ * secret ({ provider: 'slack' | 'stripe', secret }), that secret is kept instead and never echoed back. A bad
++ * provider or secret answers { error } and changes nothing.
++ */
++ async function issue(watchId, { provider = null, secret: theirs = null } = {}) {
++ const url = `${config.baseUrl}/hooks/${watchId}`;
++ if (provider != null && provider !== '') {
++ const p = PROVIDERS[provider];
++ if (!p) return { error: `provider is one of ${Object.keys(PROVIDERS).join(', ')}` };
++ const value = String(theirs || '').trim();
++ if (!p.secret.test(value)) return { error: `that does not look like a ${p.name} signing secret` };
++ await st.set.run(watchId, value, now(), provider);
++ return { url, provider, signature: p.how };
++ }
const secret = newSecret();
-- await st.set.run(watchId, secret, now());
-- return { url: `${config.baseUrl}/hooks/${watchId}`, secret, signature: 'X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the body with the secret>, or Authorization: Bearer <secret>' };
++ await st.set.run(watchId, secret, now(), null);
++ return { url, secret, signature: 'X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the body with the secret>, Authorization: Bearer <secret>, or Telegram\'s X-Telegram-Bot-Api-Secret-Token' };
}
async function forget(watchId) { await st.drop.run(watchId); }
++ /** Whose secret signs this watch's deliveries: null for Watch's own, else 'slack' or 'stripe'. */
++ async function providerOf(watchId) { return ((await st.get.get(watchId)) || {}).provider || null; }
function retryAfter() { return 3600 - Math.floor((now() / 1000) % 3600); }
function underBudget(id) {
@@@ -96,8 +154,18 @@ function createWebhookSources({ db, registry, check, config, now = () => Date.no
const row = /^wch_[0-9A-HJKMNP-TV-Z]{26}$/.test(id) ? await st.watch.get(id) : null;
const source = row ? (typeof row.source === 'string' ? JSON.parse(row.source) : row.source) : null;
if (!row || !source || source.kind !== 'webhook') return problem(res, 404, 'watch.not_found', 'no webhook watch at this address');
-- const secret = (await st.get.get(id) || {}).secret || null;
-- if (!verified(secret, raw, req.headers)) return problem(res, 401, 'watch.bad_signature', 'the delivery is not signed with this watch\'s secret');
++ const kept = await st.get.get(id) || {};
++ const provider = kept.provider || null;
++ if (!verified(kept.secret || null, raw, req.headers, { provider, now: now() })) return problem(res, 401, 'watch.bad_signature', 'the delivery is not signed with this watch\'s secret');
++ if (provider === 'slack') {
++ // Slack checks a new address with a challenge it wants back, and resends an event it did not see answered
++ // within three seconds: the first delivery is the one that runs the check.
++ let msg = null;
++ try { msg = JSON.parse(raw.toString('utf8')); } catch { /* a form post or not JSON: a delivery like any other */ }
++ res.setHeader('Cache-Control', 'no-store');
++ 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 (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;
@@@ -112,7 +180,7 @@ function createWebhookSources({ db, registry, check, config, now = () => Date.no
return res.json({ watch_id: id, state: out && out.run ? out.run.state : null, triggered: !!(out && out.met) });
}
-- return { issue, forget, receive, endpointFor, verified, SECRET_RE };
++ return { issue, forget, providerOf, receive, endpointFor, verified, SECRET_RE, PROVIDERS };
}
--module.exports = { createWebhookSources, verified, endpointFor, newSecret };
++module.exports = { createWebhookSources, verified, endpointFor, newSecret, PROVIDERS };
ddiff --git a/test/webhook-providers.test.js b/test/webhook-providers.test.js
nnew file mode 100644
iindex 0000000..187c9fd
---- /dev/null
++++ b/test/webhook-providers.test.js
@@@ -0,0 +1,106 @@
++'use strict';
++/**
++ * A webhook watch signed by the sender's own secret (agent plan A3, triggers v1). Slack and Stripe sign with a secret
++ * they make, not Watch's: the person pastes it (POST …/webhook-secret { provider, secret }) and that scheme checks each
++ * delivery. Slack's url_verification gets its challenge back and a retry is acknowledged without a second check; a
++ * delivery older than five minutes is refused; Telegram's secret-token header works with Watch's own secret.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { boot, request, userToken, suite, newUser } = require('./helpers');
++const { verified, PROVIDERS } = require('../server/webhook-sources');
++
++const t = suite('webhook-providers');
++let svc;
++const me = newUser();
++const ME = userToken(me);
++const OTHER = userToken(newUser());
++const api = (method, p, opts = {}) => request(svc.base, method, p, opts);
++const hmac = (secret, data) => crypto.createHmac('sha256', secret).update(data).digest('hex');
++const nowSec = () => Math.floor(Date.now() / 1000);
++const deliver = (id, body, headers = {}) => fetch(`${svc.base}/hooks/${id}`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...headers } });
++const runs = async (id) => await svc.db.prepare('SELECT * FROM check_runs WHERE watch_id = ? ORDER BY rid').all(id);
++const def = (name) => ({ name, source: { kind: 'webhook', endpoint: '/internal/hooks/x' }, cadence: null, extraction: { kind: 'json', value_path: 'event.type' }, condition: { op: 'contains', value: 'app_mention' }, action: [{ kind: 'notification' }] });
++
++const SLACK = 'a'.repeat(16) + '0123456789abcdef';
++const STRIPE = 'whsec_' + 'Zx9'.repeat(10);
++let slackWatch, stripeWatch;
++
++t('boot', async () => { svc = await boot(); });
++
++t('the helper: Slack v0, Stripe v1 (any of several), each in time; Telegram with Watch\'s own secret', () => {
++ const body = Buffer.from('{"event":{"type":"app_mention"}}');
++ const ts = String(nowSec());
++ const slack = { 'x-slack-request-timestamp': ts, 'x-slack-signature': `v0=${hmac(SLACK, `v0:${ts}:${body}`)}` };
++ assert.strictEqual(verified(SLACK, body, slack, { provider: 'slack' }), true);
++ assert.strictEqual(verified(SLACK, body, { ...slack, 'x-slack-request-timestamp': String(nowSec() - 301) }, { provider: 'slack' }), false, 'older than five minutes');
++ assert.strictEqual(verified(SLACK, Buffer.from(`${body} `), slack, { provider: 'slack' }), false, 'the exact bytes');
++ assert.strictEqual(verified(SLACK, body, { authorization: `Bearer ${SLACK}` }, { provider: 'slack' }), false, 'a Slack watch takes only Slack\'s signature');
++ const stripe = { 'stripe-signature': `t=${ts},v1=deadbeef,v1=${hmac(STRIPE, `${ts}.${body}`)}` };
++ assert.strictEqual(verified(STRIPE, body, stripe, { provider: 'stripe' }), true);
++ assert.strictEqual(verified(STRIPE, body, { 'stripe-signature': `t=${nowSec() - 400},v1=${hmac(STRIPE, `${nowSec() - 400}.${body}`)}` }, { provider: 'stripe' }), false);
++ 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']);
++});
++
++t('the owner pastes Slack\'s signing secret; it is kept, never echoed; a bad one changes nothing', async () => {
++ const made = await api('POST', '/api/v1/watches', { token: ME, body: def('slack mentions') });
++ assert.strictEqual(made.status, 201, JSON.stringify(made.body));
++ slackWatch = made.body.watch;
++ const own = made.body.webhook.secret;
++ const bad = await api('POST', `/api/v1/watches/${slackWatch.id}/webhook-secret`, { token: ME, body: { provider: 'slack', secret: 'not-hex' } });
++ assert.strictEqual(bad.status, 422);
++ assert.strictEqual((await api('POST', `/api/v1/watches/${slackWatch.id}/webhook-secret`, { token: ME, body: { provider: 'fax', secret: SLACK } })).status, 422);
++ assert.strictEqual((await svc.db.prepare('SELECT secret FROM webhook_secrets WHERE watch_id = ?').get(slackWatch.id)).secret, own, 'unchanged');
++ assert.strictEqual((await api('POST', `/api/v1/watches/${slackWatch.id}/webhook-secret`, { token: OTHER, body: { provider: 'slack', secret: SLACK } })).status, 404);
++ const set = await api('POST', `/api/v1/watches/${slackWatch.id}/webhook-secret`, { token: ME, body: { provider: 'slack', secret: SLACK } });
++ assert.strictEqual(set.status, 200, JSON.stringify(set.body));
++ assert.strictEqual(set.body.webhook.provider, 'slack');
++ assert.strictEqual(set.body.webhook.url, `${svc.config.baseUrl}/hooks/${slackWatch.id}`);
++ assert.ok(!JSON.stringify(set.body).includes(SLACK), 'their secret is never echoed');
++ assert.strictEqual(await svc.webhookSources.providerOf(slackWatch.id), 'slack');
++});
++
++t('Slack: the challenge comes back, a signed event runs the check once, a retry is acknowledged, an old one is refused', async () => {
++ const ts = String(nowSec());
++ const sign = (body, at = ts) => ({ 'X-Slack-Request-Timestamp': at, 'X-Slack-Signature': `v0=${hmac(SLACK, `v0:${at}:${body}`)}` });
++ const challenge = JSON.stringify({ type: 'url_verification', challenge: 'c4ll3ng3', token: 'old' });
++ const c = await deliver(slackWatch.id, challenge, sign(challenge));
++ assert.strictEqual(c.status, 200);
++ assert.deepStrictEqual(await c.json(), { challenge: 'c4ll3ng3' });
++ assert.strictEqual((await runs(slackWatch.id)).length, 0, 'the challenge runs no check');
++ const ev = JSON.stringify({ type: 'event_callback', event: { type: 'app_mention', text: 'hey' } });
++ const r = await deliver(slackWatch.id, ev, sign(ev));
++ assert.deepStrictEqual(await r.json(), { watch_id: slackWatch.id, state: 'condition_met', triggered: true });
++ const retry = await deliver(slackWatch.id, ev, { ...sign(ev), 'X-Slack-Retry-Num': '1', 'X-Slack-Retry-Reason': 'http_timeout' });
++ assert.strictEqual((await retry.json()).retry, true);
++ assert.strictEqual((await runs(slackWatch.id)).length, 1, 'the retry did not check again');
++ const old = String(nowSec() - 600);
++ assert.strictEqual((await deliver(slackWatch.id, ev, sign(ev, old))).status, 401, 'a replayed request');
++ assert.strictEqual((await deliver(slackWatch.id, challenge, { Authorization: `Bearer ${SLACK}` })).status, 401, 'no challenge for an unsigned caller');
++});
++
++t('Stripe: a signed event runs the check; the wrong secret is refused', async () => {
++ const made = await api('POST', '/api/v1/watches', { token: ME, body: { ...def('stripe payments'), extraction: { kind: 'json', value_path: 'type' }, condition: { op: 'contains', value: 'payment_intent.succeeded' } } });
++ stripeWatch = made.body.watch;
++ assert.strictEqual((await api('POST', `/api/v1/watches/${stripeWatch.id}/webhook-secret`, { token: ME, body: { provider: 'stripe', secret: STRIPE } })).status, 200);
++ const body = JSON.stringify({ id: 'evt_1', type: 'payment_intent.succeeded' });
++ const ts = String(nowSec());
++ const ok = await deliver(stripeWatch.id, body, { 'Stripe-Signature': `t=${ts},v1=${hmac(STRIPE, `${ts}.${body}`)}` });
++ assert.deepStrictEqual(await ok.json(), { watch_id: stripeWatch.id, state: 'condition_met', triggered: true });
++ assert.strictEqual((await deliver(stripeWatch.id, body, { 'Stripe-Signature': `t=${ts},v1=${hmac('whsec_other', `${ts}.${body}`)}` })).status, 401);
++});
++
++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_/);
++ assert.strictEqual(await svc.webhookSources.providerOf(stripeWatch.id), null);
++ const body = JSON.stringify({ type: 'payment_intent.succeeded' });
++ assert.strictEqual((await deliver(stripeWatch.id, body, { 'X-Telegram-Bot-Api-Secret-Token': rotated.body.webhook.secret })).status, 200, 'Telegram\'s header');
++});
++
++t('stop', async () => { await svc.stop(); });
++
++t.run();