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 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 (minimal for PLC operations) ===
153
154function cborEncode(value) {
155 const parts = []
156
157 function encode(val) {
158 if (val === null) {
159 parts.push(0xf6)
160 } else if (typeof val === 'string') {
161 const bytes = new TextEncoder().encode(val)
162 encodeHead(3, bytes.length)
163 parts.push(...bytes)
164 } else if (typeof val === 'number') {
165 if (Number.isInteger(val) && val >= 0) {
166 encodeHead(0, val)
167 }
168 } else if (val instanceof Uint8Array) {
169 encodeHead(2, val.length)
170 parts.push(...val)
171 } else if (Array.isArray(val)) {
172 encodeHead(4, val.length)
173 for (const item of val) encode(item)
174 } else if (typeof val === 'object') {
175 const keys = Object.keys(val).sort()
176 encodeHead(5, keys.length)
177 for (const key of keys) {
178 encode(key)
179 encode(val[key])
180 }
181 }
182 }
183
184 function encodeHead(majorType, length) {
185 const mt = majorType << 5
186 if (length < 24) {
187 parts.push(mt | length)
188 } else if (length < 256) {
189 parts.push(mt | 24, length)
190 } else if (length < 65536) {
191 parts.push(mt | 25, length >> 8, length & 0xff)
192 }
193 }
194
195 encode(value)
196 return new Uint8Array(parts)
197}
198
199// === HASHING ===
200
201async function sha256(data) {
202 const hash = await webcrypto.subtle.digest('SHA-256', data)
203 return new Uint8Array(hash)
204}
205
206// === PLC OPERATIONS ===
207
208async function signPlcOperation(operation, privateKey) {
209 // Encode operation without sig field
210 const { sig, ...opWithoutSig } = operation
211 const encoded = cborEncode(opWithoutSig)
212
213 // Sign with P-256
214 const signature = await webcrypto.subtle.sign(
215 { name: 'ECDSA', hash: 'SHA-256' },
216 privateKey,
217 encoded
218 )
219
220 // Convert to low-S form and base64url encode
221 const sigBytes = ensureLowS(new Uint8Array(signature))
222 return base64UrlEncode(sigBytes)
223}
224
225function ensureLowS(sig) {
226 // P-256 order N
227 const N = BigInt('0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551')
228 const halfN = N / 2n
229
230 const r = sig.slice(0, 32)
231 const s = sig.slice(32, 64)
232
233 // Convert s to BigInt
234 let sInt = BigInt('0x' + bytesToHex(s))
235
236 // If s > N/2, replace with N - s
237 if (sInt > halfN) {
238 sInt = N - sInt
239 const newS = hexToBytes(sInt.toString(16).padStart(64, '0'))
240 const result = new Uint8Array(64)
241 result.set(r)
242 result.set(newS, 32)
243 return result
244 }
245
246 return sig
247}
248
249function hexToBytes(hex) {
250 const bytes = new Uint8Array(hex.length / 2)
251 for (let i = 0; i < hex.length; i += 2) {
252 bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
253 }
254 return bytes
255}
256
257function base64UrlEncode(bytes) {
258 const binary = String.fromCharCode(...bytes)
259 return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
260}
261
262async function createGenesisOperation(opts) {
263 const { didKey, handle, pdsUrl, cryptoKey } = opts
264
265 // Build the full handle
266 const pdsHost = new URL(pdsUrl).host
267 const fullHandle = `${handle}.${pdsHost}`
268
269 const operation = {
270 type: 'plc_operation',
271 rotationKeys: [didKey],
272 verificationMethods: {
273 atproto: didKey
274 },
275 alsoKnownAs: [`at://${fullHandle}`],
276 services: {
277 atproto_pds: {
278 type: 'AtprotoPersonalDataServer',
279 endpoint: pdsUrl
280 }
281 },
282 prev: null
283 }
284
285 // Sign the operation
286 operation.sig = await signPlcOperation(operation, cryptoKey)
287
288 return { operation, fullHandle }
289}
290
291async function deriveDidFromOperation(operation) {
292 const { sig, ...opWithoutSig } = operation
293 const encoded = cborEncode(opWithoutSig)
294 const hash = await sha256(encoded)
295 // DID is base32 of first 24 bytes of hash
296 return 'did:plc:' + base32Encode(hash.slice(0, 24))
297}
298
299function base32Encode(bytes) {
300 const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'
301 let result = ''
302 let bits = 0
303 let value = 0
304
305 for (const byte of bytes) {
306 value = (value << 8) | byte
307 bits += 8
308 while (bits >= 5) {
309 bits -= 5
310 result += alphabet[(value >> bits) & 31]
311 }
312 }
313
314 if (bits > 0) {
315 result += alphabet[(value << (5 - bits)) & 31]
316 }
317
318 return result
319}
320
321// === PLC DIRECTORY REGISTRATION ===
322
323async function registerWithPlc(plcUrl, did, operation) {
324 const url = `${plcUrl}/${encodeURIComponent(did)}`
325
326 const response = await fetch(url, {
327 method: 'POST',
328 headers: {
329 'Content-Type': 'application/json'
330 },
331 body: JSON.stringify(operation)
332 })
333
334 if (!response.ok) {
335 const text = await response.text()
336 throw new Error(`PLC registration failed: ${response.status} ${text}`)
337 }
338
339 return true
340}
341
342// === PDS INITIALIZATION ===
343
344async function initializePds(pdsUrl, did, privateKeyHex, handle) {
345 const url = `${pdsUrl}/init?did=${encodeURIComponent(did)}`
346
347 const response = await fetch(url, {
348 method: 'POST',
349 headers: {
350 'Content-Type': 'application/json'
351 },
352 body: JSON.stringify({
353 did,
354 privateKey: privateKeyHex,
355 handle
356 })
357 })
358
359 if (!response.ok) {
360 const text = await response.text()
361 throw new Error(`PDS initialization failed: ${response.status} ${text}`)
362 }
363
364 return response.json()
365}
366
367// === RELAY NOTIFICATION ===
368
369async function notifyRelay(relayUrl, pdsHostname) {
370 const url = `${relayUrl}/xrpc/com.atproto.sync.requestCrawl`
371
372 const response = await fetch(url, {
373 method: 'POST',
374 headers: {
375 'Content-Type': 'application/json'
376 },
377 body: JSON.stringify({
378 hostname: pdsHostname
379 })
380 })
381
382 // Relay might return 200 or 202, both are OK
383 if (!response.ok && response.status !== 202) {
384 const text = await response.text()
385 console.warn(` Warning: Relay notification returned ${response.status}: ${text}`)
386 return false
387 }
388
389 return true
390}
391
392// === CREDENTIALS OUTPUT ===
393
394function saveCredentials(filename, credentials) {
395 writeFileSync(filename, JSON.stringify(credentials, null, 2))
396}
397
398// === MAIN ===
399
400async function main() {
401 const opts = parseArgs()
402
403 console.log('PDS Federation Setup')
404 console.log('====================')
405 console.log(`Handle: ${opts.handle}`)
406 console.log(`PDS: ${opts.pds}`)
407 console.log('')
408
409 // Step 1: Generate keypair
410 console.log('Generating P-256 keypair...')
411 const keyPair = await generateP256Keypair()
412 const didKey = publicKeyToDidKey(keyPair.publicKey)
413 console.log(` did:key: ${didKey}`)
414 console.log('')
415
416 // Step 2: Create genesis operation
417 console.log('Creating PLC genesis operation...')
418 const { operation, fullHandle } = await createGenesisOperation({
419 didKey,
420 handle: opts.handle,
421 pdsUrl: opts.pds,
422 cryptoKey: keyPair.cryptoKey
423 })
424 const did = await deriveDidFromOperation(operation)
425 console.log(` DID: ${did}`)
426 console.log(` Handle: ${fullHandle}`)
427 console.log('')
428
429 // Step 3: Register with PLC directory
430 console.log(`Registering with ${opts.plcUrl}...`)
431 await registerWithPlc(opts.plcUrl, did, operation)
432 console.log(' Registered successfully!')
433 console.log('')
434
435 // Step 4: Initialize PDS
436 console.log(`Initializing PDS at ${opts.pds}...`)
437 const privateKeyHex = bytesToHex(keyPair.privateKey)
438 await initializePds(opts.pds, did, privateKeyHex, fullHandle)
439 console.log(' PDS initialized!')
440 console.log('')
441
442 // Step 5: Notify relay
443 const pdsHostname = new URL(opts.pds).host
444 console.log(`Notifying relay at ${opts.relayUrl}...`)
445 const relayOk = await notifyRelay(opts.relayUrl, pdsHostname)
446 if (relayOk) {
447 console.log(' Relay notified!')
448 }
449 console.log('')
450
451 // Step 6: Save credentials
452 const credentials = {
453 handle: fullHandle,
454 did,
455 privateKeyHex: bytesToHex(keyPair.privateKey),
456 didKey,
457 pdsUrl: opts.pds,
458 createdAt: new Date().toISOString()
459 }
460
461 const credentialsFile = `./credentials-${opts.handle}.json`
462 saveCredentials(credentialsFile, credentials)
463
464 // Final output
465 console.log('Setup Complete!')
466 console.log('===============')
467 console.log(`Handle: ${fullHandle}`)
468 console.log(`DID: ${did}`)
469 console.log(`PDS: ${opts.pds}`)
470 console.log('')
471 console.log(`Credentials saved to: ${credentialsFile}`)
472 console.log('Keep this file safe - it contains your private key!')
473}
474
475main().catch(err => {
476 console.error('Error:', err.message)
477 process.exit(1)
478})