diff --git a/test/js/README.md b/test/js/README.md new file mode 100644 index 000000000..adf9c6551 --- /dev/null +++ b/test/js/README.md @@ -0,0 +1,33 @@ +# A JS tester for boulder + +The node.js scripts in this directory provide a simple end-to-end test of Boulder. (Using some pieces from [node-acme](https://github.com/letsencrypt/node-acme/)) To run: + +``` +# Install dependencies +> npm install inquirer cli node-forge + +# Start cfssl with signing parameters +# (These are the default parameters to use a Yubikey.) +# (You'll need to make your own key, cert, and policy.) +> go install -tags pkcs11 github.com/cloudflare/cfssl/cmd/cfssl +> cfssl serve -port 8888 -ca ca.cert.pem \ + -pkcs11-module "/Library/OpenSC/lib/opensc-pkcs11.so" \ + -pkcs11-token "Yubico Yubik NEO CCID" \ + -pkcs11-pin 123456 \ + -pkcs11-label "PIV AUTH key" \ + -config policy.json + +# Start boulder +# (Change CFSSL parameters to match your setup.) +> go install github.com/letsencrypt/boulder +> boulder-start --cfssl localhost:8888 + --cfsslProfile ee \ + --cfsslAuthKey 79999d86250c367a2b517a1ae7d409c1 \ + monolithic + +# Client side +> mkdir -p .well-known/acme-challenge/ +> node demo.js +> mv -- *.txt .well-known/acme-challenge/ # In a different window +> python -m SimpleHTTPServer 5001 # In yet another window +``` diff --git a/test/js/acme-util.js b/test/js/acme-util.js new file mode 100644 index 000000000..151e37c76 --- /dev/null +++ b/test/js/acme-util.js @@ -0,0 +1,68 @@ +module.exports = { + + fromStandardB64: function(x) { + return x.replace(/[+]/g, "-").replace(/\//g, "_").replace(/=/g,""); + }, + + toStandardB64: function(x) { + var b64 = x.replace(/-/g, "+").replace(/_/g, "/").replace(/=/g, ""); + + switch (b64.length % 4) { + case 2: b64 += "=="; break; + case 3: b64 += "="; break; + } + + return b64; + }, + + b64enc: function(buffer) { + return this.fromStandardB64(buffer.toString("base64")); + }, + + b64dec: function(str) { + return new Buffer(this.toStandardB64(str), "base64"); + }, + + isB64String: function(x) { + return (typeof(x) == "string") && !x.match(/[^a-zA-Z0-9_-]/); + }, + + fieldsPresent: function(fields, object) { + for (var i in fields) { + if (!(fields[i] in object)) { + return false; + } + } + return true; + }, + + validSignature: function(sig) { + return ((typeof(sig) == "object") && + ("alg" in sig) && (typeof(sig.alg) == "string") && + ("nonce" in sig) && this.isB64String(sig.nonce) && + ("sig" in sig) && this.isB64String(sig.sig) && + ("jwk" in sig) && this.validJWK(sig.jwk)); + }, + + validJWK: function(jwk) { + return ((typeof(jwk) == "object") && ("kty" in jwk) && ( + ((jwk.kty == "RSA") + && ("n" in jwk) && this.isB64String(jwk.n) + && ("e" in jwk) && this.isB64String(jwk.e)) || + ((jwk.kty == "EC") + && ("crv" in jwk) + && ("x" in jwk) && this.isB64String(jwk.x) + && ("y" in jwk) && this.isB64String(jwk.y)) + ) && !("d" in jwk)); + }, + + // A simple, non-standard fingerprint for a JWK, + // just so that we don't have to store objects + keyFingerprint: function(jwk) { + switch (jwk.kty) { + case "RSA": return jwk.n; + case "EC": return jwk.crv + jwk.x + jwk.y; + } + throw "Unrecognized key type"; + } +}; diff --git a/test/js/crypto-util.js b/test/js/crypto-util.js new file mode 100644 index 000000000..dcc9854ca --- /dev/null +++ b/test/js/crypto-util.js @@ -0,0 +1,341 @@ +var crypto = require("crypto"); +var forge = require("node-forge"); +var util = require("./acme-util.js"); + +var TOKEN_SIZE = 16; +var NONCE_SIZE = 16; + +function bytesToBuffer(bytes) { + return new Buffer(forge.util.bytesToHex(bytes), "hex"); +} + +function bufferToBytes(buf) { + return forge.util.hexToBytes(buf.toString("hex")); +} + +function bytesToBase64(bytes) { + return util.b64enc(bytesToBuffer(bytes)); +} + +function base64ToBytes(base64) { + return bufferToBytes(util.b64dec(base64)); +} + +function bnToBase64(bn) { + var hex = bn.toString(16); + if (hex.length % 2 == 1) { hex = "0" + hex; } + return util.b64enc(new Buffer(hex, "hex")); +} + +function base64ToBn(base64) { + return new forge.jsbn.BigInteger(util.b64dec(base64).toString("hex"), 16); +} + +function importPrivateKey(privateKey) { + return forge.pki.rsa.setPrivateKey( + base64ToBn(privateKey.n), + base64ToBn(privateKey.e), base64ToBn(privateKey.d), + base64ToBn(privateKey.p), base64ToBn(privateKey.q), + base64ToBn(privateKey.dp),base64ToBn(privateKey.dq), + base64ToBn(privateKey.qi)); +} + +function importPublicKey(publicKey) { + return forge.pki.rsa.setPublicKey( + base64ToBn(publicKey.n), + base64ToBn(publicKey.e)); +} + +function exportPrivateKey(privateKey) { + return { + "kty": "RSA", + "n": bnToBase64(privateKey.n), + "e": bnToBase64(privateKey.e), + "d": bnToBase64(privateKey.d), + "p": bnToBase64(privateKey.p), + "q": bnToBase64(privateKey.q), + "dp": bnToBase64(privateKey.dP), + "dq": bnToBase64(privateKey.dQ), + "qi": bnToBase64(privateKey.qInv) + }; +} + +function exportPublicKey(publicKey) { + return { + "kty": "RSA", + "n": bnToBase64(publicKey.n), + "e": bnToBase64(publicKey.e) + }; +} + +// A note on formats: +// * Keys are always represented as JWKs +// * Signature objects are in ACME format +// * Certs and CSRs are base64-encoded +module.exports = { + ///// RANDOM STRINGS + + randomString: function(nBytes) { + return bytesToBase64(forge.random.getBytesSync(nBytes)); + }, + + randomSerialNumber: function() { + return forge.util.bytesToHex(forge.random.getBytesSync(4)); + }, + + newToken: function() { + return this.randomString(TOKEN_SIZE); + }, + + ///// SHA-256 + + sha256: function(buf) { + return crypto.createHash('sha256').update(buf).digest('hex'); + }, + + ///// KEY PAIR MANAGEMENT + + generateKeyPair: function(bits) { + var keyPair = forge.pki.rsa.generateKeyPair({bits: bits, e: 0x10001}); + return { + privateKey: exportPrivateKey(keyPair.privateKey), + publicKey: exportPublicKey(keyPair.publicKey) + }; + }, + + importPemPrivateKey: function(pem) { + var key = forge.pki.privateKeyFromPem(pem); + return { + privateKey: exportPrivateKey(key), + publicKey: exportPublicKey(key) + }; + }, + + importPemCertificate: function(pem) { + return forge.pki.certificateFromPem(pem); + }, + + privateKeyToPem: function(privateKey) { + var priv = importPrivateKey(privateKey); + return forge.pki.privateKeyToPem(priv); + }, + + certificateToPem: function(certificate) { + var derCert = base64ToBytes(certificate); + var cert = forge.pki.certificateFromAsn1(forge.asn1.fromDer(derCert)); + return forge.pki.certificateToPem(cert); + }, + + certificateRequestToPem: function(csr) { + var derReq = base64ToBytes(csr); + var c = forge.pki.certificateFromAsn1(forge.asn1.fromDer(derReq)); + return forge.pki.certificateRequestToPem(c); + }, + + ///// SIGNATURE GENERATION / VERIFICATION + + generateSignature: function(keyPair, payload) { + var nonce = bytesToBuffer(forge.random.getBytesSync(NONCE_SIZE)); + var privateKey = importPrivateKey(keyPair.privateKey); + + // Compute JWS signature + var protectedHeader = JSON.stringify({ + nonce: util.b64enc(nonce) + }); + var protected64 = util.b64enc(new Buffer(protectedHeader)); + var payload64 = util.b64enc(payload); + var signatureInputBuf = new Buffer(protected64 + "." + payload64); + var signatureInput = bufferToBytes(signatureInputBuf); + var md = forge.md.sha256.create(); + md.update(signatureInput); + var sig = privateKey.sign(md); + + return { + header: { + alg: "RS256", + jwk: keyPair.publicKey, + }, + protected: protected64, + payload: payload64, + signature: util.b64enc(bytesToBuffer(sig)), + } + }, + + verifySignature: function(jws) { + if (jws.protected) { + if (!jws.header) { + jws.header = {}; + } + + try { + console.log(jws.protected); + var protectedJSON = util.b64dec(jws.protected).toString(); + console.log(protectedJSON); + var protectedObj = JSON.parse(protectedJSON); + for (key in protectedObj) { + jws.header[key] = protectedObj[key]; + } + } catch (e) { + console.log("error unmarshaling json: "+e) + return false; + } + } + + // Assumes validSignature(sig) + if (!jws.header.jwk || (jws.header.jwk.kty != "RSA")) { + // Unsupported key type + console.log("Unsupported key type"); + return false; + } else if (!jws.header.alg || !jws.header.alg.match(/^RS/)) { + // Unsupported algorithm + console.log("Unsupported alg: "+jws.header.alg); + return false; + } + + // Compute signature input + var protected64 = (jws.protected)? jws.protected : ""; + var payload64 = (jws.payload)? jws.payload : ""; + var signatureInputBuf = new Buffer(protected64 + "." + payload64); + var signatureInput = bufferToBytes(signatureInputBuf); + + // Compute message digest + var md; + switch (jws.header.alg) { + case "RS1": md = forge.md.sha1.create(); break; + case "RS256": md = forge.md.sha256.create(); break; + case "RS384": md = forge.md.sha384.create(); break; + case "RS512": md = forge.md.sha512.create(); break; + default: return false; // Unsupported algorithm + } + md.update(signatureInput); + + // Import the key and signature + var publicKey = importPublicKey(jws.header.jwk); + var sig = bufferToBytes(util.b64dec(jws.signature)); + + return publicKey.verify(md.digest().bytes(), sig); + }, + + ///// CSR GENERATION / VERIFICATION + + generateCSR: function(keyPair, identifier) { + var privateKey = importPrivateKey(keyPair.privateKey); + var publicKey = importPublicKey(keyPair.publicKey); + + // Create and sign the CSR + var csr = forge.pki.createCertificationRequest(); + csr.publicKey = publicKey; + csr.setSubject([{ name: 'commonName', value: identifier }]); + csr.sign(privateKey); + + // Convert CSR -> DER -> Base64 + var der = forge.asn1.toDer(forge.pki.certificationRequestToAsn1(csr)); + return util.b64enc(bytesToBuffer(der)); + }, + + verifiedCommonName: function(csr_b64) { + var der = bufferToBytes(util.b64dec(csr_b64)); + var csr = forge.pki.certificationRequestFromAsn1(forge.asn1.fromDer(der)); + + if (!csr.verify()) { + return false; + } + + for (var i=0; i]/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; + } +} + +/* + +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 (TODO) + | / +getDomain + | +getChallenges + | +getReadyToValidate + | +sendResponse + | +ensureValidation + | +getCertificate + | +downloadCertificate + | +saveFiles + + +*/ + +function main() { + console.log("Generating key pair..."); + state.keyPair = crypto.generateKeyPair(state.keyPairBits); + console.log(); + inquirer.prompt(questions.email, register) +} + +function register(answers) { + var email = answers.email; + + // Register public key + var registerMessage = JSON.stringify({ + contact: [ "mailto:" + email ] + }); + var jws = crypto.generateSignature(state.keyPair, new Buffer(registerMessage)); + var payload = JSON.stringify(jws); + + var options = url.parse(state.newRegistrationURL); + options.method = "POST"; + var req = http.request(options, getTerms); + req.write(payload) + req.end(); +} + +function getTerms(resp) { + if (Math.floor(resp.statusCode / 100) != 2) { + // Non-2XX response + console.log("Registration request failed with code " + resp.statusCode); + return; + } + + var links = parseLink(resp.headers["link"]); + if (!links || !("next" in links)) { + console.log("The server did not provide information to proceed"); + return + } + + state.registrationURL = resp.headers["location"]; + state.newAuthorizationURL = links["next"]; + state.termsRequired = ("terms-of-service" in links); + + if (state.termsRequired) { + // TODO getAgreement + // inquirer.prompt(questions.terms, getAgreement); + console.log("The CA requires your agreement to terms (not supported)."); + return + } else { + inquirer.prompt(questions.domain, getChallenges); + } +} + +function getChallenges(answers) { + state.domain = answers.domain; + + // Register public key + var authzMessage = JSON.stringify({ + identifier: { + type: "dns", + value: state.domain + } + }); + var jws = crypto.generateSignature(state.keyPair, new Buffer(authzMessage)); + var payload = JSON.stringify(jws); + + var options = url.parse(state.newAuthorizationURL); + options.method = "POST"; + var req = http.request(options, getReadyToValidate); + req.write(payload) + req.end(); +} + +function getReadyToValidate(resp) { + if (Math.floor(resp.statusCode / 100) != 2) { + // Non-2XX response + console.log("Authorization request failed with code " + resp.statusCode) + return; + } + + var links = parseLink(resp.headers["link"]); + if (!links || !("next" in links)) { + console.log("The server did not provide information to proceed"); + return + } + + state.authorizationURL = resp.headers["location"]; + state.newCertificateURL = links["next"]; + + var body = "" + resp.on('data', function(chunk) { + body += chunk; + }); + resp.on('end', function(chunk) { + if (chunk) { body += chunk; } + + var authz = JSON.parse(body); + + var simpleHttps = authz.challenges.filter(function(x) { return x.type == "simpleHttps"; }); + if (simpleHttps.length == 0) { + console.log("The server didn't offer any challenges we can handle."); + return; + } + + var challenge = simpleHttps[0]; + var path = crypto.randomString(8) + ".txt"; + fs.writeFileSync(path, challenge.token); + state.responseURL = challenge["uri"]; + state.path = path; + + console.log(); + console.log("To validate that you own "+ state.domain +", the CA has\n" + + "asked you to provision a file on your server. I've saved\n" + + "the file here for you.\n"); + console.log(" File: " + path); + console.log(" URL: http://"+ state.domain +"/.well-known/acme-challenge/"+ path); + console.log(); + + // To do this locally (boulder connects to port 5001) + // > mkdir -p .well-known/acme-challenge/ + // > mv $CHALLENGE_FILE ./well-known/acme-challenge/ + // > python -m SimpleHTTPServer 5001 + + inquirer.prompt(questions.readyToValidate, sendResponse); + }); +} + +function sendResponse() { + var responseMessage = JSON.stringify({ + path: state.path + }); + var jws = crypto.generateSignature(state.keyPair, new Buffer(responseMessage)); + var payload = JSON.stringify(jws); + + cli.spinner("Validating domain"); + + var options = url.parse(state.responseURL); + options.method = "POST"; + var req = http.request(options, ensureValidation); + req.write(payload) + req.end(); +} + +function ensureValidation(resp) { + if (Math.floor(resp.statusCode / 100) != 2) { + // Non-2XX response + console.log("Authorization status request failed with code " + resp.statusCode) + return; + } + + var body = ""; + resp.on('data', function(chunk) { + body += chunk; + }); + resp.on('end', function(chunk) { + if (chunk) { body += chunk; } + + var authz = JSON.parse(body); + + if (authz.status == "pending") { + setTimeout(function() { + http.get(state.authorizationURL, ensureValidation); + }, state.retryDelay); + } else if (authz.status == "valid") { + cli.spinner("Validating domain ... done", true); + console.log(); + getCertificate(); + } else if (authz.status == "invalid") { + console.log("The CA was unable to validate the file you provisioned."); + return; + } else { + console.log("The CA returned an authorization in an unexpected state"); + console.log(JSON.stringify(authz, null, " ")); + return; + } + }); +} + +function getCertificate() { + var csr = crypto.generateCSR(state.keyPair, state.domain); + + var certificateMessage = JSON.stringify({ + csr: csr, + authorizations: [ state.authorizationURL ] + }); + var jws = crypto.generateSignature(state.keyPair, new Buffer(certificateMessage)); + var payload = JSON.stringify(jws); + + cli.spinner("Requesting certificate"); + + var options = url.parse(state.newCertificateURL); + options.method = "POST"; + var req = http.request(options, downloadCertificate); + req.write(payload) + req.end(); +} + +function downloadCertificate(resp) { + var chunks = []; + resp.on('data', function(chunk) { + chunks.push(chunk); + }); + resp.on('end', function(chunk) { + if (chunk) { chunks.push(chunk); } + var body = Buffer.concat(chunks); + + if (Math.floor(resp.statusCode / 100) != 2) { + // Non-2XX response + console.log("Certificate request failed with code " + resp.statusCode); + console.log(body.toString()); + return; + } + + cli.spinner("Requesting certificate ... done", true); + console.log(); + var certB64 = util.b64enc(body); + + state.certificate = certB64; + inquirer.prompt(questions.files, saveFiles); + }); +} + +function saveFiles(answers) { + var keyPEM = crypto.privateKeyToPem(state.keyPair.privateKey); + fs.writeFileSync(answers.keyFile, keyPEM); + + var certPEM = crypto.certificateToPem(state.certificate); + fs.writeFileSync(answers.certFile, certPEM); + + console.log("Done!") + console.log("To try it out:"); + console.log("openssl s_server -accept 8080 -www -key "+ + answers.keyFile +" -cert "+ answers.certFile); + + // XXX: Explicitly exit, since something's tenacious here + process.exit(0); +} + + +// BEGIN +main(); +