feat: add expiring backups and live account checks
CI / verify (push) Canceled after 0s

This commit is contained in:
Sucukdeluxe
2026-09-01 15:43:20 +02:00
parent 96037f2e1c
commit 9e5c5126dd
19 changed files with 710 additions and 128 deletions
+1
View File
@@ -58,6 +58,7 @@ const DEFAULTS = {
},
globalSettings: {
language: 'en',
autoHealthCheckEnabled: true,
alwaysOnTop: false,
shutdownAfterFinish: 'nothing', // nothing | sleep | shutdown | restart
logFilePath: '',
+65 -32
View File
@@ -4,9 +4,10 @@ const crypto = require('node:crypto');
const secretStore = require('./secret-store');
const { parseOnlineBackupKey } = require('./online-backup');
const STORED_ENTRY_KEYS = ['createdAt', 'encryptedKey', 'id'];
const STORED_LEGACY_ENTRY_KEYS = ['createdAt', 'encryptedKey', 'id'];
const STORED_EXPIRING_ENTRY_KEYS = ['createdAt', 'encryptedKey', 'expiresAt', 'id'];
const STORED_V1_DOCUMENT_KEYS = ['keys', 'version'];
const STORED_V2_DOCUMENT_KEYS = ['generation', 'keys', 'version'];
const STORED_GENERATED_DOCUMENT_KEYS = ['generation', 'keys', 'version'];
const KEYRING_ERROR_CODES = Object.freeze({
structure: 'KEYRING_STRUCTURE_INVALID',
unavailable: 'KEYRING_SECURE_STORAGE_UNAVAILABLE',
@@ -78,7 +79,8 @@ function createOnlineBackupKeyring({
decryptField = secretStore.decryptField,
isEncrypted = secretStore.isEncrypted,
parseKey = parseOnlineBackupKey,
fsImpl = fs.promises
fsImpl = fs.promises,
now = () => Date.now()
}) {
const directory = path.dirname(filePath);
const basename = path.basename(filePath);
@@ -128,7 +130,7 @@ function createOnlineBackupKeyring({
return { version: 1, generation: 0, keys: document.keys };
}
if (
hasExactKeys(document, STORED_V2_DOCUMENT_KEYS)
hasExactKeys(document, STORED_GENERATED_DOCUMENT_KEYS)
&& document.version === 2
&& Number.isSafeInteger(document.generation)
&& document.generation > 0
@@ -178,8 +180,10 @@ function createOnlineBackupKeyring({
}
function validateEntry(entry) {
const hasLegacyShape = hasExactKeys(entry, STORED_LEGACY_ENTRY_KEYS);
const hasExpiringShape = hasExactKeys(entry, STORED_EXPIRING_ENTRY_KEYS);
if (
!hasExactKeys(entry, STORED_ENTRY_KEYS)
(!hasLegacyShape && !hasExpiringShape)
|| !isCanonicalId(entry.id)
|| typeof entry.encryptedKey !== 'string'
|| !isEncrypted(entry.encryptedKey)
@@ -194,6 +198,16 @@ function createOnlineBackupKeyring({
return { issue: KEYRING_ERROR_CODES.structure, id: entry.id };
}
if (createdAt !== entry.createdAt) return { issue: KEYRING_ERROR_CODES.structure, id: entry.id };
let expiresAt = null;
if (hasExpiringShape && entry.expiresAt !== null) {
if (typeof entry.expiresAt !== 'string') return { issue: KEYRING_ERROR_CODES.structure, id: entry.id };
try {
expiresAt = normalizeTimestamp(entry.expiresAt);
} catch {
return { issue: KEYRING_ERROR_CODES.structure, id: entry.id };
}
if (expiresAt !== entry.expiresAt || expiresAt <= createdAt) return { issue: KEYRING_ERROR_CODES.structure, id: entry.id };
}
let key;
try {
key = decryptField(entry.encryptedKey);
@@ -213,6 +227,7 @@ function createOnlineBackupKeyring({
id: entry.id,
encryptedKey: entry.encryptedKey,
createdAt,
expiresAt,
key
}
};
@@ -300,22 +315,22 @@ function createOnlineBackupKeyring({
recovered: false
});
}
const v2States = states.filter(state => state.version === 2);
if (v2States.length > 0) {
const highestObservedGeneration = Math.max(...v2States.map(state => state.generation));
selectGeneration(v2States, highestObservedGeneration);
const validV2States = v2States.filter(state => !firstBlockingIssue(state));
if (validV2States.length > 0) {
const highestValidGeneration = Math.max(...validV2States.map(state => state.generation));
return selectGeneration(validV2States, highestValidGeneration);
const generatedStates = states.filter(state => state.version === 2);
if (generatedStates.length > 0) {
const highestObservedGeneration = Math.max(...generatedStates.map(state => state.generation));
selectGeneration(generatedStates, highestObservedGeneration);
const validGeneratedStates = generatedStates.filter(state => !firstBlockingIssue(state));
if (validGeneratedStates.length > 0) {
const highestValidGeneration = Math.max(...validGeneratedStates.map(state => state.generation));
return selectGeneration(validGeneratedStates, highestValidGeneration);
}
}
const legacyStates = states.filter(state => state.version === 1);
const validLegacyStates = legacyStates.filter(state => !firstBlockingIssue(state));
if (validLegacyStates.length > 0) return selectLegacyState(validLegacyStates);
if (v2States.length > 0) {
const highestObservedGeneration = Math.max(...v2States.map(state => state.generation));
return selectGeneration(v2States, highestObservedGeneration);
if (generatedStates.length > 0) {
const highestObservedGeneration = Math.max(...generatedStates.map(state => state.generation));
return selectGeneration(generatedStates, highestObservedGeneration);
}
return selectLegacyState(legacyStates);
}
@@ -398,7 +413,7 @@ function createOnlineBackupKeyring({
const contents = JSON.stringify({
version: 2,
generation,
keys: entries.map(({ id, encryptedKey, createdAt }) => ({ id, encryptedKey, createdAt }))
keys: entries.map(({ id, encryptedKey, createdAt, expiresAt }) => ({ id, encryptedKey, createdAt, expiresAt: expiresAt ?? null }))
});
const payload = canonicalPayload(parseDocument(contents));
const stagingPath = temporaryPath('staging');
@@ -427,22 +442,33 @@ function createOnlineBackupKeyring({
} 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 isExpired(entry) {
return entry.expiresAt !== null && new Date(entry.expiresAt).getTime() <= Number(now());
}
function list() {
return serialize(async () => {
const state = await readState();
const activeEntries = state.entries.filter(entry => !isExpired(entry));
if (activeEntries.length !== state.entries.length && !firstBlockingIssue(state)) {
await writeEntries(activeEntries, nextGeneration(state.generation));
}
const entries = activeEntries
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
.map(({ id, key, createdAt, expiresAt }) => Object.freeze({
id,
displayKey: `${key.slice(0, 9)}${key.slice(-4)}`,
createdAt,
expiresAt
}));
return Object.freeze({
entries: Object.freeze(entries),
issues: Object.freeze([...state.issues])
});
});
}
function prepare(key, createdAt) {
function prepare(key, createdAt, expiresAt = null) {
let parsed;
try {
parsed = parseKey(key);
@@ -458,10 +484,16 @@ function createOnlineBackupKeyring({
if (typeof encryptedKey !== 'string' || encryptedKey === key || !isEncrypted(encryptedKey)) {
throw issueError(KEYRING_ERROR_CODES.encrypt);
}
const normalizedCreatedAt = normalizeTimestamp(createdAt);
const normalizedExpiresAt = expiresAt === null ? null : normalizeTimestamp(expiresAt);
if (normalizedExpiresAt !== null && normalizedExpiresAt <= normalizedCreatedAt) {
throw issueError(KEYRING_ERROR_CODES.structure);
}
return Object.freeze({
id: parsed.id,
encryptedKey,
createdAt: normalizeTimestamp(createdAt)
createdAt: normalizedCreatedAt,
expiresAt: normalizedExpiresAt
});
}
@@ -482,7 +514,8 @@ function createOnlineBackupKeyring({
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;
if (entry && !isExpired(entry)) return entry.key;
if (entry) return null;
const matchingProblem = state.problems.find(problem => problem.id === id);
if (matchingProblem) throw issueError(matchingProblem.code);
const blockingIssue = firstBlockingIssue(state);
+9 -7
View File
@@ -27,7 +27,8 @@ function sanitizeEntry(entry) {
return {
id: entry.id,
displayKey: entry.displayKey,
createdAt: entry.createdAt
createdAt: entry.createdAt,
expiresAt: entry.expiresAt ?? null
};
}
@@ -35,7 +36,8 @@ function sanitizeCreatedEntry(entry, key) {
return {
id: entry.id,
displayKey: `${key.slice(0, 9)}${key.slice(-4)}`,
createdAt: entry.createdAt
createdAt: entry.createdAt,
expiresAt: entry.expiresAt ?? null
};
}
@@ -71,11 +73,11 @@ function createOnlineBackupManager({
};
}
async function createTransaction() {
async function createTransaction(retention) {
const createdAt = new Date().toISOString();
const settings = await loadSettings();
const created = createBackup(settings, appVersion(), createdAt);
const prepared = keyring.prepare(created.key, createdAt);
const created = createBackup(settings, appVersion(), createdAt, retention);
const prepared = keyring.prepare(created.key, createdAt, created.expiresAt ?? null);
await uploadBackup(created.record);
try {
await keyring.commit(prepared);
@@ -117,9 +119,9 @@ function createOnlineBackupManager({
}
}
async function createManaged() {
async function createManaged(retention = '7d') {
try {
return await serialize(createTransaction);
return await serialize(() => createTransaction(retention));
} catch (error) {
return keyringFailure(error, ERRORS.create);
}
+37 -3
View File
@@ -16,6 +16,14 @@ const MAX_PLAINTEXT_BYTES = 512 * 1024;
const REQUEST_TIMEOUT_MS = 12_000;
const KEY_CONTEXT = Buffer.from('MHU2-ONLINE-KEY-V1', 'utf8');
const AAD_CONTEXT = Buffer.from('MHU-ONLINE-BACKUP-V1', 'utf8');
const ONLINE_BACKUP_RETENTION_SECONDS = Object.freeze({
'1d': 24 * 60 * 60,
'3d': 3 * 24 * 60 * 60,
'7d': 7 * 24 * 60 * 60,
'31d': 31 * 24 * 60 * 60,
forever: null
});
const DEFAULT_ONLINE_BACKUP_RETENTION = '7d';
function checksum(idBytes, masterKey) {
return crypto.createHash('sha256').update(KEY_CONTEXT).update(idBytes).update(masterKey).digest().subarray(0, CHECKSUM_LENGTH);
@@ -131,7 +139,27 @@ function parseOnlineBackupKey(key) {
};
}
function createOnlineBackup(settings, appVersion, exportedAt = new Date().toISOString()) {
function normalizeOnlineBackupRetention(value = DEFAULT_ONLINE_BACKUP_RETENTION) {
const normalized = String(value || '').trim();
if (!Object.prototype.hasOwnProperty.call(ONLINE_BACKUP_RETENTION_SECONDS, normalized)) {
throw new Error('Gültigkeitsdauer der Online-Sicherung ist ungültig');
}
return normalized;
}
function onlineBackupExpiration(exportedAt, retention) {
const normalized = normalizeOnlineBackupRetention(retention);
const seconds = ONLINE_BACKUP_RETENTION_SECONDS[normalized];
if (seconds === null) return null;
const created = new Date(exportedAt);
if (!Number.isFinite(created.getTime())) throw new Error('Erstellungszeit der Online-Sicherung ist ungültig');
return new Date(created.getTime() + seconds * 1000).toISOString();
}
function createOnlineBackup(settings, appVersion, exportedAt = new Date().toISOString(), retention = DEFAULT_ONLINE_BACKUP_RETENTION) {
const normalizedRetention = normalizeOnlineBackupRetention(retention);
const expiresInSeconds = ONLINE_BACKUP_RETENTION_SECONDS[normalizedRetention];
const expiresAt = onlineBackupExpiration(exportedAt, normalizedRetention);
const idBytes = crypto.randomBytes(RECORD_ID_LENGTH);
const masterKey = crypto.randomBytes(MASTER_KEY_LENGTH);
const key = encodeKey(idBytes, masterKey);
@@ -163,8 +191,10 @@ function createOnlineBackup(settings, appVersion, exportedAt = new Date().toISOS
record: {
id: parsed.id,
blob: blobBytes.toString('base64url'),
deleteVerifier
}
deleteVerifier,
expiresInSeconds
},
expiresAt
};
}
@@ -241,10 +271,14 @@ async function deleteOnlineBackup(key, baseUrl = ONLINE_BACKUP_API_URL, options)
}
module.exports = {
DEFAULT_ONLINE_BACKUP_RETENTION,
ONLINE_BACKUP_API_URL,
ONLINE_BACKUP_RETENTION_SECONDS,
createOnlineBackup,
deleteOnlineBackup,
downloadOnlineBackup,
normalizeOnlineBackupRetention,
onlineBackupExpiration,
parseOnlineBackupKey,
restoreOnlineBackup,
uploadOnlineBackup
+10 -2
View File
@@ -33,6 +33,13 @@ function createPortableSettingsSnapshot(config) {
history: []
};
snapshot.globalSettings.pendingQueue = null;
snapshot.globalSettings.uploadRecovery = null;
snapshot.globalSettings.lastBrowseDirectory = '';
if (snapshot.globalSettings.folderMonitor && typeof snapshot.globalSettings.folderMonitor === 'object') {
snapshot.globalSettings.folderMonitor.paused = false;
snapshot.globalSettings.folderMonitor.pausedAt = null;
delete snapshot.globalSettings.folderMonitor.telemetry;
}
return snapshot;
}
@@ -41,14 +48,15 @@ function prepareImportedSettings(value, options = {}) {
const imported = createPortableSettingsSnapshot(value);
const pathExists = options.pathExists || fs.existsSync;
const pathDirname = options.pathDirname || path.dirname;
const warnings = Array.isArray(options.warnings) ? options.warnings : null;
const globalSettings = imported.globalSettings;
if (globalSettings.logFilePath && !pathExists(pathDirname(globalSettings.logFilePath))) {
globalSettings.logFilePath = '';
warnings?.push('Log-Dateipfad (Ordner nicht gefunden)');
}
if (globalSettings.folderMonitor && typeof globalSettings.folderMonitor === 'object') {
if (globalSettings.folderMonitor.folderPath && !pathExists(globalSettings.folderMonitor.folderPath)) {
globalSettings.folderMonitor.folderPath = '';
globalSettings.folderMonitor.enabled = false;
warnings?.push('Ordnerüberwachung (Ordner nicht gefunden und deaktiviert)');
}
}
return imported;