openvibers/openvibe.codes Commits

The webhook tester checks signature v2 only (compatibility shim C-61). OpenVibe.Events stopped sending the v1 header (X-OpenVibe-Signature, an HMAC of the body alone that verifies forever) on 2026-09-28 (shim C-60) and every consumer requires v2, so /tools/webhooks no longer takes or shows v1 "for reference". webhooks.inspect() drops checkV1 and the signature input and returns { accepted, v2, bodyBytes, parsed } (or { accepted: false, reason } for unusable input); a delivery with only a v1 value is refused as having no X-OpenVibe-Signature-V2. The page lists only X-OpenVibe-Timestamp and X-OpenVibe-Signature-V2, keeps why v2 (it covers the time; ±300 s replay window), says v1 is retired, and has no v1 field or result block; verifyResult keys on reason or a missing v2 instead of v1. The in-browser check (public/js/webhook.js) computes and reads no v1; its v2 logic is unchanged. openvibe-sdk is pinned at v0.13.0, whose signDeliveryHeaders is v2 only, so generated samples and their curl replay carry no X-OpenVibe-Signature. README and STATUS.json describe the tester as v2 only and name sdk v0.13.0. Tests: the v1 cases are gone; new checks that inspect() has no v1 in its result, that a v1-only request is refused for want of v2 (also by the SDK receiver), that samples carry no X-OpenVibe-Signature, that the page has no v1 input, and that the browser script reads no v1 field; the secrets and SSRF probes post the v2 field. npm test (Node 22) 29/29.

OP OpenVibers committed 28 Sept 2026 · 64d8feb1ac · 12 files, +133 −131


Changes

