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.handle || !opts.pds) { 39 console.error('Usage: node scripts/setup.js --handle <handle> --pds <pds-url>') 40 console.error('') 41 console.error('Options:') 42 console.error(' --handle Handle name (e.g., "alice")') 43 console.error(' --pds PDS URL (e.g., "https://atproto-pds.chad-53c.workers.dev")') 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 the full handle 293 const pdsHost = new URL(pdsUrl).host 294 const fullHandle = `${handle}.${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// === RELAY NOTIFICATION === 395 396async function notifyRelay(relayUrl, pdsHostname) { 397 const url = `${relayUrl}/xrpc/com.atproto.sync.requestCrawl` 398 399 const response = await fetch(url, { 400 method: 'POST', 401 headers: { 402 'Content-Type': 'application/json' 403 }, 404 body: JSON.stringify({ 405 hostname: pdsHostname 406 }) 407 }) 408 409 // Relay might return 200 or 202, both are OK 410 if (!response.ok && response.status !== 202) { 411 const text = await response.text() 412 console.warn(` Warning: Relay notification returned ${response.status}: ${text}`) 413 return false 414 } 415 416 return true 417} 418 419// === CREDENTIALS OUTPUT === 420 421function saveCredentials(filename, credentials) { 422 writeFileSync(filename, JSON.stringify(credentials, null, 2)) 423} 424 425// === MAIN === 426 427async function main() { 428 const opts = parseArgs() 429 430 console.log('PDS Federation Setup') 431 console.log('====================') 432 console.log(`Handle: ${opts.handle}`) 433 console.log(`PDS: ${opts.pds}`) 434 console.log('') 435 436 // Step 1: Generate keypair 437 console.log('Generating P-256 keypair...') 438 const keyPair = await generateP256Keypair() 439 const didKey = publicKeyToDidKey(keyPair.publicKey) 440 console.log(` did:key: ${didKey}`) 441 console.log('') 442 443 // Step 2: Create genesis operation 444 console.log('Creating PLC genesis operation...') 445 const { operation, fullHandle } = await createGenesisOperation({ 446 didKey, 447 handle: opts.handle, 448 pdsUrl: opts.pds, 449 cryptoKey: keyPair.cryptoKey 450 }) 451 const did = await deriveDidFromOperation(operation) 452 console.log(` DID: ${did}`) 453 console.log(` Handle: ${fullHandle}`) 454 console.log('') 455 456 // Step 3: Register with PLC directory 457 console.log(`Registering with ${opts.plcUrl}...`) 458 await registerWithPlc(opts.plcUrl, did, operation) 459 console.log(' Registered successfully!') 460 console.log('') 461 462 // Step 4: Initialize PDS 463 console.log(`Initializing PDS at ${opts.pds}...`) 464 const privateKeyHex = bytesToHex(keyPair.privateKey) 465 await initializePds(opts.pds, did, privateKeyHex, fullHandle) 466 console.log(' PDS initialized!') 467 console.log('') 468 469 // Step 5: Notify relay 470 const pdsHostname = new URL(opts.pds).host 471 console.log(`Notifying relay at ${opts.relayUrl}...`) 472 const relayOk = await notifyRelay(opts.relayUrl, pdsHostname) 473 if (relayOk) { 474 console.log(' Relay notified!') 475 } 476 console.log('') 477 478 // Step 6: Save credentials 479 const credentials = { 480 handle: fullHandle, 481 did, 482 privateKeyHex: bytesToHex(keyPair.privateKey), 483 didKey, 484 pdsUrl: opts.pds, 485 createdAt: new Date().toISOString() 486 } 487 488 const credentialsFile = `./credentials-${opts.handle}.json` 489 saveCredentials(credentialsFile, credentials) 490 491 // Final output 492 console.log('Setup Complete!') 493 console.log('===============') 494 console.log(`Handle: ${fullHandle}`) 495 console.log(`DID: ${did}`) 496 console.log(`PDS: ${opts.pds}`) 497 console.log('') 498 console.log(`Credentials saved to: ${credentialsFile}`) 499 console.log('Keep this file safe - it contains your private key!') 500} 501 502main().catch(err => { 503 console.error('Error:', err.message) 504 process.exit(1) 505})