From 4fd5fd8bd92b5d91a78f315357144803f8a79662 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Tue, 1 May 2018 07:21:32 +0000 Subject: [PATCH] progress on bacme --- .gitignore | 1 + index.html | 3 + install.sh | 8 + js/app.js | 3 +- js/bacme.js | 478 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 491 insertions(+), 2 deletions(-) create mode 100644 js/bacme.js diff --git a/.gitignore b/.gitignore index 058e9e3..1ddfc8e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ js/pkijs.org +js/browser-csr diff --git a/index.html b/index.html index 50a13ff..260e34b 100644 --- a/index.html +++ b/index.html @@ -14,6 +14,9 @@ + + + diff --git a/install.sh b/install.sh index ba99172..e674dc5 100644 --- a/install.sh +++ b/install.sh @@ -1,6 +1,14 @@ +#!/bin/bash + mkdir -p js/pkijs.org/v1.3.33/ pushd js/pkijs.org/v1.3.33/ wget -c https://raw.githubusercontent.com/PeculiarVentures/PKI.js/41b63af760cacb565dd850fb3466ada4ca163eff/org/pkijs/common.js wget -c https://raw.githubusercontent.com/PeculiarVentures/PKI.js/41b63af760cacb565dd850fb3466ada4ca163eff/org/pkijs/x509_schema.js wget -c https://raw.githubusercontent.com/PeculiarVentures/PKI.js/41b63af760cacb565dd850fb3466ada4ca163eff/org/pkijs/x509_simpl.js wget -c https://raw.githubusercontent.com/PeculiarVentures/ASN1.js/f7181c21c61e53a940ea24373ab489ad86d51bc1/org/pkijs/asn1.js +popd + +mkdir -p js/browser-csr/v1.0.0-alpha/ +pushd js/browser-csr/v1.0.0-alpha/ + wget -c https://git.coolaj86.com/coolaj86/browser-csr.js/raw/commit/c513a862a4e016794da800f0c2eec858b80837ab/csr.js +popd diff --git a/js/app.js b/js/app.js index bfdfcc6..8c0a810 100644 --- a/js/app.js +++ b/js/app.js @@ -1,7 +1,6 @@ (function () { 'use strict'; - console.log("Hello, World!"); - + //window.document.querySelector('.js-acme-directory-url').value = 'https://acme-v02.api.letsencrypt.org/directory'; window.document.querySelector('.js-acme-directory-url').value = 'https://acme-staging-v02.api.letsencrypt.org/directory'; }()); diff --git a/js/bacme.js b/js/bacme.js new file mode 100644 index 0000000..7054187 --- /dev/null +++ b/js/bacme.js @@ -0,0 +1,478 @@ +(function (exports) { +'use strict'; + +var BACME = exports.BACME = {}; +var webFetch = exports.fetch; +var webCrypto = exports.crypto; + +var directoryUrl = 'https://acme-staging-v02.api.letsencrypt.org/directory'; +var directory; + +var nonceUrl = directory.newNonce || 'https://acme-staging-v02.api.letsencrypt.org/acme/new-nonce'; +var nonce; + +var accountKeypair; +var accountJwk; + +var accountUrl = directory.newAccount; +var signedAccount; + +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 (url) { + return webFetch(directoryUrl, { mode: 'cors' }).then(function (resp) { + BACME._logHeaders(resp); + return resp.json().then(function (body) { + directory = body; + BACME._logBody(body); + return body; + }); + }); +}; + +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 = {}; +BACME.accounts.generateKeypair = function () { + // https://github.com/diafygi/webcrypto-examples#ecdsa---generatekey + var extractable = true; + return webCrypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" } + , extractable + , [ 'sign', 'verify' ] + ).then(function (result) { + accountKeypair = result; + + return webCrypto.subtle.exportKey( + "jwk" + , result.privateKey + ).then(function (jwk) { + + accountJwk = jwk; + console.log('private jwk:'); + console.log(JSON.stringify(jwk, null, 2)); + + return webCrypto.subtle.exportKey( + "pkcs8" + , result.privateKey + ).then(function (keydata) { + console.log('pkcs8:'); + console.log(Array.from(new Uint8Array(keydata))); + + 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(); + +// email = john.doe@gmail.com +BACME.accounts.sign = function (email) { + var payload64 = BACME._jsto64( + { termsOfServiceAgreed: true + , onlyReturnExisting: false + , contact: [ 'mailto:' + email ] + } + ); + + var protected64 = BACME._jsto64( + { nonce: nonce + , url: accountUrl + , alg: 'ES256' + , jwk: { + kty: accountJwk.kty + , crv: accountJwk.crv + , x: accountJwk.x + , y: accountJwk.y + } + } + ); + + // 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) { + + // 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); + + signedAccount = { + protected: protected64 + , payload: payload64 + , signature: sig64 + }; + console.log('Signed Base64 Account:'); + console.log(JSON.stringify(signedAccount, null, 2)); + }); +}; + +var account; +var accountId; + +BACME.accounts.set = function () { + nonce = null; + return window.fetch(accountUrl, { + mode: 'cors' + , method: 'POST' + , headers: { 'Content-Type': 'application/jose+json' } + , body: JSON.stringify(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; + } + + return resp.json().then(function (result) { + BACME._logBody(result); + }); + }); +}; + +var orderUrl = directory.newOrder || "https://acme-staging-v02.api.letsencrypt.org/acme/new-order"; +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 }); + + var protected64 = jsto64( + { nonce: nonce, alg: 'ES256', url: orderUrl, kid: accountId } + ); + + 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; + }); +}; + +var order; +var currentOrderUrl; +var authorizationUrls; +var finalizeUrl; + +BACME.orders.create = function () { + nonce = null; + return window.fetch(orderUrl, { + mode: 'cors' + , method: 'POST' + , headers: { 'Content-Type': 'application/jose+json' } + , body: JSON.stringify(signedOrder) + }).then(function (resp) { + console.log('Headers:'); + Array.from(resp.headers.entries()).forEach(function (h) { console.log(h[0] + ': ' + h[1]); }); + currentOrderUrl = resp.headers.get('location'); + nonce = resp.headers.get('replay-nonce'); + console.log('Next nonce:', nonce); + + return resp.json().then(function (result) { + authorizationUrls = result.authorizations; + finalizeUrl = result.finalize; + console.log('Body:'); + console.log(JSON.stringify(result, null, 2)); + + return result; + }); + }); +}; + +BACME.challenges = {}; +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 { token: challenge.token, url: challenge.url, domain: result.identifier.value, challenges: result.challenges }; + }); + }); +}; + +var thumbprint; +var keyAuth; +var httpPath; +var dnsAuth; +var dnsRecord; + +BACME.thumbprint = function () { + // https://stackoverflow.com/questions/42588786/how-to-fingerprint-a-jwk + + var accountPublicStr = '{' + ['crv', 'kty', 'x', 'y'].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(thumbprint); + + return thumbprint; + }); +}; + +BACME.challenges['http-01'] = function () { + // The contents of the key authorization file + keyAuth = token + '.' + thumbprint; + + // Where the key authorization file goes + httpPath = 'http://' + challengeDomain + '/.well-known/acme-challenge/' + token; + + console.log("echo '" + keyAuth + "' > '" + httpPath + "'"); + + return { + path: httpPath + , value: keyAuth + }; +}); +BACME.challenges['dns-01'] = function () { + return window.crypto.subtle.digest( + { name: "SHA-256", } + , textEncoder.encode(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.' + 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; + +BACME.challenges.accept = function () { + var payload64 = jsto64( + {} + ); + + var protected64 = jsto64( + { nonce: nonce, alg: 'ES256', url: challengeUrl, kid: accountId } + ); + + nonce = null; + return window.crypto.subtle.sign( + { name: "ECDSA", hash: { name: "SHA-256" } } + , accountKeypair.privateKey + , textEncoder.encode(protected64 + '.' + payload64) + ).then(function (signature) { + + var sig64 = btoa(Array.prototype.map.call(new Uint8Array(signature), function (ch) { + return String.fromCharCode(ch); + }).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + + var body = { + protected: protected64 + , payload: payload64 + , signature: sig64 + }; + + return window.fetch( + challengeUrl + , { mode: 'cors' + , method: 'POST' + , headers: { 'Content-Type': 'application/jose+json' } + , body: JSON.stringify(body) + } + ).then(function (resp) { + console.log('Headers:'); + Array.from(resp.headers.entries()).forEach(function (h) { console.log(h[0] + ': ' + h[1]); }); + nonce = resp.headers.get('replay-nonce'); + + return resp.json().then(function (reply) { + challengePollUrl = reply.url; + + console.log('Challenge ACK:'); + console.log(JSON.stringify(reply)); + }); + }); + }); +}; + +BACME.challenges.check = function () { + return window.fetch(challengePollUrl, { mode: 'cors' }).then(function (resp) { + BACME._logHeaders(resp); + nonce = resp.headers.get('replay-nonce'); + + return resp.json().then(function (reply) { + challengePollUrl = reply.url; + + BACME._logBody(reply); + + return reply; + }); + }); +}; + +var domainKeypair; +var domainJwk; + +BACME.domains = {}; +// TODO factor out from BACME.accounts.generateKeypair +BACME.domains.generateKeypair = function () { + var extractable = true; + return window.crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" } + , extractable + , [ 'sign', 'verify' ] + ).then(function (result) { + domainKeypair = result; + + return window.crypto.subtle.exportKey( + "jwk" + , result.privateKey + ).then(function (jwk) { + + domainJwk = jwk; + console.log('private jwk:'); + console.log(JSON.stringify(jwk, null, 2)); + + return domainKeypair; + }) + }); +}; + +BACME.order.generateCsr = function (keypair, domains) { + return Promise.resolve(CSR.generate(keypair, domains)); +}; + +var certificateUrl; + +BACME.order.finalize = function () { + var payload64 = jsto64( + { csr: csr } + ); + + var protected64 = jsto64( + { nonce: nonce, alg: 'ES256', url: finalizeUrl, kid: accountId } + ); + + nonce = null; + return window.crypto.subtle.sign( + { name: "ECDSA", hash: { name: "SHA-256" } } + , accountKeypair.privateKey + , textEncoder.encode(protected64 + '.' + payload64) + ).then(function (signature) { + + var sig64 = btoa(Array.prototype.map.call(new Uint8Array(signature), function (ch) { + return String.fromCharCode(ch); + }).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + + var body = { + protected: protected64 + , payload: payload64 + , signature: sig64 + }; + + return window.fetch( + finalizeUrl + , { mode: 'cors' + , method: 'POST' + , headers: { 'Content-Type': 'application/jose+json' } + , body: JSON.stringify(body) + } + ).then(function (resp) { + BACME._logHeaders(resp); + nonce = resp.headers.get('replay-nonce'); + + return resp.json().then(function (reply) { + certificateUrl = reply.certificate; + BACME._logBody(reply); + }); + }); + }); +}; + +}(window));