From e651755417fe2eb06c18fbb2c45477a7ffec26d8 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Tue, 15 Dec 2015 22:07:02 +0000 Subject: [PATCH] refactor and docs --- README.md | 129 ++++------- lib/acme-client.js | 218 ++++++++++--------- lib/{crypto-util.js => letsencrypt-forge.js} | 1 + lib/letsencrypt-node-crypto.js | 8 + lib/letsencrypt-ursa.js | 85 ++++++++ lib/node.js | 38 ++++ node.js | 20 ++ package.json | 26 +-- 8 files changed, 321 insertions(+), 204 deletions(-) rename lib/{crypto-util.js => letsencrypt-forge.js} (99%) create mode 100644 lib/letsencrypt-node-crypto.js create mode 100644 lib/letsencrypt-ursa.js create mode 100644 lib/node.js create mode 100644 node.js diff --git a/README.md b/README.md index fc32b06..511f72c 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,57 @@ -# letiny -Tiny acme client library and CLI to obtain ssl certificates (without using external commands like openssl). +# letiny-core +A framework for building letsencrypt clients, forked from `letiny`. + + * browser + * node with `forge` (works on windows) + * node with `ursa` (works fast) + * any javascript implementation ## Usage: -`npm install letiny` +```bash +npm install --save letiny-core +``` -### Using the "webroot" option -This will create a file in `/var/www/example.com/.well-known/acme-challenge/` to verify the domain. -```js -require('letiny').getCert({ - email:'me@example.com', - domains:['example.com', 'www.example.com'], - webroot:'/var/www/example.com', - certFile:'./cert.pem', - keyFile:'./key.pem', - caFile:'./ca.pem', - agreeTerms:true -}, function(err, cert, key, cacert) { - console.log(err || cert+'\n'+key+'\n'+cacert); +```javascript +'use strict'; + +var leCore = require('leCore'); + +leCore. +``` + +## API + +``` +LeCore.registerNewAccount(); + +LeCore.getCertificate(); + +LeCore.Acme // Signs requests with JWK + acme = new Acme(lePrivateKey) // privateKey format is abstract + acme.post(url, body, cb) // POST with signature + acme.parseLinks(link) // (internal) parses 'link' header + acme.getNonce(url, cb) // (internal) HEAD request to get 'replay-nonce' strings + +LeCore.leCrypto + generateSignature(lePrivateKey, nodeBufferBody, nonceString) +``` + +For testing and development, you can also inject the dependencies you want to use: + +```javascript +leCore.create({ + request: require('request') +, leCrypto: rquire('./lib/letsencrypt-forge') }); ``` -### Using the "challenge" option -This allows you to provide the challenge data on your own, so you can obtain certificates on-the-fly within your software. -```js -require('letiny').getCert({ - email:'me@example.com', - domains:'example.com', - challenge:function(domain, path, data, done) { - // make http://+domain+path serving "data" - done(); - }, - certFile:'./cert.pem', - keyFile:'./key.pem', - caFile:'./ca.pem', - agreeTerms:true -}, function(err, cert, key, cacert) { - console.log(err || cert+'\n'+key+'\n'+cacert); -}); -``` - -### Options -#### Required: - * `email`: Your email adress - * `domains`: Comma seperated string or array - * `agreeTerms`: You need to agree the terms - * `webroot` (string) or `challenge` (function) - -#### Optional: - * `certFile`: Path to save certificate - * `keyFile`: Path to save private key - * `caFile`: Path to save issuer certificate - * `pfxFile`: Path to save PKCS#12 certificate - * `pfxPassword`: Password for PKCS#12 certificate - * `aes`: (boolean), use AES instead of 3DES for PKCS#12 certificate - * `newReg`: URL, use *https://acme-staging.api.letsencrypt.org/acme/new-reg* for testing - - -## Command line interface -```sudo npm install letiny -g``` -#### Options: -``` --h, --help output usage information --e, --email your email address --w, --webroot path for webroot verification --m, --manual use manual verification --d, --domains domains (comma seperated) --c, --cert path to save your certificate (cert.pem) --k, --key path to save your private key (privkey.pem) --i, --ca path to save issuer certificate (cacert.pem) ---pfx path to save PKCS#12 certificate (optional) ---password password for PKCS#12 certificate (optional) ---aes use AES instead of 3DES for PKCS#12 ---agree agree terms of the ACME CA (required) ---newreg optional AMCE server newReg URL ---debug print debug information -``` -When --pfx is used without --cert, --key and --ca no .pem files will be created. - -#### Examples: -``` -letiny -e me@example.com -w /var/www/example.com -d example.com --agree -letiny -e me@example.com -m -d example.com -c cert.pem -k key.pem -i ca.pem --agree -letiny -e me@example.com -m -d example.com,www.example.com --agree -letiny -e me@example.com -m -d example.com --pfx cert.pfx --password secret --agree -letiny --email me@example.com --webroot ./ --domains example.com --agree -``` - - ## Licence + MPL 2.0 +All of the code is available under the MPL-2.0. + +Some of the files are original work not modified from `letiny` +and are made available under MIT as well (check file headers). diff --git a/lib/acme-client.js b/lib/acme-client.js index 6e64617..2a05db4 100644 --- a/lib/acme-client.js +++ b/lib/acme-client.js @@ -4,129 +4,131 @@ * Some code used from https://github.com/letsencrypt/boulder/tree/master/test/js * MPL 2.0 */ - 'use strict'; -var NOOP=function () {}; -var log=NOOP; -var request=require('request'); -var cryptoUtil=require('./crypto-util'); +module.exports.create = function (deps) { -function Acme(privateKey) { - this.privateKey=privateKey; - this.nonces=[]; -} + var NOOP=function () {}; + var log=NOOP; + var request=require('request'); + var generateSignature=deps.leCrypto.generateSignature; -Acme.prototype.getNonce=function(url, cb) { - var self=this; + function Acme(privateKey) { + this.privateKey=privateKey; + this.nonces=[]; + } - request.head({ - url:url, - }, function(err, res/*, body*/) { - if (err) { - return cb(err); - } - if (res && 'replay-nonce' in res.headers) { - log('Storing nonce: '+res.headers['replay-nonce']); - self.nonces.push(res.headers['replay-nonce']); - cb(); - return; - } + Acme.prototype.getNonce=function(url, cb) { + var self=this; - cb(new Error('Failed to get nonce for request')); - }); -}; - -Acme.prototype.post=function(url, body, cb) { - var self=this, payload, jws, signed; - - if (this.nonces.length===0) { - this.getNonce(url, function(err) { + request.head({ + url:url, + }, function(err, res/*, body*/) { if (err) { return cb(err); } - self.post(url, body, cb); - }); - return; - } - - log('Using nonce: '+this.nonces[0]); - payload=JSON.stringify(body, null, 2); - jws=cryptoUtil.generateSignature( - this.privateKey, new Buffer(payload), this.nonces.shift() - ); - signed=JSON.stringify(jws, null, 2); - - log('Posting to '+url); - log(signed.green); - log('Payload:'+payload.blue); - - return request.post({ - url:url, - body:signed, - encoding:null - }, function(err, res, body) { - var parsed; - - if (err) { - console.error(err.stack); - return cb(err); - } - if (res) { - log(('HTTP/1.1 '+res.statusCode).yellow); - } - - Object.keys(res.headers).forEach(function(key) { - var value, upcased; - value=res.headers[key]; - upcased=key.charAt(0).toUpperCase()+key.slice(1); - log((upcased+': '+value).yellow); - }); - - if (body && !body.toString().match(/[^\x00-\x7F]/)) { - try { - parsed=JSON.parse(body); - log(JSON.stringify(parsed, null, 2).cyan); - } catch(err) { - log(body.toString().cyan); + if (res && 'replay-nonce' in res.headers) { + log('Storing nonce: '+res.headers['replay-nonce']); + self.nonces.push(res.headers['replay-nonce']); + cb(); + return; } + + cb(new Error('Failed to get nonce for request')); + }); + }; + + Acme.prototype.post=function(url, body, cb) { + var self=this, payload, jws, signed; + + if (this.nonces.length===0) { + this.getNonce(url, function(err) { + if (err) { + return cb(err); + } + self.post(url, body, cb); + }); + return; } - if ('replay-nonce' in res.headers) { - log('Storing nonce: '+res.headers['replay-nonce']); - self.nonces.push(res.headers['replay-nonce']); - } + log('Using nonce: '+this.nonces[0]); + payload=JSON.stringify(body, null, 2); + jws=generateSignature( + this.privateKey, new Buffer(payload), this.nonces.shift() + ); + signed=JSON.stringify(jws, null, 2); - cb(err, res, body); - }); -}; + log('Posting to '+url); + log(signed.green); + log('Payload:'+payload.blue); -Acme.parseLink = function parseLink(link) { - var links; - try { - links=link.split(',').map(function(link) { - var parts, url, info; - parts=link.trim().split(';'); - url=parts.shift().replace(/[<>]/g, ''); - info=parts.reduce(function(acc, p) { - var m=p.trim().match(/(.+) *= *"(.+)"/); - if (m) { - acc[m[1]]=m[2]; + return request.post({ + url:url, + body:signed, + encoding:null + }, function(err, res, body) { + var parsed; + + if (err) { + console.error(err.stack); + return cb(err); + } + if (res) { + log(('HTTP/1.1 '+res.statusCode).yellow); + } + + Object.keys(res.headers).forEach(function(key) { + var value, upcased; + value=res.headers[key]; + upcased=key.charAt(0).toUpperCase()+key.slice(1); + log((upcased+': '+value).yellow); + }); + + if (body && !body.toString().match(/[^\x00-\x7F]/)) { + try { + parsed=JSON.parse(body); + log(JSON.stringify(parsed, null, 2).cyan); + } catch(err) { + log(body.toString().cyan); + } + } + + if ('replay-nonce' in res.headers) { + log('Storing nonce: '+res.headers['replay-nonce']); + self.nonces.push(res.headers['replay-nonce']); + } + + cb(err, res, body); + }); + }; + + Acme.parseLink = function parseLink(link) { + var links; + try { + links=link.split(',').map(function(link) { + var parts, url, info; + parts=link.trim().split(';'); + url=parts.shift().replace(/[<>]/g, ''); + 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; }, {}); - info.url=url; - return info; - }).reduce(function(acc, link) { - if ('rel' in link) { - acc[link.rel]=link.url; - } - return acc; - }, {}); - return links; - } catch(err) { - return null; - } -}; + return links; + } catch(err) { + return null; + } + }; -module.exports = Acme; + return Acme; +}; diff --git a/lib/crypto-util.js b/lib/letsencrypt-forge.js similarity index 99% rename from lib/crypto-util.js rename to lib/letsencrypt-forge.js index a0f0bf7..4db0fee 100644 --- a/lib/crypto-util.js +++ b/lib/letsencrypt-forge.js @@ -2,6 +2,7 @@ // 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/. +'use strict'; var crypto = require("crypto"); var forge = require("node-forge"); diff --git a/lib/letsencrypt-node-crypto.js b/lib/letsencrypt-node-crypto.js new file mode 100644 index 0000000..33d8005 --- /dev/null +++ b/lib/letsencrypt-node-crypto.js @@ -0,0 +1,8 @@ +/*! + * letsencrypt-core + * Copyright(c) 2015 AJ ONeal https://daplie.com + * Apache-2.0 OR MIT (and hence also MPL 2.0) +*/ +'use strict'; + +module.exports = {}; diff --git a/lib/letsencrypt-ursa.js b/lib/letsencrypt-ursa.js new file mode 100644 index 0000000..04e75a1 --- /dev/null +++ b/lib/letsencrypt-ursa.js @@ -0,0 +1,85 @@ +/*! + * letsencrypt-core + * Copyright(c) 2015 AJ ONeal https://daplie.com + * Apache-2.0 OR MIT (and hence also MPL 2.0) +*/ +'use strict'; + +var crypto = require('crypto'); +var ursa = require('ursa'); +var forge = require('node-forge'); + +function binstr2b64(binstr) { + return new Buffer(binstr, 'binary').toString('base64'); +} + +function toAcmePrivateKey(privkeyPem) { + var forgePrivkey = forge.pki.privateKeyFromPem(privkeyPem); + + return { + kty: "RSA" + , n: binstr2b64(forgePrivkey.n) + , e: binstr2b64(forgePrivkey.e) + , d: binstr2b64(forgePrivkey.d) + , p: binstr2b64(forgePrivkey.p) + , q: binstr2b64(forgePrivkey.q) + , dp: binstr2b64(forgePrivkey.dP) + , dq: binstr2b64(forgePrivkey.dQ) + , qi: binstr2b64(forgePrivkey.qInv) + }; +} + +function generateRsaKeypair(bitlen, exp, cb) { + var keypair = ursa.generatePrivateKey(bitlen /*|| 2048*/, exp /*65537*/); + var pems = { + publicKeyPem: keypair.toPublicPem().toString('ascii') // ascii PEM: ----BEGIN... + , privateKeyPem: keypair.toPrivatePem().toString('ascii') // ascii PEM: ----BEGIN... + }; + + // I would have chosen sha1 or sha2... but whatever + pems.publicKeyMd5 = crypto.createHash('md5').update(pems.publicKeyPem).digest('hex'); + // json { n: ..., e: ..., iq: ..., etc } + pems.privateKeyJwk = toAcmePrivateKey(pems.privateKeyPem); + pems.privateKeyJson = pems.privateKeyJwk; + + // TODO thumbprint + + cb(null, pems); +} + +function parseAccountPrivateKey(pkj, cb) { + Object.keys(pkj).forEach(function (key) { + pkj[key] = new Buffer(pkj[key], 'base64'); + }); + + var priv; + + try { + priv = ursa.createPrivateKeyFromComponents( + pkj.n // modulus + , pkj.e // exponent + , pkj.p + , pkj.q + , pkj.dp + , pkj.dq + , pkj.qi + , pkj.d + ); + } catch(e) { + cb(e); + return; + } + + cb(null, { + privateKeyPem: priv.toPrivatePem.toString('ascii') + , publicKeyPem: priv.toPublicPem.toString('ascii') + }); +} + +module.exports.generateRsaKeypair = generateRsaKeypair; +module.exports.privateJwkToPems = parseAccountPrivateKey; +module.exports.privatePemToJwk = toAcmePrivateKey; + +// TODO deprecate +module.exports.toAcmePrivateKey = toAcmePrivateKey; +module.exports.parseAccountPrivateKey = parseAccountPrivateKey; diff --git a/lib/node.js b/lib/node.js new file mode 100644 index 0000000..2d34c2b --- /dev/null +++ b/lib/node.js @@ -0,0 +1,38 @@ +/*! + * letsencrypt-core + * Copyright(c) 2015 AJ ONeal https://daplie.com + * Apache-2.0 OR MIT (and hence also MPL 2.0) +*/ +'use strict'; + +var request = require('request'); +var leCrypto = require('./letsencrypt-node-crypto'); +var leForge = require('./letsencrypt-forge'); +var leUrsa; + +try { + leUrsa = require('./letsencrypt-ursa'); +} catch(e) { + leUrsa = {}; + // things will run a little slower on keygen, but it'll work on windows + // (but don't try this on raspberry pi - 20+ MINUTES key generation) +} + +// order of crypto precdence is +// * native +// * ursa +// * forge (fallback) +Object.keys(leUrsa).forEach(function (key) { + if (!leCrypto[key]) { + leCrypto[key] = leUrsa[key]; + } +}); + +Object.keys(leForge).forEach(function (key) { + if (!leCrypto[key]) { + leCrypto[key] = leForge[key]; + } +}); + +module.exports.request = request; +module.exports.leCrypto = leCrypto; diff --git a/node.js b/node.js new file mode 100644 index 0000000..8e10151 --- /dev/null +++ b/node.js @@ -0,0 +1,20 @@ +/*! + * letsencrypt-core + * Copyright(c) 2015 AJ ONeal https://daplie.com + * Apache-2.0 OR MIT (and hence also MPL 2.0) +*/ +'use strict'; + +function create(deps) { + var LeCore = {}; + + LeCore.leCrypto = deps.leCrypto; + LeCore.Acme = require('./lib/acme-client').create(deps); + LeCore.registerNewAccount = require('./lib/register-new-account').create(deps); + LeCore.getCertificate = require('./lib/get-certificate').create(deps); + + return LeCore; +} + +module.exports = create(require('./lib/node')); +module.exports.create = create; diff --git a/package.json b/package.json index 12143bb..adf4bbc 100644 --- a/package.json +++ b/package.json @@ -1,30 +1,30 @@ { - "name": "letiny", - "version": "0.0.5-beta", + "name": "letiny-core", + "version": "1.0.0", "description": "Tiny ACME client library and CLI", - "author": "Anatol Sommer ", + "authors": [ + "Anatol Sommer ", + "AJ ONeal " + ], "license": "MPL-2.0", "repository": { "type": "git", - "url": "https://github.com/anatolsommer/letiny.git" + "url": "https://github.com/Daplie/letiny-core.git" }, "keywords": [ "tiny", "acme", "letsencrypt", "client", - "cli", + "pem", "pfx" ], - "bin": { - "letiny": "./lib/cli.js" - }, "dependencies": { - "colors": "^1.1.0", - "mkdirp": "^0.5.1", - "node-forge": "^0.6.21", - "request": "^2.55.0", - "commander": "^2.9.0" + "node-forge": "^0.6.38", + "request": "^2.55.0" + }, + "optionalDependencies": { + "ursa": "^0.9.1" }, "devDependencies": { "mocha": "^2.3.3",