Skip to content

Commit

Permalink
feat: ACME v2 based api
Browse files Browse the repository at this point in the history
  • Loading branch information
mkg20001 committed Jun 2, 2018
1 parent cb2d486 commit bfec0a1
Show file tree
Hide file tree
Showing 9 changed files with 208 additions and 133 deletions.
5 changes: 3 additions & 2 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,8 @@
"author": "Maciej Krüger <mkg20001@gmail.com>",
"license": "MIT",
"dependencies": {
"acme-v2": "^1.0.8",
"debug": "^3.1.0",
"greenlock": "~2.2.19",
"le-store-certbot": "github:mkg20001/le-store-certbot",
"libp2p": "^0.20.4",
"libp2p-mplex": "^0.7.0",
"libp2p-secio": "^0.10.0",
Expand All @@ -35,6 +34,8 @@
"mafmt": "^6.0.0",
"multihashing-async": "^0.5.1",
"named": "github:mkg20001/node-named",
"node-forge": "^0.7.5",
"promisify-es6": "^1.0.3",
"protons": "^1.0.1",
"pull-protocol-buffers": "^0.1.2",
"raven": "^2.6.2"
Expand Down
1 change: 1 addition & 0 deletions server/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ module.exports = class Nodetrust {

start (cb) {
waterfall([
cb => this.le.init(err => cb(err)),
cb => this.swarm.start(err => cb(err)),
cb => this.swarm.pubsub.subscribe(DISCOVERY, () => {}, cb), // act as a relay for nodetrust announces
cb => this.dns.start(err => cb(err))
Expand Down
13 changes: 13 additions & 0 deletions server/src/letsencrypt/acme/dig.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use strict'

const cp = require('child_process')

module.exports = (query) => new Promise((resolve, reject) => { // the nodeJS dns module seems to be buggy sometimes. use real dig.
try {
let records = cp.spawnSync('dig', ['+short', query.name, query.type, '@8.8.8.8'], {stdio: 'pipe'})
.stdout.toString().split('\n').filter(s => Boolean(s.trim())).map(v => JSON.parse(v))
resolve({ answer: records.map(data => { return { data: [data] } }) })
} catch (e) {
reject(e)
}
})
112 changes: 112 additions & 0 deletions server/src/letsencrypt/acme/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
'use strict'

const ACME = require('acme-v2')
const ACME_DIG = require('./dig')
const RSA = require('rsa-compat').RSA
const prom = require('promisify-es6')
const promCl = (cl, fnc) => prom(fnc.bind(cl))

const genKeyPair = prom((cb) => RSA.generateKeypair(2048, null, { pem: true, public: true, jwk: true }, cb))
const promiseFnc = ['getOrGenKey', 'genKey', 'registerAccount', 'obtainCertificate', 'getCertificate']

const debug = require('debug')
const log = debug('nodetrust:letsencrypt:acme')

const forge = require('node-forge')
const pki = forge.pki

const URLS = {
production: 'https://acme-v02.api.letsencrypt.org/directory',
staging: 'https://acme-staging-v02.api.letsencrypt.org/directory'
}

class LetsencryptACME {
constructor (opt) {
this.opt = opt
this.storage = opt.storage
this.challenge = opt.challenge
this.acme = ACME.ACME.create({debug: true})
this.acme._dig = ACME_DIG
promiseFnc.forEach(name => (this[name] = promCl(this, this[name])))
}
init (cb) {
this.acme.init(this.opt.serverUrl)
.then(() => this.registerAccount(this.opt.email), cb)
.then((account) => {
Object.assign(this, account)
this.acme._kid = account.account.key.kid
cb()
}, cb)
}
getOrGenKey (keyid, cb) {
if (this.storage.exists('key', keyid)) return cb(null, this.storage.readJSON('key', keyid))
return this.genKey(keyid, cb)
}
genKey (keyid, cb) {
genKeyPair((err, pair) => {
if (err) return cb(err)
this.storage.storeJSON('key', keyid, pair)
cb(null, pair)
})
}
registerAccount (email, cb) {
this.getOrGenKey('ac-key', (err, accountKeypair) => {
if (err) return cb(err)
let account = this.storage.readJSON('ac-data')
if (account) return cb(null, {account, accountKeypair})
log('creating account')
this.acme.accounts.create({
email,
accountKeypair: accountKeypair,
agreeToTerms: tosUrl => Promise.resolve(tosUrl)
}).then(account => {
this.storage.storeJSON('ac-data', account)
cb(null, {account, accountKeypair})
}, cb)
})
}

obtainCertificate (id, domainKeypair, domains, cb) {
const {accountKeypair} = this
log('obtain certificate %s', domains.join(', '))
this.acme.certificates.create({
domainKeypair,
accountKeypair,
domains,
challengeType: this.challenge.type,
setChallenge: this.challenge.set,
removeChallenge: this.challenge.remove
}).then(certs => {
let [cert, ca] = certs.split('\n\n')
let certForge = pki.certificateFromPem(cert)
let res = {
error: false,
domains,
cn: domains[0],
altnames: domains.slice(1),
privkey: domainKeypair.privateKeyPem,
cert,
chain: cert + '\n' + ca,
ca,
validity: certForge.validity.notAfter.getTime()
}
this.storage.storeJSON(...id, res)
cb(null, res)
}, cb)
}

getCertificate (nodeID, domains, cb) {
let certID = ['@' + nodeID, domains.join('!')]
log('get certificate %s %s', nodeID, domains.join(', '))
this.getOrGenKey('@' + nodeID)
.then(domainKeypair => {
let cert = this.storage.readJSON(...certID)
if (!cert) return this.obtainCertificate(certID, domainKeypair, domains)
else return Promise.resolve(cert)
}, cb)
}

}

module.exports = LetsencryptACME
module.exports.URLS = URLS
38 changes: 0 additions & 38 deletions server/src/letsencrypt/dnsChallenge.js

This file was deleted.

14 changes: 14 additions & 0 deletions server/src/letsencrypt/idPrefix.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
'use strict'

const multihashing = require('multihashing-async')
const domainBase = require('base-x')('abcdefghijklmnopqrstuvwxyz0123456789-')

module.exports = function idPrefix (id, zone, cb) { // TODO: maybe refactor/drop this method as it isn't so cryptographically safe
let pref = 'id0'
let suf = '.' + zone
multihashing(Buffer.from(id), 'sha3-224', (err, digest) => {
if (err) return cb(err)
id = domainBase.encode(digest).substr(0, 64 - pref.length - suf.length)
cb(null, pref + id + suf)
})
}
117 changes: 25 additions & 92 deletions server/src/letsencrypt/index.js
Original file line number Diff line number Diff line change
@@ -1,103 +1,36 @@
'use strict'

const LE = require('greenlock')
const storeCertbot = require('le-store-certbot')
const DnsChallenge = require('./dnsChallenge')
const cp = require('child_process')

const debug = require('debug')
const log = debug('nodetrust:letsencrypt')

const path = require('path')
const fs = require('fs')

const _FAKECERT = path.join(__dirname, '..', '..')
const read = (...f) => fs.readFileSync(path.join(_FAKECERT, ...f)).toString()

const multihashing = require('multihashing-async')
const domainBase = require('base-x')('abcdefghijklmnopqrstuvwxyz0123456789-')

const urls = {
production: 'https://acme-v02.api.letsencrypt.org/directory',
staging: 'https://acme-staging-v02.api.letsencrypt.org/directory'
}

function idToCN (id, zone, cb) { // TODO: maybe refactor this method as it could be attacked
let pref = 'id0'
let suf = '.' + zone
multihashing(Buffer.from(id), 'sha3-224', (err, digest) => {
if (err) return cb(err)
id = domainBase.encode(digest).substr(0, 64 - pref.length - suf.length)
cb(null, pref + id + suf)
})
}
const ACME = require('./acme')
const { URLS } = ACME
const Storage = require('./storage')
const prom = require('promisify-es6')
const idPrefix = prom(require('./idPrefix'))

class Letsencrypt {
constructor (opt) {
if (opt.stub) {
this.pem = { cert: read('cert.pem'), key: read('key.pem') }
}

const debug = log.enabled
const leStore = storeCertbot.create({
configDir: opt.storageDir,
debug,
log
})

this.email = opt.email

const dns = new DnsChallenge(opt)

this.le = LE.create({
version: 'v02',
server: urls[(opt.env || 'staging')] || opt.env,
agreeTos: true,

challenges: {
'dns-01': dns
},
challengeType: 'dns-01',

store: leStore,

debug,
log
})

this.le.acme._dig = (q) => new Promise((resolve, reject) => { // the nodeJS dns module seems to be buggy sometimes. use real dig.
try {
let records = cp.spawnSync('dig', ['+short', q.name, q.type, '@8.8.8.8'], {stdio: 'pipe'})
.stdout.toString().split('\n').filter(s => Boolean(s.trim())).map(v => JSON.parse(v))
resolve({ answer: records.map(data => { return { data: [data] } }) })
} catch (e) {
reject(e)
let serverUrl = URLS[(opt.env || 'staging')] || opt.env
let storage = this.storage = new Storage(opt.storageDir)
let acmeConf = {
email: opt.email,
serverUrl,
storage,
challenge: {
type: 'dns-01',
set: (auth) => opt.dns.addRecords('_acme-challenge.' + auth.identifier.value, [['TXT', auth.dnsAuthorization]]),
remove: (auth) => opt.dns.deleteRecords('_acme-challenge.' + auth.identifier.value)
}
})
}
this.acme = new ACME(acmeConf)
}
init (cb) {
this.acme.init(cb)
}
handleRequest (id, zone, domains, cb) {
if (this.pem) {
const params = {
error: false,
privkey: this.pem.key,
cert: this.pem.cert,
chain: this.pem.cert
}
return cb(null, params)
}
if (!domains.length) return cb(new Error('No domains specified!'))
idToCN(id, zone, (err, cn) => {
if (err) return cb(err)
domains = [cn].concat(domains)
log('issue: %s as %s', domains[0], domains.slice(1).join(', '))
this.le.register({
domains,
email: this.email,
agreeTos: true,
rsaKeySize: 2048,
challengeType: 'dns-01'
}).then(res => cb(null, res), cb)
})
idPrefix(id, zone)
.then(prefix => {
domains.unshift(prefix)
this.acme.getCertificate(id, domains)
}, cb)
}
}

Expand Down
39 changes: 39 additions & 0 deletions server/src/letsencrypt/storage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
'use strict'

const fs = require('fs')
const path = require('path')
const mkdirp = require('mkdirp')

class Storage {
constructor (path) {
this.path = path
}
locate (...a) {
if (!a.length) throw new Error('Must specify location')
return path.join(this.path, ...a)
}
store (...a) {
let data = a.pop()
let loc = this.locate(...a)
const dir = path.dirname(loc)
mkdirp.sync(dir)
fs.writeFileSync(loc, data)
}
exists (...a) {
return fs.existsSync(this.locate(...a))
}
read (...a) {
if (!this.exists(...a)) return
return fs.readFileSync(this.locate(...a))
}
readJSON (...a) {
if (!this.exists(...a)) return
return JSON.parse(String(fs.readFileSync(this.locate(...a))))
}
storeJSON (...a) {
let data = JSON.stringify(a.pop())
return this.store(...a, data)
}
}

module.exports = Storage
2 changes: 1 addition & 1 deletion server/src/proto/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class RPC {
}
const ips = this.addr.map(a => a.toString()).filter(a => a.startsWith('/ip')).map(a => a.split('/')[2]) // TODO: filter unique
const domains = ips.map(ip => encodeAddr(ip)).filter(Boolean).map(sub => sub + '.' + this.opt.zone)
log('cert for %s', domains.join(', '))
log('cert for %s %s', this.pi.id.toB58String(), domains.join(', '))
this.opt.le.handleRequest(this.pi.id.toB58String(), this.opt.zone, domains, (err, res) => {
if (err) return cb(err)
let data = {
Expand Down

0 comments on commit bfec0a1

Please sign in to comment.