openvibers/openvibe.codes Commits

Webhook tester verifies signature v2 (register C-61); openvibe-sdk v0.4.0

OP OpenVibers committed 23 Sept 2026 · b0381da52a · 10 files, +398 −79

- Pin openvibe-sdk v0.4.0. The tester checks X-OpenVibe-Signature-V2 with verifyDeliveryV2 and
  decides as a receiver using parseDelivery(..., { requireV2: true }) does: v2 must match
  "<t>.<raw body>" and t must be within ±300 s of now; a differing X-OpenVibe-Timestamp is
  refused; a delivery without v2 is refused. v1 is still checked and shown, and never decides.
- The result shows both signatures (expected and given), the timestamp, its age, whether it is
  inside the window, and tells a stale-but-correct v2 from a wrong one.
- Sample deliveries are signed with signDeliveryHeaders (v1, X-OpenVibe-Timestamp, v2); the page
  says a replay goes stale after 300 s.
- The in-browser verifier (Web Crypto) computes v2 and the window the same way.
- Tests: v2 accept, window both ways (±299 accepted, ±301 refused, agreeing with parseDelivery),
  wrong secret, tampered body, moved t, malformed and mismatched headers, v1 never rescuing v2,
  constant-time comparisons, the sample accepted by the SDK, the no-JS form, and the browser
  script run under Node's Web Crypto.

Changes

