#!/usr/bin/env node /** * PDS Setup Script * * Registers a did:plc, initializes the PDS, and notifies the relay. * Zero dependencies - uses Node.js built-ins only. * * Usage: node scripts/setup.js --handle alice --pds https://your-pds.workers.dev */ import { webcrypto } from 'node:crypto'; import { writeFileSync } from 'node:fs'; // === ARGUMENT PARSING === function parseArgs() { const args = process.argv.slice(2); const opts = { handle: null, pds: null, plcUrl: 'https://plc.directory', relayUrl: 'https://bsky.network', }; 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]; } } if (!opts.pds) { console.error( 'Usage: node scripts/setup.js --pds [--handle ]', ); 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)'); process.exit(1); } return opts; } // === KEY GENERATION === async function generateP256Keypair() { const keyPair = await webcrypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'], ); // Export private key as raw 32 bytes const privateJwk = await webcrypto.subtle.exportKey( 'jwk', keyPair.privateKey, ); const privateBytes = base64UrlDecode(privateJwk.d); // Export public key as uncompressed point (65 bytes) const publicRaw = await webcrypto.subtle.exportKey('raw', keyPair.publicKey); const publicBytes = new Uint8Array(publicRaw); // Compress public key to 33 bytes const compressedPublic = compressPublicKey(publicBytes); return { privateKey: privateBytes, publicKey: compressedPublic, cryptoKey: keyPair.privateKey, }; } function compressPublicKey(uncompressed) { // uncompressed is 65 bytes: 0x04 + x(32) + y(32) const x = uncompressed.slice(1, 33); const y = uncompressed.slice(33, 65); const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03; const compressed = new Uint8Array(33); compressed[0] = prefix; compressed.set(x, 1); return compressed; } function base64UrlDecode(str) { const base64 = str.replace(/-/g, '+').replace(/_/g, '/'); const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes; } function bytesToHex(bytes) { return Array.from(bytes) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } // === DID:KEY ENCODING === // Multicodec prefix for P-256 public key (0x1200) const P256_MULTICODEC = new Uint8Array([0x80, 0x24]); function publicKeyToDidKey(compressedPublicKey) { // did:key format: "did:key:" + multibase(base58btc) of multicodec + key const keyWithCodec = new Uint8Array( P256_MULTICODEC.length + compressedPublicKey.length, ); keyWithCodec.set(P256_MULTICODEC); keyWithCodec.set(compressedPublicKey, P256_MULTICODEC.length); return `did:key:z${base58btcEncode(keyWithCodec)}`; } function base58btcEncode(bytes) { const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; // Count leading zeros let zeros = 0; for (const b of bytes) { if (b === 0) zeros++; else break; } // Convert to base58 const digits = [0]; for (const byte of bytes) { let carry = byte; for (let i = 0; i < digits.length; i++) { carry += digits[i] << 8; digits[i] = carry % 58; carry = (carry / 58) | 0; } while (carry > 0) { digits.push(carry % 58); carry = (carry / 58) | 0; } } // Convert to string let result = '1'.repeat(zeros); for (let i = digits.length - 1; i >= 0; i--) { result += ALPHABET[digits[i]]; } return result; } // === CBOR ENCODING (dag-cbor compliant for PLC operations) === function cborEncodeKey(key) { // Encode a string key to CBOR bytes (for sorting) const bytes = new TextEncoder().encode(key); const parts = []; const mt = 3 << 5; // major type 3 = text string if (bytes.length < 24) { parts.push(mt | bytes.length); } else if (bytes.length < 256) { parts.push(mt | 24, bytes.length); } else if (bytes.length < 65536) { parts.push(mt | 25, bytes.length >> 8, bytes.length & 0xff); } parts.push(...bytes); return new Uint8Array(parts); } function compareBytes(a, b) { // dag-cbor: bytewise lexicographic order of encoded keys 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') { // dag-cbor: sort keys by their CBOR-encoded bytes (length first, then lexicographic) 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); } // === HASHING === async function sha256(data) { const hash = await webcrypto.subtle.digest('SHA-256', data); return new Uint8Array(hash); } // === PLC OPERATIONS === async function signPlcOperation(operation, privateKey) { // Encode operation without sig field const { sig, ...opWithoutSig } = operation; const encoded = cborEncode(opWithoutSig); // Sign with P-256 const signature = await webcrypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, privateKey, encoded, ); // Convert to low-S form and base64url encode const sigBytes = ensureLowS(new Uint8Array(signature)); return base64UrlEncode(sigBytes); } function ensureLowS(sig) { // P-256 order N const N = BigInt( '0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551', ); const halfN = N / 2n; const r = sig.slice(0, 32); const s = sig.slice(32, 64); // Convert s to BigInt let sInt = BigInt(`0x${bytesToHex(s)}`); // If s > N/2, replace with N - s if (sInt > halfN) { sInt = 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 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 base64UrlEncode(bytes) { const binary = String.fromCharCode(...bytes); return btoa(binary) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); } async function createGenesisOperation(opts) { const { didKey, handle, pdsUrl, cryptoKey } = opts; // Build full handle: subdomain.pds-hostname, or just pds-hostname if no subdomain const pdsHost = new URL(pdsUrl).host; const fullHandle = handle ? `${handle}.${pdsHost}` : pdsHost; const operation = { type: 'plc_operation', rotationKeys: [didKey], verificationMethods: { atproto: didKey, }, alsoKnownAs: [`at://${fullHandle}`], services: { atproto_pds: { type: 'AtprotoPersonalDataServer', endpoint: pdsUrl, }, }, prev: null, }; // Sign the operation operation.sig = await signPlcOperation(operation, cryptoKey); return { operation, fullHandle }; } async function deriveDidFromOperation(operation) { // DID is computed from the FULL operation INCLUDING the signature const encoded = cborEncode(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))}`; } function base32Encode(bytes) { const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; let result = ''; let bits = 0; let value = 0; for (const byte of bytes) { value = (value << 8) | byte; bits += 8; while (bits >= 5) { bits -= 5; result += alphabet[(value >> bits) & 31]; } } if (bits > 0) { result += alphabet[(value << (5 - bits)) & 31]; } return result; } // === 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, handle) { const url = `${pdsUrl}/init?did=${encodeURIComponent(did)}`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ did, privateKey: privateKeyHex, handle, }), }); if (!response.ok) { const text = await response.text(); throw new Error(`PDS initialization failed: ${response.status} ${text}`); } return response.json(); } // === HANDLE REGISTRATION === async function registerHandle(pdsUrl, handle, did) { const url = `${pdsUrl}/register-handle`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ handle, did }), }); if (!response.ok) { const text = await response.text(); throw new Error(`Handle registration failed: ${response.status} ${text}`); } return true; } // === 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) { writeFileSync(filename, JSON.stringify(credentials, null, 2)); } // === 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 console.log('Generating P-256 keypair...'); const keyPair = await generateP256Keypair(); const didKey = publicKeyToDidKey(keyPair.publicKey); console.log(` did:key: ${didKey}`); console.log(''); // Step 2: Create genesis operation console.log('Creating PLC genesis operation...'); const { operation, fullHandle } = await createGenesisOperation({ didKey, handle: opts.handle, pdsUrl: opts.pds, cryptoKey: keyPair.cryptoKey, }); 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, fullHandle); console.log(' PDS initialized!'); console.log(''); // Step 4b: Register handle -> DID mapping (only for subdomain handles) if (opts.handle) { console.log(`Registering handle mapping...`); await registerHandle(opts.pds, opts.handle, did); console.log(` Handle ${opts.handle} -> ${did}`); console.log(''); } // Step 5: Notify relay const pdsHostname = new URL(opts.pds).host; 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, privateKeyHex: bytesToHex(keyPair.privateKey), didKey, pdsUrl: opts.pds, 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('Keep this file safe - it contains your private key!'); } main().catch((err) => { console.error('Error:', err.message); process.exit(1); });