diff --git a/lib/online-backup-keyring.js b/lib/online-backup-keyring.js index 9bca9a4..21843d5 100644 --- a/lib/online-backup-keyring.js +++ b/lib/online-backup-keyring.js @@ -5,8 +5,8 @@ const secretStore = require('./secret-store'); const { parseOnlineBackupKey } = require('./online-backup'); const STORED_ENTRY_KEYS = ['createdAt', 'encryptedKey', 'id']; -const STORED_DOCUMENT_KEYS = ['keys', 'version']; -const DIRECTORY_SYNC_UNSUPPORTED = new Set(['EACCES', 'EBADF', 'EISDIR', 'EINVAL', 'ENOTSUP', 'EPERM']); +const STORED_V1_DOCUMENT_KEYS = ['keys', 'version']; +const STORED_V2_DOCUMENT_KEYS = ['generation', 'keys', 'version']; const KEYRING_ERROR_CODES = Object.freeze({ structure: 'KEYRING_STRUCTURE_INVALID', unavailable: 'KEYRING_SECURE_STORAGE_UNAVAILABLE', @@ -91,16 +91,22 @@ function createOnlineBackupKeyring({ return new OnlineBackupKeyringError(code); } - function isTemporaryFileName(value) { + function isCandidateTemporaryFileName(value) { return value.startsWith(temporaryPrefix) && /^\d+\.[0-9a-f-]+\.(?:primary|recovery)\.tmp$/u.test(value.slice(temporaryPrefix.length)); } - function recoveryCandidatePriority(candidatePath) { + function isOwnedTemporaryFileName(value) { + return value.startsWith(temporaryPrefix) + && /^\d+\.[0-9a-f-]+\.(?:primary|recovery|staging)\.tmp$/u.test(value.slice(temporaryPrefix.length)); + } + + function candidatePriority(candidatePath) { + if (candidatePath === filePath) return 0; const name = path.basename(candidatePath); - if (name.endsWith('.recovery.tmp')) return 0; - if (name.endsWith('.primary.tmp')) return 1; - return 2; + if (name.endsWith('.recovery.tmp')) return 1; + if (name.endsWith('.primary.tmp')) return 2; + return 3; } function encryptionError(error) { @@ -118,10 +124,19 @@ function createOnlineBackupKeyring({ } catch { throw issueError(KEYRING_ERROR_CODES.structure); } - if (!hasExactKeys(document, STORED_DOCUMENT_KEYS) || document.version !== 1 || !Array.isArray(document.keys)) { - throw issueError(KEYRING_ERROR_CODES.structure); + if (hasExactKeys(document, STORED_V1_DOCUMENT_KEYS) && document.version === 1 && Array.isArray(document.keys)) { + return { version: 1, generation: 0, keys: document.keys }; } - return document; + if ( + hasExactKeys(document, STORED_V2_DOCUMENT_KEYS) + && document.version === 2 + && Number.isSafeInteger(document.generation) + && document.generation > 0 + && Array.isArray(document.keys) + ) { + return document; + } + throw issueError(KEYRING_ERROR_CODES.structure); } async function readCandidate(candidatePath) { @@ -139,30 +154,27 @@ function createOnlineBackupKeyring({ try { const entries = await fsImpl.readdir(directory, { withFileTypes: true }); for (const entry of entries) { - if (entry.isFile() && isTemporaryFileName(entry.name)) candidates.add(path.join(directory, entry.name)); + if (entry.isFile() && isCandidateTemporaryFileName(entry.name)) candidates.add(path.join(directory, entry.name)); } } catch (error) { if (error?.code !== 'ENOENT') throw issueError(KEYRING_ERROR_CODES.structure); } - const ranked = []; - for (const candidatePath of candidates) { - try { - const stats = await fsImpl.stat(candidatePath); - ranked.push({ - candidatePath, - modified: stats.mtimeMs, - priority: recoveryCandidatePriority(candidatePath) - }); - } catch (error) { - if (error?.code !== 'ENOENT') ranked.push({ candidatePath, modified: 0, priority: recoveryCandidatePriority(candidatePath) }); - } - } - ranked.sort((left, right) => - right.modified - left.modified - || left.priority - right.priority - || left.candidatePath.localeCompare(right.candidatePath) + return [...candidates].sort((left, right) => + candidatePriority(left) - candidatePriority(right) + || left.localeCompare(right) ); - return ranked.map(candidate => candidate.candidatePath); + } + + function canonicalJson(value) { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (isObject(value)) { + return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`; + } + return JSON.stringify(value); + } + + function canonicalPayload(document) { + return canonicalJson({ generation: document.generation, keys: document.keys }); } function validateEntry(entry) { @@ -225,62 +237,58 @@ function createOnlineBackupKeyring({ ...problems.map(problem => problem.code), ...(source.recovered ? [KEYRING_ERROR_CODES.recovered] : []) ]); - return { source, entries, problems, duplicateIds, issues }; + return { + source, + generation: source.document.generation, + payload: canonicalPayload(source.document), + entries, + problems, + duplicateIds, + issues + }; + } + + function selectEquivalentState(states) { + return [...states].sort((left, right) => + candidatePriority(left.source.path) - candidatePriority(right.source.path) + || left.source.path.localeCompare(right.source.path) + )[0]; + } + + function selectGeneration(states, generation) { + const matches = states.filter(state => state.generation === generation); + if (new Set(matches.map(state => state.payload)).size !== 1) throw issueError(KEYRING_ERROR_CODES.structure); + return selectEquivalentState(matches); } async function readState() { - const primary = await readCandidate(filePath); - if (primary.status === 'valid') { - const primaryState = inspectSource({ ...primary, recovered: false }); - let primaryModified = 0; - try { - primaryModified = (await fsImpl.stat(filePath)).mtimeMs; - } catch {} - const candidates = await recoveryCandidates(); - if (!firstBlockingIssue(primaryState)) { - for (const candidatePath of candidates) { - if (!isTemporaryFileName(path.basename(candidatePath))) continue; - let candidateModified = 0; - try { - candidateModified = (await fsImpl.stat(candidatePath)).mtimeMs; - } catch {} - if (candidateModified < primaryModified) continue; - const candidate = await readCandidate(candidatePath); - if (candidate.status !== 'valid') continue; - const state = inspectSource({ ...candidate, recovered: true }); - if (!firstBlockingIssue(state)) return state; - } - return primaryState; - } - for (const candidatePath of candidates) { - const candidate = await readCandidate(candidatePath); - if (candidate.status !== 'valid') continue; - const state = inspectSource({ ...candidate, recovered: true }); - if (!firstBlockingIssue(state)) return state; - } - return primaryState; - } - const candidates = await recoveryCandidates(); - let fallback = null; - for (const candidatePath of candidates) { + const paths = [filePath, ...await recoveryCandidates()]; + const states = []; + let observedCandidate = false; + for (const candidatePath of paths) { const candidate = await readCandidate(candidatePath); + if (candidate.status === 'missing') continue; + observedCandidate = true; if (candidate.status !== 'valid') continue; - const state = inspectSource({ ...candidate, recovered: true }); - fallback ||= state; - if (!firstBlockingIssue(state)) return state; + states.push(inspectSource({ ...candidate, recovered: candidatePath !== filePath })); } - if (fallback) return fallback; - if (primary.status === 'missing' && candidates.length === 0) { - const document = { version: 1, keys: [] }; + if (states.length === 0) { + if (observedCandidate) throw issueError(KEYRING_ERROR_CODES.structure); + const document = { version: 1, generation: 0, keys: [] }; return inspectSource({ status: 'valid', path: filePath, - contents: JSON.stringify(document), + contents: JSON.stringify({ version: 1, keys: [] }), document, recovered: false }); } - throw issueError(KEYRING_ERROR_CODES.structure); + const highestObservedGeneration = Math.max(...states.map(state => state.generation)); + selectGeneration(states, highestObservedGeneration); + const validStates = states.filter(state => !firstBlockingIssue(state)); + if (validStates.length === 0) return selectGeneration(states, highestObservedGeneration); + const highestValidGeneration = Math.max(...validStates.map(state => state.generation)); + return selectGeneration(validStates, highestValidGeneration); } function firstBlockingIssue(state) { @@ -293,6 +301,13 @@ function createOnlineBackupKeyring({ return next; } + function nextGeneration(generation) { + if (!Number.isSafeInteger(generation) || generation < 0 || generation >= Number.MAX_SAFE_INTEGER) { + throw issueError(KEYRING_ERROR_CODES.structure); + } + return generation + 1; + } + function temporaryPath(kind) { return path.join(directory, `.${basename}.${process.pid}.${crypto.randomUUID()}.${kind}.tmp`); } @@ -326,25 +341,6 @@ function createOnlineBackupKeyring({ if (failure) throw failure; } - async function syncDirectory() { - let handle; - let failure; - try { - handle = await fsImpl.open(directory, 'r'); - await handle.sync(); - } catch (error) { - if (!DIRECTORY_SYNC_UNSUPPORTED.has(error?.code)) failure = error; - } - if (handle) { - try { - await handle.close(); - } catch (error) { - if (!DIRECTORY_SYNC_UNSUPPORTED.has(error?.code)) failure ||= error; - } - } - if (failure) throw failure; - } - async function cleanupTemporaryFiles(except = null) { let entries; try { @@ -355,35 +351,47 @@ function createOnlineBackupKeyring({ } for (const entry of entries) { const candidatePath = path.join(directory, entry.name); - if (!entry.isFile() || !isTemporaryFileName(entry.name) || candidatePath === except) continue; + if (!entry.isFile() || !isOwnedTemporaryFileName(entry.name) || candidatePath === except) continue; await removeFile(candidatePath); } } - async function writeEntries(entries) { + async function validateStaging(stagingPath, generation, payload) { + const candidate = await readCandidate(stagingPath); + if (candidate.status !== 'valid') throw issueError(KEYRING_ERROR_CODES.structure); + const state = inspectSource({ ...candidate, recovered: false }); + const blockingIssue = firstBlockingIssue(state); + if (blockingIssue) throw issueError(blockingIssue); + if (state.generation !== generation || state.payload !== payload) throw issueError(KEYRING_ERROR_CODES.structure); + } + + async function writeEntries(entries, generation) { const contents = JSON.stringify({ - version: 1, + version: 2, + generation, keys: entries.map(({ id, encryptedKey, createdAt }) => ({ id, encryptedKey, createdAt })) }); + const payload = canonicalPayload(parseDocument(contents)); + const stagingPath = temporaryPath('staging'); const primaryTemporaryPath = temporaryPath('primary'); const recoveryTemporaryPath = temporaryPath('recovery'); await fsImpl.mkdir(directory, { recursive: true }); try { - await writeAndSync(primaryTemporaryPath, contents); - await writeAndSync(recoveryTemporaryPath, contents); - await syncDirectory(); - await fsImpl.rename(primaryTemporaryPath, filePath); + await writeAndSync(stagingPath, contents); + await validateStaging(stagingPath, generation, payload); + await fsImpl.rename(stagingPath, recoveryTemporaryPath); } catch (error) { - await removeFile(primaryTemporaryPath); - await removeFile(recoveryTemporaryPath); + await removeFile(stagingPath); throw error; } let recoveryPath = recoveryTemporaryPath; try { - await syncDirectory(); + await writeAndSync(primaryTemporaryPath, contents); + await fsImpl.rename(primaryTemporaryPath, filePath); + } catch {} + try { await fsImpl.rename(recoveryTemporaryPath, backupPath); recoveryPath = null; - await syncDirectory(); } catch {} try { await cleanupTemporaryFiles(recoveryPath); @@ -436,7 +444,7 @@ function createOnlineBackupKeyring({ const blockingIssue = firstBlockingIssue(state); if (blockingIssue) throw issueError(blockingIssue); if (state.entries.some(current => current.id === validated.entry.id)) return false; - await writeEntries([...state.entries, validated.entry]); + await writeEntries([...state.entries, validated.entry], nextGeneration(state.generation)); return true; }); } @@ -460,15 +468,18 @@ function createOnlineBackupKeyring({ const entry = state.entries.find(current => current.id === id); if (!entry) return null; const plan = Object.freeze({ id: entry.id, key: entry.key }); - removalPlans.set(plan, state.entries.filter(current => current.id !== id)); + removalPlans.set(plan, { + generation: state.generation, + entries: state.entries.filter(current => current.id !== id) + }); return plan; } function commitRemove(plan) { return serialize(async () => { - const remaining = removalPlans.get(plan); - if (!remaining) throw issueError(KEYRING_ERROR_CODES.plan); - await writeEntries(remaining); + const prepared = removalPlans.get(plan); + if (!prepared) throw issueError(KEYRING_ERROR_CODES.plan); + await writeEntries(prepared.entries, nextGeneration(prepared.generation)); removalPlans.delete(plan); return true; }); diff --git a/tests/online-backup-keyring.test.js b/tests/online-backup-keyring.test.js index 7c64e0c..0b25a64 100644 --- a/tests/online-backup-keyring.test.js +++ b/tests/online-backup-keyring.test.js @@ -61,8 +61,11 @@ function keyWithRecordId(sourceKey, fill) { return `MHU2-${Buffer.concat([idBytes, masterKey, checksum]).toString('base64url')}`; } -function writeKeyring(filePath, keys) { - fs.writeFileSync(filePath, JSON.stringify({ version: 1, keys })); +function writeKeyring(filePath, keys, generation = null) { + const document = generation === null + ? { version: 1, keys } + : { version: 2, generation, keys }; + fs.writeFileSync(filePath, JSON.stringify(document)); } describe('encrypted online backup keyring', () => { @@ -75,7 +78,9 @@ describe('encrypted online backup keyring', () => { const document = JSON.parse(fs.readFileSync(filePath, 'utf8')); const snapshot = await keyring.list(); - assert.deepEqual(Object.keys(document).sort(), ['keys', 'version']); + assert.deepEqual(Object.keys(document).sort(), ['generation', 'keys', 'version']); + assert.equal(document.version, 2); + assert.equal(document.generation, 1); assert.equal(document.keys.length, 1); assert.equal(fs.readFileSync(filePath, 'utf8').includes(key), false); assert.deepEqual(snapshot.issues, []); @@ -90,6 +95,29 @@ describe('encrypted online backup keyring', () => { assert.equal(await keyring.getKey(prepared.id), key); }); + it('reads v1 as generation zero and migrates the next mutation to v2 generation one', async () => { + const { filePath, keyring } = fixture(); + const older = validKey(); + const newer = validKey(); + writeKeyring(filePath, [{ + id: parseOnlineBackupKey(older).id, + encryptedKey: encrypt(older), + createdAt: timestamp + }]); + + assert.deepEqual((await keyring.list()).entries.map(entry => entry.id), [parseOnlineBackupKey(older).id]); + assert.equal(await keyring.commit(keyring.prepare(newer, '2026-08-22T11:00:00.000Z')), true); + + const document = JSON.parse(fs.readFileSync(filePath, 'utf8')); + assert.deepEqual(Object.keys(document).sort(), ['generation', 'keys', 'version']); + assert.equal(document.version, 2); + assert.equal(document.generation, 1); + assert.deepEqual((await keyring.list()).entries.map(entry => entry.id), [ + parseOnlineBackupKey(newer).id, + parseOnlineBackupKey(older).id + ]); + }); + it('requires a canonical encrypted envelope before decrypting stored values', async () => { const key = validKey(); const id = parseOnlineBackupKey(key).id; @@ -205,24 +233,16 @@ describe('encrypted online backup keyring', () => { }); it('fully validates a unique removal plan before committing exactly that plan', async () => { - let decryptAllowed = true; - const { keyring } = fixture({ - decryptField: value => { - if (!decryptAllowed) throw new Error('late revalidation'); - return decrypt(value); - } - }); + const { keyring } = fixture(); const removed = validKey(); const retained = validKey(); await keyring.commit(keyring.prepare(removed, timestamp)); await keyring.commit(keyring.prepare(retained, '2026-08-22T11:00:00.000Z')); const plan = await keyring.prepareRemove(parseOnlineBackupKey(removed).id); - decryptAllowed = false; assert.equal(plan.id, parseOnlineBackupKey(removed).id); assert.equal(plan.key, removed); assert.equal(await keyring.commitRemove(plan), true); - decryptAllowed = true; assert.deepEqual((await keyring.list()).entries.map(entry => entry.id), [parseOnlineBackupKey(retained).id]); }); @@ -276,7 +296,143 @@ describe('encrypted online backup keyring', () => { ]); }); - it('syncs complete temporary files before atomic replacement and syncs directory metadata', async () => { + it('returns success after a validated recovery commit when primary publication fails', async () => { + const first = fixture(); + const older = validKey(); + const newer = validKey(); + await first.keyring.commit(first.keyring.prepare(older, timestamp)); + const fsImpl = { + ...fs.promises, + rename: async (source, target) => { + if (target === first.filePath) throw new Error('primary publication failed'); + return fs.promises.rename(source, target); + } + }; + const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring'); + const keyring = createOnlineBackupKeyring({ + filePath: first.filePath, + encryptField: encrypt, + decryptField: decrypt, + isEncrypted: isCanonicalEnvelope, + fsImpl + }); + + assert.equal(await keyring.commit(keyring.prepare(newer, '2026-08-22T11:00:00.000Z')), true); + assert.deepEqual((await keyring.list()).entries.map(entry => entry.id), [ + parseOnlineBackupKey(newer).id, + parseOnlineBackupKey(older).id + ]); + }); + + it('rejects a recovery staging write failure without exposing the new key', async () => { + const first = fixture(); + const older = validKey(); + const newer = validKey(); + await first.keyring.commit(first.keyring.prepare(older, timestamp)); + const fsImpl = { + ...fs.promises, + open: async (target, flags, mode) => { + const handle = await fs.promises.open(target, flags, mode); + if (!String(target).endsWith('.staging.tmp')) return handle; + return { + writeFile: async () => { throw new Error('recovery staging write failed'); }, + sync: () => handle.sync(), + close: () => handle.close() + }; + }, + unlink: async target => { + if (String(target).endsWith('.staging.tmp')) throw Object.assign(new Error('staging cleanup failed'), { code: 'EBUSY' }); + return fs.promises.unlink(target); + } + }; + const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring'); + const keyring = createOnlineBackupKeyring({ + filePath: first.filePath, + encryptField: encrypt, + decryptField: decrypt, + isEncrypted: isCanonicalEnvelope, + fsImpl + }); + + await assert.rejects( + keyring.commit(keyring.prepare(newer, '2026-08-22T11:00:00.000Z')), + /recovery staging write failed/u + ); + assert.equal(fs.readdirSync(first.directory).some(name => name.endsWith('.staging.tmp')), true); + assert.equal(fs.readdirSync(first.directory).some(name => name.endsWith('.recovery.tmp')), false); + assert.deepEqual((await keyring.list()).entries.map(entry => entry.id), [parseOnlineBackupKey(older).id]); + assert.equal(await keyring.getKey(parseOnlineBackupKey(newer).id), null); + }); + + it('rejects a recovery staging revalidation mismatch before publishing a candidate', async () => { + const first = fixture(); + const older = validKey(); + const newer = validKey(); + await first.keyring.commit(first.keyring.prepare(older, timestamp)); + const fsImpl = { + ...fs.promises, + readFile: async (target, encoding) => { + const contents = await fs.promises.readFile(target, encoding); + if (!String(target).endsWith('.staging.tmp')) return contents; + const document = JSON.parse(contents); + document.generation++; + return JSON.stringify(document); + }, + unlink: async target => { + if (String(target).endsWith('.staging.tmp')) throw Object.assign(new Error('staging cleanup failed'), { code: 'EBUSY' }); + return fs.promises.unlink(target); + } + }; + const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring'); + const keyring = createOnlineBackupKeyring({ + filePath: first.filePath, + encryptField: encrypt, + decryptField: decrypt, + isEncrypted: isCanonicalEnvelope, + fsImpl + }); + + await assert.rejects( + keyring.commit(keyring.prepare(newer, '2026-08-22T11:00:00.000Z')), + error => error.code === 'KEYRING_STRUCTURE_INVALID' + ); + assert.equal(fs.readdirSync(first.directory).some(name => name.endsWith('.staging.tmp')), true); + assert.equal(fs.readdirSync(first.directory).some(name => name.endsWith('.recovery.tmp')), false); + assert.equal(await keyring.getKey(parseOnlineBackupKey(newer).id), null); + }); + + it('cannot throw and later expose a rolled-back key when cleanup fails', async () => { + const first = fixture(); + const older = validKey(); + const newer = validKey(); + await first.keyring.commit(first.keyring.prepare(older, timestamp)); + let readdirCalls = 0; + const fsImpl = { + ...fs.promises, + readdir: async (...args) => { + readdirCalls++; + if (readdirCalls === 2) throw Object.assign(new Error('cleanup failed'), { code: 'EIO' }); + return fs.promises.readdir(...args); + }, + rename: async (source, target) => { + if (target === first.filePath || target === first.backupPath) throw new Error('publication failed'); + return fs.promises.rename(source, target); + } + }; + const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring'); + const keyring = createOnlineBackupKeyring({ + filePath: first.filePath, + encryptField: encrypt, + decryptField: decrypt, + isEncrypted: isCanonicalEnvelope, + fsImpl + }); + + assert.equal(await keyring.commit(keyring.prepare(newer, '2026-08-22T11:00:00.000Z')), true); + assert.equal(await keyring.getKey(parseOnlineBackupKey(newer).id), newer); + }); + + it('file-syncs and revalidates recovery staging before best-effort publication', async () => { const events = []; const fsImpl = { ...fs.promises, @@ -308,117 +464,63 @@ describe('encrypted online backup keyring', () => { await keyring.commit(keyring.prepare(validKey(), timestamp)); const renames = events.filter(event => event.startsWith('rename:')); - assert.equal(renames.length, 2); - for (const rename of renames) { - const source = rename.slice('rename:'.length).split('>')[0]; - assert.ok(events.indexOf(`write:${source}`) < events.indexOf(`sync:${source}`)); - assert.ok(events.indexOf(`sync:${source}`) < events.indexOf(`close:${source}`)); - assert.ok(events.indexOf(`close:${source}`) < events.indexOf(rename)); - } + assert.equal(renames.length, 3); + const stagingRename = renames.find(rename => rename.includes('.staging.tmp>') && rename.endsWith('.recovery.tmp')); + const primaryRename = renames.find(rename => rename.includes('.primary.tmp>') && rename.endsWith(`>${path.basename(filePath)}`)); + const backupRename = renames.find(rename => rename.includes('.recovery.tmp>') && rename.endsWith(`>${path.basename(backupPath)}`)); + const stagingSource = stagingRename.slice('rename:'.length).split('>')[0]; + const primarySource = primaryRename.slice('rename:'.length).split('>')[0]; + assert.ok(events.indexOf(`write:${stagingSource}`) < events.indexOf(`sync:${stagingSource}`)); + assert.ok(events.indexOf(`sync:${stagingSource}`) < events.indexOf(`close:${stagingSource}`)); + assert.ok(events.indexOf(`close:${stagingSource}`) < events.indexOf(stagingRename)); + assert.ok(events.indexOf(stagingRename) < events.indexOf(`write:${primarySource}`)); + assert.ok(events.indexOf(`close:${primarySource}`) < events.indexOf(primaryRename)); + assert.ok(events.indexOf(stagingRename) < events.indexOf(backupRename)); assert.equal(fs.existsSync(filePath), true); assert.equal(fs.existsSync(backupPath), true); - assert.ok(events.filter(event => event === `sync:${path.basename(path.dirname(filePath))}`).length >= 2); - assert.ok( - events.indexOf(`sync:${path.basename(path.dirname(filePath))}`) - < events.indexOf(renames.find(rename => rename.endsWith(`>${path.basename(filePath)}`))) - ); }); - it('leaves the primary unchanged when the pre-publication directory sync fails', async () => { - const first = fixture(); - const firstKey = validKey(); - await first.keyring.commit(first.keyring.prepare(firstKey, timestamp)); - const original = fs.readFileSync(first.filePath, 'utf8'); - let directorySyncs = 0; + it('does not depend on a Windows directory fsync commit boundary', async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-keyring-')); + directories.push(directory); + const filePath = path.join(directory, 'online-backup-keyring.json'); + let directoryOpens = 0; const fsImpl = { ...fs.promises, open: async (target, flags, mode) => { const handle = await fs.promises.open(target, flags, mode); - if (path.resolve(String(target)) !== path.resolve(first.directory)) return handle; + if (path.resolve(String(target)) !== path.resolve(directory)) return handle; + directoryOpens++; return { - sync: async () => { - directorySyncs++; - if (directorySyncs === 1) throw Object.assign(new Error('directory sync failed'), { code: 'EIO' }); - return handle.sync(); - }, + sync: async () => { throw Object.assign(new Error('unsupported'), { code: 'EPERM' }); }, close: () => handle.close() }; } }; const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring'); const keyring = createOnlineBackupKeyring({ - filePath: first.filePath, + filePath, encryptField: encrypt, decryptField: decrypt, isEncrypted: isCanonicalEnvelope, fsImpl }); - await assert.rejects( - keyring.commit(keyring.prepare(validKey(), '2026-08-22T11:00:00.000Z')), - /directory sync failed/u - ); - assert.equal(fs.readFileSync(first.filePath, 'utf8'), original); - assert.equal(fs.readdirSync(first.directory).some(name => name.endsWith('.tmp')), false); - assert.equal(await first.keyring.getKey(parseOnlineBackupKey(firstKey).id), firstKey); + assert.equal(await keyring.commit(keyring.prepare(validKey(), timestamp)), true); + assert.equal(directoryOpens, 0); }); - it('keeps a durable recovery temp after a post-publication sync failure', async () => { - const first = fixture(); - const firstKey = validKey(); - const secondKey = validKey(); - await first.keyring.commit(first.keyring.prepare(firstKey, timestamp)); - let primaryPublished = false; - let postPublicationSyncFailed = false; - const fsImpl = { - ...fs.promises, - open: async (target, flags, mode) => { - const handle = await fs.promises.open(target, flags, mode); - if (path.resolve(String(target)) !== path.resolve(first.directory)) return handle; - return { - sync: async () => { - if (primaryPublished && !postPublicationSyncFailed) { - postPublicationSyncFailed = true; - throw Object.assign(new Error('post-publication sync failed'), { code: 'EIO' }); - } - return handle.sync(); - }, - close: () => handle.close() - }; - }, - rename: async (source, target) => { - await fs.promises.rename(source, target); - if (target === first.filePath) primaryPublished = true; - } - }; - const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring'); - const keyring = createOnlineBackupKeyring({ - filePath: first.filePath, - encryptField: encrypt, - decryptField: decrypt, - isEncrypted: isCanonicalEnvelope, - fsImpl - }); - - assert.equal(await keyring.commit(keyring.prepare(secondKey, '2026-08-22T11:00:00.000Z')), true); - const recoveryTemp = fs.readdirSync(first.directory).find(name => name.endsWith('.recovery.tmp')); - assert.ok(recoveryTemp); - fs.writeFileSync(first.filePath, '{damaged-primary'); - fs.writeFileSync(first.backupPath, '{damaged-backup'); - const recovered = createOnlineBackupKeyring({ - filePath: first.filePath, - encryptField: encrypt, - decryptField: decrypt, - isEncrypted: isCanonicalEnvelope - }); - - const snapshot = await recovered.list(); - - assert.deepEqual(snapshot.entries.map(entry => entry.id), [ - parseOnlineBackupKey(secondKey).id, - parseOnlineBackupKey(firstKey).id - ]); - assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']); + it('rejects unsafe or loose v2 generation documents', async () => { + const key = validKey(); + const entry = { id: parseOnlineBackupKey(key).id, encryptedKey: encrypt(key), createdAt: timestamp }; + for (const generation of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, '1']) { + const { filePath, keyring } = fixture(); + fs.writeFileSync(filePath, JSON.stringify({ version: 2, generation, keys: [entry] })); + await assert.rejects(keyring.list(), error => error.code === 'KEYRING_STRUCTURE_INVALID'); + } + const { filePath, keyring } = fixture(); + fs.writeFileSync(filePath, JSON.stringify({ version: 2, generation: 1, keys: [entry], extra: true })); + await assert.rejects(keyring.list(), error => error.code === 'KEYRING_STRUCTURE_INVALID'); }); it('recovers a validated backup without presenting corruption as an empty keyring', async () => { @@ -464,7 +566,70 @@ describe('encrypted online backup keyring', () => { assert.equal(await recovered.getKey(parseOnlineBackupKey(key).id), key); }); - it('skips a newer cryptographically invalid recovery temp in favor of the validated backup', async () => { + it('selects the highest valid generation regardless of equal modification times', async () => { + const first = fixture(); + const older = validKey(); + const newer = validKey(); + const olderEntry = first.keyring.prepare(older, timestamp); + const newerEntry = first.keyring.prepare(newer, '2026-08-22T11:00:00.000Z'); + writeKeyring(first.filePath, [olderEntry, newerEntry], 2); + const staleTemp = path.join(first.directory, `.online-backup-keyring.json.${process.pid}.${crypto.randomUUID()}.recovery.tmp`); + writeKeyring(staleTemp, [olderEntry], 1); + const sameTime = new Date('2026-08-22T12:00:00.000Z'); + fs.utimesSync(first.filePath, sameTime, sameTime); + fs.utimesSync(staleTemp, sameTime, sameTime); + + assert.deepEqual((await first.keyring.list()).entries.map(entry => entry.id), [ + parseOnlineBackupKey(newer).id, + parseOnlineBackupKey(older).id + ]); + assert.deepEqual((await first.keyring.list()).issues, []); + }); + + it('selects a higher-generation recovery candidate over an equal-time primary', async () => { + const first = fixture(); + const older = validKey(); + const newer = validKey(); + const olderEntry = first.keyring.prepare(older, timestamp); + const newerEntry = first.keyring.prepare(newer, '2026-08-22T11:00:00.000Z'); + writeKeyring(first.filePath, [olderEntry], 1); + const recoveryTemp = path.join(first.directory, `.online-backup-keyring.json.${process.pid}.${crypto.randomUUID()}.recovery.tmp`); + writeKeyring(recoveryTemp, [olderEntry, newerEntry], 2); + const sameTime = new Date('2026-08-22T12:00:00.000Z'); + fs.utimesSync(first.filePath, sameTime, sameTime); + fs.utimesSync(recoveryTemp, sameTime, sameTime); + + const snapshot = await first.keyring.list(); + + assert.deepEqual(snapshot.entries.map(entry => entry.id), [ + parseOnlineBackupKey(newer).id, + parseOnlineBackupKey(older).id + ]); + assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']); + }); + + it('blocks conflicting canonical payloads at the same generation', async () => { + const first = fixture(); + const primaryKey = validKey(); + const backupKey = validKey(); + writeKeyring(first.filePath, [{ + id: parseOnlineBackupKey(primaryKey).id, + encryptedKey: encrypt(primaryKey), + createdAt: timestamp + }]); + writeKeyring(first.backupPath, [{ + id: parseOnlineBackupKey(backupKey).id, + encryptedKey: encrypt(backupKey), + createdAt: timestamp + }]); + + await assert.rejects( + first.keyring.list(), + error => error.code === 'KEYRING_STRUCTURE_INVALID' + ); + }); + + it('skips a cryptographically invalid recovery temp in favor of the validated backup', async () => { const first = fixture(); const key = validKey(); await first.keyring.commit(first.keyring.prepare(key, timestamp)); @@ -475,8 +640,6 @@ describe('encrypted online backup keyring', () => { encryptedKey: 'enc:v1:YWJjZA', createdAt: timestamp }]); - const future = new Date(Date.now() + 60_000); - fs.utimesSync(invalidTemp, future, future); const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring'); const recovered = createOnlineBackupKeyring({ filePath: first.filePath, @@ -491,7 +654,7 @@ describe('encrypted online backup keyring', () => { assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']); }); - it('recovers a newer fully synced commit temp after power loss before primary replacement', async () => { + it('recovers a higher-generation commit temp after power loss before primary replacement', async () => { const first = fixture(); const older = validKey(); const newer = validKey(); @@ -499,9 +662,7 @@ describe('encrypted online backup keyring', () => { const newerEntry = first.keyring.prepare(newer, '2026-08-22T11:00:00.000Z'); await first.keyring.commit(olderEntry); const recoveryTemp = path.join(first.directory, `.online-backup-keyring.json.${process.pid}.${crypto.randomUUID()}.recovery.tmp`); - writeKeyring(recoveryTemp, [olderEntry, newerEntry]); - const future = new Date(Date.now() + 60_000); - fs.utimesSync(recoveryTemp, future, future); + writeKeyring(recoveryTemp, [olderEntry, newerEntry], 2); const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring'); const recovered = createOnlineBackupKeyring({ filePath: first.filePath, @@ -519,92 +680,6 @@ describe('encrypted online backup keyring', () => { assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']); }); - it('prefers a recovery temp over the backup when both have the same modification time', async () => { - const first = fixture(); - const older = validKey(); - const newer = validKey(); - const olderEntry = first.keyring.prepare(older, timestamp); - const newerEntry = first.keyring.prepare(newer, '2026-08-22T11:00:00.000Z'); - await first.keyring.commit(olderEntry); - fs.writeFileSync(first.filePath, '{damaged-primary'); - const recoveryTemp = path.join(first.directory, `.online-backup-keyring.json.${process.pid}.${crypto.randomUUID()}.recovery.tmp`); - writeKeyring(recoveryTemp, [olderEntry, newerEntry]); - const sameTime = new Date('2026-08-22T12:00:00.000Z'); - fs.utimesSync(first.backupPath, sameTime, sameTime); - fs.utimesSync(recoveryTemp, sameTime, sameTime); - const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring'); - const recovered = createOnlineBackupKeyring({ - filePath: first.filePath, - encryptField: encrypt, - decryptField: decrypt, - isEncrypted: isCanonicalEnvelope - }); - - const snapshot = await recovered.list(); - - assert.deepEqual(snapshot.entries.map(entry => entry.id), [ - parseOnlineBackupKey(newer).id, - parseOnlineBackupKey(older).id - ]); - assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']); - }); - - it('recovers an equal-time commit temp instead of skipping it behind the valid primary', async () => { - const first = fixture(); - const older = validKey(); - const newer = validKey(); - const olderEntry = first.keyring.prepare(older, timestamp); - const newerEntry = first.keyring.prepare(newer, '2026-08-22T11:00:00.000Z'); - await first.keyring.commit(olderEntry); - const recoveryTemp = path.join(first.directory, `.online-backup-keyring.json.${process.pid}.${crypto.randomUUID()}.recovery.tmp`); - writeKeyring(recoveryTemp, [olderEntry, newerEntry]); - const sameTime = new Date('2026-08-22T12:00:00.000Z'); - fs.utimesSync(first.filePath, sameTime, sameTime); - fs.utimesSync(recoveryTemp, sameTime, sameTime); - const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring'); - const recovered = createOnlineBackupKeyring({ - filePath: first.filePath, - encryptField: encrypt, - decryptField: decrypt, - isEncrypted: isCanonicalEnvelope - }); - - const snapshot = await recovered.list(); - - assert.deepEqual(snapshot.entries.map(entry => entry.id), [ - parseOnlineBackupKey(newer).id, - parseOnlineBackupKey(older).id - ]); - assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']); - }); - - it('keeps the prior valid state and cleans temporary files when primary replacement fails', async () => { - const first = fixture(); - const firstKey = validKey(); - await first.keyring.commit(first.keyring.prepare(firstKey, timestamp)); - const original = fs.readFileSync(first.filePath, 'utf8'); - const fsImpl = { - ...fs.promises, - rename: async (source, target) => { - if (target === first.filePath) throw new Error('rename failed'); - return fs.promises.rename(source, target); - } - }; - const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring'); - const keyring = createOnlineBackupKeyring({ - filePath: first.filePath, - encryptField: encrypt, - decryptField: decrypt, - isEncrypted: isCanonicalEnvelope, - fsImpl - }); - - await assert.rejects(keyring.commit(keyring.prepare(validKey(), '2026-08-22T11:00:00.000Z')), /rename failed/u); - assert.equal(fs.readFileSync(first.filePath, 'utf8'), original); - assert.equal(fs.readdirSync(first.directory).some(name => name.endsWith('.tmp')), false); - assert.equal(await first.keyring.getKey(parseOnlineBackupKey(firstKey).id), firstKey); - }); - it('commits successfully once the primary is published even when later cleanup fails', async () => { const first = fixture(); const firstKey = validKey();