diff --git a/js/app.js b/js/app.js index d2ecc3e..c8b5802 100644 --- a/js/app.js +++ b/js/app.js @@ -5,6 +5,8 @@ var $qsa = function (s) { return window.document.querySelectorAll(s); }; var info = {}; var steps = {}; + var nonce; + var kid; var i = 1; //$qs('.js-acme-directory-url').value = 'https://acme-v02.api.letsencrypt.org/directory'; @@ -16,10 +18,12 @@ }); } - $qs('.js-acme-form-domains').addEventListener('submit', function (ev) { - ev.preventDefault(); - steps[i].submit(ev); - i += 1; + $qsa('.js-acme-form').forEach(function ($el) { + $el.addEventListener('submit', function (ev) { + ev.preventDefault(); + steps[i].submit(ev); + i += 1; + }); }); steps[1] = function () { @@ -33,7 +37,11 @@ return BACME.directory($qs('.js-acme-directory-url').value).then(function (directory) { $qs('.js-acme-tos-url').href = directory.meta.termsOfService; - steps[i](); + return BACME.nonce().then(function (_nonce) { + nonce = _nonce; + + steps[i](); + }); }); }; @@ -42,16 +50,94 @@ $qs('.js-acme-form-account').hidden = false; }; steps[2].submit = function () { - info.contact = [ 'mailto:' + $qs('.js-acme-account-email').value ]; + var email = $qs('.js-acme-account-email').value.toLowerCase().trim(); + + info.contact = [ 'mailto:' + email ]; info.agree = $qs('.js-acme-account-tos').checked; info.greenlockAgree = $qs('.js-gl-tos').checked; // TODO - // create account key - // create account - // capture email - // submit challenges - // populate challenges in table - steps[i](); + // options for + // * regenerate key + // * ECDSA / RSA / bitlength + + // TODO ping with version and account creation + + var jwk = JSON.parse(localStorage.getItem('account:' + email) || 'null'); + var p; + + function createKeypair() { + return BACME.accounts.generateKeypair({ + type: 'ECDSA' + , bitlength: '256' + }).then(function (jwk) { + localStorage.setItem('account:' + email, JSON.stringify(jwk)); + return jwk; + }) + } + + if (jwk) { + p = Promise.resolve(jwk); + } else { + p = createKeypair(); + } + + function createAccount(jwk) { + console.log('account jwk:'); + console.log(jwk); + delete jwk.key_ops; + return BACME.accounts.sign({ + jwk: jwk + , contacts: [ 'mailto:' + email ] + , agree: info.agree + , nonce: nonce + , kid: kid + }).then(function (signedAccount) { + return BACME.accounts.set({ + signedAccount: signedAccount + }).then(function (account) { + console.log('account:'); + console.log(account); + kid = account.kid; + return kid; + }); + }); + } + + return p.then(function (_jwk) { + jwk = _jwk; + kid = JSON.parse(localStorage.getItem('account-kid:' + email) || 'null'); + var p2 + + // TODO save account id rather than always retrieving it + if (kid) { + p2 = Promise.resolve(kid); + } else { + p2 = createAccount(jwk); + } + + return p2.then(function (_kid) { + kid = _kid; + return BACME.orders.sign({ + jwk: jwk + , identifiers: info.identifiers + , kid: kid + }).then(function (signedOrder) { + return BACME.orders.create({ + signedOrder: signedOrder + }).then(function (/*challengeIndexes*/) { + return BACME.challenges.all().then(function (challenges) { + console.log('challenges:'); + console.log(challenges); + // TODO populate challenges in table + steps[i](); + }); + }); + }); + }); + }).catch(function (err) { + console.error('Step \'' + i + '\' Error:'); + console.error(err); + }); }; steps[3] = function () { diff --git a/js/bacme.js b/js/bacme.js index 6f68173..1cbf4a0 100644 --- a/js/bacme.js +++ b/js/bacme.js @@ -58,11 +58,36 @@ BACME.nonce = function () { }; BACME.accounts = {}; -BACME.accounts.generateKeypair = function () { + +// type = ECDSA +// bitlength = 256 +BACME.accounts.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" }; + } + // https://github.com/diafygi/webcrypto-examples#ecdsa---generatekey var extractable = true; return webCrypto.subtle.generateKey( - { name: "ECDSA", namedCurve: "P-256" } + wcOpts , extractable , [ 'sign', 'verify' ] ).then(function (result) { @@ -71,11 +96,11 @@ BACME.accounts.generateKeypair = function () { return webCrypto.subtle.exportKey( "jwk" , result.privateKey - ).then(function (jwk) { + ).then(function (privJwk) { - accountJwk = jwk; + accountJwk = privJwk; console.log('private jwk:'); - console.log(JSON.stringify(jwk, null, 2)); + console.log(JSON.stringify(privJwk, null, 2)); return webCrypto.subtle.exportKey( "pkcs8" @@ -84,7 +109,8 @@ BACME.accounts.generateKeypair = function () { console.log('pkcs8:'); console.log(Array.from(new Uint8Array(keydata))); - return accountKeypair; + return privJwk; + //return accountKeypair; }); }) }); @@ -97,63 +123,137 @@ BACME._jsto64 = function (json) { var textEncoder = new TextEncoder(); +BACME._importKey = function (jwk) { + var alg; // I think the 256 refers to the hash + var wcOpts = {}; + var extractable = false; + + // ECDSA + if (/^EC/i.test(jwk.kty)) { + wcOpts.name = 'ECDSA'; + wcOpts.namedCurve = jwk.crv; + alg = 'ES256'; + } + + // RSA + if (/^RS/i.test(jwk.kty)) { + wcOpts.name = 'RSASSA-PKCS1-v1_5'; + wcOpts.hash = { name: "SHA-256" }; + alg = 'RS256'; + } + + return window.crypto.subtle.importKey( + "jwk" + , jwk + , wcOpts + , extractable + , [ "sign"/*, "verify"*/ ] + ).then(function (keypair) { + return { + wcKey: keypair + , meta: { + alg: alg + , name: wcOpts.name + , hash: wcOpts.hash + } + , jwk: jwk + }; + }); +}; +BACME._sign = function (opts) { + var wcPrivKey = opts.abstractKey.wcKey; + 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 -BACME.accounts.sign = function (email) { - var payload64 = BACME._jsto64( - { termsOfServiceAgreed: true - , onlyReturnExisting: false - , contact: [ 'mailto:' + email ] - } - ); +// jwk = { ... } +// agree = true +BACME.accounts.sign = function (opts) { - var protected64 = BACME._jsto64( - { nonce: nonce - , url: accountUrl - , alg: 'ES256' - , jwk: { - kty: accountJwk.kty - , crv: accountJwk.crv - , x: accountJwk.x - , y: accountJwk.y - } - } - ); + return BACME._importKey(opts.jwk).then(function (abstractKey) { - // Note: this function hashes before signing so send data, not the hash - return window.crypto.subtle.sign( - { name: "ECDSA", hash: { name: "SHA-256" } } - , accountKeypair.privateKey - , textEncoder.encode(protected64 + '.' + payload64) - ).then(function (signature) { + var payloadJson = + { termsOfServiceAgreed: opts.agree + , onlyReturnExisting: false + , contact: opts.contacts || [ 'mailto:' + opts.email ] + }; + console.log('payload:'); + console.log(payloadJson); + var payload64 = BACME._jsto64( + payloadJson + ); - // 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, ''); + // TODO RSA + var protectedJson = + { nonce: opts.nonce + , url: accountUrl + , alg: abstractKey.meta.alg + , jwk: { + kty: opts.jwk.kty + , crv: opts.jwk.crv + , x: opts.jwk.x + , y: opts.jwk.y + } + }; + console.log('protected:'); + console.log(protectedJson); + var protected64 = BACME._jsto64( + protectedJson + ); - console.log('URL-safe Base64 Signature:'); - console.log(sig64); - - signedAccount = { - protected: protected64 - , payload: payload64 - , signature: sig64 - }; - console.log('Signed Base64 Account:'); - console.log(JSON.stringify(signedAccount, null, 2)); - }); + // Note: this function hashes before signing so send data, not the hash + return BACME._sign({ + abstractKey: abstractKey + , payload64: payload64 + , protected64: protected64 + }); + }); }; var account; var accountId; -BACME.accounts.set = function () { +BACME.accounts.set = function (opts) { nonce = null; return window.fetch(accountUrl, { mode: 'cors' , method: 'POST' , headers: { 'Content-Type': 'application/jose+json' } - , body: JSON.stringify(signedAccount) + , body: JSON.stringify(opts.signedAccount) }).then(function (resp) { BACME._logHeaders(resp); nonce = resp.headers.get('replay-nonce'); @@ -163,11 +263,18 @@ BACME.accounts.set = function () { if (!resp.headers.get('content-type')) { console.log('Body: '); - return; + + 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; }); }); }; @@ -178,37 +285,29 @@ var signedOrder; BACME.orders = {}; // identifiers = [ { type: 'dns', value: 'example.com' }, { type: 'dns', value: '*.example.com' } ] -BACME.orders.sign = function (identifiers) { - var payload64 = jsto64({ identifiers: identifiers }); +// signedAccount +BACME.orders.sign = function (opts) { + var payload64 = BACME._jsto64({ identifiers: opts.identifiers }); - var protected64 = jsto64( - { nonce: nonce, alg: 'ES256', url: orderUrl, kid: accountId } + var protected64 = BACME._jsto64( + { nonce: nonce, alg: 'ES256', url: orderUrl, kid: opts.kid } ); - return window.crypto.subtle.sign( - { name: "ECDSA", hash: { name: "SHA-256" } } - , accountKeypair.privateKey - , textEncoder.encode(protected64 + '.' + payload64) - ).then(function (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('URL-safe Base64 Signature:'); - console.log(sig64); - - signedOrder = { - protected: protected64 - , payload: payload64 - , signature: sig64 - }; - console.log('Signed Base64 Order:'); - console.log(JSON.stringify(signedAccount, null, 2)); - - return signedOrder; - }); + return BACME._importKey(opts.jwk).then(function (abstractKey) { + 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 order; @@ -216,25 +315,26 @@ var currentOrderUrl; var authorizationUrls; var finalizeUrl; -BACME.orders.create = function () { +BACME.orders.create = function (opts) { nonce = null; return window.fetch(orderUrl, { mode: 'cors' , method: 'POST' , headers: { 'Content-Type': 'application/jose+json' } - , body: JSON.stringify(signedOrder) + , body: JSON.stringify(opts.signedOrder) }).then(function (resp) { - console.log('Headers:'); - Array.from(resp.headers.entries()).forEach(function (h) { console.log(h[0] + ': ' + h[1]); }); + 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; - console.log('Body:'); - console.log(JSON.stringify(result, null, 2)); + BACME._logBody(result); return result; }); @@ -242,6 +342,22 @@ BACME.orders.create = function () { }; 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; @@ -273,10 +389,19 @@ var httpPath; var dnsAuth; var dnsRecord; -BACME.thumbprint = function () { +BACME.thumbprint = function (opts) { // https://stackoverflow.com/questions/42588786/how-to-fingerprint-a-jwk - var accountPublicStr = '{' + ['crv', 'kty', 'x', 'y'].map(function (key) { + var accountJwk = opts.jwk; + var keys; + + if (/^EC/i.test(opts.jwk.kty)) { + keys = [ 'e', 'kty', 'n' ]; + } else if (/^RS/i.test(opts.jwk.kty)) { + keys = [ 'crv', 'kty', 'x', 'y' ]; + } + + var accountPublicStr = '{' + keys.map(function (key) { return '"' + key + '":"' + accountJwk[key] + '"'; }).join(',') + '}'; @@ -338,11 +463,11 @@ BACME.challenges['dns-01'] = function () { var challengePollUrl; BACME.challenges.accept = function () { - var payload64 = jsto64( + var payload64 = BACME._jsto64( {} ); - var protected64 = jsto64( + var protected64 = BACME._jsto64( { nonce: nonce, alg: 'ES256', url: challengeUrl, kid: accountId } ); @@ -371,8 +496,7 @@ BACME.challenges.accept = function () { , body: JSON.stringify(body) } ).then(function (resp) { - console.log('Headers:'); - Array.from(resp.headers.entries()).forEach(function (h) { console.log(h[0] + ': ' + h[1]); }); + BACME._logHeaders(resp); nonce = resp.headers.get('replay-nonce'); return resp.json().then(function (reply) { @@ -435,11 +559,11 @@ BACME.orders.generateCsr = function (keypair, domains) { var certificateUrl; BACME.orders.finalize = function () { - var payload64 = jsto64( + var payload64 = BACME._jsto64( { csr: csr } ); - var protected64 = jsto64( + var protected64 = BACME._jsto64( { nonce: nonce, alg: 'ES256', url: finalizeUrl, kid: accountId } );