A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto pds
1#!/usr/bin/env node 2 3/** 4 * PDS Setup Script 5 * 6 * Registers a did:plc, initializes the PDS, and notifies the relay. 7 * Zero dependencies - uses Node.js built-ins only. 8 * 9 * Usage: node scripts/setup.js --handle alice --pds https://your-pds.workers.dev 10 */ 11 12import { webcrypto } from 'node:crypto'; 13import { writeFileSync } from 'node:fs'; 14 15// === ARGUMENT PARSING === 16 17function parseArgs() { 18 const args = process.argv.slice(2); 19 const opts = { 20 handle: null, 21 pds: null, 22 plcUrl: 'https://plc.directory', 23 relayUrl: 'https://bsky.network', 24 }; 25 26 for (let i = 0; i < args.length; i++) { 27 if (args[i] === '--handle' && args[i + 1]) { 28 opts.handle = args[++i]; 29 } else if (args[i] === '--pds' && args[i + 1]) { 30 opts.pds = args[++i]; 31 } else if (args[i] === '--plc-url' && args[i + 1]) { 32 opts.plcUrl = args[++i]; 33 } else if (args[i] === '--relay-url' && args[i + 1]) { 34 opts.relayUrl = args[++i]; 35 } 36 } 37 38 if (!opts.pds) { 39 console.error( 40 'Usage: node scripts/setup.js --pds <pds-url> [--handle <subdomain>]', 41 ); 42 console.error(''); 43 console.error('Options:'); 44 console.error( 45 ' --pds PDS URL (e.g., "https://atproto-pds.chad-53c.workers.dev")', 46 ); 47 console.error( 48 ' --handle Subdomain handle (e.g., "alice") - optional, uses bare hostname if omitted', 49 ); 50 console.error( 51 ' --plc-url PLC directory URL (default: https://plc.directory)', 52 ); 53 console.error(' --relay-url Relay URL (default: https://bsky.network)'); 54 process.exit(1); 55 } 56 57 return opts; 58} 59 60// === KEY GENERATION === 61 62async function generateP256Keypair() { 63 const keyPair = await webcrypto.subtle.generateKey( 64 { name: 'ECDSA', namedCurve: 'P-256' }, 65 true, 66 ['sign', 'verify'], 67 ); 68 69 // Export private key as raw 32 bytes 70 const privateJwk = await webcrypto.subtle.exportKey( 71 'jwk', 72 keyPair.privateKey, 73 ); 74 const privateBytes = base64UrlDecode(privateJwk.d); 75 76 // Export public key as uncompressed point (65 bytes) 77 const publicRaw = await webcrypto.subtle.exportKey('raw', keyPair.publicKey); 78 const publicBytes = new Uint8Array(publicRaw); 79 80 // Compress public key to 33 bytes 81 const compressedPublic = compressPublicKey(publicBytes); 82 83 return { 84 privateKey: privateBytes, 85 publicKey: compressedPublic, 86 cryptoKey: keyPair.privateKey, 87 }; 88} 89 90function compressPublicKey(uncompressed) { 91 // uncompressed is 65 bytes: 0x04 + x(32) + y(32) 92 const x = uncompressed.slice(1, 33); 93 const y = uncompressed.slice(33, 65); 94 const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03; 95 const compressed = new Uint8Array(33); 96 compressed[0] = prefix; 97 compressed.set(x, 1); 98 return compressed; 99} 100 101function base64UrlDecode(str) { 102 const base64 = str.replace(/-/g, '+').replace(/_/g, '/'); 103 const binary = atob(base64); 104 const bytes = new Uint8Array(binary.length); 105 for (let i = 0; i < binary.length; i++) { 106 bytes[i] = binary.charCodeAt(i); 107 } 108 return bytes; 109} 110 111function bytesToHex(bytes) { 112 return Array.from(bytes) 113 .map((b) => b.toString(16).padStart(2, '0')) 114 .join(''); 115} 116 117// === DID:KEY ENCODING === 118 119// Multicodec prefix for P-256 public key (0x1200) 120const P256_MULTICODEC = new Uint8Array([0x80, 0x24]); 121 122function publicKeyToDidKey(compressedPublicKey) { 123 // did:key format: "did:key:" + multibase(base58btc) of multicodec + key 124 const keyWithCodec = new Uint8Array( 125 P256_MULTICODEC.length + compressedPublicKey.length, 126 ); 127 keyWithCodec.set(P256_MULTICODEC); 128 keyWithCodec.set(compressedPublicKey, P256_MULTICODEC.length); 129 130 return `did:key:z${base58btcEncode(keyWithCodec)}`; 131} 132 133function base58btcEncode(bytes) { 134 const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; 135 136 // Count leading zeros 137 let zeros = 0; 138 for (const b of bytes) { 139 if (b === 0) zeros++; 140 else break; 141 } 142 143 // Convert to base58 144 const digits = [0]; 145 for (const byte of bytes) { 146 let carry = byte; 147 for (let i = 0; i < digits.length; i++) { 148 carry += digits[i] << 8; 149 digits[i] = carry % 58; 150 carry = (carry / 58) | 0; 151 } 152 while (carry > 0) { 153 digits.push(carry % 58); 154 carry = (carry / 58) | 0; 155 } 156 } 157 158 // Convert to string 159 let result = '1'.repeat(zeros); 160 for (let i = digits.length - 1; i >= 0; i--) { 161 result += ALPHABET[digits[i]]; 162 } 163 164 return result; 165} 166 167// === CBOR ENCODING (dag-cbor compliant for PLC operations) === 168 169function cborEncodeKey(key) { 170 // Encode a string key to CBOR bytes (for sorting) 171 const bytes = new TextEncoder().encode(key); 172 const parts = []; 173 const mt = 3 << 5; // major type 3 = text string 174 if (bytes.length < 24) { 175 parts.push(mt | bytes.length); 176 } else if (bytes.length < 256) { 177 parts.push(mt | 24, bytes.length); 178 } else if (bytes.length < 65536) { 179 parts.push(mt | 25, bytes.length >> 8, bytes.length & 0xff); 180 } 181 parts.push(...bytes); 182 return new Uint8Array(parts); 183} 184 185function compareBytes(a, b) { 186 // dag-cbor: bytewise lexicographic order of encoded keys 187 const minLen = Math.min(a.length, b.length); 188 for (let i = 0; i < minLen; i++) { 189 if (a[i] !== b[i]) return a[i] - b[i]; 190 } 191 return a.length - b.length; 192} 193 194function cborEncode(value) { 195 const parts = []; 196 197 function encode(val) { 198 if (val === null) { 199 parts.push(0xf6); 200 } else if (typeof val === 'string') { 201 const bytes = new TextEncoder().encode(val); 202 encodeHead(3, bytes.length); 203 parts.push(...bytes); 204 } else if (typeof val === 'number') { 205 if (Number.isInteger(val) && val >= 0) { 206 encodeHead(0, val); 207 } 208 } else if (val instanceof Uint8Array) { 209 encodeHead(2, val.length); 210 parts.push(...val); 211 } else if (Array.isArray(val)) { 212 encodeHead(4, val.length); 213 for (const item of val) encode(item); 214 } else if (typeof val === 'object') { 215 // dag-cbor: sort keys by their CBOR-encoded bytes (length first, then lexicographic) 216 const keys = Object.keys(val); 217 const keysSorted = keys.sort((a, b) => 218 compareBytes(cborEncodeKey(a), cborEncodeKey(b)), 219 ); 220 encodeHead(5, keysSorted.length); 221 for (const key of keysSorted) { 222 encode(key); 223 encode(val[key]); 224 } 225 } 226 } 227 228 function encodeHead(majorType, length) { 229 const mt = majorType << 5; 230 if (length < 24) { 231 parts.push(mt | length); 232 } else if (length < 256) { 233 parts.push(mt | 24, length); 234 } else if (length < 65536) { 235 parts.push(mt | 25, length >> 8, length & 0xff); 236 } 237 } 238 239 encode(value); 240 return new Uint8Array(parts); 241} 242 243// === HASHING === 244 245async function sha256(data) { 246 const hash = await webcrypto.subtle.digest('SHA-256', data); 247 return new Uint8Array(hash); 248} 249 250// === PLC OPERATIONS === 251 252async function signPlcOperation(operation, privateKey) { 253 // Encode operation without sig field 254 const { sig, ...opWithoutSig } = operation; 255 const encoded = cborEncode(opWithoutSig); 256 257 // Sign with P-256 258 const signature = await webcrypto.subtle.sign( 259 { name: 'ECDSA', hash: 'SHA-256' }, 260 privateKey, 261 encoded, 262 ); 263 264 // Convert to low-S form and base64url encode 265 const sigBytes = ensureLowS(new Uint8Array(signature)); 266 return base64UrlEncode(sigBytes); 267} 268 269function ensureLowS(sig) { 270 // P-256 order N 271 const N = BigInt( 272 '0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551', 273 ); 274 const halfN = N / 2n; 275 276 const r = sig.slice(0, 32); 277 const s = sig.slice(32, 64); 278 279 // Convert s to BigInt 280 let sInt = BigInt(`0x${bytesToHex(s)}`); 281 282 // If s > N/2, replace with N - s 283 if (sInt > halfN) { 284 sInt = N - sInt; 285 const newS = hexToBytes(sInt.toString(16).padStart(64, '0')); 286 const result = new Uint8Array(64); 287 result.set(r); 288 result.set(newS, 32); 289 return result; 290 } 291 292 return sig; 293} 294 295function hexToBytes(hex) { 296 const bytes = new Uint8Array(hex.length / 2); 297 for (let i = 0; i < hex.length; i += 2) { 298 bytes[i / 2] = parseInt(hex.substr(i, 2), 16); 299 } 300 return bytes; 301} 302 303function base64UrlEncode(bytes) { 304 const binary = String.fromCharCode(...bytes); 305 return btoa(binary) 306 .replace(/\+/g, '-') 307 .replace(/\//g, '_') 308 .replace(/=+$/, ''); 309} 310 311async function createGenesisOperation(opts) { 312 const { didKey, handle, pdsUrl, cryptoKey } = opts; 313 314 // Build full handle: subdomain.pds-hostname, or just pds-hostname if no subdomain 315 const pdsHost = new URL(pdsUrl).host; 316 const fullHandle = handle ? `${handle}.${pdsHost}` : pdsHost; 317 318 const operation = { 319 type: 'plc_operation', 320 rotationKeys: [didKey], 321 verificationMethods: { 322 atproto: didKey, 323 }, 324 alsoKnownAs: [`at://${fullHandle}`], 325 services: { 326 atproto_pds: { 327 type: 'AtprotoPersonalDataServer', 328 endpoint: pdsUrl, 329 }, 330 }, 331 prev: null, 332 }; 333 334 // Sign the operation 335 operation.sig = await signPlcOperation(operation, cryptoKey); 336 337 return { operation, fullHandle }; 338} 339 340async function deriveDidFromOperation(operation) { 341 // DID is computed from the FULL operation INCLUDING the signature 342 const encoded = cborEncode(operation); 343 const hash = await sha256(encoded); 344 // DID is base32 of first 15 bytes of hash (= 24 base32 chars) 345 return `did:plc:${base32Encode(hash.slice(0, 15))}`; 346} 347 348function base32Encode(bytes) { 349 const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; 350 let result = ''; 351 let bits = 0; 352 let value = 0; 353 354 for (const byte of bytes) { 355 value = (value << 8) | byte; 356 bits += 8; 357 while (bits >= 5) { 358 bits -= 5; 359 result += alphabet[(value >> bits) & 31]; 360 } 361 } 362 363 if (bits > 0) { 364 result += alphabet[(value << (5 - bits)) & 31]; 365 } 366 367 return result; 368} 369 370// === PLC DIRECTORY REGISTRATION === 371 372async function registerWithPlc(plcUrl, did, operation) { 373 const url = `${plcUrl}/${encodeURIComponent(did)}`; 374 375 const response = await fetch(url, { 376 method: 'POST', 377 headers: { 378 'Content-Type': 'application/json', 379 }, 380 body: JSON.stringify(operation), 381 }); 382 383 if (!response.ok) { 384 const text = await response.text(); 385 throw new Error(`PLC registration failed: ${response.status} ${text}`); 386 } 387 388 return true; 389} 390 391// === PDS INITIALIZATION === 392 393async function initializePds(pdsUrl, did, privateKeyHex, handle) { 394 const url = `${pdsUrl}/init?did=${encodeURIComponent(did)}`; 395 396 const response = await fetch(url, { 397 method: 'POST', 398 headers: { 399 'Content-Type': 'application/json', 400 }, 401 body: JSON.stringify({ 402 did, 403 privateKey: privateKeyHex, 404 handle, 405 }), 406 }); 407 408 if (!response.ok) { 409 const text = await response.text(); 410 throw new Error(`PDS initialization failed: ${response.status} ${text}`); 411 } 412 413 return response.json(); 414} 415 416// === HANDLE REGISTRATION === 417 418async function registerHandle(pdsUrl, handle, did) { 419 const url = `${pdsUrl}/register-handle`; 420 421 const response = await fetch(url, { 422 method: 'POST', 423 headers: { 424 'Content-Type': 'application/json', 425 }, 426 body: JSON.stringify({ handle, did }), 427 }); 428 429 if (!response.ok) { 430 const text = await response.text(); 431 throw new Error(`Handle registration failed: ${response.status} ${text}`); 432 } 433 434 return true; 435} 436 437// === RELAY NOTIFICATION === 438 439async function notifyRelay(relayUrl, pdsHostname) { 440 const url = `${relayUrl}/xrpc/com.atproto.sync.requestCrawl`; 441 442 const response = await fetch(url, { 443 method: 'POST', 444 headers: { 445 'Content-Type': 'application/json', 446 }, 447 body: JSON.stringify({ 448 hostname: pdsHostname, 449 }), 450 }); 451 452 // Relay might return 200 or 202, both are OK 453 if (!response.ok && response.status !== 202) { 454 const text = await response.text(); 455 console.warn( 456 ` Warning: Relay notification returned ${response.status}: ${text}`, 457 ); 458 return false; 459 } 460 461 return true; 462} 463 464// === CREDENTIALS OUTPUT === 465 466function saveCredentials(filename, credentials) { 467 writeFileSync(filename, JSON.stringify(credentials, null, 2)); 468} 469 470// === MAIN === 471 472async function main() { 473 const opts = parseArgs(); 474 475 console.log('PDS Federation Setup'); 476 console.log('===================='); 477 console.log(`PDS: ${opts.pds}`); 478 console.log(''); 479 480 // Step 1: Generate keypair 481 console.log('Generating P-256 keypair...'); 482 const keyPair = await generateP256Keypair(); 483 const didKey = publicKeyToDidKey(keyPair.publicKey); 484 console.log(` did:key: ${didKey}`); 485 console.log(''); 486 487 // Step 2: Create genesis operation 488 console.log('Creating PLC genesis operation...'); 489 const { operation, fullHandle } = await createGenesisOperation({ 490 didKey, 491 handle: opts.handle, 492 pdsUrl: opts.pds, 493 cryptoKey: keyPair.cryptoKey, 494 }); 495 const did = await deriveDidFromOperation(operation); 496 console.log(` DID: ${did}`); 497 console.log(` Handle: ${fullHandle}`); 498 console.log(''); 499 500 // Step 3: Register with PLC directory 501 console.log(`Registering with ${opts.plcUrl}...`); 502 await registerWithPlc(opts.plcUrl, did, operation); 503 console.log(' Registered successfully!'); 504 console.log(''); 505 506 // Step 4: Initialize PDS 507 console.log(`Initializing PDS at ${opts.pds}...`); 508 const privateKeyHex = bytesToHex(keyPair.privateKey); 509 await initializePds(opts.pds, did, privateKeyHex, fullHandle); 510 console.log(' PDS initialized!'); 511 console.log(''); 512 513 // Step 4b: Register handle -> DID mapping (only for subdomain handles) 514 if (opts.handle) { 515 console.log(`Registering handle mapping...`); 516 await registerHandle(opts.pds, opts.handle, did); 517 console.log(` Handle ${opts.handle} -> ${did}`); 518 console.log(''); 519 } 520 521 // Step 5: Notify relay 522 const pdsHostname = new URL(opts.pds).host; 523 console.log(`Notifying relay at ${opts.relayUrl}...`); 524 const relayOk = await notifyRelay(opts.relayUrl, pdsHostname); 525 if (relayOk) { 526 console.log(' Relay notified!'); 527 } 528 console.log(''); 529 530 // Step 6: Save credentials 531 const credentials = { 532 handle: fullHandle, 533 did, 534 privateKeyHex: bytesToHex(keyPair.privateKey), 535 didKey, 536 pdsUrl: opts.pds, 537 createdAt: new Date().toISOString(), 538 }; 539 540 const credentialsFile = `./credentials-${opts.handle || new URL(opts.pds).host}.json`; 541 saveCredentials(credentialsFile, credentials); 542 543 // Final output 544 console.log('Setup Complete!'); 545 console.log('==============='); 546 console.log(`Handle: ${fullHandle}`); 547 console.log(`DID: ${did}`); 548 console.log(`PDS: ${opts.pds}`); 549 console.log(''); 550 console.log(`Credentials saved to: ${credentialsFile}`); 551 console.log('Keep this file safe - it contains your private key!'); 552} 553 554main().catch((err) => { 555 console.error('Error:', err.message); 556 process.exit(1); 557});