/** * ACME challenge verification */ import dnsSdk from "dns" import https from 'https' import {log} from './logger.js' import axios from './axios.js' import * as util from './util.js' import {isAlpnCertificateAuthorizationValid} from './crypto/index.js' const dns = dnsSdk.promises /** * Verify ACME HTTP challenge * * https://datatracker.ietf.org/doc/html/rfc8555#section-8.3 * * @param {object} authz Identifier authorization * @param {object} challenge Authorization challenge * @param {string} keyAuthorization Challenge key authorization * @param {string} [suffix] URL suffix * @returns {Promise} */ async function verifyHttpChallenge(authz, challenge, keyAuthorization, suffix = `/.well-known/acme-challenge/${challenge.token}`) { async function doQuery(challengeUrl){ log(`正在测试请求 ${challengeUrl} `) // const httpsPort = axios.defaults.acmeSettings.httpsChallengePort || 443; // const challengeUrl = `https://${authz.identifier.value}:${httpsPort}${suffix}`; /* May redirect to HTTPS with invalid/self-signed cert - https://letsencrypt.org/docs/challenge-types/#http-01-challenge */ const httpsAgent = new https.Agent({ rejectUnauthorized: false }); log(`Sending HTTP query to ${authz.identifier.value}, suffix: ${suffix}, port: ${httpPort}`); let data = "" try{ const resp = await axios.get(challengeUrl, { httpsAgent }); data = (resp.data || '').replace(/\s+$/, ''); }catch (e) { log(`[error] HTTP request error from ${authz.identifier.value}`,e.message); return false } if (!data || (data !== keyAuthorization)) { log(`[error] Authorization not found in HTTP response from ${authz.identifier.value}`); return false } return true } const httpPort = axios.defaults.acmeSettings.httpChallengePort || 80; const challengeUrl = `http://${authz.identifier.value}:${httpPort}${suffix}`; if (!await doQuery(challengeUrl)) { const httpsPort = axios.defaults.acmeSettings.httpsChallengePort || 443; const httpsChallengeUrl = `https://${authz.identifier.value}:${httpsPort}${suffix}`; const res = await doQuery(httpsChallengeUrl) if (!res) { throw new Error(`[error] 验证失败,请检查以上测试url是否可以正常访问`); } } log(`Key authorization match for ${challenge.type}/${authz.identifier.value}, ACME challenge verified`); return true; } /** * Walk DNS until TXT records are found */ async function walkDnsChallengeRecord(recordName, resolver = dns,deep = 0) { let records = []; /* Resolve TXT records */ try { log(`检查域名 ${recordName} 的TXT记录`); const txtRecords = await resolver.resolveTxt(recordName); if (txtRecords && txtRecords.length) { log(`找到 ${txtRecords.length} 条 TXT记录( ${recordName})`); log(`TXT records: ${JSON.stringify(txtRecords)}`); records = records.concat(...txtRecords); } } catch (e) { log(`解析 TXT 记录出错, ${recordName} :${e.message}`); } /* Resolve CNAME record first */ try { log(`检查是否存在CNAME映射: ${recordName}`); const cnameRecords = await resolver.resolveCname(recordName); if (cnameRecords.length) { const cnameRecord = cnameRecords[0]; log(`已找到${recordName}的CNAME记录,将检查: ${cnameRecord}`); let res= await walkTxtRecord(cnameRecord,deep+1); if (res && res.length) { log(`从CNAME中找到TXT记录: ${JSON.stringify(res)}`); records = records.concat(...res); } }else{ log(`没有CNAME映射(${recordName})`); } } catch (e) { log(`检查CNAME出错(${recordName}) :${e.message}`); } return records } export async function walkTxtRecord(recordName,deep = 0) { if(deep >5){ log(`walkTxtRecord too deep (#${deep}) , skip walk`) return [] } const txtRecords = [] try { /* Default DNS resolver first */ log('从本地DNS服务器获取TXT解析记录'); const res = await walkDnsChallengeRecord(recordName,dns,deep); if (res && res.length > 0) { for (const item of res) { txtRecords.push(item) } } } catch (e) { log(`本地获取TXT解析记录失败:${e.message}`) } try{ /* Authoritative DNS resolver */ log(`从域名权威服务器获取TXT解析记录`); const authoritativeResolver = await util.getAuthoritativeDnsResolver(recordName); const res = await walkDnsChallengeRecord(recordName, authoritativeResolver,deep); if (res && res.length > 0) { for (const item of res) { txtRecords.push(item) } } }catch (e) { log(`权威服务器获取TXT解析记录失败:${e.message}`) } if (txtRecords.length === 0) { throw new Error(`没有找到TXT解析记录(${recordName})`); } return txtRecords; } /** * Verify ACME DNS challenge * * https://datatracker.ietf.org/doc/html/rfc8555#section-8.4 * * @param {object} authz Identifier authorization * @param {object} challenge Authorization challenge * @param {string} keyAuthorization Challenge key authorization * @param {string} [prefix] DNS prefix * @returns {Promise} */ async function verifyDnsChallenge(authz, challenge, keyAuthorization, prefix = '_acme-challenge.') { const recordName = `${prefix}${authz.identifier.value}`; log(`本地校验TXT记录): ${recordName}`); let recordValues = await walkTxtRecord(recordName); //去重 recordValues = [...new Set(recordValues)]; log(`DNS查询成功, 找到 ${recordValues.length} 条TXT记录:${recordValues}`); if (!recordValues.length || !recordValues.includes(keyAuthorization)) { throw new Error(`没有找到需要的DNS TXT记录: ${recordName},期望:${keyAuthorization},结果:${recordValues}`); } log(`关键授权匹配成功(${challenge.type}/${recordName}):${keyAuthorization},校验成功, ACME challenge verified`); return true; } /** * Verify ACME TLS ALPN challenge * * https://datatracker.ietf.org/doc/html/rfc8737 * * @param {object} authz Identifier authorization * @param {object} challenge Authorization challenge * @param {string} keyAuthorization Challenge key authorization * @returns {Promise} */ 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 */ export default { 'http-01': verifyHttpChallenge, 'dns-01': verifyDnsChallenge, 'tls-alpn-01': verifyTlsAlpnChallenge, };