@certd/acme-client 1.39.11 → 1.39.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/client.js +1 -1
- package/src/logs/app.log +0 -0
- package/src/util.js +3 -1
- package/src/verify.js +181 -166
- package/types/index.d.ts +3 -1
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"description": "Simple and unopinionated ACME client",
|
|
4
4
|
"private": false,
|
|
5
5
|
"author": "nmorsman",
|
|
6
|
-
"version": "1.39.
|
|
6
|
+
"version": "1.39.12",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"module": "scr/index.js",
|
|
9
9
|
"main": "src/index.js",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"types"
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@certd/basic": "^1.39.
|
|
21
|
+
"@certd/basic": "^1.39.12",
|
|
22
22
|
"@peculiar/x509": "^1.11.0",
|
|
23
23
|
"asn1js": "^3.0.5",
|
|
24
24
|
"axios": "^1.9.0",
|
|
@@ -70,5 +70,5 @@
|
|
|
70
70
|
"bugs": {
|
|
71
71
|
"url": "https://github.com/publishlab/node-acme-client/issues"
|
|
72
72
|
},
|
|
73
|
-
"gitHead": "
|
|
73
|
+
"gitHead": "898bc9b9f2f75df11ea0803b144862ba98b7511a"
|
|
74
74
|
}
|
package/src/client.js
CHANGED
|
@@ -494,7 +494,7 @@ class AcmeClient {
|
|
|
494
494
|
throw new Error('Unable to verify ACME challenge, URL not found');
|
|
495
495
|
}
|
|
496
496
|
|
|
497
|
-
const {challenges} = createChallengeFn({logger:this.logger});
|
|
497
|
+
const {challenges} = createChallengeFn({logger:this.logger,walkFromAuthoritative: this.opts.walkFromAuthoritative});
|
|
498
498
|
|
|
499
499
|
const verify = challenges
|
|
500
500
|
if (typeof verify[challenge.type] === 'undefined') {
|
package/src/logs/app.log
ADDED
|
File without changes
|
package/src/util.js
CHANGED
|
@@ -252,7 +252,7 @@ async function resolveDomainBySoaRecord(recordName, logger = log) {
|
|
|
252
252
|
|
|
253
253
|
async function getAuthoritativeDnsResolver(recordName, logger = log) {
|
|
254
254
|
logger(`获取域名${recordName}的权威NS服务器: `);
|
|
255
|
-
const resolver = new dns.Resolver({
|
|
255
|
+
const resolver = new dns.Resolver({timeout: 2000,tries: 2});
|
|
256
256
|
|
|
257
257
|
try {
|
|
258
258
|
/* Resolve root domain by SOA */
|
|
@@ -352,3 +352,5 @@ export {
|
|
|
352
352
|
resolveDomainBySoaRecord
|
|
353
353
|
};
|
|
354
354
|
|
|
355
|
+
|
|
356
|
+
|
package/src/verify.js
CHANGED
|
@@ -4,19 +4,23 @@
|
|
|
4
4
|
|
|
5
5
|
import dnsSdk from "dns"
|
|
6
6
|
import https from 'https'
|
|
7
|
-
import {log as defaultLog} from './logger.js'
|
|
7
|
+
import { log as defaultLog } from './logger.js'
|
|
8
8
|
import axios from './axios.js'
|
|
9
9
|
import * as util from './util.js'
|
|
10
|
-
import {isAlpnCertificateAuthorizationValid} from './crypto/index.js'
|
|
11
|
-
import {utils} from '@certd/basic'
|
|
10
|
+
import { isAlpnCertificateAuthorizationValid } from './crypto/index.js'
|
|
11
|
+
import { utils } from '@certd/basic'
|
|
12
12
|
|
|
13
13
|
const dns = dnsSdk.promises
|
|
14
14
|
|
|
15
|
+
let walkFromAuthoritative = true
|
|
16
|
+
export function setWalkFromAuthoritative(value = true) {
|
|
17
|
+
walkFromAuthoritative = value
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createChallengeFn(opts = {}) {
|
|
21
|
+
const logger = opts?.logger || { info: defaultLog, error: defaultLog, warn: defaultLog, debug: defaultLog }
|
|
15
22
|
|
|
16
|
-
|
|
17
|
-
const logger = opts?.logger || {info:defaultLog,error:defaultLog,warn:defaultLog,debug:defaultLog}
|
|
18
|
-
|
|
19
|
-
const log = function(...args){
|
|
23
|
+
const log = function (...args) {
|
|
20
24
|
logger.info(...args)
|
|
21
25
|
}
|
|
22
26
|
/**
|
|
@@ -31,201 +35,212 @@ export function createChallengeFn(opts = {}){
|
|
|
31
35
|
* @returns {Promise<boolean>}
|
|
32
36
|
*/
|
|
33
37
|
|
|
34
|
-
async function verifyHttpChallenge(authz, challenge, keyAuthorization, suffix = `/.well-known/acme-challenge/${challenge.token}`) {
|
|
38
|
+
async function verifyHttpChallenge(authz, challenge, keyAuthorization, suffix = `/.well-known/acme-challenge/${challenge.token}`) {
|
|
35
39
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
async function doQuery(challengeUrl) {
|
|
41
|
+
log(`正在测试请求 ${challengeUrl} `)
|
|
42
|
+
// const httpsPort = axios.defaults.acmeSettings.httpsChallengePort || 443;
|
|
43
|
+
// const challengeUrl = `https://${authz.identifier.value}:${httpsPort}${suffix}`;
|
|
40
44
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
log(`Sending HTTP query to ${authz.identifier.value}, suffix: ${suffix}, port: ${httpPort}`);
|
|
45
|
-
let data = ""
|
|
46
|
-
try{
|
|
47
|
-
const resp = await axios.get(challengeUrl, { httpsAgent });
|
|
48
|
-
data = (resp.data || '').replace(/\s+$/, '');
|
|
49
|
-
}catch (e) {
|
|
50
|
-
log(`[error] HTTP request error from ${authz.identifier.value}`,e.message);
|
|
51
|
-
return false
|
|
52
|
-
}
|
|
45
|
+
/* May redirect to HTTPS with invalid/self-signed cert - https://letsencrypt.org/docs/challenge-types/#http-01-challenge */
|
|
46
|
+
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
|
53
47
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
48
|
+
log(`Sending HTTP query to ${authz.identifier.value}, suffix: ${suffix}, port: ${httpPort}`);
|
|
49
|
+
let data = ""
|
|
50
|
+
try {
|
|
51
|
+
const resp = await axios.get(challengeUrl, { httpsAgent });
|
|
52
|
+
data = (resp.data || '').replace(/\s+$/, '');
|
|
53
|
+
} catch (e) {
|
|
54
|
+
log(`[error] HTTP request error from ${authz.identifier.value}`, e.message);
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
59
57
|
|
|
60
|
-
|
|
58
|
+
if (!data || (data !== keyAuthorization)) {
|
|
59
|
+
log(`[error] Authorization not found in HTTP response from ${authz.identifier.value}`);
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
return true
|
|
61
63
|
|
|
62
|
-
const httpPort = axios.defaults.acmeSettings.httpChallengePort || 80;
|
|
63
|
-
let host = authz.identifier.value;
|
|
64
|
-
if(utils.domain.isIpv6(host)){
|
|
65
|
-
host = `[${host}]`;
|
|
66
|
-
}
|
|
67
|
-
const challengeUrl = `http://${host}:${httpPort}${suffix}`;
|
|
68
|
-
|
|
69
|
-
if (!await doQuery(challengeUrl)) {
|
|
70
|
-
const httpsPort = axios.defaults.acmeSettings.httpsChallengePort || 443;
|
|
71
|
-
const httpsChallengeUrl = `https://${host}:${httpsPort}${suffix}`;
|
|
72
|
-
const res = await doQuery(httpsChallengeUrl)
|
|
73
|
-
if (!res) {
|
|
74
|
-
throw new Error(`[error] 验证失败,请检查以上测试url是否可以正常访问`);
|
|
75
64
|
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
log(`Key authorization match for ${challenge.type}/${authz.identifier.value}, ACME challenge verified`);
|
|
80
|
-
return true;
|
|
81
|
-
}
|
|
82
65
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
66
|
+
const httpPort = axios.defaults.acmeSettings.httpChallengePort || 80;
|
|
67
|
+
let host = authz.identifier.value;
|
|
68
|
+
if (utils.domain.isIpv6(host)) {
|
|
69
|
+
host = `[${host}]`;
|
|
70
|
+
}
|
|
71
|
+
const challengeUrl = `http://${host}:${httpPort}${suffix}`;
|
|
72
|
+
|
|
73
|
+
if (!await doQuery(challengeUrl)) {
|
|
74
|
+
const httpsPort = axios.defaults.acmeSettings.httpsChallengePort || 443;
|
|
75
|
+
const httpsChallengeUrl = `https://${host}:${httpsPort}${suffix}`;
|
|
76
|
+
const res = await doQuery(httpsChallengeUrl)
|
|
77
|
+
if (!res) {
|
|
78
|
+
throw new Error(`[error] 验证失败,请检查以上测试url是否可以正常访问`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
86
81
|
|
|
87
|
-
async function walkDnsChallengeRecord(recordName, resolver = dns,deep = 0) {
|
|
88
82
|
|
|
89
|
-
|
|
83
|
+
log(`Key authorization match for ${challenge.type}/${authz.identifier.value}, ACME challenge verified`);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
90
86
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Walk DNS until TXT records are found
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
async function walkDnsChallengeRecord(recordName, resolver = dns, deep = 0) {
|
|
92
|
+
|
|
93
|
+
let records = [];
|
|
94
|
+
|
|
95
|
+
const isAuthoritative = resolver === dns
|
|
96
|
+
/* Resolve TXT records */
|
|
97
|
+
try {
|
|
98
|
+
log(`检查域名 ${recordName} 的TXT记录(from ${isAuthoritative ? '本地DNS' : '权威DNS服务器'})`);
|
|
99
|
+
const txtRecords = await resolver.resolveTxt(recordName);
|
|
100
|
+
if (txtRecords && txtRecords.length) {
|
|
101
|
+
log(`找到 ${txtRecords.length} 条 TXT记录( ${recordName})`);
|
|
102
|
+
log(`TXT records: ${JSON.stringify(txtRecords)}`);
|
|
103
|
+
records = records.concat(...txtRecords);
|
|
104
|
+
}
|
|
105
|
+
} catch (e) {
|
|
106
|
+
log(`解析 TXT 记录出错, ${recordName} :${e.message}`);
|
|
99
107
|
}
|
|
100
|
-
} catch (e) {
|
|
101
|
-
log(`解析 TXT 记录出错, ${recordName} :${e.message}`);
|
|
102
|
-
}
|
|
103
108
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
109
|
+
/* Resolve CNAME record first */
|
|
110
|
+
try {
|
|
111
|
+
log(`检查是否存在CNAME映射: ${recordName}`);
|
|
112
|
+
const cnameRecords = await resolver.resolveCname(recordName);
|
|
113
|
+
|
|
114
|
+
if (cnameRecords.length) {
|
|
115
|
+
const cnameRecord = cnameRecords[0];
|
|
116
|
+
log(`已找到${recordName}的CNAME记录,将检查: ${cnameRecord}`);
|
|
117
|
+
let res = await walkTxtRecord(cnameRecord, deep + 1);
|
|
118
|
+
if (res && res.length) {
|
|
119
|
+
log(`从CNAME中找到TXT记录: ${JSON.stringify(res)}`);
|
|
120
|
+
records = records.concat(...res);
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
log(`没有CNAME映射(${recordName})`);
|
|
116
124
|
}
|
|
117
|
-
}
|
|
118
|
-
log(
|
|
125
|
+
} catch (e) {
|
|
126
|
+
log(`检查CNAME出错(${recordName}) :${e.message}`);
|
|
119
127
|
}
|
|
120
|
-
|
|
121
|
-
log(`检查CNAME出错(${recordName}) :${e.message}`);
|
|
128
|
+
return records
|
|
122
129
|
}
|
|
123
|
-
return records
|
|
124
|
-
}
|
|
125
130
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
+
async function walkTxtRecord(recordName, deep = 0) {
|
|
132
|
+
if (deep > 5) {
|
|
133
|
+
log(`walkTxtRecord too deep (#${deep}) , skip walk`)
|
|
134
|
+
return []
|
|
135
|
+
}
|
|
131
136
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
137
|
+
const txtRecords = []
|
|
138
|
+
try {
|
|
139
|
+
/* Default DNS resolver first */
|
|
140
|
+
log('从本地DNS服务器获取TXT解析记录');
|
|
141
|
+
const res = await walkDnsChallengeRecord(recordName, dns, deep);
|
|
142
|
+
if (res && res.length > 0) {
|
|
143
|
+
for (const item of res) {
|
|
144
|
+
txtRecords.push(item)
|
|
145
|
+
}
|
|
140
146
|
}
|
|
141
|
-
}
|
|
142
147
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
148
|
+
} catch (e) {
|
|
149
|
+
log(`本地获取TXT解析记录失败:${e.message}`)
|
|
150
|
+
}
|
|
146
151
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
152
|
+
if (walkFromAuthoritative !==false) {
|
|
153
|
+
try {
|
|
154
|
+
/* Authoritative DNS resolver */
|
|
155
|
+
log(`从域名权威服务器获取TXT解析记录`);
|
|
156
|
+
const authoritativeResolver = await util.getAuthoritativeDnsResolver(recordName, log);
|
|
157
|
+
const res = await walkDnsChallengeRecord(recordName, authoritativeResolver, deep);
|
|
158
|
+
if (res && res.length > 0) {
|
|
159
|
+
for (const item of res) {
|
|
160
|
+
txtRecords.push(item)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} catch (e) {
|
|
164
|
+
log(`权威服务器获取TXT解析记录失败:${e.message}`)
|
|
155
165
|
}
|
|
166
|
+
}else{
|
|
167
|
+
log(`跳过从权威服务器获取TXT解析记录`);
|
|
156
168
|
}
|
|
157
|
-
}catch (e) {
|
|
158
|
-
log(`权威服务器获取TXT解析记录失败:${e.message}`)
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (txtRecords.length === 0) {
|
|
162
|
-
throw new Error(`没有找到TXT解析记录(${recordName})`);
|
|
163
|
-
}
|
|
164
|
-
return txtRecords;
|
|
165
|
-
}
|
|
166
169
|
|
|
167
|
-
/**
|
|
168
|
-
* Verify ACME DNS challenge
|
|
169
|
-
*
|
|
170
|
-
* https://datatracker.ietf.org/doc/html/rfc8555#section-8.4
|
|
171
|
-
*
|
|
172
|
-
* @param {object} authz Identifier authorization
|
|
173
|
-
* @param {object} challenge Authorization challenge
|
|
174
|
-
* @param {string} keyAuthorization Challenge key authorization
|
|
175
|
-
* @param {string} [prefix] DNS prefix
|
|
176
|
-
* @returns {Promise<boolean>}
|
|
177
|
-
*/
|
|
178
170
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
//去重
|
|
184
|
-
recordValues = [...new Set(recordValues)];
|
|
185
|
-
log(`DNS查询成功, 找到 ${recordValues.length} 条TXT记录:${recordValues}`);
|
|
186
|
-
if (!recordValues.length || !recordValues.includes(keyAuthorization)) {
|
|
187
|
-
const err = `没有找到需要的DNS TXT记录: ${recordName},期望:${keyAuthorization},结果:${recordValues}`
|
|
188
|
-
throw new Error(err);
|
|
171
|
+
if (txtRecords.length === 0) {
|
|
172
|
+
throw new Error(`没有找到TXT解析记录(${recordName})`);
|
|
173
|
+
}
|
|
174
|
+
return txtRecords;
|
|
189
175
|
}
|
|
190
176
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
177
|
+
/**
|
|
178
|
+
* Verify ACME DNS challenge
|
|
179
|
+
*
|
|
180
|
+
* https://datatracker.ietf.org/doc/html/rfc8555#section-8.4
|
|
181
|
+
*
|
|
182
|
+
* @param {object} authz Identifier authorization
|
|
183
|
+
* @param {object} challenge Authorization challenge
|
|
184
|
+
* @param {string} keyAuthorization Challenge key authorization
|
|
185
|
+
* @param {string} [prefix] DNS prefix
|
|
186
|
+
* @returns {Promise<boolean>}
|
|
187
|
+
*/
|
|
188
|
+
|
|
189
|
+
async function verifyDnsChallenge(authz, challenge, keyAuthorization, prefix = '_acme-challenge.') {
|
|
190
|
+
const recordName = `${prefix}${authz.identifier.value}`;
|
|
191
|
+
log(`本地校验TXT记录): ${recordName}`);
|
|
192
|
+
let recordValues = await walkTxtRecord(recordName, 0, walkFromAuthoritative);
|
|
193
|
+
//去重
|
|
194
|
+
recordValues = [...new Set(recordValues)];
|
|
195
|
+
log(`DNS查询成功, 找到 ${recordValues.length} 条TXT记录:${recordValues}`);
|
|
196
|
+
if (!recordValues.length || !recordValues.includes(keyAuthorization)) {
|
|
197
|
+
const err = `没有找到需要的DNS TXT记录: ${recordName},期望:${keyAuthorization},结果:${recordValues}`
|
|
198
|
+
throw new Error(err);
|
|
199
|
+
}
|
|
205
200
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
log(`Establishing TLS connection with host: ${host}:${tlsAlpnPort}`);
|
|
201
|
+
log(`关键授权匹配成功(${challenge.type}/${recordName}):${keyAuthorization},校验成功, ACME challenge verified`);
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
210
204
|
|
|
211
|
-
|
|
212
|
-
|
|
205
|
+
/**
|
|
206
|
+
* Verify ACME TLS ALPN challenge
|
|
207
|
+
*
|
|
208
|
+
* https://datatracker.ietf.org/doc/html/rfc8737
|
|
209
|
+
*
|
|
210
|
+
* @param {object} authz Identifier authorization
|
|
211
|
+
* @param {object} challenge Authorization challenge
|
|
212
|
+
* @param {string} keyAuthorization Challenge key authorization
|
|
213
|
+
* @returns {Promise<boolean>}
|
|
214
|
+
*/
|
|
215
|
+
|
|
216
|
+
async function verifyTlsAlpnChallenge(authz, challenge, keyAuthorization) {
|
|
217
|
+
const tlsAlpnPort = axios.defaults.acmeSettings.tlsAlpnChallengePort || 443;
|
|
218
|
+
const host = authz.identifier.value;
|
|
219
|
+
log(`Establishing TLS connection with host: ${host}:${tlsAlpnPort}`);
|
|
220
|
+
|
|
221
|
+
const certificate = await util.retrieveTlsAlpnCertificate(host, tlsAlpnPort);
|
|
222
|
+
log('Certificate received from server successfully, matching key authorization in ALPN');
|
|
223
|
+
|
|
224
|
+
if (!isAlpnCertificateAuthorizationValid(certificate, keyAuthorization)) {
|
|
225
|
+
throw new Error(`Authorization not found in certificate from ${authz.identifier.value}`);
|
|
226
|
+
}
|
|
213
227
|
|
|
214
|
-
|
|
215
|
-
|
|
228
|
+
log(`Key authorization match for ${challenge.type}/${authz.identifier.value}, ACME challenge verified`);
|
|
229
|
+
return true;
|
|
216
230
|
}
|
|
217
231
|
|
|
218
|
-
log(`Key authorization match for ${challenge.type}/${authz.identifier.value}, ACME challenge verified`);
|
|
219
|
-
return true;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
232
|
return {
|
|
223
|
-
challenges:{
|
|
233
|
+
challenges: {
|
|
224
234
|
'http-01': verifyHttpChallenge,
|
|
225
235
|
'dns-01': verifyDnsChallenge,
|
|
226
236
|
'tls-alpn-01': verifyTlsAlpnChallenge,
|
|
227
237
|
},
|
|
228
238
|
walkTxtRecord,
|
|
239
|
+
walkDnsChallengeRecord,
|
|
229
240
|
}
|
|
230
241
|
|
|
231
|
-
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
// createChallengeFn({logger:{info:console.log}}).walkDnsChallengeRecord("handsfree.work")
|
package/types/index.d.ts
CHANGED
|
@@ -219,4 +219,6 @@ export function getAuthoritativeDnsResolver(record:string): Promise<any>;
|
|
|
219
219
|
|
|
220
220
|
export const CancelError: typeof CancelError;
|
|
221
221
|
|
|
222
|
-
export function resolveDomainBySoaRecord(domain: string): Promise<string>;
|
|
222
|
+
export function resolveDomainBySoaRecord(domain: string): Promise<string>;
|
|
223
|
+
|
|
224
|
+
export function setWalkFromAuthoritative(value = true): void;
|