Files
cloudron-box/src/acme2.js

550 lines
24 KiB
JavaScript
Raw Normal View History

2018-09-10 15:19:10 -07:00
'use strict';
exports = module.exports = {
getCertificate,
// testing
_name: 'acme',
_getChallengeSubdomain: getChallengeSubdomain
};
2021-05-06 22:29:34 -07:00
const assert = require('assert'),
blobs = require('./blobs.js'),
2021-05-07 22:44:13 -07:00
BoxError = require('./boxerror.js'),
2018-09-10 15:19:10 -07:00
crypto = require('crypto'),
debug = require('debug')('box:cert/acme2'),
2021-08-13 17:22:28 -07:00
dns = require('./dns.js'),
2018-09-10 15:19:10 -07:00
fs = require('fs'),
os = require('os'),
2018-09-10 15:19:10 -07:00
path = require('path'),
paths = require('./paths.js'),
2021-05-07 22:44:13 -07:00
promiseRetry = require('./promise-retry.js'),
2018-09-10 15:19:10 -07:00
safe = require('safetydance'),
2024-02-20 23:09:49 +01:00
shell = require('./shell.js'),
superagent = require('superagent'),
users = require('./users.js');
2018-09-10 15:19:10 -07:00
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
2022-11-17 08:58:20 +01:00
function Acme2(fqdn, domainObject, email) {
assert.strictEqual(typeof fqdn, 'string');
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof email, 'string');
2018-09-10 15:19:10 -07:00
2022-11-17 08:58:20 +01:00
this.fqdn = fqdn;
this.accountKey = null;
2022-11-17 08:58:20 +01:00
this.email = email;
2018-09-10 15:19:10 -07:00
this.keyId = null;
2022-11-17 08:58:20 +01:00
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;
2018-09-10 15:19:10 -07:00
this.directory = {};
this.forceHttpAuthorization = domainObject.provider.match(/noop|manual|wildcard/) !== null;
2022-11-17 08:58:20 +01:00
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 ];
}
2022-11-17 08:58:20 +01:00
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}`);
2018-09-10 15:19:10 -07:00
}
// urlsafe base64 encoding (jose)
function urlBase64Encode(string) {
return string.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
function b64(str) {
2022-04-14 17:41:41 -05:00
const buf = Buffer.isBuffer(str) ? str : Buffer.from(str);
2018-09-10 15:19:10 -07:00
return urlBase64Encode(buf.toString('base64'));
}
2024-02-20 23:09:49 +01:00
async function getModulus(pem) {
assert.strictEqual(typeof pem, 'string');
2018-09-10 15:19:10 -07:00
2024-02-21 19:40:27 +01:00
const stdout = await shell.exec('getModulus', 'openssl rsa -modulus -noout', { input: pem });
2022-04-14 17:41:41 -05:00
const match = stdout.match(/Modulus=([0-9a-fA-F]+)$/m);
2024-02-20 23:09:49 +01:00
if (!match) throw new BoxError(BoxError.OPENSSL_ERROR, 'Could not get modulus');
2018-09-10 15:19:10 -07:00
return Buffer.from(match[1], 'hex');
}
2021-05-06 22:29:34 -07:00
Acme2.prototype.sendSignedRequest = async function (url, payload) {
2018-09-10 15:19:10 -07:00
assert.strictEqual(typeof url, 'string');
assert.strictEqual(typeof payload, 'string');
assert.strictEqual(typeof this.accountKey, 'string');
2018-09-10 15:19:10 -07:00
const that = this;
2023-02-01 15:43:59 +01:00
const header = {
2018-09-10 15:19:10 -07:00
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',
2024-02-20 23:09:49 +01:00
n: b64(await getModulus(this.accountKey))
2018-09-10 15:19:10 -07:00
};
}
2021-05-06 22:29:34 -07:00
const payload64 = b64(payload);
2021-05-07 15:56:43 -07:00
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}`);
2021-11-17 10:54:26 -08:00
if (response.status !== 204) throw new BoxError(BoxError.ACME_ERROR, `Invalid response code when fetching nonce : ${response.status}`);
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
const nonce = response.headers['Replay-Nonce'.toLowerCase()];
2021-11-17 10:54:26 -08:00
if (!nonce) throw new BoxError(BoxError.ACME_ERROR, 'No nonce in response');
2018-09-10 15:19:10 -07:00
2023-02-01 15:43:59 +01:00
debug(`sendSignedRequest: using nonce ${nonce} for url ${url}`);
2018-09-10 15:19:10 -07:00
const protected64 = b64(JSON.stringify(Object.assign({}, header, { nonce: nonce })));
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
const signer = crypto.createSign('RSA-SHA256');
signer.update(protected64 + '.' + payload64, 'utf8');
const signature64 = urlBase64Encode(signer.sign(that.accountKey, 'base64'));
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
const data = {
protected: protected64,
payload: payload64,
signature: signature64
};
2018-09-10 15:19:10 -07:00
2021-05-07 15:56:43 -07:00
[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}`);
2021-05-07 15:56:43 -07:00
return response;
2018-09-10 15:19:10 -07:00
};
// https://tools.ietf.org/html/rfc8555#section-6.3
2021-05-06 22:29:34 -07:00
Acme2.prototype.postAsGet = async function (url) {
return await this.sendSignedRequest(url, '');
};
2021-05-06 22:29:34 -07:00
Acme2.prototype.updateContact = async function (registrationUri) {
2018-09-10 15:19:10 -07:00
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 ]
};
2021-05-06 22:29:34 -07:00
const result = await this.sendSignedRequest(registrationUri, JSON.stringify(payload));
2021-11-17 10:54:26 -08:00
if (result.status !== 200) throw new BoxError(BoxError.ACME_ERROR, `Failed to update contact. Expecting 200, got ${result.status} ${JSON.stringify(result.body)}`);
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
debug(`updateContact: contact of user updated to ${this.email}`);
2018-09-10 15:19:10 -07:00
};
async function generateAccountKey() {
2024-02-21 19:40:27 +01:00
const acmeAccountKey = await shell.exec('generateAccountKey', 'openssl genrsa 4096', {});
return acmeAccountKey;
}
Acme2.prototype.ensureAccount = async function () {
2021-05-06 22:29:34 -07:00
const payload = {
2018-09-10 15:19:10 -07:00
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));
}
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
// 200 if already exists. 201 for new accounts
2021-11-17 10:54:26 -08:00
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)}`);
2018-09-10 15:19:10 -07:00
debug(`ensureAccount: user registered keyid: ${result.headers.location}`);
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
this.keyId = result.headers.location;
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
await this.updateContact(result.headers.location);
2018-09-10 15:19:10 -07:00
};
2022-11-17 08:58:20 +01:00
Acme2.prototype.newOrder = async function () {
const payload = { identifiers: [] };
this.altNames.forEach(an => {
payload.identifiers.push({
2018-09-10 15:19:10 -07:00
type: 'dns',
value: an
});
});
2018-09-10 15:19:10 -07:00
debug(`newOrder: ${JSON.stringify(this.altNames)}`);
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
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}`);
2021-11-17 10:54:26 -08:00
if (result.status !== 201) throw new BoxError(BoxError.ACME_ERROR, `Failed to send new order. Expecting 201, got ${result.statusCode} ${JSON.stringify(result.body)}`);
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
const order = result.body, orderUrl = result.headers.location;
2023-02-01 15:43:59 +01:00
debug(`newOrder: created order ${this.cn} order: ${JSON.stringify(result.body)} orderUrl: ${orderUrl}`);
2018-09-10 15:19:10 -07:00
2021-11-17 10:54:26 -08:00
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');
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
return { order, orderUrl };
2018-09-10 15:19:10 -07:00
};
2021-05-06 22:29:34 -07:00
Acme2.prototype.waitForOrder = async function (orderUrl) {
2018-09-10 15:19:10 -07:00
assert.strictEqual(typeof orderUrl, 'string');
debug(`waitForOrder: ${orderUrl}`);
2021-12-07 11:18:26 -08:00
return await promiseRetry({ times: 15, interval: 20000, debug }, async () => {
2018-09-10 15:19:10 -07:00
debug('waitForOrder: getting status');
2021-05-07 15:56:43 -07:00
const result = await this.postAsGet(orderUrl);
2021-05-06 22:29:34 -07:00
if (result.status !== 200) {
debug(`waitForOrder: invalid response code getting uri ${result.status}`);
2021-11-17 10:54:26 -08:00
throw new BoxError(BoxError.ACME_ERROR, `Bad response when waiting for order. code: ${result.status}`);
2021-05-06 22:29:34 -07:00
}
debug('waitForOrder: status is "%s %j', result.body.status, result.body);
2021-11-17 10:54:26 -08:00
if (result.body.status === 'pending' || result.body.status === 'processing') throw new BoxError(BoxError.ACME_ERROR, `Request is in ${result.body.status} state`);
2021-05-06 22:29:34 -07:00
else if (result.body.status === 'valid' && result.body.certificate) return result.body.certificate;
2021-11-17 10:54:26 -08:00
else throw new BoxError(BoxError.ACME_ERROR, `Unexpected status or invalid response when waiting for order: ${JSON.stringify(result.body)}`);
2021-05-06 22:29:34 -07:00
});
2018-09-10 15:19:10 -07:00
};
2024-02-20 23:09:49 +01:00
Acme2.prototype.getKeyAuthorization = async function (token) {
assert(typeof this.accountKey, 'string');
2018-09-10 15:19:10 -07:00
2023-02-01 15:43:59 +01:00
const jwk = {
2018-09-10 15:19:10 -07:00
e: b64(Buffer.from([0x01, 0x00, 0x01])), // Exponent - 65537
kty: 'RSA',
2024-02-20 23:09:49 +01:00
n: b64(await getModulus(this.accountKey))
2018-09-10 15:19:10 -07:00
};
2023-02-01 15:43:59 +01:00
const shasum = crypto.createHash('sha256');
2018-09-10 15:19:10 -07:00
shasum.update(JSON.stringify(jwk));
2023-02-01 15:43:59 +01:00
const thumbprint = urlBase64Encode(shasum.digest('base64'));
2018-09-10 20:50:36 -07:00
return token + '.' + thumbprint;
2018-09-10 15:19:10 -07:00
};
2021-05-06 22:29:34 -07:00
Acme2.prototype.notifyChallengeReady = async function (challenge) {
2018-09-10 15:19:10 -07:00
assert.strictEqual(typeof challenge, 'object'); // { type, status, url, token }
2023-02-01 15:43:59 +01:00
debug(`notifyChallengeReady: ${challenge.url} was met`);
2018-09-10 15:19:10 -07:00
2024-02-20 23:09:49 +01:00
const keyAuthorization = await this.getKeyAuthorization(challenge.token);
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
const payload = {
2018-09-10 15:19:10 -07:00
resource: 'challenge',
keyAuthorization: keyAuthorization
};
2021-05-06 22:29:34 -07:00
const result = await this.sendSignedRequest(challenge.url, JSON.stringify(payload));
2021-11-17 10:54:26 -08:00
if (result.status !== 200) throw new BoxError(BoxError.ACME_ERROR, `Failed to notify challenge. Expecting 200, got ${result.statusCode} ${JSON.stringify(result.body)}`);
2018-09-10 15:19:10 -07:00
};
2021-05-06 22:29:34 -07:00
Acme2.prototype.waitForChallenge = async function (challenge) {
2018-09-10 15:19:10 -07:00
assert.strictEqual(typeof challenge, 'object');
2023-02-01 15:43:59 +01:00
debug(`waitingForChallenge: ${JSON.stringify(challenge)}`);
2018-09-10 15:19:10 -07:00
2021-12-07 11:18:26 -08:00
await promiseRetry({ times: 15, interval: 20000, debug }, async () => {
2018-09-10 15:19:10 -07:00
debug('waitingForChallenge: getting status');
2021-05-06 22:29:34 -07:00
const result = await this.postAsGet(challenge.url);
if (result.status !== 200) {
debug(`waitForChallenge: invalid response code getting uri ${result.status}`);
2021-11-17 10:54:26 -08:00
throw new BoxError(BoxError.ACME_ERROR, `Bad response code when waiting for challenge : ${result.status}`);
2021-05-06 22:29:34 -07:00
}
debug(`waitForChallenge: status is "${result.body.status}" "${JSON.stringify(result.body)}"`);
2021-11-17 10:54:26 -08:00
if (result.body.status === 'pending') throw new BoxError(BoxError.ACME_ERROR, 'Challenge is in pending state');
2021-05-06 22:29:34 -07:00
else if (result.body.status === 'valid') return;
2021-11-17 10:54:26 -08:00
else throw new BoxError(BoxError.ACME_ERROR, `Unexpected status when waiting for challenge: ${result.body.status}`);
2018-09-10 15:19:10 -07:00
});
};
// https://community.letsencrypt.org/t/public-beta-rate-limits/4772 for rate limits
Acme2.prototype.signCertificate = async function (finalizationUrl, csrPem) {
2018-09-10 15:19:10 -07:00
assert.strictEqual(typeof finalizationUrl, 'string');
assert.strictEqual(typeof csrPem, 'string');
2024-02-28 18:13:44 +01:00
const csrDer = await shell.exec('signCertificate', 'openssl req -inform pem -outform der', { input: csrPem, encoding: 'buffer' });
2018-09-10 15:19:10 -07:00
const payload = {
csr: b64(csrDer)
};
2023-02-01 15:43:59 +01:00
debug(`signCertificate: sending sign request to ${finalizationUrl}`);
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
const result = await this.sendSignedRequest(finalizationUrl, JSON.stringify(payload));
// 429 means we reached the cert limit for this domain
2021-11-17 10:54:26 -08:00
if (result.status !== 200) throw new BoxError(BoxError.ACME_ERROR, `Failed to sign certificate. Expecting 200, got ${result.status} ${JSON.stringify(result.body)}`);
2018-09-10 15:19:10 -07:00
};
2022-11-17 08:58:20 +01:00
Acme2.prototype.ensureKey = async function () {
const key = await blobs.getString(`${blobs.CERT_PREFIX}-${this.certName}.key`);
2022-11-17 08:58:20 +01:00
if (key) {
debug(`ensureKey: reuse existing key for ${this.cn}`);
return key;
}
2018-09-10 15:19:10 -07:00
2022-11-17 08:58:20 +01:00
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
2024-02-21 19:40:27 +01:00
const newKey = await shell.exec('ensureKey', 'openssl ecparam -genkey -name secp256r1', {});
2022-11-17 08:58:20 +01:00
return newKey;
};
2018-09-10 15:19:10 -07:00
2022-11-17 08:58:20 +01:00
Acme2.prototype.createCsr = async function (key) {
assert.strictEqual(typeof key, 'string');
2018-09-10 15:19:10 -07:00
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}`);
2022-11-17 08:58:20 +01:00
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
2024-02-21 19:40:27 +01:00
const csrPem = await shell.exec('createCsr', `openssl req -new -key ${keyFilePath} -outform PEM -subj /CN=${this.cn} -config ${opensslConfigFile}`, {});
2022-02-25 16:43:16 -08:00
await safe(fs.promises.rm(tmpdir, { recursive: true, force: true }));
2022-11-17 08:58:20 +01:00
debug(`createCsr: csr file created for ${this.cn}`);
return csrPem; // inspect with openssl req -text -noout -in hostname.csr -inform pem
2018-09-10 15:19:10 -07:00
};
2022-11-17 08:58:20 +01:00
Acme2.prototype.downloadCertificate = async function (certUrl) {
2018-09-10 15:19:10 -07:00
assert.strictEqual(typeof certUrl, 'string');
2022-11-17 08:58:20 +01:00
return await promiseRetry({ times: 5, interval: 20000, debug }, async () => {
debug(`downloadCertificate: downloading certificate of ${this.cn}`);
2018-09-10 15:19:10 -07:00
2021-05-07 15:56:43 -07:00
const result = await this.postAsGet(certUrl);
2021-11-17 10:54:26 -08:00
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)}`);
2018-09-10 15:19:10 -07:00
const fullChainPem = result.body.toString('utf8'); // buffer
2022-11-17 08:58:20 +01:00
return fullChainPem;
2021-05-06 22:29:34 -07:00
});
2018-09-10 15:19:10 -07:00
};
Acme2.prototype.prepareHttpChallenge = async function (challenge) {
assert.strictEqual(typeof challenge, 'object');
2018-09-10 20:50:36 -07:00
2023-02-01 15:43:59 +01:00
debug(`prepareHttpChallenge: preparing for challenge ${JSON.stringify(challenge)}`);
2018-09-10 20:50:36 -07:00
2024-02-20 23:09:49 +01:00
const keyAuthorization = await this.getKeyAuthorization(challenge.token);
2018-09-10 20:50:36 -07:00
2023-02-01 15:43:59 +01:00
const challengeFilePath = path.join(paths.ACME_CHALLENGES_DIR, challenge.token);
debug(`prepareHttpChallenge: writing ${keyAuthorization} to ${challengeFilePath}`);
2018-09-10 20:50:36 -07:00
2023-02-01 15:43:59 +01:00
if (!safe.fs.writeFileSync(challengeFilePath, keyAuthorization)) throw new BoxError(BoxError.FS_ERROR, `Error writing challenge: ${safe.error.message}`);
2018-09-10 20:50:36 -07:00
};
2022-11-17 08:58:20 +01:00
Acme2.prototype.cleanupHttpChallenge = async function (challenge) {
2018-09-10 20:50:36 -07:00
assert.strictEqual(typeof challenge, 'object');
2023-02-01 15:43:59 +01:00
const challengeFilePath = path.join(paths.ACME_CHALLENGES_DIR, challenge.token);
debug(`cleanupHttpChallenge: unlinking ${challengeFilePath}`);
2018-09-28 17:05:53 -07:00
2023-02-01 15:43:59 +01:00
if (!safe.fs.unlinkSync(challengeFilePath)) throw new BoxError(BoxError.FS_ERROR, `Error unlinking challenge: ${safe.error.message}`);
2018-09-10 20:50:36 -07:00
};
2022-11-17 08:58:20 +01:00
function getChallengeSubdomain(cn, domain) {
let challengeSubdomain;
2022-11-17 08:58:20 +01:00
if (cn === domain) {
challengeSubdomain = '_acme-challenge';
2022-11-17 08:58:20 +01:00
} else if (cn.includes('*')) { // wildcard
let subdomain = cn.slice(0, -domain.length - 1);
2018-10-31 15:41:02 -07:00
challengeSubdomain = subdomain ? subdomain.replace('*', '_acme-challenge') : '_acme-challenge';
} else {
2022-11-17 08:58:20 +01:00
challengeSubdomain = '_acme-challenge.' + cn.slice(0, -domain.length - 1);
}
2022-11-17 08:58:20 +01:00
debug(`getChallengeSubdomain: challenge subdomain for cn ${cn} at domain ${domain} is ${challengeSubdomain}`);
2018-10-31 15:41:02 -07:00
return challengeSubdomain;
}
Acme2.prototype.prepareDnsChallenge = async function (cn, challenge) {
assert.strictEqual(typeof challenge, 'object');
2018-09-10 20:50:36 -07:00
debug(`prepareDnsChallenge: preparing for challenge: ${JSON.stringify(challenge)}`);
2018-09-10 20:50:36 -07:00
2024-02-20 23:09:49 +01:00
const keyAuthorization = await this.getKeyAuthorization(challenge.token);
2021-05-06 22:29:34 -07:00
const shasum = crypto.createHash('sha256');
2018-09-10 20:50:36 -07:00
shasum.update(keyAuthorization);
const txtValue = urlBase64Encode(shasum.digest('base64'));
const challengeSubdomain = getChallengeSubdomain(cn, this.domain);
2018-09-10 20:50:36 -07:00
2018-09-11 22:46:17 -07:00
debug(`prepareDnsChallenge: update ${challengeSubdomain} with ${txtValue}`);
2018-09-10 20:50:36 -07:00
2022-11-17 08:58:20 +01:00
await dns.upsertDnsRecords(challengeSubdomain, this.domain, 'TXT', [ `"${txtValue}"` ]);
2018-09-10 20:50:36 -07:00
2022-11-17 08:58:20 +01:00
await dns.waitForDnsRecord(challengeSubdomain, this.domain, 'TXT', txtValue, { times: 200 });
2018-09-10 20:50:36 -07:00
};
Acme2.prototype.cleanupDnsChallenge = async function (cn, challenge) {
assert.strictEqual(typeof cn, 'string');
2018-09-10 20:50:36 -07:00
assert.strictEqual(typeof challenge, 'object');
2024-02-20 23:09:49 +01:00
const keyAuthorization = await this.getKeyAuthorization(challenge.token);
2022-11-17 08:58:20 +01:00
const shasum = crypto.createHash('sha256');
2018-09-10 20:50:36 -07:00
shasum.update(keyAuthorization);
const txtValue = urlBase64Encode(shasum.digest('base64'));
const challengeSubdomain = getChallengeSubdomain(cn, this.domain);
2018-09-10 20:50:36 -07:00
debug(`cleanupDnsChallenge: remove ${challengeSubdomain} with ${txtValue}`);
2018-09-10 20:50:36 -07:00
2022-11-17 08:58:20 +01:00
await dns.removeDnsRecords(challengeSubdomain, this.domain, 'TXT', [ `"${txtValue}"` ]);
2018-09-10 20:50:36 -07:00
};
Acme2.prototype.prepareChallenge = async function (cn, authorization) {
assert.strictEqual(typeof cn, 'string');
assert.strictEqual(typeof authorization, 'object');
2018-09-10 15:19:10 -07:00
debug(`prepareChallenge: http: ${this.forceHttpAuthorization} cn: ${cn} authorization: ${JSON.stringify(authorization)}`);
2018-09-10 20:50:36 -07:00
// 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];
2021-05-06 22:29:34 -07:00
}
await this.prepareDnsChallenge(cn, dnsChallenges[0]);
return dnsChallenges[0];
2018-09-10 15:19:10 -07:00
};
Acme2.prototype.cleanupChallenge = async function (cn, challenge) {
assert.strictEqual(typeof cn, 'string');
2018-09-10 20:50:36 -07:00
assert.strictEqual(typeof challenge, 'object');
debug(`cleanupChallenge: http: ${this.forceHttpAuthorization}`);
2019-10-03 10:36:57 -07:00
if (this.forceHttpAuthorization) {
2022-11-17 08:58:20 +01:00
await this.cleanupHttpChallenge(challenge);
2018-09-10 20:50:36 -07:00
} else {
await this.cleanupDnsChallenge(cn, challenge);
2018-09-10 20:50:36 -07:00
}
};
2022-11-17 08:58:20 +01:00
Acme2.prototype.acmeFlow = async function () {
await this.ensureAccount();
2022-11-17 08:58:20 +01:00
const { order, orderUrl } = await this.newOrder();
for (const authorizationUrl of order.authorizations) {
2021-05-06 22:29:34 -07:00
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);
2021-05-06 22:29:34 -07:00
await this.notifyChallengeReady(challenge);
await this.waitForChallenge(challenge);
await safe(this.cleanupChallenge(cn, challenge), { debug });
2021-05-06 22:29:34 -07:00
}
2022-11-17 08:58:20 +01:00
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 };
2018-09-10 15:19:10 -07:00
};
2021-05-06 22:29:34 -07:00
Acme2.prototype.loadDirectory = async function () {
2021-12-07 11:18:26 -08:00
await promiseRetry({ times: 3, interval: 20000, debug }, async () => {
2021-05-07 15:56:43 -07:00
const response = await superagent.get(this.caDirectory).timeout(30000).ok(() => true);
2018-09-10 15:19:10 -07:00
2021-11-17 10:54:26 -08:00
if (response.status !== 200) throw new BoxError(BoxError.ACME_ERROR, `Invalid response code when fetching directory : ${response.status}`);
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
if (typeof response.body.newNonce !== 'string' ||
typeof response.body.newOrder !== 'string' ||
2021-11-17 10:54:26 -08:00
typeof response.body.newAccount !== 'string') throw new BoxError(BoxError.ACME_ERROR, `Invalid response body : ${response.body}`);
2018-09-10 15:19:10 -07:00
2021-05-06 22:29:34 -07:00
this.directory = response.body;
});
2018-09-10 15:19:10 -07:00
};
2022-11-17 08:58:20 +01:00
Acme2.prototype.getCertificate = async function () {
debug(`getCertificate: start acme flow for ${this.cn} from ${this.caDirectory}`);
2018-09-10 15:19:10 -07:00
2022-11-17 08:58:20 +01:00
await this.loadDirectory();
const result = await this.acmeFlow();
2018-09-10 15:19:10 -07:00
debug(`getCertificate: acme flow completed for ${this.cn}`);
2018-09-11 22:46:17 -07:00
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);
2022-11-17 08:58:20 +01:00
return result;
2018-09-10 15:19:10 -07:00
};
2022-11-17 08:58:20 +01:00
async function getCertificate(fqdn, domainObject) {
assert.strictEqual(typeof fqdn, 'string'); // this can also be a wildcard domain (for alias domains)
2022-11-17 08:58:20 +01:00
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
2018-09-10 15:19:10 -07:00
2022-11-17 08:58:20 +01:00
return await promiseRetry({ times: 3, interval: 0, debug }, async function () {
debug(`getCertificate: for fqdn ${fqdn} and domain ${domainObject.domain}`);
2019-10-03 14:47:18 -07:00
2022-11-17 08:58:20 +01:00
const acme = new Acme2(fqdn, domainObject, email);
return await acme.getCertificate();
2021-09-07 09:34:23 -07:00
});
2018-09-10 15:19:10 -07:00
}