Compare commits
	
		
			No commits in common. "f1e11f1be778df9f7a088bb1fa6de10b873c8418" and "76621560cb58ac235895397eccdd902fab7d3079" have entirely different histories.
		
	
	
		
			f1e11f1be7
			...
			76621560cb
		
	
		
							
								
								
									
										10
									
								
								app.js
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								app.js
									
									
									
									
									
								
							| @ -46,8 +46,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) { | ||||
| @ -102,6 +102,12 @@ | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     $('form.js-acme-account').addEventListener('submit', function (ev) { | ||||
|       ev.preventDefault(); | ||||
|       ev.stopPropagation(); | ||||
|       $('.js-loading').hidden = false; | ||||
|       //ACME.accounts.create
 | ||||
|     }); | ||||
| 
 | ||||
|     $('.js-generate').hidden = false; | ||||
|     $('.js-create-account').hidden = false; | ||||
|  | ||||
							
								
								
									
										16
									
								
								index.html
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								index.html
									
									
									
									
									
								
							| @ -59,6 +59,13 @@ | ||||
|       <button class="js-generate" hidden>Generate</button> | ||||
|     </form> | ||||
| 
 | ||||
|     <h2>ACME Account</h2> | ||||
|     <form class="js-acme-account"> | ||||
|       <label for="-acmeEmail">Email:</label> | ||||
|       <input class="js-email" type="email" id="-acmeEmail"> | ||||
|       <button class="js-create-account" hidden>Create Account</button> | ||||
|     </form> | ||||
| 
 | ||||
|     <div class="js-loading" hidden>Loading</div> | ||||
| 
 | ||||
|     <details class="js-toc-jwk" hidden> | ||||
| @ -93,7 +100,14 @@ | ||||
|       <summary>PEM Public (base64-encoded SPKI/PKIX DER)</summary> | ||||
|       <pre><code  class="js-input-pem-spki-public" ></code></pre> | ||||
|     </details> | ||||
| 
 | ||||
|     <details class="js-toc-acme-account-request" hidden> | ||||
|       <summary>ACME Account Request</summary> | ||||
|       <pre><code class="js-acme-account-request"> </code></pre> | ||||
|     </details> | ||||
|     <details class="js-toc-acme-account-response" hidden> | ||||
|       <summary>ACME Account Response</summary> | ||||
|       <pre><code class="js-acme-account-response"> </code></pre> | ||||
|     </details> | ||||
|     <script src="./lib/bluecrypt-encoding.js"></script> | ||||
|     <script src="./lib/asn1-packer.js"></script> | ||||
|     <script src="./lib/x509.js"></script> | ||||
|  | ||||
							
								
								
									
										951
									
								
								lib/acme.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										951
									
								
								lib/acme.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,951 @@ | ||||
| // Copyright 2018-present AJ ONeal. All rights reserved
 | ||||
| /* This Source Code Form is subject to the terms of the Mozilla Public | ||||
|  * License, v. 2.0. If a copy of the MPL was not distributed with this | ||||
|  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 | ||||
| (function (exports) { | ||||
| 'use strict'; | ||||
| /* globals Promise */ | ||||
| 
 | ||||
| var ACME = exports.ACME = {}; | ||||
| var Keypairs = exports.Keypairs || {}; | ||||
| var Enc = exports.Enc || {}; | ||||
| var Crypto = exports.Crypto || {}; | ||||
| 
 | ||||
| ACME.formatPemChain = function formatPemChain(str) { | ||||
|   return str.trim().replace(/[\r\n]+/g, '\n').replace(/\-\n\-/g, '-\n\n-') + '\n'; | ||||
| }; | ||||
| ACME.splitPemChain = function splitPemChain(str) { | ||||
|   return str.trim().split(/[\r\n]{2,}/g).map(function (str) { | ||||
|     return str + '\n'; | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| 
 | ||||
| // http-01: GET https://example.org/.well-known/acme-challenge/{{token}} => {{keyAuth}}
 | ||||
| // dns-01: TXT _acme-challenge.example.org. => "{{urlSafeBase64(sha256(keyAuth))}}"
 | ||||
| ACME.challengePrefixes = { | ||||
|   'http-01': '/.well-known/acme-challenge' | ||||
| , 'dns-01': '_acme-challenge' | ||||
| }; | ||||
| 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) { | ||||
|       var err; | ||||
| 
 | ||||
|       // TODO limit the number of bytes that are allowed to be downloaded
 | ||||
|       if (auth.keyAuthorization === resp.body.toString('utf8').trim()) { | ||||
|         return true; | ||||
|       } | ||||
| 
 | ||||
|       err = new Error( | ||||
|         "Error: Failed HTTP-01 Pre-Flight / Dry Run.\n" | ||||
|       + "curl '" + url + "'\n" | ||||
|       + "Expected: '" + auth.keyAuthorization + "'\n" | ||||
|       + "Got: '" + resp.body + "'\n" | ||||
|       + "See https://git.coolaj86.com/coolaj86/acme-v2.js/issues/4" | ||||
|       ); | ||||
|       err.code = 'E_FAIL_DRY_CHALLENGE'; | ||||
|       return Promise.reject(err); | ||||
|     }); | ||||
|   } | ||||
| , 'dns-01': function (me, auth) { | ||||
|     // remove leading *. on wildcard domains
 | ||||
|     return me.dig({ | ||||
|       type: 'TXT' | ||||
|     , name: auth.dnsHost | ||||
|     }).then(function (ans) { | ||||
|       var err; | ||||
| 
 | ||||
|       if (ans.answer.some(function (txt) { | ||||
|         return auth.dnsAuthorization === txt.data[0]; | ||||
|       })) { | ||||
|         return true; | ||||
|       } | ||||
| 
 | ||||
|       err = new Error( | ||||
|         "Error: Failed DNS-01 Pre-Flight Dry Run.\n" | ||||
|       + "dig TXT '" + auth.dnsHost + "' does not return '" + auth.dnsAuthorization + "'\n" | ||||
|       + "See https://git.coolaj86.com/coolaj86/acme-v2.js/issues/4" | ||||
|       ); | ||||
|       err.code = 'E_FAIL_DRY_CHALLENGE'; | ||||
|       return Promise.reject(err); | ||||
|     }); | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| ACME._directory = function (me) { | ||||
|   // GET-as-GET ok
 | ||||
|   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; | ||||
|   }); | ||||
| }; | ||||
| // ACME RFC Section 7.3 Account Creation
 | ||||
