@swarp/cli 0.0.1-rc.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,472 @@
1
+ /**
2
+ * mTLS certificate generation for SWARP.
3
+ *
4
+ * Generates a self-signed CA and signs router + agent leaf certs.
5
+ * Uses node:crypto for key generation and signing — no openssl binary required.
6
+ * X.509 DER encoding is done in pure JavaScript using ASN.1 primitives.
7
+ */
8
+
9
+ import {
10
+ generateKeyPairSync,
11
+ createSign,
12
+ createPrivateKey,
13
+ createHash,
14
+ randomBytes,
15
+ X509Certificate,
16
+ } from 'node:crypto';
17
+ import { writeFileSync, readFileSync, mkdirSync, existsSync, appendFileSync } from 'node:fs';
18
+ import { join, resolve, basename } from 'node:path';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // ASN.1 / DER encoder
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const TAG = {
25
+ BOOLEAN: 0x01,
26
+ INTEGER: 0x02,
27
+ BIT_STRING: 0x03,
28
+ OCTET_STRING: 0x04,
29
+ SEQUENCE: 0x30,
30
+ SET: 0x31,
31
+ UTF8_STRING: 0x0c,
32
+ UTC_TIME: 0x17,
33
+ OID: 0x06,
34
+ };
35
+
36
+ function derLength(len) {
37
+ if (len < 0x80) return Buffer.from([len]);
38
+ if (len <= 0xff) return Buffer.from([0x81, len]);
39
+ return Buffer.from([0x82, (len >> 8) & 0xff, len & 0xff]);
40
+ }
41
+
42
+ function tlv(tag, value) {
43
+ const v = Buffer.isBuffer(value) ? value : Buffer.from(value);
44
+ return Buffer.concat([Buffer.from([tag]), derLength(v.length), v]);
45
+ }
46
+
47
+ function sequence(...items) {
48
+ return tlv(TAG.SEQUENCE, Buffer.concat(items));
49
+ }
50
+
51
+ function set_(...items) {
52
+ return tlv(TAG.SET, Buffer.concat(items));
53
+ }
54
+
55
+ function integerFromBuffer(buf) {
56
+ const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
57
+ // Strip leading zeros — DER requires minimal integer encoding
58
+ let start = 0;
59
+ while (start < b.length - 1 && b[start] === 0x00) start++;
60
+ const trimmed = start > 0 ? b.subarray(start) : b;
61
+ // Add padding zero if high bit set (positive integer must not look negative)
62
+ const prefixed = trimmed[0] & 0x80 ? Buffer.concat([Buffer.from([0x00]), trimmed]) : trimmed;
63
+ return tlv(TAG.INTEGER, prefixed);
64
+ }
65
+
66
+ function integerFromNumber(n) {
67
+ let hex = BigInt(n).toString(16);
68
+ if (hex.length % 2 !== 0) hex = '0' + hex;
69
+ return integerFromBuffer(Buffer.from(hex, 'hex'));
70
+ }
71
+
72
+ function oid(dotted) {
73
+ const parts = dotted.split('.').map(Number);
74
+ const bytes = [40 * parts[0] + parts[1]];
75
+ for (let i = 2; i < parts.length; i++) {
76
+ let n = parts[i];
77
+ const enc = [n & 0x7f];
78
+ n >>= 7;
79
+ while (n > 0) {
80
+ enc.unshift((n & 0x7f) | 0x80);
81
+ n >>= 7;
82
+ }
83
+ bytes.push(...enc);
84
+ }
85
+ return tlv(TAG.OID, Buffer.from(bytes));
86
+ }
87
+
88
+ function utf8String(s) {
89
+ return tlv(TAG.UTF8_STRING, Buffer.from(s, 'utf8'));
90
+ }
91
+
92
+ function utcTime(date) {
93
+ const d = new Date(date);
94
+ const pad = (n) => String(n).padStart(2, '0');
95
+ const s =
96
+ String(d.getUTCFullYear()).slice(-2) +
97
+ pad(d.getUTCMonth() + 1) +
98
+ pad(d.getUTCDate()) +
99
+ pad(d.getUTCHours()) +
100
+ pad(d.getUTCMinutes()) +
101
+ pad(d.getUTCSeconds()) +
102
+ 'Z';
103
+ return tlv(TAG.UTC_TIME, Buffer.from(s, 'ascii'));
104
+ }
105
+
106
+ function bitString(buf) {
107
+ return tlv(TAG.BIT_STRING, Buffer.concat([Buffer.from([0x00]), buf]));
108
+ }
109
+
110
+ function boolean_(val) {
111
+ return tlv(TAG.BOOLEAN, Buffer.from([val ? 0xff : 0x00]));
112
+ }
113
+
114
+ function contextExplicit(n, content) {
115
+ const tag = 0xa0 | n;
116
+ const v = Buffer.isBuffer(content) ? content : Buffer.from(content);
117
+ return Buffer.concat([Buffer.from([tag]), derLength(v.length), v]);
118
+ }
119
+
120
+ function contextImplicit(n, content) {
121
+ const tag = 0x80 | n;
122
+ const v = Buffer.isBuffer(content) ? content : Buffer.from(content);
123
+ return Buffer.concat([Buffer.from([tag]), derLength(v.length), v]);
124
+ }
125
+
126
+ function octetString(content) {
127
+ return tlv(TAG.OCTET_STRING, content);
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // OID constants
132
+ // ---------------------------------------------------------------------------
133
+
134
+ const OID = {
135
+ commonName: '2.5.4.3',
136
+ ecdsaWithSHA256: '1.2.840.10045.4.3.2',
137
+ basicConstraints: '2.5.29.19',
138
+ subjectKeyIdentifier: '2.5.29.14',
139
+ authorityKeyIdentifier: '2.5.29.35',
140
+ keyUsage: '2.5.29.15',
141
+ };
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Name
145
+ // ---------------------------------------------------------------------------
146
+
147
+ function rdnSequence(cn) {
148
+ return sequence(
149
+ set_(
150
+ sequence(
151
+ oid(OID.commonName),
152
+ utf8String(cn),
153
+ ),
154
+ ),
155
+ );
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Key identifier
160
+ // ---------------------------------------------------------------------------
161
+
162
+ function computeKeyId(spkiDer) {
163
+ return createHash('sha1').update(spkiDer).digest().slice(0, 20);
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Extensions
168
+ // ---------------------------------------------------------------------------
169
+
170
+ function buildExtensions({ isCA, subjectSpki, authoritySpki }) {
171
+ const exts = [];
172
+
173
+ if (isCA) {
174
+ const bcValue = sequence(boolean_(true));
175
+ exts.push(
176
+ sequence(
177
+ oid(OID.basicConstraints),
178
+ boolean_(true),
179
+ octetString(bcValue),
180
+ ),
181
+ );
182
+ }
183
+
184
+ const skid = computeKeyId(subjectSpki);
185
+ exts.push(
186
+ sequence(
187
+ oid(OID.subjectKeyIdentifier),
188
+ octetString(octetString(skid)),
189
+ ),
190
+ );
191
+
192
+ if (authoritySpki) {
193
+ const akid = computeKeyId(authoritySpki);
194
+ const akidContent = sequence(contextImplicit(0, akid));
195
+ exts.push(
196
+ sequence(
197
+ oid(OID.authorityKeyIdentifier),
198
+ octetString(akidContent),
199
+ ),
200
+ );
201
+ }
202
+
203
+ if (!isCA) {
204
+ // digitalSignature + keyEncipherment
205
+ exts.push(
206
+ sequence(
207
+ oid(OID.keyUsage),
208
+ boolean_(true),
209
+ octetString(bitString(Buffer.from([0xa0]))),
210
+ ),
211
+ );
212
+ }
213
+
214
+ return sequence(...exts);
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // TBSCertificate
219
+ // ---------------------------------------------------------------------------
220
+
221
+ /**
222
+ * @param {object} opts
223
+ * @param {Buffer} opts.serial
224
+ * @param {string} opts.issuerCN
225
+ * @param {string} opts.subjectCN
226
+ * @param {Date} opts.notBefore
227
+ * @param {Date} opts.notAfter
228
+ * @param {Buffer} opts.subjectSpki - Subject SubjectPublicKeyInfo DER
229
+ * @param {Buffer|null} opts.authoritySpki - Issuer SubjectPublicKeyInfo DER (null for self-signed)
230
+ * @param {boolean} opts.isCA
231
+ */
232
+ function buildTbs(opts) {
233
+ const { serial, issuerCN, subjectCN, notBefore, notAfter, subjectSpki, authoritySpki, isCA } = opts;
234
+
235
+ const version = contextExplicit(0, integerFromNumber(2));
236
+ const exts = buildExtensions({ isCA, subjectSpki, authoritySpki });
237
+ const extsWrapped = contextExplicit(3, exts);
238
+
239
+ return sequence(
240
+ version,
241
+ integerFromBuffer(serial),
242
+ sequence(oid(OID.ecdsaWithSHA256)),
243
+ rdnSequence(issuerCN),
244
+ sequence(utcTime(notBefore), utcTime(notAfter)),
245
+ rdnSequence(subjectCN),
246
+ subjectSpki,
247
+ extsWrapped,
248
+ );
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Sign + wrap
253
+ // ---------------------------------------------------------------------------
254
+
255
+ function signTbs(tbsDer, privateKeyPem) {
256
+ const key = createPrivateKey({ key: privateKeyPem, format: 'pem', type: 'pkcs8' });
257
+ const signer = createSign('SHA256');
258
+ signer.update(tbsDer);
259
+ const sigDer = signer.sign(key);
260
+
261
+ return sequence(
262
+ tbsDer,
263
+ sequence(oid(OID.ecdsaWithSHA256)),
264
+ bitString(sigDer),
265
+ );
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // PEM helpers
270
+ // ---------------------------------------------------------------------------
271
+
272
+ function toPem(label, der) {
273
+ const b64 = (Buffer.isBuffer(der) ? der : Buffer.from(der)).toString('base64');
274
+ const lines = b64.match(/.{1,64}/g).join('\n');
275
+ return `-----BEGIN ${label}-----\n${lines}\n-----END ${label}-----\n`;
276
+ }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // Key generation
280
+ // ---------------------------------------------------------------------------
281
+
282
+ const VALIDITY_DAYS = 365 * 3;
283
+
284
+ function daysFromNow(days) {
285
+ return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
286
+ }
287
+
288
+ function genKeyPair() {
289
+ const { privateKey: privatePem, publicKey: spkiDer } = generateKeyPairSync('ec', {
290
+ namedCurve: 'P-256',
291
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
292
+ publicKeyEncoding: { type: 'spki', format: 'der' },
293
+ });
294
+ return { privatePem, spkiDer: Buffer.from(spkiDer) };
295
+ }
296
+
297
+ function randomSerialBytes() {
298
+ const b = randomBytes(8);
299
+ b[0] &= 0x7f;
300
+ return b;
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // Certificate builders
305
+ // ---------------------------------------------------------------------------
306
+
307
+ function buildCaCert(caKey) {
308
+ const now = new Date();
309
+ const tbs = buildTbs({
310
+ serial: randomSerialBytes(),
311
+ issuerCN: 'swarp-ca',
312
+ subjectCN: 'swarp-ca',
313
+ notBefore: now,
314
+ notAfter: daysFromNow(VALIDITY_DAYS),
315
+ subjectSpki: caKey.spkiDer,
316
+ authoritySpki: null,
317
+ isCA: true,
318
+ });
319
+
320
+ const certDer = signTbs(tbs, caKey.privatePem);
321
+ return toPem('CERTIFICATE', certDer);
322
+ }
323
+
324
+ function buildLeafCert(cn, leafKey, caOpts) {
325
+ const { caCertPem, caKeyPem } = caOpts;
326
+
327
+ const caCert = new X509Certificate(caCertPem);
328
+ const caSpkiDer = Buffer.from(caCert.publicKey.export({ type: 'spki', format: 'der' }));
329
+
330
+ const now = new Date();
331
+ const tbs = buildTbs({
332
+ serial: randomSerialBytes(),
333
+ issuerCN: 'swarp-ca',
334
+ subjectCN: cn,
335
+ notBefore: now,
336
+ notAfter: daysFromNow(VALIDITY_DAYS),
337
+ subjectSpki: leafKey.spkiDer,
338
+ authoritySpki: caSpkiDer,
339
+ isCA: false,
340
+ });
341
+
342
+ const certDer = signTbs(tbs, caKeyPem);
343
+ return toPem('CERTIFICATE', certDer);
344
+ }
345
+
346
+ // ---------------------------------------------------------------------------
347
+ // .gitignore helper
348
+ // ---------------------------------------------------------------------------
349
+
350
+ function ensureGitignoreEntry(outDir, fsImpl) {
351
+ const gitignorePath = resolve('.gitignore');
352
+ // Normalise to a simple dirname token (e.g. 'certs') so absolute test paths
353
+ // don't end up written verbatim into .gitignore.
354
+ const dirName = basename(resolve(outDir));
355
+ const entry = `${dirName}/`;
356
+
357
+ if (!fsImpl.existsSync(gitignorePath)) return;
358
+
359
+ const existing = fsImpl.readFileSync(gitignorePath, 'utf8');
360
+ if (existing.includes(entry)) return;
361
+
362
+ fsImpl.appendFileSync(
363
+ gitignorePath,
364
+ `\n# SWARP mTLS certificates (never commit private keys)\n${entry}\n`,
365
+ );
366
+ }
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // Exported API
370
+ // ---------------------------------------------------------------------------
371
+
372
+ /**
373
+ * Generates a full mTLS certificate bundle and writes it to `outDir`.
374
+ *
375
+ * Output files:
376
+ * ca.crt — CA certificate (public; safe to commit)
377
+ * ca.key — CA private key (put in GitHub Secrets; never commit)
378
+ * router.crt/.key — Router mTLS keypair
379
+ * agent-example.crt/.key — Sample agent mTLS keypair
380
+ *
381
+ * Adds `outDir/` to .gitignore if the file exists and the entry is absent.
382
+ *
383
+ * @param {string} [outDir='certs'] - Output directory (created if missing)
384
+ * @param {object} [opts]
385
+ * @param {object} [opts.fs] - Filesystem shims for testing
386
+ */
387
+ export async function generateCerts(outDir = 'certs', { fs: fsOverride } = {}) {
388
+ const fs = {
389
+ writeFileSync,
390
+ mkdirSync,
391
+ existsSync,
392
+ appendFileSync,
393
+ readFileSync,
394
+ ...fsOverride,
395
+ };
396
+ const out = resolve(outDir);
397
+
398
+ if (!fs.existsSync(out)) {
399
+ fs.mkdirSync(out, { recursive: true });
400
+ }
401
+
402
+ const caKey = genKeyPair();
403
+ const caCertPem = buildCaCert(caKey);
404
+
405
+ fs.writeFileSync(join(out, 'ca.crt'), caCertPem, 'utf8');
406
+ fs.writeFileSync(join(out, 'ca.key'), caKey.privatePem, 'utf8');
407
+
408
+ const caOpts = { caCertPem, caKeyPem: caKey.privatePem };
409
+
410
+ const routerKey = genKeyPair();
411
+ const routerCertPem = buildLeafCert('swarp-router', routerKey, caOpts);
412
+ fs.writeFileSync(join(out, 'router.crt'), routerCertPem, 'utf8');
413
+ fs.writeFileSync(join(out, 'router.key'), routerKey.privatePem, 'utf8');
414
+
415
+ const agentKey = genKeyPair();
416
+ const agentCertPem = buildLeafCert('example', agentKey, caOpts);
417
+ fs.writeFileSync(join(out, 'agent-example.crt'), agentCertPem, 'utf8');
418
+ fs.writeFileSync(join(out, 'agent-example.key'), agentKey.privatePem, 'utf8');
419
+
420
+ ensureGitignoreEntry(outDir, fs);
421
+
422
+ console.log(`\nCertificates written to ${out}/`);
423
+ console.log(' ca.crt CA certificate (safe to commit)');
424
+ console.log(' ca.key CA private key');
425
+ console.log(' router.crt / router.key Router mTLS keypair');
426
+ console.log(' agent-example.crt / .key Sample agent mTLS keypair');
427
+ console.log('\n Put SWARP_MTLS_CA_KEY in GitHub Secrets. Never commit it.\n');
428
+ }
429
+
430
+ /**
431
+ * Rotates the certificate for a named agent using the existing CA.
432
+ * Reads ca.crt and ca.key from `outDir`, writes new agent-<name>.crt/.key.
433
+ *
434
+ * @param {string} name - Agent name used as CN and filename prefix
435
+ * @param {string} [outDir='certs'] - Directory containing ca.crt and ca.key
436
+ * @param {object} [opts]
437
+ * @param {object} [opts.fs] - Filesystem shims for testing
438
+ */
439
+ export async function rotateCert(name, outDir = 'certs', { fs: fsOverride } = {}) {
440
+ const fs = {
441
+ writeFileSync,
442
+ mkdirSync,
443
+ existsSync,
444
+ appendFileSync,
445
+ readFileSync,
446
+ ...fsOverride,
447
+ };
448
+ const out = resolve(outDir);
449
+
450
+ const caKeyPath = join(out, 'ca.key');
451
+ const caCrtPath = join(out, 'ca.crt');
452
+
453
+ if (!fs.existsSync(caKeyPath) || !fs.existsSync(caCrtPath)) {
454
+ throw new Error(
455
+ `CA files not found in ${out}/. Run 'swarp certs generate' first.`,
456
+ );
457
+ }
458
+
459
+ const caKeyPem = fs.readFileSync(caKeyPath, 'utf8');
460
+ const caCertPem = fs.readFileSync(caCrtPath, 'utf8');
461
+
462
+ const leafKey = genKeyPair();
463
+ const leafCertPem = buildLeafCert(name, leafKey, { caCertPem, caKeyPem });
464
+
465
+ fs.writeFileSync(join(out, `agent-${name}.crt`), leafCertPem, 'utf8');
466
+ fs.writeFileSync(join(out, `agent-${name}.key`), leafKey.privatePem, 'utf8');
467
+
468
+ console.log(`\nRotated certificate for agent '${name}':`);
469
+ console.log(` agent-${name}.crt`);
470
+ console.log(` agent-${name}.key`);
471
+ console.log(`\n Update SWARP_AGENT_${name.toUpperCase()}_CERT in GitHub Secrets.\n`);
472
+ }