WIP Building out all features necessary for Let's Encrypt #6

Closed
coolaj86 wants to merge 17 commits from more-acme into master
13 changed files with 754 additions and 1023 deletions

181
app.js
View File

@ -6,7 +6,9 @@
var Rasha = window.Rasha;
var Eckles = window.Eckles;
var x509 = window.x509;
var CSR = window.CSR;
var ACME = window.ACME;
var accountStuff = {};
function $(sel) {
return document.querySelector(sel);
@ -15,6 +17,14 @@
return Array.prototype.slice.call(document.querySelectorAll(sel));
}
function checkTos(tos) {
if ($('input[name="tos"]:checked')) {
return tos;
} else {
return '';
}
}
function run() {
console.log('hello');
@ -51,8 +61,10 @@
, namedCurve: $('input[name="ec-crv"]:checked').value
, modulusLength: $('input[name="rsa-len"]:checked').value
};
var then = Date.now();
console.log('opts', opts);
Keypairs.generate(opts).then(function (results) {
console.log("Key generation time:", (Date.now() - then) + "ms");
var pubDer;
var privDer;
if (/EC/i.test(opts.kty)) {
@ -101,6 +113,9 @@
$$('input').map(function ($el) { $el.disabled = false; });
$$('button').map(function ($el) { $el.disabled = false; });
$('.js-toc-jwk').hidden = false;
$('.js-create-account').hidden = false;
$('.js-create-csr').hidden = false;
});
});
@ -110,56 +125,25 @@
$('.js-loading').hidden = false;
var acme = ACME.create({
Keypairs: Keypairs
, CSR: CSR
});
acme.init('https://acme-staging-v02.api.letsencrypt.org/directory').then(function (result) {
console.log('acme result', result);
var privJwk = JSON.parse($('.js-jwk').innerText).private;
var email = $('.js-email').innerText;
function checkTos(tos) {
console.log("TODO checkbox for agree to terms");
return tos;
}
var email = $('.js-email').value;
return acme.accounts.create({
email: email
, agreeToTerms: checkTos
, accountKeypair: { privateKeyJwk: privJwk }
}).then(function (account) {
console.log("account created result:", account);
return Keypairs.generate({
kty: 'RSA'
, modulusLength: 2048
}).then(function (pair) {
console.log('domain keypair:', pair);
var domains = ($('.js-domains').innerText||'example.com').split(/[, ]+/g);
return acme.certificates.create({
accountKeypair: { privateKeyJwk: privJwk }
, account: account
, domainKeypair: { privateKeyJwk: pair.private }
, email: email
, domains: domains
, agreeToTerms: checkTos
, challenges: {
'dns-01': {
set: function (opts) {
console.log('dns-01 set challenge:');
console.log(JSON.stringify(opts, null, 2));
return new Promise(function (resolve) {
while (!window.confirm("Did you set the challenge?")) {}
resolve();
});
}
, remove: function (opts) {
console.log('dns-01 remove challenge:');
console.log(JSON.stringify(opts, null, 2));
return new Promise(function (resolve) {
while (!window.confirm("Did you delete the challenge?")) {}
resolve();
});
}
}
}
});
});
accountStuff.account = account;
accountStuff.privateJwk = privJwk;
accountStuff.email = email;
accountStuff.acme = acme;
$('.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) {
console.error("A bad thing happened:");
console.error(err);
@ -168,8 +152,123 @@
});
});
$('form.js-csr').addEventListener('submit', function (ev) {
ev.preventDefault();
ev.stopPropagation();
generateCsr();
});
$('form.js-acme-order').addEventListener('submit', function (ev) {
ev.preventDefault();
ev.stopPropagation();
var account = accountStuff.account;
var privJwk = accountStuff.privateJwk;
var email = accountStuff.email;
var acme = accountStuff.acme;
var domains = ($('.js-domains').value||'example.com').split(/[, ]+/g);
return getDomainPrivkey().then(function (domainPrivJwk) {
console.log('Has CSR already?');
console.log(accountStuff.csr);
return acme.certificates.create({
accountKeypair: { privateKeyJwk: privJwk }
, account: account
, domainKeypair: { privateKeyJwk: domainPrivJwk }
, csr: accountStuff.csr
, email: email
, domains: domains
, skipDryRun: $('input[name="skip-dryrun"]:checked') && true
, agreeToTerms: checkTos
, challenges: {
'dns-01': {
set: function (opts) {
console.info('dns-01 set challenge:');
console.info('TXT', opts.dnsHost);
console.info(opts.dnsAuthorization);
return new Promise(function (resolve) {
while (!window.confirm("Did you set the challenge?")) {}
resolve();
});
}
, remove: function (opts) {
console.log('dns-01 remove challenge:');
console.info('TXT', opts.dnsHost);
console.info(opts.dnsAuthorization);
return new Promise(function (resolve) {
while (!window.confirm("Did you delete the challenge?")) {}
resolve();
});
}
}
, 'http-01': {
set: function (opts) {
console.info('http-01 set challenge:');
console.info(opts.challengeUrl);
console.info(opts.keyAuthorization);
return new Promise(function (resolve) {
while (!window.confirm("Did you set the challenge?")) {}
resolve();
});
}
, remove: function (opts) {
console.log('http-01 remove challenge:');
console.info(opts.challengeUrl);
console.info(opts.keyAuthorization);
return new Promise(function (resolve) {
while (!window.confirm("Did you delete the challenge?")) {}
resolve();
});
}
}
}
, 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) {
console.error("challenge failed:");
console.error(err);
window.alert("failed! " + err.message || JSON.stringify(err));
});
});
});
$('.js-generate').hidden = false;
$('.js-create-account').hidden = false;
}
function getDomainPrivkey() {
if (accountStuff.domainPrivateJwk) { return Promise.resolve(accountStuff.domainPrivateJwk); }
return Keypairs.generate({
kty: $('input[name="kty"]:checked').value
, namedCurve: $('input[name="ec-crv"]:checked').value
, modulusLength: $('input[name="rsa-len"]:checked').value
}).then(function (pair) {
console.log('domain keypair:', pair);
accountStuff.domainPrivateJwk = pair.private;
return pair.private;
});
}
function generateCsr() {
var domains = ($('.js-domains').value||'example.com').split(/[, ]+/g);
//var privJwk = JSON.parse($('.js-jwk').innerText).private;
return getDomainPrivkey().then(function (privJwk) {
accountStuff.domainPrivateJwk = privJwk;
return CSR({ jwk: privJwk, domains: domains }).then(function (pem) {
// Verify with https://www.sslshopper.com/csr-decoder.html
accountStuff.csr = pem;
console.log('Created CSR:');
console.log(pem);
console.log('CSR info:');
console.log(CSR._info(pem));
return pem;
});
});
}
window.addEventListener('load', run);

