import { useQueryClient } from '@tanstack/react-query'; import { ShieldCheckIcon } from 'lucide-react'; import { useState } from 'react'; import { Button } from '#/components/atoms/button.jsx'; import { Heading } from '#/components/atoms/heading.jsx'; import { Notice } from '#/components/atoms/notice.jsx'; import { Panel } from '#/components/atoms/panel.jsx'; import { ErrorNotice } from '#/components/molecules/error-notice.jsx'; import { FormShell } from '#/components/molecules/form-shell.jsx'; import { TextField } from '#/components/molecules/text-field.jsx'; import { post } from '#/lib/api.js'; import { b64 } from '#/lib/base64.js'; import { dateTime } from '#/lib/format.js'; import { queryKeys } from '#/lib/query.js'; // OWASP's floor for PBKDF2-SHA-256. Run here, in the browser: the server may // be a Cloudflare Worker, whose WebCrypto refuses counts this high — and // deriving locally means the passphrase never leaves this page anyway. const PBKDF2_ITERATIONS = 600000; /** * Derive the sealing key from the passphrase. The server receives only the * derived key, salt and count, and stores only salt and count beside the * ciphertext — decrypting still takes the passphrase. * @param {string} passphrase */ async function deriveKeyMaterial(passphrase) { const salt = crypto.getRandomValues(new Uint8Array(16)); const baseKey = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(passphrase), 'PBKDF2', false, ['deriveBits'], ); const bits = await crypto.subtle.deriveBits( { name: 'PBKDF2', hash: 'SHA-256', salt, iterations: PBKDF2_ITERATIONS }, baseKey, 256, ); return { key: b64(bits), salt: b64(salt.buffer), iterations: PBKDF2_ITERATIONS, }; } /** * The opt-in signing-key backup. Snapshots leave the account's signing key out * unless a passphrase is set; then each one carries a copy encrypted with * AES-GCM under a key derived from that passphrase. The passphrase itself is * never stored, so losing it makes the copy useless — the card says so. * @param {{data: {settings: {keyBackup: {createdAt: string, stale: boolean}|null}}}} props */ export function BackupKeyCard({ data }) { const qc = useQueryClient(); const keyBackup = data.settings.keyBackup; const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const remove = async () => { setError(''); setBusy(true); try { const result = await post('/backups/settings', { removeKeyBackup: true }); qc.setQueryData(queryKeys.backups, result); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } }; return ( Signing-key backup

Snapshots normally leave out the key that signs your account's changes. Enter a passphrase and each snapshot carries an encrypted copy (AES-GCM, key derived from the passphrase), so the account can be fully restored on another server.

The passphrase never leaves this page and is never stored anywhere. If you lose it, that copy is useless.

{keyBackup && ( <> {keyBackup.stale && ( Your signing key changed after this copy was made, so the copy no longer matches. Enter your passphrase again to back up the current key. )}
Encrypted copy in every snapshot
Set up {dateTime(keyBackup.createdAt)}
)} {(!keyBackup || keyBackup.stale) && ( { const result = await post('/backups/settings', { keyDerived: await deriveKeyMaterial(values.passphrase), }); qc.setQueryData(queryKeys.backups, result); }} > )}
); }