openvibers/openvibe.events Commits
Security crawl for secrets (roadmap WS-R task 5: internal-secret leak). Events holds one kind of secret, each subscription's signing secret, and reads none from its environment (test/security-secrets.test.js asserts that: a new secret-looking variable fails until it gets a sentinel). With service and app subscriptions whose secrets were rotated (both sign during the overlap), every GET route Express knows after boot (test/security-crawl.js walks the router stack, so a new route is crawled without anyone listing it) is requested with seeded and nonsense ids and several query strings, the realtime stream read for a moment, as anonymous, two people, three services, a reader and three developer apps; every write route gets malformed JSON and empty bodies, every route forged, alg-none, expired and wrong-audience tokens. No answer may carry a subscription secret (also URL-encoded or base64), another caller's token or any part of Network's private key; the deliveries sent, the log and every table but subscriptions neither. test/security-world.js is the shared fixture for the security suites.
Changes
@@@ -47,6 +47,8 @@ function checkRetryPolicy(p) {function subscriptionsRouter({ config, store, auth, dnsLookup }) {const router = express.Router();++ // Creation and rotation answer with the signing secret (shown once): nothing here may be cached.++ router.use('/api/v1/subscriptions', (req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });const guard = auth.appOrService(CAPS.subscribe, CAPS.appSubscribe, { requireService: true });const consumerOf = (req) => (req.principal.kind === 'app' ? req.principal.sub : req.principal.service);nnew file mode 100644@@@ -0,0 +1,147 @@++'use strict';++/**++ * The crawler behind the security suites (roadmap WS-R task 5): lists every route of the booted++ * Events app (walking Express's router stack, so a route added later is crawled without anyone++ * listing it), fills route parameters with seeded values, and requests each path as several++ * callers, reporting any response whose body or headers carry a value that caller must never see.++ * The realtime stream is read for a moment and then closed. Not a test itself (no .test.js).++ */++++/** The path an Express 4 layer is mounted at ('' for app-level middleware), or null when it is a pattern. */++function mountPath(layer) {++ if (!layer.regexp || layer.regexp.fast_slash) return '';++ let src = layer.regexp.source.replace(/^\^/, '').replace(/\\\/\?\(\?=\\\/\|\$\)$/i, '').replace(/\/\?\(\?=\/\|\$\)$/i, '');++ let i = 0;++ src = src.replace(/\(\?:\(\[\^\\\/\]\+\?\)\)/g, () => `:${(layer.keys[i++] || {}).name || 'param'}`);++ src = src.replace(/\\\//g, '/').replace(/\\\./g, '.').replace(/\\-/g, '-');++ return /[\\^$()|[\]*+?]/.test(src) ? null : src;++}++++/** Every route of the Express app behind `server` (an http.Server): [{ path, methods }]. */++function listRoutes(server) {++ const app = server.listeners('request').find((fn) => fn && fn._router);++ if (!app) throw new Error('no Express app on this server');++ const out = [];++ const walk = (stack, prefix) => {++ for (const layer of stack) {++ if (layer.route) {++ const methods = Object.keys(layer.route.methods).filter((m) => layer.route.methods[m]);++ for (const p of [].concat(layer.route.path)) if (typeof p === 'string') out.push({ path: prefix + p, methods });++ } else if (layer.handle && Array.isArray(layer.handle.stack)) {++ const mp = mountPath(layer);++ if (mp !== null) walk(layer.handle.stack, prefix + mp);++ } else {++ const mp = mountPath(layer);++ if (mp) out.push({ path: prefix + mp, methods: ['_all'] });++ }++ }++ };++ walk(app._router.stack, '');++ const seen = new Set();++ return out.filter((r) => { const k = `${r.methods.join(',')} ${r.path}`; if (seen.has(k)) return false; seen.add(k); return true; });++}++++/** Concrete paths for a template: candidate i of every parameter, for each i (no cross product). */++function expand(template, values) {++ const names = [];++ const t = `/${template.replace(/^\/+/, '')}`.replace(/\*/g, 'x').replace(/:([A-Za-z0-9_]+)\??(\([^)]*\))?/g, (m, n) => { names.push(n); return `:${n}`; });++ if (!names.length) return [t];++ const lists = names.map((n) => values(n));++ const width = Math.max(...lists.map((l) => l.length));++ const paths = new Set();++ for (let i = 0; i < width; i++) {++ let j = 0;++ paths.add(t.replace(/:([A-Za-z0-9_]+)/g, () => { const l = lists[j++]; return encodeURIComponent(String(l[Math.min(i, l.length - 1)])); }));++ }++ return [...paths];++}++++/** Every path for `method`: each route expanded, also with each of `queries` appended, plus `extra`. */++function pathsFor(routes, values, { method = 'get', queries = [], extra = [] } = {}) {++ const paths = new Set();++ for (const r of routes) {++ if (!r.methods.includes(method) && !r.methods.includes('_all')) continue;++ for (const p of expand(r.path, values)) {++ paths.add(p);++ for (const q of queries) if (!p.includes('?')) paths.add(`${p}?${q}`);++ }++ }++ for (const p of extra) paths.add(p);++ return [...paths];++}++++/** The forms of a secret worth looking for: as is, URL-encoded, base64 and base64url. */++function forms(value) {++ const v = String(value);++ const out = new Set([v, encodeURIComponent(v)]);++ if (v.length >= 12) {++ out.add(Buffer.from(v).toString('base64').replace(/=+$/, ''));++ out.add(Buffer.from(v).toString('base64url'));++ }++ return [...out];++}++++/** Which of `needles` ({ label: value }) a response ({ text, headers }) carries: [{ label, where }]. */++function leaks(res, needles) {++ const found = [];++ const h = res.headers;++ const headerText = [...(h && typeof h.entries === 'function' ? h.entries() : Object.entries(h || {}))].map(([k, v]) => `${k}: ${v}`).join('\n');++ for (const [label, value] of Object.entries(needles)) {++ if (!value) continue;++ for (const f of forms(value)) {++ if (res.text && res.text.includes(f)) { found.push({ label, where: 'body' }); break; }++ if (headerText.includes(f)) { found.push({ label, where: 'headers' }); break; }++ }++ }++ return found;++}++++/** One request; a streaming answer (SSE) is read for `streamMs` and then cut. */++async function hit(base, method, p, { headers = {}, body, streamMs = 250 } = {}) {++ const ac = new AbortController();++ const h = { ...headers };++ let b;++ if (body !== undefined) { if (typeof body === 'string') b = body; else b = JSON.stringify(body); h['content-type'] = h['content-type'] || 'application/json'; }++ const res = await fetch(base + p, { method, headers: h, body: b, redirect: 'manual', signal: ac.signal });++ let text = '';++ if (String(res.headers.get('content-type') || '').includes('text/event-stream')) {++ const reader = res.body.getReader();++ const dec = new TextDecoder();++ const stop = setTimeout(() => ac.abort(), streamMs);++ try { for (;;) { const { done, value } = await reader.read(); if (done) break; text += dec.decode(value, { stream: true }); } } catch { /* cut */ }++ clearTimeout(stop);++ } else {++ text = await res.text();++ }++ return { status: res.status, headers: res.headers, text };++}++++/**++ * Requests every path as every caller ({ who: headers }). `needlesFor(who)` names what that caller++ * must never see. Resolves { found, answered, statuses, byPath }.++ */++async function crawl(base, paths, people, needlesFor, { method = 'GET', body, concurrency = 8 } = {}) {++ const found = [];++ const statuses = {};++ const byPath = {};++ let answered = 0;++ for (const [who, headers] of Object.entries(people)) {++ const needles = needlesFor(who);++ const queue = [...paths];++ await Promise.all(Array.from({ length: concurrency }, async () => {++ while (queue.length) {++ const p = queue.shift();++ let r;++ try { r = await hit(base, method, p, { headers, body }); } catch { r = { status: 0, text: '', headers: {} }; }++ if (r.status) answered++;++ const cls = r.status ? `${String(r.status)[0]}xx` : 'none';++ statuses[cls] = (statuses[cls] || 0) + 1;++ byPath[`${who} ${method} ${p}`] = r.status;++ for (const l of leaks(r, needles)) found.push(`${who}: ${method} ${p} → ${r.status} carries ${l.label} in its ${l.where}`);++ }++ }));++ }++ return { found, answered, statuses, byPath };++}++++module.exports = { mountPath, listRoutes, expand, pathsFor, forms, leaks, hit, crawl };nnew file mode 100644@@@ -0,0 +1,141 @@++'use strict';++// Internal-secret leak (roadmap WS-R task 5). Events holds one kind of secret: each subscription's++// signing secret (service and app subscriptions; after a rotation the previous one keeps signing++// during the overlap). It reads no secret from its environment (asserted below: a new secret-looking++// variable fails this test until it gets a sentinel here); the Network key it verifies with is public,++// and the matching private key (generated by test/helpers.js) must never appear either.++// With the seeded world of test/security-world.js, every GET route Express knows after boot (walked++// from the router stack, so a new route is crawled without anyone listing it) is requested with++// seeded and nonsense ids and several query strings, the realtime stream read for a moment, as++// anonymous, two users (Bearer and cookie), three services (one an operator), a reader and three++// developer apps; then every write route gets malformed JSON from everyone and an empty body from++// anonymous and an app, and every route wrong tokens (forged, alg none, expired, wrong audience).++// No body or header may carry a subscription secret (as is, URL-encoded or base64), another caller's++// token or any part of the private key; the shown-once answers (create, rotate) must be no-store; the++// deliveries sent, the log, and every table but subscriptions must not carry a secret either.++// node test/security-secrets.test.js++const assert = require('assert');++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++const { suite, serviceToken, appToken, userToken, ISSUER } = require('./helpers');++const { buildWorld } = require('./security-world');++const crawler = require('./security-crawl');++++const t = suite('security-secrets');++let w;++const needles = {};++++t('Events reads no secret from its environment (so there is none to plant a sentinel in)', async () => {++ const names = new Set();++ const walk = (d) => {++ for (const f of fs.readdirSync(d)) {++ const p = path.join(d, f);++ if (fs.statSync(p).isDirectory()) walk(p);++ else if (p.endsWith('.js')) for (const m of fs.readFileSync(p, 'utf8').matchAll(/\b(?:process\.)?env\.([A-Z][A-Z0-9_]+)|env\[['"]([A-Z][A-Z0-9_]+)['"]\]/g)) names.add(m[1] || m[2]);++ }++ };++ walk(path.join(__dirname, '..', 'server'));++ assert.ok(names.has('EVENTS_DB_PATH') && names.has('OV_NETWORK_PUBLIC_KEY'), 'the scan sees the config');++ const secretish = [...names].filter((n) => /SECRET|PASSWORD|TOKEN|PRIVATE|CREDENTIAL|_KEY$/.test(n) && n !== 'OV_NETWORK_PUBLIC_KEY');++ assert.deepStrictEqual(secretish, [], 'a new secret-looking variable: give it a sentinel in this test');++});++++t('world', async () => {++ w = await buildWorld();++ Object.assign(needles, w.secrets);++ // The Network private key (Events only ever has the public half).++ const pem = (k) => k.split('\n').filter((l) => l && !l.startsWith('-----'));++ const pub = pem(crypto.createPublicKey(w.privateKey).export({ type: 'spki', format: 'pem' })).join('');++ pem(w.privateKey).forEach((l, i) => { if (!pub.includes(l)) needles[`network private key PEM line ${i}`] = l; });++ const jwk = crypto.createPrivateKey(w.privateKey).export({ format: 'jwk' });++ for (const f of ['d', 'p', 'q', 'dp', 'dq', 'qi']) needles[`network private key JWK ${f}`] = jwk[f];++ assert.strictEqual(Object.keys(w.secrets).length, 7);++});++++t('shown once: creation and rotation answer with the secret, never cacheable', async () => {++ assert.strictEqual(w.shownOnce.length, 4);++ for (const s of w.shownOnce) assert.match(s.cache, /no-store/, `${s.what}: Cache-Control no-store`);++});++++t('crawl: no GET, refusal or error carries a secret, a token or the key', async () => {++ const { subs, ev, ids } = w;++ const people = {++ anonymous: {},++ userBearer: { authorization: `Bearer ${w.tok.userX}` },++ userCookie: { cookie: `ov_token=${w.tok.userY}` },++ live: { authorization: `Bearer ${w.tok.live}` },++ media: { authorization: `Bearer ${w.tok.media}` },++ ops: { authorization: `Bearer ${w.tok.ops}` },++ reader: { authorization: `Bearer ${w.tok.reader}` },++ appA: { authorization: `Bearer ${w.tok.appA}` },++ appA2: { authorization: `Bearer ${w.tok.appA2}` },++ appB: { authorization: `Bearer ${w.tok.appB}` },++ };++ const tokenOf = (h) => String(h.authorization || h.cookie || '').replace(/^Bearer |^ov_token=/, '');++ const needlesFor = (who) => {++ const n = { ...needles };++ for (const [other, h] of Object.entries(people)) if (other !== who && tokenOf(h)) n[`${other}'s token`] = tokenOf(h);++ return n;++ };++ const values = (name) => ({ id: [subs.live.id, subs.media.id, subs.appA.id, subs.appB.id, ev.public, ev.internal, ev.appA, 'sub_00000000000000000000000000', 'x', '..%2f'] }[name] || ['x']);++ const queries = ['topic=*', 'topic=live.*', `topic=app.${ids.keyA}.*`, `topic=app.${ids.keyB}.*,live.*`, 'status=dead', `subscription_id=${subs.appB.id}`, 'after_seq=0&limit=1000',++ 'topics=live.*,network.notification.*&last_event_id=0', 'debug=1&include=secret'];++ const paths = crawler.pathsFor(w.routes, values, { method: 'get', queries, extra: ['/nope', '/api/nope', '/.env', '/data/events.db', '/package.json', '/api/v1/subscriptions/../../package.json'] });++ const gets = await crawler.crawl(w.base, paths, people, needlesFor);++ const found = [...gets.found];++ assert.ok(gets.answered === paths.length * Object.keys(people).length, 'every request answered');++ assert.ok((gets.statuses['2xx'] || 0) > 150, `real answers, not only refusals (${JSON.stringify(gets.statuses)})`);++ const st = (who, p) => gets.byPath[`${who} GET ${p}`];++ assert.strictEqual(st('live', '/api/v1/subscriptions'), 200, 'the crawl was signed in');++ assert.strictEqual(st('appA', '/api/v1/subscriptions'), 200);++ assert.strictEqual(st('ops', '/api/v1/deliveries'), 200);++ assert.strictEqual(st('anonymous', '/api/v1/subscriptions'), 401);++++ // Error paths: every write route, malformed from everyone, empty from anonymous and an app.++ for (const m of ['post', 'put', 'patch', 'delete']) {++ const list = crawler.pathsFor(w.routes, values, { method: m });++ if (!list.length) continue;++ found.push(...(await crawler.crawl(w.base, list, people, needlesFor, { method: m.toUpperCase(), body: '{"broken": ' })).found);++ found.push(...(await crawler.crawl(w.base, list, { anonymous: {}, appB: people.appB }, needlesFor, { method: m.toUpperCase(), body: '{}' })).found);++ }++ // Wrong tokens of every kind.++ const now = Math.floor(Date.now() / 1000);++ const other = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });++ const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');++ const wrong = {++ forged: { authorization: `Bearer ${serviceToken('live', ['events.delivery.admin'], { key: other.privateKey })}` },++ algNone: { authorization: `Bearer ${b64({ alg: 'none', typ: 'JWT' })}.${b64({ iss: ISSUER, sub: 'svc:live', actor_type: 'service', aud: ['openvibe.events'], cap: ['events.delivery.admin'], iat: now, exp: now + 300 })}.` },++ expired: { authorization: `Bearer ${serviceToken('live', ['events.delivery.admin'], { exp: now - 60 })}` },++ wrongAudience: { authorization: `Bearer ${serviceToken('live', ['events.delivery.admin'], { aud: 'openvibe.media' })}` },++ appWithAdmin: { authorization: `Bearer ${appToken({ appId: ids.A, projectId: ids.PA, env: 'production', cap: ['events.delivery.admin', 'events.subscription.manage'] })}` },++ userAsService: { authorization: `Bearer ${userToken({ aud: ['openvibe.events'] })}` },++ garbage: { authorization: 'Bearer x.y.z' },++ };++ const wr = await crawler.crawl(w.base, paths.filter((p) => p.startsWith('/api/v1/')), wrong, () => needles);++ found.push(...wr.found);++ const ok2xx = Object.entries(wr.byPath).filter(([, s]) => s < 400).map(([k, s]) => `${k} → ${s}`);++ assert.deepStrictEqual(ok2xx, [], 'wrong tokens open nothing');++ assert.deepStrictEqual(found, [], `secrets in answers:\n${found.join('\n')}`);++});++++t('deliveries, the log and the tables carry no secret', async () => {++ const all = { ...needles };++ const sent = [...w.fp.calls.map((c) => ({ text: c.rawBody.toString('utf8'), headers: c.headers })), ...w.appSeen.map((c) => ({ text: c.rawBody, headers: c.headers }))];++ assert.ok(sent.length >= 5, 'deliveries were made');++ for (const s of sent) assert.ok(s.headers['x-openvibe-signature'] || s.headers['x-openvibe-signature-v2'] || Object.keys(s.headers).some((k) => /signature/.test(k)), 'each delivery is signed');++ const inSent = sent.flatMap((s) => crawler.leaks(s, all).map((l) => l.label));++ assert.deepStrictEqual(inSent, [], 'a delivery carries signatures, never the secret');++ const inLog = crawler.leaks({ text: w.logged.join('\n'), headers: {} }, all).map((l) => l.label);++ assert.deepStrictEqual(inLog, [], 'nothing secret was logged');++ const db = w.h.db;++ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all().map((r) => r.name).filter((n) => n !== 'subscriptions');++ assert.ok(tables.includes('events') && tables.includes('deliveries'));++ const hits = [];++ for (const tb of tables) for (const l of crawler.leaks({ text: JSON.stringify(db.prepare(`SELECT * FROM "${tb}"`).all()), headers: {} }, all)) hits.push(`${tb}: ${l.label}`);++ assert.deepStrictEqual(hits, [], 'no secret outside the subscriptions table (events, deliveries, usage outbox)');++});++++t('stop', async () => { await w.stop(); });++++t.run();nnew file mode 100644@@@ -0,0 +1,140 @@++'use strict';++/**++ * The world the security suites run against (roadmap WS-R task 5): Events started in-process++ * (server/index.js start()) on a temp database with a manual clock and a log that is kept, the++ * Network key pair generated by test/helpers.js at load (throwaway, never committed), and:++ * services live (publish, read, subscribe), media (the same), network (publish), ops++ * (events.delivery.admin), and a reader without admin++ * apps A (project PA, sandbox), A2 (project PA, production), B (project PB, sandbox)++ * people x and y (user tokens), plus realtime tickets for x++ * subscriptions, each with its signing secret: live's (explicit secret, then rotated: both++ * secrets sign during the overlap), media's, A's (rotated too), A2's and B's; first-party++ * deliveries go to a stub on 127.0.0.1, app deliveries through the real guarded poster++ * with a fake DNS whose public answers the local receiver stands in for++ * events public, internal and subject (for x, for y) first-party events, and app events in++ * each project and environment, each with a distinct marker in its payload++ * `secrets` collects every secret value with a label. Not a test itself (no .test.js).++ */++const fs = require('fs');++const path = require('path');++const nodeHttp = require('http');++const { ids } = require('openvibe-contracts');++const { load } = require('../server/config');++const { start } = require('../server/index');++const apps = require('../server/apps');++const { createGuardedPost, isPublicAddress } = require('../server/egress');++const { privateKey, publicKey, serviceToken, appToken, userToken, realtimeTicket, manualClock, tmpDir, request, envelope, subscriber } = require('./helpers');++const crawler = require('./security-crawl');++++const S = (name) => `sentinel-not-a-secret-${name}`;++const ALL = ['events.app.publish', 'events.app.read', 'events.app.subscribe'];++++async function buildWorld({ env = {} } = {}) {++ const dir = tmpDir();++ const logged = [];++ const keep = (...a) => logged.push(a.map((x) => (x && x.stack) || String(x)).join(' '));++ const log = { log: keep, info: keep, warn: keep, error: keep };++++ // App endpoints: a fake DNS, and a local receiver standing in for every public address.++ const dnsTable = new Map([['hooks-a.example.com', ['93.184.216.34']], ['hooks-a2.example.com', ['93.184.216.35']], ['hooks-b.example.com', ['93.184.216.36']]]);++ const dnsLookup = (host, opts, cb) => {++ if (typeof opts === 'function') { cb = opts; opts = {}; }++ const list = (dnsTable.get(String(host).toLowerCase()) || []).map((a) => ({ address: a, family: a.includes(':') ? 6 : 4 }));++ if (!list.length) return cb(Object.assign(new Error('not found'), { code: 'ENOTFOUND' }));++ return opts.all ? cb(null, list) : cb(null, list[0].address, list[0].family);++ };++ const appSeen = [];++ const receiver = nodeHttp.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => { appSeen.push({ url: req.url, headers: req.headers, rawBody: Buffer.concat(chunks).toString('utf8') }); res.statusCode = 204; res.end(); });++ });++ await new Promise((r) => receiver.listen(0, '127.0.0.1', r));++ const receiverPort = receiver.address().port;++ const appPost = createGuardedPost({++ lookup: (host, opts, cb) => dnsLookup(host, opts, (err, a, f) => {++ if (err) return cb(err);++ const list = (Array.isArray(a) ? a : [{ address: a, family: f }]).map((x) => ({ ...x, address: isPublicAddress(x.address) ? '127.0.0.1' : x.address }));++ return opts.all ? cb(null, list) : cb(null, list[0].address, list[0].family);++ }),++ requestImpl: (options, cb) => nodeHttp.request({ ...options, port: receiverPort }, cb),++ isAllowed: (ip) => ip === '127.0.0.1' || isPublicAddress(ip),++ });++++ const clock = manualClock();++ const config = load({ NODE_ENV: 'test', PORT: '0', EVENTS_DB_PATH: path.join(dir, 'events.db'), OV_NETWORK_PUBLIC_KEY: publicKey, EVENTS_WORKER: 'off', ...env });++ const h = await start({ config, clock, log, appPost, dnsLookup });++ const base = `http://127.0.0.1:${h.server.address().port}`;++ const routes = crawler.listRoutes(h.server);++++ // ── Principals ───────────────────────────────────────────────────++ const FP = ['events.event.publish', 'events.event.read', 'events.subscription.manage'];++ const PA = `prj_${ids.ulid()}`; const PB = `prj_${ids.ulid()}`;++ const A = ids.newId('app'); const A2 = ids.newId('app'); const B = ids.newId('app');++ const x = ids.newId('user'); const y = ids.newId('user');++ const tok = {++ live: serviceToken('live', FP),++ media: serviceToken('media', FP),++ network: serviceToken('network', ['events.event.publish']),++ ops: serviceToken('ops', ['events.delivery.admin']),++ reader: serviceToken('search', ['events.event.read']),++ appA: appToken({ appId: A, projectId: PA, env: 'sandbox', cap: ALL }),++ appA2: appToken({ appId: A2, projectId: PA, env: 'production', cap: ALL }),++ appB: appToken({ appId: B, projectId: PB, env: 'sandbox', cap: ALL }),++ userX: userToken({ subjectId: x }),++ userY: userToken({ subjectId: y }),++ };++ const call = (token, method, p, body, headers = {}) => request(base, method, p, { token, body, headers });++ const must = (r, status, what) => { if (r.status !== status) throw new Error(`${what}: ${r.status} ${r.text.slice(0, 300)}`); return r; };++ const shownOnce = [];++ const once = (what, r) => { shownOnce.push({ what, status: r.status, cache: r.headers.get('cache-control') || '' }); return r; };++++ // ── Subscriptions and their secrets ──────────────────────────────++ const fp = await subscriber();++ const secrets = {};++ const subs = {};++ const liveSecret1 = S('live-subscription-secret-1');++ const liveSecret2 = S('live-subscription-secret-2');++ subs.live = once('subscription create (service)', must(await call(tok.live, 'POST', '/api/v1/subscriptions', { topic_pattern: 'live.*', endpoint: fp.url, secret: liveSecret1 }), 201, 'live subscription')).body;++ once('rotate (service)', must(await call(tok.live, 'POST', `/api/v1/subscriptions/${subs.live.id}/rotate-secret`, { secret: liveSecret2, overlap_s: 3600 }), 200, 'live rotate'));++ secrets['live subscription secret (previous, in overlap)'] = liveSecret1;++ secrets['live subscription secret (current)'] = liveSecret2;++ subs.media = must(await call(tok.media, 'POST', '/api/v1/subscriptions', { topic_pattern: 'media.*', endpoint: fp.url.replace('/hook', '/media') }), 201, 'media subscription').body;++ secrets['media subscription secret'] = subs.media.secret;++ const keyA = apps.projectKey(PA); const keyB = apps.projectKey(PB);++ subs.appA = once('subscription create (app)', must(await call(tok.appA, 'POST', '/api/v1/subscriptions', { topic_pattern: `app.${keyA}.*`, endpoint: 'https://hooks-a.example.com/a' }), 201, 'app A subscription')).body;++ secrets['app A subscription secret (previous, in overlap)'] = subs.appA.secret;++ const rotA = once('rotate (app)', must(await call(tok.appA, 'POST', `/api/v1/subscriptions/${subs.appA.id}/rotate-secret`, {}), 200, 'app A rotate')).body;++ secrets['app A subscription secret (current)'] = rotA.secret;++ subs.appA2 = must(await call(tok.appA2, 'POST', '/api/v1/subscriptions', { topic_pattern: `app.${keyA}.*`, endpoint: 'https://hooks-a2.example.com/a2' }), 201, 'app A2 subscription').body;++ secrets['app A2 subscription secret'] = subs.appA2.secret;++ subs.appB = must(await call(tok.appB, 'POST', '/api/v1/subscriptions', { topic_pattern: `app.${keyB}.*`, endpoint: 'https://hooks-b.example.com/b' }), 201, 'app B subscription').body;++ secrets['app B subscription secret'] = subs.appB.secret;++ for (const s of Object.values(subs)) delete s.secret;++++ // ── Events, one marker each ──────────────────────────────────────++ const ev = {};++ const pub = async (name, token, body) => { ev[name] = must(await call(token, 'POST', '/api/v1/events', body), 201, `publish ${name}`).body.event_id; };++ await pub('public', tok.live, envelope('live', { visibility: 'public', payload: { title: 'marker-public-live' } }));++ await pub('internal', tok.live, envelope('live', { visibility: 'internal', payload: { title: 'marker-internal-live' } }));++ const note = (to, m) => envelope('network', { event_type: 'network.notification.created', visibility: 'subject', subject: { type: 'user', id: to }, actor: { type: 'service', id: 'network' }, payload: { title: m } });++ await pub('subjectX', tok.network, note(x, 'marker-subject-x'));++ await pub('subjectY', tok.network, note(y, 'marker-subject-y'));++ const appEvent = (appId, projectId, marker) => envelope('x', { source: apps.appSource(appId), event_type: `app.${apps.projectKey(projectId)}.order.created`, actor: { type: 'app', id: appId }, subject: { type: 'order', id: '42' }, visibility: 'internal', payload: { title: marker } });++ await pub('appA', tok.appA, appEvent(A, PA, 'marker-app-a-sandbox'));++ await pub('appA2', tok.appA2, appEvent(A2, PA, 'marker-app-a-production'));++ await pub('appB', tok.appB, appEvent(B, PB, 'marker-app-b-sandbox'));++ await h.worker.drain();++++ const markers = {++ public: 'marker-public-live', internal: 'marker-internal-live', subjectX: 'marker-subject-x', subjectY: 'marker-subject-y',++ appA: 'marker-app-a-sandbox', appA2: 'marker-app-a-production', appB: 'marker-app-b-sandbox',++ };++ return {++ h, base, dir, config, clock, routes, logged, secrets, subs, ev, markers, tok, shownOnce, call, fp, appSeen, dnsTable, dnsLookup,++ ids: { PA, PB, A, A2, B, x, y, keyA, keyB }, privateKey, realtimeTicket,++ stop: async () => { await fp.close(); await new Promise((r) => receiver.close(r)); await h.close(); fs.rmSync(dir, { recursive: true, force: true }); },++ };++}++++module.exports = { buildWorld, S, ALL };
@@ -47,6 +47,8 @@ function checkRetryPolicy(p) {
@@ -47,6 +47,8 @@ function checkRetryPolicy(p) {
function subscriptionsRouter({ config, store, auth, dnsLookup }) {
function subscriptionsRouter({ config, store, auth, dnsLookup }) {
const router = express.Router();
const router = express.Router();
+ // Creation and rotation answer with the signing secret (shown once): nothing here may be cached.
+ router.use('/api/v1/subscriptions', (req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
const guard = auth.appOrService(CAPS.subscribe, CAPS.appSubscribe, { requireService: true });
const guard = auth.appOrService(CAPS.subscribe, CAPS.appSubscribe, { requireService: true });
const consumerOf = (req) => (req.principal.kind === 'app' ? req.principal.sub : req.principal.service);
const consumerOf = (req) => (req.principal.kind === 'app' ? req.principal.sub : req.principal.service);
new file mode 100644
new file mode 100644
@@ -0,0 +1,147 @@
@@ -0,0 +1,147 @@
+'use strict';
+/**
+ * The crawler behind the security suites (roadmap WS-R task 5): lists every route of the booted
+ * Events app (walking Express's router stack, so a route added later is crawled without anyone
+ * listing it), fills route parameters with seeded values, and requests each path as several
+ * callers, reporting any response whose body or headers carry a value that caller must never see.
+ * The realtime stream is read for a moment and then closed. Not a test itself (no .test.js).
+ */
+
+/** The path an Express 4 layer is mounted at ('' for app-level middleware), or null when it is a pattern. */
+function mountPath(layer) {
+ if (!layer.regexp || layer.regexp.fast_slash) return '';
+ let src = layer.regexp.source.replace(/^\^/, '').replace(/\\\/\?\(\?=\\\/\|\$\)$/i, '').replace(/\/\?\(\?=\/\|\$\)$/i, '');
+ let i = 0;
+ src = src.replace(/\(\?:\(\[\^\\\/\]\+\?\)\)/g, () => `:${(layer.keys[i++] || {}).name || 'param'}`);
+ src = src.replace(/\\\//g, '/').replace(/\\\./g, '.').replace(/\\-/g, '-');
+ return /[\\^$()|[\]*+?]/.test(src) ? null : src;
+}
+
+/** Every route of the Express app behind `server` (an http.Server): [{ path, methods }]. */
+function listRoutes(server) {
+ const app = server.listeners('request').find((fn) => fn && fn._router);
+ if (!app) throw new Error('no Express app on this server');
+ const out = [];
+ const walk = (stack, prefix) => {
+ for (const layer of stack) {
+ if (layer.route) {
+ const methods = Object.keys(layer.route.methods).filter((m) => layer.route.methods[m]);
+ for (const p of [].concat(layer.route.path)) if (typeof p === 'string') out.push({ path: prefix + p, methods });
+ } else if (layer.handle && Array.isArray(layer.handle.stack)) {
+ const mp = mountPath(layer);
+ if (mp !== null) walk(layer.handle.stack, prefix + mp);
+ } else {
+ const mp = mountPath(layer);
+ if (mp) out.push({ path: prefix + mp, methods: ['_all'] });
+ }
+ }
+ };
+ walk(app._router.stack, '');
+ const seen = new Set();
+ return out.filter((r) => { const k = `${r.methods.join(',')} ${r.path}`; if (seen.has(k)) return false; seen.add(k); return true; });
+}
+
+/** Concrete paths for a template: candidate i of every parameter, for each i (no cross product). */
+function expand(template, values) {
+ const names = [];
+ const t = `/${template.replace(/^\/+/, '')}`.replace(/\*/g, 'x').replace(/:([A-Za-z0-9_]+)\??(\([^)]*\))?/g, (m, n) => { names.push(n); return `:${n}`; });
+ if (!names.length) return [t];
+ const lists = names.map((n) => values(n));
+ const width = Math.max(...lists.map((l) => l.length));
+ const paths = new Set();
+ for (let i = 0; i < width; i++) {
+ let j = 0;
+ paths.add(t.replace(/:([A-Za-z0-9_]+)/g, () => { const l = lists[j++]; return encodeURIComponent(String(l[Math.min(i, l.length - 1)])); }));
+ }
+ return [...paths];
+}
+
+/** Every path for `method`: each route expanded, also with each of `queries` appended, plus `extra`. */
+function pathsFor(routes, values, { method = 'get', queries = [], extra = [] } = {}) {
+ const paths = new Set();
+ for (const r of routes) {
+ if (!r.methods.includes(method) && !r.methods.includes('_all')) continue;
+ for (const p of expand(r.path, values)) {
+ paths.add(p);
+ for (const q of queries) if (!p.includes('?')) paths.add(`${p}?${q}`);
+ }
+ }
+ for (const p of extra) paths.add(p);
+ return [...paths];
+}
+
+/** The forms of a secret worth looking for: as is, URL-encoded, base64 and base64url. */
+function forms(value) {
+ const v = String(value);
+ const out = new Set([v, encodeURIComponent(v)]);
+ if (v.length >= 12) {
+ out.add(Buffer.from(v).toString('base64').replace(/=+$/, ''));
+ out.add(Buffer.from(v).toString('base64url'));
+ }
+ return [...out];
+}
+
+/** Which of `needles` ({ label: value }) a response ({ text, headers }) carries: [{ label, where }]. */
+function leaks(res, needles) {
+ const found = [];
+ const h = res.headers;
+ const headerText = [...(h && typeof h.entries === 'function' ? h.entries() : Object.entries(h || {}))].map(([k, v]) => `${k}: ${v}`).join('\n');
+ for (const [label, value] of Object.entries(needles)) {
+ if (!value) continue;
+ for (const f of forms(value)) {
+ if (res.text && res.text.includes(f)) { found.push({ label, where: 'body' }); break; }
+ if (headerText.includes(f)) { found.push({ label, where: 'headers' }); break; }
+ }
+ }
+ return found;
+}
+
+/** One request; a streaming answer (SSE) is read for `streamMs` and then cut. */
+async function hit(base, method, p, { headers = {}, body, streamMs = 250 } = {}) {
+ const ac = new AbortController();
+ const h = { ...headers };
+ let b;
+ if (body !== undefined) { if (typeof body === 'string') b = body; else b = JSON.stringify(body); h['content-type'] = h['content-type'] || 'application/json'; }
+ const res = await fetch(base + p, { method, headers: h, body: b, redirect: 'manual', signal: ac.signal });
+ let text = '';
+ if (String(res.headers.get('content-type') || '').includes('text/event-stream')) {
+ const reader = res.body.getReader();
+ const dec = new TextDecoder();
+ const stop = setTimeout(() => ac.abort(), streamMs);
+ try { for (;;) { const { done, value } = await reader.read(); if (done) break; text += dec.decode(value, { stream: true }); } } catch { /* cut */ }
+ clearTimeout(stop);
+ } else {
+ text = await res.text();
+ }
+ return { status: res.status, headers: res.headers, text };
+}
+
+/**
+ * Requests every path as every caller ({ who: headers }). `needlesFor(who)` names what that caller
+ * must never see. Resolves { found, answered, statuses, byPath }.
+ */
+async function crawl(base, paths, people, needlesFor, { method = 'GET', body, concurrency = 8 } = {}) {
+ const found = [];
+ const statuses = {};
+ const byPath = {};
+ let answered = 0;
+ for (const [who, headers] of Object.entries(people)) {
+ const needles = needlesFor(who);
+ const queue = [...paths];
+ await Promise.all(Array.from({ length: concurrency }, async () => {
+ while (queue.length) {
+ const p = queue.shift();
+ let r;
+ try { r = await hit(base, method, p, { headers, body }); } catch { r = { status: 0, text: '', headers: {} }; }
+ if (r.status) answered++;
+ const cls = r.status ? `${String(r.status)[0]}xx` : 'none';
+ statuses[cls] = (statuses[cls] || 0) + 1;
+ byPath[`${who} ${method} ${p}`] = r.status;
+ for (const l of leaks(r, needles)) found.push(`${who}: ${method} ${p} → ${r.status} carries ${l.label} in its ${l.where}`);
+ }
+ }));
+ }
+ return { found, answered, statuses, byPath };
+}
+
+module.exports = { mountPath, listRoutes, expand, pathsFor, forms, leaks, hit, crawl };
new file mode 100644
new file mode 100644
@@ -0,0 +1,141 @@
@@ -0,0 +1,141 @@
+'use strict';
+// Internal-secret leak (roadmap WS-R task 5). Events holds one kind of secret: each subscription's
+// signing secret (service and app subscriptions; after a rotation the previous one keeps signing
+// during the overlap). It reads no secret from its environment (asserted below: a new secret-looking
+// variable fails this test until it gets a sentinel here); the Network key it verifies with is public,
+// and the matching private key (generated by test/helpers.js) must never appear either.
+// With the seeded world of test/security-world.js, every GET route Express knows after boot (walked
+// from the router stack, so a new route is crawled without anyone listing it) is requested with
+// seeded and nonsense ids and several query strings, the realtime stream read for a moment, as
+// anonymous, two users (Bearer and cookie), three services (one an operator), a reader and three
+// developer apps; then every write route gets malformed JSON from everyone and an empty body from
+// anonymous and an app, and every route wrong tokens (forged, alg none, expired, wrong audience).
+// No body or header may carry a subscription secret (as is, URL-encoded or base64), another caller's
+// token or any part of the private key; the shown-once answers (create, rotate) must be no-store; the
+// deliveries sent, the log, and every table but subscriptions must not carry a secret either.
+// node test/security-secrets.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const { suite, serviceToken, appToken, userToken, ISSUER } = require('./helpers');
+const { buildWorld } = require('./security-world');
+const crawler = require('./security-crawl');
+
+const t = suite('security-secrets');
+let w;
+const needles = {};
+
+t('Events reads no secret from its environment (so there is none to plant a sentinel in)', async () => {
+ const names = new Set();
+ const walk = (d) => {
+ for (const f of fs.readdirSync(d)) {
+ const p = path.join(d, f);
+ if (fs.statSync(p).isDirectory()) walk(p);
+ else if (p.endsWith('.js')) for (const m of fs.readFileSync(p, 'utf8').matchAll(/\b(?:process\.)?env\.([A-Z][A-Z0-9_]+)|env\[['"]([A-Z][A-Z0-9_]+)['"]\]/g)) names.add(m[1] || m[2]);
+ }
+ };
+ walk(path.join(__dirname, '..', 'server'));
+ assert.ok(names.has('EVENTS_DB_PATH') && names.has('OV_NETWORK_PUBLIC_KEY'), 'the scan sees the config');
+ const secretish = [...names].filter((n) => /SECRET|PASSWORD|TOKEN|PRIVATE|CREDENTIAL|_KEY$/.test(n) && n !== 'OV_NETWORK_PUBLIC_KEY');
+ assert.deepStrictEqual(secretish, [], 'a new secret-looking variable: give it a sentinel in this test');
+});
+
+t('world', async () => {
+ w = await buildWorld();
+ Object.assign(needles, w.secrets);
+ // The Network private key (Events only ever has the public half).
+ const pem = (k) => k.split('\n').filter((l) => l && !l.startsWith('-----'));
+ const pub = pem(crypto.createPublicKey(w.privateKey).export({ type: 'spki', format: 'pem' })).join('');
+ pem(w.privateKey).forEach((l, i) => { if (!pub.includes(l)) needles[`network private key PEM line ${i}`] = l; });
+ const jwk = crypto.createPrivateKey(w.privateKey).export({ format: 'jwk' });
+ for (const f of ['d', 'p', 'q', 'dp', 'dq', 'qi']) needles[`network private key JWK ${f}`] = jwk[f];
+ assert.strictEqual(Object.keys(w.secrets).length, 7);
+});
+
+t('shown once: creation and rotation answer with the secret, never cacheable', async () => {
+ assert.strictEqual(w.shownOnce.length, 4);
+ for (const s of w.shownOnce) assert.match(s.cache, /no-store/, `${s.what}: Cache-Control no-store`);
+});
+
+t('crawl: no GET, refusal or error carries a secret, a token or the key', async () => {
+ const { subs, ev, ids } = w;
+ const people = {
+ anonymous: {},
+ userBearer: { authorization: `Bearer ${w.tok.userX}` },
+ userCookie: { cookie: `ov_token=${w.tok.userY}` },
+ live: { authorization: `Bearer ${w.tok.live}` },
+ media: { authorization: `Bearer ${w.tok.media}` },
+ ops: { authorization: `Bearer ${w.tok.ops}` },
+ reader: { authorization: `Bearer ${w.tok.reader}` },
+ appA: { authorization: `Bearer ${w.tok.appA}` },
+ appA2: { authorization: `Bearer ${w.tok.appA2}` },
+ appB: { authorization: `Bearer ${w.tok.appB}` },
+ };
+ const tokenOf = (h) => String(h.authorization || h.cookie || '').replace(/^Bearer |^ov_token=/, '');
+ const needlesFor = (who) => {
+ const n = { ...needles };
+ for (const [other, h] of Object.entries(people)) if (other !== who && tokenOf(h)) n[`${other}'s token`] = tokenOf(h);
+ return n;
+ };
+ const values = (name) => ({ id: [subs.live.id, subs.media.id, subs.appA.id, subs.appB.id, ev.public, ev.internal, ev.appA, 'sub_00000000000000000000000000', 'x', '..%2f'] }[name] || ['x']);
+ const queries = ['topic=*', 'topic=live.*', `topic=app.${ids.keyA}.*`, `topic=app.${ids.keyB}.*,live.*`, 'status=dead', `subscription_id=${subs.appB.id}`, 'after_seq=0&limit=1000',
+ 'topics=live.*,network.notification.*&last_event_id=0', 'debug=1&include=secret'];
+ const paths = crawler.pathsFor(w.routes, values, { method: 'get', queries, extra: ['/nope', '/api/nope', '/.env', '/data/events.db', '/package.json', '/api/v1/subscriptions/../../package.json'] });
+ const gets = await crawler.crawl(w.base, paths, people, needlesFor);
+ const found = [...gets.found];
+ assert.ok(gets.answered === paths.length * Object.keys(people).length, 'every request answered');
+ assert.ok((gets.statuses['2xx'] || 0) > 150, `real answers, not only refusals (${JSON.stringify(gets.statuses)})`);
+ const st = (who, p) => gets.byPath[`${who} GET ${p}`];
+ assert.strictEqual(st('live', '/api/v1/subscriptions'), 200, 'the crawl was signed in');
+ assert.strictEqual(st('appA', '/api/v1/subscriptions'), 200);
+ assert.strictEqual(st('ops', '/api/v1/deliveries'), 200);
+ assert.strictEqual(st('anonymous', '/api/v1/subscriptions'), 401);
+
+ // Error paths: every write route, malformed from everyone, empty from anonymous and an app.
+ for (const m of ['post', 'put', 'patch', 'delete']) {
+ const list = crawler.pathsFor(w.routes, values, { method: m });
+ if (!list.length) continue;
+ found.push(...(await crawler.crawl(w.base, list, people, needlesFor, { method: m.toUpperCase(), body: '{"broken": ' })).found);
+ found.push(...(await crawler.crawl(w.base, list, { anonymous: {}, appB: people.appB }, needlesFor, { method: m.toUpperCase(), body: '{}' })).found);
+ }
+ // Wrong tokens of every kind.
+ const now = Math.floor(Date.now() / 1000);
+ const other = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
+ const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
+ const wrong = {
+ forged: { authorization: `Bearer ${serviceToken('live', ['events.delivery.admin'], { key: other.privateKey })}` },
+ algNone: { authorization: `Bearer ${b64({ alg: 'none', typ: 'JWT' })}.${b64({ iss: ISSUER, sub: 'svc:live', actor_type: 'service', aud: ['openvibe.events'], cap: ['events.delivery.admin'], iat: now, exp: now + 300 })}.` },
+ expired: { authorization: `Bearer ${serviceToken('live', ['events.delivery.admin'], { exp: now - 60 })}` },
+ wrongAudience: { authorization: `Bearer ${serviceToken('live', ['events.delivery.admin'], { aud: 'openvibe.media' })}` },
+ appWithAdmin: { authorization: `Bearer ${appToken({ appId: ids.A, projectId: ids.PA, env: 'production', cap: ['events.delivery.admin', 'events.subscription.manage'] })}` },
+ userAsService: { authorization: `Bearer ${userToken({ aud: ['openvibe.events'] })}` },
+ garbage: { authorization: 'Bearer x.y.z' },
+ };
+ const wr = await crawler.crawl(w.base, paths.filter((p) => p.startsWith('/api/v1/')), wrong, () => needles);
+ found.push(...wr.found);
+ const ok2xx = Object.entries(wr.byPath).filter(([, s]) => s < 400).map(([k, s]) => `${k} → ${s}`);
+ assert.deepStrictEqual(ok2xx, [], 'wrong tokens open nothing');
+ assert.deepStrictEqual(found, [], `secrets in answers:\n${found.join('\n')}`);
+});
+
+t('deliveries, the log and the tables carry no secret', async () => {
+ const all = { ...needles };
+ const sent = [...w.fp.calls.map((c) => ({ text: c.rawBody.toString('utf8'), headers: c.headers })), ...w.appSeen.map((c) => ({ text: c.rawBody, headers: c.headers }))];
+ assert.ok(sent.length >= 5, 'deliveries were made');
+ for (const s of sent) assert.ok(s.headers['x-openvibe-signature'] || s.headers['x-openvibe-signature-v2'] || Object.keys(s.headers).some((k) => /signature/.test(k)), 'each delivery is signed');
+ const inSent = sent.flatMap((s) => crawler.leaks(s, all).map((l) => l.label));
+ assert.deepStrictEqual(inSent, [], 'a delivery carries signatures, never the secret');
+ const inLog = crawler.leaks({ text: w.logged.join('\n'), headers: {} }, all).map((l) => l.label);
+ assert.deepStrictEqual(inLog, [], 'nothing secret was logged');
+ const db = w.h.db;
+ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all().map((r) => r.name).filter((n) => n !== 'subscriptions');
+ assert.ok(tables.includes('events') && tables.includes('deliveries'));
+ const hits = [];
+ for (const tb of tables) for (const l of crawler.leaks({ text: JSON.stringify(db.prepare(`SELECT * FROM "${tb}"`).all()), headers: {} }, all)) hits.push(`${tb}: ${l.label}`);
+ assert.deepStrictEqual(hits, [], 'no secret outside the subscriptions table (events, deliveries, usage outbox)');
+});
+
+t('stop', async () => { await w.stop(); });
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -0,0 +1,140 @@
+'use strict';
+/**
+ * The world the security suites run against (roadmap WS-R task 5): Events started in-process
+ * (server/index.js start()) on a temp database with a manual clock and a log that is kept, the
+ * Network key pair generated by test/helpers.js at load (throwaway, never committed), and:
+ * services live (publish, read, subscribe), media (the same), network (publish), ops
+ * (events.delivery.admin), and a reader without admin
+ * apps A (project PA, sandbox), A2 (project PA, production), B (project PB, sandbox)
+ * people x and y (user tokens), plus realtime tickets for x
+ * subscriptions, each with its signing secret: live's (explicit secret, then rotated: both
+ * secrets sign during the overlap), media's, A's (rotated too), A2's and B's; first-party
+ * deliveries go to a stub on 127.0.0.1, app deliveries through the real guarded poster
+ * with a fake DNS whose public answers the local receiver stands in for
+ * events public, internal and subject (for x, for y) first-party events, and app events in
+ * each project and environment, each with a distinct marker in its payload
+ * `secrets` collects every secret value with a label. Not a test itself (no .test.js).
+ */
+const fs = require('fs');
+const path = require('path');
+const nodeHttp = require('http');
+const { ids } = require('openvibe-contracts');
+const { load } = require('../server/config');
+const { start } = require('../server/index');
+const apps = require('../server/apps');
+const { createGuardedPost, isPublicAddress } = require('../server/egress');
+const { privateKey, publicKey, serviceToken, appToken, userToken, realtimeTicket, manualClock, tmpDir, request, envelope, subscriber } = require('./helpers');
+const crawler = require('./security-crawl');
+
+const S = (name) => `sentinel-not-a-secret-${name}`;
+const ALL = ['events.app.publish', 'events.app.read', 'events.app.subscribe'];
+
+async function buildWorld({ env = {} } = {}) {
+ const dir = tmpDir();
+ const logged = [];
+ const keep = (...a) => logged.push(a.map((x) => (x && x.stack) || String(x)).join(' '));
+ const log = { log: keep, info: keep, warn: keep, error: keep };
+
+ // App endpoints: a fake DNS, and a local receiver standing in for every public address.
+ const dnsTable = new Map([['hooks-a.example.com', ['93.184.216.34']], ['hooks-a2.example.com', ['93.184.216.35']], ['hooks-b.example.com', ['93.184.216.36']]]);
+ const dnsLookup = (host, opts, cb) => {
+ if (typeof opts === 'function') { cb = opts; opts = {}; }
+ const list = (dnsTable.get(String(host).toLowerCase()) || []).map((a) => ({ address: a, family: a.includes(':') ? 6 : 4 }));
+ if (!list.length) return cb(Object.assign(new Error('not found'), { code: 'ENOTFOUND' }));
+ return opts.all ? cb(null, list) : cb(null, list[0].address, list[0].family);
+ };
+ const appSeen = [];
+ const receiver = nodeHttp.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => { appSeen.push({ url: req.url, headers: req.headers, rawBody: Buffer.concat(chunks).toString('utf8') }); res.statusCode = 204; res.end(); });
+ });
+ await new Promise((r) => receiver.listen(0, '127.0.0.1', r));
+ const receiverPort = receiver.address().port;
+ const appPost = createGuardedPost({
+ lookup: (host, opts, cb) => dnsLookup(host, opts, (err, a, f) => {
+ if (err) return cb(err);
+ const list = (Array.isArray(a) ? a : [{ address: a, family: f }]).map((x) => ({ ...x, address: isPublicAddress(x.address) ? '127.0.0.1' : x.address }));
+ return opts.all ? cb(null, list) : cb(null, list[0].address, list[0].family);
+ }),
+ requestImpl: (options, cb) => nodeHttp.request({ ...options, port: receiverPort }, cb),
+ isAllowed: (ip) => ip === '127.0.0.1' || isPublicAddress(ip),
+ });
+
+ const clock = manualClock();
+ const config = load({ NODE_ENV: 'test', PORT: '0', EVENTS_DB_PATH: path.join(dir, 'events.db'), OV_NETWORK_PUBLIC_KEY: publicKey, EVENTS_WORKER: 'off', ...env });
+ const h = await start({ config, clock, log, appPost, dnsLookup });
+ const base = `http://127.0.0.1:${h.server.address().port}`;
+ const routes = crawler.listRoutes(h.server);
+
+ // ── Principals ───────────────────────────────────────────────────
+ const FP = ['events.event.publish', 'events.event.read', 'events.subscription.manage'];
+ const PA = `prj_${ids.ulid()}`; const PB = `prj_${ids.ulid()}`;
+ const A = ids.newId('app'); const A2 = ids.newId('app'); const B = ids.newId('app');
+ const x = ids.newId('user'); const y = ids.newId('user');
+ const tok = {
+ live: serviceToken('live', FP),
+ media: serviceToken('media', FP),
+ network: serviceToken('network', ['events.event.publish']),
+ ops: serviceToken('ops', ['events.delivery.admin']),
+ reader: serviceToken('search', ['events.event.read']),
+ appA: appToken({ appId: A, projectId: PA, env: 'sandbox', cap: ALL }),
+ appA2: appToken({ appId: A2, projectId: PA, env: 'production', cap: ALL }),
+ appB: appToken({ appId: B, projectId: PB, env: 'sandbox', cap: ALL }),
+ userX: userToken({ subjectId: x }),
+ userY: userToken({ subjectId: y }),
+ };
+ const call = (token, method, p, body, headers = {}) => request(base, method, p, { token, body, headers });
+ const must = (r, status, what) => { if (r.status !== status) throw new Error(`${what}: ${r.status} ${r.text.slice(0, 300)}`); return r; };
+ const shownOnce = [];
+ const once = (what, r) => { shownOnce.push({ what, status: r.status, cache: r.headers.get('cache-control') || '' }); return r; };
+
+ // ── Subscriptions and their secrets ──────────────────────────────
+ const fp = await subscriber();
+ const secrets = {};
+ const subs = {};
+ const liveSecret1 = S('live-subscription-secret-1');
+ const liveSecret2 = S('live-subscription-secret-2');
+ subs.live = once('subscription create (service)', must(await call(tok.live, 'POST', '/api/v1/subscriptions', { topic_pattern: 'live.*', endpoint: fp.url, secret: liveSecret1 }), 201, 'live subscription')).body;
+ once('rotate (service)', must(await call(tok.live, 'POST', `/api/v1/subscriptions/${subs.live.id}/rotate-secret`, { secret: liveSecret2, overlap_s: 3600 }), 200, 'live rotate'));
+ secrets['live subscription secret (previous, in overlap)'] = liveSecret1;
+ secrets['live subscription secret (current)'] = liveSecret2;
+ subs.media = must(await call(tok.media, 'POST', '/api/v1/subscriptions', { topic_pattern: 'media.*', endpoint: fp.url.replace('/hook', '/media') }), 201, 'media subscription').body;
+ secrets['media subscription secret'] = subs.media.secret;
+ const keyA = apps.projectKey(PA); const keyB = apps.projectKey(PB);
+ subs.appA = once('subscription create (app)', must(await call(tok.appA, 'POST', '/api/v1/subscriptions', { topic_pattern: `app.${keyA}.*`, endpoint: 'https://hooks-a.example.com/a' }), 201, 'app A subscription')).body;
+ secrets['app A subscription secret (previous, in overlap)'] = subs.appA.secret;
+ const rotA = once('rotate (app)', must(await call(tok.appA, 'POST', `/api/v1/subscriptions/${subs.appA.id}/rotate-secret`, {}), 200, 'app A rotate')).body;
+ secrets['app A subscription secret (current)'] = rotA.secret;
+ subs.appA2 = must(await call(tok.appA2, 'POST', '/api/v1/subscriptions', { topic_pattern: `app.${keyA}.*`, endpoint: 'https://hooks-a2.example.com/a2' }), 201, 'app A2 subscription').body;
+ secrets['app A2 subscription secret'] = subs.appA2.secret;
+ subs.appB = must(await call(tok.appB, 'POST', '/api/v1/subscriptions', { topic_pattern: `app.${keyB}.*`, endpoint: 'https://hooks-b.example.com/b' }), 201, 'app B subscription').body;
+ secrets['app B subscription secret'] = subs.appB.secret;
+ for (const s of Object.values(subs)) delete s.secret;
+
+ // ── Events, one marker each ──────────────────────────────────────
+ const ev = {};
+ const pub = async (name, token, body) => { ev[name] = must(await call(token, 'POST', '/api/v1/events', body), 201, `publish ${name}`).body.event_id; };
+ await pub('public', tok.live, envelope('live', { visibility: 'public', payload: { title: 'marker-public-live' } }));
+ await pub('internal', tok.live, envelope('live', { visibility: 'internal', payload: { title: 'marker-internal-live' } }));
+ const note = (to, m) => envelope('network', { event_type: 'network.notification.created', visibility: 'subject', subject: { type: 'user', id: to }, actor: { type: 'service', id: 'network' }, payload: { title: m } });
+ await pub('subjectX', tok.network, note(x, 'marker-subject-x'));
+ await pub('subjectY', tok.network, note(y, 'marker-subject-y'));
+ const appEvent = (appId, projectId, marker) => envelope('x', { source: apps.appSource(appId), event_type: `app.${apps.projectKey(projectId)}.order.created`, actor: { type: 'app', id: appId }, subject: { type: 'order', id: '42' }, visibility: 'internal', payload: { title: marker } });
+ await pub('appA', tok.appA, appEvent(A, PA, 'marker-app-a-sandbox'));
+ await pub('appA2', tok.appA2, appEvent(A2, PA, 'marker-app-a-production'));
+ await pub('appB', tok.appB, appEvent(B, PB, 'marker-app-b-sandbox'));
+ await h.worker.drain();
+
+ const markers = {
+ public: 'marker-public-live', internal: 'marker-internal-live', subjectX: 'marker-subject-x', subjectY: 'marker-subject-y',
+ appA: 'marker-app-a-sandbox', appA2: 'marker-app-a-production', appB: 'marker-app-b-sandbox',
+ };
+ return {
+ h, base, dir, config, clock, routes, logged, secrets, subs, ev, markers, tok, shownOnce, call, fp, appSeen, dnsTable, dnsLookup,
+ ids: { PA, PB, A, A2, B, x, y, keyA, keyB }, privateKey, realtimeTicket,
+ stop: async () => { await fp.close(); await new Promise((r) => receiver.close(r)); await h.close(); fs.rmSync(dir, { recursive: true, force: true }); },
+ };
+}
+
+module.exports = { buildWorld, S, ALL };