| /* | ||||
|  { | ||||
|    "protected": base64url({ | ||||
|      "alg": "ES256", | ||||
|      "jwk": {...}, | ||||
|      "nonce": "6S8IqOGY7eL2lsGoTZYifg", | ||||
|      "url": "https://example.com/acme/new-account" | ||||
|    }), | ||||
|    "payload": base64url({ | ||||
|      "termsOfServiceAgreed": true, | ||||
|      "onlyReturnExisting": false, | ||||
|      "contact": [ | ||||
|        "mailto:cert-admin@example.com", | ||||
|        "mailto:admin@example.com" | ||||
|      ] | ||||
|    }), | ||||
|    "signature": "RZPOnYoPs1PhjszF...-nh6X1qtOFPB519I" | ||||
|  } | ||||
| */ | ||||
| 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) { | ||||
| 
 | ||||
|       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 }); | ||||
|         } | ||||
|         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') | ||||
|             , 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' | ||||
|           , url: me._directoryUrls.newAccount | ||||
|           , headers: { 'Content-Type': 'application/jose+json' } | ||||
|           , json: jws | ||||
|           }).then(function (resp) { | ||||
|             var account = resp.body; | ||||
| 
 | ||||
|             if (2 !== Math.floor(resp.statusCode / 100)) { | ||||
|               throw new Error('account error: ' + JSON.stringify(body)); | ||||
|             } | ||||
| 
 | ||||
|             me._nonce = resp.toJSON().headers['replay-nonce']; | ||||
|             var location = resp.toJSON().headers.location; | ||||
|             // the account id url
 | ||||
|             me._kid = location; | ||||
|             if (me.debug) { console.debug('[DEBUG] new account location:'); } | ||||
|             if (me.debug) { console.debug(location); } | ||||
|             if (me.debug) { console.debug(resp.toJSON()); } | ||||
| 
 | ||||
|             /* | ||||
|             { | ||||
|               contact: ["mailto:jon@example.com"], | ||||
|               orders: "https://some-url", | ||||
|               status: 'valid' | ||||
|             } | ||||
|             */ | ||||
|             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; | ||||
|             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<tos>; }')); | ||||
|       } | ||||
|     }); | ||||
|   }); | ||||
| }; | ||||
| /* | ||||
|  POST /acme/new-order HTTP/1.1 | ||||
|  Host: example.com | ||||
|  Content-Type: application/jose+json | ||||
| 
 | ||||
|  { | ||||
|    "protected": base64url({ | ||||
|      "alg": "ES256", | ||||
|      "kid": "https://example.com/acme/acct/1", | ||||
|      "nonce": "5XJ1L3lEkMG7tR6pA00clA", | ||||
|      "url": "https://example.com/acme/new-order" | ||||
|    }), | ||||
|    "payload": base64url({ | ||||
|      "identifiers": [{"type:"dns","value":"example.com"}], | ||||
|      "notBefore": "2016-01-01T00:00:00Z", | ||||
|      "notAfter": "2016-01-08T00:00:00Z" | ||||
|    }), | ||||
|    "signature": "H6ZXtGjTZyUnPeKn...wEA4TklBdh3e454g" | ||||
|  } | ||||
| */ | ||||
| ACME._getChallenges = function (me, options, auth) { | ||||
|   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 resp.body; | ||||
|   }); | ||||
| }; | ||||
| ACME._wait = function wait(ms) { | ||||
|   return new Promise(function (resolve) { | ||||
|     setTimeout(resolve, (ms || 1100)); | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| ACME._testChallengeOptions = function () { | ||||
|   var chToken = ACME._prnd(16); | ||||
|   return [ | ||||
|     { | ||||
|       "type": "http-01", | ||||
|       "status": "pending", | ||||
|       "url": "https://acme-staging-v02.example.com/0", | ||||
|       "token": "test-" + chToken + "-0" | ||||
|     } | ||||
|   , { | ||||
|       "type": "dns-01", | ||||
|       "status": "pending", | ||||
|       "url": "https://acme-staging-v02.example.com/1", | ||||
|       "token": "test-" + chToken + "-1", | ||||
|       "_wildcard": true | ||||
|     } | ||||
|   , { | ||||
|       "type": "tls-sni-01", | ||||
|       "status": "pending", | ||||
|       "url": "https://acme-staging-v02.example.com/2", | ||||
|       "token": "test-" + chToken + "-2" | ||||
|     } | ||||
|   , { | ||||
|       "type": "tls-alpn-01", | ||||
|       "status": "pending", | ||||
|       "url": "https://acme-staging-v02.example.com/3", | ||||
|       "token": "test-" + chToken + "-3" | ||||
|     } | ||||
|   ]; | ||||
| }; | ||||
| ACME._testChallenges = function (me, options) { | ||||
|   if (me.skipChallengeTest) { | ||||
|     return Promise.resolve(); | ||||
|   } | ||||
| 
 | ||||
|   var CHECK_DELAY = 0; | ||||
|   return Promise.all(options.domains.map(function (identifierValue) { | ||||
|     // TODO we really only need one to pass, not all to pass
 | ||||
|     var challenges = ACME._testChallengeOptions(); | ||||
|     if (identifierValue.includes("*")) { | ||||
|       challenges = challenges.filter(function (ch) { return ch._wildcard; }); | ||||
|     } | ||||
| 
 | ||||
|     var challenge = ACME._chooseChallenge(options, { challenges: challenges }); | ||||
|     if (!challenge) { | ||||
|       // For example, wildcards require dns-01 and, if we don't have that, we have to bail
 | ||||
|       var enabled = options.challengeTypes.join(', ') || 'none'; | ||||
|       var suitable = challenges.map(function (r) { return r.type; }).join(', ') || 'none'; | ||||
|       return Promise.reject(new Error( | ||||
|         "None of the challenge types that you've enabled ( " + enabled + " )" | ||||
|           + " are suitable for validating the domain you've selected (" + identifierValue + ")." | ||||
|           + " You must enable one of ( " + suitable + " )." | ||||
|       )); | ||||
|     } | ||||
|     if ('dns-01' === challenge.type) { | ||||
|       // Give the nameservers a moment to propagate
 | ||||
|       CHECK_DELAY = 1.5 * 1000; | ||||
|     } | ||||
| 
 | ||||
|     return Promise.resolve().then(function () { | ||||
|       var results = { | ||||
|         identifier: { | ||||
|           type: "dns" | ||||
|         , value: identifierValue.replace(/^\*\./, '') | ||||
|         } | ||||
|       , challenges: [ challenge ] | ||||
|       , expires: new Date(Date.now() + (60 * 1000)).toISOString() | ||||
|       , 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; | ||||
|       }); | ||||
|     }); | ||||
|   })).then(function (auths) { | ||||
|     return ACME._wait(CHECK_DELAY).then(function () { | ||||
|       return Promise.all(auths.map(function (auth) { | ||||
|         return ACME.challengeTests[auth.type](me, auth); | ||||
|       })); | ||||
|     }); | ||||
|   }); | ||||
| }; | ||||
| ACME._chooseChallenge = function(options, results) { | ||||
|   // For each of the challenge types that we support
 | ||||
|   var challenge; | ||||
|   options.challengeTypes.some(function (chType) { | ||||
|     // And for each of the challenge types that are allowed
 | ||||
|     return results.challenges.some(function (ch) { | ||||
|       // Check to see if there are any matches
 | ||||
|       if (ch.type === chType) { | ||||
|         challenge = ch; | ||||
|         return true; | ||||
|       } | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   return challenge; | ||||
| }; | ||||
| ACME._challengeToAuth = function (me, options, request, challenge, dryrun) { | ||||
|   // we don't poison the dns cache with our dummy request
 | ||||
|   var dnsPrefix = ACME.challengePrefixes['dns-01']; | ||||
|   if (dryrun) { | ||||
|     dnsPrefix = dnsPrefix.replace('acme-challenge', 'greenlock-dryrun-' + ACME._prnd(4)); | ||||
|   } | ||||
| 
 | ||||
|   var auth = {}; | ||||
| 
 | ||||
|   // straight copy from the new order response
 | ||||
|   // { identifier, status, expires, challenges, wildcard }
 | ||||
|   Object.keys(request).forEach(function (key) { | ||||
|     auth[key] = request[key]; | ||||
|   }); | ||||
| 
 | ||||
|   // copy from the challenge we've chosen
 | ||||
|   // { type, status, url, token }
 | ||||
|   // (note the duplicate status overwrites the one above, but they should be the same)
 | ||||
|   Object.keys(challenge).forEach(function (key) { | ||||
|     // don't confused devs with the id url
 | ||||
|     auth[key] = challenge[key]; | ||||
|   }); | ||||
| 
 | ||||
|   // batteries-included helpers
 | ||||
|   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 Crypto._sha('sha256', auth.keyAuthorization).then(function (hash) { | ||||
|     auth.dnsAuthorization = hash; | ||||
|     return auth; | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| ACME._untame = function (name, wild) { | ||||
|   if (wild) { name = '*.' + name.replace('*.', ''); } | ||||
|   return name; | ||||
| }; | ||||
| 
 | ||||
| // https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-7.5.1
 | ||||
| ACME._postChallenge = function (me, options, auth) { | ||||
|   var RETRY_INTERVAL = me.retryInterval || 1000; | ||||
|   var DEAUTH_INTERVAL = me.deauthWait || 10 * 1000; | ||||
|   var MAX_POLL = me.retryPoll || 8; | ||||
|   var MAX_PEND = me.retryPending || 4; | ||||
|   var count = 0; | ||||
| 
 | ||||
|   var altname = ACME._untame(auth.identifier.value, auth.wildcard); | ||||
| 
 | ||||
|   /* | ||||
|    POST /acme/authz/1234 HTTP/1.1 | ||||
|    Host: example.com | ||||
|    Content-Type: application/jose+json | ||||
| 
 | ||||
|    { | ||||
|      "protected": base64url({ | ||||
|        "alg": "ES256", | ||||
|        "kid": "https://example.com/acme/acct/1", | ||||
|        "nonce": "xWCM9lGbIyCgue8di6ueWQ", | ||||
|        "url": "https://example.com/acme/authz/1234" | ||||
|      }), | ||||
|      "payload": base64url({ | ||||
|        "status": "deactivated" | ||||
|      }), | ||||
|      "signature": "srX9Ji7Le9bjszhu...WTFdtujObzMtZcx4" | ||||
|    } | ||||
|    */ | ||||
|   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' | ||||
|     , url: auth.url | ||||
|     , headers: { 'Content-Type': 'application/jose+json' } | ||||
|     , json: jws | ||||
|     }).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); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   function pollStatus() { | ||||
|     if (count >= MAX_POLL) { | ||||
|       return Promise.reject(new Error( | ||||
|         "[acme-v2] stuck in bad pending/processing state for '" + altname + "'" | ||||
|       )); | ||||
|     } | ||||
| 
 | ||||
|     count += 1; | ||||
| 
 | ||||
|     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) { | ||||
|       if ('processing' === resp.body.status) { | ||||
|         if (me.debug) { console.debug('poll: again'); } | ||||
|         return ACME._wait(RETRY_INTERVAL).then(pollStatus); | ||||
|       } | ||||
| 
 | ||||
|       // This state should never occur
 | ||||
|       if ('pending' === resp.body.status) { | ||||
|         if (count >= MAX_PEND) { | ||||
|           return ACME._wait(RETRY_INTERVAL).then(deactivate).then(respondToChallenge); | ||||
|         } | ||||
|         if (me.debug) { console.debug('poll: again'); } | ||||
|         return ACME._wait(RETRY_INTERVAL).then(respondToChallenge); | ||||
|       } | ||||
| 
 | ||||
|       if ('valid' === resp.body.status) { | ||||
|         if (me.debug) { console.debug('poll: valid'); } | ||||
| 
 | ||||
|         try { | ||||
|           if (1 === options.removeChallenge.length) { | ||||
|             options.removeChallenge(auth).then(function () {}, function () {}); | ||||
|           } else if (2 === options.removeChallenge.length) { | ||||
|             options.removeChallenge(auth, function (err) { return err; }); | ||||
|           } else { | ||||
|             if (!ACME._removeChallengeWarn) { | ||||
|               console.warn("Please update to acme-v2 removeChallenge(options) <Promise> or removeChallenge(options, cb)."); | ||||
|               console.warn("The API has been changed for compatibility with all ACME / Let's Encrypt challenge types."); | ||||
|               ACME._removeChallengeWarn = true; | ||||
|             } | ||||
|             options.removeChallenge(auth.request.identifier, auth.token, function () {}); | ||||
|           } | ||||
|         } catch(e) {} | ||||
|         return resp.body; | ||||
|       } | ||||
| 
 | ||||
|       var errmsg; | ||||
|       if (!resp.body.status) { | ||||
|         errmsg = "[acme-v2] (E_STATE_EMPTY) empty challenge state for '" + altname + "':"; | ||||
|       } | ||||
|       else if ('invalid' === resp.body.status) { | ||||
|         errmsg = "[acme-v2] (E_STATE_INVALID) challenge state for '" + altname + "': '" + resp.body.status + "'"; | ||||
|       } | ||||
|       else { | ||||
|         errmsg = "[acme-v2] (E_STATE_UKN) challenge state for '" + altname + "': '" + resp.body.status + "'"; | ||||
|       } | ||||
| 
 | ||||
|       return Promise.reject(new Error(errmsg)); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   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' | ||||
|     , url: auth.url | ||||
|     , headers: { 'Content-Type': 'application/jose+json' } | ||||
|     , json: jws | ||||
|     }).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); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   return respondToChallenge(); | ||||
| }; | ||||
| ACME._setChallenge = function (me, options, auth) { | ||||
|   return new Promise(function (resolve, reject) { | ||||
|     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(err) { reject(err); } else { resolve(); } | ||||
|         }); | ||||
|       } else { | ||||
|         var challengeCb = function(err) { | ||||
|           if(err) { reject(err); } else { resolve(); } | ||||
|         }; | ||||
|         // for backwards compat adding extra keys without changing params length
 | ||||
|         Object.keys(auth).forEach(function (key) { | ||||
|           challengeCb[key] = auth[key]; | ||||
|         }); | ||||
|         if (!ACME._setChallengeWarn) { | ||||
|           console.warn("Please update to acme-v2 setChallenge(options) <Promise> or setChallenge(options, cb)."); | ||||
|           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); | ||||
|       } | ||||
|     } catch(e) { | ||||
|       reject(e); | ||||
|     } | ||||
|   }).then(function () { | ||||
|     // TODO: Do we still need this delay? Or shall we leave it to plugins to account for themselves?
 | ||||
|     var DELAY = me.setChallengeWait || 500; | ||||
|     if (me.debug) { console.debug('\n[DEBUG] waitChallengeDelay %s\n', DELAY); } | ||||
|     return ACME._wait(DELAY); | ||||
|   }); | ||||
| }; | ||||
| ACME._finalizeOrder = function (me, options, validatedDomains) { | ||||
|   if (me.debug) { console.debug('finalizeOrder:'); } | ||||
|   var csr = me.RSA.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 | ||||
|     }).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); } | ||||
| 
 | ||||
