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.
502 lines
17 KiB
JavaScript
502 lines
17 KiB
JavaScript
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const crypto = require('node:crypto');
|
|
const secretStore = require('./secret-store');
|
|
const { parseOnlineBackupKey } = require('./online-backup');
|
|
|
|
const STORED_ENTRY_KEYS = ['createdAt', 'encryptedKey', 'id'];
|
|
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',
|
|
decrypt: 'KEYRING_DECRYPT_FAILED',
|
|
mismatch: 'KEYRING_ID_MISMATCH',
|
|
duplicate: 'KEYRING_DUPLICATE_ID',
|
|
recovered: 'KEYRING_RECOVERED',
|
|
encrypt: 'KEYRING_ENCRYPT_FAILED',
|
|
plan: 'KEYRING_REMOVE_PLAN_INVALID'
|
|
});
|
|
const ERROR_MESSAGES = Object.freeze({
|
|
[KEYRING_ERROR_CODES.structure]: 'Gespeicherter Online-Schlüsselbund ist beschädigt',
|
|
[KEYRING_ERROR_CODES.unavailable]: 'Sichere Schlüsselspeicherung ist nicht verfügbar',
|
|
[KEYRING_ERROR_CODES.decrypt]: 'Gespeicherter Online-Sicherungsschlüssel konnte nicht entschlüsselt werden',
|
|
[KEYRING_ERROR_CODES.mismatch]: 'Gespeicherte Online-Sicherungskennung stimmt nicht mit dem Schlüssel überein',
|
|
[KEYRING_ERROR_CODES.duplicate]: 'Gespeicherte Online-Sicherungskennung ist mehrdeutig',
|
|
[KEYRING_ERROR_CODES.recovered]: 'Online-Schlüsselbund wurde aus einer Wiederherstellungsdatei geladen',
|
|
[KEYRING_ERROR_CODES.encrypt]: 'Online-Sicherungsschlüssel konnte nicht sicher vorbereitet werden',
|
|
[KEYRING_ERROR_CODES.plan]: 'Online-Sicherung konnte lokal nicht eindeutig entfernt werden'
|
|
});
|
|
const ISSUE_ORDER = Object.freeze([
|
|
KEYRING_ERROR_CODES.unavailable,
|
|
KEYRING_ERROR_CODES.duplicate,
|
|
KEYRING_ERROR_CODES.decrypt,
|
|
KEYRING_ERROR_CODES.mismatch,
|
|
KEYRING_ERROR_CODES.structure,
|
|
KEYRING_ERROR_CODES.recovered
|
|
]);
|
|
|
|
class OnlineBackupKeyringError extends Error {
|
|
constructor(code) {
|
|
super(ERROR_MESSAGES[code] || ERROR_MESSAGES[KEYRING_ERROR_CODES.structure]);
|
|
this.name = 'OnlineBackupKeyringError';
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
function isObject(value) {
|
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function hasExactKeys(value, expected) {
|
|
if (!isObject(value)) return false;
|
|
const keys = Object.keys(value).sort();
|
|
return keys.length === expected.length && keys.every((key, index) => key === expected[index]);
|
|
}
|
|
|
|
function isCanonicalId(value) {
|
|
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{22}$/u.test(value)) return false;
|
|
const decoded = Buffer.from(value, 'base64url');
|
|
return decoded.length === 16 && decoded.toString('base64url') === value;
|
|
}
|
|
|
|
function normalizeTimestamp(value) {
|
|
const timestamp = new Date(value);
|
|
if (!Number.isFinite(timestamp.getTime())) throw new OnlineBackupKeyringError(KEYRING_ERROR_CODES.structure);
|
|
return timestamp.toISOString();
|
|
}
|
|
|
|
function uniqueIssues(issues) {
|
|
const values = [...new Set(issues)];
|
|
values.sort((left, right) => ISSUE_ORDER.indexOf(left) - ISSUE_ORDER.indexOf(right));
|
|
return values;
|
|
}
|
|
|
|
function createOnlineBackupKeyring({
|
|
filePath,
|
|
encryptField = secretStore.encryptField,
|
|
decryptField = secretStore.decryptField,
|
|
isEncrypted = secretStore.isEncrypted,
|
|
parseKey = parseOnlineBackupKey,
|
|
fsImpl = fs.promises
|
|
}) {
|
|
const directory = path.dirname(filePath);
|
|
const basename = path.basename(filePath);
|
|
const backupPath = `${filePath}.bak`;
|
|
const temporaryPrefix = `.${basename}.`;
|
|
const removalPlans = new WeakMap();
|
|
let mutation = Promise.resolve();
|
|
|
|
function issueError(code) {
|
|
return new OnlineBackupKeyringError(code);
|
|
}
|
|
|
|
function isCandidateTemporaryFileName(value) {
|
|
return value.startsWith(temporaryPrefix)
|
|
&& /^\d+\.[0-9a-f-]+\.(?:primary|recovery)\.tmp$/u.test(value.slice(temporaryPrefix.length));
|
|
}
|
|
|
|
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 1;
|
|
if (name.endsWith('.primary.tmp')) return 2;
|
|
return 3;
|
|
}
|
|
|
|
function encryptionError(error) {
|
|
return issueError(error?.code === 'SECRET_STORE_UNAVAILABLE' ? KEYRING_ERROR_CODES.unavailable : KEYRING_ERROR_CODES.encrypt);
|
|
}
|
|
|
|
function decryptionIssue(error) {
|
|
return error?.code === 'SECRET_STORE_UNAVAILABLE' ? KEYRING_ERROR_CODES.unavailable : KEYRING_ERROR_CODES.decrypt;
|
|
}
|
|
|
|
function parseDocument(contents) {
|
|
let document;
|
|
try {
|
|
document = JSON.parse(contents);
|
|
} catch {
|
|
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 };
|
|
}
|
|
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) {
|
|
try {
|
|
const contents = await fsImpl.readFile(candidatePath, 'utf8');
|
|
return { status: 'valid', path: candidatePath, contents, document: parseDocument(contents) };
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') return { status: 'missing', path: candidatePath };
|
|
return { status: 'invalid', path: candidatePath };
|
|
}
|
|
}
|
|
|
|
async function recoveryCandidates() {
|
|
const candidates = new Set([backupPath]);
|
|
try {
|
|
const entries = await fsImpl.readdir(directory, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
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);
|
|
}
|
|
return [...candidates].sort((left, right) =>
|
|
candidatePriority(left) - candidatePriority(right)
|
|
|| left.localeCompare(right)
|
|
);
|
|
}
|
|
|
|
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) {
|
|
if (
|
|
!hasExactKeys(entry, STORED_ENTRY_KEYS)
|
|
|| !isCanonicalId(entry.id)
|
|
|| typeof entry.encryptedKey !== 'string'
|
|
|| !isEncrypted(entry.encryptedKey)
|
|
|| typeof entry.createdAt !== 'string'
|
|
) {
|
|
return { issue: KEYRING_ERROR_CODES.structure, id: typeof entry?.id === 'string' ? entry.id : null };
|
|
}
|
|
let createdAt;
|
|
try {
|
|
createdAt = normalizeTimestamp(entry.createdAt);
|
|
} catch {
|
|
return { issue: KEYRING_ERROR_CODES.structure, id: entry.id };
|
|
}
|
|
if (createdAt !== entry.createdAt) return { issue: KEYRING_ERROR_CODES.structure, id: entry.id };
|
|
let key;
|
|
try {
|
|
key = decryptField(entry.encryptedKey);
|
|
} catch (error) {
|
|
return { issue: decryptionIssue(error), id: entry.id };
|
|
}
|
|
if (typeof key !== 'string') return { issue: KEYRING_ERROR_CODES.decrypt, id: entry.id };
|
|
let parsed;
|
|
try {
|
|
parsed = parseKey(key);
|
|
} catch {
|
|
return { issue: KEYRING_ERROR_CODES.decrypt, id: entry.id };
|
|
}
|
|
if (parsed?.id !== entry.id) return { issue: KEYRING_ERROR_CODES.mismatch, id: entry.id };
|
|
return {
|
|
entry: {
|
|
id: entry.id,
|
|
encryptedKey: entry.encryptedKey,
|
|
createdAt,
|
|
key
|
|
}
|
|
};
|
|
}
|
|
|
|
function inspectSource(source) {
|
|
const idCounts = new Map();
|
|
for (const entry of source.document.keys) {
|
|
if (isCanonicalId(entry?.id)) idCounts.set(entry.id, (idCounts.get(entry.id) || 0) + 1);
|
|
}
|
|
const duplicateIds = new Set([...idCounts].filter(([, count]) => count > 1).map(([id]) => id));
|
|
const entries = [];
|
|
const problems = [];
|
|
for (const entry of source.document.keys) {
|
|
if (duplicateIds.has(entry?.id)) continue;
|
|
const result = validateEntry(entry);
|
|
if (result.entry) entries.push(result.entry);
|
|
else problems.push({ code: result.issue, id: result.id });
|
|
}
|
|
for (const id of duplicateIds) problems.push({ code: KEYRING_ERROR_CODES.duplicate, id });
|
|
const issues = uniqueIssues([
|
|
...problems.map(problem => problem.code),
|
|
...(source.recovered ? [KEYRING_ERROR_CODES.recovered] : [])
|
|
]);
|
|
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 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;
|
|
states.push(inspectSource({ ...candidate, recovered: candidatePath !== filePath }));
|
|
}
|
|
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({ version: 1, keys: [] }),
|
|
document,
|
|
recovered: false
|
|
});
|
|
}
|
|
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) {
|
|
return state.issues.find(issue => issue !== KEYRING_ERROR_CODES.recovered) || null;
|
|
}
|
|
|
|
function serialize(operation) {
|
|
const next = mutation.catch(() => {}).then(operation);
|
|
mutation = 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) {
|
|
return path.join(directory, `.${basename}.${process.pid}.${crypto.randomUUID()}.${kind}.tmp`);
|
|
}
|
|
|
|
async function removeFile(target) {
|
|
try {
|
|
await fsImpl.unlink(target);
|
|
} catch (error) {
|
|
if (error?.code !== 'ENOENT') return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function writeAndSync(target, contents) {
|
|
let handle;
|
|
let failure;
|
|
try {
|
|
handle = await fsImpl.open(target, 'wx', 0o600);
|
|
await handle.writeFile(contents, { encoding: 'utf8' });
|
|
await handle.sync();
|
|
} catch (error) {
|
|
failure = error;
|
|
}
|
|
if (handle) {
|
|
try {
|
|
await handle.close();
|
|
} catch (error) {
|
|
failure ||= error;
|
|
}
|
|
}
|
|
if (failure) throw failure;
|
|
}
|
|
|
|
async function cleanupTemporaryFiles(except = null) {
|
|
let entries;
|
|
try {
|
|
entries = await fsImpl.readdir(directory, { withFileTypes: true });
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') return;
|
|
throw error;
|
|
}
|
|
for (const entry of entries) {
|
|
const candidatePath = path.join(directory, entry.name);
|
|
if (!entry.isFile() || !isOwnedTemporaryFileName(entry.name) || candidatePath === except) continue;
|
|
await removeFile(candidatePath);
|
|
}
|
|
}
|
|
|
|
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: 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(stagingPath, contents);
|
|
await validateStaging(stagingPath, generation, payload);
|
|
await fsImpl.rename(stagingPath, recoveryTemporaryPath);
|
|
} catch (error) {
|
|
await removeFile(stagingPath);
|
|
throw error;
|
|
}
|
|
let recoveryPath = recoveryTemporaryPath;
|
|
try {
|
|
await writeAndSync(primaryTemporaryPath, contents);
|
|
await fsImpl.rename(primaryTemporaryPath, filePath);
|
|
} catch {}
|
|
try {
|
|
await fsImpl.rename(recoveryTemporaryPath, backupPath);
|
|
recoveryPath = null;
|
|
} catch {}
|
|
try {
|
|
await cleanupTemporaryFiles(recoveryPath);
|
|
} catch {}
|
|
}
|
|
|
|
async function list() {
|
|
const state = await readState();
|
|
const entries = state.entries
|
|
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
|
|
.map(({ id, key, createdAt }) => Object.freeze({
|
|
id,
|
|
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
|
createdAt
|
|
}));
|
|
return Object.freeze({
|
|
entries: Object.freeze(entries),
|
|
issues: Object.freeze([...state.issues])
|
|
});
|
|
}
|
|
|
|
function prepare(key, createdAt) {
|
|
let parsed;
|
|
try {
|
|
parsed = parseKey(key);
|
|
} catch {
|
|
throw issueError(KEYRING_ERROR_CODES.structure);
|
|
}
|
|
let encryptedKey;
|
|
try {
|
|
encryptedKey = encryptField(key);
|
|
} catch (error) {
|
|
throw encryptionError(error);
|
|
}
|
|
if (typeof encryptedKey !== 'string' || encryptedKey === key || !isEncrypted(encryptedKey)) {
|
|
throw issueError(KEYRING_ERROR_CODES.encrypt);
|
|
}
|
|
return Object.freeze({
|
|
id: parsed.id,
|
|
encryptedKey,
|
|
createdAt: normalizeTimestamp(createdAt)
|
|
});
|
|
}
|
|
|
|
function commit(entry) {
|
|
return serialize(async () => {
|
|
const validated = validateEntry(entry);
|
|
if (!validated.entry) throw issueError(validated.issue);
|
|
const state = await readState();
|
|
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], nextGeneration(state.generation));
|
|
return true;
|
|
});
|
|
}
|
|
|
|
async function getKey(id) {
|
|
const state = await readState();
|
|
if (state.duplicateIds.has(id)) throw issueError(KEYRING_ERROR_CODES.duplicate);
|
|
const entry = state.entries.find(current => current.id === id);
|
|
if (entry) return entry.key;
|
|
const matchingProblem = state.problems.find(problem => problem.id === id);
|
|
if (matchingProblem) throw issueError(matchingProblem.code);
|
|
const blockingIssue = firstBlockingIssue(state);
|
|
if (blockingIssue) throw issueError(blockingIssue);
|
|
return null;
|
|
}
|
|
|
|
async function prepareRemove(id) {
|
|
const state = await readState();
|
|
const blockingIssue = firstBlockingIssue(state);
|
|
if (blockingIssue) throw issueError(blockingIssue);
|
|
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, {
|
|
generation: state.generation,
|
|
entries: state.entries.filter(current => current.id !== id)
|
|
});
|
|
return plan;
|
|
}
|
|
|
|
function commitRemove(plan) {
|
|
return serialize(async () => {
|
|
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;
|
|
});
|
|
}
|
|
|
|
async function remove(id) {
|
|
const plan = await prepareRemove(id);
|
|
if (!plan) return false;
|
|
return commitRemove(plan);
|
|
}
|
|
|
|
return Object.freeze({ list, prepare, commit, remove, getKey, prepareRemove, commitRemove });
|
|
}
|
|
|
|
module.exports = {
|
|
KEYRING_ERROR_CODES,
|
|
OnlineBackupKeyringError,
|
|
createOnlineBackupKeyring
|
|
};
|