WIP Building out all features necessary for Let's Encrypt #6
							
								
								
									
										25
									
								
								app.js
									
									
									
									
									
								
							
							
						
						
									
										25
									
								
								app.js
									
									
									
									
									
								
							| @ -18,8 +18,11 @@ | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   function checkTos(tos) { |   function checkTos(tos) { | ||||||
|     console.log("TODO checkbox for agree to terms"); |     if ($('input[name="tos"]:checked')) { | ||||||
|     return tos; |       return tos; | ||||||
|  |     } else { | ||||||
|  |       return ''; | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   function run() { |   function run() { | ||||||
| @ -139,6 +142,8 @@ | |||||||
|           accountStuff.email = email; |           accountStuff.email = email; | ||||||
|           accountStuff.acme = acme; |           accountStuff.acme = acme; | ||||||
|           $('.js-create-order').hidden = false; |           $('.js-create-order').hidden = false; | ||||||
|  |           $('.js-toc-acme-account-response').hidden = false; | ||||||
|  |           $('.js-acme-account-response').innerText = JSON.stringify(account, null, 2); | ||||||
|         }).catch(function (err) { |         }).catch(function (err) { | ||||||
|           console.error("A bad thing happened:"); |           console.error("A bad thing happened:"); | ||||||
|           console.error(err); |           console.error(err); | ||||||
| @ -163,14 +168,17 @@ | |||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|       var domains = ($('.js-domains').value||'example.com').split(/[, ]+/g); |       var domains = ($('.js-domains').value||'example.com').split(/[, ]+/g); | ||||||
|       return getDomainPrivkey().then(function () { |       return getDomainPrivkey().then(function (domainPrivJwk) { | ||||||
|  |         console.log('Has CSR already?'); | ||||||
|  |         console.log(accountStuff.csr); | ||||||
|         return acme.certificates.create({ |         return acme.certificates.create({ | ||||||
|           accountKeypair: { privateKeyJwk: privJwk } |           accountKeypair: { privateKeyJwk: privJwk } | ||||||
|         , account: account |         , account: account | ||||||
|         //, domainKeypair: { privateKeyJwk: accountStuff.domainPrivateJwk }
 |         , domainKeypair: { privateKeyJwk: domainPrivJwk } | ||||||
|         , csr: accountStuff.csr |         , csr: accountStuff.csr | ||||||
|         , email: email |         , email: email | ||||||
|         , domains: domains |         , domains: domains | ||||||
|  |         , skipDryRun: $('input[name="skip-dryrun"]:checked') && true | ||||||
|         , agreeToTerms: checkTos |         , agreeToTerms: checkTos | ||||||
|         , challenges: { |         , challenges: { | ||||||
|             'dns-01': { |             'dns-01': { | ||||||
| @ -215,7 +223,14 @@ | |||||||
|             } |             } | ||||||
|           } |           } | ||||||
|         , challengeTypes: [$('input[name="acme-challenge-type"]:checked').value] |         , challengeTypes: [$('input[name="acme-challenge-type"]:checked').value] | ||||||
|  |         }).then(function (results) { | ||||||
|  |           console.log('Got Certificates:'); | ||||||
|  |           console.log(results); | ||||||
|  |           $('.js-toc-acme-order-response').hidden = false; | ||||||
|  |           $('.js-acme-order-response').innerText = JSON.stringify(results, null, 2); | ||||||
|         }).catch(function (err) { |         }).catch(function (err) { | ||||||
|  |           console.error("challenge failed:"); | ||||||
|  |           console.error(err); | ||||||
|           window.alert("failed! " + err.message || JSON.stringify(err)); |           window.alert("failed! " + err.message || JSON.stringify(err)); | ||||||
|         }); |         }); | ||||||
|       }); |       }); | ||||||
| @ -245,7 +260,7 @@ | |||||||
|       return CSR({ jwk: privJwk, domains: domains }).then(function (pem) { |       return CSR({ jwk: privJwk, domains: domains }).then(function (pem) { | ||||||
|         // Verify with https://www.sslshopper.com/csr-decoder.html
 |         // Verify with https://www.sslshopper.com/csr-decoder.html
 | ||||||
|         accountStuff.csr = pem; |         accountStuff.csr = pem; | ||||||
|         console.log('CSR:'); |         console.log('Created CSR:'); | ||||||
|         console.log(pem); |         console.log(pem); | ||||||
| 
 | 
 | ||||||
|         console.log('CSR info:'); |         console.log('CSR info:'); | ||||||
|  | |||||||
							
								
								
									
										20
									
								
								index.html
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								index.html
									
									
									
									
									
								
							| @ -56,7 +56,10 @@ | |||||||
|     <h2>ACME Account</h2> |     <h2>ACME Account</h2> | ||||||
|     <form class="js-acme-account"> |     <form class="js-acme-account"> | ||||||
|       <label for="-acmeEmail">Email:</label> |       <label for="-acmeEmail">Email:</label> | ||||||
|       <input class="js-email" type="email" id="-acmeEmail"> |       <input class="js-email" type="email" id="-acmeEmail" value="john.doe@gmail.com"> | ||||||
|  |       <br> | ||||||
|  |       <label for="-acmeTos"><input class="js-tos" name="tos" type="checkbox" id="-acmeTos" checked> | ||||||
|  |         Agree to Let's Encrypt Terms of Service</label> | ||||||
|       <br> |       <br> | ||||||
|       <button class="js-create-account" hidden>Create Account</button> |       <button class="js-create-account" hidden>Create Account</button> | ||||||
|     </form> |     </form> | ||||||
| @ -64,7 +67,7 @@ | |||||||
|     <h2>Certificate Signing Request</h2> |     <h2>Certificate Signing Request</h2> | ||||||
|     <form class="js-csr"> |     <form class="js-csr"> | ||||||
|       <label for="-acmeDomains">Domains:</label> |       <label for="-acmeDomains">Domains:</label> | ||||||
|       <input class="js-domains" type="text" id="-acmeDomains"> |       <input class="js-domains" type="text" id="-acmeDomains" value="example.com www.example.com"> | ||||||
|       <br> |       <br> | ||||||
|       <button class="js-create-csr" hidden>Create CSR</button> |       <button class="js-create-csr" hidden>Create CSR</button> | ||||||
|     </form> |     </form> | ||||||
| @ -77,6 +80,9 @@ | |||||||
|       <label for="-dns01"><input type="radio" id="-dns01" |       <label for="-dns01"><input type="radio" id="-dns01" | ||||||
|        name="acme-challenge-type" value="dns-01">dns-01</label> |        name="acme-challenge-type" value="dns-01">dns-01</label> | ||||||
|       <br> |       <br> | ||||||
|  |       <label for="-skipDryrun"><input class="js-skip-dryrun" name="skip-dryrun" | ||||||
|  |         type="checkbox" id="-skipDryrun" checked> Skip dry-run challenge</label> | ||||||
|  |       <br> | ||||||
|       <button class="js-create-order" hidden>Create Order</button> |       <button class="js-create-order" hidden>Create Order</button> | ||||||
|     </form> |     </form> | ||||||
| 
 | 
 | ||||||
| @ -114,14 +120,14 @@ | |||||||
|       <summary>PEM Public (base64-encoded SPKI/PKIX DER)</summary> |       <summary>PEM Public (base64-encoded SPKI/PKIX DER)</summary> | ||||||
|       <pre><code  class="js-input-pem-spki-public" ></code></pre> |       <pre><code  class="js-input-pem-spki-public" ></code></pre> | ||||||
|     </details> |     </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> |     <details class="js-toc-acme-account-response" hidden> | ||||||
|       <summary>ACME Account Response</summary> |       <summary>ACME Account Request</summary> | ||||||
|       <pre><code class="js-acme-account-response"> </code></pre> |       <pre><code class="js-acme-account-response"> </code></pre> | ||||||
|     </details> |     </details> | ||||||
|  |     <details class="js-toc-acme-order-response" hidden> | ||||||
|  |       <summary>ACME Order Response</summary> | ||||||
|  |       <pre><code class="js-acme-order-response"> </code></pre> | ||||||
|  |     </details> | ||||||
|     <script src="./lib/bluecrypt-encoding.js"></script> |     <script src="./lib/bluecrypt-encoding.js"></script> | ||||||
|     <script src="./lib/asn1-packer.js"></script> |     <script src="./lib/asn1-packer.js"></script> | ||||||
|     <script src="./lib/x509.js"></script> |     <script src="./lib/x509.js"></script> | ||||||
|  | |||||||
| @ -837,6 +837,7 @@ ACME._generateCsrWeb64 = function (me, options, validatedDomains) { | |||||||
|     csr = Enc.base64ToUrlBase64(csr.trim().replace(/\s+/g, '')); |     csr = Enc.base64ToUrlBase64(csr.trim().replace(/\s+/g, '')); | ||||||
|     return Promise.resolve(csr); |     return Promise.resolve(csr); | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|   return ACME._importKeypair(me, options.domainKeypair).then(function (pair) { |   return ACME._importKeypair(me, options.domainKeypair).then(function (pair) { | ||||||
|     return me.CSR({ jwk: pair.private, domains: validatedDomains, encoding: 'der' }).then(function (der) { |     return me.CSR({ jwk: pair.private, domains: validatedDomains, encoding: 'der' }).then(function (der) { | ||||||
|       return Enc.bufToUrlBase64(der); |       return Enc.bufToUrlBase64(der); | ||||||
|  | |||||||
| @ -1,699 +0,0 @@ | |||||||
| /*global CSR*/ |  | ||||||
| // CSR takes a while to load after the page load
 |  | ||||||
| (function (exports) { |  | ||||||
| 'use strict'; |  | ||||||
| 
 |  | ||||||
| var BACME = exports.ACME = {}; |  | ||||||
| var webFetch = exports.fetch; |  | ||||||
| var Keypairs = exports.Keypairs; |  | ||||||
| var Promise = exports.Promise; |  | ||||||
| 
 |  | ||||||
| var directoryUrl = 'https://acme-staging-v02.api.letsencrypt.org/directory'; |  | ||||||
| var directory; |  | ||||||
| 
 |  | ||||||
| var nonceUrl; |  | ||||||
| var nonce; |  | ||||||
| 
 |  | ||||||
| var accountKeypair; |  | ||||||
| var accountJwk; |  | ||||||
| 
 |  | ||||||
| var accountUrl; |  | ||||||
| 
 |  | ||||||
| BACME.challengePrefixes = { |  | ||||||
|   'http-01': '/.well-known/acme-challenge' |  | ||||||
| , 'dns-01': '_acme-challenge' |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| BACME._logHeaders = function (resp) { |  | ||||||
|   console.log('Headers:'); |  | ||||||
|   Array.from(resp.headers.entries()).forEach(function (h) { console.log(h[0] + ': ' + h[1]); }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| BACME._logBody = function (body) { |  | ||||||
|   console.log('Body:'); |  | ||||||
|   console.log(JSON.stringify(body, null, 2)); |  | ||||||
|   console.log(''); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| BACME.directory = function (opts) { |  | ||||||
|   return webFetch(opts.directoryUrl || directoryUrl, { mode: 'cors' }).then(function (resp) { |  | ||||||
|     BACME._logHeaders(resp); |  | ||||||
|     return resp.json().then(function (reply) { |  | ||||||
|       if (/error/.test(reply.type)) { |  | ||||||
|         return Promise.reject(new Error(reply.detail || reply.type)); |  | ||||||
|       } |  | ||||||
|       directory = reply; |  | ||||||
|       nonceUrl = directory.newNonce || 'https://acme-staging-v02.api.letsencrypt.org/acme/new-nonce'; |  | ||||||
|       accountUrl = directory.newAccount || 'https://acme-staging-v02.api.letsencrypt.org/acme/new-account'; |  | ||||||
|       orderUrl = directory.newOrder || "https://acme-staging-v02.api.letsencrypt.org/acme/new-order"; |  | ||||||
|       BACME._logBody(reply); |  | ||||||
|       return reply; |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| BACME.nonce = function () { |  | ||||||
|   return webFetch(nonceUrl, { mode: 'cors' }).then(function (resp) { |  | ||||||
|     BACME._logHeaders(resp); |  | ||||||
|     nonce = resp.headers.get('replay-nonce'); |  | ||||||
|     console.log('Nonce:', nonce); |  | ||||||
|     // resp.body is empty
 |  | ||||||
|     return resp.headers.get('replay-nonce'); |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| BACME.accounts = {}; |  | ||||||
| 
 |  | ||||||
| // type = ECDSA
 |  | ||||||
| // bitlength = 256
 |  | ||||||
| BACME.accounts.generateKeypair = function (opts) { |  | ||||||
|   return BACME.generateKeypair(opts).then(function (result) { |  | ||||||
|     accountKeypair = result; |  | ||||||
| 
 |  | ||||||
|     return webCrypto.subtle.exportKey( |  | ||||||
|       "jwk" |  | ||||||
|     , result.privateKey |  | ||||||
|     ).then(function (privJwk) { |  | ||||||
| 
 |  | ||||||
|       accountJwk = privJwk; |  | ||||||
|       console.log('private jwk:'); |  | ||||||
|       console.log(JSON.stringify(privJwk, null, 2)); |  | ||||||
| 
 |  | ||||||
|       return privJwk; |  | ||||||
|       /* |  | ||||||
|       return webCrypto.subtle.exportKey( |  | ||||||
|         "pkcs8" |  | ||||||
|       , result.privateKey |  | ||||||
|       ).then(function (keydata) { |  | ||||||
|         console.log('pkcs8:'); |  | ||||||
|         console.log(Array.from(new Uint8Array(keydata))); |  | ||||||
| 
 |  | ||||||
|         return privJwk; |  | ||||||
|         //return accountKeypair;
 |  | ||||||
|       }); |  | ||||||
|       */ |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| // json to url-safe base64
 |  | ||||||
| BACME._jsto64 = function (json) { |  | ||||||
|   return btoa(JSON.stringify(json)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| var textEncoder = new TextEncoder(); |  | ||||||
| 
 |  | ||||||
| BACME._importKey = function (jwk) { |  | ||||||
|   var alg; // I think the 256 refers to the hash
 |  | ||||||
|   var wcOpts = {}; |  | ||||||
|   var extractable = true; // TODO make optionally false?
 |  | ||||||
|   var priv = jwk; |  | ||||||
|   var pub; |  | ||||||
| 
 |  | ||||||
|   // ECDSA
 |  | ||||||
|   if (/^EC/i.test(jwk.kty)) { |  | ||||||
|     wcOpts.name = 'ECDSA'; |  | ||||||
|     wcOpts.namedCurve = jwk.crv; |  | ||||||
|     alg = 'ES256'; |  | ||||||
|     pub = { |  | ||||||
|       crv: priv.crv |  | ||||||
|     , kty: priv.kty |  | ||||||
|     , x: priv.x |  | ||||||
|     , y: priv.y |  | ||||||
|     }; |  | ||||||
|     if (!priv.d) { |  | ||||||
|       priv = null; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   // RSA
 |  | ||||||
|   if (/^RS/i.test(jwk.kty)) { |  | ||||||
|     wcOpts.name = 'RSASSA-PKCS1-v1_5'; |  | ||||||
|     wcOpts.hash = { name: "SHA-256" }; |  | ||||||
|     alg = 'RS256'; |  | ||||||
|     pub = { |  | ||||||
|       e: priv.e |  | ||||||
|     , kty: priv.kty |  | ||||||
|     , n: priv.n |  | ||||||
|     }; |  | ||||||
|     if (!priv.p) { |  | ||||||
|       priv = null; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return window.crypto.subtle.importKey( |  | ||||||
|     "jwk" |  | ||||||
|   , pub |  | ||||||
|   , wcOpts |  | ||||||
|   , extractable |  | ||||||
|   , [ "verify" ] |  | ||||||
|   ).then(function (publicKey) { |  | ||||||
|     function give(privateKey) { |  | ||||||
|       return { |  | ||||||
|         wcPub: publicKey |  | ||||||
|       , wcKey: privateKey |  | ||||||
|       , wcKeypair: { publicKey: publicKey, privateKey: privateKey } |  | ||||||
|       , meta: { |  | ||||||
|           alg: alg |  | ||||||
|         , name: wcOpts.name |  | ||||||
|         , hash: wcOpts.hash |  | ||||||
|         } |  | ||||||
|       , jwk: jwk |  | ||||||
|       }; |  | ||||||
|     } |  | ||||||
|     if (!priv) { |  | ||||||
|       return give(); |  | ||||||
|     } |  | ||||||
|     return window.crypto.subtle.importKey( |  | ||||||
|       "jwk" |  | ||||||
|     , priv |  | ||||||
|     , wcOpts |  | ||||||
|     , extractable |  | ||||||
|     , [ "sign"/*, "verify"*/ ] |  | ||||||
|     ).then(give); |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| BACME._sign = function (opts) { |  | ||||||
|   var wcPrivKey = opts.abstractKey.wcKeypair.privateKey; |  | ||||||
|   var wcOpts = opts.abstractKey.meta; |  | ||||||
|   var alg = opts.abstractKey.meta.alg; // I think the 256 refers to the hash
 |  | ||||||
|   var signHash; |  | ||||||
| 
 |  | ||||||
|   console.log('kty', opts.abstractKey.jwk.kty); |  | ||||||
|   signHash = { name: "SHA-" + alg.replace(/[a-z]+/ig, '') }; |  | ||||||
| 
 |  | ||||||
|   var msg = textEncoder.encode(opts.protected64 + '.' + opts.payload64); |  | ||||||
|   console.log('msg:', msg); |  | ||||||
|   return window.crypto.subtle.sign( |  | ||||||
|     { name: wcOpts.name, hash: signHash } |  | ||||||
|   , wcPrivKey |  | ||||||
|   , msg |  | ||||||
|   ).then(function (signature) { |  | ||||||
|     //console.log('sig1:', signature);
 |  | ||||||
|     //console.log('sig2:', new Uint8Array(signature));
 |  | ||||||
|     //console.log('sig3:', Array.prototype.slice.call(new Uint8Array(signature)));
 |  | ||||||
|     // convert buffer to urlsafe base64
 |  | ||||||
|     var sig64 = btoa(Array.prototype.map.call(new Uint8Array(signature), function (ch) { |  | ||||||
|       return String.fromCharCode(ch); |  | ||||||
|     }).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); |  | ||||||
| 
 |  | ||||||
|     console.log('[1] URL-safe Base64 Signature:'); |  | ||||||
|     console.log(sig64); |  | ||||||
| 
 |  | ||||||
|     var signedMsg = { |  | ||||||
|       protected: opts.protected64 |  | ||||||
|     , payload: opts.payload64 |  | ||||||
|     , signature: sig64 |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     console.log('Signed Base64 Msg:'); |  | ||||||
|     console.log(JSON.stringify(signedMsg, null, 2)); |  | ||||||
| 
 |  | ||||||
|     return signedMsg; |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| // email = john.doe@gmail.com
 |  | ||||||
| // jwk = { ... }
 |  | ||||||
| // agree = true
 |  | ||||||
| BACME.accounts.sign = function (opts) { |  | ||||||
| 
 |  | ||||||
|   return BACME._importKey(opts.jwk).then(function (abstractKey) { |  | ||||||
| 
 |  | ||||||
|     var payloadJson = |  | ||||||
|       { termsOfServiceAgreed: opts.agree |  | ||||||
|       , onlyReturnExisting: false |  | ||||||
|       , contact: opts.contacts || [ 'mailto:' + opts.email ] |  | ||||||
|       }; |  | ||||||
|     console.log('payload:'); |  | ||||||
|     console.log(payloadJson); |  | ||||||
|     var payload64 = BACME._jsto64( |  | ||||||
|       payloadJson |  | ||||||
|     ); |  | ||||||
| 
 |  | ||||||
|     var protectedJson = |  | ||||||
|       { nonce: opts.nonce |  | ||||||
|       , url: accountUrl |  | ||||||
|       , alg: abstractKey.meta.alg |  | ||||||
|       , jwk: null |  | ||||||
|       }; |  | ||||||
| 
 |  | ||||||
|     if (/EC/i.test(opts.jwk.kty)) { |  | ||||||
|       protectedJson.jwk = { |  | ||||||
|         crv: opts.jwk.crv |  | ||||||
|       , kty: opts.jwk.kty |  | ||||||
|       , x: opts.jwk.x |  | ||||||
|       , y: opts.jwk.y |  | ||||||
|       }; |  | ||||||
|     } else if (/RS/i.test(opts.jwk.kty)) { |  | ||||||
|       protectedJson.jwk = { |  | ||||||
|         e: opts.jwk.e |  | ||||||
|       , kty: opts.jwk.kty |  | ||||||
|       , n: opts.jwk.n |  | ||||||
|       }; |  | ||||||
|     } else { |  | ||||||
|       return Promise.reject(new Error("[acme.accounts.sign] unsupported key type '" + opts.jwk.kty + "'")); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     console.log('protected:'); |  | ||||||
|     console.log(protectedJson); |  | ||||||
|     var protected64 = BACME._jsto64( |  | ||||||
|       protectedJson |  | ||||||
|     ); |  | ||||||
| 
 |  | ||||||
|     // Note: this function hashes before signing so send data, not the hash
 |  | ||||||
|     return BACME._sign({ |  | ||||||
|       abstractKey: abstractKey |  | ||||||
|     , payload64: payload64 |  | ||||||
|     , protected64: protected64 |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| var accountId; |  | ||||||
| 
 |  | ||||||
| BACME.accounts.set = function (opts) { |  | ||||||
|   nonce = null; |  | ||||||
|   return window.fetch(accountUrl, { |  | ||||||
|     mode: 'cors' |  | ||||||
|   , method: 'POST' |  | ||||||
|   , headers: { 'Content-Type': 'application/jose+json' } |  | ||||||
|   , body: JSON.stringify(opts.signedAccount) |  | ||||||
|   }).then(function (resp) { |  | ||||||
|     BACME._logHeaders(resp); |  | ||||||
|     nonce = resp.headers.get('replay-nonce'); |  | ||||||
|     accountId = resp.headers.get('location'); |  | ||||||
|     console.log('Next nonce:', nonce); |  | ||||||
|     console.log('Location/kid:', accountId); |  | ||||||
| 
 |  | ||||||
|     if (!resp.headers.get('content-type')) { |  | ||||||
|      console.log('Body: <none>'); |  | ||||||
| 
 |  | ||||||
|      return { kid: accountId }; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     return resp.json().then(function (result) { |  | ||||||
|       if (/^Error/i.test(result.detail)) { |  | ||||||
|         return Promise.reject(new Error(result.detail)); |  | ||||||
|       } |  | ||||||
|       result.kid = accountId; |  | ||||||
|       BACME._logBody(result); |  | ||||||
| 
 |  | ||||||
|       return result; |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| var orderUrl; |  | ||||||
| 
 |  | ||||||
| BACME.orders = {}; |  | ||||||
| 
 |  | ||||||
| // identifiers = [ { type: 'dns', value: 'example.com' }, { type: 'dns', value: '*.example.com' } ]
 |  | ||||||
| // signedAccount
 |  | ||||||
| BACME.orders.sign = function (opts) { |  | ||||||
|   var payload64 = BACME._jsto64({ identifiers: opts.identifiers }); |  | ||||||
| 
 |  | ||||||
|   return BACME._importKey(opts.jwk).then(function (abstractKey) { |  | ||||||
|     var protected64 = BACME._jsto64( |  | ||||||
|       { nonce: nonce, alg: abstractKey.meta.alg/*'ES256'*/, url: orderUrl, kid: opts.kid } |  | ||||||
|     ); |  | ||||||
|     console.log('abstractKey:'); |  | ||||||
|     console.log(abstractKey); |  | ||||||
|     return BACME._sign({ |  | ||||||
|       abstractKey: abstractKey |  | ||||||
|     , payload64: payload64 |  | ||||||
|     , protected64: protected64 |  | ||||||
|     }).then(function (sig) { |  | ||||||
|       if (!sig) { |  | ||||||
|         throw new Error('sig is undefined... nonsense!'); |  | ||||||
|       } |  | ||||||
|       console.log('newsig', sig); |  | ||||||
|       return sig; |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| var currentOrderUrl; |  | ||||||
| var authorizationUrls; |  | ||||||
| var finalizeUrl; |  | ||||||
| 
 |  | ||||||
| BACME.orders.create = function (opts) { |  | ||||||
|   nonce = null; |  | ||||||
|   return window.fetch(orderUrl, { |  | ||||||
|     mode: 'cors' |  | ||||||
|   , method: 'POST' |  | ||||||
|   , headers: { 'Content-Type': 'application/jose+json' } |  | ||||||
|   , body: JSON.stringify(opts.signedOrder) |  | ||||||
|   }).then(function (resp) { |  | ||||||
|     BACME._logHeaders(resp); |  | ||||||
|     currentOrderUrl = resp.headers.get('location'); |  | ||||||
|     nonce = resp.headers.get('replay-nonce'); |  | ||||||
|     console.log('Next nonce:', nonce); |  | ||||||
| 
 |  | ||||||
|     return resp.json().then(function (result) { |  | ||||||
|       if (/^Error/i.test(result.detail)) { |  | ||||||
|         return Promise.reject(new Error(result.detail)); |  | ||||||
|       } |  | ||||||
|       authorizationUrls = result.authorizations; |  | ||||||
|       finalizeUrl = result.finalize; |  | ||||||
|       BACME._logBody(result); |  | ||||||
| 
 |  | ||||||
|       result.url = currentOrderUrl; |  | ||||||
|       return result; |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| BACME.challenges = {}; |  | ||||||
| BACME.challenges.all = function () { |  | ||||||
|   var challenges = []; |  | ||||||
| 
 |  | ||||||
|   function next() { |  | ||||||
|     if (!authorizationUrls.length) { |  | ||||||
|       return challenges; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     return BACME.challenges.view().then(function (challenge) { |  | ||||||
|       challenges.push(challenge); |  | ||||||
|       return next(); |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return next(); |  | ||||||
| }; |  | ||||||
| BACME.challenges.view = function () { |  | ||||||
|   var authzUrl = authorizationUrls.pop(); |  | ||||||
|   var token; |  | ||||||
|   var challengeDomain; |  | ||||||
|   var challengeUrl; |  | ||||||
| 
 |  | ||||||
|   return window.fetch(authzUrl, { |  | ||||||
|     mode: 'cors' |  | ||||||
|   }).then(function (resp) { |  | ||||||
|     BACME._logHeaders(resp); |  | ||||||
| 
 |  | ||||||
|     return resp.json().then(function (result) { |  | ||||||
|       // Note: select the challenge you wish to use
 |  | ||||||
|       var challenge = result.challenges.slice(0).pop(); |  | ||||||
|       token = challenge.token; |  | ||||||
|       challengeUrl = challenge.url; |  | ||||||
|       challengeDomain = result.identifier.value; |  | ||||||
| 
 |  | ||||||
|       BACME._logBody(result); |  | ||||||
| 
 |  | ||||||
|       return { |  | ||||||
|         challenges: result.challenges |  | ||||||
|       , expires: result.expires |  | ||||||
|       , identifier: result.identifier |  | ||||||
|       , status: result.status |  | ||||||
|       , wildcard: result.wildcard |  | ||||||
|       //, token: challenge.token
 |  | ||||||
|       //, url: challenge.url
 |  | ||||||
|       //, domain: result.identifier.value,
 |  | ||||||
|       }; |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| var thumbprint; |  | ||||||
| var keyAuth; |  | ||||||
| var httpPath; |  | ||||||
| var dnsAuth; |  | ||||||
| var dnsRecord; |  | ||||||
| 
 |  | ||||||
| BACME.thumbprint = function (opts) { |  | ||||||
|   // https://stackoverflow.com/questions/42588786/how-to-fingerprint-a-jwk
 |  | ||||||
| 
 |  | ||||||
|   var accountJwk = opts.jwk; |  | ||||||
|   var keys; |  | ||||||
| 
 |  | ||||||
|   if (/^EC/i.test(opts.jwk.kty)) { |  | ||||||
|     keys = [ 'crv', 'kty', 'x', 'y' ]; |  | ||||||
|   } else if (/^RS/i.test(opts.jwk.kty)) { |  | ||||||
|     keys = [ 'e', 'kty', 'n' ]; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   var accountPublicStr = '{' + keys.map(function (key) { |  | ||||||
|     return '"' + key + '":"' + accountJwk[key] + '"'; |  | ||||||
|   }).join(',') + '}'; |  | ||||||
| 
 |  | ||||||
|   return window.crypto.subtle.digest( |  | ||||||
|     { name: "SHA-256" } // SHA-256 is spec'd, non-optional
 |  | ||||||
|   , textEncoder.encode(accountPublicStr) |  | ||||||
|   ).then(function (hash) { |  | ||||||
|     thumbprint = btoa(Array.prototype.map.call(new Uint8Array(hash), function (ch) { |  | ||||||
|       return String.fromCharCode(ch); |  | ||||||
|     }).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); |  | ||||||
| 
 |  | ||||||
|     console.log('Thumbprint:'); |  | ||||||
|     console.log(opts); |  | ||||||
|     console.log(accountPublicStr); |  | ||||||
|     console.log(thumbprint); |  | ||||||
| 
 |  | ||||||
|     return thumbprint; |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| // { token, thumbprint, challengeDomain }
 |  | ||||||
| BACME.challenges['http-01'] = function (opts) { |  | ||||||
|   // The contents of the key authorization file
 |  | ||||||
|   keyAuth = opts.token + '.' + opts.thumbprint; |  | ||||||
| 
 |  | ||||||
|   // Where the key authorization file goes
 |  | ||||||
|   httpPath = 'http://' + opts.challengeDomain + '/.well-known/acme-challenge/' + opts.token; |  | ||||||
| 
 |  | ||||||
|   console.log("echo '" + keyAuth + "' > '" + httpPath + "'"); |  | ||||||
| 
 |  | ||||||
|   return { |  | ||||||
|     path: httpPath |  | ||||||
|   , value: keyAuth |  | ||||||
|   }; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| // { keyAuth }
 |  | ||||||
| BACME.challenges['dns-01'] = function (opts) { |  | ||||||
|   console.log('opts.keyAuth for DNS:'); |  | ||||||
|   console.log(opts.keyAuth); |  | ||||||
|   return window.crypto.subtle.digest( |  | ||||||
|     { name: "SHA-256", } |  | ||||||
|   , textEncoder.encode(opts.keyAuth) |  | ||||||
|   ).then(function (hash) { |  | ||||||
|     dnsAuth = btoa(Array.prototype.map.call(new Uint8Array(hash), function (ch) { |  | ||||||
|       return String.fromCharCode(ch); |  | ||||||
|     }).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); |  | ||||||
| 
 |  | ||||||
|     dnsRecord = '_acme-challenge.' + opts.challengeDomain; |  | ||||||
| 
 |  | ||||||
|     console.log('DNS TXT Auth:'); |  | ||||||
|     // The name of the record
 |  | ||||||
|     console.log(dnsRecord); |  | ||||||
|     // The TXT record value
 |  | ||||||
|     console.log(dnsAuth); |  | ||||||
| 
 |  | ||||||
|     return { |  | ||||||
|       type: 'TXT' |  | ||||||
|     , host: dnsRecord |  | ||||||
|     , answer: dnsAuth |  | ||||||
|     }; |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| var challengePollUrl; |  | ||||||
| 
 |  | ||||||
| // { jwk, challengeUrl, accountId (kid) }
 |  | ||||||
| BACME.challenges.accept = function (opts) { |  | ||||||
|   var payload64 = BACME._jsto64({}); |  | ||||||
| 
 |  | ||||||
|   return BACME._importKey(opts.jwk).then(function (abstractKey) { |  | ||||||
|     var protected64 = BACME._jsto64( |  | ||||||
|       { nonce: nonce, alg: abstractKey.meta.alg/*'ES256'*/, url: opts.challengeUrl, kid: opts.accountId } |  | ||||||
|     ); |  | ||||||
|     return BACME._sign({ |  | ||||||
|       abstractKey: abstractKey |  | ||||||
|     , payload64: payload64 |  | ||||||
|     , protected64: protected64 |  | ||||||
|     }); |  | ||||||
|   }).then(function (signedAccept) { |  | ||||||
| 
 |  | ||||||
|     nonce = null; |  | ||||||
|     return window.fetch( |  | ||||||
|       opts.challengeUrl |  | ||||||
|     , { mode: 'cors' |  | ||||||
|       , method: 'POST' |  | ||||||
|       , headers: { 'Content-Type': 'application/jose+json' } |  | ||||||
|       , body: JSON.stringify(signedAccept) |  | ||||||
|       } |  | ||||||
|     ).then(function (resp) { |  | ||||||
|       BACME._logHeaders(resp); |  | ||||||
|       nonce = resp.headers.get('replay-nonce'); |  | ||||||
|       console.log("ACCEPT NONCE:", nonce); |  | ||||||
| 
 |  | ||||||
|       return resp.json().then(function (reply) { |  | ||||||
|         challengePollUrl = reply.url; |  | ||||||
| 
 |  | ||||||
|         console.log('Challenge ACK:'); |  | ||||||
|         console.log(JSON.stringify(reply)); |  | ||||||
|         return reply; |  | ||||||
|       }); |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| BACME.challenges.check = function (opts) { |  | ||||||
|   return window.fetch(opts.challengePollUrl, { mode: 'cors' }).then(function (resp) { |  | ||||||
|     BACME._logHeaders(resp); |  | ||||||
| 
 |  | ||||||
|     return resp.json().then(function (reply) { |  | ||||||
|       if (/error/.test(reply.type)) { |  | ||||||
|         return Promise.reject(new Error(reply.detail || reply.type)); |  | ||||||
|       } |  | ||||||
|       challengePollUrl = reply.url; |  | ||||||
| 
 |  | ||||||
|       BACME._logBody(reply); |  | ||||||
| 
 |  | ||||||
|       return reply; |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| var domainKeypair; |  | ||||||
| var domainJwk; |  | ||||||
| 
 |  | ||||||
| BACME.generateKeypair = function (opts) { |  | ||||||
|   var wcOpts = {}; |  | ||||||
| 
 |  | ||||||
|   // ECDSA has only the P curves and an associated bitlength
 |  | ||||||
|   if (/^EC/i.test(opts.type)) { |  | ||||||
|     wcOpts.name = 'ECDSA'; |  | ||||||
|     if (/256/.test(opts.bitlength)) { |  | ||||||
|       wcOpts.namedCurve = 'P-256'; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   // RSA-PSS is another option, but I don't think it's used for Let's Encrypt
 |  | ||||||
|   // I think the hash is only necessary for signing, not generation or import
 |  | ||||||
|   if (/^RS/i.test(opts.type)) { |  | ||||||
|     wcOpts.name = 'RSASSA-PKCS1-v1_5'; |  | ||||||
|     wcOpts.modulusLength = opts.bitlength; |  | ||||||
|     if (opts.bitlength < 2048) { |  | ||||||
|       wcOpts.modulusLength = opts.bitlength * 8; |  | ||||||
|     } |  | ||||||
|     wcOpts.publicExponent = new Uint8Array([0x01, 0x00, 0x01]); |  | ||||||
|     wcOpts.hash = { name: "SHA-256" }; |  | ||||||
|   } |  | ||||||
|   var extractable = true; |  | ||||||
|   return window.crypto.subtle.generateKey( |  | ||||||
|     wcOpts |  | ||||||
|   , extractable |  | ||||||
|   , [ 'sign', 'verify' ] |  | ||||||
|   ); |  | ||||||
| }; |  | ||||||
| BACME.domains = {}; |  | ||||||
| // TODO factor out from BACME.accounts.generateKeypair even more
 |  | ||||||
| BACME.domains.generateKeypair = function (opts) { |  | ||||||
|   return BACME.generateKeypair(opts).then(function (result) { |  | ||||||
|     domainKeypair = result; |  | ||||||
| 
 |  | ||||||
|     return window.crypto.subtle.exportKey( |  | ||||||
|       "jwk" |  | ||||||
|     , result.privateKey |  | ||||||
|     ).then(function (privJwk) { |  | ||||||
| 
 |  | ||||||
|       domainJwk = privJwk; |  | ||||||
|       console.log('private jwk:'); |  | ||||||
|       console.log(JSON.stringify(privJwk, null, 2)); |  | ||||||
| 
 |  | ||||||
|       return privJwk; |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| // { serverJwk, domains }
 |  | ||||||
| BACME.orders.generateCsr = function (opts) { |  | ||||||
|   return BACME._importKey(opts.serverJwk).then(function (abstractKey) { |  | ||||||
|     return Promise.resolve(CSR.generate({ keypair: abstractKey.wcKeypair, domains: opts.domains })); |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| var certificateUrl; |  | ||||||
| 
 |  | ||||||
| // { csr, jwk, finalizeUrl, accountId }
 |  | ||||||
| BACME.orders.finalize = function (opts) { |  | ||||||
|   var payload64 = BACME._jsto64( |  | ||||||
|     { csr: opts.csr } |  | ||||||
|   ); |  | ||||||
| 
 |  | ||||||
|   return BACME._importKey(opts.jwk).then(function (abstractKey) { |  | ||||||
|     var protected64 = BACME._jsto64( |  | ||||||
|       { nonce: nonce, alg: abstractKey.meta.alg/*'ES256'*/, url: opts.finalizeUrl, kid: opts.accountId } |  | ||||||
|     ); |  | ||||||
|     return BACME._sign({ |  | ||||||
|       abstractKey: abstractKey |  | ||||||
|     , payload64: payload64 |  | ||||||
|     , protected64: protected64 |  | ||||||
|     }); |  | ||||||
|   }).then(function (signedFinal) { |  | ||||||
| 
 |  | ||||||
|     nonce = null; |  | ||||||
|     return window.fetch( |  | ||||||
|       opts.finalizeUrl |  | ||||||
|     , { mode: 'cors' |  | ||||||
|       , method: 'POST' |  | ||||||
|       , headers: { 'Content-Type': 'application/jose+json' } |  | ||||||
|       , body: JSON.stringify(signedFinal) |  | ||||||
|       } |  | ||||||
|     ).then(function (resp) { |  | ||||||
|       BACME._logHeaders(resp); |  | ||||||
|       nonce = resp.headers.get('replay-nonce'); |  | ||||||
| 
 |  | ||||||
|       return resp.json().then(function (reply) { |  | ||||||
|         if (/error/.test(reply.type)) { |  | ||||||
|           return Promise.reject(new Error(reply.detail || reply.type)); |  | ||||||
|         } |  | ||||||
|         certificateUrl = reply.certificate; |  | ||||||
|         BACME._logBody(reply); |  | ||||||
| 
 |  | ||||||
|         return reply; |  | ||||||
|       }); |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| BACME.orders.receive = function (opts) { |  | ||||||
|   return window.fetch( |  | ||||||
|     opts.certificateUrl |  | ||||||
|   , { mode: 'cors' |  | ||||||
|     , method: 'GET' |  | ||||||
|     } |  | ||||||
|   ).then(function (resp) { |  | ||||||
|     BACME._logHeaders(resp); |  | ||||||
|     nonce = resp.headers.get('replay-nonce'); |  | ||||||
| 
 |  | ||||||
|     return resp.text().then(function (reply) { |  | ||||||
|       BACME._logBody(reply); |  | ||||||
| 
 |  | ||||||
|       return reply; |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| BACME.orders.check = function (opts) { |  | ||||||
|   return window.fetch( |  | ||||||
|     opts.orderUrl |  | ||||||
|   , { mode: 'cors' |  | ||||||
|     , method: 'GET' |  | ||||||
|     } |  | ||||||
|   ).then(function (resp) { |  | ||||||
|     BACME._logHeaders(resp); |  | ||||||
| 
 |  | ||||||
|     return resp.json().then(function (reply) { |  | ||||||
|       if (/error/.test(reply.type)) { |  | ||||||
|         return Promise.reject(new Error(reply.detail || reply.type)); |  | ||||||
|       } |  | ||||||
|       BACME._logBody(reply); |  | ||||||
| 
 |  | ||||||
|       return reply; |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| }(window)); |  | ||||||
| @ -1,86 +0,0 @@ | |||||||
| /*global Promise*/ |  | ||||||
| (function (exports) { |  | ||||||
| 'use strict'; |  | ||||||
| 
 |  | ||||||
| var Keypairs = exports.Keypairs = {}; |  | ||||||
| 
 |  | ||||||
| Keypairs._stance = "We take the stance that if you're knowledgeable enough to" |  | ||||||
|   + " properly and securely use non-standard crypto then you shouldn't need Bluecrypt anyway."; |  | ||||||
| Keypairs._universal = "Bluecrypt only supports crypto with standard cross-browser and cross-platform support."; |  | ||||||
| Keypairs.generate = function (opts) { |  | ||||||
|   var wcOpts = {}; |  | ||||||
|   if (!opts) { |  | ||||||
|     opts = {}; |  | ||||||
|   } |  | ||||||
|   if (!opts.kty) { |  | ||||||
|     opts.kty = 'EC'; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   // ECDSA has only the P curves and an associated bitlength |  | ||||||
|   if (/^EC/i.test(opts.kty)) { |  | ||||||
|     wcOpts.name = 'ECDSA'; |  | ||||||
|     if (!opts.namedCurve) { |  | ||||||
|       opts.namedCurve = 'P-256'; |  | ||||||
|     } |  | ||||||
|     wcOpts.namedCurve = opts.namedCurve; // true for supported curves |  | ||||||
|     if (/256/.test(wcOpts.namedCurve)) { |  | ||||||
|       wcOpts.namedCurve = 'P-256'; |  | ||||||
|       wcOpts.hash = { name: "SHA-256" }; |  | ||||||
|     } else if (/384/.test(wcOpts.namedCurve)) { |  | ||||||
|       wcOpts.namedCurve = 'P-384'; |  | ||||||
|       wcOpts.hash = { name: "SHA-384" }; |  | ||||||
|     } else { |  | ||||||
|       return Promise.Reject(new Error("'" + wcOpts.namedCurve + "' is not an NIST approved ECDSA namedCurve. " |  | ||||||
|         + " Please choose either 'P-256' or 'P-384'. " |  | ||||||
|         + Keypairs._stance)); |  | ||||||
|     } |  | ||||||
|   } else if (/^RSA$/i.test(opts.kty)) { |  | ||||||
|     // Support PSS? I don't think it's used for Let's Encrypt |  | ||||||
|     wcOpts.name = 'RSASSA-PKCS1-v1_5'; |  | ||||||
|     if (!opts.modulusLength) { |  | ||||||
|       opts.modulusLength = 2048; |  | ||||||
|     } |  | ||||||
|     wcOpts.modulusLength = opts.modulusLength; |  | ||||||
|     if (wcOpts.modulusLength >= 2048 && wcOpts.modulusLength < 3072) { |  | ||||||
|       // erring on the small side... for no good reason |  | ||||||
|       wcOpts.hash = { name: "SHA-256" }; |  | ||||||
|     } else if (wcOpts.modulusLength >= 3072 && wcOpts.modulusLength < 4096) { |  | ||||||
|       wcOpts.hash = { name: "SHA-384" }; |  | ||||||
|     } else if (wcOpts.modulusLength < 4097) { |  | ||||||
|       wcOpts.hash = { name: "SHA-512" }; |  | ||||||
|     } else { |  | ||||||
|       // Public key thumbprints should be paired with a hash of similar length, |  | ||||||
|       // so anything above SHA-512's keyspace would be left under-represented anyway. |  | ||||||
|       return Promise.Reject(new Error("'" + wcOpts.modulusLength + "' is not within the safe and universally" |  | ||||||
|         + " acceptable range of 2048-4096. Typically you should pick 2048, 3072, or 4096, though other values" |  | ||||||
|         + " divisible by 8 are allowed. " + Keypairs._stance)); |  | ||||||
|     } |  | ||||||
|     // TODO maybe allow this to be set to any of the standard values? |  | ||||||
|     wcOpts.publicExponent = new Uint8Array([0x01, 0x00, 0x01]); |  | ||||||
|   } else { |  | ||||||
|     return Promise.Reject(new Error("'" + opts.kty + "' is not a well-supported key type." |  | ||||||
|       + Keypairs._universal |  | ||||||
|       + " Please choose either 'EC' or 'RSA' keys.")); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   var extractable = true; |  | ||||||
|   return window.crypto.subtle.generateKey( |  | ||||||
|     wcOpts |  | ||||||
|   , extractable |  | ||||||
|   , [ 'sign', 'verify' ] |  | ||||||
|   ).then(function (result) { |  | ||||||
|     return window.crypto.subtle.exportKey( |  | ||||||
|       "jwk" |  | ||||||
|     , result.privateKey |  | ||||||
|     ).then(function (privJwk) { |  | ||||||
|       // TODO remove |  | ||||||
|       console.log('private jwk:'); |  | ||||||
|       console.log(JSON.stringify(privJwk, null, 2)); |  | ||||||
|       return { |  | ||||||
|         privateKey: privJwk |  | ||||||
|       }; |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| }(window)); |  | ||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user