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