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