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