openvibers/openvibe.network Commits
Security crawl for secrets (roadmap WS-R task 5: internal-secret leak). test/security-secrets.test.js boots the real server with a throwaway RS256 and VAPID pair and a fake low-entropy sentinel in every secret it reads from the environment, seeds the database-held ones (OAuth client secrets, developer credentials overlapping after a rotation and their hashes, an export token, a refresh token and its hash, session and anonymous tokens, push keys, provider-secret copies, password hashes, a verification key), then requests every GET route Express knows after boot (test/security-crawl.js walks the router stack from a preload, so a new route is crawled without anyone listing it) as anonymous, a user, a staff admin, the owner, a service principal, a developer app and the legacy key, plus file probes, malformed and empty writes to every write route and wrong credentials. No body or header may carry a secret (also URL-encoded or base64); the log, the event outbox and the audit tables neither. The owner's clear-text reads of provider secrets (owner-guard.js) are the documented exception. test/security-world.js is the shared fixture for the security suites.
Changes
@@@ -6,7 +6,7 @@"scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js",-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/follows.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js",++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/follows.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js","reset-db": "node server/reset-db.js","grant-admin": "node server/grant-admin.js"},@@@ -301,6 +301,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {// Core site settings are owner-only (off-limits to admins).router.get('/settings', requireOwner, (req, res) => {++ res.set('Cache-Control', 'private, no-store'); // the owner sees secrets in clear heretry {const rows = db.prepare('SELECT * FROM site_settings').all();const settings = {};@@@ -396,6 +397,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {// URL Registryrouter.get('/url-registry', (req, res) => {++ res.set('Cache-Control', 'private, no-store'); // the owner sees secret-typed values in clear heretry {let entries = urlRegistry.getAllRegistryEntries(db);// Mask secret-typed registry values (e.g. DEPLOY_CLOUDFLARE_TOKEN) for non-owners.@@@ -216,6 +216,7 @@ router.post('/token', (req, res) => {const db = getDb(req);const config = getConfig(req);const { grant_type, client_id, client_secret, code, redirect_uri, refresh_token } = req.body;++ res.set('Cache-Control', 'no-store'); // every grant answers with tokens (RFC 6749 §5.1)// Developer apps (app_<ULID>): client_credentials or authorization_code + PKCE, own credential store.if (devTokens.isAppClient(client_id)) {@@@ -14,6 +14,9 @@ const subjects = require('../identity/subjects');const { staffClaims } = require('./staff-claims');const revocation = require('./revocation');const router = express.Router();++// Sign-in, refresh, password change, sessions and anonymous identities answer with tokens: none of++// these answers may be kept by a browser or proxy cache (a route may still set its own policy).++router.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });// ── Helpers ──────────────────────────────────────────────────@@@ -457,7 +457,10 @@ app.use(require('./status/routes').createStatusRoutes({ ecosystem }));app.use(require('./updates/routes').createUpdatesRoutes({ blogUrl: process.env.OV_BLOG_INTERNAL_URL || 'http://127.0.0.1:4810' }).router);app.get('/api/.well-known/jwks', (_req, res) => {-- const out = { public_key: publicKey, algorithm: privateKey === publicKey ? 'HS256' : 'RS256' };++ // Without key files the ephemeral HS256 "key pair" is one shared secret: publishing it would let++ // anyone sign sessions. Only a real public key is ever served.++ if (privateKey === publicKey || !String(publicKey).includes('BEGIN')) return res.json({ algorithm: 'HS256', keys: [] });++ const out = { public_key: publicKey, algorithm: 'RS256' };if (publicKey.includes('BEGIN')) {try {const jwk = require('crypto').createPublicKey(publicKey).export({ format: 'jwk' });@@@ -242,11 +242,15 @@ router.get('/stats', (req, res) => {});// Service URLs only (no secrets); any first-party service that resolves identities may read them.++// Secret-typed entries (DEPLOY_CLOUDFLARE_TOKEN) are left out: they are the owner's, not the services'.// TODO(contracts): a network.registry.read capability would say it more exactly.router.get('/url-registry/resolved', principals.guard('identity.subject.resolve'), (req, res) => {try {const db = getDb(req);-- const resolved = urlRegistry.getResolvedRegistry(db, process.env);++ const { URL_DEFINITIONS } = require('openvibe-shared/url-resolver');++ const { isSensitiveSettingKey } = require('../auth/owner-guard');++ const resolved = Object.fromEntries(Object.entries(urlRegistry.getResolvedRegistry(db, process.env))++ .filter(([key]) => !((URL_DEFINITIONS[key] || {}).type === 'secret' || isSensitiveSettingKey(key))));res.json({ ok: true, registry: resolved });} catch (err) {res.status(500).json({ ok: false, error: err.message });nnew file mode 100644@@@ -0,0 +1,193 @@++'use strict';++/**++ * The crawler behind the security suites (roadmap WS-R task 5): lists every route of the booted++ * Network (walking Express's router stack inside the server process, 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. Not a test itself (no .test.js); used with test/helpers/boot-server.js.++ *++ * The server runs in a child process, so the route list comes from a preload (routeDumpPreload())++ * that wraps express's app.listen and writes the router stack to a file once the server listens; the++ * preload also refuses every TCP connection off loopback.++ */++const fs = require('fs');++const path = require('path');++++const ROOT = path.join(__dirname, '..');++++/** 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 an Express app: [{ path, methods }] ('_all' for mounted middleware with a path). */++function listAppRoutes(app) {++ 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; });++}++++/**++ * Source of a preload for the child server: once app.listen() is listening, writes the route list++ * (listAppRoutes) as JSON to process.env.OV_ROUTE_DUMP. Also keeps the process from reaching anything++ * but loopback (as boot-server's own guard does), whatever client library connects.++ */++function routeDumpPreload() {++ return `'use strict';++const path = require('path');++const express = require(${JSON.stringify(path.join(ROOT, 'node_modules', 'express'))});++const { listAppRoutes } = require(${JSON.stringify(__filename)});++const listen = express.application.listen;++express.application.listen = function (...args) {++ const app = this;++ const server = listen.apply(app, args);++ server.once('listening', () => {++ try { require('fs').writeFileSync(process.env.OV_ROUTE_DUMP, JSON.stringify(listAppRoutes(app))); } catch (e) { console.error('route dump failed', e.message); }++ });++ return server;++};++// No traffic off the machine by any client (fetch, http(s), undici, discord.js, ws): every TCP++// connect to something other than loopback fails the way an unreachable host does.++const net = require('net');++const realConnect = net.Socket.prototype.connect;++net.Socket.prototype.connect = function (...args) {++ let o = args[0];++ if (Array.isArray(o)) o = o[0];++ if (o && typeof o === 'object' && o.path) return realConnect.apply(this, args);++ const host = String((o && typeof o === 'object') ? (o.host || o.hostname || 'localhost') : (typeof args[1] === 'string' ? args[1] : 'localhost')).replace(/^\\[|\\]$/g, '');++ const port = (o && typeof o === 'object') ? o.port : args[0];++ if (process.env.OV_CONNECT_LOG) { try { require('fs').appendFileSync(process.env.OV_CONNECT_LOG, host + ' ' + port + '\\n'); } catch { /* */ } }++ if (host === '127.0.0.1' || host === 'localhost' || host === '::1') return realConnect.apply(this, args);++ process.nextTick(() => this.destroy(Object.assign(new Error('test: no egress to ' + host), { code: 'ECONNREFUSED' })));++ return this;++};++`;++}++++/** Writes the preload into `dir` and returns the env that loads it: { NODE_OPTIONS, OV_ROUTE_DUMP, OV_CONNECT_LOG } (every TCP connect is logged as 'host port'). */++function routeDumpEnv(dir) {++ const file = path.join(dir, 'route-dump-preload.js');++ fs.writeFileSync(file, routeDumpPreload());++ return { NODE_OPTIONS: `--require ${file}`, OV_ROUTE_DUMP: path.join(dir, 'routes.json'), OV_CONNECT_LOG: path.join(dir, 'connects.log') };++}++++/** The route list the child wrote (waits briefly for it). */++async function readRoutes(dumpFile) {++ for (let i = 0; i < 50; i++) {++ try { return JSON.parse(fs.readFileSync(dumpFile, 'utf8')); } catch { await new Promise((r) => setTimeout(r, 100)); }++ }++ throw new Error('the server never wrote its route list');++}++++/** 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` ('get', or a write method): each route expanded, also with `query` appended, plus `extra`. */++function pathsFor(routes, values, { method = 'get', query = '', 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);++ if (query && !p.includes('?')) paths.add(`${p}?${query}`);++ }++ }++ 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 }) `text` + `headers` carry: [{ 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;++}++++/**++ * Requests every path as every caller ({ who: headers }) with `method` (and `body` for writes).++ * `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;++ let n = 0;++ const nextIp = () => { n++; return `198.19.${(n >> 8) & 255}.${n & 255}`; }; // the API limiter counts per address++ 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 {++ const h = { 'x-forwarded-for': nextIp(), ...headers };++ let b;++ if (body !== undefined) { if (typeof body === 'string') b = body; else { b = JSON.stringify(body); h['content-type'] = 'application/json'; } }++ const res = await fetch(base + p, { method, headers: h, body: b, redirect: 'manual' });++ r = { status: res.status, headers: res.headers, text: await res.text() };++ } 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, listAppRoutes, routeDumpPreload, routeDumpEnv, readRoutes, expand, pathsFor, forms, leaks, crawl };nnew file mode 100644@@@ -0,0 +1,197 @@++'use strict';++// Internal-secret leak (roadmap WS-R task 5). The real server boots with a throwaway RS256 pair, a++// generated VAPID pair and a fake low-entropy sentinel in every other secret it reads from the++// environment (test/security-world.js), plus database-held secrets: first-party OAuth client++// secrets, developer app credentials (two overlapping after a rotation) and their hashes, an export++// token, a refresh token and its hash, a session token, an anonymous identity's token, push keys,++// provider-secret copies in site_settings, password hashes and a verification key.++// Every GET route Express knows after boot (test/security-crawl.js walks the router, so a route added++// later is crawled without anyone listing it) is requested with seeded and nonsense ids, with and++// without a query string, plus probes for files that must never be served, as: anonymous, a user,++// a staff admin, the owner, a service principal (live), a developer app and the legacy internal key.++// Then every write route is sent malformed JSON (and anonymous an empty body), and wrong credentials.++// No body or header may carry any secret (as is, URL-encoded or base64); the responses that show a++// secret once (token, credential and session creation) must be Cache-Control: no-store. The log, the++// event outbox and the audit tables written meanwhile must not carry one either. Also: a Network booted++// without its key files never publishes its ephemeral HS256 secret as a "public key".++// node test/security-secrets.test.js++const assert = require('assert');++const jwt = require('jsonwebtoken');++const { buildWorld } = require('./security-world');++const { bootServer } = require('./helpers/boot-server');++const crawler = require('./security-crawl');++++const out = (...a) => process.stdout.write(a.join(' ') + '\n');++++(async () => {++ const t0 = Date.now();++ const w = await buildWorld({ label: 'secrets' });++ try {++ const { users, dev } = w;++ const values = (name) => ({++ project: [dev.PA.id, dev.PB.id, 'prj_00000000000000000000000000', '..%2f'],++ app: [dev.PA.app, dev.PB.app, 'app_00000000000000000000000000', 'x'],++ credential: [dev.PA.credential, dev.PB.credential, 'crd_x'],++ capability: ['media.object.upload', 'identity.subject.resolve', 'x'],++ subject: [users.alice.subject, users.bob.subject, 'usr_x'],++ target: [users.alice.subject, users.bob.subject, 'x'],++ type: ['user', 'channel', 'x'],++ creator: [users.alice.subject, 'alice', 'x'],++ id: [String(users.alice.id), String(users.owner.id), '1', '999999', 'x', '-1'],++ userId: [String(users.alice.id), String(users.bob.id), '999999'],++ username: ['alice', 'rootowner', 'nobody'],++ name: ['alice', 'live', 'network', 'nobody'],++ ns: ['ai.preferences', 'chat.tts_defaults', 'live.profile', 'x'],++ namespace: ['general', 'network', 'x'],++ key: ['DEPLOY_CLOUDFLARE_TOKEN', 'OV_LIVE_URL', 'x'],++ token: ['not-a-token', 'x'],++ idOrSlug: ['default', 'x'],++ serviceId: ['live', 'network', 'x'],++ domain: ['openvibe.live', 'x'],++ topic: ['live.stream.started', 'x'],++ category: ['system', 'x'],++ }[name] || ['1', 'x']);++ const extra = ['/api/nope', '/internal/nope', '/INTERNAL/users/1', '/.env', '/.git/config', '/package.json', '/server/config.js',++ '/data/network.db', '/data/keys/private.pem', '/keys/private.pem', '/private.pem', '/network.db', '/data/avatars/..%2fnetwork.db',++ '/data/avatars/%2e%2e/network.db', '/shared/..%2f..%2fpackage.json', '/node_modules/.package-lock.json', '/api/.well-known/jwks',++ '/.well-known/openid-configuration', '/oauth/.well-known/openid-configuration', '/api/ready', '/metrics', '/release.json', '/status',++ '/api/v1/status', '/api/admin/secrets', '/api/admin/settings', '/api/admin/config', '/api/admin/url-registry', '/api/admin/email',++ '/api/admin/discord/', '/api/admin/integrations/github/', '/api/admin/deploy/config', '/internal/url-registry/resolved',++ '/internal/integrations/github-token', '/api/setup/status', '/oauth/authorize?client_id=live&redirect_uri=https%3A%2F%2Fevil.test%2F&response_type=code',++ '/oauth/client-info?client_id=live', `/oauth/client-info?client_id=${dev.PA.app}`, '/api/auth/anon-identities', '/sso/check', '/fedcm/accounts'];++ const paths = crawler.pathsFor(w.routes, values, { method: 'get', query: 'limit=5&all=1&debug=1&include=secret', extra });++ const tokenOf = (h) => String((h && h.authorization) || '').replace(/^Bearer /, '');++ const people = { ...w.callers, legacy: { 'x-internal-key': w.sentinels.INTERNAL_API_KEY } };++ // What each caller must never see: every secret, plus every other caller's credential. Staff++ // admins and the owner list verification keys (they make them to hand out); nobody else sees one.++ const needlesFor = (who) => {++ const n = { ...w.secrets };++ for (const [other, h] of Object.entries(people)) if (other !== who && tokenOf(h)) n[`${other}'s bearer token`] = tokenOf(h);++ if (who !== 'admin' && who !== 'owner') n['verification key'] = w.verificationKey;++ return n;++ };++++ // ── Every GET, as everyone ─────────────────────────────────────++ const gets = await crawler.crawl(w.srv.base, paths, people, needlesFor);++ out(`GET crawl: ${paths.length} paths x ${Object.keys(people).length} callers, ${JSON.stringify(gets.statuses)}`);++ // The one documented exception (server/auth/owner-guard.js): the owner, and only the owner,++ // reads provider secrets in the admin settings and the secret-typed registry values in clear,++ // to change them; staff admins get them masked. Those two reads must not be cached.++ const OWNER_READS = /^owner: GET \/api\/admin\/(settings|url-registry)(\?[^ ]*)? → 200 carries (db setting [a-z_.]+|env DEPLOY_CLOUDFLARE_TOKEN) in its body$/;++ const ownerReads = gets.found.filter((f) => OWNER_READS.test(f));++ assert.ok(ownerReads.length > 0, 'the owner exception is still exercised');++ for (const p of ['/api/admin/settings', '/api/admin/url-registry']) {++ const r = await w.call(people.owner, 'GET', p);++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('cache-control') || '', /no-store/, `${p}: the owner's clear-text read is never cached`);++ const staff = await w.call(people.admin, 'GET', p);++ assert.ok(staff.status === 403 || (staff.status === 200 && crawler.leaks(staff, needlesFor('admin')).length === 0), `${p}: staff admins get it masked or not at all`);++ }++ const leaked = gets.found.filter((f) => !OWNER_READS.test(f));++ assert.deepStrictEqual(leaked, [], `secrets in GET responses:\n${leaked.join('\n')}`);++ assert.ok(paths.length >= 300 && gets.answered === paths.length * Object.keys(people).length, 'every request was answered');++ assert.ok((gets.statuses['2xx'] || 0) > 500, 'the crawl reached real pages, not only refusals');++ assert.strictEqual(Object.values(gets.byPath).filter((s) => s === 429).length, 0, 'no request was rate-limited away');++ // The crawl really was signed in: each caller reaches something only it can.++ const st = (who, p) => gets.byPath[`${who} GET ${p}`];++ assert.strictEqual(st('user', '/api/auth/me'), 200);++ assert.strictEqual(st('anonymous', '/api/auth/me'), 401);++ assert.strictEqual(st('admin', '/api/admin/url-registry'), 200);++ assert.strictEqual(st('admin', '/api/admin/settings'), 403, 'settings are the owner\'s');++ assert.strictEqual(st('user', '/api/admin/url-registry'), 403);++ assert.strictEqual(st('owner', '/api/admin/secrets'), 200);++ assert.strictEqual(st('owner', '/api/admin/settings'), 200);++ assert.strictEqual(st('service', '/internal/url-registry/resolved'), 200, 'live may read the resolved registry');++ assert.strictEqual(st('legacy', '/internal/url-registry/resolved'), 200);++ assert.strictEqual(st('anonymous', '/internal/url-registry/resolved'), 403);++++ // JWKS and OIDC discovery: the public key, never a private part.++ const jwks = await w.call(null, 'GET', '/api/.well-known/jwks');++ assert.strictEqual(jwks.status, 200);++ assert.ok(jwks.body.public_key.includes('BEGIN PUBLIC KEY') && jwks.body.keys.length === 1);++ for (const f of ['d', 'p', 'q', 'dp', 'dq', 'qi']) assert.ok(!(f in jwks.body.keys[0]), `JWKS has no private member ${f}`);++ assert.ok(!/PRIVATE/.test(jwks.text));++++ // ── Error paths: every write route, refused or malformed ─────────++ const writes = {};++ for (const m of ['post', 'put', 'patch', 'delete']) writes[m] = crawler.pathsFor(w.routes, values, { method: m });++ const found = [];++ let sent = 0;++ for (const [m, list] of Object.entries(writes)) {++ const M = m.toUpperCase();++ for (const [body, callers] of [++ ['{"broken": ', people], // malformed JSON, everyone++ ['{}', { anonymous: {}, app: people.app || {} }], // empty body without the right credential++ ]) {++ const r = await crawler.crawl(w.srv.base, list, callers, needlesFor, { method: M, body });++ sent += list.length * Object.keys(callers).length;++ found.push(...r.found);++ }++ }++ // Wrong credentials of every kind on the paths that take them.++ const wrong = {++ 'wrong internal key': { 'x-internal-key': w.sentinels.INTERNAL_API_KEY.slice(0, -1) + 'x' },++ 'forged bearer': { authorization: `Bearer ${jwt.sign({ sub: users.owner.id, id: users.owner.id }, 'guess', { issuer: w.issuer })}` },++ 'none alg bearer': { authorization: `Bearer ${jwt.sign({ sub: users.owner.id, id: users.owner.id }, null, { algorithm: 'none' })}` },++ 'setup token guess': { 'x-setup-token': 'x', authorization: 'Bearer x' },++ };++ const wr = await crawler.crawl(w.srv.base, [...writes.post.filter((p) => /^\/(internal|oauth|api\/setup|api\/admin)/.test(p)), ...paths.filter((p) => /^\/(internal|api\/admin)/.test(p))], wrong, needlesFor, { method: 'GET' });++ found.push(...wr.found);++ for (const [grant, form] of [++ ['client_credentials', { client_id: 'live', client_secret: 'wrong' }],++ ['client_credentials', { client_id: dev.PA.clientId, client_secret: 'ovsec_wrong' }],++ ['authorization_code', { client_id: 'live', client_secret: w.clientSecrets.live, code: 'nope', redirect_uri: 'https://evil.test/' }],++ ['refresh_token', { client_id: 'live', client_secret: w.clientSecrets.live, refresh_token: 'f'.repeat(96) }],++ ]) {++ const r = await w.call(null, 'POST', '/oauth/token', new URLSearchParams({ grant_type: grant, ...form }));++ assert.ok(r.status >= 400, `${grant} with wrong material is refused`);++ for (const l of crawler.leaks(r, needlesFor('anonymous'))) found.push(`oauth ${grant} refusal carries ${l.label}`);++ }++ out(`write/error crawl: ${sent} requests`);++ assert.deepStrictEqual(found, [], `secrets in error responses:\n${found.join('\n')}`);++++ // ── Shown once, never cached ─────────────────────────────────────++ for (const s of w.shownOnce.filter((x) => x.status >= 200 && x.status < 300)) assert.match(s.cache, /no-store/, `${s.what}: Cache-Control no-store`);++ assert.ok(w.shownOnce.filter((x) => x.status >= 200 && x.status < 300).length >= 6, 'the shown-once responses were made');++ // The other answers that carry a token: refresh, an anonymous session, the anonymous identities of this address.++ for (const [who, m, p, body] of [['alice', 'POST', '/api/auth/refresh', {}], [null, 'POST', '/api/auth/anon-session', {}], [null, 'GET', '/api/auth/anon-identities'], [null, 'POST', '/api/auth/login', { username: 'alice', password: 'wrong' }]]) {++ const r = await w.call(who, m, p, body);++ assert.match(r.headers.get('cache-control') || '', /no-store/, `${m} ${p} (${r.status}): Cache-Control no-store`);++ }++++ // ── Logs, outbox and audit tables ────────────────────────────────++ const all = { ...needlesFor('nobody'), 'verification key': w.verificationKey };++ delete all['verification key']; // stored in its own table by design; only its readers are checked above++ const logHits = crawler.leaks({ text: w.srv.logs(), headers: {} }, all).map((l) => l.label);++ assert.deepStrictEqual(logHits, [], 'nothing secret was logged');++ const tables = w.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND (name LIKE '%outbox%' OR name LIKE '%audit%' OR name LIKE '%log%' OR name LIKE '%usage%' OR name LIKE '%changes%' OR name LIKE '%alerts%' OR name LIKE '%revisions%' OR name = 'notifications')").all().map((r) => r.name);++ assert.ok(tables.includes('network_event_outbox') && tables.includes('dev_audit') && tables.includes('audit_log'), tables.join(','));++ const rowHits = [];++ for (const t of tables) {++ const text = JSON.stringify(w.db.prepare(`SELECT * FROM "${t}"`).all());++ for (const l of crawler.leaks({ text, headers: {} }, all)) rowHits.push(`${t}: ${l.label}`);++ }++ assert.deepStrictEqual(rowHits, [], 'no secret in the outbox, audit or log tables');++ assert.ok(w.db.prepare('SELECT COUNT(*) AS n FROM network_event_outbox').get().n > 0, 'the outbox was written meanwhile');++ } finally {++ await w.stop();++ }++++ // ── Without key files: the ephemeral HS256 secret is never published ───++ const bare = await bootServer();++ try {++ const r = await fetch(`${bare.base}/api/.well-known/jwks`);++ const body = await r.json();++ const k = body.public_key;++ if (k && !String(k).includes('BEGIN')) {++ // What the published "key" would let anyone do: sign a session for the first admin.++ const forged = jwt.sign({ sub: 1, id: 1, username: 'admin', role: 'admin' }, k, { algorithm: 'HS256', issuer: 'https://openvibe.network', expiresIn: '1h' });++ const me = await fetch(`${bare.base}/api/auth/me`, { headers: { authorization: `Bearer ${forged}` } });++ assert.notStrictEqual(me.status, 200, 'a session signed with the published HS256 secret is refused');++ }++ assert.ok(!k || String(k).includes('BEGIN PUBLIC KEY'), 'a keyless Network publishes no HS256 secret as its public key');++ } finally {++ await bare.stop();++ }++ out(`security secrets: all checks passed (${((Date.now() - t0) / 1000).toFixed(1)} s)`);++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,223 @@++'use strict';++/**++ * The world the security suites run against (roadmap WS-R task 5): the real server/index.js booted in++ * a child process (test/helpers/boot-server.js) on a scratch database, with a throwaway RS256 key++ * pair and VAPID pair generated here, an obviously fake low-entropy sentinel in every other secret++ * the server reads from its environment, and a seeded population:++ * people alice, bob (users), carol (a third user), staff (role admin, not the owner),++ * owner (ADMIN_USERNAME, created by the server itself from ADMIN_PASSWORD; OWNER_USERNAME)++ * developer projects: alice's PA (app AA, confidential sandbox, its credential rotated so two++ * secrets overlap) and bob's PB (app AB); an export token minted for PA++ * per-person data: alice's modules, blocks, follows, notifications, sessions, linked account,++ * history, push subscription, refresh token (OAuth code flow); an anonymous identity;++ * provider-secret copies in site_settings; a verification key; a closed old incident++ * `secrets` collects every secret value with a label, for the suites to look for in responses.++ * Not a test itself (no .test.js).++ */++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++const jwt = require('jsonwebtoken');++const Database = require('better-sqlite3');++const { bootServer } = require('./helpers/boot-server');++const crawler = require('./security-crawl');++++const S = (name) => `sentinel-not-a-secret-${name}`;++++/** Every secret-typed environment variable Network (or a module it loads) reads, with its sentinel. */++function sentinelEnv() {++ return {++ INTERNAL_API_KEY: S('internal-api-key'),++ SETUP_TOKEN: S('setup-token'),++ NETWORK_EVENTS_SECRET: S('network-events-secret'),++ OV_LIVE_WEBHOOK_SECRET: S('live-webhook-secret'),++ OV_GAMES_WEBHOOK_SECRET: S('games-webhook-secret'),++ ADMIN_PASSWORD: S('admin-password'),++ RESEND_API_KEY: S('resend-api-key'),++ RESEND_WEBHOOK_SECRET: S('resend-webhook-secret'),++ DISCORD_BOT_TOKEN: S('discord-bot-token'),++ DISCORD_OAUTH_CLIENT_SECRET: S('discord-oauth-client-secret'),++ GITHUB_TOKEN: S('github-token'),++ DEPLOY_CLOUDFLARE_TOKEN: S('deploy-cloudflare-token'),++ OV_OAUTH_CLIENT_SECRET: S('ov-oauth-client-secret'),++ NET_IPINFO_TOKEN: S('net-ipinfo-token'),++ NET_GLOBALPING_TOKEN: S('net-globalping-token'),++ };++}++++/** Base64 runs of a PEM body worth looking for (each 64-char line, and the joined body). */++function pemChunks(pem) {++ const lines = String(pem).split('\n').filter((l) => l && !l.startsWith('-----'));++ return { lines, body: lines.join('') };++}++++async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {++ const os = require('os');++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `ov-${label}-`));++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++ fs.mkdirSync(path.join(dir, 'keys'));++ fs.writeFileSync(path.join(dir, 'keys', 'private.pem'), keys.privateKey);++ fs.writeFileSync(path.join(dir, 'keys', 'public.pem'), keys.publicKey);++ const vapid = require('web-push').generateVAPIDKeys();++ const sentinels = sentinelEnv();++ const dump = crawler.routeDumpEnv(dir);++ const ISSUER = 'https://openvibe.network';++ const srv = await bootServer({++ env: {++ ...sentinels,++ VAPID_PRIVATE_KEY: vapid.privateKey, VAPID_PUBLIC_KEY: vapid.publicKey,++ JWT_PRIVATE_KEY: path.join(dir, 'keys', 'private.pem'), JWT_PUBLIC_KEY: path.join(dir, 'keys', 'public.pem'),++ ADMIN_USERNAME: 'rootowner', OWNER_USERNAME: 'rootowner', BASE_URL: ISSUER, OV_NETWORK_URL: ISSUER,++ DEV_SANDBOX_AUDIENCES: 'openvibe.media,openvibe.network', DEV_CREDENTIAL_OVERLAP_S: '3600',++ ...dump, ...extraEnv,++ },++ });++ const routes = await crawler.readRoutes(dump.OV_ROUTE_DUMP);++ const db = new Database(path.join(srv.dir, 'network.db'));++ db.pragma('busy_timeout = 5000');++++ // ── Secrets, by label ────────────────────────────────────────────++ const secrets = {};++ for (const [k, v] of Object.entries(sentinels)) secrets[`env ${k}`] = v;++ secrets['env VAPID_PRIVATE_KEY'] = vapid.privateKey;++ const priv = pemChunks(keys.privateKey);++ const pub = pemChunks(keys.publicKey);++ priv.lines.forEach((l, i) => { if (!pub.body.includes(l)) secrets[`signing key PEM line ${i}`] = l; });++ const jwk = crypto.createPrivateKey(keys.privateKey).export({ format: 'jwk' });++ for (const f of ['d', 'p', 'q', 'dp', 'dq', 'qi']) secrets[`signing key JWK ${f}`] = jwk[f];++++ // ── People ───────────────────────────────────────────────────────++ const subjects = require('../server/identity/subjects');++ const hash = '$2a$10$' + 'x'.repeat(53);++ const addUser = (username, role = 'user', email = null) => {++ db.prepare('INSERT INTO users (username, email, password_hash, display_name, role) VALUES (?, ?, ?, ?, ?)').run(username, email, hash, username, role);++ const row = db.prepare('SELECT * FROM users WHERE username = ?').get(username);++ subjects.ensureUserSubject(db, row);++ return db.prepare('SELECT * FROM users WHERE id = ?').get(row.id);++ };++ const rows = {++ alice: addUser('alice', 'user', '[email protected]'),++ bob: addUser('bob', 'user', '[email protected]'),++ carol: addUser('carol', 'user', '[email protected]'),++ staff: addUser('staffer', 'admin', '[email protected]'),++ };++ const ownerRow = db.prepare("SELECT * FROM users WHERE username = 'rootowner'").get();++ if (!ownerRow || ownerRow.role !== 'admin') throw new Error('the server did not create the ADMIN_USERNAME account');++ subjects.ensureUserSubject(db, ownerRow);++ rows.owner = db.prepare('SELECT * FROM users WHERE id = ?').get(ownerRow.id);++ secrets['owner password hash'] = rows.owner.password_hash;++ const sign = (u) => jwt.sign({ sub: u.id, id: u.id, username: u.username, role: u.role }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '2h' });++ const users = {};++ for (const [k, u] of Object.entries(rows)) users[k] = { id: u.id, username: u.username, subject: u.subject_id, email: u.email, token: sign(u) };++++ // First-party OAuth clients get unique secrets (stored in clear in oauth_clients).++ const clientSecrets = {};++ for (const c of db.prepare('SELECT client_id FROM oauth_clients').all()) {++ clientSecrets[c.client_id] = `${S('client')}-${c.client_id}-${crypto.randomBytes(6).toString('hex')}`;++ db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(clientSecrets[c.client_id], c.client_id);++ secrets[`oauth client secret ${c.client_id}`] = clientSecrets[c.client_id];++ }++++ // ── HTTP ─────────────────────────────────────────────────────────++ let ipn = 0;++ const nextIp = () => { ipn++; return `198.18.${(ipn >> 8) & 255}.${ipn & 255}`; };++ const who = (w) => (typeof w === 'string' ? (users[w] ? { authorization: `Bearer ${users[w].token}` } : {}) : (w || {}));++ async function call(w, method, p, body, headers = {}) {++ const h = { 'x-forwarded-for': nextIp(), ...who(w), ...headers };++ let b;++ if (body !== undefined && body !== null) {++ if (typeof body === 'string') b = body;++ else if (body instanceof URLSearchParams) { b = body.toString(); h['content-type'] = 'application/x-www-form-urlencoded'; } else { b = JSON.stringify(body); h['content-type'] = h['content-type'] || 'application/json'; }++ }++ const r = await fetch(srv.base + p, { method, headers: h, body: b, redirect: 'manual' });++ const text = await r.text();++ let json = null; try { json = text ? JSON.parse(text) : null; } catch { /* not JSON */ }++ return { status: r.status, headers: r.headers, text, body: json };++ }++ const shownOnce = []; // responses that legitimately carry a secret once (checked for no-store by the suites)++ const once = (what, r) => { shownOnce.push({ what, status: r.status, cache: r.headers.get('cache-control') || '' }); return r; };++++ // Service principal tokens (client_credentials) and the legacy key.++ async function serviceToken(clientId, audience = 'openvibe.network') {++ const r = once(`service token ${clientId}`, await call(null, 'POST', '/oauth/token', new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecrets[clientId], audience })));++ if (r.status !== 200) throw new Error(`service token for ${clientId}: ${r.status} ${r.text}`);++ return r.body.access_token;++ }++ const liveToken = await serviceToken('live');++++ // ── Developer projects ───────────────────────────────────────────++ const must = (r, status, what) => { if (r.status !== status) throw new Error(`${what}: ${r.status} ${r.text.slice(0, 300)}`); return r; };++ const dev = {};++ for (const [owner, name] of [['alice', 'PA'], ['bob', 'PB']]) {++ const p = must(await call(owner, 'POST', '/api/v1/projects', { name: `${owner} project` }), 201, `${owner} project`).body;++ const a = once(`app creation ${name}`, must(await call(owner, 'POST', `/api/v1/projects/${p.id}/apps`, { name: `${owner} app`, environment: 'sandbox', type: 'confidential' }), 201, `${owner} app`));++ dev[name] = { id: p.id, app: a.body.id, clientId: a.body.oauth_client_id || a.body.client_id || a.body.id, secret: a.body.credential.client_secret, credential: a.body.credential.id, owner };++ secrets[`dev credential ${name}`] = dev[name].secret;++ }++ // Rotation overlap: alice's old secret keeps working while the new one exists.++ const rot = once('credential rotation', must(await call('alice', 'POST', `/api/v1/projects/${dev.PA.id}/apps/${dev.PA.app}/credentials/rotate`, {}), 201, 'rotate'));++ dev.PA.secret2 = rot.body.credential.client_secret; dev.PA.credential2 = rot.body.credential.id;++ secrets['dev credential PA rotated'] = dev.PA.secret2;++ for (const c of db.prepare('SELECT id, secret_hash FROM dev_credentials').all()) secrets[`dev credential hash ${c.id}`] = c.secret_hash;++ // A grant request (pending) to have a grant row on each app.++ for (const P of ['PA', 'PB']) await call(dev[P].owner, 'POST', `/api/v1/projects/${dev[P].id}/apps/${dev[P].app}/grants`, { capability: 'media.object.upload' });++ const exp = once('export token', must(await call('alice', 'POST', `/api/v1/projects/${dev.PA.id}/export-tokens`, { audience: 'openvibe.media', env: 'sandbox' }), 201, 'export token'));++ secrets['export token PA'] = exp.body.access_token;++ // A developer app token (its project has no Network grant, so it is minted for Media; Network must refuse it everywhere).++ const appTok = once('app token', await call(null, 'POST', '/oauth/token', new URLSearchParams({ grant_type: 'client_credentials', client_id: dev.PA.clientId, client_secret: dev.PA.secret2, audience: 'openvibe.media' })));++ const appToken = appTok.status === 200 ? appTok.body.access_token : null;++++ // ── Per-person data ──────────────────────────────────────────────++ await call('alice', 'PUT', '/api/modules/ai.preferences', { data: { style: 'casual', history: false } }, { 'if-match': '0' });++ await call('alice', 'PUT', '/api/modules/chat.tts_defaults', { data: { send: true, volume: 37 } }, { 'if-match': '0' });++ await call('alice', 'PUT', `/api/v1/me/blocks/${users.carol.subject}`, {});++ await call('alice', 'PUT', `/api/v1/me/follows/user/${users.bob.subject}`, {});++ await call({ authorization: `Bearer ${liveToken}` }, 'POST', '/internal/notifications/push', { user_id: users.alice.id, type: 'system', category: 'system', title: 'alice-private-notification', message: 'for alice only', service: 'live' });++ const sess = once('session creation', await call('alice', 'POST', '/api/auth/sessions', { device_name: 'alice-laptop' }));++ if (sess.body && sess.body.session_token) secrets['alice session token'] = sess.body.session_token;++ await call({ 'x-internal-key': sentinels.INTERNAL_API_KEY }, 'POST', '/internal/link-account', { user_id: users.alice.id, service: 'games', service_user_id: 'alice-games-771' });++ await call('alice', 'POST', '/api/history', { service: 'live', url: 'https://openvibe.live/@alice-private-history', title: 'alice-private-history' });++ const pushSub = { endpoint: 'https://push.services.mozilla.com/wpush/v2/alice-endpoint-capability-0001', keys: { p256dh: vapid.publicKey, auth: 'alice-push-auth-secret' } };++ await call('alice', 'POST', '/api/push/subscribe', { subscription: pushSub });++ secrets['alice push endpoint'] = pushSub.endpoint;++ secrets['alice push auth'] = pushSub.keys.auth;++ // A refresh token through the OAuth code flow (client 'live').++ const REDIRECT = (JSON.parse(db.prepare("SELECT redirect_uris FROM oauth_clients WHERE client_id = 'live'").get().redirect_uris) || [])[0];++ const conf = await call(null, 'POST', '/oauth/confirm', { token: users.alice.token, client_id: 'live', redirect_uri: REDIRECT });++ if (conf.status === 200 && conf.body && conf.body.redirect) {++ const code = new URL(conf.body.redirect).searchParams.get('code');++ const t = once('oauth code exchange', await call(null, 'POST', '/oauth/token', { grant_type: 'authorization_code', client_id: 'live', client_secret: clientSecrets.live, code, redirect_uri: REDIRECT }));++ if (t.body && t.body.refresh_token) secrets['alice refresh token'] = t.body.refresh_token;++ }++ for (const r of db.prepare('SELECT token FROM oauth_tokens').all()) secrets[`refresh token hash ${r.token.slice(0, 6)}`] = r.token;++ // An anonymous identity seen from another address.++ const anonToken = `anon-${crypto.randomBytes(12).toString('hex')}`;++ db.prepare('INSERT INTO anon_users (anon_number, session_token, ip) VALUES (?, ?, ?)').run(900001, anonToken, '192.0.2.77');++ secrets['anon session token'] = anonToken;++ // Provider-secret copies in the database (the environment wins, but the copies are secrets too).++ for (const k of ['resend_api_key', 'resend_webhook_secret', 'discord_bot_token', 'discord_oauth_client_secret', 'github_token', 'net.ipinfo_token', 'ses_secret_access_key']) {++ const v = S(`db-${k.replace(/[._]/g, '-')}`);++ db.prepare("INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, 'secret')").run(k, v);++ secrets[`db setting ${k}`] = v;++ }++ const vk = `VK-${crypto.randomBytes(8).toString('hex')}`;++ db.prepare('INSERT INTO verification_keys (key, target_username, created_by) VALUES (?, ?, ?)').run(vk, 'carol', rows.staff.id);++ // A per-user password hash is never shown to anyone.++ for (const u of ['alice', 'bob', 'staff']) { db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(`$2a$10$${u}${crypto.randomBytes(20).toString('hex')}`, users[u].id); secrets[`${u} password hash`] = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(users[u].id).password_hash; }++++ const callers = {++ anonymous: {},++ user: { authorization: `Bearer ${users.bob.token}`, cookie: `ov_token=${users.bob.token}` },++ admin: { authorization: `Bearer ${users.staff.token}`, cookie: `ov_token=${users.staff.token}` },++ owner: { authorization: `Bearer ${users.owner.token}`, cookie: `ov_token=${users.owner.token}` },++ service: { authorization: `Bearer ${liveToken}` },++ ...(appToken ? { app: { authorization: `Bearer ${appToken}` } } : {}),++ };++ return {++ dir: srv.dir, srv, db, keys, vapid, issuer: ISSUER, routes, sentinels, secrets, users, dev, callers, clientSecrets,++ liveToken, appToken, anonToken, verificationKey: vk, pushSub, shownOnce, call, serviceToken, sign, S,++ stop: async () => { try { db.close(); } catch { /* */ } await srv.stop(); try { fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(srv.dir, { recursive: true, force: true }); } catch { /* */ } },++ };++}++++module.exports = { buildWorld, sentinelEnv, pemChunks, S };
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/follows.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js",
+ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/follows.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js",
"reset-db": "node server/reset-db.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -301,6 +301,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -301,6 +301,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// Core site settings are owner-only (off-limits to admins).
// Core site settings are owner-only (off-limits to admins).
router.get('/settings', requireOwner, (req, res) => {
router.get('/settings', requireOwner, (req, res) => {
+ res.set('Cache-Control', 'private, no-store'); // the owner sees secrets in clear here
try {
try {
const rows = db.prepare('SELECT * FROM site_settings').all();
const rows = db.prepare('SELECT * FROM site_settings').all();
const settings = {};
const settings = {};
@@ -396,6 +397,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -396,6 +397,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// URL Registry
// URL Registry
router.get('/url-registry', (req, res) => {
router.get('/url-registry', (req, res) => {
+ res.set('Cache-Control', 'private, no-store'); // the owner sees secret-typed values in clear here
try {
try {
let entries = urlRegistry.getAllRegistryEntries(db);
let entries = urlRegistry.getAllRegistryEntries(db);
// Mask secret-typed registry values (e.g. DEPLOY_CLOUDFLARE_TOKEN) for non-owners.
// Mask secret-typed registry values (e.g. DEPLOY_CLOUDFLARE_TOKEN) for non-owners.
@@ -216,6 +216,7 @@ router.post('/token', (req, res) => {
@@ -216,6 +216,7 @@ router.post('/token', (req, res) => {
const db = getDb(req);
const db = getDb(req);
const config = getConfig(req);
const config = getConfig(req);
const { grant_type, client_id, client_secret, code, redirect_uri, refresh_token } = req.body;
const { grant_type, client_id, client_secret, code, redirect_uri, refresh_token } = req.body;
+ res.set('Cache-Control', 'no-store'); // every grant answers with tokens (RFC 6749 §5.1)
// Developer apps (app_<ULID>): client_credentials or authorization_code + PKCE, own credential store.
// Developer apps (app_<ULID>): client_credentials or authorization_code + PKCE, own credential store.
if (devTokens.isAppClient(client_id)) {
if (devTokens.isAppClient(client_id)) {
@@ -14,6 +14,9 @@ const subjects = require('../identity/subjects');
@@ -14,6 +14,9 @@ const subjects = require('../identity/subjects');
const { staffClaims } = require('./staff-claims');
const { staffClaims } = require('./staff-claims');
const revocation = require('./revocation');
const revocation = require('./revocation');
const router = express.Router();
const router = express.Router();
+// Sign-in, refresh, password change, sessions and anonymous identities answer with tokens: none of
+// these answers may be kept by a browser or proxy cache (a route may still set its own policy).
+router.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
// ── Helpers ──────────────────────────────────────────────────
// ── Helpers ──────────────────────────────────────────────────
@@ -457,7 +457,10 @@ app.use(require('./status/routes').createStatusRoutes({ ecosystem }));
@@ -457,7 +457,10 @@ app.use(require('./status/routes').createStatusRoutes({ ecosystem }));
app.use(require('./updates/routes').createUpdatesRoutes({ blogUrl: process.env.OV_BLOG_INTERNAL_URL || 'http://127.0.0.1:4810' }).router);
app.use(require('./updates/routes').createUpdatesRoutes({ blogUrl: process.env.OV_BLOG_INTERNAL_URL || 'http://127.0.0.1:4810' }).router);
app.get('/api/.well-known/jwks', (_req, res) => {
app.get('/api/.well-known/jwks', (_req, res) => {
- const out = { public_key: publicKey, algorithm: privateKey === publicKey ? 'HS256' : 'RS256' };
+ // Without key files the ephemeral HS256 "key pair" is one shared secret: publishing it would let
+ // anyone sign sessions. Only a real public key is ever served.
+ if (privateKey === publicKey || !String(publicKey).includes('BEGIN')) return res.json({ algorithm: 'HS256', keys: [] });
+ const out = { public_key: publicKey, algorithm: 'RS256' };
if (publicKey.includes('BEGIN')) {
if (publicKey.includes('BEGIN')) {
try {
try {
const jwk = require('crypto').createPublicKey(publicKey).export({ format: 'jwk' });
const jwk = require('crypto').createPublicKey(publicKey).export({ format: 'jwk' });
@@ -242,11 +242,15 @@ router.get('/stats', (req, res) => {
@@ -242,11 +242,15 @@ router.get('/stats', (req, res) => {
});
});
// Service URLs only (no secrets); any first-party service that resolves identities may read them.
// Service URLs only (no secrets); any first-party service that resolves identities may read them.
+// Secret-typed entries (DEPLOY_CLOUDFLARE_TOKEN) are left out: they are the owner's, not the services'.
// TODO(contracts): a network.registry.read capability would say it more exactly.
// TODO(contracts): a network.registry.read capability would say it more exactly.
router.get('/url-registry/resolved', principals.guard('identity.subject.resolve'), (req, res) => {
router.get('/url-registry/resolved', principals.guard('identity.subject.resolve'), (req, res) => {
try {
try {
const db = getDb(req);
const db = getDb(req);
- const resolved = urlRegistry.getResolvedRegistry(db, process.env);
+ const { URL_DEFINITIONS } = require('openvibe-shared/url-resolver');
+ const { isSensitiveSettingKey } = require('../auth/owner-guard');
+ const resolved = Object.fromEntries(Object.entries(urlRegistry.getResolvedRegistry(db, process.env))
+ .filter(([key]) => !((URL_DEFINITIONS[key] || {}).type === 'secret' || isSensitiveSettingKey(key))));
res.json({ ok: true, registry: resolved });
res.json({ ok: true, registry: resolved });
} catch (err) {
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
res.status(500).json({ ok: false, error: err.message });
new file mode 100644
new file mode 100644
@@ -0,0 +1,193 @@
@@ -0,0 +1,193 @@
+'use strict';
+/**
+ * The crawler behind the security suites (roadmap WS-R task 5): lists every route of the booted
+ * Network (walking Express's router stack inside the server process, 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. Not a test itself (no .test.js); used with test/helpers/boot-server.js.
+ *
+ * The server runs in a child process, so the route list comes from a preload (routeDumpPreload())
+ * that wraps express's app.listen and writes the router stack to a file once the server listens; the
+ * preload also refuses every TCP connection off loopback.
+ */
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.join(__dirname, '..');
+
+/** 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 an Express app: [{ path, methods }] ('_all' for mounted middleware with a path). */
+function listAppRoutes(app) {
+ 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; });
+}
+
+/**
+ * Source of a preload for the child server: once app.listen() is listening, writes the route list
+ * (listAppRoutes) as JSON to process.env.OV_ROUTE_DUMP. Also keeps the process from reaching anything
+ * but loopback (as boot-server's own guard does), whatever client library connects.
+ */
+function routeDumpPreload() {
+ return `'use strict';
+const path = require('path');
+const express = require(${JSON.stringify(path.join(ROOT, 'node_modules', 'express'))});
+const { listAppRoutes } = require(${JSON.stringify(__filename)});
+const listen = express.application.listen;
+express.application.listen = function (...args) {
+ const app = this;
+ const server = listen.apply(app, args);
+ server.once('listening', () => {
+ try { require('fs').writeFileSync(process.env.OV_ROUTE_DUMP, JSON.stringify(listAppRoutes(app))); } catch (e) { console.error('route dump failed', e.message); }
+ });
+ return server;
+};
+// No traffic off the machine by any client (fetch, http(s), undici, discord.js, ws): every TCP
+// connect to something other than loopback fails the way an unreachable host does.
+const net = require('net');
+const realConnect = net.Socket.prototype.connect;
+net.Socket.prototype.connect = function (...args) {
+ let o = args[0];
+ if (Array.isArray(o)) o = o[0];
+ if (o && typeof o === 'object' && o.path) return realConnect.apply(this, args);
+ const host = String((o && typeof o === 'object') ? (o.host || o.hostname || 'localhost') : (typeof args[1] === 'string' ? args[1] : 'localhost')).replace(/^\\[|\\]$/g, '');
+ const port = (o && typeof o === 'object') ? o.port : args[0];
+ if (process.env.OV_CONNECT_LOG) { try { require('fs').appendFileSync(process.env.OV_CONNECT_LOG, host + ' ' + port + '\\n'); } catch { /* */ } }
+ if (host === '127.0.0.1' || host === 'localhost' || host === '::1') return realConnect.apply(this, args);
+ process.nextTick(() => this.destroy(Object.assign(new Error('test: no egress to ' + host), { code: 'ECONNREFUSED' })));
+ return this;
+};
+`;
+}
+
+/** Writes the preload into `dir` and returns the env that loads it: { NODE_OPTIONS, OV_ROUTE_DUMP, OV_CONNECT_LOG } (every TCP connect is logged as 'host port'). */
+function routeDumpEnv(dir) {
+ const file = path.join(dir, 'route-dump-preload.js');
+ fs.writeFileSync(file, routeDumpPreload());
+ return { NODE_OPTIONS: `--require ${file}`, OV_ROUTE_DUMP: path.join(dir, 'routes.json'), OV_CONNECT_LOG: path.join(dir, 'connects.log') };
+}
+
+/** The route list the child wrote (waits briefly for it). */
+async function readRoutes(dumpFile) {
+ for (let i = 0; i < 50; i++) {
+ try { return JSON.parse(fs.readFileSync(dumpFile, 'utf8')); } catch { await new Promise((r) => setTimeout(r, 100)); }
+ }
+ throw new Error('the server never wrote its route list');
+}
+
+/** 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` ('get', or a write method): each route expanded, also with `query` appended, plus `extra`. */
+function pathsFor(routes, values, { method = 'get', query = '', 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);
+ if (query && !p.includes('?')) paths.add(`${p}?${query}`);
+ }
+ }
+ 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 }) `text` + `headers` carry: [{ 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;
+}
+
+/**
+ * Requests every path as every caller ({ who: headers }) with `method` (and `body` for writes).
+ * `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;
+ let n = 0;
+ const nextIp = () => { n++; return `198.19.${(n >> 8) & 255}.${n & 255}`; }; // the API limiter counts per address
+ 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 {
+ const h = { 'x-forwarded-for': nextIp(), ...headers };
+ let b;
+ if (body !== undefined) { if (typeof body === 'string') b = body; else { b = JSON.stringify(body); h['content-type'] = 'application/json'; } }
+ const res = await fetch(base + p, { method, headers: h, body: b, redirect: 'manual' });
+ r = { status: res.status, headers: res.headers, text: await res.text() };
+ } 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, listAppRoutes, routeDumpPreload, routeDumpEnv, readRoutes, expand, pathsFor, forms, leaks, crawl };
new file mode 100644
new file mode 100644
@@ -0,0 +1,197 @@
@@ -0,0 +1,197 @@
+'use strict';
+// Internal-secret leak (roadmap WS-R task 5). The real server boots with a throwaway RS256 pair, a
+// generated VAPID pair and a fake low-entropy sentinel in every other secret it reads from the
+// environment (test/security-world.js), plus database-held secrets: first-party OAuth client
+// secrets, developer app credentials (two overlapping after a rotation) and their hashes, an export
+// token, a refresh token and its hash, a session token, an anonymous identity's token, push keys,
+// provider-secret copies in site_settings, password hashes and a verification key.
+// Every GET route Express knows after boot (test/security-crawl.js walks the router, so a route added
+// later is crawled without anyone listing it) is requested with seeded and nonsense ids, with and
+// without a query string, plus probes for files that must never be served, as: anonymous, a user,
+// a staff admin, the owner, a service principal (live), a developer app and the legacy internal key.
+// Then every write route is sent malformed JSON (and anonymous an empty body), and wrong credentials.
+// No body or header may carry any secret (as is, URL-encoded or base64); the responses that show a
+// secret once (token, credential and session creation) must be Cache-Control: no-store. The log, the
+// event outbox and the audit tables written meanwhile must not carry one either. Also: a Network booted
+// without its key files never publishes its ephemeral HS256 secret as a "public key".
+// node test/security-secrets.test.js
+const assert = require('assert');
+const jwt = require('jsonwebtoken');
+const { buildWorld } = require('./security-world');
+const { bootServer } = require('./helpers/boot-server');
+const crawler = require('./security-crawl');
+
+const out = (...a) => process.stdout.write(a.join(' ') + '\n');
+
+(async () => {
+ const t0 = Date.now();
+ const w = await buildWorld({ label: 'secrets' });
+ try {
+ const { users, dev } = w;
+ const values = (name) => ({
+ project: [dev.PA.id, dev.PB.id, 'prj_00000000000000000000000000', '..%2f'],
+ app: [dev.PA.app, dev.PB.app, 'app_00000000000000000000000000', 'x'],
+ credential: [dev.PA.credential, dev.PB.credential, 'crd_x'],
+ capability: ['media.object.upload', 'identity.subject.resolve', 'x'],
+ subject: [users.alice.subject, users.bob.subject, 'usr_x'],
+ target: [users.alice.subject, users.bob.subject, 'x'],
+ type: ['user', 'channel', 'x'],
+ creator: [users.alice.subject, 'alice', 'x'],
+ id: [String(users.alice.id), String(users.owner.id), '1', '999999', 'x', '-1'],
+ userId: [String(users.alice.id), String(users.bob.id), '999999'],
+ username: ['alice', 'rootowner', 'nobody'],
+ name: ['alice', 'live', 'network', 'nobody'],
+ ns: ['ai.preferences', 'chat.tts_defaults', 'live.profile', 'x'],
+ namespace: ['general', 'network', 'x'],
+ key: ['DEPLOY_CLOUDFLARE_TOKEN', 'OV_LIVE_URL', 'x'],
+ token: ['not-a-token', 'x'],
+ idOrSlug: ['default', 'x'],
+ serviceId: ['live', 'network', 'x'],
+ domain: ['openvibe.live', 'x'],
+ topic: ['live.stream.started', 'x'],
+ category: ['system', 'x'],
+ }[name] || ['1', 'x']);
+ const extra = ['/api/nope', '/internal/nope', '/INTERNAL/users/1', '/.env', '/.git/config', '/package.json', '/server/config.js',
+ '/data/network.db', '/data/keys/private.pem', '/keys/private.pem', '/private.pem', '/network.db', '/data/avatars/..%2fnetwork.db',
+ '/data/avatars/%2e%2e/network.db', '/shared/..%2f..%2fpackage.json', '/node_modules/.package-lock.json', '/api/.well-known/jwks',
+ '/.well-known/openid-configuration', '/oauth/.well-known/openid-configuration', '/api/ready', '/metrics', '/release.json', '/status',
+ '/api/v1/status', '/api/admin/secrets', '/api/admin/settings', '/api/admin/config', '/api/admin/url-registry', '/api/admin/email',
+ '/api/admin/discord/', '/api/admin/integrations/github/', '/api/admin/deploy/config', '/internal/url-registry/resolved',
+ '/internal/integrations/github-token', '/api/setup/status', '/oauth/authorize?client_id=live&redirect_uri=https%3A%2F%2Fevil.test%2F&response_type=code',
+ '/oauth/client-info?client_id=live', `/oauth/client-info?client_id=${dev.PA.app}`, '/api/auth/anon-identities', '/sso/check', '/fedcm/accounts'];
+ const paths = crawler.pathsFor(w.routes, values, { method: 'get', query: 'limit=5&all=1&debug=1&include=secret', extra });
+ const tokenOf = (h) => String((h && h.authorization) || '').replace(/^Bearer /, '');
+ const people = { ...w.callers, legacy: { 'x-internal-key': w.sentinels.INTERNAL_API_KEY } };
+ // What each caller must never see: every secret, plus every other caller's credential. Staff
+ // admins and the owner list verification keys (they make them to hand out); nobody else sees one.
+ const needlesFor = (who) => {
+ const n = { ...w.secrets };
+ for (const [other, h] of Object.entries(people)) if (other !== who && tokenOf(h)) n[`${other}'s bearer token`] = tokenOf(h);
+ if (who !== 'admin' && who !== 'owner') n['verification key'] = w.verificationKey;
+ return n;
+ };
+
+ // ── Every GET, as everyone ─────────────────────────────────────
+ const gets = await crawler.crawl(w.srv.base, paths, people, needlesFor);
+ out(`GET crawl: ${paths.length} paths x ${Object.keys(people).length} callers, ${JSON.stringify(gets.statuses)}`);
+ // The one documented exception (server/auth/owner-guard.js): the owner, and only the owner,
+ // reads provider secrets in the admin settings and the secret-typed registry values in clear,
+ // to change them; staff admins get them masked. Those two reads must not be cached.
+ const OWNER_READS = /^owner: GET \/api\/admin\/(settings|url-registry)(\?[^ ]*)? → 200 carries (db setting [a-z_.]+|env DEPLOY_CLOUDFLARE_TOKEN) in its body$/;
+ const ownerReads = gets.found.filter((f) => OWNER_READS.test(f));
+ assert.ok(ownerReads.length > 0, 'the owner exception is still exercised');
+ for (const p of ['/api/admin/settings', '/api/admin/url-registry']) {
+ const r = await w.call(people.owner, 'GET', p);
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('cache-control') || '', /no-store/, `${p}: the owner's clear-text read is never cached`);
+ const staff = await w.call(people.admin, 'GET', p);
+ assert.ok(staff.status === 403 || (staff.status === 200 && crawler.leaks(staff, needlesFor('admin')).length === 0), `${p}: staff admins get it masked or not at all`);
+ }
+ const leaked = gets.found.filter((f) => !OWNER_READS.test(f));
+ assert.deepStrictEqual(leaked, [], `secrets in GET responses:\n${leaked.join('\n')}`);
+ assert.ok(paths.length >= 300 && gets.answered === paths.length * Object.keys(people).length, 'every request was answered');
+ assert.ok((gets.statuses['2xx'] || 0) > 500, 'the crawl reached real pages, not only refusals');
+ assert.strictEqual(Object.values(gets.byPath).filter((s) => s === 429).length, 0, 'no request was rate-limited away');
+ // The crawl really was signed in: each caller reaches something only it can.
+ const st = (who, p) => gets.byPath[`${who} GET ${p}`];
+ assert.strictEqual(st('user', '/api/auth/me'), 200);
+ assert.strictEqual(st('anonymous', '/api/auth/me'), 401);
+ assert.strictEqual(st('admin', '/api/admin/url-registry'), 200);
+ assert.strictEqual(st('admin', '/api/admin/settings'), 403, 'settings are the owner\'s');
+ assert.strictEqual(st('user', '/api/admin/url-registry'), 403);
+ assert.strictEqual(st('owner', '/api/admin/secrets'), 200);
+ assert.strictEqual(st('owner', '/api/admin/settings'), 200);
+ assert.strictEqual(st('service', '/internal/url-registry/resolved'), 200, 'live may read the resolved registry');
+ assert.strictEqual(st('legacy', '/internal/url-registry/resolved'), 200);
+ assert.strictEqual(st('anonymous', '/internal/url-registry/resolved'), 403);
+
+ // JWKS and OIDC discovery: the public key, never a private part.
+ const jwks = await w.call(null, 'GET', '/api/.well-known/jwks');
+ assert.strictEqual(jwks.status, 200);
+ assert.ok(jwks.body.public_key.includes('BEGIN PUBLIC KEY') && jwks.body.keys.length === 1);
+ for (const f of ['d', 'p', 'q', 'dp', 'dq', 'qi']) assert.ok(!(f in jwks.body.keys[0]), `JWKS has no private member ${f}`);
+ assert.ok(!/PRIVATE/.test(jwks.text));
+
+ // ── Error paths: every write route, refused or malformed ─────────
+ const writes = {};
+ for (const m of ['post', 'put', 'patch', 'delete']) writes[m] = crawler.pathsFor(w.routes, values, { method: m });
+ const found = [];
+ let sent = 0;
+ for (const [m, list] of Object.entries(writes)) {
+ const M = m.toUpperCase();
+ for (const [body, callers] of [
+ ['{"broken": ', people], // malformed JSON, everyone
+ ['{}', { anonymous: {}, app: people.app || {} }], // empty body without the right credential
+ ]) {
+ const r = await crawler.crawl(w.srv.base, list, callers, needlesFor, { method: M, body });
+ sent += list.length * Object.keys(callers).length;
+ found.push(...r.found);
+ }
+ }
+ // Wrong credentials of every kind on the paths that take them.
+ const wrong = {
+ 'wrong internal key': { 'x-internal-key': w.sentinels.INTERNAL_API_KEY.slice(0, -1) + 'x' },
+ 'forged bearer': { authorization: `Bearer ${jwt.sign({ sub: users.owner.id, id: users.owner.id }, 'guess', { issuer: w.issuer })}` },
+ 'none alg bearer': { authorization: `Bearer ${jwt.sign({ sub: users.owner.id, id: users.owner.id }, null, { algorithm: 'none' })}` },
+ 'setup token guess': { 'x-setup-token': 'x', authorization: 'Bearer x' },
+ };
+ const wr = await crawler.crawl(w.srv.base, [...writes.post.filter((p) => /^\/(internal|oauth|api\/setup|api\/admin)/.test(p)), ...paths.filter((p) => /^\/(internal|api\/admin)/.test(p))], wrong, needlesFor, { method: 'GET' });
+ found.push(...wr.found);
+ for (const [grant, form] of [
+ ['client_credentials', { client_id: 'live', client_secret: 'wrong' }],
+ ['client_credentials', { client_id: dev.PA.clientId, client_secret: 'ovsec_wrong' }],
+ ['authorization_code', { client_id: 'live', client_secret: w.clientSecrets.live, code: 'nope', redirect_uri: 'https://evil.test/' }],
+ ['refresh_token', { client_id: 'live', client_secret: w.clientSecrets.live, refresh_token: 'f'.repeat(96) }],
+ ]) {
+ const r = await w.call(null, 'POST', '/oauth/token', new URLSearchParams({ grant_type: grant, ...form }));
+ assert.ok(r.status >= 400, `${grant} with wrong material is refused`);
+ for (const l of crawler.leaks(r, needlesFor('anonymous'))) found.push(`oauth ${grant} refusal carries ${l.label}`);
+ }
+ out(`write/error crawl: ${sent} requests`);
+ assert.deepStrictEqual(found, [], `secrets in error responses:\n${found.join('\n')}`);
+
+ // ── Shown once, never cached ─────────────────────────────────────
+ for (const s of w.shownOnce.filter((x) => x.status >= 200 && x.status < 300)) assert.match(s.cache, /no-store/, `${s.what}: Cache-Control no-store`);
+ assert.ok(w.shownOnce.filter((x) => x.status >= 200 && x.status < 300).length >= 6, 'the shown-once responses were made');
+ // The other answers that carry a token: refresh, an anonymous session, the anonymous identities of this address.
+ for (const [who, m, p, body] of [['alice', 'POST', '/api/auth/refresh', {}], [null, 'POST', '/api/auth/anon-session', {}], [null, 'GET', '/api/auth/anon-identities'], [null, 'POST', '/api/auth/login', { username: 'alice', password: 'wrong' }]]) {
+ const r = await w.call(who, m, p, body);
+ assert.match(r.headers.get('cache-control') || '', /no-store/, `${m} ${p} (${r.status}): Cache-Control no-store`);
+ }
+
+ // ── Logs, outbox and audit tables ────────────────────────────────
+ const all = { ...needlesFor('nobody'), 'verification key': w.verificationKey };
+ delete all['verification key']; // stored in its own table by design; only its readers are checked above
+ const logHits = crawler.leaks({ text: w.srv.logs(), headers: {} }, all).map((l) => l.label);
+ assert.deepStrictEqual(logHits, [], 'nothing secret was logged');
+ const tables = w.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND (name LIKE '%outbox%' OR name LIKE '%audit%' OR name LIKE '%log%' OR name LIKE '%usage%' OR name LIKE '%changes%' OR name LIKE '%alerts%' OR name LIKE '%revisions%' OR name = 'notifications')").all().map((r) => r.name);
+ assert.ok(tables.includes('network_event_outbox') && tables.includes('dev_audit') && tables.includes('audit_log'), tables.join(','));
+ const rowHits = [];
+ for (const t of tables) {
+ const text = JSON.stringify(w.db.prepare(`SELECT * FROM "${t}"`).all());
+ for (const l of crawler.leaks({ text, headers: {} }, all)) rowHits.push(`${t}: ${l.label}`);
+ }
+ assert.deepStrictEqual(rowHits, [], 'no secret in the outbox, audit or log tables');
+ assert.ok(w.db.prepare('SELECT COUNT(*) AS n FROM network_event_outbox').get().n > 0, 'the outbox was written meanwhile');
+ } finally {
+ await w.stop();
+ }
+
+ // ── Without key files: the ephemeral HS256 secret is never published ───
+ const bare = await bootServer();
+ try {
+ const r = await fetch(`${bare.base}/api/.well-known/jwks`);
+ const body = await r.json();
+ const k = body.public_key;
+ if (k && !String(k).includes('BEGIN')) {
+ // What the published "key" would let anyone do: sign a session for the first admin.
+ const forged = jwt.sign({ sub: 1, id: 1, username: 'admin', role: 'admin' }, k, { algorithm: 'HS256', issuer: 'https://openvibe.network', expiresIn: '1h' });
+ const me = await fetch(`${bare.base}/api/auth/me`, { headers: { authorization: `Bearer ${forged}` } });
+ assert.notStrictEqual(me.status, 200, 'a session signed with the published HS256 secret is refused');
+ }
+ assert.ok(!k || String(k).includes('BEGIN PUBLIC KEY'), 'a keyless Network publishes no HS256 secret as its public key');
+ } finally {
+ await bare.stop();
+ }
+ out(`security secrets: all checks passed (${((Date.now() - t0) / 1000).toFixed(1)} s)`);
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,223 @@
@@ -0,0 +1,223 @@
+'use strict';
+/**
+ * The world the security suites run against (roadmap WS-R task 5): the real server/index.js booted in
+ * a child process (test/helpers/boot-server.js) on a scratch database, with a throwaway RS256 key
+ * pair and VAPID pair generated here, an obviously fake low-entropy sentinel in every other secret
+ * the server reads from its environment, and a seeded population:
+ * people alice, bob (users), carol (a third user), staff (role admin, not the owner),
+ * owner (ADMIN_USERNAME, created by the server itself from ADMIN_PASSWORD; OWNER_USERNAME)
+ * developer projects: alice's PA (app AA, confidential sandbox, its credential rotated so two
+ * secrets overlap) and bob's PB (app AB); an export token minted for PA
+ * per-person data: alice's modules, blocks, follows, notifications, sessions, linked account,
+ * history, push subscription, refresh token (OAuth code flow); an anonymous identity;
+ * provider-secret copies in site_settings; a verification key; a closed old incident
+ * `secrets` collects every secret value with a label, for the suites to look for in responses.
+ * Not a test itself (no .test.js).
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const jwt = require('jsonwebtoken');
+const Database = require('better-sqlite3');
+const { bootServer } = require('./helpers/boot-server');
+const crawler = require('./security-crawl');
+
+const S = (name) => `sentinel-not-a-secret-${name}`;
+
+/** Every secret-typed environment variable Network (or a module it loads) reads, with its sentinel. */
+function sentinelEnv() {
+ return {
+ INTERNAL_API_KEY: S('internal-api-key'),
+ SETUP_TOKEN: S('setup-token'),
+ NETWORK_EVENTS_SECRET: S('network-events-secret'),
+ OV_LIVE_WEBHOOK_SECRET: S('live-webhook-secret'),
+ OV_GAMES_WEBHOOK_SECRET: S('games-webhook-secret'),
+ ADMIN_PASSWORD: S('admin-password'),
+ RESEND_API_KEY: S('resend-api-key'),
+ RESEND_WEBHOOK_SECRET: S('resend-webhook-secret'),
+ DISCORD_BOT_TOKEN: S('discord-bot-token'),
+ DISCORD_OAUTH_CLIENT_SECRET: S('discord-oauth-client-secret'),
+ GITHUB_TOKEN: S('github-token'),
+ DEPLOY_CLOUDFLARE_TOKEN: S('deploy-cloudflare-token'),
+ OV_OAUTH_CLIENT_SECRET: S('ov-oauth-client-secret'),
+ NET_IPINFO_TOKEN: S('net-ipinfo-token'),
+ NET_GLOBALPING_TOKEN: S('net-globalping-token'),
+ };
+}
+
+/** Base64 runs of a PEM body worth looking for (each 64-char line, and the joined body). */
+function pemChunks(pem) {
+ const lines = String(pem).split('\n').filter((l) => l && !l.startsWith('-----'));
+ return { lines, body: lines.join('') };
+}
+
+async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
+ const os = require('os');
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `ov-${label}-`));
+ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+ fs.mkdirSync(path.join(dir, 'keys'));
+ fs.writeFileSync(path.join(dir, 'keys', 'private.pem'), keys.privateKey);
+ fs.writeFileSync(path.join(dir, 'keys', 'public.pem'), keys.publicKey);
+ const vapid = require('web-push').generateVAPIDKeys();
+ const sentinels = sentinelEnv();
+ const dump = crawler.routeDumpEnv(dir);
+ const ISSUER = 'https://openvibe.network';
+ const srv = await bootServer({
+ env: {
+ ...sentinels,
+ VAPID_PRIVATE_KEY: vapid.privateKey, VAPID_PUBLIC_KEY: vapid.publicKey,
+ JWT_PRIVATE_KEY: path.join(dir, 'keys', 'private.pem'), JWT_PUBLIC_KEY: path.join(dir, 'keys', 'public.pem'),
+ ADMIN_USERNAME: 'rootowner', OWNER_USERNAME: 'rootowner', BASE_URL: ISSUER, OV_NETWORK_URL: ISSUER,
+ DEV_SANDBOX_AUDIENCES: 'openvibe.media,openvibe.network', DEV_CREDENTIAL_OVERLAP_S: '3600',
+ ...dump, ...extraEnv,
+ },
+ });
+ const routes = await crawler.readRoutes(dump.OV_ROUTE_DUMP);
+ const db = new Database(path.join(srv.dir, 'network.db'));
+ db.pragma('busy_timeout = 5000');
+
+ // ── Secrets, by label ────────────────────────────────────────────
+ const secrets = {};
+ for (const [k, v] of Object.entries(sentinels)) secrets[`env ${k}`] = v;
+ secrets['env VAPID_PRIVATE_KEY'] = vapid.privateKey;
+ const priv = pemChunks(keys.privateKey);
+ const pub = pemChunks(keys.publicKey);
+ priv.lines.forEach((l, i) => { if (!pub.body.includes(l)) secrets[`signing key PEM line ${i}`] = l; });
+ const jwk = crypto.createPrivateKey(keys.privateKey).export({ format: 'jwk' });
+ for (const f of ['d', 'p', 'q', 'dp', 'dq', 'qi']) secrets[`signing key JWK ${f}`] = jwk[f];
+
+ // ── People ───────────────────────────────────────────────────────
+ const subjects = require('../server/identity/subjects');
+ const hash = '$2a$10$' + 'x'.repeat(53);
+ const addUser = (username, role = 'user', email = null) => {
+ db.prepare('INSERT INTO users (username, email, password_hash, display_name, role) VALUES (?, ?, ?, ?, ?)').run(username, email, hash, username, role);
+ const row = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
+ subjects.ensureUserSubject(db, row);
+ return db.prepare('SELECT * FROM users WHERE id = ?').get(row.id);
+ };
+ const rows = {
+ alice: addUser('alice', 'user', '[email protected]'),
+ bob: addUser('bob', 'user', '[email protected]'),
+ carol: addUser('carol', 'user', '[email protected]'),
+ staff: addUser('staffer', 'admin', '[email protected]'),
+ };
+ const ownerRow = db.prepare("SELECT * FROM users WHERE username = 'rootowner'").get();
+ if (!ownerRow || ownerRow.role !== 'admin') throw new Error('the server did not create the ADMIN_USERNAME account');
+ subjects.ensureUserSubject(db, ownerRow);
+ rows.owner = db.prepare('SELECT * FROM users WHERE id = ?').get(ownerRow.id);
+ secrets['owner password hash'] = rows.owner.password_hash;
+ const sign = (u) => jwt.sign({ sub: u.id, id: u.id, username: u.username, role: u.role }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '2h' });
+ const users = {};
+ for (const [k, u] of Object.entries(rows)) users[k] = { id: u.id, username: u.username, subject: u.subject_id, email: u.email, token: sign(u) };
+
+ // First-party OAuth clients get unique secrets (stored in clear in oauth_clients).
+ const clientSecrets = {};
+ for (const c of db.prepare('SELECT client_id FROM oauth_clients').all()) {
+ clientSecrets[c.client_id] = `${S('client')}-${c.client_id}-${crypto.randomBytes(6).toString('hex')}`;
+ db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(clientSecrets[c.client_id], c.client_id);
+ secrets[`oauth client secret ${c.client_id}`] = clientSecrets[c.client_id];
+ }
+
+ // ── HTTP ─────────────────────────────────────────────────────────
+ let ipn = 0;
+ const nextIp = () => { ipn++; return `198.18.${(ipn >> 8) & 255}.${ipn & 255}`; };
+ const who = (w) => (typeof w === 'string' ? (users[w] ? { authorization: `Bearer ${users[w].token}` } : {}) : (w || {}));
+ async function call(w, method, p, body, headers = {}) {
+ const h = { 'x-forwarded-for': nextIp(), ...who(w), ...headers };
+ let b;
+ if (body !== undefined && body !== null) {
+ if (typeof body === 'string') b = body;
+ else if (body instanceof URLSearchParams) { b = body.toString(); h['content-type'] = 'application/x-www-form-urlencoded'; } else { b = JSON.stringify(body); h['content-type'] = h['content-type'] || 'application/json'; }
+ }
+ const r = await fetch(srv.base + p, { method, headers: h, body: b, redirect: 'manual' });
+ const text = await r.text();
+ let json = null; try { json = text ? JSON.parse(text) : null; } catch { /* not JSON */ }
+ return { status: r.status, headers: r.headers, text, body: json };
+ }
+ const shownOnce = []; // responses that legitimately carry a secret once (checked for no-store by the suites)
+ const once = (what, r) => { shownOnce.push({ what, status: r.status, cache: r.headers.get('cache-control') || '' }); return r; };
+
+ // Service principal tokens (client_credentials) and the legacy key.
+ async function serviceToken(clientId, audience = 'openvibe.network') {
+ const r = once(`service token ${clientId}`, await call(null, 'POST', '/oauth/token', new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecrets[clientId], audience })));
+ if (r.status !== 200) throw new Error(`service token for ${clientId}: ${r.status} ${r.text}`);
+ return r.body.access_token;
+ }
+ const liveToken = await serviceToken('live');
+
+ // ── Developer projects ───────────────────────────────────────────
+ const must = (r, status, what) => { if (r.status !== status) throw new Error(`${what}: ${r.status} ${r.text.slice(0, 300)}`); return r; };
+ const dev = {};
+ for (const [owner, name] of [['alice', 'PA'], ['bob', 'PB']]) {
+ const p = must(await call(owner, 'POST', '/api/v1/projects', { name: `${owner} project` }), 201, `${owner} project`).body;
+ const a = once(`app creation ${name}`, must(await call(owner, 'POST', `/api/v1/projects/${p.id}/apps`, { name: `${owner} app`, environment: 'sandbox', type: 'confidential' }), 201, `${owner} app`));
+ dev[name] = { id: p.id, app: a.body.id, clientId: a.body.oauth_client_id || a.body.client_id || a.body.id, secret: a.body.credential.client_secret, credential: a.body.credential.id, owner };
+ secrets[`dev credential ${name}`] = dev[name].secret;
+ }
+ // Rotation overlap: alice's old secret keeps working while the new one exists.
+ const rot = once('credential rotation', must(await call('alice', 'POST', `/api/v1/projects/${dev.PA.id}/apps/${dev.PA.app}/credentials/rotate`, {}), 201, 'rotate'));
+ dev.PA.secret2 = rot.body.credential.client_secret; dev.PA.credential2 = rot.body.credential.id;
+ secrets['dev credential PA rotated'] = dev.PA.secret2;
+ for (const c of db.prepare('SELECT id, secret_hash FROM dev_credentials').all()) secrets[`dev credential hash ${c.id}`] = c.secret_hash;
+ // A grant request (pending) to have a grant row on each app.
+ for (const P of ['PA', 'PB']) await call(dev[P].owner, 'POST', `/api/v1/projects/${dev[P].id}/apps/${dev[P].app}/grants`, { capability: 'media.object.upload' });
+ const exp = once('export token', must(await call('alice', 'POST', `/api/v1/projects/${dev.PA.id}/export-tokens`, { audience: 'openvibe.media', env: 'sandbox' }), 201, 'export token'));
+ secrets['export token PA'] = exp.body.access_token;
+ // A developer app token (its project has no Network grant, so it is minted for Media; Network must refuse it everywhere).
+ const appTok = once('app token', await call(null, 'POST', '/oauth/token', new URLSearchParams({ grant_type: 'client_credentials', client_id: dev.PA.clientId, client_secret: dev.PA.secret2, audience: 'openvibe.media' })));
+ const appToken = appTok.status === 200 ? appTok.body.access_token : null;
+
+ // ── Per-person data ──────────────────────────────────────────────
+ await call('alice', 'PUT', '/api/modules/ai.preferences', { data: { style: 'casual', history: false } }, { 'if-match': '0' });
+ await call('alice', 'PUT', '/api/modules/chat.tts_defaults', { data: { send: true, volume: 37 } }, { 'if-match': '0' });
+ await call('alice', 'PUT', `/api/v1/me/blocks/${users.carol.subject}`, {});
+ await call('alice', 'PUT', `/api/v1/me/follows/user/${users.bob.subject}`, {});
+ await call({ authorization: `Bearer ${liveToken}` }, 'POST', '/internal/notifications/push', { user_id: users.alice.id, type: 'system', category: 'system', title: 'alice-private-notification', message: 'for alice only', service: 'live' });
+ const sess = once('session creation', await call('alice', 'POST', '/api/auth/sessions', { device_name: 'alice-laptop' }));
+ if (sess.body && sess.body.session_token) secrets['alice session token'] = sess.body.session_token;
+ await call({ 'x-internal-key': sentinels.INTERNAL_API_KEY }, 'POST', '/internal/link-account', { user_id: users.alice.id, service: 'games', service_user_id: 'alice-games-771' });
+ await call('alice', 'POST', '/api/history', { service: 'live', url: 'https://openvibe.live/@alice-private-history', title: 'alice-private-history' });
+ const pushSub = { endpoint: 'https://push.services.mozilla.com/wpush/v2/alice-endpoint-capability-0001', keys: { p256dh: vapid.publicKey, auth: 'alice-push-auth-secret' } };
+ await call('alice', 'POST', '/api/push/subscribe', { subscription: pushSub });
+ secrets['alice push endpoint'] = pushSub.endpoint;
+ secrets['alice push auth'] = pushSub.keys.auth;
+ // A refresh token through the OAuth code flow (client 'live').
+ const REDIRECT = (JSON.parse(db.prepare("SELECT redirect_uris FROM oauth_clients WHERE client_id = 'live'").get().redirect_uris) || [])[0];
+ const conf = await call(null, 'POST', '/oauth/confirm', { token: users.alice.token, client_id: 'live', redirect_uri: REDIRECT });
+ if (conf.status === 200 && conf.body && conf.body.redirect) {
+ const code = new URL(conf.body.redirect).searchParams.get('code');
+ const t = once('oauth code exchange', await call(null, 'POST', '/oauth/token', { grant_type: 'authorization_code', client_id: 'live', client_secret: clientSecrets.live, code, redirect_uri: REDIRECT }));
+ if (t.body && t.body.refresh_token) secrets['alice refresh token'] = t.body.refresh_token;
+ }
+ for (const r of db.prepare('SELECT token FROM oauth_tokens').all()) secrets[`refresh token hash ${r.token.slice(0, 6)}`] = r.token;
+ // An anonymous identity seen from another address.
+ const anonToken = `anon-${crypto.randomBytes(12).toString('hex')}`;
+ db.prepare('INSERT INTO anon_users (anon_number, session_token, ip) VALUES (?, ?, ?)').run(900001, anonToken, '192.0.2.77');
+ secrets['anon session token'] = anonToken;
+ // Provider-secret copies in the database (the environment wins, but the copies are secrets too).
+ for (const k of ['resend_api_key', 'resend_webhook_secret', 'discord_bot_token', 'discord_oauth_client_secret', 'github_token', 'net.ipinfo_token', 'ses_secret_access_key']) {
+ const v = S(`db-${k.replace(/[._]/g, '-')}`);
+ db.prepare("INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, 'secret')").run(k, v);
+ secrets[`db setting ${k}`] = v;
+ }
+ const vk = `VK-${crypto.randomBytes(8).toString('hex')}`;
+ db.prepare('INSERT INTO verification_keys (key, target_username, created_by) VALUES (?, ?, ?)').run(vk, 'carol', rows.staff.id);
+ // A per-user password hash is never shown to anyone.
+ for (const u of ['alice', 'bob', 'staff']) { db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(`$2a$10$${u}${crypto.randomBytes(20).toString('hex')}`, users[u].id); secrets[`${u} password hash`] = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(users[u].id).password_hash; }
+
+ const callers = {
+ anonymous: {},
+ user: { authorization: `Bearer ${users.bob.token}`, cookie: `ov_token=${users.bob.token}` },
+ admin: { authorization: `Bearer ${users.staff.token}`, cookie: `ov_token=${users.staff.token}` },
+ owner: { authorization: `Bearer ${users.owner.token}`, cookie: `ov_token=${users.owner.token}` },
+ service: { authorization: `Bearer ${liveToken}` },
+ ...(appToken ? { app: { authorization: `Bearer ${appToken}` } } : {}),
+ };
+ return {
+ dir: srv.dir, srv, db, keys, vapid, issuer: ISSUER, routes, sentinels, secrets, users, dev, callers, clientSecrets,
+ liveToken, appToken, anonToken, verificationKey: vk, pushSub, shownOnce, call, serviceToken, sign, S,
+ stop: async () => { try { db.close(); } catch { /* */ } await srv.stop(); try { fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(srv.dir, { recursive: true, force: true }); } catch { /* */ } },
+ };
+}
+
+module.exports = { buildWorld, sentinelEnv, pemChunks, S };