172 lines
5.3 KiB
JavaScript
172 lines
5.3 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'];
|
|
|
|
function isObject(value) {
|
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function normalizeTimestamp(value) {
|
|
const timestamp = new Date(value);
|
|
if (!Number.isFinite(timestamp.getTime())) throw new Error('Ungültiger Erstellungszeitpunkt');
|
|
return timestamp.toISOString();
|
|
}
|
|
|
|
function createOnlineBackupKeyring({
|
|
filePath,
|
|
encryptField = secretStore.encryptField,
|
|
decryptField = secretStore.decryptField,
|
|
parseKey = parseOnlineBackupKey,
|
|
fsImpl = fs.promises
|
|
}) {
|
|
let mutation = Promise.resolve();
|
|
|
|
function validateEntry(entry) {
|
|
if (!isObject(entry)) return null;
|
|
const keys = Object.keys(entry).sort();
|
|
if (
|
|
keys.length !== STORED_ENTRY_KEYS.length
|
|
|| !keys.every((key, index) => key === STORED_ENTRY_KEYS[index])
|
|
|| typeof entry.id !== 'string'
|
|
|| !entry.id
|
|
|| typeof entry.encryptedKey !== 'string'
|
|
|| !entry.encryptedKey
|
|
|| typeof entry.createdAt !== 'string'
|
|
) {
|
|
return null;
|
|
}
|
|
let key;
|
|
let parsed;
|
|
try {
|
|
key = decryptField(entry.encryptedKey);
|
|
parsed = parseKey(key);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (typeof key !== 'string' || parsed?.id !== entry.id) return null;
|
|
let createdAt;
|
|
try {
|
|
createdAt = normalizeTimestamp(entry.createdAt);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (createdAt !== entry.createdAt) return null;
|
|
return { id: entry.id, encryptedKey: entry.encryptedKey, createdAt, key };
|
|
}
|
|
|
|
async function readEntries(rejectInvalidEntries = false) {
|
|
let contents;
|
|
try {
|
|
contents = await fsImpl.readFile(filePath, 'utf8');
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') return [];
|
|
throw error;
|
|
}
|
|
let document;
|
|
try {
|
|
document = JSON.parse(contents);
|
|
} catch {
|
|
throw new Error('Gespeicherter Online-Schlüsselbund ist ungültig');
|
|
}
|
|
if (!isObject(document) || document.version !== 1 || !Array.isArray(document.entries)) {
|
|
throw new Error('Gespeicherter Online-Schlüsselbund ist ungültig');
|
|
}
|
|
const entries = document.entries.map(validateEntry);
|
|
if (rejectInvalidEntries && entries.some((entry) => !entry)) {
|
|
throw new Error('Gespeicherter Online-Schlüsselbund ist ungültig');
|
|
}
|
|
return entries.filter(Boolean);
|
|
}
|
|
|
|
async function writeEntries(entries) {
|
|
const directory = path.dirname(filePath);
|
|
const temporaryPath = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
const document = JSON.stringify({
|
|
version: 1,
|
|
entries: entries.map(({ id, encryptedKey, createdAt }) => ({ id, encryptedKey, createdAt }))
|
|
});
|
|
await fsImpl.mkdir(directory, { recursive: true });
|
|
try {
|
|
await fsImpl.writeFile(temporaryPath, document, { encoding: 'utf8', flag: 'wx' });
|
|
await fsImpl.rename(temporaryPath, filePath);
|
|
} catch (error) {
|
|
try {
|
|
await fsImpl.unlink(temporaryPath);
|
|
} catch (cleanupError) {
|
|
if (cleanupError?.code !== 'ENOENT') throw cleanupError;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function serialize(operation) {
|
|
const next = mutation.catch(() => {}).then(operation);
|
|
mutation = next;
|
|
return next;
|
|
}
|
|
|
|
async function list() {
|
|
const entries = await readEntries();
|
|
const sanitized = 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(sanitized);
|
|
}
|
|
|
|
function prepare(key, createdAt) {
|
|
let parsed;
|
|
let encryptedKey;
|
|
try {
|
|
parsed = parseKey(key);
|
|
encryptedKey = encryptField(key);
|
|
} catch {
|
|
throw new Error('Online-Sicherungsschlüssel konnte nicht sicher vorbereitet werden');
|
|
}
|
|
if (typeof encryptedKey !== 'string' || !encryptedKey || encryptedKey === key) {
|
|
throw new Error('Online-Sicherungsschlüssel konnte nicht sicher vorbereitet werden');
|
|
}
|
|
return Object.freeze({
|
|
id: parsed.id,
|
|
encryptedKey,
|
|
createdAt: normalizeTimestamp(createdAt)
|
|
});
|
|
}
|
|
|
|
function commit(entry) {
|
|
return serialize(async () => {
|
|
const validated = validateEntry(entry);
|
|
if (!validated) throw new Error('Online-Sicherungsschlüssel ist ungültig');
|
|
const entries = await readEntries(true);
|
|
if (entries.some((current) => current.id === validated.id)) return;
|
|
await writeEntries([...entries, validated]);
|
|
});
|
|
}
|
|
|
|
function remove(id) {
|
|
return serialize(async () => {
|
|
const entries = await readEntries(true);
|
|
const remaining = entries.filter((entry) => entry.id !== id);
|
|
if (remaining.length === entries.length) return false;
|
|
await writeEntries(remaining);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
async function getKey(id) {
|
|
const entries = await readEntries();
|
|
return entries.find((entry) => entry.id === id)?.key ?? null;
|
|
}
|
|
|
|
return Object.freeze({ list, prepare, commit, remove, getKey });
|
|
}
|
|
|
|
module.exports = { createOnlineBackupKeyring };
|