A minimal AT Protocol Personal Data Server written in JavaScript.
atproto pds
42
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 278 lines 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});