diff --git a/app.js b/app.js index 7619d43..c8a5332 100644 --- a/app.js +++ b/app.js @@ -1,3 +1,4 @@ +/*global Promise*/ (function () { 'use strict'; @@ -47,8 +48,8 @@ $$('button').map(function ($el) { $el.disabled = true; }); var opts = { kty: $('input[name="kty"]:checked').value - , namedCurve: $('input[name="ec-crv"]:checked').value - , modulusLength: $('input[name="rsa-len"]:checked').value + , namedCurve: $('input[name="ec-crv"]:checked').value + , modulusLength: $('input[name="rsa-len"]:checked').value }; console.log('opts', opts); Keypairs.generate(opts).then(function (results) { @@ -112,15 +113,56 @@ }); acme.init('https://acme-staging-v02.api.letsencrypt.org/directory').then(function (result) { console.log('acme result', result); + var privJwk = JSON.parse($('.js-jwk').innerText).private; + var email = $('.js-email').innerText; + function checkTos(tos) { + console.log("TODO checkbox for agree to terms"); + return tos; + } return acme.accounts.create({ - email: $('.js-email').innerText - , agreeToTerms: function (tos) { - console.log("TODO checkbox for agree to terms"); - return tos; - } - , accountKeypair: { - privateKeyJwk: JSON.parse($('.js-jwk').innerText).private - } + email: email + , agreeToTerms: checkTos + , accountKeypair: { privateKeyJwk: privJwk } + }).then(function (account) { + console.log("account created result:", account); + return Keypairs.generate({ + kty: 'RSA' + , modulusLength: 2048 + }).then(function (pair) { + console.log('domain keypair:', pair); + var domains = ($('.js-domains').innerText||'example.com').split(/[, ]+/g); + return acme.certificates.create({ + accountKeypair: { privateKeyJwk: privJwk } + , account: account + , domainKeypair: { privateKeyJwk: pair.private } + , email: email + , domains: domains + , agreeToTerms: checkTos + , challenges: { + 'dns-01': { + set: function (opts) { + console.log('dns-01 set challenge:'); + console.log(JSON.stringify(opts, null, 2)); + return new Promise(function (resolve) { + while (!window.confirm("Did you set the challenge?")) {} + resolve(); + }); + } + , remove: function (opts) { + console.log('dns-01 remove challenge:'); + console.log(JSON.stringify(opts, null, 2)); + return new Promise(function (resolve) { + while (!window.confirm("Did you delete the challenge?")) {} + resolve(); + }); + } + } + } + }); + }); + }).catch(function (err) { + console.error("A bad thing happened:"); + console.error(err); }); }); }); diff --git a/index.html b/index.html index 013e3b4..b4d91c8 100644 --- a/index.html +++ b/index.html @@ -63,6 +63,10 @@
diff --git a/lib/acme.js b/lib/acme.js index 867d3dc..cfb20fe 100644 --- a/lib/acme.js +++ b/lib/acme.js @@ -7,7 +7,7 @@ /* globals Promise */ var ACME = exports.ACME = {}; -var Keypairs = exports.Keypairs || {}; +//var Keypairs = exports.Keypairs || {}; var Enc = exports.Enc || {}; var Crypto = exports.Crypto || {}; @@ -90,7 +90,7 @@ ACME._getNonce = function (me) { break; } } - if (nonce) { return Promise.resolve(nonce); } + if (nonce) { return Promise.resolve(nonce.nonce); } return me.request({ method: 'HEAD', url: me._directoryUrls.newNonce }).then(function (resp) { return resp.headers['replay-nonce']; }); @@ -132,26 +132,7 @@ ACME._registerAccount = function (me, options) { return; } - var jwk = options.accountKeypair.privateKeyJwk; - var p; - if (jwk) { - // nix the browser jwk extras - jwk.key_ops = undefined; - jwk.ext = undefined; - p = Promise.resolve({ private: jwk, public: Keypairs.neuter({ jwk: jwk }) }); - } else { - p = Keypairs.import({ pem: options.accountKeypair.privateKeyPem }); - } - return p.then(function (pair) { - options.accountKeypair.privateKeyJwk = pair.private; - options.accountKeypair.publicKeyJwk = pair.public; - if (pair.public.kid) { - pair = JSON.parse(JSON.stringify(pair)); - delete pair.public.kid; - delete pair.private.kid; - } - return pair; - }).then(function (pair) { + return ACME._importKeypair(me, options.accountKeypair).then(function (pair) { var contact; if (options.contact) { contact = options.contact.slice(0); @@ -209,7 +190,7 @@ ACME._registerAccount = function (me, options) { status: 'valid' } */ - if (!account) { account = { _emptyResponse: true, key: {} }; } + if (!account) { account = { _emptyResponse: true }; } // https://git.coolaj86.com/coolaj86/acme-v2.js/issues/8 if (!account.key) { account.key = {}; } account.key.kid = options._kid; @@ -346,9 +327,10 @@ ACME._testChallenges = function (me, options) { , wildcard: identifierValue.includes('*.') || undefined }; var dryrun = true; - var auth = ACME._challengeToAuth(me, options, results, challenge, dryrun); - return ACME._setChallenge(me, options, auth).then(function () { - return auth; + return ACME._challengeToAuth(me, options, results, challenge, dryrun).then(function (auth) { + return ACME._setChallenge(me, options, auth).then(function () { + return auth; + }); }); }); })).then(function (auths) { @@ -402,17 +384,19 @@ ACME._challengeToAuth = function (me, options, request, challenge, dryrun) { auth.hostname = auth.identifier.value; // because I'm not 100% clear if the wildcard identifier does or doesn't have the leading *. in all cases auth.altname = ACME._untame(auth.identifier.value, auth.wildcard); - return me.Keypairs.thumbprint({ jwk: options.accountKeypair.publicKeyJwk }).then(function (thumb) { - auth.thumbprint = thumb; - // keyAuthorization = token || '.' || base64url(JWK_Thumbprint(accountKey)) - auth.keyAuthorization = challenge.token + '.' + auth.thumbprint; - // conflicts with ACME challenge id url is already in use, so we call this challengeUrl instead - auth.challengeUrl = 'http://' + auth.identifier.value + ACME.challengePrefixes['http-01'] + '/' + auth.token; - auth.dnsHost = dnsPrefix + '.' + auth.hostname.replace('*.', ''); + return ACME._importKeypair(me, options.accountKeypair).then(function (pair) { + return me.Keypairs.thumbprint({ jwk: pair.public }).then(function (thumb) { + auth.thumbprint = thumb; + // keyAuthorization = token || '.' || base64url(JWK_Thumbprint(accountKey)) + auth.keyAuthorization = challenge.token + '.' + auth.thumbprint; + // conflicts with ACME challenge id url is already in use, so we call this challengeUrl instead + auth.challengeUrl = 'http://' + auth.identifier.value + ACME.challengePrefixes['http-01'] + '/' + auth.token; + auth.dnsHost = dnsPrefix + '.' + auth.hostname.replace('*.', ''); - return Crypto._sha('sha256', auth.keyAuthorization).then(function (hash) { - auth.dnsAuthorization = hash; - return auth; + return Crypto._sha('sha256', auth.keyAuthorization).then(function (hash) { + auth.dnsAuthorization = hash; + return auth; + }); }); }); }; @@ -542,15 +526,20 @@ ACME._postChallenge = function (me, options, auth) { return respondToChallenge(); }; ACME._setChallenge = function (me, options, auth) { + console.log('challenge auth:', auth); + console.log('challenges:', options.challenges); return new Promise(function (resolve, reject) { + var challengers = options.challenges || {}; + var challenger = (challengers[auth.type] && challengers[auth.type].set) || options.setChallenge; try { - if (1 === options.setChallenge.length) { - options.setChallenge(auth).then(resolve).catch(reject); - } else if (2 === options.setChallenge.length) { - options.setChallenge(auth, function (err) { + if (1 === challenger.length) { + challenger(auth).then(resolve).catch(reject); + } else if (2 === challenger.length) { + challenger(auth, function (err) { if(err) { reject(err); } else { resolve(); } }); } else { + // TODO remove this old backwards-compat var challengeCb = function(err) { if(err) { reject(err); } else { resolve(); } }; @@ -563,7 +552,7 @@ ACME._setChallenge = function (me, options, auth) { console.warn("The API has been changed for compatibility with all ACME / Let's Encrypt challenge types."); ACME._setChallengeWarn = true; } - options.setChallenge(auth.identifier.value, auth.token, auth.keyAuthorization, challengeCb); + challenger(auth.identifier.value, auth.token, auth.keyAuthorization, challengeCb); } } catch(e) { reject(e); @@ -577,81 +566,82 @@ ACME._setChallenge = function (me, options, auth) { }; ACME._finalizeOrder = function (me, options, validatedDomains) { if (me.debug) { console.debug('finalizeOrder:'); } - var csr = me.Keypairs.generateCsrWeb64(options.domainKeypair, validatedDomains); - var body = { csr: csr }; - var payload = JSON.stringify(body); + return ACME._generateCsrWeb64(me, options, validatedDomains).then(function (csr) { + var body = { csr: csr }; + var payload = JSON.stringify(body); - function pollCert() { - if (me.debug) { console.debug('[acme-v2.js] pollCert:'); } - return ACME._jwsRequest({ - options: options - , url: options._finalize - , protected: { kid: options._kid } - , payload: Enc.strToBuf(payload) - }).then(function (resp) { - if (me.debug) { console.debug('order finalized: resp.body:'); } - if (me.debug) { console.debug(resp.body); } + function pollCert() { + if (me.debug) { console.debug('[acme-v2.js] pollCert:'); } + return ACME._jwsRequest({ + options: options + , url: options._finalize + , protected: { kid: options._kid } + , payload: Enc.strToBuf(payload) + }).then(function (resp) { + if (me.debug) { console.debug('order finalized: resp.body:'); } + if (me.debug) { console.debug(resp.body); } - // https://tools.ietf.org/html/draft-ietf-acme-acme-12#section-7.1.3 - // Possible values are: "pending" => ("invalid" || "ready") => "processing" => "valid" - if ('valid' === resp.body.status) { - options._expires = resp.body.expires; - options._certificate = resp.body.certificate; + // https://tools.ietf.org/html/draft-ietf-acme-acme-12#section-7.1.3 + // Possible values are: "pending" => ("invalid" || "ready") => "processing" => "valid" + if ('valid' === resp.body.status) { + options._expires = resp.body.expires; + options._certificate = resp.body.certificate; - return resp.body; // return order - } + return resp.body; // return order + } - if ('processing' === resp.body.status) { - return ACME._wait().then(pollCert); - } + if ('processing' === resp.body.status) { + return ACME._wait().then(pollCert); + } - if (me.debug) { console.debug("Error: bad status:\n" + JSON.stringify(resp.body, null, 2)); } + if (me.debug) { console.debug("Error: bad status:\n" + JSON.stringify(resp.body, null, 2)); } + + if ('pending' === resp.body.status) { + return Promise.reject(new Error( + "Did not finalize order: status 'pending'." + + " Best guess: You have not accepted at least one challenge for each domain:\n" + + "Requested: '" + options.domains.join(', ') + "'\n" + + "Validated: '" + validatedDomains.join(', ') + "'\n" + + JSON.stringify(resp.body, null, 2) + )); + } + + if ('invalid' === resp.body.status) { + return Promise.reject(new Error( + "Did not finalize order: status 'invalid'." + + " Best guess: One or more of the domain challenges could not be verified" + + " (or the order was canceled).\n" + + "Requested: '" + options.domains.join(', ') + "'\n" + + "Validated: '" + validatedDomains.join(', ') + "'\n" + + JSON.stringify(resp.body, null, 2) + )); + } + + if ('ready' === resp.body.status) { + return Promise.reject(new Error( + "Did not finalize order: status 'ready'." + + " Hmmm... this state shouldn't be possible here. That was the last state." + + " This one should at least be 'processing'.\n" + + "Requested: '" + options.domains.join(', ') + "'\n" + + "Validated: '" + validatedDomains.join(', ') + "'\n" + + JSON.stringify(resp.body, null, 2) + "\n\n" + + "Please open an issue at https://git.coolaj86.com/coolaj86/acme-v2.js" + )); + } - if ('pending' === resp.body.status) { return Promise.reject(new Error( - "Did not finalize order: status 'pending'." - + " Best guess: You have not accepted at least one challenge for each domain:\n" - + "Requested: '" + options.domains.join(', ') + "'\n" - + "Validated: '" + validatedDomains.join(', ') + "'\n" - + JSON.stringify(resp.body, null, 2) - )); - } - - if ('invalid' === resp.body.status) { - return Promise.reject(new Error( - "Did not finalize order: status 'invalid'." - + " Best guess: One or more of the domain challenges could not be verified" - + " (or the order was canceled).\n" - + "Requested: '" + options.domains.join(', ') + "'\n" - + "Validated: '" + validatedDomains.join(', ') + "'\n" - + JSON.stringify(resp.body, null, 2) - )); - } - - if ('ready' === resp.body.status) { - return Promise.reject(new Error( - "Did not finalize order: status 'ready'." - + " Hmmm... this state shouldn't be possible here. That was the last state." - + " This one should at least be 'processing'.\n" + "Didn't finalize order: Unhandled status '" + resp.body.status + "'." + + " This is not one of the known statuses...\n" + "Requested: '" + options.domains.join(', ') + "'\n" + "Validated: '" + validatedDomains.join(', ') + "'\n" + JSON.stringify(resp.body, null, 2) + "\n\n" + "Please open an issue at https://git.coolaj86.com/coolaj86/acme-v2.js" )); - } + }); + } - return Promise.reject(new Error( - "Didn't finalize order: Unhandled status '" + resp.body.status + "'." - + " This is not one of the known statuses...\n" - + "Requested: '" + options.domains.join(', ') + "'\n" - + "Validated: '" + validatedDomains.join(', ') + "'\n" - + JSON.stringify(resp.body, null, 2) + "\n\n" - + "Please open an issue at https://git.coolaj86.com/coolaj86/acme-v2.js" - )); - }); - } - - return pollCert(); + return pollCert(); + }); }; // _kid // registerAccount @@ -686,16 +676,18 @@ ACME._getCertificate = function (me, options) { } if (!(options.domains && options.domains.length)) { return Promise.reject(new Error("options.domains must be a list of string domain names," - + " with the first being the subject of the domain (or options.subject must specified).")); + + " with the first being the subject of the certificate (or options.subject must specified).")); } - // It's just fine if there's no account, we'll go get the key id we need via the public key - if (options.accountKid || options.account && options.account.kid) { - options._kid = options.accountKid || options.account.kid; - } else { + // It's just fine if there's no account, we'll go get the key id we need via the existing key + options._kid = options._kid || options.accountKid + || (options.account && (options.account.kid + || (options.account.key && options.account.key.kid))); + if (!options._kid) { //return Promise.reject(new Error("must include KeyID")); // This is an idempotent request. It'll return the same account for the same public key. - return ACME._registerAccount(me, options).then(function () { + return ACME._registerAccount(me, options).then(function (account) { + options._kid = account.key.kid; // start back from the top return ACME._getCertificate(me, options); }); @@ -720,9 +712,6 @@ ACME._getCertificate = function (me, options) { }; var payload = JSON.stringify(body); - // determine the signing algorithm to use in protected header // TODO isn't that handled by the signer? - options._kty = (options.accountKeypair.privateKeyJwk && options.accountKeypair.privateKeyJwk.kty || 'RSA'); - options._alg = ('EC' === options._kty) ? 'ES256' : 'RS256'; // TODO vary with bitwidth of key (if not handled) if (me.debug) { console.debug('\n[DEBUG] newOrder\n'); } return ACME._jwsRequest({ options: options @@ -815,6 +804,13 @@ ACME._getCertificate = function (me, options) { }); }); }; +ACME._generateCsrWeb64 = function (me, options, validatedDomains) { + return ACME._importKeypair(me, options.domainKeypair).then(function (/*pair*/) { + return me.Keypairs.generateCsr(options.domainKeypair, validatedDomains).then(function (der) { + return Enc.bufToUrlBase64(der); + }); + }); +}; ACME.create = function create(me) { if (!me) { me = {}; } @@ -942,6 +938,30 @@ ACME._defaultRequest = function (opts) { }); }); }; + +ACME._importKeypair = function (me, kp) { + var jwk = kp.privateKeyJwk; + var p; + if (jwk) { + // nix the browser jwk extras + jwk.key_ops = undefined; + jwk.ext = undefined; + p = Promise.resolve({ private: jwk, public: me.Keypairs.neuter({ jwk: jwk }) }); + } else { + p = me.Keypairs.import({ pem: kp.privateKeyPem }); + } + return p.then(function (pair) { + kp.privateKeyJwk = pair.private; + kp.publicKeyJwk = pair.public; + if (pair.public.kid) { + pair = JSON.parse(JSON.stringify(pair)); + delete pair.public.kid; + delete pair.private.kid; + } + return pair; + }); +}; + /* TODO Per-Order State Params