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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
371 lines · 10.6 KB Raw History
#!/usr/bin/env node

/**
 * PDS Setup Script
 *
 * Registers a did:plc, initializes the PDS, and notifies the relay.
 *
 * Usage: node scripts/setup.js --handle alice --pds https://your-pds.workers.dev
 */

import { writeFileSync } from 'node:fs';
import { base32Encode, bytesToHex } from '@pdsjs/core/crypto';
import { generateKemKey } from '@pdsjs/core/kem';
import { publicKeyToDidKey, signPlcOperationObject } from '@pdsjs/core/plc';
import { cborEncodeDagCbor } from '@pdsjs/core/repo';
import { k256Signer } from '@pdsjs/node';

// === CRYPTO HELPERS ===

/**
 * An account signing key on either curve, with the signer the PLC operation
 * needs. secp256k1 is the default: the reference PDS gives every account one,
 * and some third-party verifiers (Go feed generators, labelers) accept no
 * other curve for service auth. WebCrypto cannot sign it, so the scalar and
 * the signer come from the adapter's injected implementation.
 * @param {'secp256k1'|'p256'} curve
 */
async function generateAccountKey(curve) {
  if (curve === 'secp256k1') {
    const privateKey = k256Signer.generatePrivateKey();
    const publicKey = k256Signer.getPublicKey(privateKey, true);
    return {
      privateKey,
      publicKey,
      signingKey: {
        curve,
        sign: (data) => k256Signer.sign(privateKey, data),
        publicKey: async () => publicKey,
      },
    };
  }
  const { generateKeyPair, importPrivateKey } = await import(
    '@pdsjs/core/crypto'
  );
  const keyPair = await generateKeyPair();
  return {
    privateKey: keyPair.privateKey,
    publicKey: keyPair.publicKey,
    signingKey: await importPrivateKey(keyPair.privateKey),
  };
}

// === ARGUMENT PARSING ===

function parseArgs() {
  const args = process.argv.slice(2);
  const opts = {
    handle: null,
    pds: null,
    plcUrl: 'https://plc.directory',
    relayUrl: 'https://bsky.network',
    curve: 'secp256k1',
  };

  for (let i = 0; i < args.length; i++) {
    if (args[i] === '--handle' && args[i + 1]) {
      opts.handle = args[++i];
    } else if (args[i] === '--pds' && args[i + 1]) {
      opts.pds = args[++i];
    } else if (args[i] === '--plc-url' && args[i + 1]) {
      opts.plcUrl = args[++i];
    } else if (args[i] === '--relay-url' && args[i + 1]) {
      opts.relayUrl = args[++i];
    } else if (args[i] === '--curve' && args[i + 1]) {
      opts.curve = args[++i];
    }
  }

  if (!opts.pds) {
    console.error(
      'Usage: node scripts/setup.js --pds <pds-url> [--handle <subdomain>]',
    );
    console.error('');
    console.error('Options:');
    console.error(
      '  --pds         PDS URL (e.g., "https://atproto-pds.chad-53c.workers.dev")',
    );
    console.error(
      '  --handle      Subdomain handle (e.g., "alice") - optional, uses bare hostname if omitted',
    );
    console.error(
      '  --plc-url     PLC directory URL (default: https://plc.directory)',
    );
    console.error('  --relay-url   Relay URL (default: https://bsky.network)');
    console.error(
      '  --curve       Signing key curve, secp256k1 or p256 (default: secp256k1)',
    );
    process.exit(1);
  }

  return opts;
}

// === DID:KEY ENCODING ===

// Multicodec prefix for P-256 public key (0x1200)
// === HASHING ===

async function sha256(data) {
  const hash = await crypto.subtle.digest('SHA-256', data);
  return new Uint8Array(hash);
}

// === PLC OPERATIONS ===

async function createGenesisOperation(opts) {
  const { didKey, recoveryDidKey, kemDidKey, handle, pdsUrl, cryptoKey } = opts;

  // Build full handle: subdomain.pds-hostname, or just pds-hostname if no
  // subdomain. hostname, not host — a handle must never carry a port.
  const pdsHost = new URL(pdsUrl).hostname;
  const fullHandle = handle ? `${handle}.${pdsHost}` : pdsHost;

  const operation = {
    type: 'plc_operation',
    // Highest priority first. The recovery key can rewrite what the signing
    // key authorized; the reverse is refused.
    rotationKeys: recoveryDidKey ? [recoveryDidKey, didKey] : [didKey],
    verificationMethods: {
      atproto: didKey,
      atproto_kem: kemDidKey,
    },
    alsoKnownAs: [`at://${fullHandle}`],
    services: {
      atproto_pds: {
        type: 'AtprotoPersonalDataServer',
        endpoint: pdsUrl,
      },
    },
    prev: null,
  };

  // Sign the operation
  const signed = await signPlcOperationObject(operation, cryptoKey);

  return { operation: signed, fullHandle };
}

async function deriveDidFromOperation(operation) {
  // DID is computed from the FULL operation INCLUDING the signature
  const encoded = cborEncodeDagCbor(operation);
  const hash = await sha256(encoded);
  // DID is base32 of first 15 bytes of hash (= 24 base32 chars)
  return `did:plc:${base32Encode(hash.slice(0, 15))}`;
}

// === PLC DIRECTORY REGISTRATION ===

async function registerWithPlc(plcUrl, did, operation) {
  const url = `${plcUrl}/${encodeURIComponent(did)}`;

  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(operation),
  });

  if (!response.ok) {
    const text = await response.text();
    throw new Error(`PLC registration failed: ${response.status} ${text}`);
  }

  return true;
}

// === PDS INITIALIZATION ===

