utils.js 7.29 KB
'use strict';

var U = module.exports;

var promisify = require('util').promisify;
//var resolveSoa = promisify(require('dns').resolveSoa);
var resolveMx = promisify(require('dns').resolveMx);
var punycode = require('punycode');
var Keypairs = require('@root/keypairs');
// TODO move to @root
var certParser = require('cert-info');

U._parseDuration = function(str) {
    if ('number' === typeof str) {
        return str;
    }

    var pattern = /^(\-?\d+(\.\d+)?)([wdhms]|ms)$/;
    var matches = str.match(pattern);
    if (!matches || !matches[0]) {
        throw new Error('invalid duration string: ' + str);
    }

    var n = parseInt(matches[1], 10);
    var unit = matches[3];

    switch (unit) {
        case 'w':
            n *= 7;
        /*falls through*/
        case 'd':
            n *= 24;
        /*falls through*/
        case 'h':
            n *= 60;
        /*falls through*/
        case 'm':
            n *= 60;
        /*falls through*/
        case 's':
            n *= 1000;
        /*falls through*/
        case 'ms':
            n *= 1; // for completeness
    }

    return n;
};

U._encodeName = function(str) {
    return punycode.toASCII(str.toLowerCase(str));
};

U._validName = function(str) {
    // A quick check of the 38 and two ½ valid characters
    // 253 char max full domain, including dots
    // 63 char max each label segment
    // Note: * is not allowed, but it's allowable here
    // Note: _ (underscore) is only allowed for "domain names", not "hostnames"
    // Note: - (hyphen) is not allowed as a first character (but a number is)
    return (
        /^(\*\.)?[a-z0-9_\.\-]+\.[a-z0-9_\.\-]+$/.test(str) &&
        str.length < 254 &&
        str.split('.').every(function(label) {
            return label.length > 0 && label.length < 64;
        })
    );
};

U._validMx = function(email) {
    var host = email.split('@').slice(1)[0];
    // try twice, just because DNS hiccups sometimes
    // Note: we don't care if the domain exists, just that it *can* exist
    return resolveMx(host).catch(function() {
        return U._timeout(1000).then(function() {
            return resolveMx(host);
        });
    });
};

// should be called after _validName
U._validDomain = function(str) {
    // TODO use @root/dns (currently dns-suite)
    // because node's dns can't read Authority records
    return Promise.resolve(str);
    /*
	// try twice, just because DNS hiccups sometimes
	// Note: we don't care if the domain exists, just that it *can* exist
	return resolveSoa(str).catch(function() {
		return U._timeout(1000).then(function() {
			return resolveSoa(str);
		});
	});
  */
};

// foo.example.com and *.example.com overlap
// should be called after _validName
// (which enforces *. or no *)
U._uniqueNames = function(altnames) {
    var dups = {};
    var wilds = {};
    if (
        altnames.some(function(w) {
            if ('*.' !== w.slice(0, 2)) {
                return;
            }
            if (wilds[w]) {
                return true;
            }
            wilds[w] = true;
        })
    ) {
        return false;
    }

    return altnames.every(function(name) {
        var w;
        if ('*.' !== name.slice(0, 2)) {
            w =
                '*.' +
                name
                    .split('.')
                    .slice(1)
                    .join('.');
        } else {
            return true;
        }

        if (!dups[name] && !dups[w]) {
            dups[name] = true;
            return true;
        }
    });
};

U._timeout = function(d) {
    return new Promise(function(resolve) {
        setTimeout(resolve, d);
    });
};

U._genKeypair = function(keyType) {
    var keyopts;
    var len = parseInt(keyType.replace(/.*?(\d)/, '$1') || 0, 10);
    if (/RSA/.test(keyType)) {
        keyopts = {
            kty: 'RSA',
            modulusLength: len || 2048
        };
    } else if (/^(EC|P\-?\d)/i.test(keyType)) {
        keyopts = {
            kty: 'EC',
            namedCurve: 'P-' + (len || 256)
        };
    } else {
        // TODO put in ./errors.js
        throw new Error('invalid key type: ' + keyType);
    }

    return Keypairs.generate(keyopts).then(function(pair) {
        return U._jwkToSet(pair.private);
    });
};

// TODO use ACME._importKeypair ??
U._importKeypair = function(keypair) {
    // this should import all formats equally well:
    // 'object' (JWK), 'string' (private key pem), kp.privateKeyPem, kp.privateKeyJwk
    if (keypair.private || keypair.d) {
        return U._jwkToSet(keypair.private || keypair);
    }
    if (keypair.privateKeyJwk) {
        return U._jwkToSet(keypair.privateKeyJwk);
    }

    if ('string' !== typeof keypair && !keypair.privateKeyPem) {
        // TODO put in errors
        throw new Error('missing private key');
    }

    return Keypairs.import({ pem: keypair.privateKeyPem || keypair }).then(
        function(priv) {
            if (!priv.d) {
                throw new Error('missing private key');
            }
            return U._jwkToSet(priv);
        }
    );
};

U._jwkToSet = function(jwk) {
    var keypair = {
        privateKeyJwk: jwk
    };
    return Promise.all([
        Keypairs.export({
            jwk: jwk,
            encoding: 'pem'
        }).then(function(pem) {
            keypair.privateKeyPem = pem;
        }),
        Keypairs.export({
            jwk: jwk,
            encoding: 'pem',
            public: true
        }).then(function(pem) {
            keypair.publicKeyPem = pem;
        }),
        Keypairs.publish({
            jwk: jwk
        }).then(function(pub) {
            keypair.publicKeyJwk = pub;
        })
    ]).then(function() {
        return keypair;
    });
};

U._attachCertInfo = function(results) {
    var certInfo = certParser.info(results.cert);

    // subject, altnames, issuedAt, expiresAt
    Object.keys(certInfo).forEach(function(key) {
        results[key] = certInfo[key];
    });

    return results;
};

U._certHasDomain = function(certInfo, _domain) {
    var names = (certInfo.altnames || []).slice(0);
    return names.some(function(name) {
        var domain = _domain.toLowerCase();
        name = name.toLowerCase();
        if ('*.' === name.substr(0, 2)) {
            name = name.substr(2);
            domain = domain
                .split('.')
                .slice(1)
                .join('.');
        }
        return name === domain;
    });
};

// a bit heavy to be labeled 'utils'... perhaps 'common' would be better?
U._getOrCreateKeypair = function(db, subject, query, keyType, mustExist) {
    var exists = false;
    return db
        .checkKeypair(query)
        .then(function(kp) {
            if (kp) {
                exists = true;
                return U._importKeypair(kp);
            }

            if (mustExist) {
                // TODO put in errors
                throw new Error(
                    'required keypair not found: ' +
                        (subject || '') +
                        ' ' +
                        JSON.stringify(query)
                );
            }

            return U._genKeypair(keyType);
        })
        .then(function(keypair) {
            return { exists: exists, keypair: keypair };
        });
};

U._getKeypair = function(db, subject, query) {
    return U._getOrCreateKeypair(db, subject, query, '', true).then(function(
        result
    ) {
        return result.keypair;
    });
};