Files
cloudron-box/src/acme2.js
Girish Ramakrishnan dbbe93955c acme2: add bare domain to altNames when requesting wildcard cert
this is primarily to support DoT + ClientID. the adguard package
can now use this cert when the DoT port is enabled.

we thought of adding a "dot: true" flag for the manifest. that flag
would request a special wildcard cert as well as setup the dns. setting
up the dns is complicated ux wise because it would be totally hidden
from the user. It's better they add an alias and thus we make things
a bit more explicit (what if user was using the wildcard domain for something
else etc).
2023-02-25 20:22:09 +01:00

558 lines
24 KiB
JavaScript

'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'),
superagent = require('superagent'),
safe = require('safetydance'),
users = require('./users.js'),
_ = require('underscore');
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.performHttpAuthorization = 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.performHttpAuthorization}`);
}
// 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'));
}
function getModulus(pem) {
assert.strictEqual(typeof pem, 'string');
const stdout = safe.child_process.execSync('openssl rsa -modulus -noout', { input: pem, encoding: 'utf8' });
if (!stdout) return null;
const match = stdout.match(/Modulus=([0-9a-fA-F]+)$/m);
if (!match) return null;
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(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(_.extend({ }, 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 = safe.child_process.execSync('openssl genrsa 4096', { encoding: 'utf8' });
if (!acmeAccountKey) throw new BoxError(BoxError.OPENSSL_ERROR, `Could not generate acme account key: ${safe.error.message}`);
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 = function (token) {
assert(typeof this.accountKey, 'string');
const jwk = {
e: b64(Buffer.from([0x01, 0x00, 0x01])), // Exponent - 65537
kty: 'RSA',
n: b64(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 = 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 = safe.child_process.execSync('openssl req -inform pem -outform der', { input: csrPem });
if (!csrDer) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error);
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}`);
const newKey = safe.child_process.execSync('openssl ecparam -genkey -name secp384r1', { encoding: 'utf8' }); // openssl ecparam -list_curves
if (!newKey) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error);
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 = safe.child_process.execSync(`openssl req -new -key ${keyFilePath} -outform PEM -subj /CN=${this.cn} -config ${opensslConfigFile}`, { encoding: 'utf8' });
if (!csrPem) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error);
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 (authorization) {
assert.strictEqual(typeof authorization, 'object');
debug(`prepareHttpChallenge: challenges: ${JSON.stringify(authorization)}`);
const httpChallenges = authorization.challenges.filter(function(x) { return x.type === 'http-01'; });
if (httpChallenges.length === 0) throw new BoxError(BoxError.ACME_ERROR, 'no http challenges');
const challenge = httpChallenges[0];
debug(`prepareHttpChallenge: preparing for challenge ${JSON.stringify(challenge)}`);
const keyAuthorization = 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}`);
return challenge;
};
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
let 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, authorization) {
assert.strictEqual(typeof authorization, 'object');
debug(`prepareDnsChallenge: challenges: ${JSON.stringify(authorization)}`);
const dnsChallenges = authorization.challenges.filter(function(x) { return x.type === 'dns-01'; });
if (dnsChallenges.length === 0) throw new BoxError(BoxError.ACME_ERROR, 'no dns challenges');
const challenge = dnsChallenges[0];
const keyAuthorization = 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 });
return challenge;
};
Acme2.prototype.cleanupDnsChallenge = async function (cn, challenge) {
assert.strictEqual(typeof cn, 'string');
assert.strictEqual(typeof challenge, 'object');
const keyAuthorization = 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.performHttpAuthorization} cn: ${cn} authorization: ${JSON.stringify(authorization)}`);
if (this.performHttpAuthorization) {
return await this.prepareHttpChallenge(authorization);
} else {
return await this.prepareDnsChallenge(cn, authorization);
}
};
Acme2.prototype.cleanupChallenge = async function (cn, challenge) {
assert.strictEqual(typeof cn, 'string');
assert.strictEqual(typeof challenge, 'object');
debug(`cleanupChallenge: http: ${this.performHttpAuthorization}`);
if (this.performHttpAuthorization) {
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();
});
}