ddiff --git a/README.md b/README.md
iindex 50d5fee..1fc793c 100644
---- a/README.md
++++ b/README.md
@@@ -32,7 +32,7 @@ A place where a developer outside the network goes from an OpenVibe account to a
|---|---|---|
| Portal | `/projects`, `/projects/:project`, `/projects/:project/apps/:app` | Create projects; add members; create sandbox/production apps (secret shown once); rotate and revoke credentials; request, approve, deny and revoke grants through a scope editor that offers only grantable capabilities (public, or partner when in the allowance) with each one's description, owner and visibility; quotas; audit (admin+). Network's errors are shown with its status, code, detail and request id. |
| OAuth helper | `/oauth`, `/oauth/test-callback` | Explains authorization code + PKCE (S256) for apps, builds a test authorize URL, and a callback that shows `code`/`state`/`error` and **never exchanges** the code. |
--| Webhook tools | `/tools/webhooks` | Verify `X-OpenVibe-Signature: sha256=<HMAC>` against a raw body (in the browser with Web Crypto, or on the server without JavaScript, constant-time, secret dropped); generate a signed sample delivery for any event type the contracts catalog lists. |
++| Webhook tools | `/tools/webhooks` | Verify a delivery as a receiver that requires signature v2 does: `X-OpenVibe-Signature-V2: t=<ts>,v2=<HMAC of "<ts>.<raw body>">` must match and `t` must be within ±300 s of now (the page shows the age and the window, and tells a stale-but-correct signature from a wrong one); `X-OpenVibe-Timestamp` must equal `t`; v1 `X-OpenVibe-Signature: sha256=<HMAC>` is checked and shown but never decides. In the browser with Web Crypto, or on the server without JavaScript with openvibe-sdk's `verifyDelivery`/`verifyDeliveryV2`; constant-time; secret dropped. Generate a sample delivery signed with v1 and v2 (`signDeliveryHeaders`) for any event type the contracts catalog lists. |
| Docs | `/docs/*` | Contracts (field tables, fixtures as examples, raw schema), capability catalog (grantable ones highlighted), event types, SDK reference from its `.d.ts` files, ADRs. Every page states the versions it was generated from. The service registry is read live from Network, with health as Network reports it. |
| Playgrounds | `/projects/:project/apps/:app/playground` | Media upload (sandbox, `media.object.upload`) and Events publish (`events.app.publish`: types `app.<project_key>.*`, source `app-<app ULID>`), run **as the app** with its own token (from its secret typed for that one request, or a pasted token). Refused — with the missing grant named, and nothing requested or called — unless Network lists the grant as approved. Sandbox apps only. |
| Manifests | `/manifests/validate`, `/projects/:project/apps/:app/releases/new` | Validate `codes.app-manifest@1` and `mods.mod-manifest@1` with openvibe-contracts; create, publish, deprecate and revoke releases. |
@@@ -47,7 +47,7 @@ Everything is server-rendered and usable without JavaScript. The only scripts of
- **OpenVibe.Network** — SSO (OAuth client `codes`, PKCE S256), JWKS, `/api/v1/projects` (called server-side with the person's Network access token), the registry (`/api/v1/registry/services`, `/.well-known/openvibe`), client-credentials tokens (Codes' own for the events relay; the app's own in playgrounds).
- **OpenVibe.Events** — the outbox relay publishes `codes.app.*` with Codes' service token (`events.event.publish`); the Events playground calls it with the app's token (`events.app.publish`).
- **OpenVibe.Media** — the Media playground uploads with the app's token into the project's namespace.
--- **openvibe-contracts** tag v0.27.0 (its package.json says 0.28.0; the docs show both), **openvibe-sdk v0.2.2**, **openvibe-shared v1.3.0** (pinned tag tarballs).
++- **openvibe-contracts** tag v0.27.0 (its package.json says 0.28.0; the docs show both), **openvibe-sdk v0.4.0**, **openvibe-shared v1.3.0** (pinned tag tarballs).
No path in Codes sends or accepts a shared loopback key (tested by grep and at runtime).
@@@ -84,7 +84,7 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4900
npx openvibe-contracts-check --service codes --src server
```
--Tests: ADR-013 trust-tier migration; secrets never persisted or re-displayed; Network errors surfaced honestly (404/403/409/422/503, unreachable, expired session refresh); scope editor offers only grantable capabilities and refuses forged requests before Network sees them; generated docs match the pinned versions (every contract, capability, event type, SDK module); webhook verification (tampering, prefixes, lengths, constant-time); playgrounds refuse without the grant and call nothing; manifest validation; release lifecycle and events; no internal key anywhere; PKCE sign-in; readiness, release.json, loopback metrics; the released codes manifest matching the code.
++Tests: ADR-013 trust-tier migration; secrets never persisted or re-displayed; Network errors surfaced honestly (404/403/409/422/503, unreachable, expired session refresh); scope editor offers only grantable capabilities and refuses forged requests before Network sees them; generated docs match the pinned versions (every contract, capability, event type, SDK module); webhook verification, v1 and v2 (tampering, prefixes, lengths, the ±300 s window both ways, timestamp header mismatch, v1 never deciding, constant-time, the in-browser verifier agreeing); playgrounds refuse without the grant and call nothing; manifest validation; release lifecycle and events; no internal key anywhere; PKCE sign-in; readiness, release.json, loopback metrics; the released codes manifest matching the code.
## Acceptance (must be true before "done")
ddiff --git a/STATUS.json b/STATUS.json
iindex d9f8cb1..71427f8 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,7 +9,7 @@
"domain": "openvibe.codes",
"packages": [
"openvibe-contracts v0.28.0 (same content as tag v0.27.0; the latest contracts release is v0.29.0)",
-- "openvibe-sdk v0.3.1 (v0.4.0 is released; not adopted yet)",
++ "openvibe-sdk v0.4.0",
"openvibe-shared v1.3.0"
],
"summary": "Deployed and public since 2026-09-23: https://openvibe.codes is served by this portal (openvibe-ovh, unit openvibe-codes on 127.0.0.1:4900 behind nginx, release a9371c3), and the domain left OpenVibe.Sites. Sign-in uses the registered Network OAuth client `codes` (PKCE S256); the principal `codes` holds events.event.publish and the Events relay is configured. It is barely used yet: production holds 0 manifests, 0 releases and 0 playground runs. The external-developer path (account, project, sandbox app, grants, Media upload, app events) was run once on production on 2026-09-23 with public endpoints and curl, not through these playgrounds; that check is not committed to a repository or CI.",
@@@ -20,7 +20,7 @@
"Scope editor offering only capabilities apps can be granted (public, or partner when in the allowance)",
"Docs generated at boot from the pinned openvibe-contracts and openvibe-sdk: contracts with field tables and fixtures, capability catalog, event types, SDK reference; service registry read live from Network with its health",
"OAuth callback helper and a test callback that never exchanges the code",
-- "Webhook signature verifier (server or in-browser) and signed sample deliveries for catalogued event types",
++ "Webhook tester for signature v1 and v2 (server or in-browser): decides as a receiver requiring v2 does, shows the timestamp's age and the ±300 s window, v1 shown but never deciding; signed sample deliveries carry v1, X-OpenVibe-Timestamp and v2",
"Playgrounds (Media upload with media.object.upload, Events publish with events.app.publish) that run with the app's own token and refuse without the grant",
"App and mod manifest validation; release metadata (draft, publish, deprecate, revoke) with codes.app.* events through the SDK outbox; trust tiers per ADR-013 (unreviewed, reviewed, first-party) as staff-set metadata, older names migrated at boot",
"Policy pages rendered from the published ADRs; licensing read from package metadata",
@@@ -28,7 +28,6 @@
"Readiness (/api/ready), /release.json and loopback-only /metrics"
],
"notYet": [
-- "Webhook tester verifies v1 signatures only; Events consumers now require v2 (X-OpenVibe-Signature-V2), which needs openvibe-sdk v0.4.0 here",
"The playgrounds have never been used on production (0 playground runs); the one production end-to-end run used curl, and it is not a committed, repeatable check",
"No usage, trace or error dashboards: no service reports per-project usage yet; quotas show recorded limits only",
"Billing sandbox, Chat bot and Realtime playgrounds; CLI and scaffolding; templates; project data export and delete",
ddiff --git a/package-lock.json b/package-lock.json
iindex cdc1058..a352c97 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1",
++ "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": {
@@@ -1039,9 +1039,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.3.1",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1",
-- "integrity": "sha512-6fMz/kuIucCOYJW5CAUusHgK6rm5PhIC1/0IKmV87a4nAnEsjWk1h2JTX7HKw0JSO0O+9f/Fydj1Iwq08VnxGw==",
++ "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 e493c36..a553eac 100644
---- a/package.json
++++ b/package.json
@@@ -28,7 +28,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1",
++ "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/public/js/webhook.js b/public/js/webhook.js
iindex d6fa793..98ae654 100644
---- a/public/js/webhook.js
++++ b/public/js/webhook.js
@@@ -1,11 +1,17 @@
--// OpenVibe.Codes — webhook signature check computed in the browser (Web Crypto), so the secret
++// OpenVibe.Codes — webhook signature checks computed in the browser (Web Crypto), so the secret
// never leaves this page. Without JavaScript (or Web Crypto) the form posts to Codes instead,
--// which computes the same HMAC and forgets the secret.
++// which computes the same HMACs with openvibe-sdk and forgets the secret.
++//
++// v1: X-OpenVibe-Signature = sha256=<hex HMAC-SHA256 of the raw body>
++// v2: X-OpenVibe-Signature-V2 = t=<unix seconds>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">
++// A receiver requiring v2 accepts only a matching v2 within ±300 s of its clock; v1 never decides.
(function () {
'use strict';
++ var WINDOW_SEC = 300;
var form = document.getElementById('verify-form');
var out = document.getElementById('verify-result');
if (!form || !out || !window.crypto || !window.crypto.subtle || !window.TextEncoder) return;
++ var enc = new TextEncoder();
function hex(buf) {
return Array.prototype.map.call(new Uint8Array(buf), function (b) { return ('0' + b.toString(16)).slice(-2); }).join('');
@@@ -17,6 +23,20 @@
for (var i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
++ // t and v2 values from the header, parsed as openvibe-sdk does; null when malformed.
++ function parseV2(header) {
++ var t = null, values = [], parts = header.split(',');
++ for (var i = 0; i < parts.length; i++) {
++ var at = parts[i].indexOf('=');
++ if (at < 0) return null;
++ var k = parts[i].slice(0, at).trim(), v = parts[i].slice(at + 1).trim();
++ if (k === 't') {
++ if (t !== null || !/^\d{1,12}$/.test(v)) return null;
++ t = Number(v);
++ } else if (k === 'v2') values.push(v);
++ }
++ return t === null || !values.length ? null : { t: t, values: values };
++ }
function show(ok, lines) {
out.textContent = '';
var box = document.createElement('div');
@@@ -33,16 +53,46 @@
form.addEventListener('submit', function (e) {
e.preventDefault();
var body = form.elements.body.value.replace(/\r\n/g, '\n');
-- var given = form.elements.signature.value.trim();
++ var given1 = form.elements.signature.value.trim();
++ var given2 = form.elements.signature_v2.value.trim();
++ var stated = form.elements.timestamp.value.trim();
var secret = form.elements.secret.value;
if (!body || !secret) { show(false, ['Enter the raw body and the secret.']); return; }
-- var enc = new TextEncoder();
++ var parsed = given2 ? parseV2(given2) : null;
++ var nowSec = Date.now() / 1000;
crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
-- .then(function (key) { return crypto.subtle.sign('HMAC', key, enc.encode(body)); })
-- .then(function (sig) {
-- var expected = 'sha256=' + hex(sig);
-- var ok = equal(given, expected);
-- show(ok, [ok ? 'Signature valid.' : 'Signature NOT valid.', 'Expected: ' + expected, 'Given: ' + (given || '(none)'), 'Computed in your browser; nothing was sent.']);
++ .then(function (key) {
++ return Promise.all([
++ crypto.subtle.sign('HMAC', key, enc.encode(body)),
++ parsed ? crypto.subtle.sign('HMAC', key, enc.encode(parsed.t + '.' + body)) : null,
++ ]);
++ })
++ .then(function (sigs) {
++ var expected1 = 'sha256=' + hex(sigs[0]);
++ var ok1 = equal(given1, expected1);
++ var lines = [];
++ var accepted = false, v2Line;
++ if (!given2) {
++ v2Line = 'v2: not given. Events sends X-OpenVibe-Signature-V2 on every delivery; a receiver that requires v2 refuses a delivery without it.';
++ } else if (!parsed) {
++ v2Line = 'v2: malformed. It must look like t=<unix seconds>,v2=<64 lowercase hex characters>.';
++ } else {
++ var hex2 = hex(sigs[1]);
++ var sigOk = parsed.values.some(function (v) { return equal(v, hex2); });
++ var age = Math.floor(nowSec) - parsed.t;
++ var inside = Math.abs(nowSec - parsed.t) <= WINDOW_SEC;
++ var agree = !stated || stated === String(parsed.t);
++ accepted = sigOk && inside && agree;
++ v2Line = 'v2: signature ' + (sigOk ? 'matches' : 'does NOT match') + '; t=' + parsed.t + ' is ' + (age >= 0 ? age + ' s ago' : (-age) + ' s in the future') +
++ ', ' + (inside ? 'inside' : 'OUTSIDE') + ' the ±' + WINDOW_SEC + ' s window by your clock' +
++ (stated ? '; X-OpenVibe-Timestamp ' + (agree ? 'equals t=' : 'DIFFERS from t= (' + stated + ')') : '') + '.';
++ lines.push('Expected v2: t=' + parsed.t + ',v2=' + hex2);
++ }
++ lines.unshift(v2Line);
++ lines.unshift(accepted ? 'Accepted: v2 verifies and is inside the window.' : 'Refused: a receiver that requires v2 rejects this delivery.');
++ lines.push('v1 (reference only, never decides): ' + (given1 ? (ok1 ? 'valid' : 'NOT valid') : 'not given') + '. Expected ' + expected1);
++ lines.push('Computed in your browser; nothing was sent.');
++ show(accepted, lines);
})
.catch(function () { form.submit(); });
});
ddiff --git a/server/domain/webhooks.js b/server/domain/webhooks.js
iindex 8873ccd..ce79361 100644
---- a/server/domain/webhooks.js
++++ b/server/domain/webhooks.js
@@@ -3,29 +3,28 @@
/**
* Webhook tester and signed-event inspector.
*
-- * OpenVibe.Events delivers `{ "event": <events.event-envelope@1>, "seq": <n> }` with
-- * X-OpenVibe-Signature: sha256=<hex HMAC-SHA256 of the RAW body under the subscription secret>.
-- * Verification here uses openvibe-sdk's own signDelivery/verifyDelivery (constant-time compare), so
-- * the tester agrees with what a receiver built on the SDK does. The secret a developer types is used
-- * for one computation and dropped: never stored, logged or rendered back.
++ * OpenVibe.Events delivers `{ "event": <events.event-envelope@1>, "seq": <n> }` with two signatures
++ * under the subscription secret:
++ * X-OpenVibe-Signature: sha256=<hex HMAC-SHA256 of the RAW body> (v1)
++ * X-OpenVibe-Timestamp: <unix seconds this attempt was sent>
++ * X-OpenVibe-Signature-V2: t=<that timestamp>,v2=<hex HMAC-SHA256 of "<t>.<raw body>"> (v2)
++ * Every consumer requires v2 and refuses it more than 300 s from its own clock (replay window).
++ * Verification here uses openvibe-sdk's own signDelivery/verifyDelivery and signDeliveryV2/
++ * verifyDeliveryV2 (constant-time compare), so the tester agrees with what a receiver built on the
++ * SDK does. The secret a developer types is used for one computation and dropped: never stored,
++ * logged or rendered back.
*/
const crypto = require('crypto');
const contracts = require('openvibe-contracts');
--const { signDelivery, verifyDelivery } = require('openvibe-sdk/events');
++const { signDelivery, verifyDelivery, signDeliveryV2, verifyDeliveryV2, signDeliveryHeaders } = require('openvibe-sdk/events');
const MAX_BODY = 256 * 1024;
++/** The replay window every Events consumer applies to v2 (openvibe-sdk's default). */
++const V2_WINDOW_SEC = 300;
++/** Wide enough to check the v2 HMAC alone, so a stale-but-correct signature is told apart from a wrong one. */
++const ANY_TIME = Number.MAX_SAFE_INTEGER;
--/**
-- * → { valid, reason, expected, given, bodyBytes, parsed }
-- * expected is the signature the secret produces for this body (showing it does not reveal the
-- * secret, and it is what a developer needs to see why a comparison failed).
-- */
--function inspect({ rawBody, signature, secret }) {
-- const body = typeof rawBody === 'string' ? rawBody : '';
-- const given = typeof signature === 'string' ? signature.trim() : '';
-- if (!body) return { valid: false, reason: 'paste the raw request body exactly as received (byte for byte)' };
-- if (Buffer.byteLength(body) > MAX_BODY) return { valid: false, reason: 'body is larger than 256 KB' };
-- if (!secret) return { valid: false, reason: 'enter the subscription secret' };
++function checkV1(body, given, secret) {
const expected = signDelivery(body, secret);
const valid = verifyDelivery(body, given, secret);
let reason = null;
@@@ -35,6 +34,76 @@ function inspect({ rawBody, signature, secret }) {
else if (given.length !== expected.length) reason = 'wrong length: sha256= followed by 64 lowercase hex characters';
else reason = 'the signature does not match this body and secret (check the body was not re-serialised: whitespace and key order matter)';
}
++ return { present: !!given, valid, reason, expected, given };
++}
++
++/** t and the v2 values from `t=<ts>,v2=<hex>[,v2=<hex>]`, parsed as the SDK does; null when malformed. */
++function parseV2Header(header) {
++ let t = null;
++ const values = [];
++ for (const part of header.split(',')) {
++ const i = part.indexOf('=');
++ if (i < 0) return null;
++ 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 null;
++ t = Number(v);
++ } else if (k === 'v2') values.push(v);
++ }
++ return t === null || !values.length ? null : { t, values };
++}
++
++function checkV2(body, given, stated, secret, now) {
++ const out = { present: !!given, valid: false, signatureValid: false, reason: null, expected: null, given, timestamp: null, statedTimestamp: stated || null, ageSec: null, windowSec: V2_WINDOW_SEC, withinWindow: null, timestampsAgree: null };
++ if (!given) {
++ out.reason = 'no X-OpenVibe-Signature-V2 value given. Events sends it on every delivery, and a receiver that requires v2 refuses a delivery without it';
++ return out;
++ }
++ const parsed = parseV2Header(given);
++ if (!parsed) {
++ out.reason = 'the header value must look like t=<unix seconds>,v2=<64 lowercase hex characters>';
++ return out;
++ }
++ out.timestamp = parsed.t;
++ out.expected = signDeliveryV2(body, secret, parsed.t);
++ out.ageSec = Math.floor(now / 1000) - parsed.t;
++ out.withinWindow = Math.abs(now / 1000 - parsed.t) <= V2_WINDOW_SEC;
++ out.timestampsAgree = stated ? stated === String(parsed.t) : null;
++ // The HMAC alone (any time, X-OpenVibe-Timestamp left out), then the check a receiver makes.
++ out.signatureValid = verifyDeliveryV2(body, { 'x-openvibe-signature-v2': given }, secret, { toleranceSec: ANY_TIME, now });
++ const headers = { 'x-openvibe-signature-v2': given };
++ if (stated) headers['x-openvibe-timestamp'] = stated;
++ out.valid = verifyDeliveryV2(body, headers, secret, { toleranceSec: V2_WINDOW_SEC, now });
++ if (!out.valid) {
++ if (!out.signatureValid) {
++ out.reason = parsed.values.every((v) => v.length !== 64)
++ ? 'wrong length: v2= followed by 64 lowercase hex characters'
++ : 'the v2 signature does not match: it is the HMAC of "<t>.<raw body>" (the timestamp, a dot, then the body byte for byte)';
++ } else if (out.timestampsAgree === false) {
++ out.reason = `X-OpenVibe-Timestamp (${stated}) is not the t= in X-OpenVibe-Signature-V2 (${parsed.t})`;
++ } else {
++ out.reason = `the signature matches, but t is ${Math.abs(out.ageSec)} s ${out.ageSec >= 0 ? 'in the past' : 'in the future'}, outside the ±${V2_WINDOW_SEC} s window: a receiver refuses it now. Events signs every attempt afresh, so only a replayed or long-held delivery is this old (or a clock is wrong: keep NTP on)`;
++ }
++ }
++ return out;
++}
++
++/**
++ * → { accepted, v1, v2, bodyBytes, parsed } or { accepted: false, reason } for unusable input.
++ * accepted is what a receiver requiring v2 decides (parseDelivery(..., { requireV2: true })):
++ * v2 must verify and be within the window; v1 is shown but never decides.
++ * Each check carries `expected`, the signature the secret produces for this body (showing it
++ * does not reveal the secret, and it is what a developer needs to see why a comparison failed).
++ */
++function inspect({ rawBody, signature, signatureV2, timestamp, secret, now = Date.now() }) {
++ const body = typeof rawBody === 'string' ? rawBody : '';
++ const str = (v) => (typeof v === 'string' ? v.trim() : '');
++ if (!body) return { accepted: false, reason: 'paste the raw request body exactly as received (byte for byte)' };
++ if (Buffer.byteLength(body) > MAX_BODY) return { accepted: false, reason: 'body is larger than 256 KB' };
++ if (!secret) return { accepted: false, reason: 'enter the subscription secret' };
++ const v1 = checkV1(body, str(signature), secret);
++ const v2 = checkV2(body, str(signatureV2), str(timestamp), secret, now);
let parsed = null;
try {
const obj = JSON.parse(body);
@@@ -45,7 +114,7 @@ function inspect({ rawBody, signature, secret }) {
parsed = { note: 'JSON, but not an Events delivery ({ event, seq })' };
}
} catch { parsed = { note: 'not JSON' }; }
-- return { valid, reason, expected, given, bodyBytes: Buffer.byteLength(body), parsed };
++ return { accepted: v2.valid, v1, v2, bodyBytes: Buffer.byteLength(body), parsed };
}
/**
@@@ -80,7 +149,7 @@ function sample({ eventType, secret, producer, now = Date.now() }) {
'X-OpenVibe-Subscription-Id': subscriptionId,
'X-OpenVibe-Delivery-Attempt': '1',
'X-OpenVibe-Hops': '0',
-- 'X-OpenVibe-Signature': signDelivery(body, secret),
++ ...signDeliveryHeaders(body, secret, { now }),
traceparent: `00-${envelope.trace_id}-${crypto.randomBytes(8).toString('hex')}-01`,
};
return { body, headers, envelopeValid: check.valid, envelopeErrors: check.errors };
@@@ -93,4 +162,4 @@ function curlFor(headers, body, endpoint = 'https://your-app.example/webhooks/op
return `curl -X POST ${q(endpoint)} \\\n${hs} \\\n --data-binary ${q(body)}`;
}
--module.exports = { inspect, sample, curlFor, MAX_BODY };
++module.exports = { inspect, sample, curlFor, parseV2Header, MAX_BODY, V2_WINDOW_SEC };
ddiff --git a/server/http/tools.js b/server/http/tools.js
iindex afff4e4..88a8022 100644
---- a/server/http/tools.js
++++ b/server/http/tools.js
@@@ -12,7 +12,7 @@ const express = require('express');
const { asyncRouter } = require('./router');
const crypto = require('crypto');
const rateLimit = require('express-rate-limit');
--const { html, raw, code, table, notice } = require('../render/html');
++const { html, raw, code, table, notice, badge, time } = require('../render/html');
const { send } = require('../render/layout');
const webhooks = require('../domain/webhooks');
const manifests = require('../domain/manifests');
@@@ -98,11 +98,13 @@ ${got.code ? html`<h2>Exchange it (on your server)</h2>
<li><code>X-OpenVibe-Timestamp: &lt;unix seconds&gt;</code>, the time this attempt was sent. Every retry gets a new one.</li>
<li><code>X-OpenVibe-Signature-V2: t=&lt;that timestamp&gt;,v2=&lt;hex HMAC-SHA256 of "&lt;t&gt;.&lt;raw body&gt;"&gt;</code> (v2).</li>
</ul>
--<p>Verify against the <strong>raw bytes</strong>, before parsing, with a constant-time comparison. v1 covers only the body, so a captured delivery verifies forever. v2 also covers the time: refuse it when <code>t</code> is more than 300 seconds from your clock, in either direction. When the v2 header is present but does not verify or is stale, reject the delivery. Never fall back to v1. <code>parseDelivery(raw, headers, secret, { requireV2: true })</code> in <a href="/docs/sdk/events"><code>openvibe-sdk/events</code></a> (0.4.0 and later) does all of this, and also refuses deliveries that carry only v1. <code>verifyDelivery()</code> checks v1 only. The tester below checks the v1 header.</p>
++<p>Verify against the <strong>raw bytes</strong>, before parsing, with a constant-time comparison. v1 covers only the body, so a captured delivery verifies forever. v2 also covers the time: refuse it when <code>t</code> is more than 300 seconds from your clock, in either direction. When the v2 header is present but does not verify or is stale, reject the delivery. Never fall back to v1. <code>parseDelivery(raw, headers, secret, { requireV2: true })</code> in <a href="/docs/sdk/events"><code>openvibe-sdk/events</code></a> (0.4.0 and later) does all of this, and also refuses deliveries that carry only v1. <code>verifyDelivery()</code> checks v1 only; <code>verifyDeliveryV2()</code> checks v2 and the window. The tester below checks both, says whether the timestamp is inside the window now, and decides as a receiver that requires v2 does: v1 is shown for reference and never decides.</p>
<h2>Verify a delivery</h2>
<form method="post" action="/tools/webhooks/verify" class="stack" id="verify-form">
<label>Raw body <textarea name="body" rows="8" required spellcheck="false">${values.body || ''}</textarea></label>
--<label>X-OpenVibe-Signature <input name="signature" value="${values.signature || ''}" size="80" spellcheck="false" autocomplete="off"></label>
++<label>X-OpenVibe-Signature-V2 <input name="signature_v2" value="${values.signature_v2 || ''}" size="80" spellcheck="false" autocomplete="off" placeholder="t=1790000000,v2=…"></label>
++<label>X-OpenVibe-Timestamp <span class="muted small">(optional; when given it must equal t=)</span> <input name="timestamp" value="${values.timestamp || ''}" size="14" spellcheck="false" autocomplete="off" inputmode="numeric"></label>
++<label>X-OpenVibe-Signature <span class="muted small">(v1, optional)</span> <input name="signature" value="${values.signature || ''}" size="80" spellcheck="false" autocomplete="off" placeholder="sha256=…"></label>
<label>Subscription secret <input type="password" name="secret" value="" autocomplete="off" required></label>
<p class="muted small">Without JavaScript the form is sent to Codes, which computes the HMAC and forgets the secret. With JavaScript it is computed in your browser and nothing is sent.</p>
<button type="submit">Verify</button></form>
@@@ -115,21 +117,34 @@ ${got.code ? html`<h2>Exchange it (on your server)</h2>
${sample ? html`<div class="result"><p>A delivery exactly as Events would send it (event types from <code>openvibe-contracts v${docs.contractsVersion}</code>). The envelope ${sample.envelopeValid ? 'validates' : 'does NOT validate'} as events.event-envelope@1. The payload is empty: contracts define no payload schema for this event type yet, and Codes does not invent one.</p>
${table(['Header', 'Value'], Object.entries(sample.headers).map(([k, v]) => [code(k), code(v)]))}
<p>Body (byte for byte):</p><pre><code>${sample.body}</code></pre>
--<p>Replay it against your own endpoint (Codes never sends requests to your URLs):</p><pre><code>${webhooks.curlFor(sample.headers, sample.body)}</code></pre></div>` : ''}`,
++<p>Replay it against your own endpoint (Codes never sends requests to your URLs). The v2 timestamp is the time you pressed Generate, so a receiver refuses the replay once it is more than ${webhooks.V2_WINDOW_SEC} seconds old: generate a fresh one, or use it to check that your endpoint does refuse a stale delivery.</p><pre><code>${webhooks.curlFor(sample.headers, sample.body)}</code></pre></div>` : ''}`,
});
function verifyResult(v) {
-- return html`<div class="result ${v.valid ? 'ok' : 'bad'}" role="status"><p><strong>${v.valid ? 'Signature valid.' : 'Signature NOT valid.'}</strong> ${v.reason || ''}</p>
--${v.expected ? html`<dl class="facts"><dt>Expected</dt><dd>${code(v.expected)}</dd><dt>Given</dt><dd>${code(v.given || '(none)')}</dd><dt>Body</dt><dd>${v.bodyBytes} bytes</dd>
++ if (!v.v1) return html`<div class="result bad" role="status"><p><strong>Cannot check.</strong> ${v.reason || ''}</p></div>`;
++ const { v1, v2 } = v;
++ const age = v2.ageSec == null ? '' : v2.ageSec >= 0 ? `${v2.ageSec} s ago` : `${-v2.ageSec} s in the future`;
++ return html`<div class="result ${v.accepted ? 'ok' : 'bad'}" role="status"><p><strong>${v.accepted ? 'Accepted: v2 verifies and is inside the window.' : 'Refused: a receiver that requires v2 rejects this delivery.'}</strong>${v.accepted ? '' : html` ${v2.reason || ''}`}</p>
++<h3>v2 (X-OpenVibe-Signature-V2) ${badge(v2.valid ? 'valid' : v2.signatureValid ? 'stale' : 'NOT valid', v2.valid ? 'ok' : v2.signatureValid ? 'warn' : 'bad')}</h3>
++<dl class="facts">${v2.timestamp != null ? html`<dt>Timestamp</dt><dd>${code(String(v2.timestamp))} ${v2.timestamp < 8.64e9 ? time(new Date(v2.timestamp * 1000).toISOString()) : ''}, ${age} by this server's clock</dd>
++<dt>Window</dt><dd>${v2.withinWindow ? html`${badge('inside', 'ok')} within ±${v2.windowSec} s` : html`${badge('outside', 'bad')} more than ${v2.windowSec} s from now`}</dd>
++<dt>Signature</dt><dd>${v2.signatureValid ? 'matches this body and secret' : 'does not match this body and secret'}</dd>` : ''}
++${v2.timestampsAgree != null ? html`<dt>X-OpenVibe-Timestamp</dt><dd>${code(v2.statedTimestamp)} ${v2.timestampsAgree ? 'equals t=' : html`${badge('differs from t=', 'bad')}`}</dd>` : ''}
++<dt>Expected</dt><dd>${code(v2.expected || '(needs a t= value)')}</dd><dt>Given</dt><dd>${code(v2.given || '(none)')}</dd></dl>
++<h3>v1 (X-OpenVibe-Signature) ${badge(v1.valid ? 'valid' : v1.present ? 'NOT valid' : 'not given', v1.valid ? 'ok' : v1.present ? 'bad' : '')}</h3>
++<p class="muted small">Shown for reference. v1 covers only the body, so it cannot tell a replay from a delivery; it never decides.${v1.present && !v1.valid ? html` ${v1.reason}` : ''}</p>
++<dl class="facts"><dt>Expected</dt><dd>${code(v1.expected)}</dd><dt>Given</dt><dd>${code(v1.given || '(none)')}</dd></dl>
++<h3>Body</h3>
++<dl class="facts"><dt>Size</dt><dd>${v.bodyBytes} bytes</dd>
${v.parsed && v.parsed.event_type ? html`<dt>Event</dt><dd>${code(v.parsed.event_type)} ${code(v.parsed.event_id || '')} seq ${v.parsed.seq}; envelope ${v.parsed.envelopeValid ? 'validates' : html`does not validate: ${v.parsed.envelopeErrors.map((e) => `${e.path} ${e.message}`).join('; ')}`}</dd>` : ''}
--${v.parsed && v.parsed.note ? html`<dt>Body</dt><dd>${v.parsed.note}</dd>` : ''}</dl>` : ''}</div>`;
++${v.parsed && v.parsed.note ? html`<dt>Shape</dt><dd>${v.parsed.note}</dd>` : ''}</dl></div>`;
}
r.get('/tools/webhooks', (req, res) => webhookPage(req, res));
r.post('/tools/webhooks/verify', limiter, form, (req, res) => {
const b = req.body || {};
-- const v = webhooks.inspect({ rawBody: typeof b.body === 'string' ? b.body.replace(/\r\n/g, '\n') : '', signature: b.signature, secret: typeof b.secret === 'string' ? b.secret : '' });
-- webhookPage(req, res, { status: v.valid ? 200 : 422, verify: v, values: { body: b.body, signature: b.signature } });
++ const v = webhooks.inspect({ rawBody: typeof b.body === 'string' ? b.body.replace(/\r\n/g, '\n') : '', signature: b.signature, signatureV2: b.signature_v2, timestamp: b.timestamp, secret: typeof b.secret === 'string' ? b.secret : '' });
++ webhookPage(req, res, { status: v.accepted ? 200 : 422, verify: v, values: { body: b.body, signature: b.signature, signature_v2: b.signature_v2, timestamp: b.timestamp } });
});
r.post('/tools/webhooks/sample', limiter, form, (req, res) => {
const b = req.body || {};
ddiff --git a/test/docs.test.js b/test/docs.test.js
iindex dd11eba..2710600 100644
---- a/test/docs.test.js
++++ b/test/docs.test.js
@@@ -22,7 +22,7 @@ const sdkPkg = require('openvibe-sdk/package.json');
assert.strictEqual(tag('openvibe-contracts'), 'v0.28.0');
assert.strictEqual(tag('openvibe-sdk'), `v${sdkPkg.version}`);
assert.strictEqual(tag('openvibe-shared'), `v${require('openvibe-shared/package.json').version}`);
-- assert.strictEqual(sdkPkg.version, '0.3.1');
++ assert.strictEqual(sdkPkg.version, '0.4.0');
// The contracts tag v0.28.0 carries the released codes manifest and codes.app-manifest@1.
assert.ok(contracts.services.get('codes') && contracts.services.get('codes').status === 'alpha');
assert.ok(contracts.resolve('codes.app-manifest@1'));
ddiff --git a/test/webhook-browser.test.js b/test/webhook-browser.test.js
nnew file mode 100644
iindex 0000000..be8e9ce
---- /dev/null
++++ b/test/webhook-browser.test.js
@@@ -0,0 +1,83 @@
++'use strict';
++/**
++ * public/js/webhook.js (the in-browser verifier) decides as the server and openvibe-sdk do: run in a
++ * vm with Node's Web Crypto and a minimal stand-in for the form, it accepts a fresh v2, refuses a
++ * stale v2 or a v1-only delivery, and never lets v1 decide.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++const { check, done } = require('./helpers/boot');
++
++const SRC = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'webhook.js'), 'utf8');
++
++/** Runs webhook.js against a fake form; resolves with the text of the result box. */
++function runBrowser(fields, nowMs) {
++ return new Promise((resolve, reject) => {
++ const el = (tag) => ({ tag, className: '', children: [], textContent: '', setAttribute() {}, appendChild(c) { this.children.push(c); } });
++ const out = el('div');
++ let submitHandler = null;
++ const form = {
++ elements: Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, { value: v }])),
++ addEventListener: (type, fn) => { if (type === 'submit') submitHandler = fn; },
++ submit: () => reject(new Error('fell back to the server')),
++ };
++ const text = (n) => (n.children.length ? n.children.map(text).join(' ') : n.textContent);
++ // The script clears the result box with textContent = ''.
++ Object.defineProperty(out, 'textContent', { set() { out.children = []; }, get() { return ''; } });
++ const document = {
++ getElementById: (id) => (id === 'verify-form' ? form : id === 'verify-result' ? out : null),
++ createElement: el,
++ };
++ const window = { crypto: globalThis.crypto, TextEncoder };
++ const DateStub = { now: () => nowMs };
++ vm.runInNewContext(SRC, { window, document, crypto: globalThis.crypto, TextEncoder, Promise, Date: DateStub });
++ assert.ok(submitHandler, 'the script binds the form');
++ submitHandler({ preventDefault() {} });
++ const wait = () => (out.children.length ? resolve({ ok: out.children[0].className.includes(' ok'), text: text(out.children[0]) }) : setTimeout(wait, 5));
++ wait();
++ });
++}
++
++(async () => {
++ const secret = 'whsec_' + crypto.randomBytes(16).toString('hex');
++ const body = JSON.stringify({ event: { event_type: 'media.object.ready' }, seq: 3 });
++ const nowMs = 1_790_000_000_000;
++ const nowSec = nowMs / 1000;
++ const v1 = `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`;
++ const v2 = (ts, sec = secret) => `t=${ts},v2=${crypto.createHmac('sha256', sec).update(`${ts}.${body}`).digest('hex')}`;
++ const fields = (o) => ({ body, signature: v1, signature_v2: '', timestamp: '', secret, ...o });
++
++ await check('a fresh v2 is accepted, computed in the browser', async () => {
++ const r = await runBrowser(fields({ signature_v2: v2(nowSec), timestamp: String(nowSec) }), nowMs);
++ assert.strictEqual(r.ok, true, r.text);
++ assert.match(r.text, /Accepted/);
++ assert.match(r.text, /inside the ±300 s window/);
++ assert.match(r.text, /nothing was sent/);
++ });
++
++ await check('a v2 301 s old matches but is refused as outside the window', async () => {
++ const r = await runBrowser(fields({ signature_v2: v2(nowSec - 301) }), nowMs);
++ assert.strictEqual(r.ok, false);
++ assert.match(r.text, /signature matches; t=\d+ is 301 s ago, OUTSIDE/);
++ });
++
++ await check('v1 alone is refused; a wrong v2 is refused even with a valid v1; a differing X-OpenVibe-Timestamp is refused', async () => {
++ const v1Only = await runBrowser(fields({}), nowMs);
++ assert.strictEqual(v1Only.ok, false);
++ assert.match(v1Only.text, /v2: not given/);
++ assert.match(v1Only.text, /v1 \(reference only, never decides\): valid/);
++ const wrong = await runBrowser(fields({ signature_v2: v2(nowSec, 'other') }), nowMs);
++ assert.strictEqual(wrong.ok, false);
++ assert.match(wrong.text, /does NOT match/);
++ const differs = await runBrowser(fields({ signature_v2: v2(nowSec), timestamp: String(nowSec - 1) }), nowMs);
++ assert.strictEqual(differs.ok, false);
++ assert.match(differs.text, /DIFFERS from t=/);
++ const malformed = await runBrowser(fields({ signature_v2: 'v2=abc' }), nowMs);
++ assert.match(malformed.text, /malformed/);
++ });
++
++ done();
++})();
ddiff --git a/test/webhooks.test.js b/test/webhooks.test.js
iindex 1b4edbb..d319cb7 100644
---- a/test/webhooks.test.js
++++ b/test/webhooks.test.js
@@@ -1,13 +1,15 @@
'use strict';
/**
-- * Webhook signature verification: correct against an independent HMAC, rejects tampering, wrong
-- * secrets, prefixes and lengths, compares in constant time (crypto.timingSafeEqual), and sample
-- * deliveries verify with the SDK receivers use. The no-JS path works through the server.
++ * Webhook signature verification, v1 and v2: correct against independent HMACs, rejects tampering,
++ * wrong secrets, prefixes, lengths and malformed headers, applies the ±300 s v2 window exactly as
++ * openvibe-sdk's parseDelivery(..., { requireV2: true }) does (v2 decides; v1 never rescues it),
++ * compares in constant time (crypto.timingSafeEqual), and sample deliveries carry v1 and v2 that
++ * the SDK receivers use accept. The no-JS path works through the server.
*/
const assert = require('assert');
const crypto = require('crypto');
const contracts = require('openvibe-contracts');
--const { verifyDelivery, parseDelivery } = require('openvibe-sdk/events');
++const { verifyDelivery, verifyDeliveryV2, parseDelivery } = require('openvibe-sdk/events');
const webhooks = require('../server/domain/webhooks');
const { boot, check, done } = require('./helpers/boot');
@@@ -17,71 +19,172 @@ const hmac = (body, secret) => `sha256=${crypto.createHmac('sha256', secret).upd
const secret = 'whsec_' + crypto.randomBytes(16).toString('hex');
const body = JSON.stringify({ event: { event_id: contracts.ids.newId('event'), event_type: 'network.app.created', version: 1, source: 'network', actor: { type: 'system', id: 'network' }, timestamp: new Date().toISOString(), subject: { type: 'app', id: 'app_1' }, payload: {} }, seq: 7 });
-- await check('a correct signature verifies (independent HMAC-SHA256 of the raw body)', async () => {
++ // A clock 0.7 s into a second: ages are whole seconds, the window is checked on the exact time.
++ const nowSec = Math.floor(Date.now() / 1000);
++ const nowMs = nowSec * 1000 + 700;
++ const v2 = (b, sec, ts) => `t=${ts},v2=${crypto.createHmac('sha256', sec).update(`${ts}.${b}`).digest('hex')}`;
++
++ await check('v1: a correct signature verifies (independent HMAC-SHA256 of the raw body)', async () => {
const v = webhooks.inspect({ rawBody: body, signature: hmac(body, secret), secret });
-- assert.strictEqual(v.valid, true);
-- assert.strictEqual(v.expected, hmac(body, secret));
++ assert.strictEqual(v.v1.valid, true);
++ assert.strictEqual(v.v1.expected, hmac(body, secret));
assert.strictEqual(v.parsed.event_type, 'network.app.created');
assert.strictEqual(v.parsed.seq, 7);
});
-- await check('tampered body, wrong secret, missing prefix, wrong length, uppercase hex: all rejected', async () => {
++ await check('v1: tampered body, wrong secret, missing prefix, wrong length, uppercase hex: all rejected', async () => {
const good = hmac(body, secret);
-- assert.strictEqual(webhooks.inspect({ rawBody: body.replace('"seq":7', '"seq":8'), signature: good, secret }).valid, false);
-- assert.strictEqual(webhooks.inspect({ rawBody: body, signature: good, secret: secret + 'x' }).valid, false);
-- const noPrefix = webhooks.inspect({ rawBody: body, signature: good.slice(7), secret });
++ const v1 = (o) => webhooks.inspect({ rawBody: body, signature: good, secret, ...o }).v1;
++ assert.strictEqual(v1({ rawBody: body.replace('"seq":7', '"seq":8') }).valid, false);
++ assert.strictEqual(v1({ secret: secret + 'x' }).valid, false);
++ const noPrefix = v1({ signature: good.slice(7) });
assert.strictEqual(noPrefix.valid, false);
assert.match(noPrefix.reason, /sha256=/);
-- const short = webhooks.inspect({ rawBody: body, signature: good.slice(0, -2), secret });
++ const short = v1({ signature: good.slice(0, -2) });
assert.strictEqual(short.valid, false);
assert.match(short.reason, /wrong length/);
-- assert.strictEqual(webhooks.inspect({ rawBody: body, signature: good.toUpperCase().replace('SHA256=', 'sha256='), secret }).valid, false);
++ assert.strictEqual(v1({ signature: good.toUpperCase().replace('SHA256=', 'sha256=') }).valid, false);
// Re-serialised JSON (same data, different bytes) must fail: verification is over raw bytes.
-- assert.strictEqual(webhooks.inspect({ rawBody: JSON.stringify(JSON.parse(body), null, 1), signature: good, secret }).valid, false);
++ assert.strictEqual(v1({ rawBody: JSON.stringify(JSON.parse(body), null, 1) }).valid, false);
++ });
++
++ await check('v2: a fresh, correct signature is accepted (independent HMAC of "<t>.<raw body>")', async () => {
++ const v = webhooks.inspect({ rawBody: body, signature: hmac(body, secret), signatureV2: v2(body, secret, nowSec), timestamp: String(nowSec), secret, now: nowMs });
++ assert.strictEqual(v.accepted, true);
++ assert.strictEqual(v.v2.valid, true);
++ assert.strictEqual(v.v2.signatureValid, true);
++ assert.strictEqual(v.v2.withinWindow, true);
++ assert.strictEqual(v.v2.timestampsAgree, true);
++ assert.strictEqual(v.v2.timestamp, nowSec);
++ assert.strictEqual(v.v2.expected, v2(body, secret, nowSec));
++ assert.strictEqual(v.v2.windowSec, 300);
++ assert.strictEqual(v.v1.valid, true, 'v1 is still checked and shown');
++ });
++
++ await check('v2: the ±300 s window, both directions, with the signature told apart from the time', async () => {
++ const at = (ts) => webhooks.inspect({ rawBody: body, signatureV2: v2(body, secret, ts), secret, now: nowMs });
++ assert.strictEqual(at(nowSec - 299).accepted, true);
++ assert.strictEqual(at(nowSec + 299).accepted, true);
++ const stale = at(nowSec - 301);
++ assert.strictEqual(stale.accepted, false);
++ assert.strictEqual(stale.v2.signatureValid, true, 'the HMAC matches; only the time is wrong');
++ assert.strictEqual(stale.v2.withinWindow, false);
++ assert.strictEqual(stale.v2.ageSec, 301);
++ assert.match(stale.v2.reason, /301 s in the past, outside the ±300 s window/);
++ const future = at(nowSec + 400);
++ assert.strictEqual(future.accepted, false);
++ assert.strictEqual(future.v2.withinWindow, false);
++ assert.match(future.v2.reason, /in the future/);
++ // Agrees with the SDK a receiver uses.
++ for (const ts of [nowSec - 301, nowSec - 299, nowSec + 299, nowSec + 301]) {
++ const headers = { 'x-openvibe-signature-v2': v2(body, secret, ts) };
++ assert.strictEqual(at(ts).accepted, parseDelivery(body, headers, secret, { requireV2: true, now: nowMs }) !== null, `t offset ${ts - nowSec}`);
++ }
++ });
++
++ await check('v2: wrong secret, tampered body, a v2 signed for another t, malformed and mismatched headers: refused', async () => {
++ const good = v2(body, secret, nowSec);
++ const run = (o) => webhooks.inspect({ rawBody: body, signatureV2: good, secret, now: nowMs, ...o });
++ const wrongSecret = run({ secret: secret + 'x' });
++ assert.strictEqual(wrongSecret.accepted, false);
++ assert.strictEqual(wrongSecret.v2.signatureValid, false);
++ assert.match(wrongSecret.v2.reason, /does not match/);
++ assert.strictEqual(run({ rawBody: body.replace('"seq":7', '"seq":8') }).accepted, false);
++ // The HMAC for one time moved onto another: the time is covered by the signature.
++ const moved = run({ signatureV2: good.replace(`t=${nowSec}`, `t=${nowSec - 1}`) });
++ assert.strictEqual(moved.accepted, false);
++ assert.strictEqual(moved.v2.signatureValid, false);
++ for (const bad of ['v2=abc', `t=${nowSec}`, `t=x,v2=${'0'.repeat(64)}`, 'nonsense', `t=${nowSec},t=${nowSec},v2=${'0'.repeat(64)}`]) {
++ const r = run({ signatureV2: bad });
++ assert.strictEqual(r.accepted, false, bad);
++ assert.match(r.v2.reason, /t=<unix seconds>,v2=/, bad);
++ }
++ const short = run({ signatureV2: good.slice(0, -2) });
++ assert.match(short.v2.reason, /wrong length/);
++ const mismatch = run({ timestamp: String(nowSec - 5) });
++ assert.strictEqual(mismatch.accepted, false);
++ assert.strictEqual(mismatch.v2.signatureValid, true);
++ assert.strictEqual(mismatch.v2.timestampsAgree, false);
++ assert.match(mismatch.v2.reason, /X-OpenVibe-Timestamp \(\d+\) is not the t=/);
++ });
++
++ await check('v2 decides: a valid v1 alone is refused, and a valid v1 never rescues a bad v2', async () => {
++ const v1Only = webhooks.inspect({ rawBody: body, signature: hmac(body, secret), secret, now: nowMs });
++ assert.strictEqual(v1Only.v1.valid, true);
++ assert.strictEqual(v1Only.v2.present, false);
++ assert.strictEqual(v1Only.accepted, false);
++ assert.match(v1Only.v2.reason, /no X-OpenVibe-Signature-V2/);
++ const staleWithV1 = webhooks.inspect({ rawBody: body, signature: hmac(body, secret), signatureV2: v2(body, secret, nowSec - 3600), secret, now: nowMs });
++ assert.strictEqual(staleWithV1.v1.valid, true);
++ assert.strictEqual(staleWithV1.accepted, false);
});
-- await check('the comparison is constant-time (crypto.timingSafeEqual on equal-length buffers)', async () => {
++ await check('the comparisons are constant-time (crypto.timingSafeEqual on equal-length buffers)', async () => {
const orig = crypto.timingSafeEqual;
let calls = 0;
crypto.timingSafeEqual = (a, b) => { calls++; assert.strictEqual(a.length, b.length); return orig(a, b); };
try {
webhooks.inspect({ rawBody: body, signature: hmac(body, secret), secret });
-- const wrong = hmac(body, 'other');
-- webhooks.inspect({ rawBody: body, signature: wrong, secret });
++ webhooks.inspect({ rawBody: body, signature: hmac(body, 'other'), secret });
++ assert.strictEqual(calls, 2, 'every equal-length v1 comparison goes through timingSafeEqual');
++ calls = 0;
++ webhooks.inspect({ rawBody: body, signatureV2: v2(body, 'other', nowSec), secret, now: nowMs });
++ assert.ok(calls >= 1, 'the v2 comparison goes through timingSafeEqual');
} finally { crypto.timingSafeEqual = orig; }
-- assert.strictEqual(calls, 2, 'every equal-length comparison goes through timingSafeEqual');
});
-- await check('a generated sample is a valid envelope, signed so the SDK receiver accepts it', async () => {
-- const s = webhooks.sample({ eventType: 'media.object.ready', secret, producer: 'media' });
++ await check('a generated sample carries v1, X-OpenVibe-Timestamp and v2, and the SDK receiver requiring v2 accepts it', async () => {
++ const s = webhooks.sample({ eventType: 'media.object.ready', secret, producer: 'media', now: nowMs });
assert.strictEqual(s.envelopeValid, true, JSON.stringify(s.envelopeErrors));
const headers = Object.fromEntries(Object.entries(s.headers).map(([k, v]) => [k.toLowerCase(), v]));
assert.strictEqual(verifyDelivery(s.body, headers['x-openvibe-signature'], secret), true);
-- const parsed = parseDelivery(s.body, headers, secret);
++ assert.strictEqual(headers['x-openvibe-timestamp'], String(nowSec));
++ assert.strictEqual(headers['x-openvibe-signature-v2'], v2(s.body, secret, nowSec));
++ assert.strictEqual(verifyDeliveryV2(s.body, headers, secret, { now: nowMs }), true);
++ const parsed = parseDelivery(s.body, headers, secret, { requireV2: true, now: nowMs });
assert.strictEqual(parsed.event.event_type, 'media.object.ready');
assert.strictEqual(parsed.seq, 1);
assert.strictEqual(contracts.validate('events.event-envelope@1', parsed.event).valid, true);
++ assert.strictEqual(parseDelivery(s.body, headers, secret, { requireV2: true, now: nowMs + 301_000 }), null, 'the sample goes stale after 300 s');
assert.strictEqual(verifyDelivery(s.body, headers['x-openvibe-signature'], 'wrong'), false);
assert.deepStrictEqual(JSON.parse(s.body).event.payload, {}, 'no invented payload');
++ // What the tester says about its own sample.
++ const v = webhooks.inspect({ rawBody: s.body, signature: headers['x-openvibe-signature'], signatureV2: headers['x-openvibe-signature-v2'], timestamp: headers['x-openvibe-timestamp'], secret, now: nowMs });
++ assert.strictEqual(v.accepted, true);
});
const t = await boot();
-- await check('no-JS path: the server verifies and says why a signature fails', async () => {
-- const ok = await t.get('/tools/webhooks/verify', { form: { body, signature: hmac(body, secret), secret } });
++ await check('no-JS path: the server checks v2 and v1, shows the window, and says why a delivery is refused', async () => {
++ const ts = Math.floor(Date.now() / 1000);
++ const ok = await t.get('/tools/webhooks/verify', { form: { body, signature: hmac(body, secret), signature_v2: v2(body, secret, ts), timestamp: String(ts), secret } });
assert.strictEqual(ok.status, 200);
-- assert.match(ok.text, /Signature valid\./);
-- const bad = await t.get('/tools/webhooks/verify', { form: { body, signature: hmac(body, 'nope'), secret } });
++ assert.match(ok.text, /Accepted: v2 verifies and is inside the window\./);
++ assert.match(ok.text, /badge ok">inside</);
++ assert.match(ok.text, /v1 \(X-OpenVibe-Signature\) <span class="badge ok">valid/);
++ const stale = await t.get('/tools/webhooks/verify', { form: { body, signature: hmac(body, secret), signature_v2: v2(body, secret, ts - 600), secret } });
++ assert.strictEqual(stale.status, 422);
++ assert.match(stale.text, /Refused: a receiver that requires v2 rejects this delivery\./);
++ assert.match(stale.text, /badge warn">stale</);
++ assert.match(stale.text, /badge bad">outside</);
++ const v1Only = await t.get('/tools/webhooks/verify', { form: { body, signature: hmac(body, secret), secret } });
++ assert.strictEqual(v1Only.status, 422);
++ assert.match(v1Only.text, /no X-OpenVibe-Signature-V2 value given/);
++ const bad = await t.get('/tools/webhooks/verify', { form: { body, signature: hmac(body, 'nope'), signature_v2: v2(body, 'nope', ts), secret } });
assert.strictEqual(bad.status, 422);
-- assert.match(bad.text, /Signature NOT valid\./);
-- assert.ok(!ok.text.includes(secret) && !bad.text.includes(secret));
-- assert.match(ok.text, /<input type="password" name="secret" value=""/);
++ assert.match(bad.text, /v2 \(X-OpenVibe-Signature-V2\) <span class="badge bad">NOT valid/);
++ for (const r of [ok, stale, v1Only, bad]) {
++ assert.ok(!r.text.includes(secret));
++ assert.match(r.text, /<input type="password" name="secret" value=""/);
++ }
++ assert.ok(ok.text.includes(`value="${v2(body, secret, ts)}"`), 'the v2 header comes back in the form');
});
await check('no-JS path: sample generation for an event type from the contracts catalog', async () => {
const r = await t.get('/tools/webhooks/sample', { form: { event_type: 'network.credential.rotated', secret } });
assert.strictEqual(r.status, 200);
-- assert.match(r.text, /X-OpenVibe-Signature/);
++ assert.match(r.text, /X-OpenVibe-Signature-V2/);
++ assert.match(r.text, /X-OpenVibe-Timestamp/);
assert.match(r.text, /The envelope validates/);
const unknown = await t.get('/tools/webhooks/sample', { form: { event_type: 'made.up.event', secret } });
assert.strictEqual(unknown.status, 422);