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.
Changes
@@@ -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:4900npx 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")@@@ -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",@@@ -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"@@@ -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": {@@@ -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);})@@@ -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 = Datparsed = { 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.*/@@@ -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": <envelope>, "seq": <n>}</code> and these headers, signed with your subscription secret:</p><ul>--<li><code>X-OpenVibe-Signature: sha256=<hex HMAC-SHA256 of the raw body></code> (v1).</li><li><code>X-OpenVibe-Timestamp: <unix seconds></code>, the time this attempt was sent. Every retry gets a new one.</li><li><code>X-OpenVibe-Signature-V2: t=<that timestamp>,v2=<hex HMAC-SHA256 of "<t>.<raw body>"></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 || {};@@@ -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'));@@@ -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);@@@ -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));@@@ -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();})();@@@ -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).updconst 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).updassert.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).updassert.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).updlet 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).updassert.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).updassert.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();
@@ -32,7 +32,7 @@ A place where a developer outside the network goes from an OpenVibe account to a
@@ -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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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
@@ -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.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.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.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).
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
@@ -135,7 +135,7 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4900
npx openvibe-contracts-check --service codes --src server
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")
## Acceptance (must be true before "done")
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
"port": 4900,
"port": 4900,
"domain": "openvibe.codes",
"domain": "openvibe.codes",
"contracts": "openvibe-contracts v0.53.0",
"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.",
"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": [
"works": [
"Sign-in with OpenVibe.Network (authorization code + PKCE S256), session tokens in httpOnly cookies",
"Sign-in with OpenVibe.Network (authorization code + PKCE S256), session tokens in httpOnly cookies",
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"Scope editor offering only capabilities apps can be granted (public, or partner when in the allowance)",
"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",
"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",
"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",
"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",
"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",
"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 @@
@@ -47,7 +47,7 @@
"secrets shown once, never stored or logged",
"secrets shown once, never stored or logged",
"scope editor offering only grantable capabilities",
"scope editor offering only grantable capabilities",
"docs generated from the pinned contracts and SDK",
"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",
"grant-respecting Media and Events playgrounds",
"app and mod manifest validation and release metadata with codes.app.* events",
"app and mod manifest validation and release metadata with codes.app.* events",
"ADR-013 trust tiers",
"ADR-013 trust tiers",
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
},
"engines": {
"engines": {
@@ -1039,9 +1039,9 @@
@@ -1039,9 +1039,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
},
"engines": {
"engines": {
@@ -1,10 +1,10 @@
@@ -1,10 +1,10 @@
// OpenVibe.Codes — webhook signature checks 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,
// 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>">
// 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 () {
(function () {
'use strict';
'use strict';
var WINDOW_SEC = 300;
var WINDOW_SEC = 300;
@@ -53,7 +53,6 @@
@@ -53,7 +53,6 @@
form.addEventListener('submit', function (e) {
form.addEventListener('submit', function (e) {
e.preventDefault();
e.preventDefault();
var body = form.elements.body.value.replace(/\r\n/g, '\n');
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 given2 = form.elements.signature_v2.value.trim();
var stated = form.elements.timestamp.value.trim();
var stated = form.elements.timestamp.value.trim();
var secret = form.elements.secret.value;
var secret = form.elements.secret.value;
@@ -62,14 +61,9 @@
@@ -62,14 +61,9 @@
var nowSec = Date.now() / 1000;
var nowSec = Date.now() / 1000;
crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
.then(function (key) {
.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 lines = [];
var accepted = false, v2Line;
var accepted = false, v2Line;
if (!given2) {
if (!given2) {
@@ -77,7 +71,7 @@
@@ -77,7 +71,7 @@
} else if (!parsed) {
} else if (!parsed) {
v2Line = 'v2: malformed. It must look like t=<unix seconds>,v2=<64 lowercase hex characters>.';
v2Line = 'v2: malformed. It must look like t=<unix seconds>,v2=<64 lowercase hex characters>.';
} else {
} else {
- var hex2 = hex(sigs[1]);
+ var hex2 = hex(sig2);
var sigOk = parsed.values.some(function (v) { return equal(v, hex2); });
var sigOk = parsed.values.some(function (v) { return equal(v, hex2); });
var age = Math.floor(nowSec) - parsed.t;
var age = Math.floor(nowSec) - parsed.t;
var inside = Math.abs(nowSec - parsed.t) <= WINDOW_SEC;
var inside = Math.abs(nowSec - parsed.t) <= WINDOW_SEC;
@@ -90,7 +84,6 @@
@@ -90,7 +84,6 @@
}
}
lines.unshift(v2Line);
lines.unshift(v2Line);
lines.unshift(accepted ? 'Accepted: v2 verifies and is inside the window.' : 'Refused: a receiver that requires v2 rejects this delivery.');
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.');
lines.push('Computed in your browser; nothing was sent.');
show(accepted, lines);
show(accepted, lines);
})
})
@@ -3,20 +3,19 @@
@@ -3,20 +3,19 @@
/**
/**
* Webhook tester and signed-event inspector.
* 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-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).
* 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 crypto = require('crypto');
const contracts = require('openvibe-contracts');
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;
const MAX_BODY = 256 * 1024;
/** The replay window every Events consumer applies to v2 (openvibe-sdk's default). */
/** The replay window every Events consumer applies to v2 (openvibe-sdk's default). */
@@ -24,19 +23,6 @@ const V2_WINDOW_SEC = 300;
@@ -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. */
/** 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;
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. */
/** t and the v2 values from `t=<ts>,v2=<hex>[,v2=<hex>]`, parsed as the SDK does; null when malformed. */
function parseV2Header(header) {
function parseV2Header(header) {
let t = null;
let t = null;
@@ -90,19 +76,18 @@ function checkV2(body, given, stated, secret, now) {
@@ -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 })):
* 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).
* 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 body = typeof rawBody === 'string' ? rawBody : '';
const str = (v) => (typeof v === 'string' ? v.trim() : '');
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 (!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 (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' };
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);
const v2 = checkV2(body, str(signatureV2), str(timestamp), secret, now);
let parsed = null;
let parsed = null;
try {
try {
@@ -114,11 +99,12 @@ function inspect({ rawBody, signature, signatureV2, timestamp, secret, now = Dat
@@ -114,11 +99,12 @@ function inspect({ rawBody, signature, signatureV2, timestamp, secret, now = Dat
parsed = { note: 'JSON, but not an Events delivery ({ event, seq })' };
parsed = { note: 'JSON, but not an Events delivery ({ event, seq })' };
}
}
} catch { parsed = { note: 'not JSON' }; }
} 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,
* 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.
* so the payload is empty and the page says so rather than inventing fields.
*/
*/
@@ -97,17 +97,15 @@ ${got.code ? html`<h2>Exchange it (on your server)</h2>
@@ -97,17 +97,15 @@ ${got.code ? html`<h2>Exchange it (on your server)</h2>
body: html`<h1>Webhook tester and signed-event inspector</h1>
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": <envelope>, "seq": <n>}</code> and these headers, signed with your subscription secret:</p>
<p>OpenVibe.Events delivers each event as <code>POST</code> with the body <code>{"event": <envelope>, "seq": <n>}</code> and these headers, signed with your subscription secret:</p>
<ul>
<ul>
-<li><code>X-OpenVibe-Signature: sha256=<hex HMAC-SHA256 of the raw body></code> (v1).</li>
<li><code>X-OpenVibe-Timestamp: <unix seconds></code>, the time this attempt was sent. Every retry gets a new one.</li>
<li><code>X-OpenVibe-Timestamp: <unix seconds></code>, the time this attempt was sent. Every retry gets a new one.</li>
<li><code>X-OpenVibe-Signature-V2: t=<that timestamp>,v2=<hex HMAC-SHA256 of "<t>.<raw body>"></code> (v2).</li>
<li><code>X-OpenVibe-Signature-V2: t=<that timestamp>,v2=<hex HMAC-SHA256 of "<t>.<raw body>"></code> (v2).</li>
</ul>
</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>
<h2>Verify a delivery</h2>
<form method="post" action="/tools/webhooks/verify" class="stack" id="verify-form">
<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>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-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-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>
<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>
<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>
<button type="submit">Verify</button></form>
@@ -124,8 +122,8 @@ ${table(['Header', 'Value'], Object.entries(sample.headers).map(([k, v]) => [cod
@@ -124,8 +122,8 @@ ${table(['Header', 'Value'], Object.entries(sample.headers).map(([k, v]) => [cod
});
});
function verifyResult(v) {
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`;
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>
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>
<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
@@ -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>` : ''}
<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>` : ''}
${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>
<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>
<h3>Body</h3>
<dl class="facts"><dt>Size</dt><dd>${v.bodyBytes} bytes</dd>
<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.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>` : ''
@@ -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.get('/tools/webhooks', (req, res) => webhookPage(req, res));
r.post('/tools/webhooks/verify', limiter, webhookLimit, form, (req, res) => {
r.post('/tools/webhooks/verify', limiter, webhookLimit, form, (req, res) => {
const b = req.body || {};
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) => {
r.post('/tools/webhooks/sample', limiter, webhookLimit, form, (req, res) => {
const b = req.body || {};
const b = req.body || {};
@@ -22,7 +22,7 @@ const sdkPkg = require('openvibe-sdk/package.json');
@@ -22,7 +22,7 @@ const sdkPkg = require('openvibe-sdk/package.json');
assert.strictEqual(tag('openvibe-contracts'), 'v0.53.0');
assert.strictEqual(tag('openvibe-contracts'), 'v0.53.0');
assert.strictEqual(tag('openvibe-sdk'), `v${sdkPkg.version}`);
assert.strictEqual(tag('openvibe-sdk'), `v${sdkPkg.version}`);
assert.strictEqual(tag('openvibe-shared'), `v${require('openvibe-shared/package.json').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.
// 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.services.get('codes') && contracts.services.get('codes').status === 'alpha');
assert.ok(contracts.resolve('codes.app-manifest@1'));
assert.ok(contracts.resolve('codes.app-manifest@1'));
@@ -86,7 +86,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -86,7 +86,7 @@ const { boot, check, done } = require('./helpers/boot');
await check('webhook tester secrets are not echoed', async () => {
await check('webhook tester secrets are not echoed', async () => {
const hook = 'whsec_test_' + 'y'.repeat(40);
const hook = 'whsec_test_' + 'y'.repeat(40);
secrets.push(hook);
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);
assert.strictEqual(r1.status, 422);
const r2 = record(await t.get('/tools/webhooks/sample', { form: { event_type: 'network.app.created', secret: hook } }));
const r2 = record(await t.get('/tools/webhooks/sample', { form: { event_type: 'network.app.created', secret: hook } }));
assert.strictEqual(r2.status, 200);
assert.strictEqual(r2.status, 200);
@@ -43,7 +43,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
@@ -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('/manifests/validate', { form: { kind: 'app', manifest } });
await t.get(`${base}/redirects`, { as: dev, form: { redirect_uris: u } });
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/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)}`);
await t.get(`/tools/webhooks?url=${encodeURIComponent(u)}`);
}
}
const hit = outbound.filter((u) => u.includes(PROBE));
const hit = outbound.filter((u) => u.includes(PROBE));
@@ -1,8 +1,9 @@
@@ -1,8 +1,9 @@
'use strict';
'use strict';
/**
/**
* public/js/webhook.js (the in-browser verifier) decides as the server and openvibe-sdk do: run in a
* 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 assert = require('assert');
const crypto = require('crypto');
const crypto = require('crypto');
@@ -20,7 +21,13 @@ function runBrowser(fields, nowMs) {
@@ -20,7 +21,13 @@ function runBrowser(fields, nowMs) {
const out = el('div');
const out = el('div');
let submitHandler = null;
let submitHandler = null;
const form = {
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; },
addEventListener: (type, fn) => { if (type === 'submit') submitHandler = fn; },
submit: () => reject(new Error('fell back to the server')),
submit: () => reject(new Error('fell back to the server')),
};
};
@@ -46,9 +53,8 @@ function runBrowser(fields, nowMs) {
@@ -46,9 +53,8 @@ function runBrowser(fields, nowMs) {
const body = JSON.stringify({ event: { event_type: 'media.object.ready' }, seq: 3 });
const body = JSON.stringify({ event: { event_type: 'media.object.ready' }, seq: 3 });
const nowMs = 1_790_000_000_000;
const nowMs = 1_790_000_000_000;
const nowSec = nowMs / 1000;
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 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 () => {
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);
const r = await runBrowser(fields({ signature_v2: v2(nowSec), timestamp: String(nowSec) }), nowMs);
@@ -64,11 +70,10 @@ function runBrowser(fields, nowMs) {
@@ -64,11 +70,10 @@ function runBrowser(fields, nowMs) {
assert.match(r.text, /signature matches; t=\d+ is 301 s ago, OUTSIDE/);
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);
const wrong = await runBrowser(fields({ signature_v2: v2(nowSec, 'other') }), nowMs);
assert.strictEqual(wrong.ok, false);
assert.strictEqual(wrong.ok, false);
assert.match(wrong.text, /does NOT match/);
assert.match(wrong.text, /does NOT match/);
@@ -79,5 +84,13 @@ function runBrowser(fields, nowMs) {
@@ -79,5 +84,13 @@ function runBrowser(fields, nowMs) {
assert.match(malformed.text, /malformed/);
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();
done();
})();
})();
@@ -1,19 +1,21 @@
@@ -1,19 +1,21 @@
'use strict';
'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 assert = require('assert');
const crypto = require('crypto');
const crypto = require('crypto');
const contracts = require('openvibe-contracts');
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 webhooks = require('../server/domain/webhooks');
const { boot, check, done } = require('./helpers/boot');
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 () => {
(async () => {
const secret = 'whsec_' + crypto.randomBytes(16).toString('hex');
const secret = 'whsec_' + crypto.randomBytes(16).toString('hex');
@@ -24,32 +26,8 @@ const hmac = (body, secret) => `sha256=${crypto.createHmac('sha256', secret).upd
@@ -24,32 +26,8 @@ const hmac = (body, secret) => `sha256=${crypto.createHmac('sha256', secret).upd
const nowMs = nowSec * 1000 + 700;
const nowMs = nowSec * 1000 + 700;
const v2 = (b, sec, ts) => `t=${ts},v2=${crypto.createHmac('sha256', sec).update(`${ts}.${b}`).digest('hex')}`;
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 () => {
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.accepted, true);
assert.strictEqual(v.v2.valid, true);
assert.strictEqual(v.v2.valid, true);
assert.strictEqual(v.v2.signatureValid, true);
assert.strictEqual(v.v2.signatureValid, true);
@@ -58,7 +36,21 @@ const hmac = (body, secret) => `sha256=${crypto.createHmac('sha256', secret).upd
@@ -58,7 +36,21 @@ const hmac = (body, secret) => `sha256=${crypto.createHmac('sha256', secret).upd
assert.strictEqual(v.v2.timestamp, nowSec);
assert.strictEqual(v.v2.timestamp, nowSec);
assert.strictEqual(v.v2.expected, v2(body, secret, nowSec));
assert.strictEqual(v.v2.expected, v2(body, secret, nowSec));
assert.strictEqual(v.v2.windowSec, 300);
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 () => {
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
@@ -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=/);
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.accepted, false);
+ assert.strictEqual(v1Only.v2.present, false);
assert.match(v1Only.v2.reason, /no X-OpenVibe-Signature-V2/);
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 () => {
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
@@ -124,20 +117,21 @@ const hmac = (body, secret) => `sha256=${crypto.createHmac('sha256', secret).upd
let calls = 0;
let calls = 0;
crypto.timingSafeEqual = (a, b) => { calls++; assert.strictEqual(a.length, b.length); return orig(a, b); };
crypto.timingSafeEqual = (a, b) => { calls++; assert.strictEqual(a.length, b.length); return orig(a, b); };
try {
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;
calls = 0;
webhooks.inspect({ rawBody: body, signatureV2: v2(body, 'other', nowSec), secret, now: nowMs });
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; }
} 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 });
const s = webhooks.sample({ eventType: 'media.object.ready', secret, producer: 'media', now: nowMs });
assert.strictEqual(s.envelopeValid, true, JSON.stringify(s.envelopeErrors));
assert.strictEqual(s.envelopeValid, true, JSON.stringify(s.envelopeErrors));
const headers = Object.fromEntries(Object.entries(s.headers).map(([k, v]) => [k.toLowerCase(), v]));
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-timestamp'], String(nowSec));
assert.strictEqual(headers['x-openvibe-signature-v2'], v2(s.body, secret, nowSec));
assert.strictEqual(headers['x-openvibe-signature-v2'], v2(s.body, secret, nowSec));
assert.strictEqual(verifyDeliveryV2(s.body, headers, secret, { now: nowMs }), true);
assert.strictEqual(verifyDeliveryV2(s.body, headers, secret, { now: nowMs }), true);
@@ -146,31 +140,34 @@ const hmac = (body, secret) => `sha256=${crypto.createHmac('sha256', secret).upd
@@ -146,31 +140,34 @@ const hmac = (body, secret) => `sha256=${crypto.createHmac('sha256', secret).upd
assert.strictEqual(parsed.seq, 1);
assert.strictEqual(parsed.seq, 1);
assert.strictEqual(contracts.validate('events.event-envelope@1', parsed.event).valid, true);
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(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');
assert.deepStrictEqual(JSON.parse(s.body).event.payload, {}, 'no invented payload');
// What the tester says about its own sample.
// 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);
assert.strictEqual(v.accepted, true);
});
});
const t = await boot();
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 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.strictEqual(ok.status, 200);
assert.match(ok.text, /Accepted: v2 verifies and is inside the window\./);
assert.match(ok.text, /Accepted: v2 verifies and is inside the window\./);
assert.match(ok.text, /badge ok">inside</);
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.strictEqual(stale.status, 422);
assert.match(stale.text, /Refused: a receiver that requires v2 rejects this delivery\./);
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 warn">stale</);
assert.match(stale.text, /badge bad">outside</);
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.strictEqual(v1Only.status, 422);
assert.match(v1Only.text, /no X-OpenVibe-Signature-V2 value given/);
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.strictEqual(bad.status, 422);
assert.match(bad.text, /v2 \(X-OpenVibe-Signature-V2\) <span class="badge bad">NOT valid/);
assert.match(bad.text, /v2 \(X-OpenVibe-Signature-V2\) <span class="badge bad">NOT valid/);
for (const r of [ok, stale, v1Only, bad]) {
for (const r of [ok, stale, v1Only, bad]) {
@@ -180,16 +177,34 @@ const hmac = (body, secret) => `sha256=${crypto.createHmac('sha256', secret).upd
@@ -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');
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 } });
const r = await t.get('/tools/webhooks/sample', { form: { event_type: 'network.credential.rotated', secret } });
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.match(r.text, /X-OpenVibe-Signature-V2/);
assert.match(r.text, /X-OpenVibe-Signature-V2/);
assert.match(r.text, /X-OpenVibe-Timestamp/);
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/);
assert.match(r.text, /The envelope validates/);
const unknown = await t.get('/tools/webhooks/sample', { form: { event_type: 'made.up.event', secret } });
const unknown = await t.get('/tools/webhooks/sample', { form: { event_type: 'made.up.event', secret } });
assert.strictEqual(unknown.status, 422);
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 () => {
await check('the page offers only event types some service manifest produces', async () => {
const r = await t.get('/tools/webhooks');
const r = await t.get('/tools/webhooks');
const offered = [...r.text.matchAll(/<option(?: selected)?>([^<]+)<\/option>/g)].map((m) => m[1]).sort();
const offered = [...r.text.matchAll(/<option(?: selected)?>([^<]+)<\/option>/g)].map((m) => m[1]).sort();