This commit is contained in:
@@ -58,6 +58,7 @@ const DEFAULTS = {
|
||||
},
|
||||
globalSettings: {
|
||||
language: 'en',
|
||||
autoHealthCheckEnabled: true,
|
||||
alwaysOnTop: false,
|
||||
shutdownAfterFinish: 'nothing', // nothing | sleep | shutdown | restart
|
||||
logFilePath: '',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user