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