From df96cc301176d9bd62d9b99dbc2bfa79a33ac8fc Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:55:07 +0200 Subject: [PATCH] fix: replace keyring ordering with committed generations Read strict v1 keyrings as generation zero and write strict v2 documents with monotonically increasing safe integer generations. Select the highest fully validated generation and reject conflicting canonical payloads at the same generation. Make a hidden, file-synced, disk-revalidated recovery staging file the rollback boundary. Publish it as a visible recovery candidate before treating the local transaction as committed, then keep primary, backup, and cleanup work best-effort. Remove mtime and directory-fsync truth from recovery decisions and cover rollback-safe staging failures, post-commit publication failures, stale candidates, v1 migration, and generation conflicts. --- lib/online-backup-keyring.js | 225 +++++++------- tests/online-backup-keyring.test.js | 463 ++++++++++++++++------------ 2 files changed, 387 insertions(+), 301 deletions(-) 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();