openvibers/openvibe.sdk Commits

0.4.0: replay protection for Events webhook deliveries (signature v2)

OP OpenVibers committed 23 Sept 2026 · 5351262621 · 12 files, +250 −24

- openvibe-sdk/events: verifyDeliveryV2(raw, headers, secret, { toleranceSec = 300, now }) checks
  X-OpenVibe-Signature-V2 (t=<ts>,v2=<HMAC-SHA256 of "<ts>.<raw body>">) in constant time and
  refuses timestamps outside ±toleranceSec or disagreeing with X-OpenVibe-Timestamp
- parseDelivery(raw, headers, secret, { requireV2 = false, toleranceSec, now }): a present v2
  header must verify and be fresh (never a v1 fallback); without one, v1 only while requireV2 is off
- signDeliveryV2() and signDeliveryHeaders() for tests; verifyDelivery/signDelivery (v1) unchanged
- mock Events deliverEvents() sends v1 + v2 headers, signed afresh on every attempt
- types, ESM, README (Receiving event webhooks), CHANGELOG; version 0.4.0 (not tagged)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 0405e97..0c1520c 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,35 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
++## 0.4.0 (2026-09-23)
++
++Replay protection for OpenVibe.Events webhook deliveries (signature v2). Backward compatible:
++existing callers of `parseDelivery(raw, headers, secret)` and `verifyDelivery()` keep working, and
++v1-only deliveries are still accepted until you opt in to `requireV2`.
++
++- **Events sends v2.** Besides the unchanged `X-OpenVibe-Signature: sha256=<HMAC of the raw body>`,
++ every delivery attempt (retries included, each with a fresh time) carries
++ `X-OpenVibe-Timestamp: <unix seconds>` and
++ `X-OpenVibe-Signature-V2: t=<ts>,v2=<hex HMAC-SHA256 of "<ts>.<raw body>">`.
++- New `verifyDeliveryV2(rawBody, headers, secret, { toleranceSec = 300, now = Date.now() })`:
++ constant-time check of the v2 signature, false when the timestamp is more than `toleranceSec`
++ from `now` either way or when `X-OpenVibe-Timestamp` disagrees with `t`. Several `v2=` values
++ are allowed (any one may match); a bad `toleranceSec` throws.
++- `parseDelivery(rawBody, headers, secret, { requireV2 = false, toleranceSec, now })`: when a v2
++ header is present it must verify and be fresh, and a bad or stale v2 returns `null` (it never
++ falls back to v1); without a v2 header, v1 is accepted only while `requireV2` is false. Header
++ lookup on a plain object is now case-insensitive. **Behaviour change**: a delivery that carries
++ v2 is now refused when your clock is more than 300 s off Events' (keep NTP on), or when the v2
++ signature does not verify even if v1 does. Conversely, once v2 verifies, v1 is not consulted:
++ a test that blanks only `X-OpenVibe-Signature` on an Events delivery and expects a refusal must
++ drop or break `X-OpenVibe-Signature-V2` too.
++- New `signDeliveryV2(rawBody, secret, timestamp?)` and `signDeliveryHeaders(rawBody, secret,
++ { now })` (the three signature headers, for tests that post deliveries to a consumer).
++ `verifyDelivery` and `signDelivery` (v1) are unchanged.
++- **Mock Events** `deliverEvents()` / `startDeliveries()` send v1 and v2 headers, signed afresh on
++ every attempt, as production does.
++- Types and ESM entry points include the new functions.
++
## 0.3.1 (2026-09-23)
The remaining gaps OpenVibe.Examples found, so its examples need no local stand-ins for Events or
ddiff --git a/README.md b/README.md
iindex 7138bbb..11fad12 100644
---- a/README.md
++++ b/README.md
@@@ -2,14 +2,14 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.3.1 (roadmap Wave 2; developer apps from Wave 20). Tested against local stub servers and the built-in mock platform only, never against the live platform. Several OpenVibe services pin v0.2.2; OpenVibe.Examples uses 0.3.0.
++**Status:** alpha, v0.4.0 (roadmap Wave 2; developer apps from Wave 20). Tested against local stub servers and the built-in mock platform only, never against the live platform. Several OpenVibe services pin v0.2.2; OpenVibe.Examples uses 0.3.0.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts`: it copies the contract types it uses (from Contracts v0.28.0, checked in CI).
@@@ -155,7 +155,7 @@ const res = await jobs.file(job.id, 0);
| `openvibe-sdk/registry` | both | `createRegistryClient(client)`: `services({status})`, `service(id)`, `capabilities({owner})`, `capability(id)`, `namespaces()`, `contracts()`, `topics()`, `domain(host)`, `descriptor()` |
| `openvibe-sdk/identity` | server | `createIdentityClient(client)`: `resolve({subjectId} \| {system,type,id})`, `resolveBatch({subjectIds} \| {system,type,ids})` |
| `openvibe-sdk/modules` | both | `createModulesClient(client)`: `get`, `put(ns, data, {revision})`, `delete`, `list`, `update(ns, fn)`, `publicGet`; `forSubject.get/put/update` for services |
--| `openvibe-sdk/events` | server | `createEventsClient(client, {source})`: `publish`, `prepare`, `pull`, `iterate`, `get`, `get/setCheckpoint`, `subscriptions.create/list/get/disable/enable` (`subscribe`), `deliveries`, `replay`; `verifyDelivery`, `signDelivery`, `parseDelivery`; developer apps: `createAppEvents(client, {projectId, appId, onBehalfOf?})` (same calls, scoped to `app.<project_key>.*`), `projectKey`, `appSource`; `createOutbox`, `createInbox` |
++| `openvibe-sdk/events` | server | `createEventsClient(client, {source})`: `publish`, `prepare`, `pull`, `iterate`, `get`, `get/setCheckpoint`, `subscriptions.create/list/get/disable/enable` (`subscribe`), `deliveries`, `replay`; `parseDelivery` (v1 and v2, `requireV2`), `verifyDeliveryV2`, `signDeliveryV2`, `signDeliveryHeaders`, `verifyDelivery`, `signDelivery`; developer apps: `createAppEvents(client, {projectId, appId, onBehalfOf?})` (same calls, scoped to `app.<project_key>.*`), `projectKey`, `appSource`; `createOutbox`, `createInbox` |
| `openvibe-sdk/realtime` | both | `subscribe(topics, onEvent, {lastEventId, onGap, …})`, `createRealtimeClient(client)`, `parseSSE(body)` |
| `openvibe-sdk/media` | both (credentials: server) | `createMediaClient(client, {app, apiKey?, actingUserId?})`: `files.upload/list/iterate/get/delete`; `mediaUrls(origin)` public URL helpers |
| `openvibe-sdk/community` | both | `createCommunityClient(client, {actingSubject?, origin?, sourceRef?, staff?})`: `pastes.list/iterate/get/create/update/delete/fork/like/copy/versions/byUser/config`, `pastes.comments.list/create/delete`, `as(subject)` |
@@@ -203,6 +203,33 @@ Server-only subpaths are declared `"browser": null` in the exports map, so a bun
- `createServiceTokenClient().getTokenInfo({ audience })` returns `{ accessToken, scope, expiresAt, unverifiedClaims }`. `decodeUnverified()` / `unverifiedClaims()` decode a JWT without verifying it: for display and diagnostics, never for authorization.
- PKCE follows RFC 7636 S256. The verifier is 64 characters from the unreserved set, and the challenge is `BASE64URL(SHA-256(verifier))`. Network verifies the verifier whenever the authorization carried a challenge, and requires S256 from every developer app.
++### Receiving event webhooks
++
++OpenVibe.Events POSTs each delivery as `{ event, seq }` with two signatures:
++
++- `X-OpenVibe-Signature: sha256=<hex HMAC-SHA256 of the raw body>` (v1). It covers only the body, so a captured delivery verifies forever.
++- `X-OpenVibe-Timestamp: <unix seconds>` and `X-OpenVibe-Signature-V2: t=<that timestamp>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">` (v2). Every attempt, retries included, is signed with the time it is sent, so a delivery stops verifying 300 s later.
++
++The key is the subscription secret. Verify the raw bytes, not re-serialized JSON:
++
++```js
++const { parseDelivery } = require('openvibe-sdk/events');
++
++app.post('/internal/events', express.raw({ type: 'application/json' }), (req, res) => {
++ const d = parseDelivery(req.body, req.headers, process.env.EVENTS_WEBHOOK_SECRET, { requireV2: true });
++ if (!d) return res.sendStatus(401);
++ inbox.once('my-service', d.event.event_id, () => { /* synchronous db writes */ }); // still dedupe: delivery is at least once
++ res.sendStatus(204);
++});
++```
++
++`parseDelivery(raw, headers, secret, { requireV2 = false, toleranceSec = 300, now })`:
++
++- If `X-OpenVibe-Signature-V2` is present, it must verify and its timestamp must be within ±`toleranceSec` of `now` (ms, default `Date.now()`). A bad or stale v2 returns `null`. It never falls back to v1.
++- If there is no v2 header, v1 is accepted only while `requireV2` is false (the default, so 0.4.0 changes nothing for existing callers).
++
++Turn `requireV2` on once Events sends v2 to you: a replayed v1-only delivery then fails. The lower-level checks are `verifyDeliveryV2(raw, headers, secret, { toleranceSec, now })` (constant-time; also refuses a `X-OpenVibe-Timestamp` that differs from `t`) and `verifyDelivery(raw, signatureHeader, secret)` (v1, unchanged). Keep your clock in sync (NTP): the window is ±300 s both ways. In tests, `signDeliveryHeaders(raw, secret, { now })` returns the three headers as Events sends them.
++
### Testing your app
```js
@@@ -222,7 +249,7 @@ The mock answers at the real public origins with real RS256 tokens and checks au
- **Network:** discovery, `/oauth/token` (client credentials, authorization code with PKCE, refresh; developer apps), `GET /oauth/authorize` (consents automatically as `setAuthorization({ subjectId })`, or declines with `{ decision: 'deny' }`), the JWKS, the registry, `/api/v1/projects` (projects, members, apps, credentials, grants, quotas, audit), `/api/modules` and `/internal/modules`, and `/internal/identity`.
- **Developer apps:** `apps` / `projects` options, `addApp()`, `signAppToken()`. App tokens carry `sub app:…`, `cap`, `ns: [project_id]`, `project_id`, `env` and `on_behalf_of` (code flow); codes are single use, PKCE-bound and bound to the `audience` given at authorize; a sandbox app of a project with members can be authorized only by them. The capability catalog is the public + active capabilities of openvibe-contracts v0.28.0 (`DEFAULT_APP_CATALOG`, `events.app.*` included).
- **Sandbox:** as in production, Media (on `/api/v1/<project_id>/files`) and Events (on the `events.app.*` routes) accept sandbox app tokens and keep their data apart from production; every other route and mock service refuses `env: sandbox` tokens (`401 token.sandbox_refused`) unless `acceptSandbox` lists the audience or capability. Unlike Network, sandbox apps get tokens for any audience unless you pass `sandboxAudiences`, and projects created through the API start with the whole catalog as allowance unless you pass `defaultAllowance: []`.
--- **Events:** publish with `event_id` dedupe, pull (with a `gap` after `pruneEvents(seq)`), checkpoints, subscriptions, `/realtime/stream` SSE with `Last-Event-ID` and gap events, and a delivery worker: `deliverEvents()` / `startDeliveries()` POST signed deliveries to your local endpoint in order, retry, and mark them dead after `max_attempts`. Developer apps follow OpenVibe.Events' rules: app tokens are judged only on `events.app.publish | read | subscribe`; types `app.<project_key>.<name…>`, source `app-<ulid>`, actor the app or its `on_behalf_of` user; reads, checkpoints and subscriptions limited to the own project in the token's env plus public first-party events (every pattern starts with a literal segment, `app.*` patterns name the own key); app endpoints https and not loopback, private or local names; first-party readers never see sandbox events and see app events only through `app.*`; realtime streams neither. Not modelled: per-project quotas, revocation, and the DNS half of the endpoint check. Your delivery worker's `fetch` (`deliverEvents({ fetch })`) can route `https://hooks.example.com/…` to a local server.
++- **Events:** publish with `event_id` dedupe, pull (with a `gap` after `pruneEvents(seq)`), checkpoints, subscriptions, `/realtime/stream` SSE with `Last-Event-ID` and gap events, and a delivery worker: `deliverEvents()` / `startDeliveries()` POST signed deliveries (v1 and v2 headers, fresh timestamp per attempt) to your local endpoint in order, retry, and mark them dead after `max_attempts`. Developer apps follow OpenVibe.Events' rules: app tokens are judged only on `events.app.publish | read | subscribe`; types `app.<project_key>.<name…>`, source `app-<ulid>`, actor the app or its `on_behalf_of` user; reads, checkpoints and subscriptions limited to the own project in the token's env plus public first-party events (every pattern starts with a literal segment, `app.*` patterns name the own key); app endpoints https and not loopback, private or local names; first-party readers never see sandbox events and see app events only through `app.*`; realtime streams neither. Not modelled: per-project quotas, revocation, and the DNS half of the endpoint check. Your delivery worker's `fetch` (`deliverEvents({ fetch })`) can route `https://hooks.example.com/…` to a local server.
- **Media:** the files API and `GET /f/:key`, with Media's tenant rules: `media.object.upload` uploads and deletes, `media.object.read` lists and gets (app keys, service tokens and app tokens alike); a developer app reaches only `/api/v1/<its project_id>/files`, where production uses the tenant `prj_…` and sandbox `prj_…-sandbox` (100 MB, `mediaQuotaMb`); sandbox files come back as `{ sandbox: true, url: <signed>, url_expires_at }` and `/f/<key>` serves them only with a valid signature.
- **Tools jobs** (`jobs: true | { stepMs, handlers }`) at `origins.tools` and the satellites `img.`, `audio.` and `docs.openvibe.tools` (`platform.toolsOrigins`; `toolsSatellites` overrides), each with its own jobs: submit with Idempotency-Key replay, get, cancel, SSE with `Last-Event-ID` and `204` when finished, result files; `dropJobStreams()` simulates a dropped connection.
ddiff --git a/STATUS.json b/STATUS.json
iindex 20ef66a..345703d 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.3.1",
++ "version": "0.4.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"updated": "2026-09-23"
}
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex ea76a03..c559ad1 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.3.1, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.4.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, jobs, projects). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -36,7 +36,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.3.1';
++const SDK_VERSION = '0.4.0';
module.exports = {
createClient,
ddiff --git a/esm/events.mjs b/esm/events.mjs
iindex c8e0280..69e75bf 100644
---- a/esm/events.mjs
++++ b/esm/events.mjs
@@@ -3,6 +3,6 @@ import cjs from '../src/events.js';
export const {
createEventsClient, verifyDelivery, signDelivery, parseDelivery, createOutbox, createInbox,
-- projectKey, appSource, createAppEvents,
++ projectKey, appSource, createAppEvents, verifyDeliveryV2, signDeliveryV2, signDeliveryHeaders,
} = cjs;
export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex b6ee595..22b7f1b 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.3.1",
++ "version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.3.1",
++ "version": "0.4.0",
"license": "MIT",
"devDependencies": {
"better-sqlite3": "^11.10.0"
ddiff --git a/package.json b/package.json
iindex 99d6b07..15effb9 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.3.1",
++ "version": "0.4.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Tools jobs and developer projects, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 3630586..12ff9fc 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.3.1';
++const SDK_VERSION = '0.4.0';
module.exports = {
createClient,
ddiff --git a/src/events.js b/src/events.js
iindex 124baaa..102e3ab 100644
---- a/src/events.js
++++ b/src/events.js
@@@ -33,13 +33,100 @@ function verifyDelivery(rawBody, signatureHeader, secret) {
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
++// v2 (replay window). Events also sends, on every attempt with the time it is sent:
++// X-OpenVibe-Timestamp: <unix seconds>
++// X-OpenVibe-Signature-V2: t=<unix seconds>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">
++// A v1 signature covers only the body, so a captured delivery verifies forever; v2 stops
++// verifying once the timestamp is more than `toleranceSec` (300) from the consumer's clock.
++
++const V2_TOLERANCE_SEC = 300;
++
++function v2Hex(rawBody, secret, timestamp) {
++ return crypto.createHmac('sha256', String(secret)).update(`${timestamp}.`).update(toBuffer(rawBody)).digest('hex');
++}
++
++/** `t=<ts>,v2=<hex HMAC-SHA256 of "<ts>.<raw body>">`, the value of X-OpenVibe-Signature-V2. */
++function signDeliveryV2(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(rawBody, secret, timestamp)}`;
++}
++
++/**
++ * The three signature headers Events puts on a delivery (X-OpenVibe-Signature,
++ * X-OpenVibe-Timestamp, X-OpenVibe-Signature-V2), for tests that post deliveries to a consumer.
++ */
++function signDeliveryHeaders(rawBody, secret, { now = Date.now() } = {}) {
++ const timestamp = Math.floor(Number(now) / 1000);
++ return {
++ 'X-OpenVibe-Signature': signDelivery(rawBody, secret),
++ 'X-OpenVibe-Timestamp': String(timestamp),
++ 'X-OpenVibe-Signature-V2': signDeliveryV2(rawBody, secret, timestamp),
++ };
++}
++
++/** One header from req.headers (any key case; arrays joined) or a Fetch Headers; undefined if absent. */
++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` 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(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;
++}
++
/**
-- * Verify and parse one delivery: { event, seq, subscriptionId, attempt } or null when the
-- * signature does not verify. `headers` is req.headers (or a Fetch Headers).
++ * Verify and parse one delivery: { event, seq, subscriptionId, attempt } or null when it does not
++ * verify. `headers` is req.headers (or a Fetch Headers).
++ *
++ * X-OpenVibe-Signature-V2 present it must verify and be within ±toleranceSec (default 300) of
++ * `now`; a bad or stale v2 is null, never a fallback to v1
++ * no v2 header v1 (X-OpenVibe-Signature) is accepted unless requireV2
++ *
++ * Set requireV2: true once OpenVibe.Events sends v2 to you: then a replayed v1-only delivery fails.
*/
--function parseDelivery(rawBody, headers, secret) {
-- const get = (k) => (headers && typeof headers.get === 'function' ? headers.get(k) : headers && headers[k.toLowerCase()]);
-- if (!verifyDelivery(rawBody, get('x-openvibe-signature'), secret)) return null;
++function parseDelivery(rawBody, headers, secret, { requireV2 = false, toleranceSec, now } = {}) {
++ const get = (k) => headerValue(headers, k);
++ if (get('x-openvibe-signature-v2') !== undefined) {
++ if (!verifyDeliveryV2(rawBody, headers, secret, { toleranceSec, now })) return null;
++ } else if (requireV2 || !verifyDelivery(rawBody, get('x-openvibe-signature'), secret)) {
++ return null;
++ }
let body;
try { body = JSON.parse(toBuffer(rawBody).toString('utf8')); } catch { return null; }
if (!body || !body.event) return null;
@@@ -272,5 +359,5 @@ const { createOutbox, createInbox } = require('./outbox');
module.exports = {
createEventsClient, verifyDelivery, signDelivery, parseDelivery, createOutbox, createInbox,
-- projectKey, appSource, createAppEvents,
++ projectKey, appSource, createAppEvents, verifyDeliveryV2, signDeliveryV2, signDeliveryHeaders,
};
ddiff --git a/src/testing/index.js b/src/testing/index.js
iindex 0d0120b..f6b79ec 100644
---- a/src/testing/index.js
++++ b/src/testing/index.js
@@@ -35,6 +35,7 @@ const { b64url, fromB64url, ulid, topicRegex, json, problem, redirect } = requir
const { createDeveloper, DEFAULT_APP_CATALOG, PRJ_ID_RE } = require('./developer');
const { createJobsService } = require('./jobs');
const appRules = require('./apps');
++const { signDeliveryHeaders } = require('../events');
const DEFAULT_ORIGINS = {
network: 'https://openvibe.network',
@@@ -593,8 +594,9 @@ function createMockPlatform(opts = {}) {
* deliverEvents({ fetch, subscriptionId, timeoutMs }) plays the Events delivery worker once:
* for each enabled subscription, POSTs every event published after it was created (in seq
* order, one at a time) to its endpoint as { event, seq }, signed like Events signs deliveries
-- * (X-OpenVibe-Signature: sha256=<HMAC of the raw body with the subscription secret>, plus
-- * X-OpenVibe-Event-Id, -Event-Type, -Seq, -Subscription-Id, -Delivery-Attempt, -Hops,
++ * (X-OpenVibe-Signature: sha256=<HMAC of the raw body with the subscription secret>;
++ * X-OpenVibe-Timestamp and X-OpenVibe-Signature-V2: t=<ts>,v2=<HMAC of "<ts>.<raw body>">,
++ * signed afresh on every attempt; plus X-OpenVibe-Event-Id, -Event-Type, -Seq, -Subscription-Id, -Delivery-Attempt, -Hops,
* traceparent). A 2xx moves on; anything else stops that subscription until the next call,
* and after retry_policy.max_attempts (default 5) the delivery is dead and skipped.
* `fetch` defaults to the global fetch, so the endpoint can be a real local HTTP server.
@@@ -625,7 +627,7 @@ function createMockPlatform(opts = {}) {
'Content-Type': 'application/json', 'User-Agent': 'OpenVibe.Events/mock',
'X-OpenVibe-Event-Id': e.event.event_id, 'X-OpenVibe-Event-Type': e.event.event_type, 'X-OpenVibe-Seq': String(e.seq),
'X-OpenVibe-Subscription-Id': sub.id, 'X-OpenVibe-Delivery-Attempt': String(attempt), 'X-OpenVibe-Hops': '0',
-- 'X-OpenVibe-Signature': `sha256=${crypto.createHmac('sha256', String(sub.secret)).update(body).digest('hex')}`,
++ ...signDeliveryHeaders(body, sub.secret),
traceparent: `00-${trace}-${crypto.randomBytes(8).toString('hex')}-01`,
},
});
ddiff --git a/test/events.test.js b/test/events.test.js
iindex f1ab98e..c880653 100644
---- a/test/events.test.js
++++ b/test/events.test.js
@@@ -5,7 +5,7 @@ const crypto = require('node:crypto');
const { stubServer, send, run, waitFor } = require('./helpers');
const { createClient, parseTraceparent } = require('../src/core');
const { createServiceTokenClient } = require('../src/auth');
--const { createEventsClient, verifyDelivery, signDelivery, parseDelivery, createInbox } = require('../src/events');
++const { createEventsClient, verifyDelivery, signDelivery, parseDelivery, createInbox, verifyDeliveryV2, signDeliveryV2, signDeliveryHeaders } = require('../src/events');
const { subscribe } = require('../src/realtime');
const { createMockPlatform } = require('../src/testing');
@@@ -132,6 +132,64 @@ run([
assert.equal(parseDelivery(raw, new Headers({ 'X-OpenVibe-Signature': 'sha256=bad' }), secret), null);
}],
++ ['verifyDeliveryV2 checks X-OpenVibe-Signature-V2 (HMAC of "<t>.<raw body>") and the ±300 s window', async () => {
++ const secret = `whsec_${'ab'.repeat(32)}`;
++ const raw = Buffer.from(JSON.stringify({ event: { event_id: 'evt_1', event_type: 'media.vod.ready' }, seq: 7 }));
++ const now = 1790000000000;
++ const t = now / 1000;
++ const v2 = signDeliveryV2(raw, secret, t);
++ assert.equal(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 };
++ assert.equal(verifyDeliveryV2(raw, headers, secret, { now }), true);
++ assert.equal(verifyDeliveryV2(raw.toString(), new Headers(headers), secret, { now }), true, 'Fetch Headers, string body');
++ assert.equal(verifyDeliveryV2(new Uint8Array(raw), { 'X-OpenVibe-Signature-V2': v2 }, secret, { now }), true, 'any header case; X-OpenVibe-Timestamp optional');
++ assert.equal(verifyDeliveryV2(raw, headers, secret, { now: now + 300000 }), true, 'edge of the window');
++ assert.equal(verifyDeliveryV2(raw, headers, secret, { now: now - 300000 }), true);
++ assert.equal(verifyDeliveryV2(raw, headers, secret, { now: now + 300001 }), false, 'too old: a replay');
++ assert.equal(verifyDeliveryV2(raw, headers, secret, { now: now - 300001 }), false, 'too far in the future');
++ assert.equal(verifyDeliveryV2(raw, headers, secret, { now: now + 600000, toleranceSec: 600 }), true, 'toleranceSec');
++ assert.equal(verifyDeliveryV2(raw, headers, secret), false, 'defaults to Date.now()');
++ assert.equal(verifyDeliveryV2(raw, headers, 'other-secret', { now }), false);
++ assert.equal(verifyDeliveryV2(Buffer.from(raw.toString().replace('7', '8')), headers, secret, { now }), false, 'body changed');
++ assert.equal(verifyDeliveryV2(raw, { ...headers, 'x-openvibe-signature-v2': v2.replace(`t=${t}`, `t=${t + 1}`) }, secret, { now }), false, 'timestamp changed');
++ assert.equal(verifyDeliveryV2(raw, { ...headers, 'x-openvibe-timestamp': String(t - 1) }, secret, { now }), false, 'X-OpenVibe-Timestamp disagrees with t');
++ assert.equal(verifyDeliveryV2(raw, { 'x-openvibe-signature-v2': `${v2},v2=${'0'.repeat(64)}` }, secret, { now }), true, 'one of several v2 values');
++ const mac = v2.split(',')[1];
++ for (const bad of [undefined, '', mac, `t=${t}`, `t=${t},t=${t},${mac}`, `t=-${t},${mac}`, `t=${t}.5,${mac}`, `t=${t},v2=00`, `t=${t},${mac}x`, signDelivery(raw, secret)]) {
++ assert.equal(verifyDeliveryV2(raw, { 'x-openvibe-signature-v2': bad }, secret, { now }), false, String(bad));
++ }
++ assert.equal(verifyDeliveryV2(raw, headers, '', { now }), false);
++ assert.equal(verifyDeliveryV2(null, headers, secret, { now }), false);
++ assert.throws(() => verifyDeliveryV2(raw, headers, secret, { toleranceSec: -1 }), TypeError);
++ assert.throws(() => signDeliveryV2(raw, secret, 1.5), TypeError);
++ assert.match(signDeliveryV2(raw, secret), /^t=\d{10},v2=[0-9a-f]{64}$/, 'defaults to now');
++ const all = signDeliveryHeaders(raw, secret, { now: now + 999 });
++ assert.deepEqual(all, { 'X-OpenVibe-Signature': signDelivery(raw, secret), 'X-OpenVibe-Timestamp': String(t), 'X-OpenVibe-Signature-V2': v2 });
++ }],
++
++ ['parseDelivery: a present v2 must verify and be fresh (no v1 fallback); requireV2 refuses v1-only', async () => {
++ const secret = `whsec_${'cd'.repeat(32)}`;
++ const raw = Buffer.from(JSON.stringify({ event: { event_id: 'evt_2', event_type: 'media.vod.ready' }, seq: 9 }));
++ const now = 1790000000000;
++ const signed = signDeliveryHeaders(raw, secret, { now });
++ const want = { event: { event_id: 'evt_2', event_type: 'media.vod.ready' }, seq: 9, subscriptionId: null, attempt: 1 };
++ const v1only = { 'x-openvibe-signature': signed['X-OpenVibe-Signature'] };
++ // Both headers (what Events sends): v2 decides.
++ assert.deepEqual(parseDelivery(raw, signed, secret, { now }), want);
++ assert.deepEqual(parseDelivery(raw, signed, secret, { now, requireV2: true }), want);
++ assert.deepEqual(parseDelivery(raw, new Headers(signed), secret, { now, requireV2: true }), want);
++ assert.equal(parseDelivery(raw, signed, secret, { now: now + 301000 }), null, 'stale v2 with a valid v1: refused, never a v1 fallback');
++ assert.equal(parseDelivery(raw, { ...signed, 'X-OpenVibe-Signature-V2': `t=${now / 1000},v2=${'0'.repeat(64)}` }, secret, { now }), null, 'bad v2 with a valid v1: refused');
++ assert.equal(parseDelivery(raw, { ...signed, 'X-OpenVibe-Signature-V2': '' }, secret, { now }), null, 'an empty v2 header is present and bad');
++ assert.equal(parseDelivery(raw, { ...v1only, 'x-openvibe-signature-v2': signed['X-OpenVibe-Signature-V2'] }, secret, { now: now + 200000, toleranceSec: 100 }), null, 'toleranceSec is passed through');
++ // No v2 header: v1 only while requireV2 is off.
++ assert.deepEqual(parseDelivery(raw, v1only, secret), want, 'v1-only still parses by default (backward compatible)');
++ assert.equal(parseDelivery(raw, v1only, secret, { requireV2: true }), null, 'requireV2 refuses a v1-only (replayable) delivery');
++ assert.equal(parseDelivery(raw, { 'x-openvibe-signature': 'sha256=bad' }, secret), null);
++ // A v2 delivery with a bad v1 still parses: v1 is not consulted once v2 is there.
++ assert.deepEqual(parseDelivery(raw, { ...signed, 'X-OpenVibe-Signature': 'sha256=bad' }, secret, { now }), want);
++ }],
++
['onPage runs only after every item of its page was handled: a crash mid-page keeps the old cursor', async () => {
const { platform, events } = setup();
for (let i = 1; i <= 6; i++) platform.publishEvent({ event_type: i === 3 ? 'live.other.thing' : 'media.vod.ready', source: 'media', actor, subject: { type: 'vod', id: String(i) } });
@@@ -190,7 +248,7 @@ run([
let secret;
const handled = [];
const srv = await stubServer((req, res, body) => {
-- const d = parseDelivery(body, req.headers, secret);
++ const d = parseDelivery(body, req.headers, secret, { requireV2: true });
if (!d) return send(res, 401, { error: 'bad signature' });
if (failNext > 0) { failNext--; return send(res, 503, { error: 'busy' }); }
const r = inbox.once('webhook', d.event.event_id, () => handled.push([d.seq, d.attempt, req.headers['x-openvibe-event-type']]));
@@@ -209,6 +267,9 @@ run([
const req = srv.requests.at(-1);
assert.equal(req.headers['x-openvibe-subscription-id'], sub.id);
assert.match(req.headers['x-openvibe-signature'], /^sha256=[0-9a-f]{64}$/);
++ assert.match(req.headers['x-openvibe-timestamp'], /^\d{10}$/);
++ assert.equal(req.headers['x-openvibe-signature-v2'].split(',')[0], `t=${req.headers['x-openvibe-timestamp']}`);
++ assert.equal(verifyDeliveryV2(req.body, req.headers, secret), true, 'the mock sends v2 too');
assert.match(req.headers.traceparent, /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
assert.equal((await platform.deliverEvents()).attempts.length, 0, 'nothing new');
ddiff --git a/types/events.d.ts b/types/events.d.ts
iindex 1dcd46f..acafcc4 100644
---- a/types/events.d.ts
++++ b/types/events.d.ts
@@@ -91,7 +91,27 @@ export declare function createAppEvents(client: OpenVibeClient, opts: { projectI
type RawBody = string | Uint8Array | ArrayBufferView;
export declare function signDelivery(rawBody: RawBody, secret: string): string;
export declare function verifyDelivery(rawBody: RawBody, signatureHeader: string | undefined | null, secret: string): boolean;
--export declare function parseDelivery(rawBody: RawBody, headers: Record<string, any> | Headers, secret: string): { event: EventEnvelope; seq: number; subscriptionId: string | null; attempt: number } | null;
++/** `t=<ts>,v2=<hex HMAC-SHA256 of "<ts>.<raw body>">`, the value of X-OpenVibe-Signature-V2 (timestamp in unix seconds, default now). */
++export declare function signDeliveryV2(rawBody: RawBody, secret: string, timestamp?: number): string;
++/** X-OpenVibe-Signature, X-OpenVibe-Timestamp and X-OpenVibe-Signature-V2 for a delivery body, as Events sends them (now in ms). */
++export declare function signDeliveryHeaders(rawBody: RawBody, secret: string, opts?: { now?: number }): {
++ 'X-OpenVibe-Signature': string;
++ 'X-OpenVibe-Timestamp': string;
++ 'X-OpenVibe-Signature-V2': string;
++};
++export interface DeliveryV2Options {
++ /** Seconds the timestamp may be away from `now`, either way. Default 300. */
++ toleranceSec?: number;
++ /** The current time in ms. Default Date.now(). */
++ now?: number;
++}
++/** Constant-time check of X-OpenVibe-Signature-V2 over the raw body, and of its timestamp (±toleranceSec). */
++export declare function verifyDeliveryV2(rawBody: RawBody, headers: Record<string, any> | Headers, secret: string, opts?: DeliveryV2Options): boolean;
++export interface ParseDeliveryOptions extends DeliveryV2Options {
++ /** Refuse deliveries without X-OpenVibe-Signature-V2 (v1-only). Default false. A present v2 header must always verify. */
++ requireV2?: boolean;
++}
++export declare function parseDelivery(rawBody: RawBody, headers: Record<string, any> | Headers, secret: string, opts?: ParseDeliveryOptions): { event: EventEnvelope; seq: number; subscriptionId: string | null; attempt: number } | null;
/** The subset of a better-sqlite3 Database the outbox and inbox use. */
export interface SqliteDatabase {