mirror of
https://github.com/certd/certd.git
synced 2026-04-24 12:27:25 +08:00
🔱: [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:
@@ -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
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user