async function initializePds(
  pdsUrl,
  did,
  privateKeyHex,
  kemKeyHex,
  handle,
  curve,
) {
  const url = `${pdsUrl}/init?did=${encodeURIComponent(did)}`;

  // A server that starts with a configured password requires it on /init.
  const password = process.env.PDS_PASSWORD;
  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      did,
      privateKey: privateKeyHex,
      kemKey: kemKeyHex,
      handle,
      curve,
      ...(password ? { password } : {}),
    }),
  });

  if (!response.ok) {
    const text = await response.text();
    throw new Error(`PDS initialization failed: ${response.status} ${text}`);
  }

  return response.json();
}

// === RELAY NOTIFICATION ===

async function notifyRelay(relayUrl, pdsHostname) {
  const url = `${relayUrl}/xrpc/com.atproto.sync.requestCrawl`;

  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      hostname: pdsHostname,
    }),
  });

  // Relay might return 200 or 202, both are OK
  if (!response.ok && response.status !== 202) {
    const text = await response.text();
    console.warn(
      `  Warning: Relay notification returned ${response.status}: ${text}`,
    );
    return false;
  }

  return true;
}

// === CREDENTIALS OUTPUT ===

function saveCredentials(filename, credentials) {
  // Owner-readable only: these files hold private keys.
  writeFileSync(filename, JSON.stringify(credentials, null, 2), {
    mode: 0o600,
  });
}

// === MAIN ===

async function main() {
  const opts = parseArgs();

  console.log('PDS Federation Setup');
  console.log('====================');
  console.log(`PDS: ${opts.pds}`);
  console.log('');

  // Step 1: Generate keypair
  if (opts.curve !== 'secp256k1' && opts.curve !== 'p256') {
    console.error('--curve must be secp256k1 or p256');
    process.exit(1);
  }
  console.log(`Generating ${opts.curve} keypair...`);
  const keyPair = await generateAccountKey(opts.curve);
  const didKey = publicKeyToDidKey(keyPair.publicKey, opts.curve);
  console.log(`  signing did:key:  ${didKey}`);

  // A second rotation key, listed first so it outranks the signing key. PLC
  // lets a higher-priority rotation key rewrite operations a lower one
  // signed, so this is what recovers the DID if the signing key is lost.
  // It never reaches the PDS: it signs nothing but rotations.
  const recoveryKey = await generateAccountKey(opts.curve);
  const recoveryDidKey = publicKeyToDidKey(recoveryKey.publicKey, opts.curve);
  console.log(`  recovery did:key: ${recoveryDidKey}`);
  console.log('');

  // The key space syncers derive a shared secret against. It only ever
  // reaches the PDS.
  const kemKey = await generateKemKey();

  // Step 2: Create genesis operation
  console.log('Creating PLC genesis operation...');
  const { operation, fullHandle } = await createGenesisOperation({
    didKey,
    recoveryDidKey,
    kemDidKey: kemKey.didKey,
    handle: opts.handle,
    pdsUrl: opts.pds,
    cryptoKey: keyPair.signingKey,
  });
  const did = await deriveDidFromOperation(operation);
  console.log(`  DID: ${did}`);
  console.log(`  Handle: ${fullHandle}`);
  console.log('');

  // Step 3: Register with PLC directory
  console.log(`Registering with ${opts.plcUrl}...`);
  await registerWithPlc(opts.plcUrl, did, operation);
  console.log('  Registered successfully!');
  console.log('');

  // Step 4: Initialize PDS
  console.log(`Initializing PDS at ${opts.pds}...`);
  const privateKeyHex = bytesToHex(keyPair.privateKey);
  await initializePds(
    opts.pds,
    did,
    privateKeyHex,
    bytesToHex(kemKey.privateKey),
    fullHandle,
    opts.curve,
  );
  console.log('  PDS initialized!');
  console.log('');

  // Step 5: Notify relay
  const pdsHostname = new URL(opts.pds).hostname;
  console.log(`Notifying relay at ${opts.relayUrl}...`);
  const relayOk = await notifyRelay(opts.relayUrl, pdsHostname);
  if (relayOk) {
    console.log('  Relay notified!');
  }
  console.log('');

  // Step 6: Save credentials
  const credentials = {
    handle: fullHandle,
    did,
    curve: opts.curve,
    privateKeyHex: bytesToHex(keyPair.privateKey),
    didKey,
    recoveryDidKey,
    pdsUrl: opts.pds,
    createdAt: new Date().toISOString(),
  };
  // The recovery key goes in its own file, so a copy of the credentials does
  // not carry the thing that recovers them.
  const recoveryFile = `./recovery-${fullHandle}.json`;
  saveCredentials(recoveryFile, {
    handle: fullHandle,
    did,
    curve: opts.curve,
    privateKeyHex: bytesToHex(recoveryKey.privateKey),
    didKey: recoveryDidKey,
    role: 'rotation key only, never given to the PDS',
    note: 'Recovers the DID if the signing key is lost. Keep it somewhere the signing key is not.',
    createdAt: new Date().toISOString(),
  });

  const credentialsFile = `./credentials-${opts.handle || new URL(opts.pds).host}.json`;
  saveCredentials(credentialsFile, credentials);

  // Final output
  console.log('Setup Complete!');
  console.log('===============');
  console.log(`Handle: ${fullHandle}`);
  console.log(`DID: ${did}`);
  console.log(`PDS: ${opts.pds}`);
  console.log('');
  console.log(`Credentials saved to: ${credentialsFile}`);
  console.log(`Recovery key saved to: ${recoveryFile}`);
  console.log('Keep this file safe - it contains your private key!');
}

main().catch((err) => {
  console.error('Error:', err.message);
  process.exit(1);
});