@certd/acme-client 0.2.0 → 0.3.1

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,526 @@
1
+ /**
2
+ * Native Node.js crypto interface
3
+ *
4
+ * @namespace crypto
5
+ */
6
+
7
+ const net = require('net');
8
+ const { promisify } = require('util');
9
+ const crypto = require('crypto');
10
+ const jsrsasign = require('jsrsasign');
11
+
12
+ const generateKeyPair = promisify(crypto.generateKeyPair);
13
+
14
+
15
+ /**
16
+ * Determine key type and info by attempting to derive public key
17
+ *
18
+ * @private
19
+ * @param {buffer|string} keyPem PEM encoded private or public key
20
+ * @returns {object}
21
+ */
22
+
23
+ function getKeyInfo(keyPem) {
24
+ const result = {
25
+ isRSA: false,
26
+ isECDSA: false,
27
+ signatureAlgorithm: null,
28
+ publicKey: crypto.createPublicKey(keyPem)
29
+ };
30
+
31
+ if (result.publicKey.asymmetricKeyType === 'rsa') {
32
+ result.isRSA = true;
33
+ result.signatureAlgorithm = 'SHA256withRSA';
34
+ }
35
+ else if (result.publicKey.asymmetricKeyType === 'ec') {
36
+ result.isECDSA = true;
37
+ result.signatureAlgorithm = 'SHA256withECDSA';
38
+ }
39
+ else {
40
+ throw new Error('Unable to parse key information, unknown format');
41
+ }
42
+
43
+ return result;
44
+ }
45
+
46
+
47
+ /**
48
+ * Generate a private RSA key
49
+ *
50
+ * @param {number} [modulusLength] Size of the keys modulus in bits, default: `2048`
51
+ * @returns {Promise<buffer>} PEM encoded private RSA key
52
+ *
53
+ * @example Generate private RSA key
54
+ * ```js
55
+ * const privateKey = await acme.crypto.createPrivateRsaKey();
56
+ * ```
57
+ *
58
+ * @example Private RSA key with modulus size 4096
59
+ * ```js
60
+ * const privateKey = await acme.crypto.createPrivateRsaKey(4096);
61
+ * ```
62
+ */
63
+
64
+ async function createPrivateRsaKey(modulusLength = 2048) {
65
+ const pair = await generateKeyPair('rsa', {
66
+ modulusLength,
67
+ privateKeyEncoding: {
68
+ type: 'pkcs8',
69
+ format: 'pem'
70
+ }
71
+ });
72
+
73
+ return Buffer.from(pair.privateKey);
74
+ }
75
+
76
+ exports.createPrivateRsaKey = createPrivateRsaKey;
77
+
78
+
79
+ /**
80
+ * Alias of `createPrivateRsaKey()`
81
+ *
82
+ * @function
83
+ */
84
+
85
+ exports.createPrivateKey = createPrivateRsaKey;
86
+
87
+
88
+ /**
89
+ * Generate a private ECDSA key
90
+ *
91
+ * @param {string} [namedCurve] ECDSA curve name (P-256, P-384 or P-521), default `P-256`
92
+ * @returns {Promise<buffer>} PEM encoded private ECDSA key
93
+ *
94
+ * @example Generate private ECDSA key
95
+ * ```js
96
+ * const privateKey = await acme.crypto.createPrivateEcdsaKey();
97
+ * ```
98
+ *
99
+ * @example Private ECDSA key using P-384 curve
100
+ * ```js
101
+ * const privateKey = await acme.crypto.createPrivateEcdsaKey('P-384');
102
+ * ```
103
+ */
104
+
105
+ exports.createPrivateEcdsaKey = async (namedCurve = 'P-256') => {
106
+ const pair = await generateKeyPair('ec', {
107
+ namedCurve,
108
+ privateKeyEncoding: {
109
+ type: 'pkcs8',
110
+ format: 'pem'
111
+ }
112
+ });
113
+
114
+ return Buffer.from(pair.privateKey);
115
+ };
116
+
117
+
118
+ /**
119
+ * Get a public key derived from a RSA or ECDSA key
120
+ *
121
+ * @param {buffer|string} keyPem PEM encoded private or public key
122
+ * @returns {buffer} PEM encoded public key
123
+ *
124
+ * @example Get public key
125
+ * ```js
126
+ * const publicKey = acme.crypto.getPublicKey(privateKey);
127
+ * ```
128
+ */
129
+
130
+ exports.getPublicKey = (keyPem) => {
131
+ const info = getKeyInfo(keyPem);
132
+
133
+ const publicKey = info.publicKey.export({
134
+ type: info.isECDSA ? 'spki' : 'pkcs1',
135
+ format: 'pem'
136
+ });
137
+
138
+ return Buffer.from(publicKey);
139
+ };
140
+
141
+
142
+ /**
143
+ * Get a JSON Web Key derived from a RSA or ECDSA key
144
+ *
145
+ * https://datatracker.ietf.org/doc/html/rfc7517
146
+ *
147
+ * @param {buffer|string} keyPem PEM encoded private or public key
148
+ * @returns {object} JSON Web Key
149
+ *
150
+ * @example Get JWK
151
+ * ```js
152
+ * const jwk = acme.crypto.getJwk(privateKey);
153
+ * ```
154
+ */
155
+
156
+ function getJwk(keyPem) {
157
+ const jwk = crypto.createPublicKey(keyPem).export({
158
+ format: 'jwk'
159
+ });
160
+
161
+ /* Sort keys */
162
+ return Object.keys(jwk).sort().reduce((result, k) => {
163
+ result[k] = jwk[k];
164
+ return result;
165
+ }, {});
166
+ }
167
+
168
+ exports.getJwk = getJwk;
169
+
170
+
171
+ /**
172
+ * Fix missing support for NIST curve names in jsrsasign
173
+ *
174
+ * @private
175
+ * @param {string} crv NIST curve name
176
+ * @returns {string} SECG curve name
177
+ */
178
+
179
+ function convertNistCurveNameToSecg(nistName) {
180
+ switch (nistName) {
181
+ case 'P-256':
182
+ return 'secp256r1';
183
+ case 'P-384':
184
+ return 'secp384r1';
185
+ case 'P-521':
186
+ return 'secp521r1';
187
+ default:
188
+ return nistName;
189
+ }
190
+ }
191
+
192
+
193
+ /**
194
+ * Split chain of PEM encoded objects from string into array
195
+ *
196
+ * @param {buffer|string} chainPem PEM encoded object chain
197
+ * @returns {array} Array of PEM objects including headers
198
+ */
199
+
200
+ function splitPemChain(chainPem) {
201
+ if (Buffer.isBuffer(chainPem)) {
202
+ chainPem = chainPem.toString();
203
+ }
204
+
205
+ return chainPem
206
+ /* Split chain into chunks, starting at every header */
207
+ .split(/\s*(?=-----BEGIN [A-Z0-9- ]+-----\r?\n?)/g)
208
+ /* Match header, PEM body and footer */
209
+ .map((pem) => pem.match(/\s*-----BEGIN ([A-Z0-9- ]+)-----\r?\n?([\S\s]+)\r?\n?-----END \1-----/))
210
+ /* Filter out non-matches or empty bodies */
211
+ .filter((pem) => pem && pem[2] && pem[2].replace(/[\r\n]+/g, '').trim())
212
+ /* Decode to hex, and back to PEM for formatting etc */
213
+ .map(([pem, header]) => jsrsasign.hextopem(jsrsasign.pemtohex(pem, header), header));
214
+ }
215
+
216
+ exports.splitPemChain = splitPemChain;
217
+
218
+
219
+ /**
220
+ * Parse body of PEM encoded object and return a Base64URL string
221
+ * If multiple objects are chained, the first body will be returned
222
+ *
223
+ * @param {buffer|string} pem PEM encoded chain or object
224
+ * @returns {string} Base64URL-encoded body
225
+ */
226
+
227
+ exports.getPemBodyAsB64u = (pem) => {
228
+ const chain = splitPemChain(pem);
229
+
230
+ if (!chain.length) {
231
+ throw new Error('Unable to parse PEM body from string');
232
+ }
233
+
234
+ /* First object, hex and back to b64 without new lines */
235
+ return jsrsasign.hextob64u(jsrsasign.pemtohex(chain[0]));
236
+ };
237
+
238
+
239
+ /**
240
+ * Parse common name from a subject object
241
+ *
242
+ * @private
243
+ * @param {object} subj Subject returned from jsrsasign
244
+ * @returns {string} Common name value
245
+ */
246
+
247
+ function parseCommonName(subj) {
248
+ const subjectArr = (subj && subj.array) ? subj.array : [];
249
+ const cnArr = subjectArr.find((s) => (s[0] && s[0].type && s[0].value && (s[0].type === 'CN')));
250
+ return (cnArr && cnArr.length && cnArr[0].value) ? cnArr[0].value : null;
251
+ }
252
+
253
+
254
+ /**
255
+ * Parse domains from a certificate or CSR
256
+ *
257
+ * @private
258
+ * @param {object} params Certificate or CSR params returned from jsrsasign
259
+ * @returns {object} {commonName, altNames}
260
+ */
261
+
262
+ function parseDomains(params) {
263
+ const commonName = parseCommonName(params.subject);
264
+ const extensionArr = (params.ext || params.extreq || []);
265
+ let altNames = [];
266
+
267
+ if (extensionArr && extensionArr.length) {
268
+ const altNameExt = extensionArr.find((e) => (e.extname && (e.extname === 'subjectAltName')));
269
+ const altNameArr = (altNameExt && altNameExt.array && altNameExt.array.length) ? altNameExt.array : [];
270
+ altNames = altNameArr.map((a) => Object.values(a)[0] || null).filter((a) => a);
271
+ }
272
+
273
+ return {
274
+ commonName,
275
+ altNames
276
+ };
277
+ }
278
+
279
+
280
+ /**
281
+ * Read domains from a Certificate Signing Request
282
+ *
283
+ * @param {buffer|string} csrPem PEM encoded Certificate Signing Request
284
+ * @returns {object} {commonName, altNames}
285
+ *
286
+ * @example Read Certificate Signing Request domains
287
+ * ```js
288
+ * const { commonName, altNames } = acme.crypto.readCsrDomains(certificateRequest);
289
+ *
290
+ * console.log(`Common name: ${commonName}`);
291
+ * console.log(`Alt names: ${altNames.join(', ')}`);
292
+ * ```
293
+ */
294
+
295
+ exports.readCsrDomains = (csrPem) => {
296
+ if (Buffer.isBuffer(csrPem)) {
297
+ csrPem = csrPem.toString();
298
+ }
299
+
300
+ /* Parse CSR */
301
+ const params = jsrsasign.KJUR.asn1.csr.CSRUtil.getParam(csrPem);
302
+ return parseDomains(params);
303
+ };
304
+
305
+
306
+ /**
307
+ * Read information from a certificate
308
+ * If multiple certificates are chained, the first will be read
309
+ *
310
+ * @param {buffer|string} certPem PEM encoded certificate or chain
311
+ * @returns {object} Certificate info
312
+ *
313
+ * @example Read certificate information
314
+ * ```js
315
+ * const info = acme.crypto.readCertificateInfo(certificate);
316
+ * const { commonName, altNames } = info.domains;
317
+ *
318
+ * console.log(`Not after: ${info.notAfter}`);
319
+ * console.log(`Not before: ${info.notBefore}`);
320
+ *
321
+ * console.log(`Common name: ${commonName}`);
322
+ * console.log(`Alt names: ${altNames.join(', ')}`);
323
+ * ```
324
+ */
325
+
326
+ exports.readCertificateInfo = (certPem) => {
327
+ const chain = splitPemChain(certPem);
328
+
329
+ if (!chain.length) {
330
+ throw new Error('Unable to parse PEM body from string');
331
+ }
332
+
333
+ /* Parse certificate */
334
+ const obj = new jsrsasign.X509();
335
+ obj.readCertPEM(chain[0]);
336
+ const params = obj.getParam();
337
+
338
+ return {
339
+ issuer: {
340
+ commonName: parseCommonName(params.issuer)
341
+ },
342
+ domains: parseDomains(params),
343
+ notBefore: jsrsasign.zulutodate(params.notbefore),
344
+ notAfter: jsrsasign.zulutodate(params.notafter)
345
+ };
346
+ };
347
+
348
+
349
+ /**
350
+ * Determine ASN.1 character string type for CSR subject field
351
+ *
352
+ * https://tools.ietf.org/html/rfc5280
353
+ * https://github.com/kjur/jsrsasign/blob/2613c64559768b91dde9793dfa318feacb7c3b8a/src/x509-1.1.js#L2404-L2412
354
+ * https://github.com/kjur/jsrsasign/blob/2613c64559768b91dde9793dfa318feacb7c3b8a/src/asn1x509-1.0.js#L3526-L3535
355
+ *
356
+ * @private
357
+ * @param {string} field CSR subject field
358
+ * @returns {string} ASN.1 jsrsasign character string type
359
+ */
360
+
361
+ function getCsrAsn1CharStringType(field) {
362
+ switch (field) {
363
+ case 'C':
364
+ return 'prn';
365
+ case 'E':
366
+ return 'ia5';
367
+ default:
368
+ return 'utf8';
369
+ }
370
+ }
371
+
372
+
373
+ /**
374
+ * Create array of subject fields for a Certificate Signing Request
375
+ *
376
+ * @private
377
+ * @param {object} input Key-value of subject fields
378
+ * @returns {object[]} Certificate Signing Request subject array
379
+ */
380
+
381
+ function createCsrSubject(input) {
382
+ return Object.entries(input).reduce((result, [type, value]) => {
383
+ if (value) {
384
+ const ds = getCsrAsn1CharStringType(type);
385
+ result.push([{ type, value, ds }]);
386
+ }
387
+
388
+ return result;
389
+ }, []);
390
+ }
391
+
392
+
393
+ /**
394
+ * Create array of alt names for Certificate Signing Requests
395
+ *
396
+ * https://github.com/kjur/jsrsasign/blob/3edc0070846922daea98d9588978e91d855577ec/src/x509-1.1.js#L1355-L1410
397
+ *
398
+ * @private
399
+ * @param {string[]} altNames Array of alt names
400
+ * @returns {object[]} Certificate Signing Request alt names array
401
+ */
402
+
403
+ function formatCsrAltNames(altNames) {
404
+ return altNames.map((value) => {
405
+ const key = net.isIP(value) ? 'ip' : 'dns';
406
+ return { [key]: value };
407
+ });
408
+ }
409
+
410
+
411
+ /**
412
+ * Create a Certificate Signing Request
413
+ *
414
+ * @param {object} data
415
+ * @param {number} [data.keySize] Size of newly created RSA private key modulus in bits, default: `2048`
416
+ * @param {string} [data.commonName] FQDN of your server
417
+ * @param {array} [data.altNames] SAN (Subject Alternative Names), default: `[]`
418
+ * @param {string} [data.country] 2 letter country code
419
+ * @param {string} [data.state] State or province
420
+ * @param {string} [data.locality] City
421
+ * @param {string} [data.organization] Organization name
422
+ * @param {string} [data.organizationUnit] Organizational unit name
423
+ * @param {string} [data.emailAddress] Email address
424
+ * @param {string} [keyPem] PEM encoded CSR private key
425
+ * @returns {Promise<buffer[]>} [privateKey, certificateSigningRequest]
426
+ *
427
+ * @example Create a Certificate Signing Request
428
+ * ```js
429
+ * const [certificateKey, certificateRequest] = await acme.crypto.createCsr({
430
+ * commonName: 'test.example.com'
431
+ * });
432
+ * ```
433
+ *
434
+ * @example Certificate Signing Request with both common and alternative names
435
+ * ```js
436
+ * const [certificateKey, certificateRequest] = await acme.crypto.createCsr({
437
+ * keySize: 4096,
438
+ * commonName: 'test.example.com',
439
+ * altNames: ['foo.example.com', 'bar.example.com']
440
+ * });
441
+ * ```
442
+ *
443
+ * @example Certificate Signing Request with additional information
444
+ * ```js
445
+ * const [certificateKey, certificateRequest] = await acme.crypto.createCsr({
446
+ * commonName: 'test.example.com',
447
+ * country: 'US',
448
+ * state: 'California',
449
+ * locality: 'Los Angeles',
450
+ * organization: 'The Company Inc.',
451
+ * organizationUnit: 'IT Department',
452
+ * emailAddress: 'contact@example.com'
453
+ * });
454
+ * ```
455
+ *
456
+ * @example Certificate Signing Request with ECDSA private key
457
+ * ```js
458
+ * const certificateKey = await acme.crypto.createPrivateEcdsaKey();
459
+ *
460
+ * const [, certificateRequest] = await acme.crypto.createCsr({
461
+ * commonName: 'test.example.com'
462
+ * }, certificateKey);
463
+ */
464
+
465
+ exports.createCsr = async (data, keyPem = null) => {
466
+ if (!keyPem) {
467
+ keyPem = await createPrivateRsaKey(data.keySize);
468
+ }
469
+ else if (!Buffer.isBuffer(keyPem)) {
470
+ keyPem = Buffer.from(keyPem);
471
+ }
472
+
473
+ if (typeof data.altNames === 'undefined') {
474
+ data.altNames = [];
475
+ }
476
+
477
+ /* Get key info and JWK */
478
+ const info = getKeyInfo(keyPem);
479
+ const jwk = getJwk(keyPem);
480
+ const extensionRequests = [];
481
+
482
+ /* Missing support for NIST curve names in jsrsasign - https://github.com/kjur/jsrsasign/blob/master/src/asn1x509-1.0.js#L4388-L4393 */
483
+ if (jwk.crv && (jwk.kty === 'EC')) {
484
+ jwk.crv = convertNistCurveNameToSecg(jwk.crv);
485
+ }
486
+
487
+ /* Ensure subject common name is present in SAN - https://cabforum.org/wp-content/uploads/BRv1.2.3.pdf */
488
+ if (data.commonName && !data.altNames.includes(data.commonName)) {
489
+ data.altNames.unshift(data.commonName);
490
+ }
491
+
492
+ /* Subject */
493
+ const subject = createCsrSubject({
494
+ CN: data.commonName,
495
+ C: data.country,
496
+ ST: data.state,
497
+ L: data.locality,
498
+ O: data.organization,
499
+ OU: data.organizationUnit,
500
+ E: data.emailAddress
501
+ });
502
+
503
+ /* SAN extension */
504
+ if (data.altNames.length) {
505
+ extensionRequests.push({
506
+ extname: 'subjectAltName',
507
+ array: formatCsrAltNames(data.altNames)
508
+ });
509
+ }
510
+
511
+ /* Create CSR */
512
+ const csr = new jsrsasign.KJUR.asn1.csr.CertificationRequest({
513
+ subject: { array: subject },
514
+ sigalg: info.signatureAlgorithm,
515
+ sbjprvkey: keyPem.toString(),
516
+ sbjpubkey: jwk,
517
+ extreq: extensionRequests
518
+ });
519
+
520
+ /* Sign CSR, get PEM */
521
+ csr.sign();
522
+ const pem = csr.getPEM();
523
+
524
+ /* Done */
525
+ return [keyPem, Buffer.from(pem)];
526
+ };