openvibers/openvibe.events Commits

Subscription secret rotation: POST /api/v1/subscriptions/:id/rotate-secret (the consumer's own subscription; overlap_s default 1 day, max 7) and scripts/rotate-subscription-secret.js for operators (writes the new secret into the consumer's env file, never prints it). During the overlap every delivery's X-OpenVibe-Signature-V2 carries a v2 for the new and the old secret, so the consumer switches without missing one (openvibe-sdk accepts any matching v2). delivery test

OP OpenVibers committed 24 Sept 2026 · 82a49f3a01 · 5 files, +124 −3


Changes

ddiff --git a/scripts/rotate-subscription-secret.js b/scripts/rotate-subscription-secret.js
nnew file mode 100755
iindex 0000000..f636823
---- /dev/null
++++ b/scripts/rotate-subscription-secret.js
@@@ -0,0 +1,58 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Operator rotation of a first-party subscription's secret, without the value ever being printed.
++ *
++ * node scripts/rotate-subscription-secret.js --db <events.db> --subscription <sub_…>
++ * --env-file </etc/openvibe/<consumer>.env> --env-var <NAME> [--overlap-s 86400] [--apply]
++ *
++ * With --apply it (1) sets a new random secret on the subscription, keeping the old one signing next to it
++ * for --overlap-s (default 1 day), and (2) writes NAME=<new secret> into the consumer's env file (replacing
++ * the line, or appending it; a .bak-<time> copy is kept). Then restart the consumer: during the overlap
++ * every delivery carries both signatures, so it verifies with either secret and nothing is missed.
++ * Without --apply it only checks that the subscription and the env file exist and says what it would do.
++ * Run as root (the env file is root-owned); the database is opened by path.
++ */
++const fs = require('fs');
++const crypto = require('crypto');
++const Database = require('better-sqlite3');
++
++const args = process.argv.slice(2);
++const opt = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d; };
++const apply = args.includes('--apply');
++const dbPath = opt('--db');
++const subId = opt('--subscription');
++const envFile = opt('--env-file');
++const envVar = opt('--env-var');
++const overlapS = Number(opt('--overlap-s', '86400'));
++
++function fail(msg) { console.error(`rotate-subscription-secret: ${msg}`); process.exit(1); }
++if (!dbPath || !subId || !envFile || !envVar) fail('--db, --subscription, --env-file and --env-var are required');
++if (!/^[A-Z][A-Z0-9_]{1,63}$/.test(envVar)) fail('--env-var must be an env variable name');
++if (!Number.isFinite(overlapS) || overlapS < 0 || overlapS > 7 * 86400) fail('--overlap-s is 0..604800');
++if (!fs.existsSync(envFile)) fail(`${envFile} does not exist`);
++
++const db = new Database(dbPath, { fileMustExist: true });
++const sub = db.prepare('SELECT id, consumer, topic_pattern, project_id FROM subscriptions WHERE id = ?').get(subId);
++if (!sub) fail(`no subscription ${subId}`);
++if (sub.project_id) fail('developer-app subscriptions rotate through the API (POST /api/v1/subscriptions/:id/rotate-secret)');
++const cols = new Set(db.prepare('PRAGMA table_info(subscriptions)').all().map((c) => c.name));
++if (!cols.has('previous_secret')) fail('this Events database predates rotation; deploy Events first (it adds the columns at boot)');
++
++const lines = fs.readFileSync(envFile, 'utf8').split('\n');
++const has = lines.some((l) => l.startsWith(`${envVar}=`));
++console.log(`subscription ${sub.id} (${sub.consumer}, ${sub.topic_pattern}); ${envFile}: ${envVar} ${has ? 'will be replaced' : 'will be appended'}; overlap ${overlapS}s`);
++if (!apply) { console.log('dry run: nothing changed (add --apply)'); process.exit(0); }
++
++const secret = `whsec_${crypto.randomBytes(32).toString('hex')}`;
++const now = Date.now();
++fs.copyFileSync(envFile, `${envFile}.bak-${new Date(now).toISOString().replace(/[:.]/g, '-')}`);
++db.transaction(() => {
++ db.prepare('UPDATE subscriptions SET previous_secret = secret, previous_secret_until = ?, secret = ?, updated_at = ? WHERE id = ?')
++ .run(now + overlapS * 1000, secret, now, sub.id);
++})();
++let out;
++if (has) out = lines.map((l) => (l.startsWith(`${envVar}=`) ? `${envVar}=${secret}` : l));
++else { out = lines.slice(); while (out.length && out[out.length - 1] === '') out.pop(); out.push(`${envVar}=${secret}`, ''); }
++fs.writeFileSync(envFile, out.join('\n'), { mode: fs.statSync(envFile).mode });
++console.log(`rotated: both secrets sign until ${new Date(now + overlapS * 1000).toISOString()}; ${envVar} updated. Restart ${sub.consumer} now.`);
ddiff --git a/server/api/subscriptions.js b/server/api/subscriptions.js
iindex 4d797d6..385432b 100644
---- a/server/api/subscriptions.js
++++ b/server/api/subscriptions.js
@@@ -139,6 +139,25 @@ function subscriptionsRouter({ config, store, auth, dnsLookup }) {
if (sub) res.json(subscriptionView(sub));
});
++ // POST /api/v1/subscriptions/:id/rotate-secret { secret?, overlap_s? } -> the new secret (shown once). The old
++ // secret keeps signing next to it for overlap_s (default 1 day, at most 7), so the consumer can switch without
++ // missing a delivery.
++ router.post('/api/v1/subscriptions/:id/rotate-secret', guard, express.json({ limit: '4kb' }), (req, res) => {
++ const sub = own(req, res);
++ if (!sub) return;
++ const b = req.body || {};
++ if (b.secret !== undefined && (typeof b.secret !== 'string' || b.secret.length < 32 || b.secret.length > 256)) {
++ return http.sendProblem(res, 422, 'events.bad_request', { detail: 'secret must be a string of 32..256 characters', ctx: req.ov });
++ }
++ const overlapS = b.overlap_s === undefined ? 86400 : Number(b.overlap_s);
++ if (!Number.isFinite(overlapS) || overlapS < 0 || overlapS > 7 * 86400) {
++ return http.sendProblem(res, 422, 'events.bad_request', { detail: 'overlap_s is 0..604800', ctx: req.ov });
++ }
++ const secret = b.secret || `whsec_${crypto.randomBytes(32).toString('hex')}`;
++ const out = store.rotateSubscriptionSecret(sub.id, secret, overlapS * 1000);
++ res.json({ ...subscriptionView(out), secret, previous_secret_valid_until: new Date(out.previous_secret_until).toISOString() });
++ });
++
for (const [action, enabled] of [['disable', false], ['enable', true]]) {
router.post(`/api/v1/subscriptions/:id/${action}`, guard, (req, res) => {
if (enabled && subscribeRevoked(req, res)) return;
ddiff --git a/server/store.js b/server/store.js
iindex f3d87b0..b2ed814 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -118,7 +118,7 @@ const PUBLISH_RECEIPT = 'events:publish'; // receipts' consumer column for acce
const ADDED_COLUMNS = {
events: [['project_id', 'TEXT'], ['env', "TEXT NOT NULL DEFAULT 'production'"], ['size_bytes', 'INTEGER NOT NULL DEFAULT 0'],
['redacted_at', 'INTEGER'], ['redacted_by', 'TEXT'], ['redacts', 'INTEGER NOT NULL DEFAULT 0']],
-- subscriptions: [['project_id', 'TEXT'], ['env', "TEXT NOT NULL DEFAULT 'production'"]],
++ subscriptions: [['project_id', 'TEXT'], ['env', "TEXT NOT NULL DEFAULT 'production'"], ['previous_secret', 'TEXT'], ['previous_secret_until', 'INTEGER']],
};
function migrate(db) {
@@@ -488,6 +488,14 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
function countSubscriptions(consumer) {
return db.prepare('SELECT COUNT(*) AS n FROM subscriptions WHERE consumer = ?').get(consumer).n;
}
++ /** Rotate a subscription's secret: the old one keeps signing (next to the new) until overlapMs from now. */
++ function rotateSubscriptionSecret(id, secret, overlapMs) {
++ const now = clock.now();
++ db.prepare('UPDATE subscriptions SET previous_secret = secret, previous_secret_until = ?, secret = ?, updated_at = ? WHERE id = ?')
++ .run(now + Math.max(0, overlapMs), secret, now, id);
++ return getSubscription(id);
++ }
++
function setSubscriptionEnabled(id, enabled) {
db.prepare('UPDATE subscriptions SET enabled = ?, updated_at = ? WHERE id = ?').run(enabled ? 1 : 0, clock.now(), id);
return getSubscription(id);
@@@ -605,7 +613,7 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
return {
db, insertBatch, redact, getEvent, revokedAt, lastSeq, oldestSeq, firstSeqSince, scan,
-- createSubscription, getSubscription, listSubscriptions, countSubscriptions, countProjectSubscriptions, setSubscriptionEnabled,
++ createSubscription, getSubscription, listSubscriptions, countSubscriptions, countProjectSubscriptions, setSubscriptionEnabled, rotateSubscriptionSecret,
dueDeliveries, recordAttempt, getDelivery, listDeliveries, requeue, deliveryCounts,
getCheckpoint, setCheckpoint, prune, ping,
};
ddiff --git a/server/worker.js b/server/worker.js
iindex de87b33..792a734 100644
---- a/server/worker.js
++++ b/server/worker.js
@@@ -21,6 +21,19 @@
*/
const crypto = require('crypto');
const { sign, signV2 } = require('../lib/client');
++
++/**
++ * The v2 header for a delivery. During a secret rotation's overlap it carries a second v2= signature made
++ * with the previous secret, so a consumer still configured with either secret accepts it (openvibe-sdk's
++ * verifyDeliveryV2 checks every v2= value).
++ */
++function signatureV2(sub, body, timestamp, nowMs) {
++ const header = signV2(body, sub.secret, timestamp);
++ if (sub.previous_secret && Number(sub.previous_secret_until) > nowMs) {
++ return `${header},v2=${signV2(body, sub.previous_secret, timestamp).split('v2=')[1]}`;
++ }
++ return header;
++}
const { rowToEnvelope } = require('./store');
const { checkEndpoint } = require('./endpoints');
const { createGuardedPost } = require('./egress');
@@@ -65,7 +78,7 @@ function createWorker({ store, config, clock = { now: () => Date.now() }, fetchI
'X-OpenVibe-Hops': String(row.hops),
'X-OpenVibe-Signature': sign(body, sub.secret),
'X-OpenVibe-Timestamp': String(timestamp),
-- 'X-OpenVibe-Signature-V2': signV2(body, sub.secret, timestamp),
++ 'X-OpenVibe-Signature-V2': signatureV2(sub, body, timestamp, clock.now()),
traceparent: `00-${row.trace_id}-${crypto.randomBytes(8).toString('hex')}-01`,
};
let res;
ddiff --git a/test/delivery.test.js b/test/delivery.test.js
iindex 5abdd08..56407cd 100644
---- a/test/delivery.test.js
++++ b/test/delivery.test.js
@@@ -251,6 +251,29 @@ t('the real loop delivers without drain()', async () => {
await stub.close();
});
++t('secret rotation: both secrets sign during the overlap, only the new one after it', async () => {
++ const stub = await subscriber();
++ const sub = (await subscribe(media, { topic_pattern: 'live.rotate.*', endpoint: stub.url })).body;
++ const r = await request(h.base, 'POST', `/api/v1/subscriptions/${sub.id}/rotate-secret`, { token: media, body: { overlap_s: 3600 } });
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.body.secret && r.body.secret !== sub.secret && r.body.previous_secret_valid_until);
++ assert.ok(!('previous_secret' in r.body), 'the old secret is never echoed');
++ const other = await request(h.base, 'POST', `/api/v1/subscriptions/${sub.id}/rotate-secret`, { token: serviceToken('someone', ['events.subscription.manage']), body: {} });
++ assert.strictEqual(other.status, 404, 'only the consumer rotates its own subscription');
++ await publish(envelope('live', { event_type: 'live.rotate.one' }));
++ await h.worker.drain();
++ let call = stub.calls.pop();
++ assert.ok(verifyDeliveryV2(call.rawBody, call.headers, sub.secret, { now: h.clock.now() }), 'the old secret still verifies');
++ assert.ok(verifyDeliveryV2(call.rawBody, call.headers, r.body.secret, { now: h.clock.now() }), 'the new secret verifies');
++ h.clock.advance(3601 * 1000);
++ await publish(envelope('live', { event_type: 'live.rotate.two' }));
++ await h.worker.drain();
++ call = stub.calls.pop();
++ assert.ok(!verifyDeliveryV2(call.rawBody, call.headers, sub.secret, { now: h.clock.now() }), 'after the overlap the old secret no longer verifies');
++ assert.ok(verifyDeliveryV2(call.rawBody, call.headers, r.body.secret, { now: h.clock.now() }));
++ await stub.close();
++});
++
t('stop', async () => { await h.stop(); });
t.run();