diff --git a/app.js b/app.js index 968f38d..d144211 100644 --- a/app.js +++ b/app.js @@ -41,7 +41,7 @@ function run() { , namedCurve: $('input[name="ec-crv"]:checked').value , modulusLength: $('input[name="rsa-len"]:checked').value }; - console.log(opts); + console.log('opts', opts); Keypairs.generate(opts).then(function (results) { $('.js-jwk').innerText = JSON.stringify(results, null, 2); // diff --git a/index.html b/index.html index 909a44a..575da3b 100644 --- a/index.html +++ b/index.html @@ -48,6 +48,8 @@
 
+ + diff --git a/lib/acme.js b/lib/acme.js new file mode 100644 index 0000000..4fba0fe --- /dev/null +++ b/lib/acme.js @@ -0,0 +1,699 @@ +/*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: '); + + 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)); diff --git a/lib/ecdsa.js b/lib/ecdsa.js new file mode 100644 index 0000000..dedc4fb --- /dev/null +++ b/lib/ecdsa.js @@ -0,0 +1,112 @@ +/*global Promise*/ +(function (exports) { +'use strict'; + +var EC = exports.Eckles = {}; +if ('undefined' !== typeof module) { module.exports = EC; } +var Enc = {}; +var textEncoder = new TextEncoder(); + +EC._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."; +EC._universal = "Bluecrypt only supports crypto with standard cross-browser and cross-platform support."; +EC.generate = function (opts) { + var wcOpts = {}; + if (!opts) { opts = {}; } + if (!opts.kty) { opts.kty = 'EC'; } + + // ECDSA has only the P curves and an associated bitlength + 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'. " + + EC._stance)); + } + + 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) { + return { + private: privJwk + , public: EC.neuter({ jwk: privJwk }) + }; + }); + }); +}; + +// 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. +EC.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) { + if ('undefined' === typeof opts.jwk[k]) { return; } + // ignore EC private parts + if ('d' === k) { return; } + jwk[k] = JSON.parse(JSON.stringify(opts.jwk[k])); + }); + return jwk; +}; + +// https://stackoverflow.com/questions/42588786/how-to-fingerprint-a-jwk +EC.__thumbprint = function (jwk) { + // Use the same entropy for SHA as for key + var alg = 'SHA-256'; + if (/384/.test(jwk.crv)) { + alg = 'SHA-384'; + } + return window.crypto.subtle.digest( + { name: alg } + , textEncoder.encode('{"crv":"' + jwk.crv + '","kty":"EC","x":"' + jwk.x + '","y":"' + jwk.y + '"}') + ).then(function (hash) { + return Enc.bufToUrlBase64(new Uint8Array(hash)); + }); +}; + +EC.thumbprint = function (opts) { + return Promise.resolve().then(function () { + var jwk; + if ('EC' === opts.kty) { + jwk = opts; + } else if (opts.jwk) { + jwk = opts.jwk; + } else { + return EC.import(opts).then(function (jwk) { + return EC.__thumbprint(jwk); + }); + } + return EC.__thumbprint(jwk); + }); +}; + +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); +}; + +}('undefined' !== typeof module ? module.exports : window)); diff --git a/lib/keypairs.js b/lib/keypairs.js index bf530b8..1f3196c 100644 --- a/lib/keypairs.js +++ b/lib/keypairs.js @@ -3,84 +3,107 @@ 'use strict'; var Keypairs = exports.Keypairs = {}; +var Rasha = exports.Rasha || require('rasha'); +var Eckles = exports.Eckles || require('eckles'); 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 + opts = opts || {}; + var p; + if (!opts.kty) { opts.kty = opts.type; } + if (!opts.kty) { opts.kty = 'EC'; } 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)); - } + p = Eckles.generate(opts); } 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]); + p = Rasha.generate(opts); } else { return Promise.Reject(new Error("'" + opts.kty + "' is not a well-supported key type." + Keypairs._universal - + " Please choose either 'EC' or 'RSA' keys.")); + + " Please choose 'EC', or 'RSA' if you have good reason to.")); } - - 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 - }; + return p.then(function (pair) { + return Keypairs.thumbprint({ jwk: pair.public }).then(function (thumb) { + pair.private.kid = thumb; // maybe not the same id on the private key? + pair.public.kid = thumb; + return pair; }); }); }; -}(window)); + +// Chopping off the private parts is now part of the public API. +// I thought it sounded a little too crude at first, but it really is the best name in every possible way. +Keypairs.neuter = Keypairs._neuter = function (opts) { + // trying to find the best balance of an immutable copy with custom attributes + var jwk = {}; + Object.keys(opts.jwk).forEach(function (k) { + if ('undefined' === typeof opts.jwk[k]) { return; } + // ignore RSA and EC private parts + if (-1 !== ['d', 'p', 'q', 'dp', 'dq', 'qi'].indexOf(k)) { return; } + jwk[k] = JSON.parse(JSON.stringify(opts.jwk[k])); + }); + return jwk; +}; + +Keypairs.thumbprint = function (opts) { + return Promise.resolve().then(function () { + if (/EC/i.test(opts.jwk.kty)) { + return Eckles.thumbprint(opts); + } else { + return Rasha.thumbprint(opts); + } + }); +}; + +Keypairs.publish = function (opts) { + if ('object' !== typeof opts.jwk || !opts.jwk.kty) { throw new Error("invalid jwk: " + JSON.stringify(opts.jwk)); } + + // returns a copy + var jwk = Keypairs.neuter(opts); + + if (jwk.exp) { + jwk.exp = setTime(jwk.exp); + } else { + if (opts.exp) { jwk.exp = setTime(opts.exp); } + else if (opts.expiresIn) { jwk.exp = Math.round(Date.now()/1000) + opts.expiresIn; } + else if (opts.expiresAt) { jwk.exp = opts.expiresAt; } + } + if (!jwk.use && false !== jwk.use) { jwk.use = "sig"; } + + if (jwk.kid) { return Promise.resolve(jwk); } + return Keypairs.thumbprint({ jwk: jwk }).then(function (thumb) { jwk.kid = thumb; return jwk; }); +}; + +function setTime(time) { + if ('number' === typeof time) { return time; } + + var t = time.match(/^(\-?\d+)([dhms])$/i); + if (!t || !t[0]) { + throw new Error("'" + time + "' should be datetime in seconds or human-readable format (i.e. 3d, 1h, 15m, 30s"); + } + + var now = Math.round(Date.now()/1000); + var num = parseInt(t[1], 10); + var unit = t[2]; + var mult = 1; + switch(unit) { + // fancy fallthrough, what fun! + case 'd': + mult *= 24; + /*falls through*/ + case 'h': + mult *= 60; + /*falls through*/ + case 'm': + mult *= 60; + /*falls through*/ + case 's': + mult *= 1; + } + + return now + (mult * num); +} + +}('undefined' !== typeof module ? module.exports : window)); diff --git a/lib/keypairs.js.min2 b/lib/keypairs.js.min2 new file mode 100644 index 0000000..bf530b8 --- /dev/null +++ b/lib/keypairs.js.min2 @@ -0,0 +1,86 @@ +/*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)); diff --git a/lib/rsa.js b/lib/rsa.js new file mode 100644 index 0000000..4ec7e07 --- /dev/null +++ b/lib/rsa.js @@ -0,0 +1,122 @@ +/*global Promise*/ +(function (exports) { +'use strict'; + +var RSA = exports.Rasha = {}; +if ('undefined' !== typeof module) { module.exports = RSA; } +var Enc = {}; +var textEncoder = new TextEncoder(); + +RSA._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."; +RSA._universal = "Bluecrypt only supports crypto with standard cross-browser and cross-platform support."; +RSA.generate = function (opts) { + var wcOpts = {}; + if (!opts) { opts = {}; } + if (!opts.kty) { opts.kty = 'RSA'; } + + // 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. " + RSA._stance)); + } + // TODO maybe allow this to be set to any of the standard values? + wcOpts.publicExponent = new Uint8Array([0x01, 0x00, 0x01]); + + 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) { + return { + private: privJwk + , public: RSA.neuter({ jwk: privJwk }) + }; + }); + }); +}; + +// 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. +RSA.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) { + if ('undefined' === typeof opts.jwk[k]) { return; } + // ignore RSA private parts + if (-1 !== ['d', 'p', 'q', 'dp', 'dq', 'qi'].indexOf(k)) { return; } + jwk[k] = JSON.parse(JSON.stringify(opts.jwk[k])); + }); + return jwk; +}; + +// https://stackoverflow.com/questions/42588786/how-to-fingerprint-a-jwk +RSA.__thumbprint = function (jwk) { + // Use the same entropy for SHA as for key + var len = Math.floor(jwk.n.length * 0.75); + var alg = 'SHA-256'; + // TODO this may be a bug + // need to confirm that the padding is no more or less than 1 byte + if (len >= 511) { + alg = 'SHA-512'; + } else if (len >= 383) { + alg = 'SHA-384'; + } + return window.crypto.subtle.digest( + { name: alg } + , textEncoder.encode('{"e":"' + jwk.e + '","kty":"RSA","n":"' + jwk.n + '"}') + ).then(function (hash) { + return Enc.bufToUrlBase64(new Uint8Array(hash)); + }); +}; + +RSA.thumbprint = function (opts) { + return Promise.resolve().then(function () { + var jwk; + if ('EC' === opts.kty) { + jwk = opts; + } else if (opts.jwk) { + jwk = opts.jwk; + } else { + return RSA.import(opts).then(function (jwk) { + return RSA.__thumbprint(jwk); + }); + } + return RSA.__thumbprint(jwk); + }); +}; + +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); +}; + +}('undefined' !== typeof module ? module.exports : window));