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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
278 lines · 7.3 KB Raw History
#!/usr/bin/env node

/**
 * Update DID handle and PDS endpoint
 *
 * Usage: node scripts/update-did.js --credentials <file> --new-handle <handle> --new-pds <url>
 */

import { webcrypto } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';

// === ARGUMENT PARSING ===

function parseArgs() {
  const args = process.argv.slice(2);
  const opts = {
    credentials: null,
    newHandle: null,
    newPds: null,
    plcUrl: 'https://plc.directory',
  };

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

  if (!opts.credentials || !opts.newHandle || !opts.newPds) {
    console.error(
      'Usage: node scripts/update-did.js --credentials <file> --new-handle <handle> --new-pds <url>',
    );
    process.exit(1);
  }

  return opts;
}

// === CRYPTO HELPERS ===

function hexToBytes(hex) {
  const bytes = new Uint8Array(hex.length / 2);
  for (let i = 0; i < hex.length; i += 2) {
    bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
  }
  return bytes;
}

function bytesToHex(bytes) {
  return Array.from(bytes)
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');
}

async function importPrivateKey(privateKeyBytes) {
  const pkcs8Prefix = new Uint8Array([
    0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48,
    0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03,
    0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20,
  ]);

  const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32);
  pkcs8.set(pkcs8Prefix);
  pkcs8.set(privateKeyBytes, pkcs8Prefix.length);

  return webcrypto.subtle.importKey(
    'pkcs8',
    pkcs8,
    { name: 'ECDSA', namedCurve: 'P-256' },
    false,
    ['sign'],
  );
}

// === CBOR ENCODING ===

function cborEncodeKey(key) {
  const bytes = new TextEncoder().encode(key);
  const parts = [];
  const mt = 3 << 5;
  if (bytes.length < 24) {
    parts.push(mt | bytes.length);
  } else if (bytes.length < 256) {
    parts.push(mt | 24, bytes.length);
  }
  parts.push(...bytes);
  return new Uint8Array(parts);
}

function compareBytes(a, b) {
  const minLen = Math.min(a.length, b.length);
  for (let i = 0; i < minLen; i++) {
    if (a[i] !== b[i]) return a[i] - b[i];
  }
  return a.length - b.length;
}

function cborEncode(value) {
  const parts = [];

  function encode(val) {
    if (val === null) {
      parts.push(0xf6);
    } else if (typeof val === 'string') {
      const bytes = new TextEncoder().encode(val);
      encodeHead(3, bytes.length);
      parts.push(...bytes);
    } else if (typeof val === 'number') {
      if (Number.isInteger(val) && val >= 0) {
        encodeHead(0, val);
      }
    } else if (val instanceof Uint8Array) {
      encodeHead(2, val.length);
      parts.push(...val);
    } else if (Array.isArray(val)) {
      encodeHead(4, val.length);
      for (const item of val) encode(item);
    } else if (typeof val === 'object') {
      const keys = Object.keys(val);
      const keysSorted = keys.sort((a, b) =>
        compareBytes(cborEncodeKey(a), cborEncodeKey(b)),
      );
      encodeHead(5, keysSorted.length);
      for (const key of keysSorted) {
        encode(key);
        encode(val[key]);
      }
    }
  }

  function encodeHead(majorType, length) {
    const mt = majorType << 5;
    if (length < 24) {
      parts.push(mt | length);
    } else if (length < 256) {
      parts.push(mt | 24, length);
    } else if (length < 65536) {
      parts.push(mt | 25, length >> 8, length & 0xff);
    }
  }

  encode(value);
  return new Uint8Array(parts);
}

// === SIGNING ===

const P256_N = BigInt(
  '0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551',
);

function ensureLowS(sig) {
  const halfN = P256_N / 2n;
  const r = sig.slice(0, 32);
  const s = sig.slice(32, 64);
  let sInt = BigInt(`0x${bytesToHex(s)}`);

  if (sInt > halfN) {
    sInt = P256_N - sInt;
    const newS = hexToBytes(sInt.toString(16).padStart(64, '0'));
    const result = new Uint8Array(64);
    result.set(r);
    result.set(newS, 32);
    return result;
  }
  return sig;
}

function base64UrlEncode(bytes) {
  const binary = String.fromCharCode(...bytes);
  return btoa(binary)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

async function signPlcOperation(operation, privateKey) {
  const { sig, ...opWithoutSig } = operation;
  const encoded = cborEncode(opWithoutSig);

  const signature = await webcrypto.subtle.sign(
    { name: 'ECDSA', hash: 'SHA-256' },
    privateKey,
    encoded,
  );

  const sigBytes = ensureLowS(new Uint8Array(signature));
  return base64UrlEncode(sigBytes);
}

// === MAIN ===

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

  // Load credentials
  const creds = JSON.parse(readFileSync(opts.credentials, 'utf-8'));
  console.log(`Updating DID: ${creds.did}`);
  console.log(`  Old handle: ${creds.handle}`);
  console.log(`  New handle: ${opts.newHandle}`);
  console.log(`  New PDS: ${opts.newPds}`);
  console.log('');

  // Fetch current operation log
  console.log('Fetching current PLC operation log...');
  const logRes = await fetch(`${opts.plcUrl}/${creds.did}/log/audit`);
  if (!logRes.ok) {
    throw new Error(`Failed to fetch PLC log: ${logRes.status}`);
  }
  const log = await logRes.json();
  const lastOp = log[log.length - 1];
  console.log(`  Found ${log.length} operations`);
  console.log(`  Last CID: ${lastOp.cid}`);
  console.log('');

  // Import private key
  const privateKey = await importPrivateKey(hexToBytes(creds.privateKeyHex));

  // Create new operation
  const newOp = {
    type: 'plc_operation',
    rotationKeys: lastOp.operation.rotationKeys,
    verificationMethods: lastOp.operation.verificationMethods,
    alsoKnownAs: [`at://${opts.newHandle}`],
    services: {
      atproto_pds: {
        type: 'AtprotoPersonalDataServer',
        endpoint: opts.newPds,
      },
    },
    prev: lastOp.cid,
  };

  // Sign the operation
  console.log('Signing new operation...');
  newOp.sig = await signPlcOperation(newOp, privateKey);

  // Submit to PLC
  console.log('Submitting to PLC directory...');
  const submitRes = await fetch(`${opts.plcUrl}/${creds.did}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(newOp),
  });

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

  console.log('  Updated successfully!');
  console.log('');

  // Update credentials file
  creds.handle = opts.newHandle;
  creds.pdsUrl = opts.newPds;
  writeFileSync(opts.credentials, JSON.stringify(creds, null, 2));
  console.log(`Updated credentials file: ${opts.credentials}`);

  // Verify
  console.log('');
  console.log('Verifying...');
  const verifyRes = await fetch(`${opts.plcUrl}/${creds.did}`);
  const didDoc = await verifyRes.json();
  console.log(`  alsoKnownAs: ${didDoc.alsoKnownAs}`);
  console.log(`  PDS endpoint: ${didDoc.service[0].serviceEndpoint}`);
}

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