feat(crypto): stronghold persistence + passphrase device-key backup/restore
Release desktop app / build (, ubuntu-22.04) (push) Has been cancelled
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target aarch64-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Release desktop app / build (--target x86_64-apple-darwin --bundles app,updater, macos-13) (push) Has been cancelled
Release desktop app / build (, ubuntu-22.04) (push) Has been cancelled
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target aarch64-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Release desktop app / build (--target x86_64-apple-darwin --bundles app,updater, macos-13) (push) Has been cancelled
This commit is contained in:
@@ -9,6 +9,9 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { LockIcon } from '../components/icons';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { loadDevicePrivateKey, saveDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import { exportDeviceKey, importDeviceKey } from '../lib/deviceBackup';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import {
|
||||
getPttSettings,
|
||||
keyCodeToLabel,
|
||||
@@ -161,6 +164,7 @@ export function SettingsPage() {
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
<DeviceKeyBackupControls />
|
||||
</Section>
|
||||
|
||||
{/* Danger zone */}
|
||||
@@ -323,6 +327,160 @@ function AudioQualityControls() {
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceKeyBackupControls() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { profile, device } = useAuth();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [backupOut, setBackupOut] = useState<string | null>(null);
|
||||
const [exportPass, setExportPass] = useState('');
|
||||
const [importPass, setImportPass] = useState('');
|
||||
const [importBlob, setImportBlob] = useState('');
|
||||
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
|
||||
|
||||
const canRun = !!profile?.userId && !!device?.id;
|
||||
|
||||
async function handleExport() {
|
||||
if (!canRun) return;
|
||||
setMsg(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id);
|
||||
if (!priv) throw new Error('No device key on this install');
|
||||
const out = await exportDeviceKey(priv, exportPass);
|
||||
setBackupOut(out);
|
||||
setExportPass('');
|
||||
setMsg({
|
||||
kind: 'ok',
|
||||
text: t('app:settings.backup_export_ok', {
|
||||
defaultValue: 'Backup erstellt — kopiere und bewahre es sicher auf.',
|
||||
}),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
kind: 'err',
|
||||
text: err instanceof Error ? err.message : 'export failed',
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!canRun) return;
|
||||
setMsg(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const priv = await importDeviceKey(importBlob.trim(), importPass);
|
||||
await saveDevicePrivateKey(devLocalSecretStore, profile.userId, device.id, priv);
|
||||
setImportBlob('');
|
||||
setImportPass('');
|
||||
setMsg({
|
||||
kind: 'ok',
|
||||
text: t('app:settings.backup_import_ok', {
|
||||
defaultValue:
|
||||
'Schlüssel importiert. Beim nächsten Reload sollten alte Nachrichten lesbar sein.',
|
||||
}),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
kind: 'err',
|
||||
text: err instanceof Error ? err.message : 'import failed',
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-xl border border-white/10 bg-ink-900/40 p-4">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{t('app:settings.device_key_backup', { defaultValue: 'Geräteschlüssel-Backup' })}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-neutral-400">
|
||||
{t('app:settings.device_key_backup_hint', {
|
||||
defaultValue:
|
||||
'Sichere deinen privaten Schlüssel passwortgeschützt, damit du auf neuen Geräten alte Nachrichten weiter lesen kannst.',
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
{t('app:settings.backup_export', { defaultValue: 'Export' })}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={exportPass}
|
||||
onChange={(e) => setExportPass(e.target.value)}
|
||||
placeholder={t('app:settings.backup_passphrase', { defaultValue: 'Passphrase (min 8)' })}
|
||||
className="flex-1 rounded-lg border border-white/10 bg-ink-800 px-3 py-2 text-sm text-white placeholder-neutral-500 focus:border-brand-400 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || exportPass.length < 8 || !canRun}
|
||||
onClick={() => void handleExport()}
|
||||
className="cursor-pointer rounded-lg bg-brand-500/80 px-4 text-sm font-semibold text-white transition hover:bg-brand-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:settings.backup_create', { defaultValue: 'Erstellen' })}
|
||||
</button>
|
||||
</div>
|
||||
{backupOut && (
|
||||
<textarea
|
||||
readOnly
|
||||
value={backupOut}
|
||||
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-emerald-500/30 bg-ink-950/60 p-2 font-mono text-[10px] text-emerald-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 border-t border-white/5 pt-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
{t('app:settings.backup_import', { defaultValue: 'Import' })}
|
||||
</div>
|
||||
<textarea
|
||||
value={importBlob}
|
||||
onChange={(e) => setImportBlob(e.target.value)}
|
||||
rows={3}
|
||||
placeholder={t('app:settings.backup_blob_placeholder', {
|
||||
defaultValue: 'chatapp-backup-v1.…',
|
||||
})}
|
||||
className="w-full rounded-lg border border-white/10 bg-ink-800 p-2 font-mono text-[11px] text-white placeholder-neutral-500 focus:border-brand-400 focus:outline-none"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={importPass}
|
||||
onChange={(e) => setImportPass(e.target.value)}
|
||||
placeholder={t('app:settings.backup_passphrase', { defaultValue: 'Passphrase' })}
|
||||
className="flex-1 rounded-lg border border-white/10 bg-ink-800 px-3 py-2 text-sm text-white placeholder-neutral-500 focus:border-brand-400 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !importBlob || !importPass || !canRun}
|
||||
onClick={() => void handleImport()}
|
||||
className="cursor-pointer rounded-lg bg-emerald-500/80 px-4 text-sm font-semibold text-white transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:settings.backup_restore', { defaultValue: 'Wiederherstellen' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
className={
|
||||
'mt-3 text-xs ' +
|
||||
(msg.kind === 'ok' ? 'text-emerald-300' : 'text-rose-300')
|
||||
}
|
||||
>
|
||||
{msg.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScreenShareControls() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const [cfg, setCfg] = useState<ScreenShareSettings>(() => getScreenShareSettings());
|
||||
|
||||
Reference in New Issue
Block a user