chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
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 (
<Panel>
<Heading>Signing-key backup</Heading>
<p className="mt-2 text-[13px] text-muted-foreground leading-relaxed">
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.
</p>
<p className="mt-2 text-[13px] text-muted-foreground leading-relaxed">
The passphrase never leaves this page and is never stored anywhere. If
you lose it, that copy is useless.
</p>
{keyBackup && (
<>
{keyBackup.stale && (
<Notice variant="warning" className="mt-4">
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.
</Notice>
)}
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-border bg-muted px-4 py-3">
<ShieldCheckIcon className="size-[18px] shrink-0 text-key" />
<div className="min-w-0">
<div className="text-[13px] text-foreground">
Encrypted copy in every snapshot
</div>
<div className="mt-0.5 text-[12px] text-faint">
Set up {dateTime(keyBackup.createdAt)}
</div>
</div>
</div>
<ErrorNotice error={error} className="mt-3" />
<Button
variant="outline"
size="block"
className="mt-3"
disabled={busy}
onClick={remove}
>
Remove from backups
</Button>
</>
)}
{(!keyBackup || keyBackup.stale) && (
<FormShell
className="mt-4"
submitLabel={
keyBackup ? 'Re-encrypt with passphrase' : 'Add to backups'
}
onSubmit={async (values) => {
const result = await post('/backups/settings', {
keyDerived: await deriveKeyMaterial(values.passphrase),
});
qc.setQueryData(queryKeys.backups, result);
}}
>
<TextField
name="passphrase"
label="Passphrase"
type="password"
required
minLength={8}
autoComplete="new-password"
/>
</FormShell>
)}
</Panel>
);
}