|       if ('valid' === resp.body.status) { | ||||
|         me._expires = resp.body.expires; | ||||
|         me._certificate = resp.body.certificate; | ||||
| 
 | ||||
|         return resp.body; // return order
 | ||||
|       } | ||||
| 
 | ||||
|       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 ('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" | ||||
|         )); | ||||
|       } | ||||
| 
 | ||||
|       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(); | ||||
| }; | ||||
| ACME._getCertificate = function (me, options) { | ||||
|   if (me.debug) { console.debug('[acme-v2] DEBUG get cert 1'); } | ||||
| 
 | ||||
|   // Lot's of error checking to inform the user of mistakes
 | ||||
|   if (!(options.challengeTypes||[]).length) { | ||||
|     options.challengeTypes = Object.keys(options.challenges||{}); | ||||
|   } | ||||
|   if (!options.challengeTypes.length) { | ||||
|     options.challengeTypes = [ options.challengeType ].filter(Boolean); | ||||
|   } | ||||
|   if (options.challengeType) { | ||||
|     options.challengeTypes.sort(function (a, b) { | ||||
|       if (a === options.challengeType) { return -1; } | ||||
|       if (b === options.challengeType) { return 1; } | ||||
|       return 0; | ||||
|     }); | ||||
|     if (options.challengeType !== options.challengeTypes[0]) { | ||||
|       return Promise.reject(new Error("options.challengeType is '" + options.challengeType + "'," | ||||
|         + " which does not exist in the supplied types '" + options.challengeTypes.join(',') + "'")); | ||||
|     } | ||||
|   } | ||||
|   // TODO check that all challengeTypes are represented in challenges
 | ||||
|   if (!options.challengeTypes.length) { | ||||
|     return Promise.reject(new Error("options.challengeTypes (string array) must be specified" | ||||
|       + " (and in order of preferential priority).")); | ||||
|   } | ||||
|   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).")); | ||||
|   } | ||||
| 
 | ||||