View File

@ -34,27 +34,21 @@
</div>
<div class="js-ec-opts">
<p>EC Options:</p>
<input type="radio" id="-crv2"
name="ec-crv" value="P-256" checked>
<label for="-crv2">P-256</label>
<input type="radio" id="-crv3"
name="ec-crv" value="P-384">
<label for="-crv3">P-384</label>
<!-- input type="radio" id="-crv5"
name="ec-crv" value="P-521">
<label for="-crv5">P-521</label -->
<label for="-crv2"><input type="radio" id="-crv2"
name="ec-crv" value="P-256" checked>P-256</label>
<label for="-crv3"><input type="radio" id="-crv3"
name="ec-crv" value="P-384">P-384</label>
<!-- label for="-crv5"><input type="radio" id="-crv5"
name="ec-crv" value="P-521">P-521</label -->
</div>
<div class="js-rsa-opts" hidden>
<p>RSA Options:</p>
<input type="radio" id="-modlen2"
name="rsa-len" value="2048" checked>
<label for="-modlen2">2048</label>
<input type="radio" id="-modlen3"
name="rsa-len" value="3072">
<label for="-modlen3">3072</label>
<input type="radio" id="-modlen5"
name="rsa-len" value="4096">
<label for="-modlen5">4096</label>
<label for="-modlen2"><input type="radio" id="-modlen2"
name="rsa-len" value="2048" checked>2048</label>
<label for="-modlen3"><input type="radio" id="-modlen3"
name="rsa-len" value="3072">3072</label>
<label for="-modlen5"><input type="radio" id="-modlen5"
name="rsa-len" value="4096">4096</label>
</div>
<button class="js-generate" hidden>Generate</button>
</form>
@ -62,14 +56,36 @@
<h2>ACME Account</h2>
<form class="js-acme-account">
<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="-acmeDomains">Domains:</label>
<input class="js-domains" type="text" id="-acmeDomains">
<label for="-acmeTos"><input class="js-tos" name="tos" type="checkbox" id="-acmeTos" checked>
Agree to Let's Encrypt Terms of Service</label>
<br>
<button class="js-create-account" hidden>Create Account</button>
</form>
<h2>Certificate Signing Request</h2>
<form class="js-csr">
<label for="-acmeDomains">Domains:</label>
<input class="js-domains" type="text" id="-acmeDomains" value="example.com www.example.com">
<br>
<button class="js-create-csr" hidden>Create CSR</button>
</form>
<h2>ACME Certificate Order</h2>
<form class="js-acme-order">
Challenge type:
<label for="-http01"><input type="radio" id="-http01"
name="acme-challenge-type" value="http-01" checked>http-01</label>
<label for="-dns01"><input type="radio" id="-dns01"
name="acme-challenge-type" value="dns-01">dns-01</label>
<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>
</form>
<div class="js-loading" hidden>Loading</div>
<details class="js-toc-jwk" hidden>
@ -104,20 +120,22 @@
<summary>PEM Public (base64-encoded SPKI/PKIX DER)</summary>
<pre><code class="js-input-pem-spki-public" ></code></pre>
</details>
<details class="js-toc-acme-account-request" hidden>
<summary>ACME Account Request</summary>
<pre><code class="js-acme-account-request">&nbsp;</code></pre>
</details>
<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">&nbsp;</code></pre>
</details>
<details class="js-toc-acme-order-response" hidden>
<summary>ACME Order Response</summary>
<pre><code class="js-acme-order-response">&nbsp;</code></pre>
</details>
<script src="./lib/bluecrypt-encoding.js"></script>
<script src="./lib/asn1-packer.js"></script>
<script src="./lib/x509.js"></script>
<script src="./lib/ecdsa.js"></script>
<script src="./lib/rsa.js"></script>
<script src="./lib/keypairs.js"></script>
<script src="./lib/asn1-parser.js"></script>
<script src="./lib/csr.js"></script>
<script src="./lib/acme.js"></script>
<script src="./app.js"></script>
</body>

View File

