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})