openvibers/openvibe.news Commits
Events webhook requires signature v2 (replay window); openvibe-sdk v0.4.0
Changes
@@@ -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"@@@ -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": {@@@ -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;@@@ -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 };@@@ -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' } });
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.21.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-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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -1060,9 +1060,9 @@
@@ -1060,9 +1060,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.21.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-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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
*/
*/
const express = require('express');
const express = require('express');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
-const { verifyDelivery, createInbox } = require('openvibe-sdk/events');
+const { verifyDeliveryV2, createInbox } = require('openvibe-sdk/events');
const CONSUMER = 'news-sources';
const CONSUMER = 'news-sources';
const EVT_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
const EVT_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -32,8 +32,8 @@ function createWebhook({ config, store, ingest, sources, log = console }) {
@@ -32,8 +32,8 @@ function createWebhook({ config, store, ingest, sources, log = console }) {
const secrets = config.events.webhookSecrets;
const secrets = config.events.webhookSecrets;
if (!secrets.length) return http.sendProblem(res, 503, 'news.webhook_disabled', { detail: 'NEWS_EVENTS_SECRET is not set', ctx });
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 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;
let body;
try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }
try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }
const event = body && body.event;
const event = body && body.event;
@@ -188,12 +188,17 @@ async function startAi({ network }) {
@@ -188,12 +188,17 @@ async function startAi({ network }) {
return { ...srv, requests, setNext: (v) => { next = v; } };
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 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 body = JSON.stringify({ event: envelope, seq });
const sig = `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`;
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 };
module.exports = { startNetwork, startSources, startAi, delivery, listen };
@@ -123,9 +123,13 @@ const { boot, check, done, launchReports } = require('./helpers/boot');
@@ -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');
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' });
const r = await t.deliver({ event_type: 'sources.item.created', payload: {} }, { secret: 'wrong-secret-wrong-secret-wrong-secret' });
assert.strictEqual(r.status, 401);
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: {} });
const other = await t.deliver({ event_type: 'blog.post.published', source: 'blog', payload: {} });
assert.strictEqual(other.status, 204);
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' } });
const forged = await t.deliver({ event_type: 'sources.item.removed', source: 'blog', payload: { item_id: reports.a.id, reason: 'forged' } });