diff --git a/README.md b/README.md index b831e47..4869865 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ RDP, SFTP, port forwarding and VPN are only available in the desktop app. - **Known hosts**: MrTerm warns you if a server's host key changes - **History** of recent connections - **Import** from `~/.ssh/config` and from **Devolutions Remote Desktop Manager** (`.rdm`/XML, JSON or CSV) -- **Backup** export and import +- **Backup** of everything (hosts, keys, passwords, snippets, forwards, VPNs, known hosts, settings), optionally encrypted with a password (scrypt + AES-256-GCM); import can merge or replace - **Web version** for your own server (Docker), usable in any browser. See [Web version (Docker)](#web-version-docker) - **LAN sync**: keep several MrTerm devices in sync over your local network, end-to-end encrypted and without a server - **7 app themes** (Midnight, Navy, Nord, Dracula, Catppuccin, Forest, Light) plus a custom accent color diff --git a/src/core/backend.js b/src/core/backend.js index 5a14912..9645922 100644 --- a/src/core/backend.js +++ b/src/core/backend.js @@ -12,6 +12,7 @@ const { DockerManager } = require('../main/docker'); const { FirewallManager } = require('../main/firewall'); const { NetworkConfigManager } = require('../main/network'); const i18n = require('../i18n'); +const backup = require('./backup'); function createBackend({ store, send, platform, version = '0.0.0', withSync = false, onLanguage = () => {} }) { // Rückfragen an die Oberfläche (Hostschlüssel, Passwörter, Kopplungscode) @@ -107,11 +108,12 @@ function createBackend({ store, send, platform, version = '0.0.0', withSync = fa }); handle('vault:settings', (s) => { store.setSettings(s); if ('language' in s) onLanguage(s.language); }); handle('vault:forgetHost', (id) => store.forgetKnownHost(id)); - handle('vault:exportData', () => publicData()); - handle('vault:importData', (data) => { - for (const col of ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns']) - for (const item of data?.[col] || []) store.upsert(col, item); - return true; + handle('backup:export', (opts) => backup.createBackup(store, { ...opts, version })); + handle('backup:inspect', (content) => backup.inspectBackup(content)); + handle('backup:import', (content, password, opts) => { + const r = backup.importBackup(store, content, password, opts); + if (opts?.settings) onLanguage(store.get().settings.language); + return r; }); // entries: [{ folder: ['A','B'], host?: {...} }] – wie am Desktop (Import aus Remote Desktop Manager) handle('vault:bulkImport', (entries) => { diff --git a/src/core/backup.js b/src/core/backup.js new file mode 100644 index 0000000..faf5282 --- /dev/null +++ b/src/core/backup.js @@ -0,0 +1,99 @@ +// Vollständiges Backup (Desktop, Web, Android): alle Collections, bekannte Hosts, Verlauf und Einstellungen. +// Optional mit Passwort verschlüsselt: scrypt (N=2^16, r=8, p=1) → AES-256-GCM. +// Gerätebezogenes (LAN-Sync-Kopplungen, Löschvermerke, App-Sperre) wird nicht exportiert. +const crypto = require('crypto'); + +const FORMAT = 'mrterm-backup'; +const COLLECTIONS = ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns']; +const KDF = { N: 1 << 16, r: 8, p: 1 }; +const scrypt = (password, salt, k) => crypto.scryptSync(String(password).normalize('NFC'), salt, 32, { ...k, maxmem: 256 * 1024 * 1024 }); + +function snapshot(store, { settings = true } = {}) { + const d = store.get(); + const out = { knownHosts: d.knownHosts || {}, history: d.history || [] }; + for (const c of COLLECTIONS) out[c] = d[c] || []; + if (settings) out.settings = d.settings; + return out; +} + +// Liefert den Dateiinhalt (JSON-Text) +function createBackup(store, { password = '', settings = true, version = '' } = {}) { + const data = snapshot(store, { settings }); + const head = { format: FORMAT, version: 2, app: version, createdAt: new Date().toISOString() }; + if (!password) return JSON.stringify({ ...head, encrypted: false, data }, null, 2); + const salt = crypto.randomBytes(16); + const key = scrypt(password, salt, KDF); + const iv = crypto.randomBytes(12); + const c = crypto.createCipheriv('aes-256-gcm', key, iv); + c.setAAD(Buffer.from(FORMAT)); + const ct = Buffer.concat([c.update(JSON.stringify(data)), c.final()]); + return JSON.stringify({ + ...head, encrypted: true, + kdf: { name: 'scrypt', salt: salt.toString('base64'), ...KDF }, + cipher: { name: 'aes-256-gcm', iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64') }, + ct: ct.toString('base64'), + }, null, 2); +} + +function parse(content) { + let j; + try { j = JSON.parse(String(content).replace(/^/, '')); } catch { throw new Error('INVALID'); } + if (!j || typeof j !== 'object') throw new Error('INVALID'); + // Altes Format (bis 0.14): unverschlüsseltes JSON mit den Collections auf oberster Ebene + if (j.format !== FORMAT) { + if (!COLLECTIONS.some((c) => Array.isArray(j[c]))) throw new Error('INVALID'); + return { encrypted: false, data: j, createdAt: null, app: '' }; + } + return { encrypted: !!j.encrypted, raw: j, data: j.encrypted ? null : j.data, createdAt: j.createdAt, app: j.app }; +} + +const counts = (data) => Object.fromEntries([...COLLECTIONS.map((c) => [c, (data[c] || []).length]), ['knownHosts', Object.keys(data.knownHosts || {}).length], ['settings', data.settings ? 1 : 0]]); + +// Vorabinfo für die Oberfläche (ohne Passwort) +function inspectBackup(content) { + const b = parse(content); + return { encrypted: b.encrypted, createdAt: b.createdAt, app: b.app, counts: b.data ? counts(b.data) : null }; +} + +function decrypt(content, password) { + const b = parse(content); + if (!b.encrypted) return b.data; + if (!password) throw new Error('PASSWORD_REQUIRED'); + const { kdf, cipher, ct } = b.raw; + const key = scrypt(password, Buffer.from(kdf.salt, 'base64'), { N: kdf.N, r: kdf.r, p: kdf.p }); + try { + const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(cipher.iv, 'base64')); + d.setAAD(Buffer.from(FORMAT)); + d.setAuthTag(Buffer.from(cipher.tag, 'base64')); + return JSON.parse(Buffer.concat([d.update(Buffer.from(ct, 'base64')), d.final()]).toString('utf8')); + } catch { throw new Error('WRONG_PASSWORD'); } +} + +// opts: { replace: vorhandene Einträge entfernen, die nicht im Backup sind; settings: Einstellungen übernehmen } +function importBackup(store, content, password, { replace = false, settings = false } = {}) { + const data = decrypt(content, password); + const d = store.get(); + store.muted = true; + try { + for (const c of COLLECTIONS) { + const items = Array.isArray(data[c]) ? data[c].filter((x) => x && typeof x === 'object') : []; + if (replace) { + const keep = new Set(items.map((x) => x.id).filter(Boolean)); + for (const x of [...(d[c] || [])]) if (!keep.has(x.id)) store.remove(c, x.id); + } + for (const item of items) store.upsert(c, { ...item }); + } + if (data.knownHosts && typeof data.knownHosts === 'object') d.knownHosts = replace ? { ...data.knownHosts } : { ...d.knownHosts, ...data.knownHosts }; + if (Array.isArray(data.history)) { + const seen = new Set(); + d.history = [...(replace ? [] : d.history || []), ...data.history].filter((h) => h && !seen.has(h.hostId) && seen.add(h.hostId)).slice(0, 30); + } + if (settings && data.settings && typeof data.settings === 'object') d.settings = { ...d.settings, ...data.settings }; + } finally { + store.muted = false; + } + store.save(); + return counts(data); +} + +module.exports = { createBackup, inspectBackup, importBackup }; diff --git a/src/i18n.js b/src/i18n.js index 300346b..731f697 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -351,6 +351,22 @@ 'Touch your security key to unlock MrTerm.': 'Berühre deinen Sicherheitsschlüssel, um MrTerm zu entsperren.', 'Security key prompt was cancelled or timed out.': 'Die Abfrage des Sicherheitsschlüssels wurde abgebrochen oder ist abgelaufen.', 'This security key does not support the hmac-secret/PRF extension.': 'Dieser Sicherheitsschlüssel unterstützt die hmac-secret/PRF-Erweiterung nicht.', + 'This file is not a MrTerm backup.': 'Diese Datei ist kein MrTerm-Backup.', + 'This backup is encrypted. Please enter the password.': 'Dieses Backup ist verschlüsselt. Bitte gib das Passwort ein.', + 'Exports all hosts, groups, SSH keys, passwords, snippets, port forwards, VPNs, known hosts and settings.': 'Exportiert alle Hosts, Gruppen, SSH-Keys, Passwörter, Snippets, Portweiterleitungen, VPNs, bekannten Hosts und Einstellungen.', + 'Recommended. Without a password, keys and passwords are stored in plain text in the file.': 'Empfohlen. Ohne Passwort stehen Schlüssel und Passwörter im Klartext in der Datei.', + 'Include settings': 'Einstellungen einschließen', + 'Export': 'Exportieren', + 'Export without password?': 'Ohne Passwort exportieren?', + 'Private keys and passwords will be readable by anyone who gets the file.': 'Private Schlüssel und Passwörter sind dann für jeden lesbar, der die Datei erhält.', + '{hosts} hosts, {keys} keys, {snippets} snippets, {vpns} VPNs': '{hosts} Hosts, {keys} Schlüssel, {snippets} Snippets, {vpns} VPNs', + 'Contents are encrypted.': 'Inhalt ist verschlüsselt.', + 'Import settings': 'Einstellungen übernehmen', + 'Replace existing data (entries not contained in the backup are deleted)': 'Vorhandene Daten ersetzen (Einträge, die nicht im Backup sind, werden gelöscht)', + 'Replace existing data?': 'Vorhandene Daten ersetzen?', + 'Hosts, keys, snippets and other entries that are not in the backup will be deleted.': 'Hosts, Schlüssel, Snippets und andere Einträge, die nicht im Backup sind, werden gelöscht.', + 'Replace': 'Ersetzen', + 'Import complete: {hosts} hosts, {keys} keys, {snippets} snippets': 'Import abgeschlossen: {hosts} Hosts, {keys} Schlüssel, {snippets} Snippets', 'Templates': 'Vorlagen', 'Run': 'Ausführen', 'Authorize SSH key for a user': 'SSH-Key für Benutzer freischalten', diff --git a/src/main/main.js b/src/main/main.js index 5e74539..73667b6 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -16,6 +16,7 @@ const { DockerManager } = require('./docker'); const { FirewallManager } = require('./firewall'); const { NetworkConfigManager } = require('./network'); const { SyncService } = require('./sync'); +const backup = require('../core/backup'); const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale()); let win; @@ -280,19 +281,19 @@ handle('vault:settings', (s) => { if ('rdpEmbed' in s) fs.writeFileSync(launchFile, JSON.stringify({ ...readLaunch(), x11: s.rdpEmbed !== false })); }); handle('vault:forgetHost', (id) => store.forgetKnownHost(id)); -handle('vault:export', async () => { - const r = await dialog.showSaveDialog(win, { defaultPath: 'mrterm-backup.json', filters: [{ name: 'JSON', extensions: ['json'] }] }); - if (r.canceled) return false; - fs.writeFileSync(r.filePath, JSON.stringify(publicData(), null, 2), { mode: 0o600 }); - return r.filePath; +// Backup: vollständig, optional per Passwort verschlüsselt (src/core/backup.js) +handle('backup:export', (opts) => backup.createBackup(store, { ...opts, version: app.getVersion() })); +handle('backup:inspect', (content) => backup.inspectBackup(content)); +handle('backup:import', (content, password, opts) => { + const r = backup.importBackup(store, content, password, opts); + if (opts?.settings) applyLanguage(); + return r; }); -handle('vault:import', async () => { - const r = await dialog.showOpenDialog(win, { filters: [{ name: 'JSON', extensions: ['json'] }], properties: ['openFile'] }); +handle('backup:saveFile', async (content, name) => { + const r = await dialog.showSaveDialog(win, { defaultPath: name, filters: [{ name: 'MrTerm Backup', extensions: ['json'] }] }); if (r.canceled) return false; - const data = JSON.parse(fs.readFileSync(r.filePaths[0], 'utf8')); - for (const col of ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns']) - for (const item of data[col] || []) store.upsert(col, item); - return true; + fs.writeFileSync(r.filePath, content, { mode: 0o600 }); + return r.filePath; }); // Import aus ~/.ssh/config diff --git a/src/renderer/app.js b/src/renderer/app.js index f9ae55d..b18669e 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -1072,8 +1072,8 @@ async function viewSettings(page) { const dataCard = h(`
${WEB ? T('Your vault is stored on the server, encrypted with a key that only your login password can unlock.') : S.vault.encrypted ? T('The vault is encrypted with the operating system keyring (Windows DPAPI / libsecret or KWallet).') : T('The vault is not encrypted because no keyring is available. On Arch/CachyOS: install gnome-keyring or kwallet.')}
${T('Exports all hosts, groups, SSH keys, passwords, snippets, port forwards, VPNs, known hosts and settings.')}
+ ${err ? `${esc(err)}
` : ''} + ${field(T('Password'), 'pw', '', { type: 'password', hint: T('Recommended. Without a password, keys and passwords are stored in plain text in the file.') }).outerHTML} + ${field(T('Repeat password'), 'pw2', '', { type: 'password' }).outerHTML} + ${check(T('Include settings'), 'settings', true).outerHTML}`, + buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Export'), value: 'form', cls: 'primary' }], + }); + if (!r) return; + if (r.pw !== r.pw2) { err = T('The passwords do not match.'); continue; } + if (r.pw && r.pw.length < 8) { err = T('The password must be at least 8 characters long.'); continue; } + if (!r.pw && !(await confirmBox(T('Export without password?'), T('Private keys and passwords will be readable by anyone who gets the file.'), T('Export')))) continue; + try { + const content = await api.call('backup:export', { password: r.pw, settings: r.settings }); + const name = `mrterm-backup-${new Date().toISOString().slice(0, 10)}${r.pw ? '-encrypted' : ''}.json`; + const p = await call('backup:saveFile', content, name); + if (p) toast(T('Exported to {path}', { path: p }), 'ok'); + } catch (e) { toast(backupError(e), 'error'); } + return; + } +} + +async function importBackup() { + const f = await call('import:pickFile', 'MrTerm Backup', ['json']); + if (!f) return; + let info; + try { info = await api.call('backup:inspect', f.content); } catch (e) { return toast(backupError(e), 'error'); } + const c = info.counts; + const summary = c ? T('{hosts} hosts, {keys} keys, {snippets} snippets, {vpns} VPNs', c) : T('Contents are encrypted.'); + let err = ''; + for (;;) { + const r = await modal({ + title: T('Import backup'), + body: `${esc(f.name)}${info.createdAt ? ' · ' + esc(new Date(info.createdAt).toLocaleString()) : ''}
${esc(summary)}
${esc(err)}
` : ''} + ${info.encrypted ? field(T('Password'), 'pw', '', { type: 'password' }).outerHTML : ''} + ${check(T('Import settings'), 'settings', false).outerHTML} + ${check(T('Replace existing data (entries not contained in the backup are deleted)'), 'replace', false).outerHTML}`, + buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Import'), value: 'form', cls: 'primary' }], + }); + if (!r) return; + if (r.replace && !(await confirmBox(T('Replace existing data?'), T('Hosts, keys, snippets and other entries that are not in the backup will be deleted.'), T('Replace')))) continue; + try { + const n = await api.call('backup:import', f.content, r.pw || '', { replace: r.replace, settings: r.settings }); + toast(T('Import complete: {hosts} hosts, {keys} keys, {snippets} snippets', n), 'ok'); + reload(); + return; + } catch (e) { err = backupError(e); } + } +} + // ============================================================ Updates let updateInfo = null; let updateDialogOpen = false; // Auto-Prüfung beim Start und manuelle Prüfung sollen keine zwei Dialoge stapeln diff --git a/src/web/public/web-api.js b/src/web/public/web-api.js index 49f8a81..f0f37c8 100644 --- a/src/web/public/web-api.js +++ b/src/web/public/web-api.js @@ -79,8 +79,7 @@ throw new Error('Pasting from the menu needs HTTPS. Use Ctrl+Shift+V or Ctrl+V instead.'); }, 'shell:open': (url) => { if (/^https?:\/\//i.test(url)) window.open(url, '_blank', 'noopener'); }, - 'vault:export': async () => { downloadText('mrterm-backup.json', JSON.stringify(await remote('vault:exportData', []), null, 2)); return 'mrterm-backup.json'; }, - 'vault:import': async () => { const f = await pickFile('.json,application/json'); if (!f) return false; return remote('vault:importData', [JSON.parse(f.content)]); }, + 'backup:saveFile': (content, name) => { downloadText(name, content); return name; }, 'key:pickFile': async () => { const f = await pickFile(); return f ? { name: f.name, content: f.content, publicKey: '' } : null; }, 'import:pickFile': async () => pickFile(), 'vault:importSshConfig': () => { throw new Error('Not available in the web version.'); },