@ -8,6 +8,7 @@
var ACME = exports.ACME = {};
//var Keypairs = exports.Keypairs || {};
//var CSR = exports.CSR;
var Enc = exports.Enc || {};
var Crypto = exports.Crypto || {};
@ -29,20 +30,19 @@ ACME.challengePrefixes = {
};
ACME.challengeTests = {
'http-01': function (me, auth) {
var url = 'http://' + auth.hostname + ACME.challengePrefixes['http-01'] + '/' + auth.token;
return me.request({ method: 'GET', url: url }).then(function (resp) {
return me.http01(auth).then(function (keyAuth) {
var err;
// TODO limit the number of bytes that are allowed to be downloaded
if (auth.keyAuthorization === resp.body.toString('utf8').trim()) {
if (auth.keyAuthorization === (keyAuth||'').trim()) {
return true;
}
err = new Error(
"Error: Failed HTTP-01 Pre-Flight / Dry Run.\n"
+ "curl '" + url + "'\n"
+ "curl '" + auth.challengeUrl + "'\n"
+ "Expected: '" + auth.keyAuthorization + "'\n"
+ "Got: '" + resp.body + "'\n"
+ "Got: '" + keyAuth + "'\n"
+ "See https://git.coolaj86.com/coolaj86/acme-v2.js/issues/4"
);
err.code = 'E_FAIL_DRY_CHALLENGE';
@ -51,10 +51,7 @@ ACME.challengeTests = {
}
, 'dns-01': function (me, auth) {
// remove leading *. on wildcard domains
return me.dig({
type: 'TXT'
, name: auth.dnsHost
}).then(function (ans) {
return me.dns01(auth).then(function (ans) {
var err;
if (ans.answer.some(function (txt) {
@ -154,7 +151,7 @@ ACME._registerAccount = function (me, options) {
, kid: options.externalAccount.id
, url: me._directoryUrls.newAccount
}
, payload: Enc.strToBuf(JSON.stringify(pair.public))
, payload: Enc.binToBuf(JSON.stringify(pair.public))
}).then(function (jws) {
body.externalAccountBinding = jws;
return body;
@ -288,10 +285,6 @@ ACME._testChallengeOptions = function () {
];
};
ACME._testChallenges = function (me, options) {
if (me.skipChallengeTest) {
return Promise.resolve();
}
var CHECK_DELAY = 0;
return Promise.all(options.domains.map(function (identifierValue) {
// TODO we really only need one to pass, not all to pass
@ -311,6 +304,12 @@ ACME._testChallenges = function (me, options) {
+ " You must enable one of ( " + suitable + " )."
));
}
// TODO remove skipChallengeTest
if (me.skipDryRun || me.skipChallengeTest) {
return null;
}
if ('dns-01' === challenge.type) {
// Give the nameservers a moment to propagate
CHECK_DELAY = 1.5 * 1000;
@ -326,17 +325,27 @@ ACME._testChallenges = function (me, options) {
, expires: new Date(Date.now() + (60 * 1000)).toISOString()
, wildcard: identifierValue.includes('*.') || undefined
};
// The dry-run comes first in the spirit of "fail fast"
// (and protecting against challenge failure rate limits)
var dryrun = true;
return ACME._challengeToAuth(me, options, results, challenge, dryrun).then(function (auth) {
if (!me._canUse[auth.type]) { return; }
return ACME._setChallenge(me, options, auth).then(function () {
return auth;
});
});
});
})).then(function (auths) {
auths = auths.filter(Boolean);
if (!auths.length) { /*skip actual test*/ return; }
return ACME._wait(CHECK_DELAY).then(function () {
return Promise.all(auths.map(function (auth) {
return ACME.challengeTests[auth.type](me, auth);
return ACME.challengeTests[auth.type](me, auth).then(function (result) {
// not a blocker
ACME._removeChallenge(me, options, auth);
return result;
});
}));
});
});
@ -390,6 +399,7 @@ ACME._challengeToAuth = function (me, options, request, challenge, dryrun) {
// keyAuthorization = token || '.' || base64url(JWK_Thumbprint(accountKey))
auth.keyAuthorization = challenge.token + '.' + auth.thumbprint;
// conflicts with ACME challenge id url is already in use, so we call this challengeUrl instead
// TODO auth.http01Url ?
auth.challengeUrl = 'http://' + auth.identifier.value + ACME.challengePrefixes['http-01'] + '/' + auth.token;
auth.dnsHost = dnsPrefix + '.' + auth.hostname.replace('*.', '');
@ -436,11 +446,11 @@ ACME._postChallenge = function (me, options, auth) {
*/
function deactivate() {
if (me.debug) { console.debug('[acme-v2.js] deactivate:'); }
return ACME._jwsRequest({
return ACME._jwsRequest(me, {
options: options
, url: auth.url
, protected: { kid: options._kid }
, payload: Enc.strToBuf(JSON.stringify({ "status": "deactivated" }))
, payload: Enc.binToBuf(JSON.stringify({ "status": "deactivated" }))
}).then(function (resp) {
if (me.debug) { console.debug('deactivate challenge: resp.body:'); }
if (me.debug) { console.debug(resp.body); }
@ -478,18 +488,7 @@ ACME._postChallenge = function (me, options, auth) {
if (me.debug) { console.debug('poll: valid'); }
try {
if (1 === options.removeChallenge.length) {
options.removeChallenge(auth).then(function () {}, function () {});
} else if (2 === options.removeChallenge.length) {
options.removeChallenge(auth, function (err) { return err; });
} else {
if (!ACME._removeChallengeWarn) {
console.warn("Please update to acme-v2 removeChallenge(options) <Promise> or removeChallenge(options, cb).");
console.warn("The API has been changed for compatibility with all ACME / Let's Encrypt challenge types.");
ACME._removeChallengeWarn = true;
}
options.removeChallenge(auth.request.identifier, auth.token, function () {});
}
ACME._removeChallenge(me, options, auth);
} catch(e) {}
return resp.body;
}
@ -511,11 +510,11 @@ ACME._postChallenge = function (me, options, auth) {
function respondToChallenge() {
if (me.debug) { console.debug('[acme-v2.js] responding to accept challenge:'); }
return ACME._jwsRequest({
return ACME._jwsRequest(me, {
options: options
, url: auth.url
, protected: { kid: options._kid }
, payload: Enc.strToBuf(JSON.stringify({}))
, payload: Enc.binToBuf(JSON.stringify({}))
}).then(function (resp) {
if (me.debug) { console.debug('respond to challenge: resp.body:'); }
if (me.debug) { console.debug(resp.body); }
@ -526,8 +525,6 @@ ACME._postChallenge = function (me, options, auth) {
return respondToChallenge();
};
ACME._setChallenge = function (me, options, auth) {
console.log('challenge auth:', auth);
console.log('challenges:', options.challenges);
return new Promise(function (resolve, reject) {
var challengers = options.challenges || {};
var challenger = (challengers[auth.type] && challengers[auth.type].set) || options.setChallenge;
@ -572,11 +569,11 @@ ACME._finalizeOrder = function (me, options, validatedDomains) {
function pollCert() {
if (me.debug) { console.debug('[acme-v2.js] pollCert:'); }
return ACME._jwsRequest({
return ACME._jwsRequest(me, {
options: options
, url: options._finalize
, protected: { kid: options._kid }
, payload: Enc.strToBuf(payload)
, payload: Enc.binToBuf(payload)
}).then(function (resp) {
if (me.debug) { console.debug('order finalized: resp.body:'); }
if (me.debug) { console.debug(resp.body); }
@ -674,6 +671,14 @@ ACME._getCertificate = function (me, options) {
return Promise.reject(new Error("options.challengeTypes (string array) must be specified"
+ " (and in order of preferential priority)."));
}
if (options.csr) {
// TODO validate csr signature
options._csr = me.CSR._info(options.csr);
options.domains = options._csr.altnames;
if (options._csr.subject !== options.domains[0]) {
return Promise.reject(new Error("certificate subject (commonName) does not match first altname (SAN)"));
}
}
if (!(options.domains && options.domains.length)) {
return Promise.reject(new Error("options.domains must be a list of string domain names,"
+ " with the first being the subject of the certificate (or options.subject must specified)."));
@ -713,14 +718,15 @@ ACME._getCertificate = function (me, options) {
var payload = JSON.stringify(body);
if (me.debug) { console.debug('\n[DEBUG] newOrder\n'); }
return ACME._jwsRequest({
return ACME._jwsRequest(me, {
options: options
, url: me._directoryUrls.newOrder
, protected: { kid: options._kid }
, payload: Enc.strToBuf(payload)
, payload: Enc.binToBuf(payload)
}).then(function (resp) {
var location = resp.headers.location;
var setAuths;
var validAuths = [];
var auths = [];
if (me.debug) { console.debug('[ordered]', location); } // the account id url
if (me.debug) { console.debug(resp); }
@ -765,16 +771,32 @@ ACME._getCertificate = function (me, options) {
});
}
function challengeNext() {
function checkNext() {
var auth = auths.shift();
if (!auth) { return; }
if (!me._canUse[auth.type] || me.skipChallengeTest) {
// not so much "valid" as "not invalid"
// but in this case we can't confirm either way
validAuths.push(auth);
return Promise.resolve();
}
return ACME.challengeTests[auth.type](me, auth).then(function () {
validAuths.push(auth);
}).then(checkNext);
}
function challengeNext() {
var auth = validAuths.shift();
if (!auth) { return; }
return ACME._postChallenge(me, options, auth).then(challengeNext);
}
// First we set every challenge
// Then we ask for each challenge to be checked
// Doing otherwise would potentially cause us to poison our own DNS cache with misses
return setNext().then(challengeNext).then(function () {
return setNext().then(checkNext).then(challengeNext).then(function () {
if (me.debug) { console.debug("[getCertificate] next.then"); }
var validatedDomains = body.identifiers.map(function (ident) {
return ident.value;
@ -805,8 +827,19 @@ ACME._getCertificate = function (me, options) {
});
};
ACME._generateCsrWeb64 = function (me, options, validatedDomains) {
return ACME._importKeypair(me, options.domainKeypair).then(function (/*pair*/) {
return me.Keypairs.generateCsr(options.domainKeypair, validatedDomains).then(function (der) {
var csr;
if (options.csr) {
csr = options.csr;
// if der, convert to base64
if ('string' !== typeof csr) { csr = Enc.bufToUrlBase64(csr); }
// nix PEM headers, if any
if ('-' === csr[0]) { csr = csr.split(/\n+/).slice(1, -1).join(''); }
csr = Enc.base64ToUrlBase64(csr.trim().replace(/\s+/g, ''));
return Promise.resolve(csr);
}
return ACME._importKeypair(me, options.domainKeypair).then(function (pair) {
return me.CSR({ jwk: pair.private, domains: validatedDomains, encoding: 'der' }).then(function (der) {
return Enc.bufToUrlBase64(der);
});
});
@ -816,23 +849,25 @@ ACME.create = function create(me) {
if (!me) { me = {}; }
// me.debug = true;
me.challengePrefixes = ACME.challengePrefixes;
me.Keypairs = me.Keypairs || me.RSA || require('rsa-compat').RSA;
me.Keypairs = me.Keypairs || exports.Keypairs || require('keypairs').Keypairs;
me.CSR = me.CSR || exports.cSR || require('CSR').CSR;
me._nonces = [];
me._canUse = {};
if (!me._baseUrl) {
me._baseUrl = "";
}
//me.Keypairs = me.Keypairs || require('keypairs');
//me.request = me.request || require('@root/request');
if (!me.dig) {
me.dig = function (query) {
// TODO use digd.js
return new me.request({ url: "/api/dns/" + query.name + "?type=" + query.type }).then(function (resp) {
if (!resp.body || !Array.isArray(resp.body.answer)) {
throw new Error("failed to get DNS response");
}
return {
answer: resp.body.answer.map(function (ans) {
return { data: ans.data, ttl: ans.ttl };
})
if (!me.dns01) {
me.dns01 = function (auth) {
return ACME._dns01(me, auth);
};
});
}
// backwards compat
if (!me.dig) { me.dig = me.dns01; }
if (!me.http01) {
me.http01 = function (auth) {
return ACME._http01(me, auth);
};
}
@ -853,9 +888,22 @@ ACME.create = function create(me) {
if ('string' !== typeof me.directoryUrl) {
throw new Error("you must supply either the ACME directory url as a string or an object of the ACME urls");
}
var p = Promise.resolve();
if (!me.skipChallengeTest) {
p = me.request({ url: me._baseUrl + "/api/_acme_api_/" }).then(function (resp) {
if (resp.body.success) {
me._canCheck['http-01'] = true;
me._canCheck['dns-01'] = true;
}
}).catch(function () {
// ignore
});
}
return p.then(function () {
return ACME._directory(me).then(function (resp) {
return fin(resp.body);
});
});
};
me.accounts = {
create: function (options) {
@ -876,6 +924,10 @@ ACME._jwsRequest = function (me, bigopts) {
bigopts.protected.nonce = nonce;
bigopts.protected.url = bigopts.url;
// protected.alg: added by Keypairs.signJws
if (!bigopts.protected.jwk) {
// protected.kid must be overwritten due to ACME's interpretation of the spec
if (!bigopts.protected.kid) { bigopts.protected.kid = bigopts.options._kid; }
}
return me.Keypairs.signJws(
{ jwk: bigopts.options.accountKeypair.privateKeyJwk
, protected: bigopts.protected
@ -992,6 +1044,48 @@ ACME._prnd = function (n) {
ACME._toHex = function (pair) {
return parseInt(pair, 10).toString(16);
};
ACME._dns01 = function (me, auth) {
return new me.request({ url: me._baseUrl + "/api/dns/" + auth.dnsHost + "?type=TXT" }).then(function (resp) {
var err;
if (!resp.body || !Array.isArray(resp.body.answer)) {
err = new Error("failed to get DNS response");
console.error(err);
throw err;
}
if (!resp.body.answer.length) {
err = new Error("failed to get DNS answer record in response");
console.error(err);
throw err;
}
return {
answer: resp.body.answer.map(function (ans) {
return { data: ans.data, ttl: ans.ttl };
})
};
});
};
ACME._http01 = function (me, auth) {
var url = encodeURIComponent(auth.challengeUrl);
return new me.request({ url: me._baseUrl + "/api/http?url=" + url }).then(function (resp) {
return resp.body;
});
};
ACME._removeChallenge = function (me, options, auth) {
var challengers = options.challenges || {};
var removeChallenge = (challengers[auth.type] && challengers[auth.type].remove) || options.removeChallenge;
if (1 === removeChallenge.length) {
removeChallenge(auth).then(function () {}, function () {});
} else if (2 === removeChallenge.length) {
removeChallenge(auth, function (err) { return err; });
} else {
if (!ACME._removeChallengeWarn) {
console.warn("Please update to acme-v2 removeChallenge(options) <Promise> or removeChallenge(options, cb).");
console.warn("The API has been changed for compatibility with all ACME / Let's Encrypt challenge types.");
ACME._removeChallengeWarn = true;
}
removeChallenge(auth.request.identifier, auth.token, function () {});
}
};
Enc.bufToUrlBase64 = function (u8) {
return Enc.bufToBase64(u8)

View File

@ -125,7 +125,7 @@ PEM.parseBlock = PEM.parseBlock || function (str) {
var der = str.split(/\n/).filter(function (line) {
return !/-----/.test(line);
}).join('');
return { der: Enc.base64ToBuf(der) };
return { bytes: Enc.base64ToBuf(der) };
};
Enc.base64ToBuf = function (b64) {

View File

@ -66,8 +66,11 @@ Enc.numToHex = function (d) {
};
Enc.bufToUrlBase64 = function (u8) {
return Enc.bufToBase64(u8)
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
return Enc.base64ToUrlBase64(Enc.bufToBase64(u8));
};
Enc.base64ToUrlBase64 = function (str) {
return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
};
Enc.bufToBase64 = function (u8) {
@ -110,6 +113,8 @@ Enc.binToHex = function (bin) {
return h;
}).join('');
};
// TODO are there any nuance differences here?
Enc.utf8ToHex = Enc.binToHex;
Enc.hexToBase64 = function (hex) {
return btoa(Enc.hexToBin(hex));

View File

@ -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));

298
lib/csr.js Normal file
View File

@ -0,0 +1,298 @@
// Copyright 2018-present AJ ONeal. All rights reserved
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
(function (exports) {
'use strict';
/*global Promise*/
var ASN1 = exports.ASN1;
var Enc = exports.Enc;
var PEM = exports.PEM;
var X509 = exports.x509;
var Keypairs = exports.Keypairs;
// TODO find a way that the prior node-ish way of `module.exports = function () {}` isn't broken
var CSR = exports.CSR = function (opts) {
// We're using a Promise here to be compatible with the browser version
// which will probably use the webcrypto API for some of the conversions
return CSR._prepare(opts).then(function (opts) {
return CSR.create(opts).then(function (bytes) {
return CSR._encode(opts, bytes);
});
});
};
CSR._prepare = function (opts) {
return Promise.resolve().then(function () {
var Keypairs;
opts = JSON.parse(JSON.stringify(opts));
// We do a bit of extra error checking for user convenience
if (!opts) { throw new Error("You must pass options with key and domains to rsacsr"); }
if (!Array.isArray(opts.domains) || 0 === opts.domains.length) {
new Error("You must pass options.domains as a non-empty array");
}
// I need to check that 例.中国 is a valid domain name
if (!opts.domains.every(function (d) {
// allow punycode? xn--
if ('string' === typeof d /*&& /\./.test(d) && !/--/.test(d)*/) {
return true;
}
})) {
throw new Error("You must pass options.domains as strings");
}
if (opts.jwk) { return opts; }
if (opts.key && opts.key.kty) {
opts.jwk = opts.key;
return opts;
}
if (!opts.pem && !opts.key) {
throw new Error("You must pass options.key as a JSON web key");
}
Keypairs = exports.Keypairs;
if (!exports.Keypairs) {
throw new Error("Keypairs.js is an optional dependency for PEM-to-JWK.\n"
+ "Install it if you'd like to use it:\n"
+ "\tnpm install --save rasha\n"
+ "Otherwise supply a jwk as the private key."
);
}
return Keypairs.import({ pem: opts.pem || opts.key }).then(function (pair) {
opts.jwk = pair.private;
return opts;
});
});
};
CSR._encode = function (opts, bytes) {
if ('der' === (opts.encoding||'').toLowerCase()) {
return bytes;
}
return PEM.packBlock({
type: "CERTIFICATE REQUEST"
, bytes: bytes /* { jwk: jwk, domains: opts.domains } */
});
};
CSR.create = function createCsr(opts) {
var hex = CSR.request(opts.jwk, opts.domains);
return CSR._sign(opts.jwk, hex).then(function (csr) {
return Enc.hexToBuf(csr);
});
};
//
// EC / RSA
//
CSR.request = function createCsrBodyEc(jwk, domains) {
var asn1pub;
if (/^EC/i.test(jwk.kty)) {
asn1pub = X509.packCsrEcPublicKey(jwk);
} else {
asn1pub = X509.packCsrRsaPublicKey(jwk);
}
return X509.packCsr(asn1pub, domains);
};
CSR._sign = function csrEcSig(jwk, request) {
// Took some tips from https://gist.github.com/codermapuche/da4f96cdb6d5ff53b7ebc156ec46a10a
// TODO will have to convert web ECDSA signatures to PEM ECDSA signatures (but RSA should be the same)
// TODO have a consistent non-private way to sign
return Keypairs._sign({ jwk: jwk, format: 'x509' }, Enc.hexToBuf(request)).then(function (sig) {
return CSR._toDer({ request: request, signature: sig, kty: jwk.kty });
});
};
CSR._toDer = function encode(opts) {
var sty;
if (/^EC/i.test(opts.kty)) {
// 1.2.840.10045.4.3.2 ecdsaWithSHA256 (ANSI X9.62 ECDSA algorithm with SHA256)
sty = ASN1('30', ASN1('06', '2a8648ce3d040302'));
} else {
// 1.2.840.113549.1.1.11 sha256WithRSAEncryption (PKCS #1)
sty = ASN1('30', ASN1('06', '2a864886f70d01010b'), ASN1('05'));
}
return ASN1('30'
// The Full CSR Request Body
, opts.request
// The Signature Type
, sty
// The Signature
, ASN1.BitStr(Enc.bufToHex(opts.signature))
);
};
X509.packCsr = function (asn1pubkey, domains) {
return ASN1('30'
// Version (0)
, ASN1.UInt('00')
// 2.5.4.3 commonName (X.520 DN component)
, ASN1('30', ASN1('31', ASN1('30', ASN1('06', '550403'), ASN1('0c', Enc.utf8ToHex(domains[0])))))
// Public Key (RSA or EC)
, asn1pubkey
// Request Body
, ASN1('a0'
, ASN1('30'
// 1.2.840.113549.1.9.14 extensionRequest (PKCS #9 via CRMF)
, ASN1('06', '2a864886f70d01090e')
, ASN1('31'
, ASN1('30'
, ASN1('30'
// 2.5.29.17 subjectAltName (X.509 extension)
, ASN1('06', '551d11')
, ASN1('04'
, ASN1('30', domains.map(function (d) {
return ASN1('82', Enc.utf8ToHex(d));
}).join(''))))))))
);
};
// TODO finish this later
// we want to parse the domains, the public key, and verify the signature
CSR._info = function (der) {
// standard base64 PEM
if ('string' === typeof der && '-' === der[0]) {
der = PEM.parseBlock(der).bytes;
}
// jose urlBase64 not-PEM
if ('string' === typeof der) {
der = Enc.base64ToBuf(der);
}
// not supporting binary-encoded bas64
var c = ASN1.parse(der);
var kty;
// A cert has 3 parts: cert, signature meta, signature
if (c.children.length !== 3) {
throw new Error("doesn't look like a certificate request: expected 3 parts of header");
}
var sig = c.children[2];
if (sig.children.length) {
// ASN1/X509 EC
sig = sig.children[0];
sig = ASN1('30', ASN1.UInt(Enc.bufToHex(sig.children[0].value)), ASN1.UInt(Enc.bufToHex(sig.children[1].value)));
sig = Enc.hexToBuf(sig);
kty = 'EC';
} else {
// Raw RSA Sig
sig = sig.value;
kty = 'RSA';
}
//c.children[1]; // signature type
var req = c.children[0];
// TODO utf8
if (4 !== req.children.length) {
throw new Error("doesn't look like a certificate request: expected 4 parts to request");
}
// 0 null
// 1 commonName / subject
var sub = Enc.bufToBin(req.children[1].children[0].children[0].children[1].value);
// 3 public key (type, key)
//console.log('oid', Enc.bufToHex(req.children[2].children[0].children[0].value));
var pub;
// TODO reuse ASN1 parser for these?
if ('EC' === kty) {
// throw away compression byte
pub = req.children[2].children[1].value.slice(1);
pub = { kty: kty, x: pub.slice(0, 32), y: pub.slice(32) };
while (0 === pub.x[0]) { pub.x = pub.x.slice(1); }
while (0 === pub.y[0]) { pub.y = pub.y.slice(1); }
if ((pub.x.length || pub.x.byteLength) > 48) {
pub.crv = 'P-521';
} else if ((pub.x.length || pub.x.byteLength) > 32) {
pub.crv = 'P-384';
} else {
pub.crv = 'P-256';
}
pub.x = Enc.bufToUrlBase64(pub.x);
pub.y = Enc.bufToUrlBase64(pub.y);
} else {
pub = req.children[2].children[1].children[0];
pub = { kty: kty, n: pub.children[0].value, e: pub.children[1].value };
while (0 === pub.n[0]) { pub.n = pub.n.slice(1); }
while (0 === pub.e[0]) { pub.e = pub.e.slice(1); }
pub.n = Enc.bufToUrlBase64(pub.n);
pub.e = Enc.bufToUrlBase64(pub.e);
}
// 4 extensions
var domains = req.children[3].children.filter(function (seq) {
// 1.2.840.113549.1.9.14 extensionRequest (PKCS #9 via CRMF)
if ('2a864886f70d01090e' === Enc.bufToHex(seq.children[0].value)) {
return true;
}
}).map(function (seq) {
return seq.children[1].children[0].children.filter(function (seq2) {
// subjectAltName (X.509 extension)
if ('551d11' === Enc.bufToHex(seq2.children[0].value)) {
return true;
}
}).map(function (seq2) {
return seq2.children[1].children[0].children.map(function (name) {
// TODO utf8
return Enc.bufToBin(name.value);
});
})[0];
})[0];
return {
subject: sub
, altnames: domains
, jwk: pub
, signature: sig
};
};
X509.packCsrRsaPublicKey = function (jwk) {
// Sequence the key
var n = ASN1.UInt(Enc.base64ToHex(jwk.n));
var e = ASN1.UInt(Enc.base64ToHex(jwk.e));
var asn1pub = ASN1('30', n, e);
// Add the CSR pub key header
return ASN1('30', ASN1('30', ASN1('06', '2a864886f70d010101'), ASN1('05')), ASN1.BitStr(asn1pub));
};
X509.packCsrEcPublicKey = function (jwk) {
var ecOid = X509._oids[jwk.crv];
if (!ecOid) {
throw new Error("Unsupported namedCurve '" + jwk.crv + "'. Supported types are " + Object.keys(X509._oids));
}
var cmp = '04'; // 04 == x+y, 02 == x-only
var hxy = '';
// Placeholder. I'm not even sure if compression should be supported.
if (!jwk.y) { cmp = '02'; }
hxy += Enc.base64ToHex(jwk.x);
if (jwk.y) { hxy += Enc.base64ToHex(jwk.y); }
// 1.2.840.10045.2.1 ecPublicKey
return ASN1('30', ASN1('30', ASN1('06', '2a8648ce3d0201'), ASN1('06', ecOid)), ASN1.BitStr(cmp + hxy));
};
X509._oids = {
// 1.2.840.10045.3.1.7 prime256v1
// (ANSI X9.62 named elliptic curve) (06 08 - 2A 86 48 CE 3D 03 01 07)
'P-256': '2a8648ce3d030107'
// 1.3.132.0.34 P-384 (06 05 - 2B 81 04 00 22)
// (SEC 2 recommended EC domain secp256r1)
, 'P-384': '2b81040022'
// requires more logic and isn't a recommended standard
// 1.3.132.0.35 P-521 (06 05 - 2B 81 04 00 23)
// (SEC 2 alternate P-521)
//, 'P-521': '2B 81 04 00 23'
};
// don't replace the full parseBlock, if it exists
PEM.parseBlock = PEM.parseBlock || function (str) {
var der = str.split(/\n/).filter(function (line) {
return !/-----/.test(line);
}).join('');
return { bytes: Enc.base64ToBuf(der) };
};
}('undefined' === typeof window ? module.exports : window));

View File

@ -180,24 +180,12 @@ Keypairs.signJws = function (opts) {
var msg = protected64 + '.' + payload64;
return Keypairs._sign(opts, msg).then(function (buf) {
/*
* This will come back into play for CSRs, but not for JOSE
if ('EC' === opts.jwk.kty) {
// ECDSA JWT signatures differ from "normal" ECDSA signatures
// https://tools.ietf.org/html/rfc7518#section-3.4
binsig = convertIfEcdsa(binsig);
}
*/
var signedMsg = {
protected: protected64
, payload: payload64
, signature: Enc.bufToUrlBase64(buf)
};
console.log('Signed Base64 Msg:');
console.log(JSON.stringify(signedMsg, null, 2));
console.log('msg:', msg);
return signedMsg;
});
}
@ -212,40 +200,6 @@ Keypairs.signJws = function (opts) {
}
});
};
Keypairs._convertIfEcdsa = function (binsig) {
// should have asn1 sequence header of 0x30
if (0x30 !== binsig[0]) { throw new Error("Impossible EC SHA head marker"); }
var index = 2; // first ecdsa "R" header byte
var len = binsig[1];
var lenlen = 0;
// Seek length of length if length is greater than 127 (i.e. two 512-bit / 64-byte R and S values)
if (0x80 & len) {
lenlen = len - 0x80; // should be exactly 1
len = binsig[2]; // should be <= 130 (two 64-bit SHA-512s, plus padding)
index += lenlen;
}
// should be of BigInt type
if (0x02 !== binsig[index]) { throw new Error("Impossible EC SHA R marker"); }
index += 1;
var rlen = binsig[index];
var bits = 32;
if (rlen > 49) {
bits = 64;
} else if (rlen > 33) {
bits = 48;
}
var r = binsig.slice(index + 1, index + 1 + rlen).toString('hex');
var slen = binsig[index + 1 + rlen + 1]; // skip header and read length
var s = binsig.slice(index + 1 + rlen + 1 + 1).toString('hex');
if (2 *slen !== s.length) { throw new Error("Impossible EC SHA S length"); }
// There may be one byte of padding on either
while (r.length < 2*bits) { r = '00' + r; }
while (s.length < 2*bits) { s = '00' + s; }
if (2*(bits+1) === r.length) { r = r.slice(2); }
if (2*(bits+1) === s.length) { s = s.slice(2); }
return Enc.hexToBuf(r + s);
};
Keypairs._sign = function (opts, payload) {
return Keypairs._import(opts).then(function (privkey) {
@ -259,9 +213,14 @@ Keypairs._sign = function (opts, payload) {
, privkey
, payload
).then(function (signature) {
// convert buffer to urlsafe base64
//return Enc.bufToUrlBase64(new Uint8Array(signature));
return new Uint8Array(signature);
signature = new Uint8Array(signature); // ArrayBuffer -> u8
// This will come back into play for CSRs, but not for JOSE
if ('EC' === opts.jwk.kty && /x509|asn1/i.test(opts.format)) {
return Keypairs._ecdsaJoseSigToAsn1Sig(signature);
} else {
// jose/jws/jwt
return signature;
}
});
});
};
@ -287,7 +246,6 @@ Keypairs._getName = function (opts) {
return 'RSASSA-PKCS1-v1_5';
}
};
Keypairs._import = function (opts) {
return Promise.resolve().then(function () {
var ops;
@ -301,7 +259,6 @@ Keypairs._import = function (opts) {
opts.jwk.ext = true;
opts.jwk.key_ops = ops;
console.log('jwk', opts.jwk);
return window.crypto.subtle.importKey(
"jwk"
, opts.jwk
@ -316,6 +273,30 @@ Keypairs._import = function (opts) {
});
});
};
// ECDSA JOSE / JWS / JWT signatures differ from "normal" ASN1/X509 ECDSA signatures
// https://tools.ietf.org/html/rfc7518#section-3.4
Keypairs._ecdsaJoseSigToAsn1Sig = function (bufsig) {
// it's easier to do the manipulation in the browser with an array
bufsig = Array.from(bufsig);
var hlen = bufsig.length / 2; // should be even
var r = bufsig.slice(0, hlen);
var s = bufsig.slice(hlen);
// unpad positive ints less than 32 bytes wide
while (!r[0]) { r = r.slice(1); }
while (!s[0]) { s = s.slice(1); }
// pad (or re-pad) ambiguously non-negative BigInts, up to 33 bytes wide
if (0x80 & r[0]) { r.unshift(0); }
if (0x80 & s[0]) { s.unshift(0); }
var len = 2 + r.length + 2 + s.length;
var head = [0x30];
// hard code 0x80 + 1 because it won't be longer than
// two SHA512 plus two pad bytes (130 bytes <= 256)
if (len >= 0x80) { head.push(0x81); }
head.push(len);
return Uint8Array.from(head.concat([0x02, r.length], r, [0x02, s.length], s));
};
function setTime(time) {
if ('number' === typeof time) { return time; }

View File

@ -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));

View File

@ -1,6 +1,6 @@
'use strict';
(function (exports) {
'use strict';
var x509 = exports.x509 = {};
var ASN1 = exports.ASN1;
var Enc = exports.Enc;

6
package-lock.json generated
View File

@ -4,6 +4,12 @@
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@root/request": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@root/request/-/request-1.3.10.tgz",
"integrity": "sha512-GSn8dfsGp0juJyXS9k7B/DjYm7Axe85wiCHfPs30eQ+/V6p2aqey45e1czb3ZwP+iPmzWCKXahhWnZhSDIil6w==",
"dev": true
},
"accepts": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.6.tgz",

View File

@ -29,6 +29,7 @@
"author": "AJ ONeal <coolaj86@gmail.com> (https://coolaj86.com/)",
"license": "MPL-2.0",
"devDependencies": {
"@root/request": "^1.3.10",
"dig.js": "^1.3.9",
"dns-suite": "^1.2.12",
"express": "^4.16.4"

View File

@ -3,6 +3,7 @@
var crypto = require('crypto');
//var dnsjs = require('dns-suite');
var dig = require('dig.js/dns-request');
var request = require('util').promisify(require('@root/request'));
var express = require('express');
var app = express();
@ -10,10 +11,9 @@ var nameservers = require('dns').getServers();
var index = crypto.randomBytes(2).readUInt16BE(0) % nameservers.length;
var nameserver = nameservers[index];
app.use('/', express.static('./'));
app.use('/', express.static(__dirname));
app.use('/api', express.json());
app.get('/api/dns/:domain', function (req, res, next) {
console.log(req.params);
var domain = req.params.domain;
var casedDomain = domain.toLowerCase().split('').map(function (ch) {
// dns0x20 takes advantage of the fact that the binary operation for toUpperCase is
@ -117,9 +117,23 @@ app.get('/api/dns/:domain', function (req, res, next) {
dig.resolveJson(query, opts);
});
app.get('/api/http', function (req, res) {
var url = req.query.url;
return request({ method: 'GET', url: url }).then(function (resp) {
res.send(resp.body);
});
});
app.get('/api/_acme_api_', function (req, res) {
res.send({ success: true });
});
// curl -L http://localhost:3000/api/dns/example.com?type=A
console.log("Listening on localhost:3000");
app.listen(3000);
console.log("Try this:");
console.log("\tcurl -L 'http://localhost:3000/api/dns/example.com?type=A'");
module.exports = app;
if (require.main === module) {
// curl -L http://localhost:3000/api/dns/example.com?type=A
console.info("Listening on localhost:3000");
app.listen(3000);
console.info("Try this:");
console.info("\tcurl -L 'http://localhost:3000/api/_acme_api_/'");
console.info("\tcurl -L 'http://localhost:3000/api/dns/example.com?type=A'");
console.info("\tcurl -L 'http://localhost:3000/api/http/?url=https://example.com'");
}