Letsencrypt https generator for Koa Apps
Tags: security, koa, letsencrypt, javascript
Published: 2019-11-13 08:00:20

While researching options for automatic https certificate generation, I couldn't find any good options that satisfied my needs for my website.

I opted to use a library called `acme-client` on npm which exposes a letsencrypt acme API that is super simple to use (async/await)

```js
const acme = require('acme-client')
const fs = require('fs')
const path = require('path')
const os = require('os')
const forge = require('node-forge')

const directoryUrl = acme.directory.letsencrypt[ENV.ssl.mode] // mode: 'staging' || 'production'
const sslDataPath = path.resolve(os.homedir(), './ssl/') //SSL path where certificates will be stored

// function to write the SSL data that is persisted across server restart (prevents letsencrypt rate-limiting)
function writeSSLObject(obj){
    fs.mkdirSync(sslDataPath, { recursive: true })
    fs.writeFileSync(`${sslDataPath}/${ENV.ssl.mode}.json`, JSON.stringify(obj), 'utf8')
}

/**
 * read sslObject data 
 * { 
 *   accountKey: 'Private Key for Letsencrypt API Communication',
 *   accountUrl: 'Letsencrypt API Account URL', 
 *   key: 'RSA Private Key',
 *   cert: 'Full chain certificate',
 * }
**/
function readSSLObject(){
    try {
        return JSON.parse(fs.readFileSync(`${sslDataPath}/${ENV.ssl.mode}.json`), 'utf8')
    } catch (error) {
        return {}
    }
}

async function getClient(){
    let opts = {
        directoryUrl
    }

    const sslObject = readSSLObject()

    if(sslObject.accountKey) opts.accountKey = sslObject.accountKey
    if(sslObject.accountUrl) opts.accountUrl = sslObject.accountUrl

    if(!opts.accountKey) opts.accountKey = String((await acme.forge.createPrivateKey()))

    const client = new acme.Client(opts)
    
    try {
        client.getAccountUrl() //check if account exists
    } catch (error) {
        await client.createAccount({
            email: ENV.ssl.email,
            termsOfServiceAgreed: true
        })

        writeSSLObject({...sslObject, accountUrl: client.getAccountUrl(), accountKey: opts.accountKey})
    }

    return { 
        client,
        cert: sslObject.cert,
        key: sslObject.key
    }
}

function getExpiry(cert){
    if(cert) return forge.pki.certificateFromPem(cert).validity.notAfter
}

module.exports = async function ssl(httpServer, http2server){
    let challengeFilePaths = {}
    let renewingCertPromise = null

    let { client, cert, key } = await getClient()
    let expires = getExpiry(cert)

    async function newCert(){
        const [privateKey, csr] = await acme.forge.createCsr({
            commonName: ENV.ssl.domains[0],
            altNames: ENV.ssl.domains
        })

        key = String(privateKey)
        cert = await client.auto({
            csr,
            challengePriority: ['http-01'],
            async challengeCreateFn(authz, challenge, challengeContents) {
                if (challenge.type === 'http-01') { //save the path in memory, with challenge contents which will be used by middleware
                    challengeFilePaths[`/.well-known/acme-challenge/${challenge.token}`] = challengeContents
                }
            },
            async challengeRemoveFn(auths, challenge){
                delete challengeFilePaths[`/.well-known/acme-challenge/${challenge.token}`]
            }
        })

        writeSSLObject({...readSSLObject(), key, cert})
        expires = getExpiry(cert)

        http2server.setSecureContext({
            key,
            cert
        })
    }

    function shouldRenewCert(){
        //if there is no renewal date, generate a new cert
        if(!expires) return true

        //check last renewal, and if more than 2 months, renew certificate
        //letsnecrypt certificates expire every 3 months
        let renewAfter = new Date(expires).setMonth(expires.getMonth() - 1)
        let now = new Date()
        
        return now > renewAfter
    }

    httpServer.on('listening', async () => { //wait for httpserver to be listening (done elsewhere in app)
        try {
            if(shouldRenewCert()) renewingCertPromise = newCert()

            if(renewingCertPromise){
                await renewingCertPromise
            }else{
                http2server.setSecureContext({
                    key,
                    cert
                })
            }

            http2server.listen(443)
        } catch (error) {
            console.error(error)
        }
    })

    /**
     * Return letsencrypt challenge middleware
     */
    return async function middleware(ctx, next){
        if(challengeFilePaths[ctx.url]){ //serve challenge contents if it matches URL
            return ctx.body = challengeFilePaths[ctx.url]
        }

        if(shouldRenewCert() && !renewingCertPromise){
            renewingCertPromise = newCert()
            await renewingCertPromise
            renewingCertPromise = null
        }

        if(renewingCertPromise) await renewingCertPromise
        
        await next()
    }
}
```

This code is used in production on [Promatia](https://promatia.com)