openvibers/openvibe.vip Commits

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

OP OpenVibers committed 23 Sept 2026 · dc98781d42 · 5 files, +17 −12

POST /internal/events calls parseDelivery(..., { requireV2: true }) for each configured secret:
a delivery is accepted only when X-OpenVibe-Signature-V2 (HMAC of "<t>.<raw body>") verifies
and t is within 300 s of this clock. A v1-only delivery (v2 stripped from a capture) or a stale
v2 is 401 vip.bad_signature, so a captured Billing entitlement cannot be replayed later.

Tests: the delivery helper sends signDeliveryHeaders; new v1-only and stale-v2 (301 s) refusals.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 5544f2c..11b3087 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",
-- "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"
},
"engines": {
@@@ -999,9 +999,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 3a3c534..23b3da2 100644
---- a/package.json
++++ b/package.json
@@@ -27,7 +27,7 @@
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",
-- "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"
},
"engines": {
ddiff --git a/server/events/consumer.js b/server/events/consumer.js
iindex d7ef004..8ec6302 100644
---- a/server/events/consumer.js
++++ b/server/events/consumer.js
@@@ -77,7 +77,8 @@ function consumerRouter({ domain, config, log = console }) {
if (!secrets.length) return http.sendProblem(res, 503, 'vip.webhook_disabled', { detail: 'VIP_EVENTS_SECRET is not set', ctx: req.ov });
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
let delivery = null;
-- for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s); if (delivery) break; }
++ // Signature v2 only: HMAC over "<t>.<raw body>" with t within ±300 s; a v1-only (v2 stripped) or stale delivery is refused.
++ for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s, { requireV2: true }); if (delivery) break; }
if (!delivery) return http.sendProblem(res, 401, 'vip.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx: req.ov });
const event = delivery.event;
if (!event || typeof event.event_id !== 'string' || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(event.event_id)) {
ddiff --git a/test/entitlements.test.js b/test/entitlements.test.js
iindex 36633fc..27ca663 100644
---- a/test/entitlements.test.js
++++ b/test/entitlements.test.js
@@@ -5,7 +5,7 @@
* valid_until (+ grace); ordering and duplicates are safe. Injected clock throughout.
*/
const assert = require('assert');
--const { signDelivery } = require('openvibe-sdk/events');
++const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { boot, harness } = require('./helpers/app');
const { DAY } = require('./helpers/stubs');
@@@ -48,15 +48,17 @@ const MIN = 60_000;
assert.strictEqual(t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === m.subject).length, 1);
});
-- test('deliveries must be signed; only Billing is believed', async () => {
++ test('deliveries must be signed (v2, inside the replay window); only Billing is believed', async () => {
const m = t.network.newUser('m3');
const ent = t.billing.pay(m.subject, creator.subject).events[1];
assert.strictEqual((await t.deliver(ent, { secret: 'x'.repeat(48) })).status, 401);
++ assert.strictEqual((await t.deliver(ent, { v1Only: true })).status, 401, 'v1 only (no v2 header): refused');
++ assert.strictEqual((await t.deliver(ent, { now: Date.now() - 301000 })).status, 401, 'stale v2 (outside the 300 s window): refused');
const forged = { ...ent, event_id: t.billing.envelope('x.y.z', { type: 'x', id: '1' }, {}).event_id, source: 'live' };
assert.strictEqual((await t.deliver(forged)).json.outcome, 'ignored:source');
assert.strictEqual(t.domain.entitlements.getRow(m.subject, creator.subject), null);
const raw = JSON.stringify({ event: ent, seq: 1 });
-- const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body: raw, headers: { 'X-OpenVibe-Signature': signDelivery(raw + ' ', t.EVENTS_SECRET) } });
++ const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body: raw, headers: signDeliveryHeaders(raw + ' ', t.EVENTS_SECRET) });
assert.strictEqual(res.status, 401);
});
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
iindex af7066b..997dd00 100644
---- a/test/helpers/app.js
++++ b/test/helpers/app.js
@@@ -12,7 +12,7 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const http = require('http');
--const { signDelivery } = require('openvibe-sdk/events');
++const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
const { startNetwork, startBilling } = require('./stubs');
const EVENTS_SECRET = 'e'.repeat(48);
@@@ -64,11 +64,13 @@ async function boot(opts = {}) {
return { status: res.status, headers: res.headers, json, text };
}
-- async function deliver(event, { secret = EVENTS_SECRET, seq = 1 } = {}) {
++ /** POST a signed Events delivery (v1 and v2 headers); `v1Only` sends only X-OpenVibe-Signature, `now` (ms) backdates v2. */
++ async function deliver(event, { secret = EVENTS_SECRET, seq = 1, v1Only = false, now } = {}) {
const raw = JSON.stringify({ event, seq });
++ const sig = v1Only ? { 'X-OpenVibe-Signature': signDelivery(raw, secret) } : signDeliveryHeaders(raw, secret, { now });
const res = await fetch(`${base}/internal/events`, {
method: 'POST', body: raw,
-- headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': signDelivery(raw, secret), 'X-OpenVibe-Seq': String(seq) },
++ headers: { 'Content-Type': 'application/json', ...sig, 'X-OpenVibe-Seq': String(seq) },
});
return { status: res.status, json: await res.json().catch(() => null) };
}