open source is social v-it.org
1#!/usr/bin/env bun
2// SPDX-License-Identifier: MIT
3// Copyright (c) 2026 sol pbc
4
5import { secp256k1 } from '@noble/curves/secp256k1';
6import { p256 } from '@noble/curves/p256';
7import { sha256 } from '@noble/hashes/sha256';
8import { encode as dagCborEncode } from '@ipld/dag-cbor';
9import bs58 from 'bs58';
10import { mkdirSync, writeFileSync } from 'node:fs';
11import { resolve } from 'node:path';
12import { Command } from 'commander';
13
14const MC_P256_PUB = new Uint8Array([0x80, 0x24]);
15const MC_K256_PUB = new Uint8Array([0xe7, 0x01]);
16const N_K256 = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
17const N_P256 = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n;
18
19const K256_ALG_ID = new Uint8Array([
20 0x30, 0x10, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x05, 0x2b, 0x81,
21 0x04, 0x00, 0x0a,
22]);
23
24const P256_ALG_ID = new Uint8Array([
25 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86,
26 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07,
27]);
28
29function ensureDir(path) {
30 mkdirSync(path, { recursive: true });
31}
32
33function b64urlNopad(data) {
34 return Buffer.from(data).toString('base64url').replace(/=+$/g, '');
35}
36
37function base32LowerNopad(data) {
38 const alphabet = 'abcdefghijklmnopqrstuvwxyz234567';
39 let bits = 0;
40 let value = 0;
41 let result = '';
42 for (const byte of data) {
43 value = (value << 8) | byte;
44 bits += 8;
45 while (bits >= 5) {
46 result += alphabet[(value >>> (bits - 5)) & 31];
47 bits -= 5;
48 }
49 }
50 if (bits > 0) {
51 result += alphabet[(value << (5 - bits)) & 31];
52 }
53 return result;
54}
55
56function getCurve(curve) {
57 if (curve === 'k256') {
58 return { ec: secp256k1, order: N_K256, mcPrefix: MC_K256_PUB, algId: K256_ALG_ID };
59 }
60 if (curve === 'p256') {
61 return { ec: p256, order: N_P256, mcPrefix: MC_P256_PUB, algId: P256_ALG_ID };
62 }
63 throw new Error("curve must be 'k256' or 'p256'");
64}
65
66function didKeyForPub(curve, compressedPubkey) {
67 const { mcPrefix } = getCurve(curve);
68 const prefixed = new Uint8Array(mcPrefix.length + compressedPubkey.length);
69 prefixed.set(mcPrefix);
70 prefixed.set(compressedPubkey, mcPrefix.length);
71 return 'did:key:z' + bs58.encode(prefixed);
72}
73
74function lowS(curve, r, s) {
75 const { order } = getCurve(curve);
76 if (s > order / 2n) {
77 s = order - s;
78 }
79 return [r, s];
80}
81
82function bigintToBytes(n, length) {
83 const hex = n.toString(16).padStart(length * 2, '0');
84 if (hex.length > length * 2) {
85 throw new Error(`integer too large for ${length} bytes`);
86 }
87 const bytes = new Uint8Array(length);
88 for (let i = 0; i < length; i++) {
89 bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
90 }
91 return bytes;
92}
93
94function signLowSRaw(curve, privateKey, message) {
95 const { ec } = getCurve(curve);
96 const msgHash = sha256(message);
97 const sig = ec.sign(msgHash, privateKey);
98 let [r, s] = [sig.r, sig.s];
99 [r, s] = lowS(curve, r, s);
100 const raw = new Uint8Array(64);
101 raw.set(bigintToBytes(r, 32), 0);
102 raw.set(bigintToBytes(s, 32), 32);
103 return raw;
104}
105
106function derLength(length) {
107 if (length < 0x80) {
108 return Buffer.from([length]);
109 }
110 if (length <= 0xff) {
111 return Buffer.from([0x81, length]);
112 }
113 return Buffer.from([0x82, (length >>> 8) & 0xff, length & 0xff]);
114}
115
116function derWrap(tag, content) {
117 const body = Buffer.from(content);
118 return Buffer.concat([Buffer.from([tag]), derLength(body.length), body]);
119}
120
121function derSequence(parts) {
122 const content = Buffer.concat(parts.map((part) => Buffer.from(part)));
123 return derWrap(0x30, content);
124}
125
126function derIntegerSmall(n) {
127 if (n === 0) return Buffer.from([0x02, 0x01, 0x00]);
128 if (n === 1) return Buffer.from([0x02, 0x01, 0x01]);
129 throw new Error('unsupported integer');
130}
131
132function derOctetString(data) {
133 return derWrap(0x04, data);
134}
135
136function derBitString(data) {
137 return derWrap(0x03, Buffer.concat([Buffer.from([0x00]), Buffer.from(data)]));
138}
139
140function buildPkcs8Der(curve, privateKeyBytes, uncompressedPubkey) {
141 const { algId } = getCurve(curve);
142 const ecPrivateKey = derSequence([
143 derIntegerSmall(1),
144 derOctetString(privateKeyBytes),
145 derWrap(0xa1, derBitString(uncompressedPubkey)),
146 ]);
147 return derSequence([derIntegerSmall(0), algId, derOctetString(ecPrivateKey)]);
148}
149
150function buildSpkiDer(curve, uncompressedPubkey) {
151 const { algId } = getCurve(curve);
152 return derSequence([algId, derBitString(uncompressedPubkey)]);
153}
154
155function toPem(label, der) {
156 const b64 = Buffer.from(der).toString('base64');
157 const lines = b64.match(/.{1,64}/g) ?? [];
158 return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
159}
160
161function generateRotationKey(outdir, curve, verbose = false) {
162 ensureDir(outdir);
163 const { ec } = getCurve(curve);
164
165 const privateKeyBytes = ec.utils.randomPrivateKey();
166 const compressed = ec.getPublicKey(privateKeyBytes, true);
167 const uncompressed = ec.getPublicKey(privateKeyBytes, false);
168
169 const privPemPath = `${outdir}/rotation_${curve}_private.pem`;
170 const pubPemPath = `${outdir}/rotation_${curve}_public.pem`;
171
172 const privatePem = toPem('PRIVATE KEY', buildPkcs8Der(curve, privateKeyBytes, uncompressed));
173 const publicPem = toPem('PUBLIC KEY', buildSpkiDer(curve, uncompressed));
174 writeFileSync(privPemPath, privatePem);
175 writeFileSync(pubPemPath, publicPem);
176
177 const didKey = didKeyForPub(curve, compressed);
178
179 if (verbose) {
180 console.log(`[verbose] Generated ${curve.toUpperCase()} keypair`);
181 console.log(`[verbose] Compressed pubkey: ${Buffer.from(compressed).toString('hex')}`);
182 }
183
184 return {
185 curve,
186 privPemPath,
187 pubPemPath,
188 didKey,
189 privateKeyBytes,
190 compressed,
191 };
192}
193
194function buildUnsignedOp(rotationDidKeys, akaList, pdsEndpoint) {
195 const verificationMethods = {};
196 const services = {};
197 if (pdsEndpoint) {
198 services.atproto_pds = {
199 type: 'AtprotoPersonalDataServer',
200 endpoint: pdsEndpoint,
201 };
202 }
203 return {
204 type: 'plc_operation',
205 rotationKeys: rotationDidKeys,
206 verificationMethods: verificationMethods,
207 alsoKnownAs: akaList,
208 services: services,
209 prev: null,
210 };
211}
212
213function derivePlcDid(signedOp) {
214 const cbor = dagCborEncode(signedOp);
215 const digest = sha256(cbor);
216 const suffix = base32LowerNopad(digest).slice(0, 24);
217 return 'did:plc:' + suffix;
218}
219
220function collect(value, previous) {
221 previous.push(value);
222 return previous;
223}
224
225const program = new Command();
226program
227 .name('plc-register')
228 .description('Generate & register a DID:PLC genesis operation.')
229 .option('--out <dir>', 'Output directory for keys (default: plc_keys)', 'plc_keys')
230 .option('--curve <curve>', 'Rotation key curve', 'k256')
231 .option('--aka <uri>', 'alsoKnownAs entry (e.g., at://alice.example). May repeat.', collect, [])
232 .option('--pds <url>', 'PDS endpoint URL (e.g., https://pds.example.com)')
233 .option('--dry-run', 'Build & print but do not POST to PLC')
234 .option('-v, --verbose', 'Show verbose output')
235 .action(async (opts) => {
236 if (!['k256', 'p256'].includes(opts.curve)) {
237 console.error(`error: option '--curve' must be 'k256' or 'p256'`);
238 process.exit(1);
239 }
240 const kb = generateRotationKey(opts.out, opts.curve, opts.verbose);
241 const unsigned = buildUnsignedOp([kb.didKey], opts.aka, opts.pds ?? null);
242
243 if (opts.verbose) {
244 console.log('[verbose] Unsigned operation:');
245 console.log(JSON.stringify(unsigned, null, 2));
246 }
247
248 const unsignedCbor = dagCborEncode(unsigned);
249 if (opts.verbose) {
250 console.log(`[verbose] Encoded CBOR size: ${unsignedCbor.length} bytes`);
251 }
252
253 const rawSig = signLowSRaw(opts.curve, kb.privateKeyBytes, unsignedCbor);
254 const sigB64u = b64urlNopad(rawSig);
255
256 if (opts.verbose) {
257 console.log(`[verbose] Signature (base64url): ${sigB64u}`);
258 }
259
260 const signed = { ...unsigned, sig: sigB64u };
261 const did = derivePlcDid(signed);
262
263 if (opts.verbose) {
264 const signedCbor = dagCborEncode(signed);
265 const digest = sha256(signedCbor);
266 console.log(`[verbose] SHA256 of signed op: ${Buffer.from(digest).toString('hex')}`);
267 }
268
269 writeFileSync(`${opts.out}/genesis_unsigned.dag-cbor`, Buffer.from(unsignedCbor));
270 writeFileSync(`${opts.out}/genesis_signed.json`, `${JSON.stringify(signed)}\n`);
271 writeFileSync(`${opts.out}/did.txt`, `${did}\n`);
272 writeFileSync(`${opts.out}/rotation_did_key.txt`, `${kb.didKey}\n`);
273
274 if (opts.verbose) {
275 console.log(`[verbose] Wrote genesis_unsigned.dag-cbor (${unsignedCbor.length} bytes)`);
276 console.log('[verbose] Wrote genesis_signed.json');
277 console.log('[verbose] Wrote did.txt');
278 console.log('[verbose] Wrote rotation_did_key.txt');
279 }
280
281 console.log(`Rotation key (did:key): ${kb.didKey}`);
282 console.log(`DID (derived): ${did}`);
283 console.log(`Wrote keys & artifacts to: ${resolve(opts.out)}`);
284
285 if (opts.dryRun) {
286 console.log('Dry run selected; not POSTing to PLC.');
287 return;
288 }
289
290 const url = `https://plc.directory/${did}`;
291 if (opts.verbose) {
292 console.log(`[verbose] POSTing to ${url}`);
293 console.log('[verbose] Request body:');
294 console.log(JSON.stringify(signed, null, 2));
295 }
296
297 try {
298 const resp = await fetch(url, {
299 method: 'POST',
300 headers: { 'Content-Type': 'application/json' },
301 body: JSON.stringify(signed),
302 signal: AbortSignal.timeout(10000),
303 });
304 const text = await resp.text();
305 console.log(`POST ${url} -> ${resp.status}`);
306 console.log(text.slice(0, 5000));
307 if (resp.ok) {
308 console.log('Registration appears successful.');
309 } else {
310 console.log('Registration failed; see response above.');
311 }
312 } catch (e) {
313 process.stderr.write(`Error POSTing to PLC: ${e}\n`);
314 process.exit(2);
315 }
316 });
317
318program.parse();