mirror of
https://github.com/certd/certd.git
synced 2026-04-14 12:30:54 +08:00
601 lines
18 KiB
JavaScript
601 lines
18 KiB
JavaScript
/**
|
|
* Native Node.js crypto interface
|
|
*
|
|
* @namespace crypto
|
|
*/
|
|
import net from 'net';
|
|
import { promisify } from 'util';
|
|
import crypto from 'crypto';
|
|
import asn1js from 'asn1js';
|
|
import x509 from '@peculiar/x509';
|
|
|
|
|
|
const randomInt = promisify(crypto.randomInt);
|
|
const generateKeyPair = promisify(crypto.generateKeyPair);
|
|
|
|
/* Use Node.js Web Crypto API */
|
|
x509.cryptoProvider.set(crypto.webcrypto);
|
|
|
|
/* id-ce-subjectAltName - https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.6 */
|
|
const subjectAltNameOID = '2.5.29.17';
|
|
|
|
/* id-pe-acmeIdentifier - https://datatracker.ietf.org/doc/html/rfc8737#section-6.1 */
|
|
const alpnAcmeIdentifierOID = '1.3.6.1.5.5.7.1.31';
|
|
|
|
/**
|
|
* Determine key type and info by attempting to derive public key
|
|
*
|
|
* @private
|
|
* @param {buffer|string} keyPem PEM encoded private or public key
|
|
* @returns {object}
|
|
*/
|
|
|
|
function getKeyInfo(keyPem) {
|
|
const result = {
|
|
isRSA: false,
|
|
isECDSA: false,
|
|
publicKey: crypto.createPublicKey(keyPem),
|
|
};
|
|
|
|
if (result.publicKey.asymmetricKeyType === 'rsa') {
|
|
result.isRSA = true;
|
|
}
|
|
else if (result.publicKey.asymmetricKeyType === 'ec') {
|
|
result.isECDSA = true;
|
|
}
|
|
else {
|
|
throw new Error('Unable to parse key information, unknown format');
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Generate a private RSA key
|
|
*
|
|
* @param {number} [modulusLength] Size of the keys modulus in bits, default: `2048`
|
|
* @returns {Promise<buffer>} PEM encoded private RSA key
|
|
*
|
|
* @example Generate private RSA key
|
|
* ```js
|
|
* const privateKey = await acme.crypto.createPrivateRsaKey();
|
|
* ```
|
|
*
|
|
* @example Private RSA key with modulus size 4096
|
|
* ```js
|
|
* const privateKey = await acme.crypto.createPrivateRsaKey(4096);
|
|
* ```
|
|
*/
|
|
|
|
export async function createPrivateRsaKey(modulusLength = 2048, encodingType = 'pkcs8') {
|
|
const pair = await generateKeyPair('rsa', {
|
|
modulusLength,
|
|
privateKeyEncoding: {
|
|
type: encodingType,
|
|
format: 'pem',
|
|
},
|
|
});
|
|
|
|
return Buffer.from(pair.privateKey);
|
|
}
|
|
|
|
|
|
/**
|
|
* Alias of `createPrivateRsaKey()`
|
|
*
|
|
* @function
|
|
*/
|
|
|
|
export const createPrivateKey = createPrivateRsaKey;
|
|
|
|
/**
|
|
* Generate a private ECDSA key
|
|
*
|
|
* @param {string} [namedCurve] ECDSA curve name (P-256, P-384 or P-521), default `P-256`
|
|
* @returns {Promise<buffer>} PEM encoded private ECDSA key
|
|
*
|
|
* @example Generate private ECDSA key
|
|
* ```js
|
|
* const privateKey = await acme.crypto.createPrivateEcdsaKey();
|
|
* ```
|
|
*
|
|
* @example Private ECDSA key using P-384 curve
|
|
* ```js
|
|
* const privateKey = await acme.crypto.createPrivateEcdsaKey('P-384');
|
|
* ```
|
|
*/
|
|
|
|
export const createPrivateEcdsaKey = async (namedCurve = 'P-256', encodingType = 'pkcs8') => {
|
|
const pair = await generateKeyPair('ec', {
|
|
namedCurve,
|
|
privateKeyEncoding: {
|
|
type: encodingType,
|
|
format: 'pem',
|
|
},
|
|
});
|
|
|
|
return Buffer.from(pair.privateKey);
|
|
};
|
|
|
|
/**
|
|
* Get a public key derived from a RSA or ECDSA key
|
|
*
|
|
* @param {buffer|string} keyPem PEM encoded private or public key
|
|
* @returns {buffer} PEM encoded public key
|
|
*
|
|
* @example Get public key
|
|
* ```js
|
|
* const publicKey = acme.crypto.getPublicKey(privateKey);
|
|
* ```
|
|
*/
|
|
|
|
export const getPublicKey = (keyPem) => {
|
|
const info = getKeyInfo(keyPem);
|
|
|
|
const publicKey = info.publicKey.export({
|
|
type: info.isECDSA ? 'spki' : 'pkcs1',
|
|
format: 'pem',
|
|
});
|
|
|
|
return Buffer.from(publicKey);
|
|
};
|
|
|
|
/**
|
|
* Get a JSON Web Key derived from a RSA or ECDSA key
|
|
*
|
|
* https://datatracker.ietf.org/doc/html/rfc7517
|
|
*
|
|
* @param {buffer|string} keyPem PEM encoded private or public key
|
|
* @returns {object} JSON Web Key
|
|
*
|
|
* @example Get JWK
|
|
* ```js
|
|
* const jwk = acme.crypto.getJwk(privateKey);
|
|
* ```
|
|
*/
|
|
|
|
export function getJwk(keyPem) {
|
|
const jwk = crypto.createPublicKey(keyPem).export({
|
|
format: 'jwk',
|
|
});
|
|
|
|
/* Sort keys */
|
|
return Object.keys(jwk).sort().reduce((result, k) => {
|
|
result[k] = jwk[k];
|
|
return result;
|
|
}, {});
|
|
}
|
|
|
|
|
|
/**
|
|
* Produce CryptoKeyPair and signing algorithm from a PEM encoded private key
|
|
*
|
|
* @private
|
|
* @param {buffer|string} keyPem PEM encoded private key
|
|
* @returns {Promise<array>} [keyPair, signingAlgorithm]
|
|
*/
|
|
|
|
async function getWebCryptoKeyPair(keyPem) {
|
|
const info = getKeyInfo(keyPem);
|
|
const jwk = getJwk(keyPem);
|
|
|
|
/* Signing algorithm */
|
|
const sigalg = {
|
|
name: 'RSASSA-PKCS1-v1_5',
|
|
hash: { name: 'SHA-256' },
|
|
};
|
|
|
|
if (info.isECDSA) {
|
|
sigalg.name = 'ECDSA';
|
|
sigalg.namedCurve = jwk.crv;
|
|
|
|
if (jwk.crv === 'P-384') {
|
|
sigalg.hash.name = 'SHA-384';
|
|
}
|
|
|
|
if (jwk.crv === 'P-521') {
|
|
sigalg.hash.name = 'SHA-512';
|
|
}
|
|
}
|
|
|
|
/* Decode PEM and import into CryptoKeyPair */
|
|
const privateKeyDec = x509.PemConverter.decodeFirst(keyPem.toString());
|
|
const privateKey = await crypto.webcrypto.subtle.importKey('pkcs8', privateKeyDec, sigalg, true, ['sign']);
|
|
const publicKey = await crypto.webcrypto.subtle.importKey('jwk', jwk, sigalg, true, ['verify']);
|
|
|
|
return [{ privateKey, publicKey }, sigalg];
|
|
}
|
|
|
|
/**
|
|
* Split chain of PEM encoded objects from string into array
|
|
*
|
|
* @param {buffer|string} chainPem PEM encoded object chain
|
|
* @returns {string[]} Array of PEM objects including headers
|
|
*/
|
|
|
|
export function splitPemChain(chainPem) {
|
|
if (Buffer.isBuffer(chainPem)) {
|
|
chainPem = chainPem.toString();
|
|
}
|
|
|
|
/* Decode into array and re-encode */
|
|
return x509.PemConverter.decodeWithHeaders(chainPem)
|
|
.map((params) => x509.PemConverter.encode([params]));
|
|
}
|
|
|
|
|
|
/**
|
|
* Parse body of PEM encoded object and return a Base64URL string
|
|
* If multiple objects are chained, the first body will be returned
|
|
*
|
|
* @param {buffer|string} pem PEM encoded chain or object
|
|
* @returns {string} Base64URL-encoded body
|
|
*/
|
|
|
|
export const getPemBodyAsB64u = (pem) => {
|
|
const chain = splitPemChain(pem);
|
|
|
|
if (!chain.length) {
|
|
throw new Error('Unable to parse PEM body from string');
|
|
}
|
|
|
|
/* Select first object, extract body and convert to b64u */
|
|
const dec = x509.PemConverter.decodeFirst(chain[0]);
|
|
return Buffer.from(dec).toString('base64url');
|
|
};
|
|
|
|
/**
|
|
* Parse domains from a certificate or CSR
|
|
*
|
|
* @private
|
|
* @param {object} input x509.Certificate or x509.Pkcs10CertificateRequest
|
|
* @returns {object} {commonName, altNames}
|
|
*/
|
|
|
|
function parseDomains(input) {
|
|
const commonName = input.subjectName.getField('CN').pop() || null;
|
|
const altNamesRaw = input.getExtension(subjectAltNameOID);
|
|
let altNames = [];
|
|
|
|
if (altNamesRaw) {
|
|
const altNamesExt = new x509.SubjectAlternativeNameExtension(altNamesRaw.rawData);
|
|
altNames = altNames.concat(altNamesExt.names.items.map((i) => i.value));
|
|
}
|
|
|
|
return {
|
|
commonName,
|
|
altNames,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Read domains from a Certificate Signing Request
|
|
*
|
|
* @param {buffer|string} csrPem PEM encoded Certificate Signing Request
|
|
* @returns {object} {commonName, altNames}
|
|
*
|
|
* @example Read Certificate Signing Request domains
|
|
* ```js
|
|
* const { commonName, altNames } = acme.crypto.readCsrDomains(certificateRequest);
|
|
*
|
|
* console.log(`Common name: ${commonName}`);
|
|
* console.log(`Alt names: ${altNames.join(', ')}`);
|
|
* ```
|
|
*/
|
|
|
|
export const readCsrDomains = (csrPem) => {
|
|
if (Buffer.isBuffer(csrPem)) {
|
|
csrPem = csrPem.toString();
|
|
}
|
|
const dec = x509.PemConverter.decodeFirst(csrPem);
|
|
const csr = new x509.Pkcs10CertificateRequest(dec);
|
|
return parseDomains(csr);
|
|
};
|
|
|
|
/**
|
|
* Read information from a certificate
|
|
* If multiple certificates are chained, the first will be read
|
|
*
|
|
* @param {buffer|string} certPem PEM encoded certificate or chain
|
|
* @returns {object} Certificate info
|
|
*
|
|
* @example Read certificate information
|
|
* ```js
|
|
* const info = acme.crypto.readCertificateInfo(certificate);
|
|
* const { commonName, altNames } = info.domains;
|
|
*
|
|
* console.log(`Not after: ${info.notAfter}`);
|
|
* console.log(`Not before: ${info.notBefore}`);
|
|
*
|
|
* console.log(`Common name: ${commonName}`);
|
|
* console.log(`Alt names: ${altNames.join(', ')}`);
|
|
* ```
|
|
*/
|
|
|
|
export const readCertificateInfo = (certPem) => {
|
|
if (Buffer.isBuffer(certPem)) {
|
|
certPem = certPem.toString();
|
|
}
|
|
|
|
const dec = x509.PemConverter.decodeFirst(certPem);
|
|
const cert = new x509.X509Certificate(dec);
|
|
|
|
return {
|
|
issuer: {
|
|
commonName: cert.issuerName.getField('CN').pop() || null,
|
|
},
|
|
domains: parseDomains(cert),
|
|
notBefore: cert.notBefore,
|
|
notAfter: cert.notAfter,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Determine ASN.1 character string type for CSR subject field name
|
|
*
|
|
* https://datatracker.ietf.org/doc/html/rfc5280
|
|
* https://github.com/PeculiarVentures/x509/blob/ecf78224fd594abbc2fa83c41565d79874f88e00/src/name.ts#L65-L71
|
|
*
|
|
* @private
|
|
* @param {string} field CSR subject field name
|
|
* @returns {string} ASN.1 character string type
|
|
*/
|
|
|
|
function getCsrAsn1CharStringType(field) {
|
|
switch (field) {
|
|
case 'C':
|
|
return 'printableString';
|
|
case 'E':
|
|
return 'ia5String';
|
|
default:
|
|
return 'utf8String';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create array of subject fields for a Certificate Signing Request
|
|
*
|
|
* https://github.com/PeculiarVentures/x509/blob/ecf78224fd594abbc2fa83c41565d79874f88e00/src/name.ts#L65-L71
|
|
*
|
|
* @private
|
|
* @param {object} input Key-value of subject fields
|
|
* @returns {object[]} Certificate Signing Request subject array
|
|
*/
|
|
|
|
function createCsrSubject(input) {
|
|
return Object.entries(input).reduce((result, [type, value]) => {
|
|
if (value) {
|
|
const ds = getCsrAsn1CharStringType(type);
|
|
result.push({ [type]: [{ [ds]: value }] });
|
|
}
|
|
|
|
return result;
|
|
}, []);
|
|
}
|
|
|
|
/**
|
|
* Create x509 subject alternate name extension
|
|
*
|
|
* https://github.com/PeculiarVentures/x509/blob/ecf78224fd594abbc2fa83c41565d79874f88e00/src/extensions/subject_alt_name.ts
|
|
*
|
|
* @private
|
|
* @param {string[]} altNames Array of alt names
|
|
* @returns {x509.SubjectAlternativeNameExtension} Subject alternate name extension
|
|
*/
|
|
|
|
function createSubjectAltNameExtension(altNames) {
|
|
return new x509.SubjectAlternativeNameExtension(altNames.map((value) => {
|
|
const type = net.isIP(value) ? 'ip' : 'dns';
|
|
return { type, value };
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Create a Certificate Signing Request
|
|
*
|
|
* @param {object} data
|
|
* @param {number} [data.keySize] Size of newly created RSA private key modulus in bits, default: `2048`
|
|
* @param {string} [data.commonName] FQDN of your server
|
|
* @param {string[]} [data.altNames] SAN (Subject Alternative Names), default: `[]`
|
|
* @param {string} [data.country] 2 letter country code
|
|
* @param {string} [data.state] State or province
|
|
* @param {string} [data.locality] City
|
|
* @param {string} [data.organization] Organization name
|
|
* @param {string} [data.organizationUnit] Organizational unit name
|
|
* @param {string} [data.emailAddress] Email address
|
|
* @param {buffer|string} [keyPem] PEM encoded CSR private key
|
|
* @returns {Promise<buffer[]>} [privateKey, certificateSigningRequest]
|
|
*
|
|
* @example Create a Certificate Signing Request
|
|
* ```js
|
|
* const [certificateKey, certificateRequest] = await acme.crypto.createCsr({
|
|
* altNames: ['test.example.com'],
|
|
* });
|
|
* ```
|
|
*
|
|
* @example Certificate Signing Request with both common and alternative names
|
|
* > *Warning*: Certificate subject common name has been [deprecated](https://letsencrypt.org/docs/glossary/#def-CN) and its use is [discouraged](https://cabforum.org/uploads/BRv1.2.3.pdf).
|
|
* ```js
|
|
* const [certificateKey, certificateRequest] = await acme.crypto.createCsr({
|
|
* keySize: 4096,
|
|
* commonName: 'test.example.com',
|
|
* altNames: ['foo.example.com', 'bar.example.com'],
|
|
* });
|
|
* ```
|
|
*
|
|
* @example Certificate Signing Request with additional information
|
|
* ```js
|
|
* const [certificateKey, certificateRequest] = await acme.crypto.createCsr({
|
|
* altNames: ['test.example.com'],
|
|
* country: 'US',
|
|
* state: 'California',
|
|
* locality: 'Los Angeles',
|
|
* organization: 'The Company Inc.',
|
|
* organizationUnit: 'IT Department',
|
|
* emailAddress: 'contact@example.com',
|
|
* });
|
|
* ```
|
|
*
|
|
* @example Certificate Signing Request with ECDSA private key
|
|
* ```js
|
|
* const certificateKey = await acme.crypto.createPrivateEcdsaKey();
|
|
*
|
|
* const [, certificateRequest] = await acme.crypto.createCsr({
|
|
* altNames: ['test.example.com'],
|
|
* }, certificateKey);
|
|
* ```
|
|
*/
|
|
|
|
export const createCsr = async (data, keyPem = null) => {
|
|
if (!keyPem) {
|
|
keyPem = await createPrivateRsaKey(data.keySize);
|
|
}
|
|
else if (!Buffer.isBuffer(keyPem)) {
|
|
keyPem = Buffer.from(keyPem);
|
|
}
|
|
|
|
if (typeof data.altNames === 'undefined') {
|
|
data.altNames = [];
|
|
}
|
|
|
|
/* Ensure subject common name is present in SAN - https://cabforum.org/wp-content/uploads/BRv1.2.3.pdf */
|
|
if (data.commonName && !data.altNames.includes(data.commonName)) {
|
|
data.altNames.unshift(data.commonName);
|
|
}
|
|
|
|
/* CryptoKeyPair and signing algorithm from private key */
|
|
const [keys, signingAlgorithm] = await getWebCryptoKeyPair(keyPem);
|
|
|
|
const extensions = [
|
|
/* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.3 */
|
|
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment), // eslint-disable-line no-bitwise
|
|
|
|
/* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.6 */
|
|
createSubjectAltNameExtension(data.altNames),
|
|
];
|
|
|
|
/* Create CSR */
|
|
const csr = await x509.Pkcs10CertificateRequestGenerator.create({
|
|
keys,
|
|
extensions,
|
|
signingAlgorithm,
|
|
name: createCsrSubject({
|
|
CN: data.commonName,
|
|
C: data.country,
|
|
ST: data.state,
|
|
L: data.locality,
|
|
O: data.organization,
|
|
OU: data.organizationUnit,
|
|
E: data.emailAddress,
|
|
}),
|
|
});
|
|
|
|
/* Done */
|
|
const pem = csr.toString('pem');
|
|
return [keyPem, Buffer.from(pem)];
|
|
};
|
|
|
|
/**
|
|
* Create a self-signed ALPN certificate for TLS-ALPN-01 challenges
|
|
*
|
|
* https://datatracker.ietf.org/doc/html/rfc8737
|
|
*
|
|
* @param {object} authz Identifier authorization
|
|
* @param {string} keyAuthorization Challenge key authorization
|
|
* @param {buffer|string} [keyPem] PEM encoded CSR private key
|
|
* @returns {Promise<buffer[]>} [privateKey, certificate]
|
|
*
|
|
* @example Create a ALPN certificate
|
|
* ```js
|
|
* const [alpnKey, alpnCertificate] = await acme.crypto.createAlpnCertificate(authz, keyAuthorization);
|
|
* ```
|
|
*
|
|
* @example Create a ALPN certificate with ECDSA private key
|
|
* ```js
|
|
* const alpnKey = await acme.crypto.createPrivateEcdsaKey();
|
|
* const [, alpnCertificate] = await acme.crypto.createAlpnCertificate(authz, keyAuthorization, alpnKey);
|
|
* ```
|
|
*/
|
|
|
|
export const createAlpnCertificate = async (authz, keyAuthorization, keyPem = null) => {
|
|
if (!keyPem) {
|
|
keyPem = await createPrivateRsaKey();
|
|
}
|
|
else if (!Buffer.isBuffer(keyPem)) {
|
|
keyPem = Buffer.from(keyPem);
|
|
}
|
|
|
|
const now = new Date();
|
|
const commonName = authz.identifier.value;
|
|
|
|
/* Pseudo-random serial - max 20 bytes, 11 for epoch (year 5138), 9 random */
|
|
const random = await randomInt(1, 999999999);
|
|
const serialNumber = `${Math.floor(now.getTime() / 1000)}${random}`;
|
|
|
|
/* CryptoKeyPair and signing algorithm from private key */
|
|
const [keys, signingAlgorithm] = await getWebCryptoKeyPair(keyPem);
|
|
|
|
const extensions = [
|
|
/* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.3 */
|
|
new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), // eslint-disable-line no-bitwise
|
|
|
|
/* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.9 */
|
|
new x509.BasicConstraintsExtension(true, 2, true),
|
|
|
|
/* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.2 */
|
|
await x509.SubjectKeyIdentifierExtension.create(keys.publicKey),
|
|
|
|
/* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.6 */
|
|
createSubjectAltNameExtension([commonName]),
|
|
];
|
|
|
|
/* ALPN extension */
|
|
const payload = crypto.createHash('sha256').update(keyAuthorization).digest('hex');
|
|
const octstr = new asn1js.OctetString({ valueHex: Buffer.from(payload, 'hex') });
|
|
extensions.push(new x509.Extension(alpnAcmeIdentifierOID, true, octstr.toBER()));
|
|
|
|
/* Self-signed ALPN certificate */
|
|
const cert = await x509.X509CertificateGenerator.createSelfSigned({
|
|
keys,
|
|
signingAlgorithm,
|
|
extensions,
|
|
serialNumber,
|
|
notBefore: now,
|
|
notAfter: now,
|
|
name: createCsrSubject({
|
|
CN: commonName,
|
|
}),
|
|
});
|
|
|
|
/* Done */
|
|
const pem = cert.toString('pem');
|
|
return [keyPem, Buffer.from(pem)];
|
|
};
|
|
|
|
/**
|
|
* Validate that a ALPN certificate contains the expected key authorization
|
|
*
|
|
* @param {buffer|string} certPem PEM encoded certificate
|
|
* @param {string} keyAuthorization Expected challenge key authorization
|
|
* @returns {boolean} True when valid
|
|
*/
|
|
|
|
export const isAlpnCertificateAuthorizationValid = (certPem, keyAuthorization) => {
|
|
const expected = crypto.createHash('sha256').update(keyAuthorization).digest('hex');
|
|
|
|
/* Attempt to locate ALPN extension */
|
|
const cert = new x509.X509Certificate(certPem);
|
|
const ext = cert.getExtension(alpnAcmeIdentifierOID);
|
|
|
|
if (!ext) {
|
|
throw new Error('Unable to locate ALPN extension within parsed certificate');
|
|
}
|
|
|
|
/* Decode extension value */
|
|
const parsed = asn1js.fromBER(ext.value);
|
|
const result = Buffer.from(parsed.result.valueBlock.valueHexView).toString('hex');
|
|
|
|
/* Return true if match */
|
|
return (result === expected);
|
|
};
|