openvibers/openvibe.events Commits

Replay window for webhook deliveries (signature v2): every attempt also carries X-OpenVibe-Timestamp (unix seconds, fresh per retry and replay) and X-OpenVibe-Signature-V2: t=<ts>,v2=<HMAC-SHA256 of "<ts>.<raw body>">; X-OpenVibe-Signature (v1) unchanged. lib/client.js: signV2() and verifyDeliveryV2(raw, headers, secret, { toleranceSec = 300, now }) — constant-time, refuses timestamps outside ±tolerance or disagreeing with X-OpenVibe-Timestamp

OP OpenVibers committed 23 Sept 2026 · e8ac63e7ac · 5 files, +122 −11


Changes

ddiff --git a/README.md b/README.md
iindex d05b786..8d16e71 100644
---- a/README.md
++++ b/README.md
@@@ -63,7 +63,9 @@ POST /api/v1/subscriptions
Topic patterns are dot-separated segments where `*` stands for one or more whole segments (`media.vod.*`, `*.created`, `media.*.ready`, `*`). Endpoints must be `http(s)` on `127.0.0.1` or `openvibe.<tld>`/its subdomains (`EVENTS_ENDPOINT_HOSTS`); redirects are never followed.
--Each delivery is `POST <endpoint>` with body `{ "event": <envelope>, "seq": n }` and headers `X-OpenVibe-Event-Id`, `X-OpenVibe-Event-Type`, `X-OpenVibe-Seq`, `X-OpenVibe-Subscription-Id`, `X-OpenVibe-Delivery-Attempt`, `X-OpenVibe-Signature: sha256=<HMAC-SHA256 of the raw body with the subscription secret>` and `traceparent` (the event's trace). Any 2xx is delivered. Anything else is retried after 1 s, 5 s, 30 s, 2 min, 10 min, 1 h (then hourly) up to 8 attempts, after which the delivery is `dead`. Priority classes go first (`critical`, `important`, `low`, then seq); one delivery per subscription is in flight at a time and at most 20 overall. Delivery is at least once and not strictly ordered; consumers dedupe with an inbox and order with `subject.revision`.
++Each delivery is `POST <endpoint>` with body `{ "event": <envelope>, "seq": n }` and headers `X-OpenVibe-Event-Id`, `X-OpenVibe-Event-Type`, `X-OpenVibe-Seq`, `X-OpenVibe-Subscription-Id`, `X-OpenVibe-Delivery-Attempt`, `X-OpenVibe-Signature: sha256=<HMAC-SHA256 of the raw body with the subscription secret>`, `X-OpenVibe-Timestamp: <unix seconds>`, `X-OpenVibe-Signature-V2: t=<that timestamp>,v2=<HMAC-SHA256 of "<t>.<raw body>">` and `traceparent` (the event's trace). Any 2xx is delivered. Anything else is retried after 1 s, 5 s, 30 s, 2 min, 10 min, 1 h (then hourly) up to 8 attempts, after which the delivery is `dead`. Priority classes go first (`critical`, `important`, `low`, then seq); one delivery per subscription is in flight at a time and at most 20 overall. Delivery is at least once and not strictly ordered; consumers dedupe with an inbox and order with `subject.revision`.
++
++**Replay window (signature v2).** v1 signs only the body, so a captured delivery verifies forever (the inbox's `event_id` dedupe is all that stops a replay). v2 signs the timestamp together with the body, and every attempt, retries included, is signed afresh with the time it is sent. Consumers check it with `verifyDeliveryV2(raw, headers, secret, { toleranceSec = 300, now })` (here) or openvibe-sdk ≥ 0.4.0 `parseDelivery(raw, headers, secret, { requireV2: true })` and reject anything more than 300 s from their clock either way. A v2 header that is present but wrong or stale is a failure: never fall back to v1 then. Rollout: Events sends both headers first, then each consumer requires v2; v1 stays on the wire until every consumer does. See [docs/replay-window-rollout.md](docs/replay-window-rollout.md).
Operators: `GET /api/v1/deliveries?status=dead` is the dead-letter queue; `POST /api/v1/deliveries/replay { subscription_id, event_ids: [...] }` or `{ subscription_id, from_seq }` requeues retained events (that is also how a new subscription catches up on history).
@@@ -98,7 +100,7 @@ curl -s -X POST "$EVENTS/api/v1/subscriptions" -H "Authorization: Bearer $TOKEN"
**Sandbox.** Events store each app event's `project_id` and `env`. Apps see and receive only events of their own environment: sandbox events never reach production apps or production subscriptions, and production events never reach sandbox ones. First-party readers and subscribers never see sandbox events, and see production app events only through a pattern that starts with `app.`. Realtime (SSE) never streams app events. Sandbox events are pruned after `EVENTS_APP_SANDBOX_RETENTION_DAYS` (7).
--**Webhook endpoints (SSRF guard, [server/egress.js](server/egress.js)).** An app subscription's endpoint must be `https`, without credentials, and its hostname must resolve only to public unicast addresses (loopback, RFC 1918, link-local and cloud metadata, CGNAT, multicast, documentation and benchmarking ranges, unique-local IPv6, and v4-in-v6 forms of any of those are refused). This is checked when the subscription is created and again on every delivery attempt, inside the connection's own DNS lookup, so the socket goes to the address that was checked. A refused address is a permanent failure (dead at once; replayable). Redirects are never followed (a `3xx` is a failed attempt). Deliveries are signed exactly like first-party ones (`X-OpenVibe-Signature`).
++**Webhook endpoints (SSRF guard, [server/egress.js](server/egress.js)).** An app subscription's endpoint must be `https`, without credentials, and its hostname must resolve only to public unicast addresses (loopback, RFC 1918, link-local and cloud metadata, CGNAT, multicast, documentation and benchmarking ranges, unique-local IPv6, and v4-in-v6 forms of any of those are refused). This is checked when the subscription is created and again on every delivery attempt, inside the connection's own DNS lookup, so the socket goes to the address that was checked. A refused address is a permanent failure (dead at once; replayable). Redirects are never followed (a `3xx` is a failed attempt). Deliveries are signed exactly like first-party ones (`X-OpenVibe-Signature`, and `X-OpenVibe-Timestamp` with `X-OpenVibe-Signature-V2`).
**Quotas** (per project and environment, enforced here, `429 events.quota_exceeded` with `quota` = `publish_rate` (plus `Retry-After: 60`), `retained_bytes` or `subscriptions`):
@@@ -131,11 +133,11 @@ db.transaction(() => {
})(); // the event exists if and only if the change committed
outbox.start(); // relay: publishes pending rows, marks them sent, backs off on failure
--// Consumer: signed webhook + exactly-once effects in the consumer's own database
++// Consumer: signed webhook (v2: signature plus a 300 s replay window) + exactly-once effects in the consumer's own database
const inbox = events.createInbox(db);
inbox.ensureSchema();
app.post('/internal/events', express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }), (req, res) => {
-- if (!events.verifyDelivery(req.rawBody, req.get('X-OpenVibe-Signature'), SECRET)) return res.sendStatus(401);
++ if (!events.verifyDeliveryV2(req.rawBody, req.headers, SECRET)) return res.sendStatus(401);
inbox.once('live', req.body.event.event_id, () => { /* synchronous db writes */ });
res.sendStatus(204);
});
ddiff --git a/lib/client.js b/lib/client.js
iindex cdb636c..de900b3 100644
---- a/lib/client.js
++++ b/lib/client.js
@@@ -14,9 +14,9 @@
* })();
* outbox.start();
*
-- * // Consumer: signed webhook + exactly-once effects
++ * // Consumer: signed webhook (v2: signature + 5-minute replay window) + exactly-once effects
* app.post('/internal/events', express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }), (req, res) => {
-- * if (!events.verifyDelivery(req.rawBody, req.get('X-OpenVibe-Signature'), SECRET)) return res.sendStatus(401);
++ * if (!events.verifyDeliveryV2(req.rawBody, req.headers, SECRET)) return res.sendStatus(401);
* inbox.once('live', req.body.event.event_id, () => { ...db writes... });
* res.sendStatus(204);
* });
@@@ -45,6 +45,67 @@ function verifyDelivery(rawBody, signature, secret) {
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
++// v2 (replay window). Each attempt also carries
++// X-OpenVibe-Timestamp: <unix seconds>
++// X-OpenVibe-Signature-V2: t=<unix seconds>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">
++// so a captured delivery stops verifying once it is older than the consumer's tolerance. v1
++// (X-OpenVibe-Signature) is still sent, unchanged, until every consumer checks v2.
++
++const V2_TOLERANCE_SEC = 300;
++
++function v2Hex(rawBody, secret, timestamp) {
++ return crypto.createHmac('sha256', String(secret)).update(`${timestamp}.`).update(rawBody).digest('hex');
++}
++
++/** `t=<ts>,v2=<hex HMAC of "<ts>.<raw body>">`, the value of X-OpenVibe-Signature-V2. */
++function signV2(rawBody, secret, timestamp = Math.floor(Date.now() / 1000)) {
++ if (!Number.isSafeInteger(timestamp) || timestamp < 0) throw new TypeError('timestamp must be unix seconds');
++ return `t=${timestamp},v2=${v2Hex(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody)), secret, timestamp)}`;
++}
++
++function headerValue(headers, name) {
++ if (!headers) return undefined;
++ if (typeof headers.get === 'function') return headers.get(name) ?? undefined;
++ let v = headers[name];
++ if (v === undefined) {
++ const key = Object.keys(headers).find(k => k.toLowerCase() === name);
++ v = key === undefined ? undefined : headers[key];
++ }
++ return Array.isArray(v) ? v.join(',') : v;
++}
++
++/**
++ * Constant-time check of a delivery's X-OpenVibe-Signature-V2 against the raw request body, and of
++ * its timestamp: false when it is more than `toleranceSec` away from `now` (ms) either way, or when
++ * X-OpenVibe-Timestamp is present and names another time. `headers` is req.headers or a Fetch Headers.
++ */
++function verifyDeliveryV2(rawBody, headers, secret, { toleranceSec = V2_TOLERANCE_SEC, now = Date.now() } = {}) {
++ if (!Number.isFinite(toleranceSec) || toleranceSec < 0) throw new TypeError('toleranceSec must be a number of seconds >= 0');
++ const header = headerValue(headers, 'x-openvibe-signature-v2');
++ if (rawBody == null || typeof header !== 'string' || !secret) return false;
++ let t = null;
++ const given = [];
++ for (const part of header.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(v);
++ }
++ if (t === null || !given.length) return false;
++ const stated = headerValue(headers, 'x-openvibe-timestamp');
++ if (stated !== undefined && String(stated).trim() !== String(t)) return false;
++ if (Math.abs(Number(now) / 1000 - t) > toleranceSec) return false;
++ const expected = Buffer.from(v2Hex(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody)), secret, t));
++ let ok = false;
++ for (const v of given) {
++ const g = Buffer.from(v);
++ if (g.length === expected.length && crypto.timingSafeEqual(g, expected)) ok = true;
++ }
++ return ok;
++}
++
// ── Envelopes ──────────────────────────────────────────────
function traceIdFrom(traceparent) {
@@@ -310,4 +371,4 @@ function createInbox(db, { table = 'idempotency_receipts', now = () => Date.now(
return { ensureSchema, once, seen };
}
--module.exports = { sign, verifyDelivery, prepareEnvelope, createPublisher, createOutbox, createInbox, PublishError };
++module.exports = { sign, verifyDelivery, signV2, verifyDeliveryV2, prepareEnvelope, createPublisher, createOutbox, createInbox, PublishError };
ddiff --git a/server/worker.js b/server/worker.js
iindex c7ea1ff..de87b33 100644
---- a/server/worker.js
++++ b/server/worker.js
@@@ -5,7 +5,9 @@
* { "event": <envelope>, "seq": <n> }
*
* with X-OpenVibe-Event-Id, X-OpenVibe-Signature (sha256=<HMAC of the raw body with the
-- * subscription secret>), traceparent (the event's trace). A 2xx is delivered; anything else
++ * subscription secret>), X-OpenVibe-Timestamp (unix seconds, new on every attempt) and
++ * X-OpenVibe-Signature-V2 (t=<that timestamp>,v2=<HMAC of "<t>.<raw body>">; consumers reject it
++ * outside ±300 s, so a captured delivery cannot be replayed later), traceparent (the event's trace). A 2xx is delivered; anything else
* (including redirects, which are never followed) is retried with backoff 1s, 5s, 30s, 2m, 10m, 1h
* up to maxAttempts, then the delivery is `dead` (the DLQ; replay requeues it).
*
@@@ -18,7 +20,7 @@
* most maxAppInflight to developer-app endpoints (so apps can never hold every slot).
*/
const crypto = require('crypto');
--const { sign } = require('../lib/client');
++const { sign, signV2 } = require('../lib/client');
const { rowToEnvelope } = require('./store');
const { checkEndpoint } = require('./endpoints');
const { createGuardedPost } = require('./egress');
@@@ -51,6 +53,7 @@ function createWorker({ store, config, clock = { now: () => Date.now() }, fetchI
let outcome;
try {
const body = JSON.stringify({ event: rowToEnvelope(row), seq: row.seq });
++ const timestamp = Math.floor(clock.now() / 1000); // per attempt: a retry is signed afresh
const headers = {
'Content-Type': 'application/json',
'User-Agent': 'OpenVibe.Events/0.1',
@@@ -61,6 +64,8 @@ function createWorker({ store, config, clock = { now: () => Date.now() }, fetchI
'X-OpenVibe-Delivery-Attempt': String(attempt),
'X-OpenVibe-Hops': String(row.hops),
'X-OpenVibe-Signature': sign(body, sub.secret),
++ 'X-OpenVibe-Timestamp': String(timestamp),
++ 'X-OpenVibe-Signature-V2': signV2(body, sub.secret, timestamp),
traceparent: `00-${row.trace_id}-${crypto.randomBytes(8).toString('hex')}-01`,
};
let res;
ddiff --git a/test/apps.test.js b/test/apps.test.js
iindex 51bfea3..65d1e59 100644
---- a/test/apps.test.js
++++ b/test/apps.test.js
@@@ -4,7 +4,7 @@
const assert = require('assert');
const nodeHttp = require('http');
const { ids } = require('openvibe-contracts');
--const { verifyDelivery } = require('../lib/client');
++const { verifyDelivery, verifyDeliveryV2 } = require('../lib/client');
const apps = require('../server/apps');
const { createGuardedPost, isPublicAddress } = require('../server/egress');
const { load } = require('../server/config');
@@@ -249,6 +249,7 @@ t('delivery: signed, scoped, sandbox never meets production', async () => {
assert.strictEqual(firstParty.calls.length, 0, 'first-party subscriptions never get sandbox events');
const a = seen.find(g => g.url === '/a');
assert.ok(verifyDelivery(a.rawBody, a.headers['x-openvibe-signature'], subA.secret), 'HMAC-signed with the subscription secret');
++ assert.ok(verifyDeliveryV2(a.rawBody, a.headers, subA.secret, { now: h.clock.now() }), 'and v2-signed (timestamped) like first-party deliveries');
assert.strictEqual(JSON.parse(a.rawBody).event.source, apps.appSource(appA));
assert.ok(pinned.length && pinned.every(ip => ip === '127.0.0.1'), 'the socket connected to the address the guard checked');
ddiff --git a/test/delivery.test.js b/test/delivery.test.js
iindex c90b73b..5abdd08 100644
---- a/test/delivery.test.js
++++ b/test/delivery.test.js
@@@ -1,6 +1,7 @@
'use strict';
const assert = require('assert');
--const { verifyDelivery } = require('../lib/client');
++const crypto = require('crypto');
++const { verifyDelivery, verifyDeliveryV2, signV2, sign } = require('../lib/client');
const { boot, request, serviceToken, envelope, subscriber, suite, sleep } = require('./helpers');
const t = suite('delivery');
@@@ -18,6 +19,37 @@ async function publish(env) {
return r.body;
}
++t('signV2 / verifyDeliveryV2: HMAC of "<t>.<raw body>", ±300 s, constant-time', async () => {
++ const secret = `whsec_${'cd'.repeat(32)}`;
++ const raw = Buffer.from(JSON.stringify({ event: { event_id: 'evt_1' }, seq: 3 }));
++ const now = 1790000000000;
++ const t = Math.floor(now / 1000);
++ const v2 = signV2(raw, secret, t);
++ assert.strictEqual(v2, `t=${t},v2=${crypto.createHmac('sha256', secret).update(`${t}.${raw}`).digest('hex')}`);
++ const headers = { 'x-openvibe-timestamp': String(t), 'x-openvibe-signature-v2': v2, 'x-openvibe-signature': sign(raw, secret) };
++ assert.ok(verifyDeliveryV2(raw, headers, secret, { now }));
++ assert.ok(verifyDeliveryV2(raw.toString(), new Headers(headers), secret, { now }), 'Fetch Headers and string bodies');
++ assert.ok(verifyDeliveryV2(raw, { 'X-OpenVibe-Signature-V2': v2 }, secret, { now }), 'any header case; X-OpenVibe-Timestamp optional');
++ assert.ok(verifyDeliveryV2(raw, headers, secret, { now: now + 300000 }), 'edge of the window');
++ assert.ok(verifyDeliveryV2(raw, headers, secret, { now: now - 300000 }));
++ assert.ok(!verifyDeliveryV2(raw, headers, secret, { now: now + 301000 }), 'too old');
++ assert.ok(!verifyDeliveryV2(raw, headers, secret, { now: now - 301000 }), 'too far in the future');
++ assert.ok(verifyDeliveryV2(raw, headers, secret, { now: now + 3600000, toleranceSec: 3601 }));
++ assert.ok(!verifyDeliveryV2(raw, headers, 'whsec_other', { now }));
++ assert.ok(!verifyDeliveryV2(Buffer.from(raw.toString().replace('3', '4')), headers, secret, { now }), 'body changed');
++ assert.ok(!verifyDeliveryV2(raw, { ...headers, 'x-openvibe-signature-v2': v2.replace(`t=${t}`, `t=${t + 1}`) }, secret, { now }), 'timestamp changed');
++ assert.ok(!verifyDeliveryV2(raw, { ...headers, 'x-openvibe-timestamp': String(t + 1) }, secret, { now }), 'X-OpenVibe-Timestamp must match t');
++ assert.ok(verifyDeliveryV2(raw, { 'x-openvibe-signature-v2': `${v2},v2=${'0'.repeat(64)}` }, secret, { now }), 'one of several v2 values');
++ for (const bad of [undefined, '', 'v2=abc', `t=${t}`, `t=${t},t=${t},${v2.split(',')[1]}`, `t=x${t},${v2.split(',')[1]}`, `t=${t},v2=00`, 'garbage', sign(raw, secret)]) {
++ assert.ok(!verifyDeliveryV2(raw, { 'x-openvibe-signature-v2': bad }, secret, { now }), String(bad));
++ }
++ assert.ok(!verifyDeliveryV2(raw, headers, '', { now }));
++ assert.ok(!verifyDeliveryV2(null, headers, secret, { now }));
++ assert.throws(() => verifyDeliveryV2(raw, headers, secret, { toleranceSec: -1 }), TypeError);
++ assert.throws(() => signV2(raw, secret, 1.5), TypeError);
++ assert.match(signV2(raw, secret), /^t=\d{10},v2=[0-9a-f]{64}$/, 'defaults to now');
++});
++
t('boot', async () => { h = await boot(); });
t('subscription API: create, list own, secret shown once', async () => {
@@@ -75,6 +107,12 @@ t('success: signed POST, verified by verifyDelivery; trace propagated', async ()
assert.ok(verifyDelivery(call.rawBody, call.headers['x-openvibe-signature'], sub.secret));
assert.ok(!verifyDelivery(call.rawBody, call.headers['x-openvibe-signature'], 'whsec_wrong_secret_wrong_secret_wrong'));
assert.ok(!verifyDelivery(Buffer.concat([call.rawBody, Buffer.from(' ')]), call.headers['x-openvibe-signature'], sub.secret));
++ const ts = Number(call.headers['x-openvibe-timestamp']);
++ assert.strictEqual(ts, Math.floor(h.clock.now() / 1000), 'X-OpenVibe-Timestamp is the attempt time in unix seconds');
++ assert.match(call.headers['x-openvibe-signature-v2'], new RegExp(`^t=${ts},v2=[0-9a-f]{64}$`));
++ assert.ok(verifyDeliveryV2(call.rawBody, call.headers, sub.secret, { now: h.clock.now() }));
++ assert.ok(!verifyDeliveryV2(call.rawBody, call.headers, 'whsec_wrong_secret_wrong_secret_wrong', { now: h.clock.now() }));
++ assert.ok(!verifyDeliveryV2(call.rawBody, call.headers, sub.secret, { now: h.clock.now() + 301000 }), 'a replay after the window fails');
assert.strictEqual(call.headers['x-openvibe-event-id'], env.event_id);
assert.match(call.headers.traceparent, /^00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01$/);
assert.deepStrictEqual(call.body.event.payload, env.payload);
@@@ -96,6 +134,10 @@ t('retries with backoff (injectable clock), then DLQ after 8 attempts, then repl
for (let attempt = 1; attempt <= 8; attempt++) {
await h.worker.drain();
assert.strictEqual(stub.calls.length, attempt, `attempt ${attempt} made`);
++ const call = stub.calls[attempt - 1];
++ assert.strictEqual(call.headers['x-openvibe-timestamp'], String(Math.floor(h.clock.now() / 1000)), 'every retry is signed with a fresh timestamp');
++ assert.ok(verifyDeliveryV2(call.rawBody, call.headers, sub.secret, { now: h.clock.now() }));
++ if (attempt > 1) assert.notStrictEqual(call.headers['x-openvibe-signature-v2'], stub.calls[attempt - 2].headers['x-openvibe-signature-v2']);
const d = h.store.getDelivery(env.event_id, sub.id);
assert.strictEqual(d.attempt, attempt);
if (attempt < 8) {