lib/ClientCertFactory.js

/** @module ClientCertFactory */

const node_ssl = require("node-openssl-cert");
const ASN1 = require("asn1js");
const Crypto = require("crypto");

/**
 * @param {...any[]} arguments The arguments to the openSSL function, minus the callback at the end.
 * @returns {Promise} A tuple with the result of the function in the first spot, and
 * the OpenSSL comment used in the second. The promise will reject if the OpenSSL command fails.
 */
function promisifyOpenSSL() {
    const functionPtr = arguments[0];
    const otherArgs = Array.prototype.slice.call(arguments, 1);
    return new Promise((resolve, reject) =>
        functionPtr.apply(null, 
            otherArgs.concat([(err, key, cmd) => err ? reject(err) : resolve([key, cmd])])));
}

/**
 * Remove the `-----BEGIN ...-----` statements and newlines from a PEM file.
 * @param {string} pem a PEM file
 * @returns {string} a raw base64 string
 */
function stripPEM(pem) {
    return pem.replace(/-----[A-Z,\s]+-----/g, "").replace(/\r?\n|\r/g, "");
}

/**
 * Convert a PEM file with `-----BEGIN ...-----` padding into a base64 buffer, to be parsed for data.
 * @param {string} pem a ASN1 structure in PEM format
 * @returns {ArrayBuffer} An ArrayBuffer containing the data. 
 */
function PEM2BUF(pem) {
    const nodeBuf = Buffer.from(stripPEM(pem), "base64");
    return nodeBuf.buffer.slice(nodeBuf.byteOffset, nodeBuf.byteOffset + nodeBuf.byteLength);
}

class ClientCertFactory {

    /**
     * ClientCertFactory properties.
     * This function will throw an exception if the OpenSSL binary is not found.
     * @param {string} binpath A path to the OpenSSL binary, global if falsey 
     * @param {string} root_cert The PEM string representing the root certificate authority.
     * @param {Array} domains An array of DNS names to allow the certificates to authenticate.
     * @param {string} hash A string representing the hash function to sign the certificate with (ex. sha256)
     * @param {string} curve The name of the eliptical curve to use (from `openssl ecparam -list_curves`).
     * Different curves may or may not be supported by the version of OpenSSL you are using.
     * @param {number} lifetime_days The number of days to issue certificates for. 
     * Certificates will automatically be valid from the time issued
     * @param {{ countryName: string, stateOrProvinceName: string, organizationName: string }} subject_base An object
     * specifiying some information to put on all the certificates.
     */
    constructor(    binpath,
                    root_cert,
                    domains = [],
                    hash = "sha256",
                    curve = "prime256v1",
                    lifetime_days = 200, 
                    subject_base = {
                        countryName: "US",
                        stateOrProvinceName: "Oregon",
                        organizationName: "Open Sensing Lab",
                    }) {
        
        // Initialize the OpenSSL wrapper with OpenSSL
        try {
            this.openssl = new node_ssl({ binpath });
        } catch (e) {
            throw new Error(`Invalid OpenSSL binpath: ${binpath}`);
        }
        // store the hash, lifetime, subject and domains to add to the
        // client certificate later on
        this.root_cert = root_cert;
        this.hash = hash;
        this.curve = curve;
        this.lifetime_days = lifetime_days;
        this.subjectBase = subject_base;
        this.domains = domains;
    }

    /**
     * Create a client certificate.
     * @param {string} root_priv_key The private key of the root certificate provided in the constructor, in PEM format.
     * This argument is passed here instead of the constructor to allow removing the key from memory when it is not in use.
     * @param {string} common_name Common name to use for the certificate, should be something unique/generated
     * @param {boolean} use_extensions Whether or not to use extensions restricting the use of the issued certificate.
     * @returns {{cert: string, key: string, key_raw: string, fingerprint: string}} An object with all of the generated values.
     * key is the private key in PEM format, key_raw is just the private key in base64, and fingerprint is the sha256 fingerprint
     * of the client certificate, in the format provided by the nodejs TLS engine.
     */
    async create_cert(root_priv_key, common_name, use_extensions = true) {
        // generate a unique private key
        const [key] = await promisifyOpenSSL(this.openssl.generateECCPrivateKey, { "curve" : this.curve });
        // create the properties required for a CSR
        const csr_opts = {
            hash: this.hash,
            days: this.lifetime_days,
            subject: Object.assign(this.subjectBase, { commonName: common_name }),
            extensions: use_extensions ? {
                basicConstraints: {
                    critical: true,
                    CA: false,
                    pathlen: 1
                },
                SANs: {
                    DNS: this.domains
                }
            } : undefined
        };
        // use those properties to create a CSR
        const [csr] = await promisifyOpenSSL(this.openssl.generateCSR, csr_opts, key, null);
        // and use that CSR to create a certificate!
        const [cert] = await promisifyOpenSSL(this.openssl.CASignCSR, csr, csr_opts, false, this.root_cert, root_priv_key, null);
        // extract the actual private key from the PEM private key for the embdedded device
        const asn1_key = ASN1.fromBER(PEM2BUF(key));
        let key_raw;
        if (asn1_key.offset !== -1) {
            const decoded_priv_key = asn1_key.result.valueBlock.value[1].valueBlock.valueHex;
            key_raw = Buffer.from(decoded_priv_key).toString("base64");
        }
        else throw new Error(`Unable to extract the private key from the following PEM: ${key}`);
        // and finally calculate the fingerprint of the client cert for use later
        const cert_der = Buffer.from(stripPEM(cert), "base64");
        const hash = Crypto.createHash("sha256");
        hash.update(cert_der);
        const fingerprint = Array.from(new Uint8Array(hash.digest().buffer)).map(v => v.toString(16).padStart(2, "0")).join(":").toUpperCase();
        // return all of the generated strings!
        return {
            certificate: cert,
            key: key,
            key_raw: key_raw,
            fingerprint: fingerprint
        }
    }
}

module.exports = ClientCertFactory;