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
+118 -107
View File
@@ -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;
});