Require canonical encrypted envelopes and surface typed sanitized keyring corruption states. Persist crash-durable primary and recovery files, prevalidate removal plans, isolate renderer refresh authority, and cover the real hidden Windows DPAPI and IPC composition.
467 lines
16 KiB
JavaScript
467 lines
16 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_DOCUMENT_KEYS = ['keys', 'version'];
|
|
const DIRECTORY_SYNC_UNSUPPORTED = new Set(['EACCES', 'EBADF', 'EISDIR', 'EINVAL', 'ENOTSUP', 'EPERM']);
|
|
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 isTemporaryFileName(value) {
|
|
return value.startsWith(temporaryPrefix)
|
|
&& /^\d+\.[0-9a-f-]+\.(?:primary|recovery)\.tmp$/u.test(value.slice(temporaryPrefix.length));
|
|
}
|
|
|
|
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_DOCUMENT_KEYS) || document.version !== 1 || !Array.isArray(document.keys)) {
|
|
throw issueError(KEYRING_ERROR_CODES.structure);
|
|
}
|
|
return document;
|
|
}
|
|
|
|
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() && isTemporaryFileName(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 });
|
|
} catch (error) {
|
|
if (error?.code !== 'ENOENT') ranked.push({ candidatePath, modified: 0 });
|
|
}
|
|
}
|
|
ranked.sort((left, right) => right.modified - left.modified);
|
|
return ranked.map(candidate => candidate.candidatePath);
|
|
}
|
|
|
|
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, entries, problems, duplicateIds, issues };
|
|
}
|
|
|
|
async function readState() {
|
|
const primary = await readCandidate(filePath);
|
|
if (primary.status === 'valid') {
|
|
let primaryModified = 0;
|
|
try {
|
|
primaryModified = (await fsImpl.stat(filePath)).mtimeMs;
|
|
} catch {}
|
|
const candidates = await recoveryCandidates();
|
|
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 inspectSource({ ...primary, recovered: false });
|
|
}
|
|
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({
|
|
status: 'valid',
|
|
path: filePath,
|
|
contents: JSON.stringify(document),
|
|
document,
|
|
recovered: false
|
|
});
|
|
}
|
|
throw issueError(KEYRING_ERROR_CODES.structure);
|
|
}
|
|
|
|
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 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 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 {
|
|
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() || !isTemporaryFileName(entry.name) || candidatePath === except) continue;
|
|
await removeFile(candidatePath);
|
|
}
|
|
}
|
|
|
|
async function writeEntries(entries) {
|
|
const contents = JSON.stringify({
|
|
version: 1,
|
|
keys: entries.map(({ id, encryptedKey, createdAt }) => ({ id, encryptedKey, createdAt }))
|
|
});
|
|
const primaryTemporaryPath = temporaryPath('primary');
|
|
const recoveryTemporaryPath = temporaryPath('recovery');
|
|
await fsImpl.mkdir(directory, { recursive: true });
|
|
let primaryPublished = false;
|
|
try {
|
|
await writeAndSync(primaryTemporaryPath, contents);
|
|
await writeAndSync(recoveryTemporaryPath, contents);
|
|
await fsImpl.rename(primaryTemporaryPath, filePath);
|
|
primaryPublished = true;
|
|
} catch (error) {
|
|
await removeFile(primaryTemporaryPath);
|
|
await removeFile(recoveryTemporaryPath);
|
|
throw error;
|
|
}
|
|
let recoveryPath = recoveryTemporaryPath;
|
|
try {
|
|
await syncDirectory();
|
|
await fsImpl.rename(recoveryTemporaryPath, backupPath);
|
|
recoveryPath = null;
|
|
await syncDirectory();
|
|
} catch {
|
|
if (!primaryPublished) throw issueError(KEYRING_ERROR_CODES.structure);
|
|
}
|
|
await cleanupTemporaryFiles(recoveryPath);
|
|
}
|
|
|
|
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]);
|
|
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, 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);
|
|
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
|
|
};
|