#!/usr/bin/env node /** * Update DID handle and PDS endpoint * * Usage: node scripts/update-did.js --credentials --new-handle --new-pds */ import { webcrypto } from 'node:crypto'; import { readFileSync, writeFileSync } from 'node:fs'; // === ARGUMENT PARSING === function parseArgs() { const args = process.argv.slice(2); const opts = { credentials: null, newHandle: null, newPds: null, plcUrl: 'https://plc.directory', }; for (let i = 0; i < args.length; i++) { if (args[i] === '--credentials' && args[i + 1]) { opts.credentials = args[++i]; } else if (args[i] === '--new-handle' && args[i + 1]) { opts.newHandle = args[++i]; } else if (args[i] === '--new-pds' && args[i + 1]) { opts.newPds = args[++i]; } else if (args[i] === '--plc-url' && args[i + 1]) { opts.plcUrl = args[++i]; } } if (!opts.credentials || !opts.newHandle || !opts.newPds) { console.error( 'Usage: node scripts/update-did.js --credentials --new-handle --new-pds ', ); process.exit(1); } return opts; } // === CRYPTO HELPERS === function hexToBytes(hex) { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { bytes[i / 2] = parseInt(hex.substr(i, 2), 16); } return bytes; } function bytesToHex(bytes) { return Array.from(bytes) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } async function importPrivateKey(privateKeyBytes) { const pkcs8Prefix = new Uint8Array([ 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20, ]); const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32); pkcs8.set(pkcs8Prefix); pkcs8.set(privateKeyBytes, pkcs8Prefix.length); return webcrypto.subtle.importKey( 'pkcs8', pkcs8, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign'], ); } // === CBOR ENCODING === function cborEncodeKey(key) { const bytes = new TextEncoder().encode(key); const parts = []; const mt = 3 << 5; if (bytes.length < 24) { parts.push(mt | bytes.length); } else if (bytes.length < 256) { parts.push(mt | 24, bytes.length); } parts.push(...bytes); return new Uint8Array(parts); } function compareBytes(a, b) { const minLen = Math.min(a.length, b.length); for (let i = 0; i < minLen; i++) { if (a[i] !== b[i]) return a[i] - b[i]; } return a.length - b.length; } function cborEncode(value) { const parts = []; function encode(val) { if (val === null) { parts.push(0xf6); } else if (typeof val === 'string') { const bytes = new TextEncoder().encode(val); encodeHead(3, bytes.length); parts.push(...bytes); } else if (typeof val === 'number') { if (Number.isInteger(val) && val >= 0) { encodeHead(0, val); } } else if (val instanceof Uint8Array) { encodeHead(2, val.length); parts.push(...val); } else if (Array.isArray(val)) { encodeHead(4, val.length); for (const item of val) encode(item); } else if (typeof val === 'object') { const keys = Object.keys(val); const keysSorted = keys.sort((a, b) => compareBytes(cborEncodeKey(a), cborEncodeKey(b)), ); encodeHead(5, keysSorted.length); for (const key of keysSorted) { encode(key); encode(val[key]); } } } function encodeHead(majorType, length) { const mt = majorType << 5; if (length < 24) { parts.push(mt | length); } else if (length < 256) { parts.push(mt | 24, length); } else if (length < 65536) { parts.push(mt | 25, length >> 8, length & 0xff); } } encode(value); return new Uint8Array(parts); } // === SIGNING === const P256_N = BigInt( '0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551', ); function ensureLowS(sig) { const halfN = P256_N / 2n; const r = sig.slice(0, 32); const s = sig.slice(32, 64); let sInt = BigInt(`0x${bytesToHex(s)}`); if (sInt > halfN) { sInt = P256_N - sInt; const newS = hexToBytes(sInt.toString(16).padStart(64, '0')); const result = new Uint8Array(64); result.set(r); result.set(newS, 32); return result; } return sig; } function base64UrlEncode(bytes) { const binary = String.fromCharCode(...bytes); return btoa(binary) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); } async function signPlcOperation(operation, privateKey) { const { sig, ...opWithoutSig } = operation; const encoded = cborEncode(opWithoutSig); const signature = await webcrypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, privateKey, encoded, ); const sigBytes = ensureLowS(new Uint8Array(signature)); return base64UrlEncode(sigBytes); } // === MAIN === async function main() { const opts = parseArgs(); // Load credentials const creds = JSON.parse(readFileSync(opts.credentials, 'utf-8')); console.log(`Updating DID: ${creds.did}`); console.log(` Old handle: ${creds.handle}`); console.log(` New handle: ${opts.newHandle}`); console.log(` New PDS: ${opts.newPds}`); console.log(''); // Fetch current operation log console.log('Fetching current PLC operation log...'); const logRes = await fetch(`${opts.plcUrl}/${creds.did}/log/audit`); if (!logRes.ok) { throw new Error(`Failed to fetch PLC log: ${logRes.status}`); } const log = await logRes.json(); const lastOp = log[log.length - 1]; console.log(` Found ${log.length} operations`); console.log(` Last CID: ${lastOp.cid}`); console.log(''); // Import private key const privateKey = await importPrivateKey(hexToBytes(creds.privateKeyHex)); // Create new operation const newOp = { type: 'plc_operation', rotationKeys: lastOp.operation.rotationKeys, verificationMethods: lastOp.operation.verificationMethods, alsoKnownAs: [`at://${opts.newHandle}`], services: { atproto_pds: { type: 'AtprotoPersonalDataServer', endpoint: opts.newPds, }, }, prev: lastOp.cid, }; // Sign the operation console.log('Signing new operation...'); newOp.sig = await signPlcOperation(newOp, privateKey); // Submit to PLC console.log('Submitting to PLC directory...'); const submitRes = await fetch(`${opts.plcUrl}/${creds.did}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newOp), }); if (!submitRes.ok) { const text = await submitRes.text(); throw new Error(`PLC update failed: ${submitRes.status} ${text}`); } console.log(' Updated successfully!'); console.log(''); // Update credentials file creds.handle = opts.newHandle; creds.pdsUrl = opts.newPds; writeFileSync(opts.credentials, JSON.stringify(creds, null, 2)); console.log(`Updated credentials file: ${opts.credentials}`); // Verify console.log(''); console.log('Verifying...'); const verifyRes = await fetch(`${opts.plcUrl}/${creds.did}`); const didDoc = await verifyRes.json(); console.log(` alsoKnownAs: ${didDoc.alsoKnownAs}`); console.log(` PDS endpoint: ${didDoc.service[0].serviceEndpoint}`); } main().catch((err) => { console.error('Error:', err.message); process.exit(1); });