WIP get challenges

This commit is contained in:
AJ ONeal 2019-04-29 00:56:40 -06:00
parent 488067ec20
commit 7385dd8580
3 changed files with 188 additions and 122 deletions

62
app.js
View File

@ -1,3 +1,4 @@
/*global Promise*/
(function () { (function () {
'use strict'; 'use strict';
@ -47,8 +48,8 @@
$$('button').map(function ($el) { $el.disabled = true; }); $$('button').map(function ($el) { $el.disabled = true; });
var opts = { var opts = {
kty: $('input[name="kty"]:checked').value kty: $('input[name="kty"]:checked').value
, namedCurve: $('input[name="ec-crv"]:checked').value , namedCurve: $('input[name="ec-crv"]:checked').value
, modulusLength: $('input[name="rsa-len"]:checked').value , modulusLength: $('input[name="rsa-len"]:checked').value
}; };
console.log('opts', opts); console.log('opts', opts);
Keypairs.generate(opts).then(function (results) { Keypairs.generate(opts).then(function (results) {
@ -112,15 +113,56 @@
}); });
acme.init('https://acme-staging-v02.api.letsencrypt.org/directory').then(function (result) { acme.init('https://acme-staging-v02.api.letsencrypt.org/directory').then(function (result) {
console.log('acme result', 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;
}
return acme.accounts.create({ return acme.accounts.create({
email: $('.js-email').innerText email: email
, agreeToTerms: function (tos) { , agreeToTerms: checkTos
console.log("TODO checkbox for agree to terms"); , accountKeypair: { privateKeyJwk: privJwk }
return tos; }).then(function (account) {
} console.log("account created result:", account);
, accountKeypair: { return Keypairs.generate({
privateKeyJwk: JSON.parse($('.js-jwk').innerText).private 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();
});
}
}
}
});
});
}).catch(function (err) {
console.error("A bad thing happened:");
console.error(err);
}); });
}); });
}); });

View File

@ -63,6 +63,10 @@
<form class="js-acme-account"> <form class="js-acme-account">
<label for="-acmeEmail">Email:</label> <label for="-acmeEmail">Email:</label>
<input class="js-email" type="email" id="-acmeEmail"> <input class="js-email" type="email" id="-acmeEmail">
<br>
<label for="-acmeDomains">Domains:</label>
<input class="js-domains" type="text" id="-acmeDomains">
<br>
<button class="js-create-account" hidden>Create Account</button> <button class="js-create-account" hidden>Create Account</button>
</form> </form>

View File

