'use strict'; const {Buffer} = require('buffer'); const crypto = require('./crypto'); const db = require('../db/DB'); const log4js = require('log4js'); class Kdf { async generateParams() { throw new Error('not implemented'); } async derive(params, info) { throw new Error('not implemented'); } } class LegacyStaticSecret extends Kdf { async derive(params, info) { return params; } } class Hkdf extends Kdf { constructor(digest, keyLen) { super(); this._digest = digest; this._keyLen = keyLen; } async generateParams() { const [secret, salt] = (await Promise.all([ crypto.randomBytes(this._keyLen), crypto.randomBytes(this._keyLen), ])).map((b) => b.toString('hex')); return {digest: this._digest, keyLen: this._keyLen, salt, secret}; } async derive(p, info) { return Buffer.from( await crypto.hkdf(p.digest, p.secret, p.salt, info, p.keyLen)).toString('hex'); } } // Key derivation algorithms. Do not modify entries in this array, except: // * It is OK to replace an unused algorithm with `null` after any entries in the database // using the algorithm have been deleted. // * It is OK to append a new algorithm to the end. // If the entries are modified in any other way then key derivation might fail or produce invalid // results due to broken compatibility with existing database records. const algorithms = [ new LegacyStaticSecret(), new Hkdf('sha256', 32), ]; const defaultAlgId = algorithms.length - 1; // In JavaScript, the % operator is remainder, not modulus. const mod = (a, n) => ((a % n) + n) % n; const intervalStart = (t, interval) => t - mod(t, interval); /** * Maintains an array of secrets across one or more Etherpad instances sharing the same database, * periodically rotating in a new secret and removing the oldest secret. * * The secrets are generated using a key derivation function (KDF) with input keying material coming * from a long-lived secret stored in the database (generated if missing). */ class SecretRotator { /** * @param {string} dbPrefix - Database key prefix to use for tracking secret metadata. * @param {number} interval - How often to rotate in a new secret. * @param {number} lifetime - How long after the end of an interval before the secret is no longer * useful. * @param {string} [legacyStaticSecret] - Optional secret to facilitate migration to secret * rotation. If the oldest known secret starts after `lifetime` ago, this secret will cover * the time period starting `lifetime` ago and ending at the start of that secret. */ constructor(dbPrefix, interval, lifetime, legacyStaticSecret = null) { /** * The secrets. The first secret in this array is the one that should be used to generate new * MACs. All of the secrets in this array should be used when attempting to authenticate an * existing MAC. The contents of this array will be updated every `interval` milliseconds, but * the Array object itself will never be replaced with a new Array object. * * @type {string[]} * @public */ this.secrets = []; Object.defineProperty(this, 'secrets', {writable: false}); // Defend against bugs. if (/[*:%]/.test(dbPrefix)) throw new Error(`dbPrefix contains an invalid char: ${dbPrefix}`); this._dbPrefix = dbPrefix; this._interval = interval; this._legacyStaticSecret = legacyStaticSecret; this._lifetime = lifetime; this._logger = log4js.getLogger(`secret-rotation ${dbPrefix}`); this._logger.debug(`new secret rotator (interval ${interval}, lifetime: ${lifetime})`); this._updateTimeout = null; // Indirections to facilitate testing. this._t = {now: Date.now.bind(Date), setTimeout, clearTimeout, algorithms}; } async _publish(params, id = null) { // Params are published to the db with a randomly generated key to avoid race conditions with // other instances. if (id == null) id = `${this._dbPrefix}:${(await crypto.randomBytes(32)).toString('hex')}`; await db.set(id, params); return id; } async start() { this._logger.debug('starting secret rotation'); if (this._updateTimeout != null) return; // Already started. await this._update(); } stop() { this._logger.debug('stopping secret rotation'); this._t.clearTimeout(this._updateTimeout); this._updateTimeout = null; } async _deriveSecrets(p, now) { this._logger.debug('deriving secrets from', p); if (!p.interval) return [await algorithms[p.algId].derive(p.algParams, null)]; const t0 = intervalStart(now, p.interval); // Start of the first interval covered by these params. To accommodate clock skew, p.interval is // subtracted. If we did not do this, then the following could happen: // 1. Instance (A) starts up and publishes params starting at the current interval. // 2. Instance (B) starts up with a clock that is in the previous interval. // 3. Instance (B) reads the params published by instance (A) and sees that there's no // coverage of what it thinks is the current interval. // 4. Instance (B) generates and publishes new params that covers what it thinks is the // current interval. // 5. Instance (B) starts generating MACs from a secret derived from the new params. // 6. Instance (A) fails to validate the MACs generated by instance (B) until it re-reads // the published params, which might take as long as interval. // An alternative approach is to backdate p.start by p.interval when creating new params, but // this could affect the end time of legacy secrets. const tA = intervalStart(p.start - p.interval, p.interval); const tZ = intervalStart(p.end - 1, p.interval); this._logger.debug('now:', now, 't0:', t0, 'tA:', tA, 'tZ:', tZ); // Starts of intervals to derive keys for. const tNs = []; // Whether the derived secret for the interval starting at tN is still relevant. If there was no // clock skew, a derived secret is relevant until p.lifetime has elapsed since the end of the // interval. To accommodate clock skew, this end time is extended by p.interval. const expired = (tN) => now >= tN + (2 * p.interval) + p.lifetime; // Walk from t0 back until either the start of coverage or the derived secret is expired. t0 // must always be the first entry in case p is the current params. (The first derived secret is // used for generating MACs, so the secret derived for t0 must be before the secrets derived for // other times.) for (let tN = Math.min(t0, tZ); tN >= tA && !expired(tN); tN -= p.interval) tNs.push(tN); // Include a future derived secret to accommodate clock skew. if (t0 + p.interval <= tZ) tNs.push(t0 + p.interval); this._logger.debug('deriving secrets for intervals with start times:', tNs); return await Promise.all( tNs.map(async (tN) => await algorithms[p.algId].derive(p.algParams, `${tN}`))); } async _update() { const now = this._t.now(); const t0 = intervalStart(now, this._interval); let next = t0 + this._interval; // When this._update() should be called again. let legacyEnd = now; // TODO: This is racy. If two instances start up at the same time and there are no existing // matching publications, each will generate and publish their own paramters. In practice this // is unlikely to happen, and if it does it can be fixed by restarting both Etherpad instances. const dbKeys = await db.findKeys(`${this._dbPrefix}:*`, null); let currentParams = null; let currentId = null; const dbWrites = []; const allParams = []; const legacyParams = []; await Promise.all(dbKeys.map(async (dbKey) => { const p = await db.get(dbKey); if (p.algId === 0 && p.algParams === this._legacyStaticSecret) legacyParams.push(p); if (p.start < legacyEnd) legacyEnd = p.start; // Check if the params have expired. Params are still useful if a MAC generated by a secret // derived from the params is still valid, which can be true up to p.end + p.lifetime if // there was no clock skew. The p.interval factor is added to accommodate clock skew. // p.interval is null for legacy secrets, so fall back to this._interval. if (now >= p.end + p.lifetime + (p.interval || this._interval)) { // This initial keying material (or legacy secret) is expired. dbWrites.push(db.remove(dbKey)); dbWrites[dbWrites.length - 1].catch(() => {}); // Prevent unhandled Promise rejections. return; } const t1 = p.interval && intervalStart(now, p.interval) + p.interval; // Start of next intrvl. const tA = intervalStart(p.start, p.interval); // Start of interval containing p.start. if (p.interval) next = Math.min(next, t1); // Determine if these params can be used to generate the current (active) secret. Note that // p.start is allowed to be in the next interval in case there is clock skew. if (p.interval && p.interval === this._interval && p.lifetime === this._lifetime && tA <= t1 && p.end > now && (currentParams == null || p.start > currentParams.start)) { if (currentParams) allParams.push(currentParams); currentParams = p; currentId = dbKey; } else { allParams.push(p); } })); if (this._legacyStaticSecret && now < legacyEnd + this._lifetime + this._interval && !legacyParams.find((p) => p.end + p.lifetime >= legacyEnd + this._lifetime)) { const d = new Date(legacyEnd).toJSON(); this._logger.debug(`adding legacy static secret for ${d} with lifetime ${this._lifetime}`); const p = { algId: 0, algParams: this._legacyStaticSecret, // The start time is equal to the end time so that this legacy secret does not affect the // end times of any legacy secrets published by other instances. start: legacyEnd, end: legacyEnd, interval: null, lifetime: this._lifetime, }; allParams.push(p); dbWrites.push(this._publish(p)); dbWrites[dbWrites.length - 1].catch(() => {}); // Prevent unhandled Promise rejections. } if (currentParams == null) { currentParams = { algId: defaultAlgId, algParams: await algorithms[defaultAlgId].generateParams(), start: now, end: now, // Extended below. interval: this._interval, lifetime: this._lifetime, }; } // Advance currentParams's expiration time to the end of the next interval if needed. (The next // interval is used so that the parameters never expire under normal circumstances.) This must // be done before deriving any secrets from currentParams so that a secret for the next interval // can be included (in case there is clock skew). currentParams.end = Math.max(currentParams.end, t0 + (2 * this._interval)); dbWrites.push(this._publish(currentParams, currentId)); dbWrites[dbWrites.length - 1].catch(() => {}); // Prevent unhandled Promise rejections. // The secrets derived from currentParams MUST be the first secrets. const secrets = await this._deriveSecrets(currentParams, now); await Promise.all( allParams.map(async (p) => secrets.push(...await this._deriveSecrets(p, now)))); // Update this.secrets all at once to avoid race conditions. this.secrets.length = 0; this.secrets.push(...secrets); this._logger.debug('active secrets:', this.secrets); // Wait for db writes to finish after updating this.secrets so that the new secrets become // active as soon as possible. await Promise.all(dbWrites); // Use an async function so that test code can tell when it's done publishing the new secrets. // The standard setTimeout() function ignores the callback's return value, but some of the tests // await the returned Promise. this._updateTimeout = this._t.setTimeout(async () => await this._update(), next - this._t.now()); } } module.exports = SecretRotator;