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