|   // 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); | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   // 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 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') | ||||
|       ); | ||||
| 
 | ||||
|       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 (!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; } | ||||
| 
 | ||||
|           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(); | ||||
|             } | ||||
| 
 | ||||
|             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); | ||||
|             }); | ||||
|           }); | ||||
|         } | ||||
| 
 | ||||
|         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; | ||||
|           }); | ||||
| 
 | ||||
|           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; | ||||
|           }); | ||||
|         }); | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| 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 || require('keypairs');
 | ||||
|   me.request = me.request || require('@coolaj86/urequest'); | ||||
|   if (!me.dig) { | ||||
|     me.dig = function (query) { | ||||
|       // TODO use digd.js
 | ||||
|       return new Promise(function (resolve, reject) { | ||||
|         var dns = require('dns'); | ||||
|         dns.resolveTxt(query.name, function (err, records) { | ||||
|           if (err) { reject(err); return; } | ||||
| 
 | ||||
|           resolve({ | ||||
|             answer: records.map(function (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 }; | ||||
|         }); | ||||
|       }); | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   me.init = function (_directoryUrl) { | ||||
|     me.directoryUrl = me.directoryUrl || _directoryUrl; | ||||
|     return ACME._directory(me).then(function (resp) { | ||||
|       me._directoryUrls = resp.body; | ||||
|       me._tos = me._directoryUrls.meta.termsOfService; | ||||
|       return me._directoryUrls; | ||||
|     }); | ||||
|   }; | ||||
|   me.accounts = { | ||||
|     create: function (options) { | ||||
|       return ACME._registerAccount(me, options); | ||||
|     } | ||||
|   }; | ||||
|   me.certificates = { | ||||
|     create: function (options) { | ||||
|       return ACME._getCertificate(me, options); | ||||
|     } | ||||
|   }; | ||||
|   return me; | ||||
| }; | ||||
| 
 | ||||
| ACME._toWebsafeBase64 = function (b64) { | ||||
|   return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g,""); | ||||
| }; | ||||
| 
 | ||||
| // In v8 this is crypto random, but we're just using it for pseudorandom
 | ||||
| ACME._prnd = function (n) { | ||||
|   var rnd = ''; | ||||
|   while (rnd.length / 2 < n) { | ||||
|     var num = Math.random().toString().substr(2); | ||||
|     if (num.length % 2) { | ||||
|       num = '0' + num; | ||||
|     } | ||||
|     var pairs = num.match(/(..?)/g); | ||||
|     rnd += pairs.map(ACME._toHex).join(''); | ||||
|   } | ||||
|   return rnd.substr(0, n*2); | ||||
| }; | ||||
| ACME._toHex = function (pair) { | ||||
|   return parseInt(pair, 10).toString(16); | ||||
| }; | ||||
| 
 | ||||
| Enc.bufToUrlBase64 = function (u8) { | ||||
|   return Enc.bufToBase64(u8) | ||||
|     .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); | ||||
| }; | ||||
| Enc.bufToBase64 = function (u8) { | ||||
|   var bin = ''; | ||||
|   u8.forEach(function (i) { | ||||
|     bin += String.fromCharCode(i); | ||||
|   }); | ||||
|   return btoa(bin); | ||||
| }; | ||||
| 
 | ||||
| Crypto._sha = function (sha, str) { | ||||
|   var encoder = new TextEncoder(); | ||||
|   var data = encoder.encode(str); | ||||
|   sha = 'SHA-' + sha.replace(/^sha-?/i, ''); | ||||
|   return window.crypto.subtle.digest(sha, data).then(function (hash) { | ||||
|     return Enc.bufToUrlBase64(new Uint8Array(hash)); | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| }('undefined' === typeof window ? module.exports : window)); | ||||
| @ -46,8 +46,6 @@ 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 }) | ||||
|  | ||||
							
								
								
									
										217
									
								
								lib/keypairs.js
									
									
									
									
									
								
							
							
						
						
									
										217
									
								
								lib/keypairs.js
									
									
									
									
									
								
							| @ -33,20 +33,12 @@ 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 = function (opts) { | ||||
| Keypairs.neuter = 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) { | ||||
| @ -136,12 +128,11 @@ Keypairs.signJws = function (opts) { | ||||
|       if (!opts.jwk) { | ||||
|         throw new Error("opts.jwk must exist and must declare 'typ'"); | ||||
|       } | ||||
|       if (opts.jwk.alg) { return opts.jwk.alg; } | ||||
|       var typ = ('RSA' === opts.jwk.kty) ? "RS" : "ES"; | ||||
|       return typ + Keypairs._getBits(opts); | ||||
|       return ('RSA' === opts.jwk.kty) ? "RS256" : "ES256"; | ||||
|     } | ||||
| 
 | ||||
|     function sign() { | ||||
|     function sign(pem) { | ||||
|       var header = opts.header; | ||||
|       var protect = opts.protected; | ||||
|       var payload = opts.payload; | ||||
| 
 | ||||
| @ -152,9 +143,8 @@ Keypairs.signJws = function (opts) { | ||||
|       if (false !== protect) { | ||||
|         if (!protect) { protect = {}; } | ||||
|         if (!protect.alg) { protect.alg = alg(); } | ||||
|         // 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; } | ||||
|         // There's a particular request where Let's Encrypt explicitly doesn't use a kid
 | ||||
|         if (!protect.kid && false !== protect.kid) { protect.kid = thumb; } | ||||
|         protectedHeader = JSON.stringify(protect); | ||||
|       } | ||||
| 
 | ||||
| @ -165,7 +155,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.buffer) | ||||
|         && ('undefined' === typeof payload.byteLength) | ||||
|       ) { | ||||
|         payload = JSON.stringify(payload); | ||||
|       } | ||||
| @ -175,145 +165,74 @@ 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 msg = protected64 + '.' + payload64; | ||||
| 
 | ||||
|       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) | ||||
|         }; | ||||
| 
 | ||||
|         console.log('Signed Base64 Msg:'); | ||||
|         console.log(JSON.stringify(signedMsg, null, 2)); | ||||
| 
 | ||||
|         console.log('msg:', msg); | ||||
|         return signedMsg; | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|     if (opts.jwk) { | ||||
|       return sign(); | ||||
|     } else { | ||||
|       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) } | ||||
|       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); | ||||
|       } | ||||
|     , 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'; | ||||
|   } | ||||
|       var sig = binsig.toString('base64') | ||||
|         .replace(/\+/g, '-') | ||||
|         .replace(/\//g, '_') | ||||
|         .replace(/=/g, '') | ||||
|       ; | ||||
| 
 | ||||
|   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' ]; | ||||
|       return { | ||||
|         header: header | ||||
|       , protected: protected64 || undefined | ||||
|       , payload: payload64 | ||||
|       , signature: sig | ||||
|       }; | ||||
|     } | ||||
|     // 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 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); | ||||
|     } else { | ||||
|       return Keypairs.export({ jwk: opts.jwk }).then(sign); | ||||
|     } | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										32
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										32
									
								
								package.json
									
									
									
									
									
								
							| @ -1,32 +0,0 @@ | ||||
