~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
146 lines · 5.1 KB Raw History
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>
  );
}