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.
This commit is contained in:
Sucukdeluxe
2026-08-22 15:55:07 +02:00
parent 82578b5f6e
commit df96cc3011
2 changed files with 387 additions and 301 deletions
+117 -106
View File
@@ -5,8 +5,8 @@ const secretStore = require('./secret-store');
const { parseOnlineBackupKey } = require('./online-backup'); const { parseOnlineBackupKey } = require('./online-backup');
const STORED_ENTRY_KEYS = ['createdAt', 'encryptedKey', 'id']; const STORED_ENTRY_KEYS = ['createdAt', 'encryptedKey', 'id'];
const STORED_DOCUMENT_KEYS = ['keys', 'version']; const STORED_V1_DOCUMENT_KEYS = ['keys', 'version'];
const DIRECTORY_SYNC_UNSUPPORTED = new Set(['EACCES', 'EBADF', 'EISDIR', 'EINVAL', 'ENOTSUP', 'EPERM']); const STORED_V2_DOCUMENT_KEYS = ['generation', 'keys', 'version'];
const KEYRING_ERROR_CODES = Object.freeze({ const KEYRING_ERROR_CODES = Object.freeze({
structure: 'KEYRING_STRUCTURE_INVALID', structure: 'KEYRING_STRUCTURE_INVALID',
unavailable: 'KEYRING_SECURE_STORAGE_UNAVAILABLE', unavailable: 'KEYRING_SECURE_STORAGE_UNAVAILABLE',
@@ -91,16 +91,22 @@ function createOnlineBackupKeyring({
return new OnlineBackupKeyringError(code); return new OnlineBackupKeyringError(code);
} }
function isTemporaryFileName(value) { function isCandidateTemporaryFileName(value) {
return value.startsWith(temporaryPrefix) return value.startsWith(temporaryPrefix)
&& /^\d+\.[0-9a-f-]+\.(?:primary|recovery)\.tmp$/u.test(value.slice(temporaryPrefix.length)); && /^\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); const name = path.basename(candidatePath);
if (name.endsWith('.recovery.tmp')) return 0; if (name.endsWith('.recovery.tmp')) return 1;
if (name.endsWith('.primary.tmp')) return 1; if (name.endsWith('.primary.tmp')) return 2;
return 2; return 3;
} }
function encryptionError(error) { function encryptionError(error) {
@@ -118,11 +124,20 @@ function createOnlineBackupKeyring({
} catch { } catch {
throw issueError(KEYRING_ERROR_CODES.structure); throw issueError(KEYRING_ERROR_CODES.structure);
} }
if (!hasExactKeys(document, STORED_DOCUMENT_KEYS) || document.version !== 1 || !Array.isArray(document.keys)) { if (hasExactKeys(document, STORED_V1_DOCUMENT_KEYS) && document.version === 1 && Array.isArray(document.keys)) {
throw issueError(KEYRING_ERROR_CODES.structure); return { version: 1, generation: 0, keys: document.keys };
} }
if (
hasExactKeys(document, STORED_V2_DOCUMENT_KEYS)
&& document.version === 2
&& Number.isSafeInteger(document.generation)
&& document.generation > 0
&& Array.isArray(document.keys)
) {
return document; return document;
} }
throw issueError(KEYRING_ERROR_CODES.structure);
}
async function readCandidate(candidatePath) { async function readCandidate(candidatePath) {
try { try {
@@ -139,30 +154,27 @@ function createOnlineBackupKeyring({
try { try {
const entries = await fsImpl.readdir(directory, { withFileTypes: true }); const entries = await fsImpl.readdir(directory, { withFileTypes: true });
for (const entry of entries) { 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) { } catch (error) {
if (error?.code !== 'ENOENT') throw issueError(KEYRING_ERROR_CODES.structure); if (error?.code !== 'ENOENT') throw issueError(KEYRING_ERROR_CODES.structure);
} }
const ranked = []; return [...candidates].sort((left, right) =>
for (const candidatePath of candidates) { candidatePriority(left) - candidatePriority(right)
try { || left.localeCompare(right)
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 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) { function validateEntry(entry) {
@@ -225,62 +237,58 @@ function createOnlineBackupKeyring({
...problems.map(problem => problem.code), ...problems.map(problem => problem.code),
...(source.recovered ? [KEYRING_ERROR_CODES.recovered] : []) ...(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() { async function readState() {
const primary = await readCandidate(filePath); const paths = [filePath, ...await recoveryCandidates()];
if (primary.status === 'valid') { const states = [];
const primaryState = inspectSource({ ...primary, recovered: false }); let observedCandidate = false;
let primaryModified = 0; for (const candidatePath of paths) {
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); const candidate = await readCandidate(candidatePath);
if (candidate.status === 'missing') continue;
observedCandidate = true;
if (candidate.status !== 'valid') continue; if (candidate.status !== 'valid') continue;
const state = inspectSource({ ...candidate, recovered: true }); states.push(inspectSource({ ...candidate, recovered: candidatePath !== filePath }));
if (!firstBlockingIssue(state)) return state;
} }
return primaryState; if (states.length === 0) {
} if (observedCandidate) throw issueError(KEYRING_ERROR_CODES.structure);
for (const candidatePath of candidates) { const document = { version: 1, generation: 0, keys: [] };
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 candidate = await readCandidate(candidatePath);
if (candidate.status !== 'valid') continue;
const state = inspectSource({ ...candidate, recovered: true });
fallback ||= state;
if (!firstBlockingIssue(state)) return state;
}
if (fallback) return fallback;
if (primary.status === 'missing' && candidates.length === 0) {
const document = { version: 1, keys: [] };
return inspectSource({ return inspectSource({
status: 'valid', status: 'valid',
path: filePath, path: filePath,
contents: JSON.stringify(document), contents: JSON.stringify({ version: 1, keys: [] }),
document, document,
recovered: false 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) { function firstBlockingIssue(state) {
@@ -293,6 +301,13 @@ function createOnlineBackupKeyring({
return next; 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) { function temporaryPath(kind) {
return path.join(directory, `.${basename}.${process.pid}.${crypto.randomUUID()}.${kind}.tmp`); return path.join(directory, `.${basename}.${process.pid}.${crypto.randomUUID()}.${kind}.tmp`);
} }
@@ -326,25 +341,6 @@ function createOnlineBackupKeyring({
if (failure) throw failure; 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) { async function cleanupTemporaryFiles(except = null) {
let entries; let entries;
try { try {
@@ -355,35 +351,47 @@ function createOnlineBackupKeyring({
} }
for (const entry of entries) { for (const entry of entries) {
const candidatePath = path.join(directory, entry.name); 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); 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({ const contents = JSON.stringify({
version: 1, version: 2,
generation,
keys: entries.map(({ id, encryptedKey, createdAt }) => ({ id, encryptedKey, createdAt })) keys: entries.map(({ id, encryptedKey, createdAt }) => ({ id, encryptedKey, createdAt }))
}); });
const payload = canonicalPayload(parseDocument(contents));
const stagingPath = temporaryPath('staging');
const primaryTemporaryPath = temporaryPath('primary'); const primaryTemporaryPath = temporaryPath('primary');
const recoveryTemporaryPath = temporaryPath('recovery'); const recoveryTemporaryPath = temporaryPath('recovery');
await fsImpl.mkdir(directory, { recursive: true }); await fsImpl.mkdir(directory, { recursive: true });
try { try {
await writeAndSync(primaryTemporaryPath, contents); await writeAndSync(stagingPath, contents);
await writeAndSync(recoveryTemporaryPath, contents); await validateStaging(stagingPath, generation, payload);
await syncDirectory(); await fsImpl.rename(stagingPath, recoveryTemporaryPath);
await fsImpl.rename(primaryTemporaryPath, filePath);
} catch (error) { } catch (error) {
await removeFile(primaryTemporaryPath); await removeFile(stagingPath);
await removeFile(recoveryTemporaryPath);
throw error; throw error;
} }
let recoveryPath = recoveryTemporaryPath; let recoveryPath = recoveryTemporaryPath;
try { try {
await syncDirectory(); await writeAndSync(primaryTemporaryPath, contents);
await fsImpl.rename(primaryTemporaryPath, filePath);
} catch {}
try {
await fsImpl.rename(recoveryTemporaryPath, backupPath); await fsImpl.rename(recoveryTemporaryPath, backupPath);
recoveryPath = null; recoveryPath = null;
await syncDirectory();
} catch {} } catch {}
try { try {
await cleanupTemporaryFiles(recoveryPath); await cleanupTemporaryFiles(recoveryPath);
@@ -436,7 +444,7 @@ function createOnlineBackupKeyring({
const blockingIssue = firstBlockingIssue(state); const blockingIssue = firstBlockingIssue(state);
if (blockingIssue) throw issueError(blockingIssue); if (blockingIssue) throw issueError(blockingIssue);
if (state.entries.some(current => current.id === validated.entry.id)) return false; 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; return true;
}); });
} }
@@ -460,15 +468,18 @@ function createOnlineBackupKeyring({
const entry = state.entries.find(current => current.id === id); const entry = state.entries.find(current => current.id === id);
if (!entry) return null; if (!entry) return null;
const plan = Object.freeze({ id: entry.id, key: entry.key }); 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; return plan;
} }
function commitRemove(plan) { function commitRemove(plan) {
return serialize(async () => { return serialize(async () => {
const remaining = removalPlans.get(plan); const prepared = removalPlans.get(plan);
if (!remaining) throw issueError(KEYRING_ERROR_CODES.plan); if (!prepared) throw issueError(KEYRING_ERROR_CODES.plan);
await writeEntries(remaining); await writeEntries(prepared.entries, nextGeneration(prepared.generation));
removalPlans.delete(plan); removalPlans.delete(plan);
return true; return true;
}); });
+268 -193
View File
@@ -61,8 +61,11 @@ function keyWithRecordId(sourceKey, fill) {
return `MHU2-${Buffer.concat([idBytes, masterKey, checksum]).toString('base64url')}`; return `MHU2-${Buffer.concat([idBytes, masterKey, checksum]).toString('base64url')}`;
} }
function writeKeyring(filePath, keys) { function writeKeyring(filePath, keys, generation = null) {
fs.writeFileSync(filePath, JSON.stringify({ version: 1, keys })); const document = generation === null
? { version: 1, keys }
: { version: 2, generation, keys };
fs.writeFileSync(filePath, JSON.stringify(document));
} }
describe('encrypted online backup keyring', () => { describe('encrypted online backup keyring', () => {
@@ -75,7 +78,9 @@ describe('encrypted online backup keyring', () => {
const document = JSON.parse(fs.readFileSync(filePath, 'utf8')); const document = JSON.parse(fs.readFileSync(filePath, 'utf8'));
const snapshot = await keyring.list(); 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(document.keys.length, 1);
assert.equal(fs.readFileSync(filePath, 'utf8').includes(key), false); assert.equal(fs.readFileSync(filePath, 'utf8').includes(key), false);
assert.deepEqual(snapshot.issues, []); assert.deepEqual(snapshot.issues, []);
@@ -90,6 +95,29 @@ describe('encrypted online backup keyring', () => {
assert.equal(await keyring.getKey(prepared.id), key); 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 () => { it('requires a canonical encrypted envelope before decrypting stored values', async () => {
const key = validKey(); const key = validKey();
const id = parseOnlineBackupKey(key).id; 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 () => { it('fully validates a unique removal plan before committing exactly that plan', async () => {
let decryptAllowed = true; const { keyring } = fixture();
const { keyring } = fixture({
decryptField: value => {
if (!decryptAllowed) throw new Error('late revalidation');
return decrypt(value);
}
});
const removed = validKey(); const removed = validKey();
const retained = validKey(); const retained = validKey();
await keyring.commit(keyring.prepare(removed, timestamp)); await keyring.commit(keyring.prepare(removed, timestamp));
await keyring.commit(keyring.prepare(retained, '2026-08-22T11:00:00.000Z')); await keyring.commit(keyring.prepare(retained, '2026-08-22T11:00:00.000Z'));
const plan = await keyring.prepareRemove(parseOnlineBackupKey(removed).id); const plan = await keyring.prepareRemove(parseOnlineBackupKey(removed).id);
decryptAllowed = false;
assert.equal(plan.id, parseOnlineBackupKey(removed).id); assert.equal(plan.id, parseOnlineBackupKey(removed).id);
assert.equal(plan.key, removed); assert.equal(plan.key, removed);
assert.equal(await keyring.commitRemove(plan), true); assert.equal(await keyring.commitRemove(plan), true);
decryptAllowed = true;
assert.deepEqual((await keyring.list()).entries.map(entry => entry.id), [parseOnlineBackupKey(retained).id]); 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 events = [];
const fsImpl = { const fsImpl = {
...fs.promises, ...fs.promises,
@@ -308,117 +464,63 @@ describe('encrypted online backup keyring', () => {
await keyring.commit(keyring.prepare(validKey(), timestamp)); await keyring.commit(keyring.prepare(validKey(), timestamp));
const renames = events.filter(event => event.startsWith('rename:')); const renames = events.filter(event => event.startsWith('rename:'));
assert.equal(renames.length, 2); assert.equal(renames.length, 3);
for (const rename of renames) { const stagingRename = renames.find(rename => rename.includes('.staging.tmp>') && rename.endsWith('.recovery.tmp'));
const source = rename.slice('rename:'.length).split('>')[0]; const primaryRename = renames.find(rename => rename.includes('.primary.tmp>') && rename.endsWith(`>${path.basename(filePath)}`));
assert.ok(events.indexOf(`write:${source}`) < events.indexOf(`sync:${source}`)); const backupRename = renames.find(rename => rename.includes('.recovery.tmp>') && rename.endsWith(`>${path.basename(backupPath)}`));
assert.ok(events.indexOf(`sync:${source}`) < events.indexOf(`close:${source}`)); const stagingSource = stagingRename.slice('rename:'.length).split('>')[0];
assert.ok(events.indexOf(`close:${source}`) < events.indexOf(rename)); 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(filePath), true);
assert.equal(fs.existsSync(backupPath), 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 () => { it('does not depend on a Windows directory fsync commit boundary', async () => {
const first = fixture(); const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-keyring-'));
const firstKey = validKey(); directories.push(directory);
await first.keyring.commit(first.keyring.prepare(firstKey, timestamp)); const filePath = path.join(directory, 'online-backup-keyring.json');
const original = fs.readFileSync(first.filePath, 'utf8'); let directoryOpens = 0;
let directorySyncs = 0;
const fsImpl = { const fsImpl = {
...fs.promises, ...fs.promises,
open: async (target, flags, mode) => { open: async (target, flags, mode) => {
const handle = await fs.promises.open(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 { return {
sync: async () => { sync: async () => { throw Object.assign(new Error('unsupported'), { code: 'EPERM' }); },
directorySyncs++;
if (directorySyncs === 1) throw Object.assign(new Error('directory sync failed'), { code: 'EIO' });
return handle.sync();
},
close: () => handle.close() close: () => handle.close()
}; };
} }
}; };
const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring'); const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring');
const keyring = createOnlineBackupKeyring({ const keyring = createOnlineBackupKeyring({
filePath: first.filePath, filePath,
encryptField: encrypt, encryptField: encrypt,
decryptField: decrypt, decryptField: decrypt,
isEncrypted: isCanonicalEnvelope, isEncrypted: isCanonicalEnvelope,
fsImpl fsImpl
}); });
await assert.rejects( assert.equal(await keyring.commit(keyring.prepare(validKey(), timestamp)), true);
keyring.commit(keyring.prepare(validKey(), '2026-08-22T11:00:00.000Z')), assert.equal(directoryOpens, 0);
/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);
}); });
it('keeps a durable recovery temp after a post-publication sync failure', async () => { it('rejects unsafe or loose v2 generation documents', async () => {
const first = fixture(); const key = validKey();
const firstKey = validKey(); const entry = { id: parseOnlineBackupKey(key).id, encryptedKey: encrypt(key), createdAt: timestamp };
const secondKey = validKey(); for (const generation of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, '1']) {
await first.keyring.commit(first.keyring.prepare(firstKey, timestamp)); const { filePath, keyring } = fixture();
let primaryPublished = false; fs.writeFileSync(filePath, JSON.stringify({ version: 2, generation, keys: [entry] }));
let postPublicationSyncFailed = false; await assert.rejects(keyring.list(), error => error.code === 'KEYRING_STRUCTURE_INVALID');
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(); const { filePath, keyring } = fixture();
}, fs.writeFileSync(filePath, JSON.stringify({ version: 2, generation: 1, keys: [entry], extra: true }));
close: () => handle.close() await assert.rejects(keyring.list(), error => error.code === 'KEYRING_STRUCTURE_INVALID');
};
},
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('recovers a validated backup without presenting corruption as an empty keyring', async () => { 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); 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 first = fixture();
const key = validKey(); const key = validKey();
await first.keyring.commit(first.keyring.prepare(key, timestamp)); await first.keyring.commit(first.keyring.prepare(key, timestamp));
@@ -475,8 +640,6 @@ describe('encrypted online backup keyring', () => {
encryptedKey: 'enc:v1:YWJjZA', encryptedKey: 'enc:v1:YWJjZA',
createdAt: timestamp createdAt: timestamp
}]); }]);
const future = new Date(Date.now() + 60_000);
fs.utimesSync(invalidTemp, future, future);
const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring'); const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring');
const recovered = createOnlineBackupKeyring({ const recovered = createOnlineBackupKeyring({
filePath: first.filePath, filePath: first.filePath,
@@ -491,7 +654,7 @@ describe('encrypted online backup keyring', () => {
assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']); 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 first = fixture();
const older = validKey(); const older = validKey();
const newer = 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'); const newerEntry = first.keyring.prepare(newer, '2026-08-22T11:00:00.000Z');
await first.keyring.commit(olderEntry); await first.keyring.commit(olderEntry);
const recoveryTemp = path.join(first.directory, `.online-backup-keyring.json.${process.pid}.${crypto.randomUUID()}.recovery.tmp`); const recoveryTemp = path.join(first.directory, `.online-backup-keyring.json.${process.pid}.${crypto.randomUUID()}.recovery.tmp`);
writeKeyring(recoveryTemp, [olderEntry, newerEntry]); writeKeyring(recoveryTemp, [olderEntry, newerEntry], 2);
const future = new Date(Date.now() + 60_000);
fs.utimesSync(recoveryTemp, future, future);
const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring'); const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring');
const recovered = createOnlineBackupKeyring({ const recovered = createOnlineBackupKeyring({
filePath: first.filePath, filePath: first.filePath,
@@ -519,92 +680,6 @@ describe('encrypted online backup keyring', () => {
assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']); 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 () => { it('commits successfully once the primary is published even when later cleanup fails', async () => {
const first = fixture(); const first = fixture();
const firstKey = validKey(); const firstKey = validKey();