From 488067ec2002a3713e2a64deb4b9e559619ca4ae Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sun, 28 Apr 2019 02:11:33 -0600 Subject: [PATCH] Can register new ECDSA or RSA account, huzzah! --- app.js | 18 +- lib/acme.js | 652 ++++++++++++++++++++++++++---------------------- lib/ecdsa.js | 2 + lib/keypairs.js | 217 +++++++++++----- 4 files changed, 518 insertions(+), 371 deletions(-) diff --git a/app.js b/app.js index 22e293a..7619d43 100644 --- a/app.js +++ b/app.js @@ -5,6 +5,7 @@ var Rasha = window.Rasha; var Eckles = window.Eckles; var x509 = window.x509; + var ACME = window.ACME; function $(sel) { return document.querySelector(sel); @@ -106,7 +107,22 @@ ev.preventDefault(); ev.stopPropagation(); $('.js-loading').hidden = false; - //ACME.accounts.create + var acme = ACME.create({ + Keypairs: Keypairs + }); + acme.init('https://acme-staging-v02.api.letsencrypt.org/directory').then(function (result) { + console.log('acme result', result); + 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 + } + }); + }); }); $('.js-generate').hidden = false; diff --git a/lib/acme.js b/lib/acme.js index afbedf4..867d3dc 100644 --- a/lib/acme.js +++ b/lib/acme.js @@ -30,7 +30,7 @@ ACME.challengePrefixes = { ACME.challengeTests = { 'http-01': function (me, auth) { var url = 'http://' + auth.hostname + ACME.challengePrefixes['http-01'] + '/' + auth.token; - return me._request({ method: 'GET', url: url }).then(function (resp) { + return me.request({ method: 'GET', url: url }).then(function (resp) { var err; // TODO limit the number of bytes that are allowed to be downloaded @@ -76,16 +76,28 @@ ACME.challengeTests = { ACME._directory = function (me) { // GET-as-GET ok - return me._request({ method: 'GET', url: me.directoryUrl, json: true }); + return me.request({ method: 'GET', url: me.directoryUrl, json: true }); }; ACME._getNonce = function (me) { // GET-as-GET, HEAD-as-HEAD ok - if (me._nonce) { return new Promise(function (resolve) { resolve(me._nonce); return; }); } - return me._request({ method: 'HEAD', url: me._directoryUrls.newNonce }).then(function (resp) { - me._nonce = resp.toJSON().headers['replay-nonce']; - return me._nonce; + var nonce; + while (true) { + nonce = me._nonces.shift(); + if (!nonce) { break; } + if (Date.now() - nonce.createdAt > (15 * 60 * 1000)) { + nonce = null; + } else { + break; + } + } + if (nonce) { return Promise.resolve(nonce); } + return me.request({ method: 'HEAD', url: me._directoryUrls.newNonce }).then(function (resp) { + return resp.headers['replay-nonce']; }); }; +ACME._setNonce = function (me, nonce) { + me._nonces.unshift({ nonce: nonce, createdAt: Date.now() }); +}; // ACME RFC Section 7.3 Account Creation /* { @@ -109,91 +121,86 @@ ACME._getNonce = function (me) { ACME._registerAccount = function (me, options) { if (me.debug) { console.debug('[acme-v2] accounts.create'); } - return ACME._getNonce(me).then(function () { - return new Promise(function (resolve, reject) { + return new Promise(function (resolve, reject) { - function agree(tosUrl) { - var err; - if (me._tos !== tosUrl) { - err = new Error("You must agree to the ToS at '" + me._tos + "'"); - err.code = "E_AGREE_TOS"; - reject(err); - return; - } + function agree(tosUrl) { + var err; + if (me._tos !== tosUrl) { + err = new Error("You must agree to the ToS at '" + me._tos + "'"); + err.code = "E_AGREE_TOS"; + reject(err); + return; + } - var jwk = options.accountKeypair.privateKeyJwk; - var p; - if (jwk) { - p = Promise.resolve({ private: jwk, public: Keypairs.neuter(jwk) }); - } else { - p = Keypairs.import({ pem: options.accountKeypair.privateKeyPem }); + 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 p.then(function (pair) { - if (pair.public.kid) { - pair = JSON.parse(JSON.stringify(pair)); - delete pair.public.kid; - delete pair.private.kid; - } - return pair; - }).then(function (pair) { - var contact; - if (options.contact) { - contact = options.contact.slice(0); - } else if (options.email) { - contact = [ 'mailto:' + options.email ]; - } - var body = { - termsOfServiceAgreed: tosUrl === me._tos - , onlyReturnExisting: false - , contact: contact - }; - if (options.externalAccount) { - body.externalAccountBinding = me.RSA.signJws( - // TODO is HMAC the standard, or is this arbitrary? - options.externalAccount.secret - , undefined - , { alg: options.externalAccount.alg || "HS256" - , kid: options.externalAccount.id - , url: me._directoryUrls.newAccount - } - , Buffer.from(JSON.stringify(pair.public)) - ); - } - var payload = JSON.stringify(body); - var jws = Keypairs.signJws( - options.accountKeypair - , undefined - , { nonce: me._nonce - , alg: (me._alg || 'RS256') + return pair; + }).then(function (pair) { + var contact; + if (options.contact) { + contact = options.contact.slice(0); + } else if (options.email) { + contact = [ 'mailto:' + options.email ]; + } + var body = { + termsOfServiceAgreed: tosUrl === me._tos + , onlyReturnExisting: false + , contact: contact + }; + var pExt; + if (options.externalAccount) { + pExt = me.Keypairs.signJws({ + // TODO is HMAC the standard, or is this arbitrary? + secret: options.externalAccount.secret + , protected: { + alg: options.externalAccount.alg || "HS256" + , kid: options.externalAccount.id , url: me._directoryUrls.newAccount - , jwk: pair.public } - , Buffer.from(payload) - ); - - delete jws.header; - if (me.debug) { console.debug('[acme-v2] accounts.create JSON body:'); } - if (me.debug) { console.debug(jws); } - me._nonce = null; - return me._request({ - method: 'POST' + , payload: Enc.strToBuf(JSON.stringify(pair.public)) + }).then(function (jws) { + body.externalAccountBinding = jws; + return body; + }); + } else { + pExt = Promise.resolve(body); + } + return pExt.then(function (body) { + var payload = JSON.stringify(body); + return ACME._jwsRequest(me, { + options: options , url: me._directoryUrls.newAccount - , headers: { 'Content-Type': 'application/jose+json' } - , json: jws + , protected: { kid: false, jwk: pair.public } + , payload: Enc.binToBuf(payload) }).then(function (resp) { var account = resp.body; if (2 !== Math.floor(resp.statusCode / 100)) { - throw new Error('account error: ' + JSON.stringify(body)); + throw new Error('account error: ' + JSON.stringify(resp.body)); } - me._nonce = resp.toJSON().headers['replay-nonce']; - var location = resp.toJSON().headers.location; + var location = resp.headers.location; // the account id url - me._kid = location; + options._kid = location; if (me.debug) { console.debug('[DEBUG] new account location:'); } if (me.debug) { console.debug(location); } - if (me.debug) { console.debug(resp.toJSON()); } + if (me.debug) { console.debug(resp); } /* { @@ -205,29 +212,29 @@ ACME._registerAccount = function (me, options) { if (!account) { account = { _emptyResponse: true, key: {} }; } // https://git.coolaj86.com/coolaj86/acme-v2.js/issues/8 if (!account.key) { account.key = {}; } - account.key.kid = me._kid; + account.key.kid = options._kid; return account; }).then(resolve, reject); }); - } + }); + } - if (me.debug) { console.debug('[acme-v2] agreeToTerms'); } - if (1 === options.agreeToTerms.length) { - // newer promise API - return options.agreeToTerms(me._tos).then(agree, reject); - } - else if (2 === options.agreeToTerms.length) { - // backwards compat cb API - return options.agreeToTerms(me._tos, function (err, tosUrl) { - if (!err) { agree(tosUrl); return; } - reject(err); - }); - } - else { - reject(new Error('agreeToTerms has incorrect function signature.' - + ' Should be fn(tos) { return Promise; }')); - } - }); + if (me.debug) { console.debug('[acme-v2] agreeToTerms'); } + if (1 === options.agreeToTerms.length) { + // newer promise API + return Promise.resolve(options.agreeToTerms(me._tos)).then(agree, reject); + } + else if (2 === options.agreeToTerms.length) { + // backwards compat cb API + return options.agreeToTerms(me._tos, function (err, tosUrl) { + if (!err) { agree(tosUrl); return; } + reject(err); + }); + } + else { + reject(new Error('agreeToTerms has incorrect function signature.' + + ' Should be fn(tos) { return Promise; }')); + } }); }; /* @@ -250,10 +257,16 @@ ACME._registerAccount = function (me, options) { "signature": "H6ZXtGjTZyUnPeKn...wEA4TklBdh3e454g" } */ -ACME._getChallenges = function (me, options, auth) { +ACME._getChallenges = function (me, options, authUrl) { if (me.debug) { console.debug('\n[DEBUG] getChallenges\n'); } // TODO POST-as-GET - return me._request({ method: 'GET', url: auth, json: true }).then(function (resp) { + + return ACME._jwsRequest(me, { + options: options + , protected: {} + , payload: '' + , url: authUrl + }).then(function (resp) { return resp.body; }); }; @@ -389,16 +402,18 @@ 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); - auth.thumbprint = me.RSA.thumbprint(options.accountKeypair); - // 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 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 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; + }); }); }; @@ -436,25 +451,13 @@ ACME._postChallenge = function (me, options, auth) { } */ function deactivate() { - var jws = me.RSA.signJws( - options.accountKeypair - , undefined - , { nonce: me._nonce, alg: (me._alg || 'RS256'), url: auth.url, kid: me._kid } - , Buffer.from(JSON.stringify({ "status": "deactivated" })) - ); - me._nonce = null; - return me._request({ - method: 'POST' + if (me.debug) { console.debug('[acme-v2.js] deactivate:'); } + return ACME._jwsRequest({ + options: options , url: auth.url - , headers: { 'Content-Type': 'application/jose+json' } - , json: jws + , protected: { kid: options._kid } + , payload: Enc.strToBuf(JSON.stringify({ "status": "deactivated" })) }).then(function (resp) { - if (me.debug) { console.debug('[acme-v2.js] deactivate:'); } - if (me.debug) { console.debug(resp.headers); } - if (me.debug) { console.debug(resp.body); } - if (me.debug) { console.debug(); } - - me._nonce = resp.toJSON().headers['replay-nonce']; if (me.debug) { console.debug('deactivate challenge: resp.body:'); } if (me.debug) { console.debug(resp.body); } return ACME._wait(DEAUTH_INTERVAL); @@ -472,7 +475,7 @@ ACME._postChallenge = function (me, options, auth) { if (me.debug) { console.debug('\n[DEBUG] statusChallenge\n'); } // TODO POST-as-GET - return me._request({ method: 'GET', url: auth.url, json: true }).then(function (resp) { + return me.request({ method: 'GET', url: auth.url, json: true }).then(function (resp) { if ('processing' === resp.body.status) { if (me.debug) { console.debug('poll: again'); } return ACME._wait(RETRY_INTERVAL).then(pollStatus); @@ -523,25 +526,13 @@ ACME._postChallenge = function (me, options, auth) { } function respondToChallenge() { - var jws = me.RSA.signJws( - options.accountKeypair - , undefined - , { nonce: me._nonce, alg: (me._alg || 'RS256'), url: auth.url, kid: me._kid } - , Buffer.from(JSON.stringify({ })) - ); - me._nonce = null; - return me._request({ - method: 'POST' + if (me.debug) { console.debug('[acme-v2.js] responding to accept challenge:'); } + return ACME._jwsRequest({ + options: options , url: auth.url - , headers: { 'Content-Type': 'application/jose+json' } - , json: jws + , protected: { kid: options._kid } + , payload: Enc.strToBuf(JSON.stringify({})) }).then(function (resp) { - if (me.debug) { console.debug('[acme-v2.js] challenge accepted!'); } - if (me.debug) { console.debug(resp.headers); } - if (me.debug) { console.debug(resp.body); } - if (me.debug) { console.debug(); } - - me._nonce = resp.toJSON().headers['replay-nonce']; if (me.debug) { console.debug('respond to challenge: resp.body:'); } if (me.debug) { console.debug(resp.body); } return ACME._wait(RETRY_INTERVAL).then(pollStatus); @@ -586,36 +577,26 @@ ACME._setChallenge = function (me, options, auth) { }; ACME._finalizeOrder = function (me, options, validatedDomains) { if (me.debug) { console.debug('finalizeOrder:'); } - var csr = me.RSA.generateCsrWeb64(options.domainKeypair, validatedDomains); + var csr = me.Keypairs.generateCsrWeb64(options.domainKeypair, validatedDomains); var body = { csr: csr }; var payload = JSON.stringify(body); function pollCert() { - var jws = me.RSA.signJws( - options.accountKeypair - , undefined - , { nonce: me._nonce, alg: (me._alg || 'RS256'), url: me._finalize, kid: me._kid } - , Buffer.from(payload) - ); - - if (me.debug) { console.debug('finalize:', me._finalize); } - me._nonce = null; - return me._request({ - method: 'POST' - , url: me._finalize - , headers: { 'Content-Type': 'application/jose+json' } - , json: jws + 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) { - // https://tools.ietf.org/html/draft-ietf-acme-acme-12#section-7.1.3 - // Possible values are: "pending" => ("invalid" || "ready") => "processing" => "valid" - me._nonce = resp.toJSON().headers['replay-nonce']; - 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) { - me._expires = resp.body.expires; - me._certificate = resp.body.certificate; + options._expires = resp.body.expires; + options._certificate = resp.body.certificate; return resp.body; // return order } @@ -672,6 +653,11 @@ ACME._finalizeOrder = function (me, options, validatedDomains) { return pollCert(); }; +// _kid +// registerAccount +// postChallenge +// finalizeOrder +// getCertificate ACME._getCertificate = function (me, options) { if (me.debug) { console.debug('[acme-v2] DEBUG get cert 1'); } @@ -704,139 +690,126 @@ ACME._getCertificate = function (me, options) { } // It's just fine if there's no account, we'll go get the key id we need via the public key - if (!me._kid) { - if (options.accountKid || options.account && options.account.kid) { - me._kid = options.accountKid || options.account.kid; - } else { - //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 () { - // start back from the top - return ACME._getCertificate(me, options); - }); - } + if (options.accountKid || options.account && options.account.kid) { + options._kid = options.accountKid || options.account.kid; + } else { + //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 () { + // start back from the top + return ACME._getCertificate(me, options); + }); } // Do a little dry-run / self-test return ACME._testChallenges(me, options).then(function () { if (me.debug) { console.debug('[acme-v2] certificates.create'); } - return ACME._getNonce(me).then(function () { - var body = { - // raw wildcard syntax MUST be used here - identifiers: options.domains.sort(function (a, b) { - // the first in the list will be the subject of the certificate, I believe (and hope) - if (!options.subject) { return 0; } - if (options.subject === a) { return -1; } - if (options.subject === b) { return 1; } - return 0; - }).map(function (hostname) { - return { type: "dns", value: hostname }; - }) - //, "notBefore": "2016-01-01T00:00:00Z" - //, "notAfter": "2016-01-08T00:00:00Z" - }; + var body = { + // raw wildcard syntax MUST be used here + identifiers: options.domains.sort(function (a, b) { + // the first in the list will be the subject of the certificate, I believe (and hope) + if (!options.subject) { return 0; } + if (options.subject === a) { return -1; } + if (options.subject === b) { return 1; } + return 0; + }).map(function (hostname) { + return { type: "dns", value: hostname }; + }) + //, "notBefore": "2016-01-01T00:00:00Z" + //, "notAfter": "2016-01-08T00:00:00Z" + }; - var payload = JSON.stringify(body); - // determine the signing algorithm to use in protected header // TODO isn't that handled by the signer? - me._kty = (options.accountKeypair.privateKeyJwk && options.accountKeypair.privateKeyJwk.kty || 'RSA'); - me._alg = ('EC' === me._kty) ? 'ES256' : 'RS256'; // TODO vary with bitwidth of key (if not handled) - var jws = me.RSA.signJws( - options.accountKeypair - , undefined - , { nonce: me._nonce, alg: me._alg, url: me._directoryUrls.newOrder, kid: me._kid } - , Buffer.from(payload, 'utf8') - ); + 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 + , url: me._directoryUrls.newOrder + , protected: { kid: options._kid } + , payload: Enc.strToBuf(payload) + }).then(function (resp) { + var location = resp.headers.location; + var setAuths; + var auths = []; + if (me.debug) { console.debug('[ordered]', location); } // the account id url + if (me.debug) { console.debug(resp); } + options._authorizations = resp.body.authorizations; + options._order = location; + options._finalize = resp.body.finalize; + //if (me.debug) console.debug('[DEBUG] finalize:', options._finalize); return; - if (me.debug) { console.debug('\n[DEBUG] newOrder\n'); } - me._nonce = null; - return me._request({ - method: 'POST' - , url: me._directoryUrls.newOrder - , headers: { 'Content-Type': 'application/jose+json' } - , json: jws - }).then(function (resp) { - me._nonce = resp.toJSON().headers['replay-nonce']; - var location = resp.toJSON().headers.location; - var setAuths; - var auths = []; - if (me.debug) { console.debug(location); } // the account id url - if (me.debug) { console.debug(resp.toJSON()); } - me._authorizations = resp.body.authorizations; - me._order = location; - me._finalize = resp.body.finalize; - //if (me.debug) console.debug('[DEBUG] finalize:', me._finalize); return; + if (!options._authorizations) { + return Promise.reject(new Error( + "[acme-v2.js] authorizations were not fetched for '" + options.domains.join() + "':\n" + + JSON.stringify(resp.body) + )); + } + if (me.debug) { console.debug("[acme-v2] POST newOrder has authorizations"); } + setAuths = options._authorizations.slice(0); - if (!me._authorizations) { - return Promise.reject(new Error( - "[acme-v2.js] authorizations were not fetched for '" + options.domains.join() + "':\n" - + JSON.stringify(resp.body) - )); - } - if (me.debug) { console.debug("[acme-v2] POST newOrder has authorizations"); } - setAuths = me._authorizations.slice(0); + function setNext() { + var authUrl = setAuths.shift(); + if (!authUrl) { return; } - function setNext() { - var authUrl = setAuths.shift(); - if (!authUrl) { return; } + return ACME._getChallenges(me, options, authUrl).then(function (results) { + // var domain = options.domains[i]; // results.identifier.value - return ACME._getChallenges(me, options, authUrl).then(function (results) { - // var domain = options.domains[i]; // results.identifier.value + // If it's already valid, we're golden it regardless + if (results.challenges.some(function (ch) { return 'valid' === ch.status; })) { + return setNext(); + } - // If it's already valid, we're golden it regardless - if (results.challenges.some(function (ch) { return 'valid' === ch.status; })) { - return setNext(); - } + var challenge = ACME._chooseChallenge(options, results); + if (!challenge) { + // For example, wildcards require dns-01 and, if we don't have that, we have to bail + return Promise.reject(new Error( + "Server didn't offer any challenge we can handle for '" + options.domains.join() + "'." + )); + } - var challenge = ACME._chooseChallenge(options, results); - if (!challenge) { - // For example, wildcards require dns-01 and, if we don't have that, we have to bail - return Promise.reject(new Error( - "Server didn't offer any challenge we can handle for '" + options.domains.join() + "'." - )); - } - - return ACME._challengeToAuth(me, options, results, challenge, false).then(function (auth) { - auths.push(auth); - return ACME._setChallenge(me, options, auth).then(setNext); - }); + return ACME._challengeToAuth(me, options, results, challenge, false).then(function (auth) { + auths.push(auth); + return ACME._setChallenge(me, options, auth).then(setNext); }); - } + }); + } - function challengeNext() { - var auth = auths.shift(); - if (!auth) { return; } - return ACME._postChallenge(me, options, auth).then(challengeNext); - } + function challengeNext() { + var auth = auths.shift(); + if (!auth) { return; } + return ACME._postChallenge(me, options, auth).then(challengeNext); + } - // First we set every challenge - // Then we ask for each challenge to be checked - // Doing otherwise would potentially cause us to poison our own DNS cache with misses - return setNext().then(challengeNext).then(function () { - if (me.debug) { console.debug("[getCertificate] next.then"); } - var validatedDomains = body.identifiers.map(function (ident) { - return ident.value; - }); + // First we set every challenge + // Then we ask for each challenge to be checked + // Doing otherwise would potentially cause us to poison our own DNS cache with misses + return setNext().then(challengeNext).then(function () { + if (me.debug) { console.debug("[getCertificate] next.then"); } + var validatedDomains = body.identifiers.map(function (ident) { + return ident.value; + }); - return ACME._finalizeOrder(me, options, validatedDomains); - }).then(function (order) { - if (me.debug) { console.debug('acme-v2: order was finalized'); } - // TODO POST-as-GET - return me._request({ method: 'GET', url: me._certificate, json: true }).then(function (resp) { - if (me.debug) { console.debug('acme-v2: csr submitted and cert received:'); } - // https://github.com/certbot/certbot/issues/5721 - var certsarr = ACME.splitPemChain(ACME.formatPemChain((resp.body||''))); - // cert, chain, fullchain, privkey, /*TODO, subject, altnames, issuedAt, expiresAt */ - var certs = { - expires: order.expires - , identifiers: order.identifiers - //, authorizations: order.authorizations - , cert: certsarr.shift() - //, privkey: privkeyPem - , chain: certsarr.join('\n') - }; - if (me.debug) { console.debug(certs); } - return certs; - }); + return ACME._finalizeOrder(me, options, validatedDomains); + }).then(function (order) { + if (me.debug) { console.debug('acme-v2: order was finalized'); } + // TODO POST-as-GET + return me.request({ method: 'GET', url: options._certificate, json: true }).then(function (resp) { + if (me.debug) { console.debug('acme-v2: csr submitted and cert received:'); } + // https://github.com/certbot/certbot/issues/5721 + var certsarr = ACME.splitPemChain(ACME.formatPemChain((resp.body||''))); + // cert, chain, fullchain, privkey, /*TODO, subject, altnames, issuedAt, expiresAt */ + var certs = { + expires: order.expires + , identifiers: order.identifiers + //, authorizations: order.authorizations + , cert: certsarr.shift() + //, privkey: privkeyPem + , chain: certsarr.join('\n') + }; + if (me.debug) { console.debug(certs); } + return certs; }); }); }); @@ -847,9 +820,10 @@ ACME.create = function create(me) { if (!me) { me = {}; } // me.debug = true; me.challengePrefixes = ACME.challengePrefixes; - me.RSA = me.RSA || require('rsa-compat').RSA; + me.Keypairs = me.Keypairs || me.RSA || require('rsa-compat').RSA; + me._nonces = []; //me.Keypairs = me.Keypairs || require('keypairs'); - me.request = me.request || require('@coolaj86/urequest'); + //me.request = me.request || require('@root/request'); if (!me.dig) { me.dig = function (query) { // TODO use digd.js @@ -860,37 +834,33 @@ ACME.create = function create(me) { resolve({ answer: records.map(function (rr) { - return { - data: rr - }; + return { data: rr }; }) }); }); }); }; } - me.promisify = me.promisify || require('util').promisify /*node v8+*/ || require('bluebird').promisify /*node v6*/; - - if ('function' !== typeof me._request) { - // MUST have a User-Agent string (see node.js version) - me._request = function (opts) { - return window.fetch(opts.url, opts).then(function (resp) { - return resp.json().then(function (json) { - var headers = {}; - Array.from(resp.headers.entries()).forEach(function (h) { headers[h[0]] = h[1]; }); - return { headers: headers , body: json }; - }); - }); - }; + if ('function' !== typeof me.request) { + me.request = ACME._defaultRequest; } - me.init = function (_directoryUrl) { - me.directoryUrl = me.directoryUrl || _directoryUrl; + me.init = function (opts) { + function fin(dir) { + me._directoryUrls = dir; + me._tos = dir.meta.termsOfService; + return dir; + } + if (opts && opts.meta && opts.termsOfService) { + return Promise.resolve(fin(opts)); + } + if (!me.directoryUrl) { me.directoryUrl = opts; } + if ('string' !== typeof me.directoryUrl) { + throw new Error("you must supply either the ACME directory url as a string or an object of the ACME urls"); + } return ACME._directory(me).then(function (resp) { - me._directoryUrls = resp.body; - me._tos = me._directoryUrls.meta.termsOfService; - return me._directoryUrls; + return fin(resp.body); }); }; me.accounts = { @@ -906,6 +876,84 @@ ACME.create = function create(me) { return me; }; +// Handle nonce, signing, and request altogether +ACME._jwsRequest = function (me, bigopts) { + return ACME._getNonce(me).then(function (nonce) { + bigopts.protected.nonce = nonce; + bigopts.protected.url = bigopts.url; + // protected.alg: added by Keypairs.signJws + return me.Keypairs.signJws( + { jwk: bigopts.options.accountKeypair.privateKeyJwk + , protected: bigopts.protected + , payload: bigopts.payload + } + ).then(function (jws) { + if (me.debug) { console.debug('[acme-v2] ' + bigopts.url + ':'); } + if (me.debug) { console.debug(jws); } + return ACME._request(me, { url: bigopts.url, json: jws }); + }); + }); +}; +// Handle some ACME-specific defaults +ACME._request = function (me, opts) { + if (!opts.headers) { opts.headers = {}; } + if (opts.json && true !== opts.json) { + opts.headers['Content-Type'] = 'application/jose+json'; + opts.body = JSON.stringify(opts.json); + if (!opts.method) { opts.method = 'POST'; } + } + return me.request(opts).then(function (resp) { + resp = resp.toJSON(); + if (resp.headers['replay-nonce']) { + ACME._setNonce(me, resp.headers['replay-nonce']); + } + return resp; + }); +}; +// A very generic, swappable request lib +ACME._defaultRequest = function (opts) { + // Note: normally we'd have to supply a User-Agent string, but not here in a browser + if (!opts.headers) { opts.headers = {}; } + if (opts.json) { + opts.headers.Accept = 'application/json'; + if (true !== opts.json) { opts.body = JSON.stringify(opts.json); } + } + if (!opts.method) { + opts.method = 'GET'; + if (opts.body) { opts.method = 'POST'; } + } + opts.cors = true; + return window.fetch(opts.url, opts).then(function (resp) { + var headers = {}; + var result = { statusCode: resp.status, headers: headers, toJSON: function () { return this; } }; + Array.from(resp.headers.entries()).forEach(function (h) { headers[h[0]] = h[1]; }); + if (!headers['content-type']) { + return result; + } + if (/json/.test(headers['content-type'])) { + return resp.json().then(function (json) { + result.body = json; + return result; + }); + } + return resp.text().then(function (txt) { + result.body = txt; + return result; + }); + }); +}; +/* +TODO +Per-Order State Params + _kty + _alg + _finalize + _expires + _certificate + _order + _authorizations +*/ + ACME._toWebsafeBase64 = function (b64) { return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g,""); }; diff --git a/lib/ecdsa.js b/lib/ecdsa.js index eff9794..62b052a 100644 --- a/lib/ecdsa.js +++ b/lib/ecdsa.js @@ -46,6 +46,8 @@ EC.generate = function (opts) { "jwk" , result.privateKey ).then(function (privJwk) { + privJwk.key_ops = undefined; + privJwk.ext = undefined; return { private: privJwk , public: EC.neuter({ jwk: privJwk }) diff --git a/lib/keypairs.js b/lib/keypairs.js index 79566d9..9d6cf3d 100644 --- a/lib/keypairs.js +++ b/lib/keypairs.js @@ -33,12 +33,20 @@ Keypairs.generate = function (opts) { }); }; +Keypairs.export = function (opts) { + return Eckles.export(opts).catch(function (err) { + return Rasha.export(opts).catch(function () { + return Promise.reject(err); + }); + }); +}; + /** * Chopping off the private parts is now part of the public API. * I thought it sounded a little too crude at first, but it really is the best name in every possible way. */ -Keypairs.neuter = Keypairs._neuter = function (opts) { +Keypairs.neuter = function (opts) { /** trying to find the best balance of an immutable copy with custom attributes */ var jwk = {}; Object.keys(opts.jwk).forEach(function (k) { @@ -63,7 +71,7 @@ Keypairs.thumbprint = function (opts) { Keypairs.publish = function (opts) { if ('object' !== typeof opts.jwk || !opts.jwk.kty) { throw new Error("invalid jwk: " + JSON.stringify(opts.jwk)); } - /** returns a copy */ + /** returns a copy */ var jwk = Keypairs.neuter(opts); if (jwk.exp) { @@ -128,11 +136,12 @@ Keypairs.signJws = function (opts) { if (!opts.jwk) { throw new Error("opts.jwk must exist and must declare 'typ'"); } - return ('RSA' === opts.jwk.kty) ? "RS256" : "ES256"; + if (opts.jwk.alg) { return opts.jwk.alg; } + var typ = ('RSA' === opts.jwk.kty) ? "RS" : "ES"; + return typ + Keypairs._getBits(opts); } - function sign(pem) { - var header = opts.header; + function sign() { var protect = opts.protected; var payload = opts.payload; @@ -143,8 +152,9 @@ Keypairs.signJws = function (opts) { if (false !== protect) { if (!protect) { protect = {}; } if (!protect.alg) { protect.alg = alg(); } - // There's a particular request where Let's Encrypt explicitly doesn't use a kid - if (!protect.kid && false !== protect.kid) { protect.kid = thumb; } + // There's a particular request where ACME / Let's Encrypt explicitly doesn't use a kid + if (false === protect.kid) { protect.kid = undefined; } + else if (!protect.kid) { protect.kid = thumb; } protectedHeader = JSON.stringify(protect); } @@ -155,7 +165,7 @@ Keypairs.signJws = function (opts) { // Trying to detect if it's a plain object (not Buffer, ArrayBuffer, Array, Uint8Array, etc) if (payload && ('string' !== typeof payload) && ('undefined' === typeof payload.byteLength) - && ('undefined' === typeof payload.byteLength) + && ('undefined' === typeof payload.buffer) ) { payload = JSON.stringify(payload); } @@ -165,76 +175,147 @@ Keypairs.signJws = function (opts) { } // node specifies RSA-SHAxxx even when it's actually ecdsa (it's all encoded x509 shasums anyway) - var nodeAlg = "SHA" + (((protect||header).alg||'').replace(/^[^\d]+/, '')||'256'); var protected64 = Enc.strToUrlBase64(protectedHeader); var payload64 = Enc.bufToUrlBase64(payload); - var binsig = require('crypto') - .createSign(nodeAlg) - .update(protect ? (protected64 + "." + payload64) : payload64) - .sign(pem) - ; - if ('EC' === opts.jwk.kty) { - // ECDSA JWT signatures differ from "normal" ECDSA signatures - // https://tools.ietf.org/html/rfc7518#section-3.4 - binsig = convertIfEcdsa(binsig); - } + var msg = protected64 + '.' + payload64; - var sig = binsig.toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, '') - ; + return Keypairs._sign(opts, msg).then(function (buf) { + /* + * This will come back into play for CSRs, but not for JOSE + if ('EC' === opts.jwk.kty) { + // ECDSA JWT signatures differ from "normal" ECDSA signatures + // https://tools.ietf.org/html/rfc7518#section-3.4 + binsig = convertIfEcdsa(binsig); + } + */ + var signedMsg = { + protected: protected64 + , payload: payload64 + , signature: Enc.bufToUrlBase64(buf) + }; - return { - header: header - , protected: protected64 || undefined - , payload: payload64 - , signature: sig - }; + console.log('Signed Base64 Msg:'); + console.log(JSON.stringify(signedMsg, null, 2)); + + console.log('msg:', msg); + return signedMsg; + }); } - function convertIfEcdsa(binsig) { - // should have asn1 sequence header of 0x30 - if (0x30 !== binsig[0]) { throw new Error("Impossible EC SHA head marker"); } - var index = 2; // first ecdsa "R" header byte - var len = binsig[1]; - var lenlen = 0; - // Seek length of length if length is greater than 127 (i.e. two 512-bit / 64-byte R and S values) - if (0x80 & len) { - lenlen = len - 0x80; // should be exactly 1 - len = binsig[2]; // should be <= 130 (two 64-bit SHA-512s, plus padding) - index += lenlen; - } - // should be of BigInt type - if (0x02 !== binsig[index]) { throw new Error("Impossible EC SHA R marker"); } - index += 1; - - var rlen = binsig[index]; - var bits = 32; - if (rlen > 49) { - bits = 64; - } else if (rlen > 33) { - bits = 48; - } - var r = binsig.slice(index + 1, index + 1 + rlen).toString('hex'); - var slen = binsig[index + 1 + rlen + 1]; // skip header and read length - var s = binsig.slice(index + 1 + rlen + 1 + 1).toString('hex'); - if (2 *slen !== s.length) { throw new Error("Impossible EC SHA S length"); } - // There may be one byte of padding on either - while (r.length < 2*bits) { r = '00' + r; } - while (s.length < 2*bits) { s = '00' + s; } - if (2*(bits+1) === r.length) { r = r.slice(2); } - if (2*(bits+1) === s.length) { s = s.slice(2); } - return Enc.hexToBuf(r + s); - } - - if (opts.pem && opts.jwk) { - return sign(opts.pem); + if (opts.jwk) { + return sign(); } else { - return Keypairs.export({ jwk: opts.jwk }).then(sign); + return Keypairs.import({ pem: opts.pem }).then(function (pair) { + opts.jwk = pair.private; + return sign(); + }); } }); }; +Keypairs._convertIfEcdsa = function (binsig) { + // should have asn1 sequence header of 0x30 + if (0x30 !== binsig[0]) { throw new Error("Impossible EC SHA head marker"); } + var index = 2; // first ecdsa "R" header byte + var len = binsig[1]; + var lenlen = 0; + // Seek length of length if length is greater than 127 (i.e. two 512-bit / 64-byte R and S values) + if (0x80 & len) { + lenlen = len - 0x80; // should be exactly 1 + len = binsig[2]; // should be <= 130 (two 64-bit SHA-512s, plus padding) + index += lenlen; + } + // should be of BigInt type + if (0x02 !== binsig[index]) { throw new Error("Impossible EC SHA R marker"); } + index += 1; + + var rlen = binsig[index]; + var bits = 32; + if (rlen > 49) { + bits = 64; + } else if (rlen > 33) { + bits = 48; + } + var r = binsig.slice(index + 1, index + 1 + rlen).toString('hex'); + var slen = binsig[index + 1 + rlen + 1]; // skip header and read length + var s = binsig.slice(index + 1 + rlen + 1 + 1).toString('hex'); + if (2 *slen !== s.length) { throw new Error("Impossible EC SHA S length"); } + // There may be one byte of padding on either + while (r.length < 2*bits) { r = '00' + r; } + while (s.length < 2*bits) { s = '00' + s; } + if (2*(bits+1) === r.length) { r = r.slice(2); } + if (2*(bits+1) === s.length) { s = s.slice(2); } + return Enc.hexToBuf(r + s); +}; + +Keypairs._sign = function (opts, payload) { + return Keypairs._import(opts).then(function (privkey) { + if ('string' === typeof payload) { + payload = (new TextEncoder()).encode(payload); + } + return window.crypto.subtle.sign( + { name: Keypairs._getName(opts) + , hash: { name: 'SHA-' + Keypairs._getBits(opts) } + } + , privkey + , payload + ).then(function (signature) { + // convert buffer to urlsafe base64 + //return Enc.bufToUrlBase64(new Uint8Array(signature)); + return new Uint8Array(signature); + }); + }); +}; +Keypairs._getBits = function (opts) { + if (opts.alg) { return opts.alg.replace(/[a-z\-]/ig, ''); } + // base64 len to byte len + var len = Math.floor((opts.jwk.n||'').length * 0.75); + + // TODO this may be a bug + // need to confirm that the padding is no more or less than 1 byte + if (/521/.test(opts.jwk.crv) || len >= 511) { + return '512'; + } else if (/384/.test(opts.jwk.crv) || len >= 383) { + return '384'; + } + + return '256'; +}; +Keypairs._getName = function (opts) { + if (/EC/i.test(opts.jwk.kty)) { + return 'ECDSA'; + } else { + return 'RSASSA-PKCS1-v1_5'; + } +}; + +Keypairs._import = function (opts) { + return Promise.resolve().then(function () { + var ops; + // all private keys just happen to have a 'd' + if (opts.jwk.d) { + ops = [ 'sign' ]; + } else { + ops = [ 'verify' ]; + } + // gotta mark it as extractable, as if it matters + opts.jwk.ext = true; + opts.jwk.key_ops = ops; + + console.log('jwk', opts.jwk); + return window.crypto.subtle.importKey( + "jwk" + , opts.jwk + , { name: Keypairs._getName(opts) + , namedCurve: opts.jwk.crv + , hash: { name: 'SHA-' + Keypairs._getBits(opts) } } + , true + , ops + ).then(function (privkey) { + delete opts.jwk.ext; + return privkey; + }); + }); +}; function setTime(time) { if ('number' === typeof time) { return time; }