openvibers/openvibe.news Commits

Events webhook requires signature v2 (replay window); openvibe-sdk v0.4.0

OP OpenVibers committed 23 Sept 2026 · 1802e7dc7a · 5 files, +21 −12

POST /internal/events verifies with verifyDeliveryV2(raw, req.headers, secret) for each
configured secret: X-OpenVibe-Signature-V2 must match "<t>.<raw body>" and t must be within
300 s of this clock. A v1-only delivery (v2 stripped from a capture) or a stale v2 is
401 news.bad_signature; v1 is no longer consulted.

Tests: the delivery helper signs v1 and v2 like Events; new v1-only and stale-v2 (301 s) refusals.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 765084f..e6b9bfa 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.21.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.1",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
},
"devDependencies": {
@@@ -1060,9 +1060,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.2.2",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
-- "integrity": "sha512-kDifUp3EcmnTWzElAmaCZnX659WErcnm4+qVEoAiYQWX/w/1p4TWvoHbUPsrX65TSROVR3tiyVkl801n/0yyCw==",
++ "version": "0.4.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",
++ "integrity": "sha512-Hyz55YOMIs0xwCQ1NXsN0sUYhlbKXAX3uWY2fPCdEEL18FXUYVhK8wlG/5JMMypFHnjDiSd9VHzBNG12hWsmkQ==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex 53b743e..4db9cda 100644
---- a/package.json
++++ b/package.json
@@@ -24,7 +24,7 @@
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.21.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.1",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
},
"devDependencies": {
ddiff --git a/server/http/webhook.js b/server/http/webhook.js
iindex 0656727..c8a76d6 100644
---- a/server/http/webhook.js
++++ b/server/http/webhook.js
@@@ -16,7 +16,7 @@
*/
const express = require('express');
const { http } = require('openvibe-contracts');
--const { verifyDelivery, createInbox } = require('openvibe-sdk/events');
++const { verifyDeliveryV2, createInbox } = require('openvibe-sdk/events');
const CONSUMER = 'news-sources';
const EVT_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -32,8 +32,8 @@ function createWebhook({ config, store, ingest, sources, log = console }) {
const secrets = config.events.webhookSecrets;
if (!secrets.length) return http.sendProblem(res, 503, 'news.webhook_disabled', { detail: 'NEWS_EVENTS_SECRET is not set', ctx });
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
-- const sig = req.get('x-openvibe-signature');
-- if (!secrets.some((s) => verifyDelivery(raw, sig, s))) return http.sendProblem(res, 401, 'news.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx });
++ // v2 only: signature over "<t>.<raw body>" and t within ±300 s (a replayed or v1-only delivery fails).
++ if (!secrets.some((s) => verifyDeliveryV2(raw, req.headers, s))) return http.sendProblem(res, 401, 'news.bad_signature', { detail: 'X-OpenVibe-Signature-V2 does not verify or is outside the replay window', ctx });
let body;
try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }
const event = body && body.event;
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex c14c1b8..1e65b9b 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -188,12 +188,17 @@ async function startAi({ network }) {
return { ...srv, requests, setNext: (v) => { next = v; } };
}
--/** An Events webhook delivery for News: { body, headers } signed with the secret. */
--function delivery(event, secret, { seq = 1, attempt = 1 } = {}) {
++/**
++ * An Events webhook delivery for News: { body, headers } signed with the secret (v1 and v2, as
++ * Events sends it). `v1Only` leaves the v2 headers off; `now` (ms) backdates the v2 timestamp.
++ */
++function delivery(event, secret, { seq = 1, attempt = 1, v1Only = false, now = Date.now() } = {}) {
const envelope = { event_id: `evt_${ids.ulid(Date.now())}`, version: 1, timestamp: new Date().toISOString(), visibility: 'internal', actor: { type: 'service', id: 'sources' }, source: 'sources', payload: {}, ...event };
const body = JSON.stringify({ event: envelope, seq });
const sig = `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`;
-- return { body, envelope, headers: { 'content-type': 'application/json', 'x-openvibe-signature': sig, 'x-openvibe-delivery-attempt': String(attempt), 'x-openvibe-seq': String(seq) } };
++ const ts = Math.floor(now / 1000);
++ const v2 = v1Only ? {} : { 'x-openvibe-timestamp': String(ts), 'x-openvibe-signature-v2': `t=${ts},v2=${crypto.createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex')}` };
++ return { body, envelope, headers: { 'content-type': 'application/json', 'x-openvibe-signature': sig, ...v2, 'x-openvibe-delivery-attempt': String(attempt), 'x-openvibe-seq': String(seq) } };
}
module.exports = { startNetwork, startSources, startAi, delivery, listen };
ddiff --git a/test/ingest.test.js b/test/ingest.test.js
iindex 2d802ee..49eff29 100644
---- a/test/ingest.test.js
++++ b/test/ingest.test.js
@@@ -123,9 +123,13 @@ const { boot, check, done, launchReports } = require('./helpers/boot');
assert.strictEqual(pulled.counts.created || 0, 0, 'the pull backstop sees it as already applied');
});
-- await check('webhook: bad signature 401, events from other producers ignored', async () => {
++ await check('webhook: bad signature, v1-only and stale v2 are 401; events from other producers ignored', async () => {
const r = await t.deliver({ event_type: 'sources.item.created', payload: {} }, { secret: 'wrong-secret-wrong-secret-wrong-secret' });
assert.strictEqual(r.status, 401);
++ const v1only = await t.deliver({ event_type: 'blog.post.published', source: 'blog', payload: {} }, { v1Only: true });
++ assert.strictEqual(v1only.status, 401, 'v1 only (no v2 header): refused');
++ const stale = await t.deliver({ event_type: 'blog.post.published', source: 'blog', payload: {} }, { now: Date.now() - 301000 });
++ assert.strictEqual(stale.status, 401, 'stale v2 (outside the 300 s window): refused');
const other = await t.deliver({ event_type: 'blog.post.published', source: 'blog', payload: {} });
assert.strictEqual(other.status, 204);
const forged = await t.deliver({ event_type: 'sources.item.removed', source: 'blog', payload: { item_id: reports.a.id, reason: 'forged' } });