openvibers/openvibe.search Commits
Events webhook requires signature v2 (replay window)
Changes
@@@ -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 };@@@ -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 },});}@@@ -21,6 +21,21 @@ t('an unsigned or wrongly signed delivery is refused and changes nothing', asyncassert.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);
@@ -28,6 +28,41 @@ function sign(raw, secret) {
@@ -28,6 +28,41 @@ function sign(raw, secret) {
return 'sha256=' + crypto.createHmac('sha256', String(secret)).update(raw).digest('hex');
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) {
function verifySignature(raw, header, secrets) {
if (!raw || typeof header !== 'string') return false;
if (!raw || typeof header !== 'string') return false;
const given = Buffer.from(header.trim());
const given = Buffer.from(header.trim());
@@ -95,8 +130,9 @@ function webhookRouter({ config, db, store, relay, log = console, now }) {
@@ -95,8 +130,9 @@ function webhookRouter({ config, db, store, relay, log = console, now }) {
const secrets = config.events.webhookSecrets;
const secrets = config.events.webhookSecrets;
if (!secrets.length) return http.sendProblem(res, 503, 'search.webhook_disabled', { detail: 'SEARCH_EVENTS_SECRET is not set', ctx });
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);
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;
let body;
try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }
try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }
@@ -131,4 +167,4 @@ function webhookRouter({ config, db, store, relay, log = console, now }) {
@@ -131,4 +167,4 @@ function webhookRouter({ config, db, store, relay, log = console, now }) {
return router;
return router;
}
}
-module.exports = { webhookRouter, createInbox, documentFromEvent, verifySignature, sign, CONSUMER };
+module.exports = { webhookRouter, createInbox, documentFromEvent, verifySignature, sign, verifySignatureV2, signV2, CONSUMER };
@@ -11,7 +11,7 @@ const path = require('path');
@@ -11,7 +11,7 @@ const path = require('path');
const { serviceAuth, ids } = require('openvibe-contracts');
const { serviceAuth, ids } = require('openvibe-contracts');
const { load } = require('../server/config');
const { load } = require('../server/config');
const { start } = require('../server/index');
const { start } = require('../server/index');
-const { sign } = require('../server/api/webhook');
+const { sign, signV2 } = require('../server/api/webhook');
const ISSUER = 'https://openvibe.network';
const ISSUER = 'https://openvibe.network';
const WEBHOOK_SECRET = 'whsec_test_' + 'x'.repeat(40);
const WEBHOOK_SECRET = 'whsec_test_' + 'x'.repeat(40);
@@ -113,13 +113,18 @@ function indexEvent(document, { action = 'upserted', source = document.owner, ev
@@ -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 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', {
return request(base, 'POST', '/internal/events', {
raw,
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 },
});
});
}
}
@@ -21,6 +21,21 @@ t('an unsigned or wrongly signed delivery is refused and changes nothing', async
@@ -21,6 +21,21 @@ t('an unsigned or wrongly signed delivery is refused and changes nothing', async
assert.strictEqual(receipts(), 0);
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 () => {
t('a delivered upsert is indexed; the same event again is a no-op', async () => {
const d = doc({ title: 'Delivered nuword' });
const d = doc({ title: 'Delivered nuword' });
const e = indexEvent(d);
const e = indexEvent(d);