openvibers/openvibe.search Commits

Events webhook requires signature v2 (replay window)

OP OpenVibers committed 23 Sept 2026 · 5a7e59b741 · 3 files, +64 −8

POST /internal/events now verifies X-OpenVibe-Signature-V2 (HMAC-SHA256 of "<t>.<raw body>")
with its own constant-time check (verifySignatureV2, same rules as openvibe-sdk 0.4.0
verifyDeliveryV2; Search keeps no SDK dependency). A delivery whose t is more than 300 s from
this clock, whose X-OpenVibe-Timestamp disagrees with t, or that carries only the v1
X-OpenVibe-Signature is refused 401 search.bad_signature. v1 is never consulted.

Tests: the delivery helper sends all three signature headers; new cases prove a v1-only
delivery, a stale v2 and a future v2 are refused and change nothing.

Changes

ddiff --git a/server/api/webhook.js b/server/api/webhook.js
iindex 1b53091..87ed4f8 100644
---- a/server/api/webhook.js
++++ b/server/api/webhook.js
@@@ -28,6 +28,41 @@ function sign(raw, secret) {
return 'sha256=' + crypto.createHmac('sha256', String(secret)).update(raw).digest('hex');
}
++/** X-OpenVibe-Signature-V2 value: t=<unix seconds>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">. */
++function signV2(raw, secret, ts = Math.floor(Date.now() / 1000)) {
++ return `t=${ts},v2=` + crypto.createHmac('sha256', String(secret)).update(`${ts}.`).update(raw).digest('hex');
++}
++
++const V2_TOLERANCE_SEC = 300;
++
++/**
++ * Constant-time check of X-OpenVibe-Signature-V2 against the raw body with any of `secrets`, and
++ * of its timestamp (±300 s of now; X-OpenVibe-Timestamp, when sent, must be the same t). Same
++ * rules as openvibe-sdk 0.4.0 verifyDeliveryV2(). v1 is never consulted.
++ */
++function verifySignatureV2(raw, headerV2, headerTs, secrets, now = Date.now()) {
++ if (!raw || typeof headerV2 !== 'string') return false;
++ let t = null;
++ const given = [];
++ for (const part of headerV2.split(',')) {
++ const i = part.indexOf('=');
++ if (i < 0) return false;
++ const k = part.slice(0, i).trim();
++ const v = part.slice(i + 1).trim();
++ if (k === 't') {
++ if (t !== null || !/^\d{1,12}$/.test(v)) return false;
++ t = Number(v);
++ } else if (k === 'v2') given.push(Buffer.from(v));
++ }
++ if (t === null || !given.length) return false;
++ if (headerTs !== undefined && String(headerTs).trim() !== String(t)) return false;
++ if (Math.abs(now / 1000 - t) > V2_TOLERANCE_SEC) return false;
++ return secrets.some((s) => {
++ const expected = Buffer.from(signV2(raw, s, t).split(',v2=')[1]);
++ return given.some((g) => g.length === expected.length && crypto.timingSafeEqual(g, expected));
++ });
++}
++
function verifySignature(raw, header, secrets) {
if (!raw || typeof header !== 'string') return false;
const given = Buffer.from(header.trim());
@@@ -95,8 +130,9 @@ function webhookRouter({ config, db, store, relay, log = console, now }) {
const secrets = config.events.webhookSecrets;
if (!secrets.length) return http.sendProblem(res, 503, 'search.webhook_disabled', { detail: 'SEARCH_EVENTS_SECRET is not set', ctx });
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
-- if (!verifySignature(raw, req.get('x-openvibe-signature'), secrets)) {
-- return http.sendProblem(res, 401, 'search.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 (!verifySignatureV2(raw, req.get('x-openvibe-signature-v2'), req.get('x-openvibe-timestamp'), secrets)) {
++ return http.sendProblem(res, 401, 'search.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; }
@@@ -131,4 +167,4 @@ function webhookRouter({ config, db, store, relay, log = console, now }) {
return router;
}
--module.exports = { webhookRouter, createInbox, documentFromEvent, verifySignature, sign, CONSUMER };
++module.exports = { webhookRouter, createInbox, documentFromEvent, verifySignature, sign, verifySignatureV2, signV2, CONSUMER };
ddiff --git a/test/helpers.js b/test/helpers.js
iindex 5ac6978..ac7e84c 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -11,7 +11,7 @@ const path = require('path');
const { serviceAuth, ids } = require('openvibe-contracts');
const { load } = require('../server/config');
const { start } = require('../server/index');
--const { sign } = require('../server/api/webhook');
++const { sign, signV2 } = require('../server/api/webhook');
const ISSUER = 'https://openvibe.network';
const WEBHOOK_SECRET = 'whsec_test_' + 'x'.repeat(40);
@@@ -113,13 +113,18 @@ function indexEvent(document, { action = 'upserted', source = document.owner, ev
};
}
--/** POST a signed delivery to /internal/events. */
--async function deliver(base, event, { secret = WEBHOOK_SECRET, seq = 1, badSignature = false } = {}) {
++/**
++ * POST a signed delivery to /internal/events with the three signature headers Events sends.
++ * `v1Only` drops the v2 headers; `now` (ms) backdates the v2 timestamp.
++ */
++async function deliver(base, event, { secret = WEBHOOK_SECRET, seq = 1, badSignature = false, v1Only = false, now = Date.now() } = {}) {
const raw = JSON.stringify({ event, seq });
-- const signature = badSignature ? sign(raw, 'wrong-secret-' + 'y'.repeat(32)) : sign(raw, secret);
++ const key = badSignature ? 'wrong-secret-' + 'y'.repeat(32) : secret;
++ const ts = Math.floor(now / 1000);
++ const v2 = v1Only ? {} : { 'X-OpenVibe-Timestamp': String(ts), 'X-OpenVibe-Signature-V2': signV2(raw, key, ts) };
return request(base, 'POST', '/internal/events', {
raw,
-- headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': signature, 'X-OpenVibe-Event-Id': event.event_id },
++ headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': sign(raw, key), ...v2, 'X-OpenVibe-Event-Id': event.event_id },
});
}
ddiff --git a/test/webhook-inbox.test.js b/test/webhook-inbox.test.js
iindex af35949..10eeea7 100644
---- a/test/webhook-inbox.test.js
++++ b/test/webhook-inbox.test.js
@@@ -21,6 +21,21 @@ t('an unsigned or wrongly signed delivery is refused and changes nothing', async
assert.strictEqual(receipts(), 0);
});
++t('signature v2 is required: a v1-only delivery and a stale v2 timestamp are refused', async () => {
++ const e = indexEvent(doc({ title: 'Replayed omiword' }));
++ const v1 = await deliver(svc.base, e, { v1Only: true });
++ assert.strictEqual(v1.status, 401, 'v1 only (v2 stripped): refused');
++ const stale = await deliver(svc.base, e, { now: Date.now() - 301000 });
++ assert.strictEqual(stale.status, 401, 'stale v2 (outside the 300 s window): refused');
++ const future = await deliver(svc.base, e, { now: Date.now() + 301000 });
++ assert.strictEqual(future.status, 401, 'v2 from too far in the future: refused');
++ assert.strictEqual((await search('omiword')).length, 0);
++ assert.strictEqual(receipts(), 0);
++ const ok = await deliver(svc.base, e);
++ assert.strictEqual(ok.status, 200, 'a fresh v2 delivery of the same event is accepted');
++ assert.strictEqual((await search('omiword')).length, 1);
++});
++
t('a delivered upsert is indexed; the same event again is a no-op', async () => {
const d = doc({ title: 'Delivered nuword' });
const e = indexEvent(d);