openvibers/openvibe.vip Commits
Events webhook requires signature v2 (replay window); openvibe-sdk v0.4.0
Changes
@@@ -15,7 +15,7 @@"express": "^4.21.2","jsonwebtoken": "^9.0.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"},"engines": {@@@ -999,9 +999,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.2.2",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",-- "integrity": "sha512-kDifUp3EcmnTWzElAmaCZnX659WErcnm4+qVEoAiYQWX/w/1p4TWvoHbUPsrX65TSROVR3tiyVkl801n/0yyCw==",++ "version": "0.4.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",++ "integrity": "sha512-Hyz55YOMIs0xwCQ1NXsN0sUYhlbKXAX3uWY2fPCdEEL18FXUYVhK8wlG/5JMMypFHnjDiSd9VHzBNG12hWsmkQ==","license": "MIT","engines": {"node": ">=20"@@@ -27,7 +27,7 @@"express": "^4.21.2","jsonwebtoken": "^9.0.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"},"engines": {@@@ -77,7 +77,8 @@ function consumerRouter({ domain, config, log = console }) {if (!secrets.length) return http.sendProblem(res, 503, 'vip.webhook_disabled', { detail: 'VIP_EVENTS_SECRET is not set', ctx: req.ov });const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);let delivery = null;-- for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s); if (delivery) break; }++ // Signature v2 only: HMAC over "<t>.<raw body>" with t within ±300 s; a v1-only (v2 stripped) or stale delivery is refused.++ for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s, { requireV2: true }); if (delivery) break; }if (!delivery) return http.sendProblem(res, 401, 'vip.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx: req.ov });const event = delivery.event;if (!event || typeof event.event_id !== 'string' || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(event.event_id)) {@@@ -5,7 +5,7 @@* valid_until (+ grace); ordering and duplicates are safe. Injected clock throughout.*/const assert = require('assert');--const { signDelivery } = require('openvibe-sdk/events');++const { signDeliveryHeaders } = require('openvibe-sdk/events');const { boot, harness } = require('./helpers/app');const { DAY } = require('./helpers/stubs');@@@ -48,15 +48,17 @@ const MIN = 60_000;assert.strictEqual(t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === m.subject).length, 1);});-- test('deliveries must be signed; only Billing is believed', async () => {++ test('deliveries must be signed (v2, inside the replay window); only Billing is believed', async () => {const m = t.network.newUser('m3');const ent = t.billing.pay(m.subject, creator.subject).events[1];assert.strictEqual((await t.deliver(ent, { secret: 'x'.repeat(48) })).status, 401);++ assert.strictEqual((await t.deliver(ent, { v1Only: true })).status, 401, 'v1 only (no v2 header): refused');++ assert.strictEqual((await t.deliver(ent, { now: Date.now() - 301000 })).status, 401, 'stale v2 (outside the 300 s window): refused');const forged = { ...ent, event_id: t.billing.envelope('x.y.z', { type: 'x', id: '1' }, {}).event_id, source: 'live' };assert.strictEqual((await t.deliver(forged)).json.outcome, 'ignored:source');assert.strictEqual(t.domain.entitlements.getRow(m.subject, creator.subject), null);const raw = JSON.stringify({ event: ent, seq: 1 });-- const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body: raw, headers: { 'X-OpenVibe-Signature': signDelivery(raw + ' ', t.EVENTS_SECRET) } });++ const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body: raw, headers: signDeliveryHeaders(raw + ' ', t.EVENTS_SECRET) });assert.strictEqual(res.status, 401);});@@@ -12,7 +12,7 @@ const fs = require('fs');const os = require('os');const path = require('path');const http = require('http');--const { signDelivery } = require('openvibe-sdk/events');++const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');const { startNetwork, startBilling } = require('./stubs');const EVENTS_SECRET = 'e'.repeat(48);@@@ -64,11 +64,13 @@ async function boot(opts = {}) {return { status: res.status, headers: res.headers, json, text };}-- async function deliver(event, { secret = EVENTS_SECRET, seq = 1 } = {}) {++ /** POST a signed Events delivery (v1 and v2 headers); `v1Only` sends only X-OpenVibe-Signature, `now` (ms) backdates v2. */++ async function deliver(event, { secret = EVENTS_SECRET, seq = 1, v1Only = false, now } = {}) {const raw = JSON.stringify({ event, seq });++ const sig = v1Only ? { 'X-OpenVibe-Signature': signDelivery(raw, secret) } : signDeliveryHeaders(raw, secret, { now });const res = await fetch(`${base}/internal/events`, {method: 'POST', body: raw,-- headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': signDelivery(raw, secret), 'X-OpenVibe-Seq': String(seq) },++ headers: { 'Content-Type': 'application/json', ...sig, 'X-OpenVibe-Seq': String(seq) },});return { status: res.status, json: await res.json().catch(() => null) };}
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
},
},
"engines": {
"engines": {
@@ -999,9 +999,9 @@
@@ -999,9 +999,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.2.2",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
- "integrity": "sha512-kDifUp3EcmnTWzElAmaCZnX659WErcnm4+qVEoAiYQWX/w/1p4TWvoHbUPsrX65TSROVR3tiyVkl801n/0yyCw==",
+ "version": "0.4.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",
+ "integrity": "sha512-Hyz55YOMIs0xwCQ1NXsN0sUYhlbKXAX3uWY2fPCdEEL18FXUYVhK8wlG/5JMMypFHnjDiSd9VHzBNG12hWsmkQ==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
},
},
"engines": {
"engines": {
@@ -77,7 +77,8 @@ function consumerRouter({ domain, config, log = console }) {
@@ -77,7 +77,8 @@ function consumerRouter({ domain, config, log = console }) {
if (!secrets.length) return http.sendProblem(res, 503, 'vip.webhook_disabled', { detail: 'VIP_EVENTS_SECRET is not set', ctx: req.ov });
if (!secrets.length) return http.sendProblem(res, 503, 'vip.webhook_disabled', { detail: 'VIP_EVENTS_SECRET is not set', ctx: req.ov });
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
let delivery = null;
let delivery = null;
- for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s); if (delivery) break; }
+ // Signature v2 only: HMAC over "<t>.<raw body>" with t within ±300 s; a v1-only (v2 stripped) or stale delivery is refused.
+ for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s, { requireV2: true }); if (delivery) break; }
if (!delivery) return http.sendProblem(res, 401, 'vip.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx: req.ov });
if (!delivery) return http.sendProblem(res, 401, 'vip.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx: req.ov });
const event = delivery.event;
const event = delivery.event;
if (!event || typeof event.event_id !== 'string' || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(event.event_id)) {
if (!event || typeof event.event_id !== 'string' || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(event.event_id)) {
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
* valid_until (+ grace); ordering and duplicates are safe. Injected clock throughout.
* valid_until (+ grace); ordering and duplicates are safe. Injected clock throughout.
*/
*/
const assert = require('assert');
const assert = require('assert');
-const { signDelivery } = require('openvibe-sdk/events');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { boot, harness } = require('./helpers/app');
const { boot, harness } = require('./helpers/app');
const { DAY } = require('./helpers/stubs');
const { DAY } = require('./helpers/stubs');
@@ -48,15 +48,17 @@ const MIN = 60_000;
@@ -48,15 +48,17 @@ const MIN = 60_000;
assert.strictEqual(t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === m.subject).length, 1);
assert.strictEqual(t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === m.subject).length, 1);
});
});
- test('deliveries must be signed; only Billing is believed', async () => {
+ test('deliveries must be signed (v2, inside the replay window); only Billing is believed', async () => {
const m = t.network.newUser('m3');
const m = t.network.newUser('m3');
const ent = t.billing.pay(m.subject, creator.subject).events[1];
const ent = t.billing.pay(m.subject, creator.subject).events[1];
assert.strictEqual((await t.deliver(ent, { secret: 'x'.repeat(48) })).status, 401);
assert.strictEqual((await t.deliver(ent, { secret: 'x'.repeat(48) })).status, 401);
+ assert.strictEqual((await t.deliver(ent, { v1Only: true })).status, 401, 'v1 only (no v2 header): refused');
+ assert.strictEqual((await t.deliver(ent, { now: Date.now() - 301000 })).status, 401, 'stale v2 (outside the 300 s window): refused');
const forged = { ...ent, event_id: t.billing.envelope('x.y.z', { type: 'x', id: '1' }, {}).event_id, source: 'live' };
const forged = { ...ent, event_id: t.billing.envelope('x.y.z', { type: 'x', id: '1' }, {}).event_id, source: 'live' };
assert.strictEqual((await t.deliver(forged)).json.outcome, 'ignored:source');
assert.strictEqual((await t.deliver(forged)).json.outcome, 'ignored:source');
assert.strictEqual(t.domain.entitlements.getRow(m.subject, creator.subject), null);
assert.strictEqual(t.domain.entitlements.getRow(m.subject, creator.subject), null);
const raw = JSON.stringify({ event: ent, seq: 1 });
const raw = JSON.stringify({ event: ent, seq: 1 });
- const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body: raw, headers: { 'X-OpenVibe-Signature': signDelivery(raw + ' ', t.EVENTS_SECRET) } });
+ const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body: raw, headers: signDeliveryHeaders(raw + ' ', t.EVENTS_SECRET) });
assert.strictEqual(res.status, 401);
assert.strictEqual(res.status, 401);
});
});
@@ -12,7 +12,7 @@ const fs = require('fs');
@@ -12,7 +12,7 @@ const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
const http = require('http');
const http = require('http');
-const { signDelivery } = require('openvibe-sdk/events');
+const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
const { startNetwork, startBilling } = require('./stubs');
const { startNetwork, startBilling } = require('./stubs');
const EVENTS_SECRET = 'e'.repeat(48);
const EVENTS_SECRET = 'e'.repeat(48);
@@ -64,11 +64,13 @@ async function boot(opts = {}) {
@@ -64,11 +64,13 @@ async function boot(opts = {}) {
return { status: res.status, headers: res.headers, json, text };
return { status: res.status, headers: res.headers, json, text };
}
}
- async function deliver(event, { secret = EVENTS_SECRET, seq = 1 } = {}) {
+ /** POST a signed Events delivery (v1 and v2 headers); `v1Only` sends only X-OpenVibe-Signature, `now` (ms) backdates v2. */
+ async function deliver(event, { secret = EVENTS_SECRET, seq = 1, v1Only = false, now } = {}) {
const raw = JSON.stringify({ event, seq });
const raw = JSON.stringify({ event, seq });
+ const sig = v1Only ? { 'X-OpenVibe-Signature': signDelivery(raw, secret) } : signDeliveryHeaders(raw, secret, { now });
const res = await fetch(`${base}/internal/events`, {
const res = await fetch(`${base}/internal/events`, {
method: 'POST', body: raw,
method: 'POST', body: raw,
- headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': signDelivery(raw, secret), 'X-OpenVibe-Seq': String(seq) },
+ headers: { 'Content-Type': 'application/json', ...sig, 'X-OpenVibe-Seq': String(seq) },
});
});
return { status: res.status, json: await res.json().catch(() => null) };
return { status: res.status, json: await res.json().catch(() => null) };
}
}