| { | ||||
|   "name": "bluecrypt-keypairs", | ||||
|   "version": "0.1.1", | ||||
|   "description": "Zero-Dependency Native Browser support for ECDSA P-256 and P-384, and RSA 2048/3072/4096 written in VanillaJS", | ||||
|   "directories": { | ||||
|     "lib": "lib" | ||||
|   }, | ||||
|   "scripts": { | ||||
|     "test": "node server.js", | ||||
|     "start": "node server.js" | ||||
|   }, | ||||
|   "repository": { | ||||
|     "type": "git", | ||||
|     "url": "https://git.coolaj86.com/coolaj86/bluecrypt-keypairs.js.git" | ||||
|   }, | ||||
|   "keywords": [ | ||||
|     "browser", | ||||
|     "EC", | ||||
|     "RSA", | ||||
|     "ECDSA", | ||||
|     "P-256", | ||||
|     "P-384", | ||||
|     "bluecrypt", | ||||
|     "keypairs", | ||||
|     "greenlock", | ||||
|     "VanillaJS" | ||||
|   ], | ||||
|   "author": "AJ ONeal <coolaj86@gmail.com> (https://coolaj86.com/)", | ||||
|   "license": "MPL-2.0", | ||||
|   "devDependencies": { | ||||
|   } | ||||
| } | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user