'use strict'; exports = module.exports = { getCertificate, // testing _name: 'acme', _getChallengeSubdomain: getChallengeSubdomain }; const assert = require('assert'), blobs = require('./blobs.js'), BoxError = require('./boxerror.js'), crypto = require('crypto'), debug = require('debug')('box:cert/acme2'), dns = require('./dns.js'), fs = require('fs'), os = require('os'), path = require('path'), paths = require('./paths.js'), promiseRetry = require('./promise-retry.js'), safe = require('safetydance'), shell = require('./shell.js')('acme2'), superagent = require('superagent'), users = require('./users.js'); const CA_PROD_DIRECTORY_URL = 'https://acme-v02.api.letsencrypt.org/directory', CA_STAGING_DIRECTORY_URL = 'https://acme-staging-v02.api.letsencrypt.org/directory'; // http://jose.readthedocs.org/en/latest/ // https://www.ietf.org/proceedings/92/slides/slides-92-acme-1.pdf // https://community.letsencrypt.org/t/list-of-client-implementations/2103 function Acme2(fqdn, domainObject, email) { assert.strictEqual(typeof fqdn, 'string'); assert.strictEqual(typeof domainObject, 'object'); assert.strictEqual(typeof email, 'string'); this.fqdn = fqdn; this.accountKey = null; this.email = email; this.keyId = null; const prod = domainObject.tlsConfig.provider.match(/.*-prod/) !== null; // matches 'le-prod' or 'letsencrypt-prod' this.caDirectory = prod ? CA_PROD_DIRECTORY_URL : CA_STAGING_DIRECTORY_URL; this.directory = {}; this.forceHttpAuthorization = domainObject.provider.match(/noop|manual|wildcard/) !== null; this.wildcard = !!domainObject.tlsConfig.wildcard; this.domain = domainObject.domain; if (fqdn !== this.domain && this.wildcard) { // bare domain is not part of wildcard SAN this.cn = dns.makeWildcard(fqdn); this.altNames = [ this.cn ]; if (fqdn.startsWith('*.')) this.altNames.push(fqdn.replace(/^\*\./, '')); // add bare domain to cert for wildcard certs } else { this.cn = fqdn; this.altNames = [ this.cn ]; } this.certName = this.cn.replace('*.', '_.'); debug(`Acme2: will get cert for fqdn: ${this.fqdn} cn: ${this.cn} certName: ${this.certName} wildcard: ${this.wildcard} http: ${this.forceHttpAuthorization}`); } // urlsafe base64 encoding (jose) function urlBase64Encode(string) { return string.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); } function b64(str) { const buf = Buffer.isBuffer(str) ? str : Buffer.from(str); return urlBase64Encode(buf.toString('base64')); } async function getModulus(pem) { assert.strictEqual(typeof pem, 'string'); const stdout = await shell.spawn('openssl', ['rsa', '-modulus', '-noout'], { encoding: 'utf8', input: pem }); const match = stdout.match(/Modulus=([0-9a-fA-F]+)$/m); if (!match) throw new BoxError(BoxError.OPENSSL_ERROR, 'Could not get modulus'); return Buffer.from(match[1], 'hex'); } Acme2.prototype.sendSignedRequest = async function (url, payload) { assert.strictEqual(typeof url, 'string'); assert.strictEqual(typeof payload, 'string'); assert.strictEqual(typeof this.accountKey, 'string'); const that = this; const header = { url: url, alg: 'RS256' }; // keyId is null when registering account if (this.keyId) { header.kid = this.keyId; } else { header.jwk = { e: b64(Buffer.from([0x01, 0x00, 0x01])), // exponent - 65537 kty: 'RSA', n: b64(await getModulus(this.accountKey)) }; } const payload64 = b64(payload); let [error, response] = await safe(superagent.get(this.directory.newNonce).timeout(30000).ok(() => true)); if (error) throw new BoxError(BoxError.NETWORK_ERROR, `Network error sending signed request: ${error.message}`); if (response.status !== 204) throw new BoxError(BoxError.ACME_ERROR, `Invalid response code when fetching nonce : ${response.status}`); const nonce = response.headers['Replay-Nonce'.toLowerCase()]; if (!nonce) throw new BoxError(BoxError.ACME_ERROR, 'No nonce in response'); debug(`sendSignedRequest: using nonce ${nonce} for url ${url}`); const protected64 = b64(JSON.stringify(Object.assign({}, header, { nonce: nonce }))); const signer = crypto.createSign('RSA-SHA256'); signer.update(protected64 + '.' + payload64, 'utf8'); const signature64 = urlBase64Encode(signer.sign(that.accountKey, 'base64')); const data = { protected: protected64, payload: payload64, signature: signature64 }; [error, response] = await safe(superagent.post(url).send(data).set('Content-Type', 'application/jose+json').set('User-Agent', 'acme-cloudron').timeout(30000).ok(() => true)); if (error) throw new BoxError(BoxError.NETWORK_ERROR, `Network error sending signed request: ${error.message}`); return response; }; // https://tools.ietf.org/html/rfc8555#section-6.3 Acme2.prototype.postAsGet = async function (url) { return await this.sendSignedRequest(url, ''); }; Acme2.prototype.updateContact = async function (registrationUri) { assert.strictEqual(typeof registrationUri, 'string'); debug(`updateContact: registrationUri: ${registrationUri} email: ${this.email}`); // https://github.com/ietf-wg-acme/acme/issues/30 const payload = { contact: [ 'mailto:' + this.email ] }; const result = await this.sendSignedRequest(registrationUri, JSON.stringify(payload)); if (result.status !== 200) throw new BoxError(BoxError.ACME_ERROR, `Failed to update contact. Expecting 200, got ${result.status} ${JSON.stringify(result.body)}`); debug(`updateContact: contact of user updated to ${this.email}`); }; async function generateAccountKey() { const acmeAccountKey = await shell.spawn('openssl', ['genrsa', '4096'], { encoding: 'utf8' }); return acmeAccountKey; } Acme2.prototype.ensureAccount = async function () { const payload = { termsOfServiceAgreed: true }; debug('ensureAccount: registering user'); this.accountKey = await blobs.getString(blobs.ACME_ACCOUNT_KEY); if (!this.accountKey) { debug('ensureAccount: generating new account keys'); this.accountKey = await generateAccountKey(); await blobs.setString(blobs.ACME_ACCOUNT_KEY, this.accountKey); } let result = await this.sendSignedRequest(this.directory.newAccount, JSON.stringify(payload)); if (result.status === 403 && result.body.type === 'urn:ietf:params:acme:error:unauthorized') { debug(`ensureAccount: key was revoked. ${result.status} ${JSON.stringify(result.body)}. generating new account key`); this.accountKey = await generateAccountKey(); await blobs.setString(blobs.ACME_ACCOUNT_KEY, this.accountKey); result = await this.sendSignedRequest(this.directory.newAccount, JSON.stringify(payload)); } // 200 if already exists. 201 for new accounts if (result.status !== 200 && result.status !== 201) throw new BoxError(BoxError.ACME_ERROR, `Failed to register new account. Expecting 200 or 201, got ${result.status} ${JSON.stringify(result.body)}`); debug(`ensureAccount: user registered keyid: ${result.headers.location}`); this.keyId = result.headers.location; await this.updateContact(result.headers.location); }; Acme2.prototype.newOrder = async function () { const payload = { identifiers: [] }; this.altNames.forEach(an => { payload.identifiers.push({ type: 'dns', value: an }); }); debug(`newOrder: ${JSON.stringify(this.altNames)}`); const result = await this.sendSignedRequest(this.directory.newOrder, JSON.stringify(payload)); if (result.status === 403) throw new BoxError(BoxError.ACCESS_DENIED, `Forbidden sending new order: ${result.body.detail}`); if (result.status !== 201) throw new BoxError(BoxError.ACME_ERROR, `Failed to send new order. Expecting 201, got ${result.statusCode} ${JSON.stringify(result.body)}`); const order = result.body, orderUrl = result.headers.location; debug(`newOrder: created order ${this.cn} order: ${JSON.stringify(result.body)} orderUrl: ${orderUrl}`); if (!Array.isArray(order.authorizations)) throw new BoxError(BoxError.ACME_ERROR, 'invalid authorizations in order'); if (typeof order.finalize !== 'string') throw new BoxError(BoxError.ACME_ERROR, 'invalid finalize in order'); if (typeof orderUrl !== 'string') throw new BoxError(BoxError.ACME_ERROR, 'invalid order location in order header'); return { order, orderUrl }; }; Acme2.prototype.waitForOrder = async function (orderUrl) { assert.strictEqual(typeof orderUrl, 'string'); debug(`waitForOrder: ${orderUrl}`); return await promiseRetry({ times: 15, interval: 20000, debug }, async () => { debug('waitForOrder: getting status'); const result = await this.postAsGet(orderUrl); if (result.status !== 200) { debug(`waitForOrder: invalid response code getting uri ${result.status}`); throw new BoxError(BoxError.ACME_ERROR, `Bad response when waiting for order. code: ${result.status}`); } debug('waitForOrder: status is "%s %j', result.body.status, result.body); if (result.body.status === 'pending' || result.body.status === 'processing') throw new BoxError(BoxError.ACME_ERROR, `Request is in ${result.body.status} state`); else if (result.body.status === 'valid' && result.body.certificate) return result.body.certificate; else throw new BoxError(BoxError.ACME_ERROR, `Unexpected status or invalid response when waiting for order: ${JSON.stringify(result.body)}`); }); }; Acme2.prototype.getKeyAuthorization = async function (token) { assert(typeof this.accountKey, 'string'); const jwk = { e: b64(Buffer.from([0x01, 0x00, 0x01])), // Exponent - 65537 kty: 'RSA', n: b64(await getModulus(this.accountKey)) }; const shasum = crypto.createHash('sha256'); shasum.update(JSON.stringify(jwk)); const thumbprint = urlBase64Encode(shasum.digest('base64')); return token + '.' + thumbprint; }; Acme2.prototype.notifyChallengeReady = async function (challenge) { assert.strictEqual(typeof challenge, 'object'); // { type, status, url, token } debug(`notifyChallengeReady: ${challenge.url} was met`); const keyAuthorization = await this.getKeyAuthorization(challenge.token); const payload = { resource: 'challenge', keyAuthorization: keyAuthorization }; const result = await this.sendSignedRequest(challenge.url, JSON.stringify(payload)); if (result.status !== 200) throw new BoxError(BoxError.ACME_ERROR, `Failed to notify challenge. Expecting 200, got ${result.statusCode} ${JSON.stringify(result.body)}`); }; Acme2.prototype.waitForChallenge = async function (challenge) { assert.strictEqual(typeof challenge, 'object'); debug(`waitingForChallenge: ${JSON.stringify(challenge)}`); await promiseRetry({ times: 15, interval: 20000, debug }, async () => { debug('waitingForChallenge: getting status'); const result = await this.postAsGet(challenge.url); if (result.status !== 200) { debug(`waitForChallenge: invalid response code getting uri ${result.status}`); throw new BoxError(BoxError.ACME_ERROR, `Bad response code when waiting for challenge : ${result.status}`); } debug(`waitForChallenge: status is "${result.body.status}" "${JSON.stringify(result.body)}"`); if (result.body.status === 'pending') throw new BoxError(BoxError.ACME_ERROR, 'Challenge is in pending state'); else if (result.body.status === 'valid') return; else throw new BoxError(BoxError.ACME_ERROR, `Unexpected status when waiting for challenge: ${result.body.status}`); }); }; // https://community.letsencrypt.org/t/public-beta-rate-limits/4772 for rate limits Acme2.prototype.signCertificate = async function (finalizationUrl, csrPem) { assert.strictEqual(typeof finalizationUrl, 'string'); assert.strictEqual(typeof csrPem, 'string'); const csrDer = await shell.spawn('openssl', ['req', '-inform', 'pem', '-outform', 'der'], { input: csrPem }); const payload = { csr: b64(csrDer) }; debug(`signCertificate: sending sign request to ${finalizationUrl}`); const result = await this.sendSignedRequest(finalizationUrl, JSON.stringify(payload)); // 429 means we reached the cert limit for this domain if (result.status !== 200) throw new BoxError(BoxError.ACME_ERROR, `Failed to sign certificate. Expecting 200, got ${result.status} ${JSON.stringify(result.body)}`); }; Acme2.prototype.ensureKey = async function () { const key = await blobs.getString(`${blobs.CERT_PREFIX}-${this.certName}.key`); if (key) { debug(`ensureKey: reuse existing key for ${this.cn}`); return key; } debug(`ensureKey: generating new key for ${this.cn}`); // same as prime256v1. openssl ecparam -list_curves. we used to use secp384r1 but it doesn't seem to be accepted by few mail servers const newKey = await shell.spawn('openssl', ['ecparam', '-genkey', '-name', 'secp256r1'], { encoding: 'utf8' }); return newKey; }; Acme2.prototype.createCsr = async function (key) { assert.strictEqual(typeof key, 'string'); const [error, tmpdir] = await safe(fs.promises.mkdtemp(path.join(os.tmpdir(), 'acme-'))); if (error) throw new BoxError(BoxError.FS_ERROR, `Error creating temporary directory for openssl config: ${error.message}`); const keyFilePath = path.join(tmpdir, 'key'); if (!safe.fs.writeFileSync(keyFilePath, key)) throw new BoxError(BoxError.FS_ERROR, `Failed to write key file: ${safe.error.message}`); // OCSP must-staple is currently disabled because nginx does not provide staple on the first request (https://forum.cloudron.io/topic/4917/ocsp-stapling-for-tls-ssl/) // ' -addext "tlsfeature = status_request"'; // this adds OCSP must-staple // we used to use -addext to the CLI to add these but that arg doesn't work on Ubuntu 16.04 // empty distinguished_name section is required for Ubuntu 16 openssl let conf = '[req]\nreq_extensions = v3_req\ndistinguished_name = req_distinguished_name\n' + '[req_distinguished_name]\n\n' + '[v3_req]\nbasicConstraints = CA:FALSE\nkeyUsage = nonRepudiation, digitalSignature, keyEncipherment\nsubjectAltName = @alt_names\n' + '[alt_names]\n'; this.altNames.forEach((an, i) => conf += `DNS.${i+1} = ${an}\n`); const opensslConfigFile = path.join(tmpdir, 'openssl.conf'); if (!safe.fs.writeFileSync(opensslConfigFile, conf)) throw new BoxError(BoxError.FS_ERROR, `Failed to write openssl config: ${safe.error.message}`); // while we pass the CN anyways, subjectAltName takes precedence const csrPem = await shell.spawn('openssl', ['req', '-new', '-key', keyFilePath, '-outform', 'PEM', '-subj', `/CN=${this.cn}`, '-config', opensslConfigFile], { encoding: 'utf8' }); await safe(fs.promises.rm(tmpdir, { recursive: true, force: true })); debug(`createCsr: csr file created for ${this.cn}`); return csrPem; // inspect with openssl req -text -noout -in hostname.csr -inform pem }; Acme2.prototype.downloadCertificate = async function (certUrl) { assert.strictEqual(typeof certUrl, 'string'); return await promiseRetry({ times: 5, interval: 20000, debug }, async () => { debug(`downloadCertificate: downloading certificate of ${this.cn}`); const result = await this.postAsGet(certUrl); if (result.statusCode === 202) throw new BoxError(BoxError.ACME_ERROR, 'Retry downloading certificate'); if (result.statusCode !== 200) throw new BoxError(BoxError.ACME_ERROR, `Failed to get cert. Expecting 200, got ${result.statusCode} ${JSON.stringify(result.body)}`); const fullChainPem = result.body.toString('utf8'); // buffer return fullChainPem; }); }; Acme2.prototype.prepareHttpChallenge = async function (challenge) { assert.strictEqual(typeof challenge, 'object'); debug(`prepareHttpChallenge: preparing for challenge ${JSON.stringify(challenge)}`); const keyAuthorization = await this.getKeyAuthorization(challenge.token); const challengeFilePath = path.join(paths.ACME_CHALLENGES_DIR, challenge.token); debug(`prepareHttpChallenge: writing ${keyAuthorization} to ${challengeFilePath}`); if (!safe.fs.writeFileSync(challengeFilePath, keyAuthorization)) throw new BoxError(BoxError.FS_ERROR, `Error writing challenge: ${safe.error.message}`); }; Acme2.prototype.cleanupHttpChallenge = async function (challenge) { assert.strictEqual(typeof challenge, 'object'); const challengeFilePath = path.join(paths.ACME_CHALLENGES_DIR, challenge.token); debug(`cleanupHttpChallenge: unlinking ${challengeFilePath}`); if (!safe.fs.unlinkSync(challengeFilePath)) throw new BoxError(BoxError.FS_ERROR, `Error unlinking challenge: ${safe.error.message}`); }; function getChallengeSubdomain(cn, domain) { let challengeSubdomain; if (cn === domain) { challengeSubdomain = '_acme-challenge'; } else if (cn.includes('*')) { // wildcard const subdomain = cn.slice(0, -domain.length - 1); challengeSubdomain = subdomain ? subdomain.replace('*', '_acme-challenge') : '_acme-challenge'; } else { challengeSubdomain = '_acme-challenge.' + cn.slice(0, -domain.length - 1); } debug(`getChallengeSubdomain: challenge subdomain for cn ${cn} at domain ${domain} is ${challengeSubdomain}`); return challengeSubdomain; } Acme2.prototype.prepareDnsChallenge = async function (cn, challenge) { assert.strictEqual(typeof challenge, 'object'); debug(`prepareDnsChallenge: preparing for challenge: ${JSON.stringify(challenge)}`); const keyAuthorization = await this.getKeyAuthorization(challenge.token); const shasum = crypto.createHash('sha256'); shasum.update(keyAuthorization); const txtValue = urlBase64Encode(shasum.digest('base64')); const challengeSubdomain = getChallengeSubdomain(cn, this.domain); debug(`prepareDnsChallenge: update ${challengeSubdomain} with ${txtValue}`); await dns.upsertDnsRecords(challengeSubdomain, this.domain, 'TXT', [ `"${txtValue}"` ]); await dns.waitForDnsRecord(challengeSubdomain, this.domain, 'TXT', txtValue, { times: 200 }); }; Acme2.prototype.cleanupDnsChallenge = async function (cn, challenge) { assert.strictEqual(typeof cn, 'string'); assert.strictEqual(typeof challenge, 'object'); const keyAuthorization = await this.getKeyAuthorization(challenge.token); const shasum = crypto.createHash('sha256'); shasum.update(keyAuthorization); const txtValue = urlBase64Encode(shasum.digest('base64')); const challengeSubdomain = getChallengeSubdomain(cn, this.domain); debug(`cleanupDnsChallenge: remove ${challengeSubdomain} with ${txtValue}`); await dns.removeDnsRecords(challengeSubdomain, this.domain, 'TXT', [ `"${txtValue}"` ]); }; Acme2.prototype.prepareChallenge = async function (cn, authorization) { assert.strictEqual(typeof cn, 'string'); assert.strictEqual(typeof authorization, 'object'); debug(`prepareChallenge: http: ${this.forceHttpAuthorization} cn: ${cn} authorization: ${JSON.stringify(authorization)}`); // validation is cached by LE for 60 days or so. if a user switches from non-wildcard DNS (http challenge) to programmatic DNS (dns challenge), then // LE remembers the challenge type and won't give us a dns challenge for 60 days! // https://letsencrypt.org/docs/faq/#i-successfully-renewed-a-certificate-but-validation-didn-t-happen-this-time-how-is-that-possible const dnsChallenges = authorization.challenges.filter(function(x) { return x.type === 'dns-01'; }); const httpChallenges = authorization.challenges.filter(function(x) { return x.type === 'http-01'; }); if (this.forceHttpAuthorization || dnsChallenges.length === 0) { if (httpChallenges.length === 0) throw new BoxError(BoxError.ACME_ERROR, 'no http challenges'); await this.prepareHttpChallenge(httpChallenges[0]); return httpChallenges[0]; } await this.prepareDnsChallenge(cn, dnsChallenges[0]); return dnsChallenges[0]; }; Acme2.prototype.cleanupChallenge = async function (cn, challenge) { assert.strictEqual(typeof cn, 'string'); assert.strictEqual(typeof challenge, 'object'); debug(`cleanupChallenge: http: ${this.forceHttpAuthorization}`); if (this.forceHttpAuthorization) { await this.cleanupHttpChallenge(challenge); } else { await this.cleanupDnsChallenge(cn, challenge); } }; Acme2.prototype.acmeFlow = async function () { await this.ensureAccount(); const { order, orderUrl } = await this.newOrder(); for (const authorizationUrl of order.authorizations) { debug(`acmeFlow: authorizing ${authorizationUrl}`); const response = await this.postAsGet(authorizationUrl); if (response.status !== 200) throw new BoxError(BoxError.ACME_ERROR, `Invalid response code getting authorization : ${response.status}`); const authorization = response.body; // { identifier, status, expires, challenges, wildcard } const cn = authorization.wildcard ? `*.${authorization.identifier.value}` : authorization.identifier.value; const challenge = await this.prepareChallenge(cn, authorization); await this.notifyChallengeReady(challenge); await this.waitForChallenge(challenge); await safe(this.cleanupChallenge(cn, challenge), { debug }); } const key = await this.ensureKey(); const csr = await this.createCsr(key); await this.signCertificate(order.finalize, csr); const certUrl = await this.waitForOrder(orderUrl); const cert = await this.downloadCertificate(certUrl); return { cert, key, csr }; }; Acme2.prototype.loadDirectory = async function () { await promiseRetry({ times: 3, interval: 20000, debug }, async () => { const response = await superagent.get(this.caDirectory).timeout(30000).ok(() => true); if (response.status !== 200) throw new BoxError(BoxError.ACME_ERROR, `Invalid response code when fetching directory : ${response.status}`); if (typeof response.body.newNonce !== 'string' || typeof response.body.newOrder !== 'string' || typeof response.body.newAccount !== 'string') throw new BoxError(BoxError.ACME_ERROR, `Invalid response body : ${response.body}`); this.directory = response.body; }); }; Acme2.prototype.getCertificate = async function () { debug(`getCertificate: start acme flow for ${this.cn} from ${this.caDirectory}`); await this.loadDirectory(); const result = await this.acmeFlow(); debug(`getCertificate: acme flow completed for ${this.cn}`); await blobs.setString(`${blobs.CERT_PREFIX}-${this.certName}.key`, result.key); await blobs.setString(`${blobs.CERT_PREFIX}-${this.certName}.cert`, result.cert); await blobs.setString(`${blobs.CERT_PREFIX}-${this.certName}.csr`, result.csr); return result; }; async function getCertificate(fqdn, domainObject) { assert.strictEqual(typeof fqdn, 'string'); // this can also be a wildcard domain (for alias domains) assert.strictEqual(typeof domainObject, 'object'); // registering user with an email requires A or MX record (https://github.com/letsencrypt/boulder/issues/1197) // we cannot use admin@fqdn because the user might not have set it up. // we simply update the account with the latest email we have each time when getting letsencrypt certs // https://github.com/ietf-wg-acme/acme/issues/30 const owner = await users.getOwner(); const email = owner?.email || 'webmaster@cloudron.io'; // can error if not activated yet return await promiseRetry({ times: 3, interval: 0, debug }, async function () { debug(`getCertificate: for fqdn ${fqdn} and domain ${domainObject.domain}`); const acme = new Acme2(fqdn, domainObject, email); return await acme.getCertificate(); }); }