A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto
pds
1#!/usr/bin/env node
2
3/**
4 * Update DID handle and PDS endpoint
5 *
6 * Usage: node scripts/update-did.js --credentials <file> --new-handle <handle> --new-pds <url>
7 */
8
9import { webcrypto } from 'crypto'
10import { readFileSync, writeFileSync } from 'fs'
11
12// === ARGUMENT PARSING ===
13
14function parseArgs() {
15 const args = process.argv.slice(2)
16 const opts = {
17 credentials: null,
18 newHandle: null,
19 newPds: null,
20 plcUrl: 'https://plc.directory'
21 }
22
23 for (let i = 0; i < args.length; i++) {
24 if (args[i] === '--credentials' && args[i + 1]) {
25 opts.credentials = args[++i]
26 } else if (args[i] === '--new-handle' && args[i + 1]) {
27 opts.newHandle = args[++i]
28 } else if (args[i] === '--new-pds' && args[i + 1]) {
29 opts.newPds = args[++i]
30 } else if (args[i] === '--plc-url' && args[i + 1]) {
31 opts.plcUrl = args[++i]
32 }
33 }
34
35 if (!opts.credentials || !opts.newHandle || !opts.newPds) {
36 console.error('Usage: node scripts/update-did.js --credentials <file> --new-handle <handle> --new-pds <url>')
37 process.exit(1)
38 }
39
40 return opts
41}
42
43// === CRYPTO HELPERS ===
44
45function hexToBytes(hex) {
46 const bytes = new Uint8Array(hex.length / 2)
47 for (let i = 0; i < hex.length; i += 2) {
48 bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
49 }
50 return bytes
51}
52
53function bytesToHex(bytes) {
54 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
55}
56
57async function importPrivateKey(privateKeyBytes) {
58 const pkcs8Prefix = new Uint8Array([
59 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48,
60 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03,
61 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20
62 ])
63
64 const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32)
65 pkcs8.set(pkcs8Prefix)
66 pkcs8.set(privateKeyBytes, pkcs8Prefix.length)
67
68 return webcrypto.subtle.importKey(
69 'pkcs8',
70 pkcs8,
71 { name: 'ECDSA', namedCurve: 'P-256' },
72 false,
73 ['sign']
74 )
75}
76
77// === CBOR ENCODING ===
78
79function cborEncodeKey(key) {
80 const bytes = new TextEncoder().encode(key)
81 const parts = []
82 const mt = 3 << 5
83 if (bytes.length < 24) {
84 parts.push(mt | bytes.length)
85 } else if (bytes.length < 256) {
86 parts.push(mt | 24, bytes.length)
87 }
88 parts.push(...bytes)
89 return new Uint8Array(parts)
90}
91
92function compareBytes(a, b) {
93 const minLen = Math.min(a.length, b.length)
94 for (let i = 0; i < minLen; i++) {
95 if (a[i] !== b[i]) return a[i] - b[i]
96 }
97 return a.length - b.length
98}
99
100function cborEncode(value) {
101 const parts = []
102
103 function encode(val) {
104 if (val === null) {
105 parts.push(0xf6)
106 } else if (typeof val === 'string') {
107 const bytes = new TextEncoder().encode(val)
108 encodeHead(3, bytes.length)
109 parts.push(...bytes)
110 } else if (typeof val === 'number') {
111 if (Number.isInteger(val) && val >= 0) {
112 encodeHead(0, val)
113 }
114 } else if (val instanceof Uint8Array) {
115 encodeHead(2, val.length)
116 parts.push(...val)
117 } else if (Array.isArray(val)) {
118 encodeHead(4, val.length)
119 for (const item of val) encode(item)
120 } else if (typeof val === 'object') {
121 const keys = Object.keys(val)
122 const keysSorted = keys.sort((a, b) => compareBytes(cborEncodeKey(a), cborEncodeKey(b)))
123 encodeHead(5, keysSorted.length)
124 for (const key of keysSorted) {
125 encode(key)
126 encode(val[key])
127 }
128 }
129 }
130
131 function encodeHead(majorType, length) {
132 const mt = majorType << 5
133 if (length < 24) {
134 parts.push(mt | length)
135 } else if (length < 256) {
136 parts.push(mt | 24, length)
137 } else if (length < 65536) {
138 parts.push(mt | 25, length >> 8, length & 0xff)
139 }
140 }
141
142 encode(value)
143 return new Uint8Array(parts)
144}
145
146// === SIGNING ===
147
148const P256_N = BigInt('0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551')
149
150function ensureLowS(sig) {
151 const halfN = P256_N / 2n
152 const r = sig.slice(0, 32)
153 const s = sig.slice(32, 64)
154 let sInt = BigInt('0x' + bytesToHex(s))
155
156 if (sInt > halfN) {
157 sInt = P256_N - sInt
158 const newS = hexToBytes(sInt.toString(16).padStart(64, '0'))
159 const result = new Uint8Array(64)
160 result.set(r)
161 result.set(newS, 32)
162 return result
163 }
164 return sig
165}
166
167function base64UrlEncode(bytes) {
168 const binary = String.fromCharCode(...bytes)
169 return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
170}
171
172async function signPlcOperation(operation, privateKey) {
173 const { sig, ...opWithoutSig } = operation
174 const encoded = cborEncode(opWithoutSig)
175
176 const signature = await webcrypto.subtle.sign(
177 { name: 'ECDSA', hash: 'SHA-256' },
178 privateKey,
179 encoded
180 )
181
182 const sigBytes = ensureLowS(new Uint8Array(signature))
183 return base64UrlEncode(sigBytes)
184}
185
186// === MAIN ===
187
188async function main() {
189 const opts = parseArgs()
190
191 // Load credentials
192 const creds = JSON.parse(readFileSync(opts.credentials, 'utf-8'))
193 console.log(`Updating DID: ${creds.did}`)
194 console.log(` Old handle: ${creds.handle}`)
195 console.log(` New handle: ${opts.newHandle}`)
196 console.log(` New PDS: ${opts.newPds}`)
197 console.log('')
198
199 // Fetch current operation log
200 console.log('Fetching current PLC operation log...')
201 const logRes = await fetch(`${opts.plcUrl}/${creds.did}/log/audit`)
202 if (!logRes.ok) {
203 throw new Error(`Failed to fetch PLC log: ${logRes.status}`)
204 }
205 const log = await logRes.json()
206 const lastOp = log[log.length - 1]
207 console.log(` Found ${log.length} operations`)
208 console.log(` Last CID: ${lastOp.cid}`)
209 console.log('')
210
211 // Import private key
212 const privateKey = await importPrivateKey(hexToBytes(creds.privateKeyHex))
213
214 // Create new operation
215 const newOp = {
216 type: 'plc_operation',
217 rotationKeys: lastOp.operation.rotationKeys,
218 verificationMethods: lastOp.operation.verificationMethods,
219 alsoKnownAs: [`at://${opts.newHandle}`],
220 services: {
221 atproto_pds: {
222 type: 'AtprotoPersonalDataServer',
223 endpoint: opts.newPds
224 }
225 },
226 prev: lastOp.cid
227 }
228
229 // Sign the operation
230 console.log('Signing new operation...')
231 newOp.sig = await signPlcOperation(newOp, privateKey)
232
233 // Submit to PLC
234 console.log('Submitting to PLC directory...')
235 const submitRes = await fetch(`${opts.plcUrl}/${creds.did}`, {
236 method: 'POST',
237 headers: { 'Content-Type': 'application/json' },
238 body: JSON.stringify(newOp)
239 })
240
241 if (!submitRes.ok) {
242 const text = await submitRes.text()
243 throw new Error(`PLC update failed: ${submitRes.status} ${text}`)
244 }
245
246 console.log(' Updated successfully!')
247 console.log('')
248
249 // Update credentials file
250 creds.handle = opts.newHandle
251 creds.pdsUrl = opts.newPds
252 writeFileSync(opts.credentials, JSON.stringify(creds, null, 2))
253 console.log(`Updated credentials file: ${opts.credentials}`)
254
255 // Verify
256 console.log('')
257 console.log('Verifying...')
258 const verifyRes = await fetch(`${opts.plcUrl}/${creds.did}`)
259 const didDoc = await verifyRes.json()
260 console.log(` alsoKnownAs: ${didDoc.alsoKnownAs}`)
261 console.log(` PDS endpoint: ${didDoc.service[0].serviceEndpoint}`)
262}
263
264main().catch(err => {
265 console.error('Error:', err.message)
266 process.exit(1)
267})