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