@ -7,7 +7,7 @@
/* globals Promise */ /* globals Promise */
var ACME = exports.ACME = {}; var ACME = exports.ACME = {};
var Keypairs = exports.Keypairs || {}; //var Keypairs = exports.Keypairs || {};
var Enc = exports.Enc || {}; var Enc = exports.Enc || {};
var Crypto = exports.Crypto || {}; var Crypto = exports.Crypto || {};
@ -90,7 +90,7 @@ ACME._getNonce = function (me) {
break; break;
} }
} }
if (nonce) { return Promise.resolve(nonce); } if (nonce) { return Promise.resolve(nonce.nonce); }
return me.request({ method: 'HEAD', url: me._directoryUrls.newNonce }).then(function (resp) { return me.request({ method: 'HEAD', url: me._directoryUrls.newNonce }).then(function (resp) {
return resp.headers['replay-nonce']; return resp.headers['replay-nonce'];
}); });
@ -132,26 +132,7 @@ ACME._registerAccount = function (me, options) {
return; return;
} }
var jwk = options.accountKeypair.privateKeyJwk; return ACME._importKeypair(me, options.accountKeypair).then(function (pair) {
var p;
if (jwk) {
// nix the browser jwk extras
jwk.key_ops = undefined;
jwk.ext = undefined;
p = Promise.resolve({ private: jwk, public: Keypairs.neuter({ jwk: jwk }) });
} else {
p = Keypairs.import({ pem: options.accountKeypair.privateKeyPem });
}
return p.then(function (pair) {
options.accountKeypair.privateKeyJwk = pair.private;
options.accountKeypair.publicKeyJwk = pair.public;
if (pair.public.kid) {
pair = JSON.parse(JSON.stringify(pair));
delete pair.public.kid;
delete pair.private.kid;
}
return pair;
}).then(function (pair) {
var contact; var contact;
if (options.contact) { if (options.contact) {
contact = options.contact.slice(0); contact = options.contact.slice(0);
@ -209,7 +190,7 @@ ACME._registerAccount = function (me, options) {
status: 'valid' status: 'valid'
} }
*/ */
if (!account) { account = { _emptyResponse: true, key: {} }; } if (!account) { account = { _emptyResponse: true }; }
// https://git.coolaj86.com/coolaj86/acme-v2.js/issues/8 // https://git.coolaj86.com/coolaj86/acme-v2.js/issues/8
if (!account.key) { account.key = {}; } if (!account.key) { account.key = {}; }
account.key.kid = options._kid; account.key.kid = options._kid;
@ -346,9 +327,10 @@ ACME._testChallenges = function (me, options) {
, wildcard: identifierValue.includes('*.') || undefined , wildcard: identifierValue.includes('*.') || undefined
}; };
var dryrun = true; var dryrun = true;
var auth = ACME._challengeToAuth(me, options, results, challenge, dryrun); return ACME._challengeToAuth(me, options, results, challenge, dryrun).then(function (auth) {
return ACME._setChallenge(me, options, auth).then(function () { return ACME._setChallenge(me, options, auth).then(function () {
return auth; return auth;
});
}); });
}); });
})).then(function (auths) { })).then(function (auths) {
@ -402,17 +384,19 @@ ACME._challengeToAuth = function (me, options, request, challenge, dryrun) {
auth.hostname = auth.identifier.value; auth.hostname = auth.identifier.value;
// because I'm not 100% clear if the wildcard identifier does or doesn't have the leading *. in all cases // because I'm not 100% clear if the wildcard identifier does or doesn't have the leading *. in all cases
auth.altname = ACME._untame(auth.identifier.value, auth.wildcard); auth.altname = ACME._untame(auth.identifier.value, auth.wildcard);
return me.Keypairs.thumbprint({ jwk: options.accountKeypair.publicKeyJwk }).then(function (thumb) { return ACME._importKeypair(me, options.accountKeypair).then(function (pair) {
auth.thumbprint = thumb; return me.Keypairs.thumbprint({ jwk: pair.public }).then(function (thumb) {
// keyAuthorization = token || '.' || base64url(JWK_Thumbprint(accountKey)) auth.thumbprint = thumb;
auth.keyAuthorization = challenge.token + '.' + auth.thumbprint; // keyAuthorization = token || '.' || base64url(JWK_Thumbprint(accountKey))
// conflicts with ACME challenge id url is already in use, so we call this challengeUrl instead auth.keyAuthorization = challenge.token + '.' + auth.thumbprint;
auth.challengeUrl = 'http://' + auth.identifier.value + ACME.challengePrefixes['http-01'] + '/' + auth.token; // conflicts with ACME challenge id url is already in use, so we call this challengeUrl instead
auth.dnsHost = dnsPrefix + '.' + auth.hostname.replace('*.', ''); auth.challengeUrl = 'http://' + auth.identifier.value + ACME.challengePrefixes['http-01'] + '/' + auth.token;
auth.dnsHost = dnsPrefix + '.' + auth.hostname.replace('*.', '');
return Crypto._sha('sha256', auth.keyAuthorization).then(function (hash) { return Crypto._sha('sha256', auth.keyAuthorization).then(function (hash) {
auth.dnsAuthorization = hash; auth.dnsAuthorization = hash;
return auth; return auth;
});
}); });
}); });
}; };
@ -542,15 +526,20 @@ ACME._postChallenge = function (me, options, auth) {
return respondToChallenge(); return respondToChallenge();
}; };
ACME._setChallenge = function (me, options, auth) { ACME._setChallenge = function (me, options, auth) {
console.log('challenge auth:', auth);
console.log('challenges:', options.challenges);
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
var challengers = options.challenges || {};
var challenger = (challengers[auth.type] && challengers[auth.type].set) || options.setChallenge;
try { try {
if (1 === options.setChallenge.length) { if (1 === challenger.length) {
options.setChallenge(auth).then(resolve).catch(reject); challenger(auth).then(resolve).catch(reject);
} else if (2 === options.setChallenge.length) { } else if (2 === challenger.length) {
options.setChallenge(auth, function (err) { challenger(auth, function (err) {
if(err) { reject(err); } else { resolve(); } if(err) { reject(err); } else { resolve(); }
}); });
} else { } else {
// TODO remove this old backwards-compat
var challengeCb = function(err) { var challengeCb = function(err) {
if(err) { reject(err); } else { resolve(); } if(err) { reject(err); } else { resolve(); }
}; };
@ -563,7 +552,7 @@ ACME._setChallenge = function (me, options, auth) {
console.warn("The API has been changed for compatibility with all ACME / Let's Encrypt challenge types."); console.warn("The API has been changed for compatibility with all ACME / Let's Encrypt challenge types.");
ACME._setChallengeWarn = true; ACME._setChallengeWarn = true;
} }
options.setChallenge(auth.identifier.value, auth.token, auth.keyAuthorization, challengeCb); challenger(auth.identifier.value, auth.token, auth.keyAuthorization, challengeCb);
} }
} catch(e) { } catch(e) {
reject(e); reject(e);
@ -577,81 +566,82 @@ ACME._setChallenge = function (me, options, auth) {
}; };
ACME._finalizeOrder = function (me, options, validatedDomains) { ACME._finalizeOrder = function (me, options, validatedDomains) {
if (me.debug) { console.debug('finalizeOrder:'); } if (me.debug) { console.debug('finalizeOrder:'); }
var csr = me.Keypairs.generateCsrWeb64(options.domainKeypair, validatedDomains); return ACME._generateCsrWeb64(me, options, validatedDomains).then(function (csr) {
var body = { csr: csr }; var body = { csr: csr };
var payload = JSON.stringify(body); var payload = JSON.stringify(body);
function pollCert() { function pollCert() {
if (me.debug) { console.debug('[acme-v2.js] pollCert:'); } if (me.debug) { console.debug('[acme-v2.js] pollCert:'); }
return ACME._jwsRequest({ return ACME._jwsRequest({
options: options options: options
, url: options._finalize , url: options._finalize
, protected: { kid: options._kid } , protected: { kid: options._kid }
, payload: Enc.strToBuf(payload) , payload: Enc.strToBuf(payload)
}).then(function (resp) { }).then(function (resp) {
if (me.debug) { console.debug('order finalized: resp.body:'); } if (me.debug) { console.debug('order finalized: resp.body:'); }
if (me.debug) { console.debug(resp.body); } if (me.debug) { console.debug(resp.body); }
// https://tools.ietf.org/html/draft-ietf-acme-acme-12#section-7.1.3 // https://tools.ietf.org/html/draft-ietf-acme-acme-12#section-7.1.3
// Possible values are: "pending" => ("invalid" || "ready") => "processing" => "valid" // Possible values are: "pending" => ("invalid" || "ready") => "processing" => "valid"
if ('valid' === resp.body.status) { if ('valid' === resp.body.status) {
options._expires = resp.body.expires; options._expires = resp.body.expires;
options._certificate = resp.body.certificate; options._certificate = resp.body.certificate;
return resp.body; // return order return resp.body; // return order
} }
if ('processing' === resp.body.status) { if ('processing' === resp.body.status) {
return ACME._wait().then(pollCert); return ACME._wait().then(pollCert);
} }
if (me.debug) { console.debug("Error: bad status:\n" + JSON.stringify(resp.body, null, 2)); } if (me.debug) { console.debug("Error: bad status:\n" + JSON.stringify(resp.body, null, 2)); }
if ('pending' === resp.body.status) {
return Promise.reject(new Error(
"Did not finalize order: status 'pending'."
+ " Best guess: You have not accepted at least one challenge for each domain:\n"
+ "Requested: '" + options.domains.join(', ') + "'\n"
+ "Validated: '" + validatedDomains.join(', ') + "'\n"
+ JSON.stringify(resp.body, null, 2)
));
}
if ('invalid' === resp.body.status) {
return Promise.reject(new Error(
"Did not finalize order: status 'invalid'."
+ " Best guess: One or more of the domain challenges could not be verified"
+ " (or the order was canceled).\n"
+ "Requested: '" + options.domains.join(', ') + "'\n"
+ "Validated: '" + validatedDomains.join(', ') + "'\n"
+ JSON.stringify(resp.body, null, 2)
));
}
if ('ready' === resp.body.status) {
return Promise.reject(new Error(
"Did not finalize order: status 'ready'."
+ " Hmmm... this state shouldn't be possible here. That was the last state."
+ " This one should at least be 'processing'.\n"
+ "Requested: '" + options.domains.join(', ') + "'\n"
+ "Validated: '" + validatedDomains.join(', ') + "'\n"
+ JSON.stringify(resp.body, null, 2) + "\n\n"
+ "Please open an issue at https://git.coolaj86.com/coolaj86/acme-v2.js"
));
}
if ('pending' === resp.body.status) {
return Promise.reject(new Error( return Promise.reject(new Error(
"Did not finalize order: status 'pending'." "Didn't finalize order: Unhandled status '" + resp.body.status + "'."
+ " Best guess: You have not accepted at least one challenge for each domain:\n" + " This is not one of the known statuses...\n"
+ "Requested: '" + options.domains.join(', ') + "'\n"
+ "Validated: '" + validatedDomains.join(', ') + "'\n"
+ JSON.stringify(resp.body, null, 2)
));
}
if ('invalid' === resp.body.status) {
return Promise.reject(new Error(
"Did not finalize order: status 'invalid'."
+ " Best guess: One or more of the domain challenges could not be verified"
+ " (or the order was canceled).\n"
+ "Requested: '" + options.domains.join(', ') + "'\n"
+ "Validated: '" + validatedDomains.join(', ') + "'\n"
+ JSON.stringify(resp.body, null, 2)
));
}
if ('ready' === resp.body.status) {
return Promise.reject(new Error(
"Did not finalize order: status 'ready'."
+ " Hmmm... this state shouldn't be possible here. That was the last state."
+ " This one should at least be 'processing'.\n"
+ "Requested: '" + options.domains.join(', ') + "'\n" + "Requested: '" + options.domains.join(', ') + "'\n"
+ "Validated: '" + validatedDomains.join(', ') + "'\n" + "Validated: '" + validatedDomains.join(', ') + "'\n"
+ JSON.stringify(resp.body, null, 2) + "\n\n" + JSON.stringify(resp.body, null, 2) + "\n\n"
+ "Please open an issue at https://git.coolaj86.com/coolaj86/acme-v2.js" + "Please open an issue at https://git.coolaj86.com/coolaj86/acme-v2.js"
)); ));
} });
}
return Promise.reject(new Error( return pollCert();
"Didn't finalize order: Unhandled status '" + resp.body.status + "'." });
+ " This is not one of the known statuses...\n"
+ "Requested: '" + options.domains.join(', ') + "'\n"
+ "Validated: '" + validatedDomains.join(', ') + "'\n"
+ JSON.stringify(resp.body, null, 2) + "\n\n"
+ "Please open an issue at https://git.coolaj86.com/coolaj86/acme-v2.js"
));
});
}
return pollCert();
}; };
// _kid // _kid
// registerAccount // registerAccount
@ -686,16 +676,18 @@ ACME._getCertificate = function (me, options) {
} }
if (!(options.domains && options.domains.length)) { if (!(options.domains && options.domains.length)) {
return Promise.reject(new Error("options.domains must be a list of string domain names," return Promise.reject(new Error("options.domains must be a list of string domain names,"
+ " with the first being the subject of the domain (or options.subject must specified).")); + " with the first being the subject of the certificate (or options.subject must specified)."));
} }
// It's just fine if there's no account, we'll go get the key id we need via the public key // It's just fine if there's no account, we'll go get the key id we need via the existing key
if (options.accountKid || options.account && options.account.kid) { options._kid = options._kid || options.accountKid
options._kid = options.accountKid || options.account.kid; || (options.account && (options.account.kid
} else { || (options.account.key && options.account.key.kid)));
if (!options._kid) {
//return Promise.reject(new Error("must include KeyID")); //return Promise.reject(new Error("must include KeyID"));
// This is an idempotent request. It'll return the same account for the same public key. // This is an idempotent request. It'll return the same account for the same public key.
return ACME._registerAccount(me, options).then(function () { return ACME._registerAccount(me, options).then(function (account) {
options._kid = account.key.kid;
// start back from the top // start back from the top
return ACME._getCertificate(me, options); return ACME._getCertificate(me, options);
}); });
@ -720,9 +712,6 @@ ACME._getCertificate = function (me, options) {
}; };
var payload = JSON.stringify(body); var payload = JSON.stringify(body);
// determine the signing algorithm to use in protected header // TODO isn't that handled by the signer?
options._kty = (options.accountKeypair.privateKeyJwk && options.accountKeypair.privateKeyJwk.kty || 'RSA');
options._alg = ('EC' === options._kty) ? 'ES256' : 'RS256'; // TODO vary with bitwidth of key (if not handled)
if (me.debug) { console.debug('\n[DEBUG] newOrder\n'); } if (me.debug) { console.debug('\n[DEBUG] newOrder\n'); }
return ACME._jwsRequest({ return ACME._jwsRequest({
options: options options: options
@ -815,6 +804,13 @@ 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) {
return Enc.bufToUrlBase64(der);
});
});
};
ACME.create = function create(me) { ACME.create = function create(me) {
if (!me) { me = {}; } if (!me) { me = {}; }
@ -942,6 +938,30 @@ ACME._defaultRequest = function (opts) {
}); });
}); });
}; };
ACME._importKeypair = function (me, kp) {
var jwk = kp.privateKeyJwk;
var p;
if (jwk) {
// nix the browser jwk extras
jwk.key_ops = undefined;
jwk.ext = undefined;
p = Promise.resolve({ private: jwk, public: me.Keypairs.neuter({ jwk: jwk }) });
} else {
p = me.Keypairs.import({ pem: kp.privateKeyPem });
}
return p.then(function (pair) {
kp.privateKeyJwk = pair.private;
kp.publicKeyJwk = pair.public;
if (pair.public.kid) {
pair = JSON.parse(JSON.stringify(pair));
delete pair.public.kid;
delete pair.private.kid;
}
return pair;
});
};
/* /*
TODO TODO
Per-Order State Params Per-Order State Params