WIP Building out all features necessary for Let's Encrypt #6
181
app.js
181
app.js
|
@ -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);
|
||||
|
|
70
index.html
70
index.html
|
@ -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"> </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"> </code></pre>
|
||||
</details>
|
||||
<details class="js-toc-acme-order-response" hidden>
|
||||
<summary>ACME Order Response</summary>
|
||||
<pre><code class="js-acme-order-response"> </code></pre>
|
||||
</details>
|
||||
<script src="./lib/bluecrypt-encoding.js"></script>
|
||||
<script src="./lib/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>
|
||||
|
|
208
lib/acme.js
208
lib/acme.js
|
@ -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,8 +888,21 @@ 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");
|
||||
}
|
||||
return ACME._directory(me).then(function (resp) {
|
||||
return fin(resp.body);
|
||||
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 = {
|
||||
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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));
|
|
@ -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));
|
|
@ -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; }
|
||||
|
|
|
@ -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));
|
|
@ -1,6 +1,6 @@
|
|||
'use strict';
|
||||
(function (exports) {
|
||||
'use strict';
|
||||
|
||||
var x509 = exports.x509 = {};
|
||||
var ASN1 = exports.ASN1;
|
||||
var Enc = exports.Enc;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
132
server.js
132
server.js
|
@ -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,16 +11,15 @@ 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
|
||||
// ch = ch | 0x20;
|
||||
return Math.round(Math.random()) % 2 ? ch : ch.toUpperCase();
|
||||
}).join('');
|
||||
// dns0x20 takes advantage of the fact that the binary operation for toUpperCase is
|
||||
// ch = ch | 0x20;
|
||||
return Math.round(Math.random()) % 2 ? ch : ch.toUpperCase();
|
||||
}).join('');
|
||||
var typ = req.query.type;
|
||||
var query = {
|
||||
header: {
|
||||
|
@ -40,60 +40,60 @@ app.get('/api/dns/:domain', function (req, res, next) {
|
|||
}
|
||||
]
|
||||
};
|
||||
var opts = {
|
||||
var opts = {
|
||||
onError: function (err) {
|
||||
next(err);
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
, onMessage: function (packet) {
|
||||
var fail0x20;
|
||||
var fail0x20;
|
||||
|
||||
if (packet.id !== query.id) {
|
||||
console.error('[SECURITY] ignoring packet for \'' + packet.question[0].name + '\' due to mismatched id');
|
||||
console.error(packet);
|
||||
return;
|
||||
}
|
||||
if (packet.id !== query.id) {
|
||||
console.error('[SECURITY] ignoring packet for \'' + packet.question[0].name + '\' due to mismatched id');
|
||||
console.error(packet);
|
||||
return;
|
||||
}
|
||||
|
||||
packet.question.forEach(function (q) {
|
||||
// if (-1 === q.name.lastIndexOf(cli.casedQuery))
|
||||
if (q.name !== casedDomain) {
|
||||
fail0x20 = q.name;
|
||||
}
|
||||
});
|
||||
packet.question.forEach(function (q) {
|
||||
// if (-1 === q.name.lastIndexOf(cli.casedQuery))
|
||||
if (q.name !== casedDomain) {
|
||||
fail0x20 = q.name;
|
||||
}
|
||||
});
|
||||
|
||||
[ 'question', 'answer', 'authority', 'additional' ].forEach(function (group) {
|
||||
(packet[group]||[]).forEach(function (a) {
|
||||
var an = a.name;
|
||||
var i = domain.toLowerCase().lastIndexOf(a.name.toLowerCase()); // answer is something like ExAMPle.cOM and query was wWw.ExAMPle.cOM
|
||||
var j = a.name.toLowerCase().lastIndexOf(domain.toLowerCase()); // answer is something like www.ExAMPle.cOM and query was ExAMPle.cOM
|
||||
[ 'question', 'answer', 'authority', 'additional' ].forEach(function (group) {
|
||||
(packet[group]||[]).forEach(function (a) {
|
||||
var an = a.name;
|
||||
var i = domain.toLowerCase().lastIndexOf(a.name.toLowerCase()); // answer is something like ExAMPle.cOM and query was wWw.ExAMPle.cOM
|
||||
var j = a.name.toLowerCase().lastIndexOf(domain.toLowerCase()); // answer is something like www.ExAMPle.cOM and query was ExAMPle.cOM
|
||||
|
||||
// it's important to note that these should only relpace changes in casing that we expected
|
||||
// any abnormalities should be left intact to go "huh?" about
|
||||
// TODO detect abnormalities?
|
||||
if (-1 !== i) {
|
||||
// "EXamPLE.cOm".replace("wWw.EXamPLE.cOm".substr(4), "www.example.com".substr(4))
|
||||
a.name = a.name.replace(casedDomain.substr(i), domain.substr(i));
|
||||
} else if (-1 !== j) {
|
||||
// "www.example.com".replace("EXamPLE.cOm", "example.com")
|
||||
a.name = a.name.substr(0, j) + a.name.substr(j).replace(casedDomain, domain);
|
||||
}
|
||||
// it's important to note that these should only relpace changes in casing that we expected
|
||||
// any abnormalities should be left intact to go "huh?" about
|
||||
// TODO detect abnormalities?
|
||||
if (-1 !== i) {
|
||||
// "EXamPLE.cOm".replace("wWw.EXamPLE.cOm".substr(4), "www.example.com".substr(4))
|
||||
a.name = a.name.replace(casedDomain.substr(i), domain.substr(i));
|
||||
} else if (-1 !== j) {
|
||||
// "www.example.com".replace("EXamPLE.cOm", "example.com")
|
||||
a.name = a.name.substr(0, j) + a.name.substr(j).replace(casedDomain, domain);
|
||||
}
|
||||
|
||||
// NOTE: right now this assumes that anything matching the query matches all the way to the end
|
||||
// it does not handle the case of a record for example.com.uk being returned in response to a query for www.example.com correctly
|
||||
// (but I don't think it should need to)
|
||||
if (a.name.length !== an.length) {
|
||||
console.error("[ERROR] question / answer mismatch: '" + an + "' != '" + a.length + "'");
|
||||
console.error(a);
|
||||
}
|
||||
});
|
||||
});
|
||||
// NOTE: right now this assumes that anything matching the query matches all the way to the end
|
||||
// it does not handle the case of a record for example.com.uk being returned in response to a query for www.example.com correctly
|
||||
// (but I don't think it should need to)
|
||||
if (a.name.length !== an.length) {
|
||||
console.error("[ERROR] question / answer mismatch: '" + an + "' != '" + a.length + "'");
|
||||
console.error(a);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (fail0x20) {
|
||||
console.warn(";; Warning: DNS 0x20 security not implemented (or packet spoofed). Queried '"
|
||||
+ casedDomain + "' but got response for '" + fail0x20 + "'.");
|
||||
return;
|
||||
}
|
||||
if (fail0x20) {
|
||||
console.warn(";; Warning: DNS 0x20 security not implemented (or packet spoofed). Queried '"
|
||||
+ casedDomain + "' but got response for '" + fail0x20 + "'.");
|
||||
return;
|
||||
}
|
||||
|
||||
res.send({
|
||||
res.send({
|
||||
header: packet.header
|
||||
, question: packet.question
|
||||
, answer: packet.answer
|
||||
|
@ -101,12 +101,12 @@ app.get('/api/dns/:domain', function (req, res, next) {
|
|||
, additional: packet.additional
|
||||
, edns_options: packet.edns_options
|
||||
});
|
||||
}
|
||||
}
|
||||
, onListening: function () {}
|
||||
, onSent: function (/*res*/) { }
|
||||
, onTimeout: function (res) {
|
||||
console.error('dns timeout:', res);
|
||||
next(new Error("DNS timeout - no response"));
|
||||
console.error('dns timeout:', res);
|
||||
next(new Error("DNS timeout - no response"));
|
||||
}
|
||||
, onClose: function () { }
|
||||
//, mdns: cli.mdns
|
||||
|
@ -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'");
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue