🔱: [acme] sync upgrade with 7 commits [trident-sync]

CHANGELOG
Fix tls-alpn-01 pebble test on Node v18+
Return correct tls-alpn-01 key authorization, tests
Support tls-alpn-01 internal challenge verification
Add tls-alpn-01 challenge test server support
Add ALPN crypto utility methods
This commit is contained in:
GitHub Actions Bot
2024-01-30 19:24:20 +00:00
parent 08c1f338d5
commit fc9e71bed2
14 changed files with 389 additions and 42 deletions
+2 -1
View File
@@ -18,7 +18,8 @@ instance.defaults.headers.common['User-Agent'] = `node-${pkg.name}/${pkg.version
/* Default ACME settings */
instance.defaults.acmeSettings = {
httpChallengePort: 80,
httpsChallengePort: 443
httpsChallengePort: 443,
tlsAlpnChallengePort: 443
};
+8 -11
View File
@@ -462,22 +462,19 @@ class AcmeClient {
const thumbprint = keysum.digest('base64url');
const result = `${challenge.token}.${thumbprint}`;
/**
* https://tools.ietf.org/html/rfc8555#section-8.3
*/
/* https://tools.ietf.org/html/rfc8555#section-8.3 */
if (challenge.type === 'http-01') {
return result;
}
/**
* https://tools.ietf.org/html/rfc8555#section-8.4
* https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-01
*/
/* https://tools.ietf.org/html/rfc8555#section-8.4 */
if (challenge.type === 'dns-01') {
return createHash('sha256').update(result).digest('base64url');
}
if ((challenge.type === 'dns-01') || (challenge.type === 'tls-alpn-01')) {
const shasum = createHash('sha256').update(result);
return shasum.digest('base64url');
/* https://tools.ietf.org/html/rfc8737 */
if (challenge.type === 'tls-alpn-01') {
return result;
}
throw new Error(`Unable to produce key authorization, unknown challenge type: ${challenge.type}`);
+117 -15
View File
@@ -9,8 +9,12 @@ const { promisify } = require('util');
const crypto = require('crypto');
const jsrsasign = require('jsrsasign');
const randomInt = promisify(crypto.randomInt);
const generateKeyPair = promisify(crypto.generateKeyPair);
/* https://datatracker.ietf.org/doc/html/rfc8737#section-6.1 */
const alpnAcmeIdentifierOID = '1.3.6.1.5.5.7.1.31';
/**
* Determine key type and info by attempting to derive public key
@@ -231,7 +235,7 @@ exports.getPemBodyAsB64u = (pem) => {
throw new Error('Unable to parse PEM body from string');
}
/* First object, hex and back to b64 without new lines */
/* Select first object, decode to hex and b64u */
return jsrsasign.hextob64u(jsrsasign.pemtohex(chain[0]));
};
@@ -303,6 +307,28 @@ exports.readCsrDomains = (csrPem) => {
};
/**
* Parse params from a single or chain of PEM encoded certificates
*
* @private
* @param {buffer|string} certPem PEM encoded certificate or chain
* @returns {object} Certificate params
*/
function getCertificateParams(certPem) {
const chain = splitPemChain(certPem);
if (!chain.length) {
throw new Error('Unable to parse PEM body from string');
}
/* Parse certificate */
const obj = new jsrsasign.X509();
obj.readCertPEM(chain[0]);
return obj.getParam();
}
/**
* Read information from a certificate
* If multiple certificates are chained, the first will be read
@@ -324,16 +350,7 @@ exports.readCsrDomains = (csrPem) => {
*/
exports.readCertificateInfo = (certPem) => {
const chain = splitPemChain(certPem);
if (!chain.length) {
throw new Error('Unable to parse PEM body from string');
}
/* Parse certificate */
const obj = new jsrsasign.X509();
obj.readCertPEM(chain[0]);
const params = obj.getParam();
const params = getCertificateParams(certPem);
return {
issuer: {
@@ -462,7 +479,7 @@ function formatCsrAltNames(altNames) {
* }, certificateKey);
*/
exports.createCsr = async (data, keyPem = null) => {
async function createCsr(data, keyPem = null) {
if (!keyPem) {
keyPem = await createPrivateRsaKey(data.keySize);
}
@@ -517,10 +534,95 @@ exports.createCsr = async (data, keyPem = null) => {
extreq: extensionRequests
});
/* Sign CSR, get PEM */
csr.sign();
/* Done */
const pem = csr.getPEM();
return [keyPem, Buffer.from(pem)];
}
exports.createCsr = createCsr;
/**
* Create a self-signed ALPN certificate for TLS-ALPN-01 challenges
*
* https://tools.ietf.org/html/rfc8737
*
* @param {object} authz Identifier authorization
* @param {string} keyAuthorization Challenge key authorization
* @param {string} [keyPem] PEM encoded CSR private key
* @returns {Promise<buffer[]>} [privateKey, certificate]
*
* @example Create a ALPN certificate
* ```js
* const [alpnKey, alpnCertificate] = await acme.crypto.createAlpnCertificate(authz, keyAuthorization);
* ```
*
* @example Create a ALPN certificate with ECDSA private key
* ```js
* const alpnKey = await acme.crypto.createPrivateEcdsaKey();
* const [, alpnCertificate] = await acme.crypto.createAlpnCertificate(authz, keyAuthorization, alpnKey);
*/
exports.createAlpnCertificate = async (authz, keyAuthorization, keyPem = null) => {
/* Create CSR first */
const now = new Date();
const commonName = authz.identifier.value;
const [key, csr] = await createCsr({ commonName }, keyPem);
/* Parse params and grab stuff we need */
const params = jsrsasign.KJUR.asn1.csr.CSRUtil.getParam(csr.toString());
const { subject, sbjpubkey, extreq, sigalg } = params;
/* ALPN extension */
const alpnExt = {
critical: true,
extname: alpnAcmeIdentifierOID,
extn: new jsrsasign.KJUR.asn1.DEROctetString({
hex: crypto.createHash('sha256').update(keyAuthorization).digest('hex')
})
};
/* Pseudo-random serial - max 20 bytes, 11 for epoch (year 5138), 9 random */
const random = await randomInt(1, 999999999);
const serial = `${Math.floor(now.getTime() / 1000)}${random}`;
/* Self-signed ALPN certificate */
const certificate = new jsrsasign.KJUR.asn1.x509.Certificate({
subject,
sbjpubkey,
sigalg,
version: 3,
serial: { hex: Buffer.from(serial).toString('hex') },
issuer: subject,
notbefore: jsrsasign.datetozulu(now),
notafter: jsrsasign.datetozulu(now),
cakey: key.toString(),
ext: extreq.concat([alpnExt])
});
/* Done */
return [keyPem, Buffer.from(pem)];
const pem = certificate.getPEM();
return [key, Buffer.from(pem)];
};
/**
* Validate that a ALPN certificate contains the expected key authorization
*
* @param {buffer|string} certPem PEM encoded certificate
* @param {string} keyAuthorization Expected challenge key authorization
* @returns {boolean} True when valid
*/
exports.isAlpnCertificateAuthorizationValid = (certPem, keyAuthorization) => {
const params = getCertificateParams(certPem);
const expectedHex = crypto.createHash('sha256').update(keyAuthorization).digest('hex');
const acmeExt = (params.ext || []).find((e) => (e && e.extname && (e.extname === alpnAcmeIdentifierOID)));
if (!acmeExt || !acmeExt.extn || !acmeExt.extn.octstr || !acmeExt.extn.octstr.hex) {
throw new Error('Unable to locate ALPN extension within parsed certificate');
}
/* Return true if match */
return (acmeExt.extn.octstr.hex === expectedHex);
};
+57 -1
View File
@@ -2,6 +2,7 @@
* Utility methods
*/
const tls = require('tls');
const dns = require('dns').promises;
const { readCertificateInfo, splitPemChain } = require('./crypto');
const { log } = require('./logger');
@@ -245,6 +246,60 @@ async function getAuthoritativeDnsResolver(recordName) {
}
/**
* Attempt to retrieve TLS ALPN certificate from peer
*
* https://nodejs.org/api/tls.html#tlsconnectoptions-callback
*
* @param {string} host Host the TLS client should connect to
* @param {number} port Port the client should connect to
* @param {string} servername Server name for the SNI (Server Name Indication)
* @returns {Promise<string>} PEM encoded certificate
*/
async function retrieveTlsAlpnCertificate(host, port, timeout = 30000) {
return new Promise((resolve, reject) => {
let result;
/* TLS connection */
const socket = tls.connect({
host,
port,
servername: host,
rejectUnauthorized: false,
ALPNProtocols: ['acme-tls/1']
});
socket.setTimeout(timeout);
socket.setEncoding('utf-8');
/* Grab certificate once connected and close */
socket.on('secureConnect', () => {
result = socket.getPeerX509Certificate();
socket.end();
});
/* Errors */
socket.on('error', (err) => {
reject(err);
});
socket.on('timeout', () => {
socket.destroy(new Error('TLS ALPN certificate lookup request timed out'));
});
/* Done, return cert as PEM if found */
socket.on('end', () => {
if (result) {
return resolve(result.toString());
}
return reject(new Error('TLS ALPN lookup failed to retrieve certificate'));
});
});
}
/**
* Export utils
*/
@@ -254,5 +309,6 @@ module.exports = {
parseLinkHeader,
findCertificateChainForIssuer,
formatResponseError,
getAuthoritativeDnsResolver
getAuthoritativeDnsResolver,
retrieveTlsAlpnCertificate
};
+31 -1
View File
@@ -7,6 +7,7 @@ const https = require('https');
const { log } = require('./logger');
const axios = require('./axios');
const util = require('./util');
const { isAlpnCertificateAuthorizationValid } = require('./crypto');
/**
@@ -121,11 +122,40 @@ async function verifyDnsChallenge(authz, challenge, keyAuthorization, prefix = '
}
/**
* Verify ACME TLS ALPN challenge
*
* https://tools.ietf.org/html/rfc8737
*
* @param {object} authz Identifier authorization
* @param {object} challenge Authorization challenge
* @param {string} keyAuthorization Challenge key authorization
* @returns {Promise<boolean>}
*/
async function verifyTlsAlpnChallenge(authz, challenge, keyAuthorization) {
const tlsAlpnPort = axios.defaults.acmeSettings.tlsAlpnChallengePort || 443;
const host = authz.identifier.value;
log(`Establishing TLS connection with host: ${host}:${tlsAlpnPort}`);
const certificate = await util.retrieveTlsAlpnCertificate(host, tlsAlpnPort);
log('Certificate received from server successfully, matching key authorization in ALPN');
if (!isAlpnCertificateAuthorizationValid(certificate, keyAuthorization)) {
throw new Error(`Authorization not found in certificate from ${authz.identifier.value}`);
}
log(`Key authorization match for ${challenge.type}/${authz.identifier.value}, ACME challenge verified`);
return true;
}
/**
* Export API
*/
module.exports = {
'http-01': verifyHttpChallenge,
'dns-01': verifyDnsChallenge
'dns-01': verifyDnsChallenge,
'tls-alpn-01': verifyTlsAlpnChallenge
};