ddiff --git a/README.md b/README.md
iindex e3d1f01..d9044b3 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; **usage** (owner, admins and staff: `/projects/:project/usage?days=7|30|90&env=all|production|sandbox`, Network's `GET /api/v1/projects/:project/usage` rendered server-side: usage by service, capability and day, each recorded quota's headroom as a `<meter>`, the published limits of the services used (their `/limits.json`), errors by code and the sampled failures with their trace ids and job or event ids; the numbers come from the services' hourly `*.usage.recorded` rollups, which Network adds up per day, so the current hour is not counted yet); audit (admin+); **export** a project's metadata as one JSON document (`/projects/:project/export`: Network's project, members, apps with credential ids and hints, grants, quotas and, for admin+, audit, plus Codes' releases with manifests and logs, trust tiers and playground runs; never a secret); owners and admins also download the **full archive** (`POST /projects/:project/export/archive`, one zip: that document with the audit log, the release manifests, the project's Media objects with download URLs and its namespaces, and its retained app events, for production and sandbox; read with Network's read-only export tokens; all or nothing when a service fails; [format](https://openvibe.codes/docs/export)) and **delete** a project (owner, confirmed by name: Network archives it — it offers archiving, not erasure — then Codes deletes drafts, their manifests and playground runs, and revokes published releases so installers are told). 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 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. |
++| 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`. v2 only: the v1 header (`X-OpenVibe-Signature`) was retired on 2026-09-28 and has no field. In the browser with Web Crypto, or on the server without JavaScript with openvibe-sdk's `verifyDeliveryV2`; constant-time; secret dropped. Generate a sample delivery signed as Events signs it (`X-OpenVibe-Timestamp` 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`); the project archive pulls the project's app events with a Network export token (`events.app.read`).
- **OpenVibe.Media** — the Media playground uploads with the app's token into the project's namespace; the project archive lists the project's objects, namespaces and download URLs with a Network export token (`media.object.list`, `media.object.read`).
--- **openvibe-contracts** v0.53.0, **openvibe-sdk v0.12.0**, **openvibe-shared v1.22.0** (pinned tag tarballs; the docs show the tag and the package version, and say so when they differ).
++- **openvibe-contracts** v0.53.0, **openvibe-sdk v0.13.0**, **openvibe-shared v1.22.0** (pinned tag tarballs; the docs show the tag and the package version, and say so when they differ).
No path in Codes sends or accepts a shared loopback key (tested by grep and at runtime).
@@@ -135,7 +135,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, 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; the usage page (owner, admins and staff only, Network not asked for anyone else, filters forwarded as offered, a Network failure shown as it answered); project export (complete, no secrets, audit for admin+ only, never partial) and delete (owner only, Network first, drafts and runs removed, public releases revoked with an event); 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, v2 only (tampering, lengths, malformed headers, the ±300 s window both ways, timestamp header mismatch, a v1-only delivery refused, samples without v1, constant-time, the in-browser verifier agreeing); playgrounds refuse without the grant and call nothing; the usage page (owner, admins and staff only, Network not asked for anyone else, filters forwarded as offered, a Network failure shown as it answered); project export (complete, no secrets, audit for admin+ only, never partial) and delete (owner only, Network first, drafts and runs removed, public releases revoked with an event); 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 f948cd1..7955b32 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -8,7 +8,7 @@
"port": 4900,
"domain": "openvibe.codes",
"contracts": "openvibe-contracts v0.53.0",
-- "packages": ["openvibe-contracts v0.53.0", "openvibe-sdk v0.12.0", "openvibe-shared v1.22.0"],
++ "packages": ["openvibe-contracts v0.53.0", "openvibe-sdk v0.13.0", "openvibe-shared v1.22.0"],
"summary": "Deployed and public since 2026-09-23: https://openvibe.codes is served by this portal (deployed with `sudo ovhost deploy codes`, unit openvibe-codes on 127.0.0.1:4900 behind nginx), 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. On 2026-09-23 production held 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.",
"works": [
"Sign-in with OpenVibe.Network (authorization code + PKCE S256), session tokens in httpOnly cookies",
@@@ -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 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",
++ "Webhook tester for signature v2 (server or in-browser): decides as a receiver requiring v2 does and shows the timestamp's age and the ±300 s window; v1 was retired on 2026-09-28 and has no field; signed sample deliveries carry X-OpenVibe-Timestamp and v2 only",
"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; governance pages (code of conduct, contributing, contributor ladder, moderation policy) rendered from the repository's Markdown",
@@@ -47,7 +47,7 @@
"secrets shown once, never stored or logged",
"scope editor offering only grantable capabilities",
"docs generated from the pinned contracts and SDK",
-- "OAuth helper and webhook tester (signature v1 and v2)",
++ "OAuth helper and webhook tester (signature v2)",
"grant-respecting Media and Events playgrounds",
"app and mod manifest validation and release metadata with codes.app.* events",
"ADR-013 trust tiers",
ddiff --git a/package-lock.json b/package-lock.json
iindex 733d11e..a5b5700 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.53.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.13.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
"engines": {
@@@ -1039,9 +1039,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.12.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
-- "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",
++ "version": "0.13.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.13.0",
++ "integrity": "sha512-RvTYWvkHjgOt7kOZwEDYjReb5MoJcpEenVAykM2S1/GeDqgKUMQrsSINvF4TM3HBwNHxlevO1kXzVMob2o2igg==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex 7ab1078..f23b9f6 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.53.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.13.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
"engines": {
ddiff --git a/public/js/webhook.js b/public/js/webhook.js
iindex 98ae654..8e9fa09 100644
---- a/public/js/webhook.js
++++ b/public/js/webhook.js
@@@ -1,10 +1,10 @@
// 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 HMACs with openvibe-sdk and forgets the secret.
++// which computes the same HMAC 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.
++// A receiver requiring v2 accepts only a matching v2 within ±300 s of its clock. (The v1 header,
++// an HMAC of the body alone, was retired on 2026-09-28.)
(function () {
'use strict';
var WINDOW_SEC = 300;
@@@ -53,7 +53,6 @@
form.addEventListener('submit', function (e) {
e.preventDefault();
var body = form.elements.body.value.replace(/\r\n/g, '\n');
-- 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;
@@@ -62,14 +61,9 @@
var nowSec = Date.now() / 1000;
crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
.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,
-- ]);
++ return 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);
++ .then(function (sig2) {
var lines = [];
var accepted = false, v2Line;
if (!given2) {
@@@ -77,7 +71,7 @@
} 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 hex2 = hex(sig2);
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;
@@@ -90,7 +84,6 @@
}
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);
})
ddiff --git a/server/domain/webhooks.js b/server/domain/webhooks.js
iindex ce79361..42f53f3 100644
---- a/server/domain/webhooks.js
++++ b/server/domain/webhooks.js
@@@ -3,20 +3,19 @@
/**
* Webhook tester and signed-event inspector.
*
-- * 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)
++ * OpenVibe.Events delivers `{ "event": <events.event-envelope@1>, "seq": <n> }` signed under the
++ * subscription secret:
* 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)
++ * X-OpenVibe-Signature-V2: t=<that timestamp>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">
* 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.
++ * The v1 header (X-OpenVibe-Signature, an HMAC of the body alone) was retired on 2026-09-28.
++ * Verification here uses openvibe-sdk's own 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, signDeliveryV2, verifyDeliveryV2, signDeliveryHeaders } = require('openvibe-sdk/events');
++const { 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). */
@@@ -24,19 +23,6 @@ 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;
--function checkV1(body, given, secret) {
-- const expected = signDelivery(body, secret);
-- const valid = verifyDelivery(body, given, secret);
-- let reason = null;
-- if (!valid) {
-- if (!given) reason = 'no X-OpenVibe-Signature value given';
-- else if (!given.startsWith('sha256=')) reason = 'the header value must start with sha256=';
-- 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;
@@@ -90,19 +76,18 @@ function checkV2(body, given, stated, secret, now) {
}
/**
-- * → { accepted, v1, v2, bodyBytes, parsed } or { accepted: false, reason } for unusable input.
++ * → { accepted, 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
++ * v2 must verify and be within the window.
++ * The 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() }) {
++function inspect({ rawBody, 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 {
@@@ -114,11 +99,12 @@ function inspect({ rawBody, signature, signatureV2, timestamp, secret, now = Dat
parsed = { note: 'JSON, but not an Events delivery ({ event, seq })' };
}
} catch { parsed = { note: 'not JSON' }; }
-- return { accepted: v2.valid, v1, v2, bodyBytes: Buffer.byteLength(body), parsed };
++ return { accepted: v2.valid, v2, bodyBytes: Buffer.byteLength(body), parsed };
}
/**
-- * A sample delivery for an event type from the contracts catalog, signed with the given secret.
++ * A sample delivery for an event type from the contracts catalog, signed with the given secret
++ * (v2 only, as Events sends it: openvibe-sdk's signDeliveryHeaders).
* The envelope is valid events.event-envelope@1. Contracts define no per-event payload schemas yet,
* so the payload is empty and the page says so rather than inventing fields.
*/
ddiff --git a/server/http/tools.js b/server/http/tools.js
iindex 6821405..fffb722 100644
---- a/server/http/tools.js
++++ b/server/http/tools.js
@@@ -97,17 +97,15 @@ ${got.code ? html`<h2>Exchange it (on your server)</h2>
body: html`<h1>Webhook tester and signed-event inspector</h1>
<p>OpenVibe.Events delivers each event as <code>POST</code> with the body <code>{"event": &lt;envelope&gt;, "seq": &lt;n&gt;}</code> and these headers, signed with your subscription secret:</p>
<ul>
--<li><code>X-OpenVibe-Signature: sha256=&lt;hex HMAC-SHA256 of the raw body&gt;</code> (v1).</li>
<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; <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>
++<p>Verify against the <strong>raw bytes</strong>, before parsing, with a constant-time comparison. v2 covers the time as well as the body, so a captured delivery cannot be replayed later: refuse it when <code>t</code> is more than 300 seconds from your clock, in either direction, and reject a delivery whose v2 header is missing, does not verify or is stale. The v1 header (<code>X-OpenVibe-Signature</code>, an HMAC of the body alone, which verifies forever) was retired on 2026-09-28: Events no longer sends it. <code>parseDelivery(raw, headers, secret, { requireV2: true })</code> in <a href="/docs/sdk/events"><code>openvibe-sdk/events</code></a> does all of this; <code>verifyDeliveryV2()</code> checks v2 and the window. The tester below checks v2 the same way, says whether the timestamp is inside the window now, and decides as a receiver that requires v2 does.</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-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>
@@@ -124,8 +122,8 @@ ${table(['Header', 'Value'], Object.entries(sample.headers).map(([k, v]) => [cod
});
function verifyResult(v) {
-- if (!v.v1) return html`<div class="result bad" role="status"><p><strong>Cannot check.</strong> ${v.reason || ''}</p></div>`;
-- const { v1, v2 } = v;
++ if (v.reason || !v.v2) return html`<div class="result bad" role="status"><p><strong>Cannot check.</strong> ${v.reason || ''}</p></div>`;
++ const { 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>
@@@ -134,9 +132,6 @@ ${table(['Header', 'Value'], Object.entries(sample.headers).map(([k, v]) => [cod
<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>` : ''}
@@@ -146,8 +141,8 @@ ${v.parsed && v.parsed.note ? html`<dt>Shape</dt><dd>${v.parsed.note}</dd>` : ''
r.get('/tools/webhooks', (req, res) => webhookPage(req, res));
r.post('/tools/webhooks/verify', limiter, webhookLimit, 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, 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 } });
++ const v = webhooks.inspect({ rawBody: typeof b.body === 'string' ? b.body.replace(/\r\n/g, '\n') : '', 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_v2: b.signature_v2, timestamp: b.timestamp } });
});
r.post('/tools/webhooks/sample', limiter, webhookLimit, form, (req, res) => {
const b = req.body || {};
ddiff --git a/test/docs.test.js b/test/docs.test.js
iindex 03f008b..52a7e2b 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.53.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.12.0');
++ assert.strictEqual(sdkPkg.version, '0.13.0');
// The contracts tag v0.53.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/secrets.test.js b/test/secrets.test.js
iindex e6e3bea..4acce3c 100644
---- a/test/secrets.test.js
++++ b/test/secrets.test.js
@@@ -86,7 +86,7 @@ const { boot, check, done } = require('./helpers/boot');
await check('webhook tester secrets are not echoed', async () => {
const hook = 'whsec_test_' + 'y'.repeat(40);
secrets.push(hook);
-- const r1 = record(await t.get('/tools/webhooks/verify', { form: { body: '{"event":{},"seq":1}', signature: 'sha256=00', secret: hook } }));
++ const r1 = record(await t.get('/tools/webhooks/verify', { form: { body: '{"event":{},"seq":1}', signature_v2: 't=1,v2=00', secret: hook } }));
assert.strictEqual(r1.status, 422);
const r2 = record(await t.get('/tools/webhooks/sample', { form: { event_type: 'network.app.created', secret: hook } }));
assert.strictEqual(r2.status, 200);
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 854aa75..c43dd73 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -43,7 +43,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
await t.get('/manifests/validate', { form: { kind: 'app', manifest } });
await t.get(`${base}/redirects`, { as: dev, form: { redirect_uris: u } });
await t.get('/tools/webhooks/sample', { form: { url: u, endpoint: u, secret: 'sample-not-a-secret', event_type: 'codes.app.published' } });
-- await t.get('/tools/webhooks/verify', { form: { url: u, endpoint: u, secret: 'sample-not-a-secret', body: '{}', signature: 't=1,v1=00' } });
++ await t.get('/tools/webhooks/verify', { form: { url: u, endpoint: u, secret: 'sample-not-a-secret', body: '{}', signature_v2: 't=1,v2=00' } });
await t.get(`/tools/webhooks?url=${encodeURIComponent(u)}`);
}
const hit = outbound.filter((u) => u.includes(PROBE));
ddiff --git a/test/webhook-browser.test.js b/test/webhook-browser.test.js
iindex be8e9ce..7f0f21c 100644
---- a/test/webhook-browser.test.js
++++ b/test/webhook-browser.test.js
@@@ -1,8 +1,9 @@
'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.
++ * vm with Node's Web Crypto and a minimal stand-in for the form, it accepts a fresh v2 and refuses a
++ * stale, wrong or missing v2. It checks v2 only: the retired v1 header (2026-09-28, shim C-61) has
++ * no field and is neither read nor computed.
*/
const assert = require('assert');
const crypto = require('crypto');
@@@ -20,7 +21,13 @@ function runBrowser(fields, nowMs) {
const out = el('div');
let submitHandler = null;
const form = {
-- elements: Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, { value: v }])),
++ // A Proxy, so reading a field the page does not have (the retired v1 one) fails the test.
++ elements: new Proxy(Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, { value: v }])), {
++ get(target, k) {
++ if (typeof k === 'string' && !(k in target)) throw new Error(`the script read form field ${k}`);
++ return target[k];
++ },
++ }),
addEventListener: (type, fn) => { if (type === 'submit') submitHandler = fn; },
submit: () => reject(new Error('fell back to the server')),
};
@@@ -46,9 +53,8 @@ function runBrowser(fields, nowMs) {
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 });
++ const fields = (o) => ({ body, 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);
@@@ -64,11 +70,10 @@ function runBrowser(fields, nowMs) {
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/);
++ await check('no v2 is refused; a wrong v2 is refused; a differing X-OpenVibe-Timestamp is refused', async () => {
++ const noV2 = await runBrowser(fields({}), nowMs);
++ assert.strictEqual(noV2.ok, false);
++ assert.match(noV2.text, /v2: not given/);
const wrong = await runBrowser(fields({ signature_v2: v2(nowSec, 'other') }), nowMs);
assert.strictEqual(wrong.ok, false);
assert.match(wrong.text, /does NOT match/);
@@@ -79,5 +84,13 @@ function runBrowser(fields, nowMs) {
assert.match(malformed.text, /malformed/);
});
++ await check('v2 only: the result has no v1 line and the script reads no v1 field', async () => {
++ const r = await runBrowser(fields({ signature_v2: v2(nowSec) }), nowMs);
++ assert.strictEqual(r.ok, true, r.text);
++ assert.doesNotMatch(r.text, /v1|sha256=/);
++ assert.doesNotMatch(SRC, /elements\.signature\b(?!_v2)/, 'no v1 field is read');
++ assert.doesNotMatch(SRC, /sha256=/, 'no v1 value is computed');
++ });
++
done();
})();
ddiff --git a/test/webhooks.test.js b/test/webhooks.test.js
iindex d319cb7..c300c6d 100644
---- a/test/webhooks.test.js
++++ b/test/webhooks.test.js
@@@ -1,19 +1,21 @@
'use strict';
/**
-- * 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.
++ * Webhook signature verification, v2 only (v1 was retired on 2026-09-28, shim C-61): correct against
++ * independent HMACs, rejects tampering, wrong secrets, lengths and malformed headers, applies the
++ * ±300 s window exactly as openvibe-sdk's parseDelivery(..., { requireV2: true }) does, refuses a
++ * delivery that carries only the retired v1 header, compares in constant time
++ * (crypto.timingSafeEqual), and sample deliveries carry v2 only, which the SDK receiver accepts.
++ * The no-JS path works through the server and offers no v1 field.
*/
const assert = require('assert');
const crypto = require('crypto');
const contracts = require('openvibe-contracts');
--const { verifyDelivery, verifyDeliveryV2, parseDelivery } = require('openvibe-sdk/events');
++const { verifyDeliveryV2, parseDelivery } = require('openvibe-sdk/events');
const webhooks = require('../server/domain/webhooks');
const { boot, check, done } = require('./helpers/boot');
--const hmac = (body, secret) => `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`;
++/** A value in the retired v1 format (sha256=<HMAC of the body alone>): the tester must ignore it. */
++const hmacV1 = (body, secret) => `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`;
(async () => {
const secret = 'whsec_' + crypto.randomBytes(16).toString('hex');
@@@ -24,32 +26,8 @@ const hmac = (body, secret) => `sha256=${crypto.createHmac('sha256', secret).upd
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.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('v1: tampered body, wrong secret, missing prefix, wrong length, uppercase hex: all rejected', async () => {
-- const good = hmac(body, 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 = v1({ signature: good.slice(0, -2) });
-- assert.strictEqual(short.valid, false);
-- assert.match(short.reason, /wrong length/);
-- 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(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 });
++ const v = webhooks.inspect({ rawBody: body, 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);
@@@ -58,7 +36,21 @@ const hmac = (body, secret) => `sha256=${crypto.createHmac('sha256', secret).upd
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');
++ assert.strictEqual(v.parsed.event_type, 'network.app.created');
++ assert.strictEqual(v.parsed.seq, 7);
++ assert.strictEqual(v.bodyBytes, Buffer.byteLength(body));
++ });
++
++ await check('the result is v2 only: { accepted, v2, bodyBytes, parsed }, with no v1 anywhere', async () => {
++ const v = webhooks.inspect({ rawBody: body, signature: hmacV1(body, secret), signatureV2: v2(body, secret, nowSec), secret, now: nowMs });
++ assert.deepStrictEqual(Object.keys(v).sort(), ['accepted', 'bodyBytes', 'parsed', 'v2']);
++ assert.strictEqual(v.v1, undefined);
++ assert.ok(!JSON.stringify(v).includes('sha256='), 'no v1 value is computed or echoed');
++ assert.ok(!/v1/i.test(Object.keys(v.v2).join(',')), 'the v2 check has no v1 fields');
++ const unusable = webhooks.inspect({ rawBody: '', signatureV2: v2(body, secret, nowSec), secret });
++ assert.deepStrictEqual(Object.keys(unusable).sort(), ['accepted', 'reason']);
++ assert.strictEqual(unusable.accepted, false);
++ assert.deepStrictEqual(Object.keys(webhooks.inspect({ rawBody: body, secret: '' })).sort(), ['accepted', 'reason']);
});
await check('v2: the ±300 s window, both directions, with the signature told apart from the time', async () => {
@@@ -108,15 +100,16 @@ const hmac = (body, secret) => `sha256=${crypto.createHmac('sha256', secret).upd
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);
++ await check('a delivery with only the retired v1 header is refused as having no X-OpenVibe-Signature-V2', async () => {
++ const v1Only = webhooks.inspect({ rawBody: body, signature: hmacV1(body, secret), secret, now: nowMs });
assert.strictEqual(v1Only.accepted, false);
++ assert.strictEqual(v1Only.v2.present, 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);
++ assert.strictEqual(v1Only.v1, undefined);
++ // The SDK receiver every consumer runs refuses it too.
++ assert.strictEqual(parseDelivery(body, { 'x-openvibe-signature': hmacV1(body, secret) }, secret, { requireV2: true, now: nowMs }), null);
++ // A stale v2 stays refused whatever else the delivery carries.
++ assert.strictEqual(webhooks.inspect({ rawBody: body, signature: hmacV1(body, secret), signatureV2: v2(body, secret, nowSec - 3600), secret, now: nowMs }).accepted, false);
});
await check('the comparisons are constant-time (crypto.timingSafeEqual on equal-length buffers)', async () => {
@@@ -124,20 +117,21 @@ const hmac = (body, secret) => `sha256=${crypto.createHmac('sha256', secret).upd
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 });
-- webhooks.inspect({ rawBody: body, signature: hmac(body, 'other'), secret });
-- assert.strictEqual(calls, 2, 'every equal-length v1 comparison goes through timingSafeEqual');
++ webhooks.inspect({ rawBody: body, signatureV2: v2(body, secret, nowSec), secret, now: nowMs });
++ assert.strictEqual(calls, 2, 'the signature alone and the receiver check both go 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');
++ assert.strictEqual(calls, 2, 'a wrong v2 is compared in constant time too');
} finally { crypto.timingSafeEqual = orig; }
});
-- await check('a generated sample carries v1, X-OpenVibe-Timestamp and v2, and the SDK receiver requiring v2 accepts it', async () => {
++ await check('a generated sample carries X-OpenVibe-Timestamp and v2 only (no X-OpenVibe-Signature), 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);
++ assert.ok(!('x-openvibe-signature' in headers), 'no v1 header');
++ assert.deepStrictEqual(Object.keys(headers).filter((k) => /signature/.test(k)), ['x-openvibe-signature-v2']);
++ assert.ok(!Object.values(headers).some((v) => String(v).startsWith('sha256=')), 'no v1 value under any name');
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);
@@@ -146,31 +140,34 @@ const hmac = (body, secret) => `sha256=${crypto.createHmac('sha256', secret).upd
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.strictEqual(parseDelivery(s.body, headers, 'wrong', { requireV2: true, now: nowMs }), null);
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 });
++ const v = webhooks.inspect({ rawBody: s.body, 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 checks v2 and v1, shows the window, and says why a delivery is refused', async () => {
++ await check('no-JS path: the server checks v2, 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 } });
++ const ok = await t.get('/tools/webhooks/verify', { form: { body, signature_v2: v2(body, secret, ts), timestamp: String(ts), secret } });
assert.strictEqual(ok.status, 200);
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.doesNotMatch(ok.text, /<h3>v1/);
++ const stale = await t.get('/tools/webhooks/verify', { form: { body, 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 } });
++ // A request carrying only a v1 value (the old field) is refused for want of v2, and the v1 value is not echoed.
++ const v1Only = await t.get('/tools/webhooks/verify', { form: { body, signature: hmacV1(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.ok(!v1Only.text.includes(hmacV1(body, secret)));
++ assert.doesNotMatch(v1Only.text, /sha256=[0-9a-f]{64}/);
++ const bad = await t.get('/tools/webhooks/verify', { form: { body, signature_v2: v2(body, 'nope', ts), secret } });
assert.strictEqual(bad.status, 422);
assert.match(bad.text, /v2 \(X-OpenVibe-Signature-V2\) <span class="badge bad">NOT valid/);
for (const r of [ok, stale, v1Only, bad]) {
@@@ -180,16 +177,34 @@ const hmac = (body, secret) => `sha256=${crypto.createHmac('sha256', secret).upd
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 () => {
++ await check('no-JS path: sample generation for an event type from the contracts catalog, v2 only', 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-V2/);
assert.match(r.text, /X-OpenVibe-Timestamp/);
++ assert.match(r.text, /<td><code>X-OpenVibe-Signature-V2<\/code><\/td>/);
++ assert.doesNotMatch(r.text, /<td><code>X-OpenVibe-Signature<\/code><\/td>/, 'no v1 header row in the sample');
++ assert.match(r.text, /X-OpenVibe-Signature-V2: t=\d+,v2=[0-9a-f]{64}/, 'the curl replay carries v2');
++ assert.doesNotMatch(r.text, /X-OpenVibe-Signature: /, 'no v1 header in the curl replay');
++ assert.doesNotMatch(r.text, /sha256=[0-9a-f]{64}/);
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);
});
++ await check('the page has no v1 input and lists only the timestamp and v2 headers', async () => {
++ const r = await t.get('/tools/webhooks');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /<input name="signature_v2"/);
++ assert.match(r.text, /<input name="timestamp"/);
++ assert.doesNotMatch(r.text, /name="signature"/, 'no v1 field');
++ assert.doesNotMatch(r.text, /placeholder="sha256=/);
++ const headerList = r.text.slice(r.text.indexOf('<ul>'), r.text.indexOf('</ul>'));
++ assert.deepStrictEqual([...headerList.matchAll(/<code>(X-OpenVibe-[A-Za-z0-9-]+):/g)].map((m) => m[1]), ['X-OpenVibe-Timestamp', 'X-OpenVibe-Signature-V2']);
++ assert.match(r.text, /retired on 2026-09-28/);
++ assert.match(r.text, /300 seconds/);
++ });
++
await check('the page offers only event types some service manifest produces', async () => {
const r = await t.get('/tools/webhooks');
const offered = [...r.text.matchAll(/<option(?: selected)?>([^<]+)<\/option>/g)].map((m) => m[1]).sort();