Add encrypted operator recovery for online backup keys
CI / verify (push) Waiting to run

This commit is contained in:
Sucukdeluxe
2026-09-22 04:34:52 +02:00
parent fa2ca27424
commit de1b79ed4b
15 changed files with 251 additions and 14 deletions
+3 -1
View File
@@ -54,6 +54,7 @@ function createOnlineBackupManager({
createBackup = createOnlineBackup,
uploadBackup = uploadOnlineBackup,
deleteBackup = deleteOnlineBackup,
loadRecoveryPublicKey = async () => null,
copyText
}) {
let mutation = Promise.resolve();
@@ -76,7 +77,8 @@ function createOnlineBackupManager({
async function createTransaction(retention) {
const createdAt = new Date().toISOString();
const settings = await loadSettings();
const created = createBackup(settings, appVersion(), createdAt, retention);
const recoveryPublicKey = await loadRecoveryPublicKey();
const created = createBackup(settings, appVersion(), createdAt, retention, recoveryPublicKey);
const prepared = keyring.prepare(created.key, createdAt, created.expiresAt ?? null);
await uploadBackup(created.record);
try {
+38
View File
@@ -0,0 +1,38 @@
const crypto = require('node:crypto');
function recoveryPublicKey(pem) {
if (typeof pem !== 'string' || pem.length > 8192 || !pem.startsWith('-----BEGIN PUBLIC KEY-----')) throw new Error('Ungültiger öffentlicher Wiederherstellungsschlüssel');
const key = crypto.createPublicKey(pem);
if (key.asymmetricKeyType !== 'rsa' || key.asymmetricKeyDetails.modulusLength !== 3072) throw new Error('Wiederherstellung benötigt RSA-3072');
return key;
}
function fingerprint(key) {
return crypto.createHash('sha256').update(key.export({ type: 'spki', format: 'der' })).digest('base64url');
}
function wrapBackupKey(key, id, pem) {
const publicKey = recoveryPublicKey(pem);
return {
version: 1,
keyId: fingerprint(publicKey),
wrappedKey: crypto.publicEncrypt({ key: publicKey, oaepHash: 'sha256', padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepLabel: Buffer.from(`MHU-RECOVERY-V1:${id}`) }, Buffer.from(key)).toString('base64url')
};
}
function recoverBackupKey(id, record, privatePem) {
const { parseOnlineBackupKey, restoreOnlineBackup } = require('./online-backup');
if (!record?.recovery || record.recovery.version !== 1) throw new Error('Diese Sicherung enthält keine Wiederherstellungsdaten');
if (record.expiresAt !== null && (!record.expiresAt || !Number.isFinite(Date.parse(record.expiresAt)) || Date.parse(record.expiresAt) <= Date.now())) throw new Error('Die Sicherung ist abgelaufen oder ihr Ablaufdatum ist ungültig');
const privateKey = crypto.createPrivateKey(privatePem);
const publicKey = crypto.createPublicKey(privateKey);
if (record.recovery.keyId !== fingerprint(publicKey)) throw new Error('Der Wiederherstellungsschlüssel gehört nicht zu dieser Sicherung');
const wrapped = record.recovery.wrappedKey;
if (typeof wrapped !== 'string' || !/^[A-Za-z0-9_-]{512}$/.test(wrapped)) throw new Error('Ungültige Wiederherstellungsdaten');
const key = crypto.privateDecrypt({ key: privateKey, oaepHash: 'sha256', padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepLabel: Buffer.from(`MHU-RECOVERY-V1:${id}`) }, Buffer.from(wrapped, 'base64url')).toString('utf8');
if (parseOnlineBackupKey(key).id !== id) throw new Error('Die Sicherungskennung stimmt nicht überein');
restoreOnlineBackup(key, record.blob);
return key;
}
module.exports = { recoveryPublicKey, wrapBackupKey, recoverBackupKey };
+12 -2
View File
@@ -156,7 +156,7 @@ function onlineBackupExpiration(exportedAt, retention) {
return new Date(created.getTime() + seconds * 1000).toISOString();
}
function createOnlineBackup(settings, appVersion, exportedAt = new Date().toISOString(), retention = DEFAULT_ONLINE_BACKUP_RETENTION) {
function createOnlineBackup(settings, appVersion, exportedAt = new Date().toISOString(), retention = DEFAULT_ONLINE_BACKUP_RETENTION, recoveryPublicKey = null) {
const normalizedRetention = normalizeOnlineBackupRetention(retention);
const expiresInSeconds = ONLINE_BACKUP_RETENTION_SECONDS[normalizedRetention];
const expiresAt = onlineBackupExpiration(exportedAt, normalizedRetention);
@@ -192,7 +192,8 @@ function createOnlineBackup(settings, appVersion, exportedAt = new Date().toISOS
id: parsed.id,
blob: blobBytes.toString('base64url'),
deleteVerifier,
expiresInSeconds
expiresInSeconds,
...(recoveryPublicKey ? { recovery: require('./online-backup-recovery').wrapBackupKey(key, parsed.id, recoveryPublicKey) } : {})
},
expiresAt
};
@@ -228,6 +229,14 @@ function restoreOnlineBackup(key, blob) {
}
}
async function downloadRecoveryPublicKey(baseUrl = ONLINE_BACKUP_API_URL, options) {
const { response, body } = await requestText(endpoint(baseUrl, '/v1/recovery-key'), { method: 'GET', headers: { accept: 'application/json' } }, options);
if (response.status !== 200) throw new Error('Wiederherstellungsschlüssel des Sicherungsdienstes ist nicht verfügbar');
const value = JSON.parse(body);
require('./online-backup-recovery').recoveryPublicKey(value.publicKey);
return value.publicKey;
}
async function uploadOnlineBackup(record, baseUrl = ONLINE_BACKUP_API_URL, options) {
const { response } = await requestText(endpoint(baseUrl, '/v1/backups'), {
method: 'POST',
@@ -277,6 +286,7 @@ module.exports = {
createOnlineBackup,
deleteOnlineBackup,
downloadOnlineBackup,
downloadRecoveryPublicKey,
normalizeOnlineBackupRetention,
onlineBackupExpiration,
parseOnlineBackupKey,