// Copyright 2014 ISRG. 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/. // // To test against a Boulder running on localhost in test mode: // cd boulder/test/js // npm install // js test.js // // To test against a live or demo Boulder, edit this file to change // newRegistrationURL, then run: // sudo js test.js. "use strict"; var colors = require("colors"); var cli = require("cli"); var cryptoUtil = require("./crypto-util"); var child_process = require('child_process'); var fs = require('fs'); var http = require('http'); var inquirer = require("inquirer"); var request = require('request'); var url = require('url'); var util = require("./acme-util"); var Acme = require("./acme"); var questions = { email: [{ type: "input", name: "email", message: "Please enter your email address (for recovery purposes)", validate: function(value) { var pass = value.match(/[\w.+-]+@[\w.-]+/i); if (pass) { return true; } else { return "Please enter a valid email address"; } } }], terms: [{ type: "confirm", name: "terms", message: "Do you agree to these terms?", default: true, }], domain: [{ type: "input", name: "domain", message: "Please enter the domain name for the certificate", validate: function(value) { var pass = value.match(/[\w.-]+/i); if (pass) { return true; } else { return "Please enter a valid domain name"; } } }], anotherDomain: [{ type: "confirm", name: "anotherDomain", message: "Any more domains to validate?", default: true, }], readyToValidate: [{ type: "input", name: "noop", message: "Press enter to when you're ready to proceed", }], files: [{ type: "input", name: "keyFile", message: "Name for key file", default: "key.pem", },{ type: "input", name: "certFile", message: "Name for certificate file", default: "cert.der", }], }; var cliOptions = cli.parse({ // To test against the demo instance, pass --newReg "https://www.letsencrypt-demo.org/acme/new-reg" // To get a cert from the demo instance, you must be publicly reachable on // port 443 under the DNS name you are trying to get, and run test.js as root. newReg: ["new-reg", "New Registration URL", "string", "http://localhost:4000/acme/new-reg"], certKeyFile: ["certKey", "File for cert key (created if not exists)", "path", "cert-key.pem"], certFile: ["cert", "Path to output certificate (DER format)", "path", "cert.pem"], email: ["email", "Email address", "string", null], agreeTerms: ["agree", "Agree to terms of service", "boolean", null], domains: ["domains", "Domain name(s) for which to request a certificate (comma-separated)", "string", null], }); var state = { certPrivateKey: null, accountKeyPair: null, newRegistrationURL: cliOptions.newReg, registrationURL: "", termsRequired: false, termsAgreed: null, termsURL: null, haveCLIDomains: !!(cliOptions.domains), domains: cliOptions.domains && cliOptions.domains.replace(/\s/g, "").split(/[^\w.-]+/), validatedDomains: [], validAuthorizationURLs: [], // We will use this as a push/shift FIFO in post() and getNonce() nonces: [], newAuthorizationURL: "", authorizationURL: "", responseURL: "", path: "", retryDelay: 1000, newCertificateURL: "", certificateURL: "", certFile: cliOptions.certFile, keyFile: cliOptions.certKeyFile, }; function parseLink(link) { try { // NB: Takes last among links with the same "rel" value var links = link.split(',').map(function(link) { var parts = link.trim().split(";"); var url = parts.shift().replace(/[<>]/g, ""); var info = parts.reduce(function(acc, p) { var m = p.trim().match(/(.+) *= *"(.+)"/); if (m) acc[m[1]] = m[2]; return acc }, {}); info["url"] = url; return info; }).reduce(function(acc, link) { if ("rel" in link) { acc[link["rel"]] = link["url"] } return acc; }, {}); return links; } catch (e) { return null; } } var post = function(url, body, callback) { return state.acme.post(url, body, callback); } /* The asynchronous nature of node.js libraries makes the control flow a little hard to follow here, but it pretty much goes straight down the page, with detours through the `inquirer` and `http` libraries. main | register | getTerms | \ | getAgreement | | | sendAgreement | / getDomain | getChallenges | getReadyToValidate | sendResponse | ensureValidation | getCertificate | downloadCertificate | saveFiles */ function main() { makeKeyPair(); } function makeKeyPair() { console.log("Generating cert key pair..."); child_process.exec("openssl req -newkey rsa:2048 -keyout " + state.keyFile + " -days 3650 -subj /CN=foo -nodes -x509 -out temp-cert.pem", function (error, stdout, stderr) { if (error) { console.log(error); process.exit(1); } state.certPrivateKey = cryptoUtil.importPemPrivateKey(fs.readFileSync(state.keyFile)); console.log(); makeAccountKeyPair() }); } function makeAccountKeyPair(answers) { console.log("Generating account key pair..."); child_process.exec("openssl genrsa -out account-key.pem 2048", function (error, stdout, stderr) { if (error) { console.log(error); process.exit(1); } state.accountKeyPair = cryptoUtil.importPemPrivateKey(fs.readFileSync("account-key.pem")); state.acme = new Acme(state.accountKeyPair); console.log(); if (cliOptions.email) { register({email: cliOptions.email}); } else { inquirer.prompt(questions.email, register) } }); } function register(answers) { var email = answers.email; // Register public key post(state.newRegistrationURL, { resource: "new-reg", contact: [ "mailto:" + email ], }, getTerms); } function getTerms(err, resp) { if (err || Math.floor(resp.statusCode / 100) != 2) { // Non-2XX response console.log("Registration request failed:" + err); process.exit(1); } var links = parseLink(resp.headers["link"]); if (!links || !("next" in links)) { console.log("The server did not provide information to proceed"); process.exit(1); } state.registrationURL = resp.headers["location"]; state.newAuthorizationURL = links["next"]; state.termsRequired = ("terms-of-service" in links); if (state.termsRequired) { state.termsURL = links["terms-of-service"]; console.log(state.termsURL); request.get(state.termsURL, getAgreement) } else { inquirer.prompt(questions.domain, getChallenges); } } function getAgreement(err, resp, body) { if (err) { console.log("getAgreement error:", err); process.exit(1); } // TODO: Check content-type console.log("The CA requires your agreement to terms."); console.log(state.termsURL); console.log(); if (!cliOptions.agreeTerms) { inquirer.prompt(questions.terms, sendAgreement); } else { sendAgreement({terms: true}); } } function sendAgreement(answers) { state.termsAgreed = answers.terms; if (state.termsRequired && !state.termsAgreed) { console.log("Sorry, can't proceed if you don't agree."); process.exit(1); } console.log("Posting agreement to: " + state.registrationURL) state.registration = { resource: "reg", agreement: state.termsURL } post(state.registrationURL, state.registration, function(err, resp, body) { if (err || Math.floor(resp.statusCode / 100) != 2) { console.log(body); console.log("error: " + err); console.log("Couldn't POST agreement back to server, aborting."); process.exit(1); } else { if (!state.domains || state.domains.length == 0) { inquirer.prompt(questions.domain, getChallenges); } else { getChallenges({domain: state.domains.pop()}); } } }); } function getChallenges(answers) { state.domain = answers.domain; // Register public key post(state.newAuthorizationURL, { resource: "new-authz", identifier: { type: "dns", value: state.domain, } }, getReadyToValidate); } function getReadyToValidate(err, resp, body) { if (err || Math.floor(resp.statusCode / 100) != 2) { // Non-2XX response console.log("Authorization request failed with code " + resp.statusCode) process.exit(1); } var links = parseLink(resp.headers["link"]); if (!links || !("next" in links)) { console.log("The server did not provide information to proceed"); process.exit(1); } state.authorizationURL = resp.headers["location"]; state.newCertificateURL = links["next"]; var authz = JSON.parse(body); var httpChallenges = authz.challenges.filter(function(x) { return x.type == "http-01"; }); if (httpChallenges.length == 0) { console.log("The server didn't offer any challenges we can handle."); process.exit(1); } var challenge = httpChallenges[0]; var jsonAuthorizedKey = util.b64dec(challenge.authorizedKey) var authorizedKey = JSON.parse(jsonAuthorizedKey); var challengePath = ".well-known/acme-challenge/" + authorizedKey.token; state.responseURL = challenge["uri"]; state.path = challengePath; // For local, test-mode validation function httpResponder(req, response) { console.log("\nGot request for", req.url); var host = req.headers["host"]; if ((host.split(/:/)[0] === state.domain || /localhost/.test(state.newRegistrationURL)) && req.method === "GET" && req.url == "/" + challengePath) { console.log("\nRespodned with authorized key file:", jsonAuthorizedKey); response.writeHead(200, {"Content-Type": "application/json"}); response.end(jsonAuthorizedKey); } else { console.log("Got invalid request for", req.method, host, req.url); response.writeHead(404, {"Content-Type": "text/plain"}); response.end(""); } } state.httpServer = http.createServer(httpResponder) if (/localhost/.test(state.newRegistrationURL)) { console.log("listening on port 5001"); state.httpServer.listen(5001) } else { console.log("listening on port 443"); state.httpServer.listen(443) } cli.spinner("Validating domain"); post(state.responseURL, { resource: "challenge", tls: true, token: authorizedKey.token, }, ensureValidation); } function ensureValidation(err, resp, body) { if (Math.floor(resp.statusCode / 100) != 2) { // Non-2XX response console.log("Authorization status request failed with code " + resp.statusCode) process.exit(1); } var authz = JSON.parse(body); if (authz.status != "pending") { state.httpServer.close(); } if (authz.status == "pending") { setTimeout(function() { request.get(state.authorizationURL, {}, ensureValidation); }, state.retryDelay); } else if (authz.status == "valid") { cli.spinner("Validating domain ... done", true); console.log(); state.validatedDomains.push(state.domain); state.validAuthorizationURLs.push(state.authorizationURL); if (state.haveCLIDomains) { console.log("have CLI domains: "); console.log(state.domains); if (state.haveCLIDomains && state.domains.length > 0) { getChallenges({domain: state.domains.pop()}); return; } else { getCertificate(); } } else { inquirer.prompt(questions.anotherDomain, function(answers) { if (answers.anotherDomain) { inquirer.prompt(questions.domain, getChallenges); } else { getCertificate(); } }); } } else if (authz.status == "invalid") { console.log("The CA was unable to validate the file you provisioned:" + body); process.exit(1); } else { console.log("The CA returned an authorization in an unexpected state"); console.log(JSON.stringify(authz, null, " ")); process.exit(1); } } function getCertificate() { cli.spinner("Requesting certificate"); var csr = cryptoUtil.generateCSR(state.certPrivateKey, state.validatedDomains); post(state.newCertificateURL, { resource: "new-cert", csr: csr, authorizations: state.validAuthorizationURLs, }, downloadCertificate); } function downloadCertificate(err, resp, body) { if (err || Math.floor(resp.statusCode / 100) != 2) { // Non-2XX response console.log("Certificate request failed with error ", err); if (body) { console.log(body.toString()); } process.exit(1); } cli.spinner("Requesting certificate ... done", true); console.log(); state.certificate = body; console.log() var certURL = resp.headers['location']; request.get({ url: certURL, encoding: null // Return body as buffer. }, function(err, res, body) { if (err) { console.log("Error: Failed to fetch certificate from", certURL, ":", err); process.exit(1); } if (res.statusCode !== 200) { console.log("Error: Failed to fetch certificate from", certURL, ":", res.statusCode, res.body.toString()); fs.writeFileSync(state.certFile, state.certificate); process.exit(1); } if (body.toString() !== state.certificate.toString()) { console.log("Error: cert at", certURL, "did not match returned cert."); } else { console.log("Successfully verified cert at", certURL); saveFiles() } }); } function saveFiles(answers) { fs.writeFileSync(state.certFile, state.certificate); console.log("Done!") console.log("To try it out:"); console.log("openssl s_server -accept 8080 -www -certform der -key "+ state.keyFile +" -cert "+ state.certFile); // XXX: Explicitly exit, since something's tenacious here process.exit(0); } // BEGIN main();