@small-tech/auto-encrypt 4.3.0 → 5.0.1
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/README.md +64 -64
- package/index.d.ts +152 -0
- package/index.js +220 -205
- package/lib/Account.js +28 -20
- package/lib/AcmeRequest.js +187 -117
- package/lib/Authorisation.js +80 -36
- package/lib/Certificate.js +292 -149
- package/lib/Configuration.js +93 -77
- package/lib/Directory.js +28 -17
- package/lib/HttpServer.js +55 -28
- package/lib/IPAddresses.js +114 -0
- package/lib/Identity.js +65 -37
- package/lib/LetsEncryptServer.js +15 -15
- package/lib/Nonce.js +36 -27
- package/lib/Order.js +65 -39
- package/lib/acme-requests/AuthorisationRequest.js +15 -14
- package/lib/acme-requests/CertificateRequest.js +16 -15
- package/lib/acme-requests/CheckOrderStatusRequest.js +19 -17
- package/lib/acme-requests/FinaliseOrderRequest.js +21 -19
- package/lib/acme-requests/NewAccountRequest.js +13 -14
- package/lib/acme-requests/NewOrderRequest.js +30 -17
- package/lib/acme-requests/ReadyForChallengeValidationRequest.js +17 -16
- package/lib/acmeCsr.js +116 -59
- package/lib/identities/AccountIdentity.js +15 -14
- package/lib/identities/CertificateIdentity.js +16 -16
- package/lib/staging/monkeyPatchTls.js +12 -12
- package/lib/test-helpers/index.js +8 -23
- package/lib/util/Pluralise.js +2 -2
- package/lib/util/Throws.js +61 -21
- package/lib/util/async-foreach.js +21 -21
- package/lib/util/fromNow.js +27 -0
- package/lib/util/log.js +5 -1
- package/lib/util/waitFor.js +5 -0
- package/lib/x.509/rfc5280.js +47 -12
- package/package.json +15 -14
- package/typedefs/lib/AcmeRequest.js +36 -25
package/lib/AcmeRequest.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
Abstract base request class for carrying out signed ACME requests over HTTPS.
|
|
3
|
+
|
|
4
|
+
@module
|
|
5
|
+
@copyright Copyright © 2020 Aral Balkan, Small Technology Foundation.
|
|
6
|
+
@license AGPLv3 or later.
|
|
7
|
+
*/
|
|
8
8
|
|
|
9
9
|
import packageJson from '../package.json' with { type: 'json' }
|
|
10
|
-
import
|
|
11
|
-
import prepareRequest from 'bent'
|
|
12
|
-
import types from '../typedefs/lib/AcmeRequest.js'
|
|
10
|
+
import crypto from 'node:crypto'
|
|
13
11
|
import Nonce from './Nonce.js'
|
|
14
12
|
import Throws from './util/Throws.js'
|
|
15
13
|
import log from './util/log.js'
|
|
@@ -21,40 +19,57 @@ const throws = new Throws({
|
|
|
21
19
|
[Symbol.for('AcmeRequest.accountNotSetError')]:
|
|
22
20
|
() => 'You cannot issue calls that require an account KeyId without first injecting a reference to the account',
|
|
23
21
|
|
|
24
|
-
[Symbol.for('AcmeRequest.requestError')]:
|
|
22
|
+
[Symbol.for('AcmeRequest.requestError')]:
|
|
23
|
+
(/** @type {{ status: number, type: string, detail: string }} */ error) => `(${error.status} ${error.type} ${error.detail})`
|
|
25
24
|
})
|
|
26
25
|
|
|
27
26
|
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
Abstract base request class for carrying out signed ACME requests over HTTPS.
|
|
28
|
+
|
|
29
|
+
@alias module:lib/AcmeRequest
|
|
30
|
+
*/
|
|
32
31
|
export default class AcmeRequest {
|
|
33
32
|
static initialised = false
|
|
33
|
+
static initialisationPromise = null
|
|
34
|
+
|
|
35
|
+
/** @import('./Directory.js').default */
|
|
34
36
|
static directory = null
|
|
37
|
+
|
|
38
|
+
/** @import('./identities/AccountIdentity.js').default */
|
|
35
39
|
static accountIdentity = null
|
|
40
|
+
|
|
36
41
|
static nonce = null
|
|
37
42
|
static __account = null
|
|
38
43
|
/** @type {string} */
|
|
39
44
|
static autoEncryptVersion = packageJson.version
|
|
40
45
|
|
|
46
|
+
/**
|
|
47
|
+
Initialise the AcmeRequest class with the directory and account identity.
|
|
48
|
+
|
|
49
|
+
@param {import('./Directory.js').default|void} directory - (Required) The ACME directory.
|
|
50
|
+
|
|
51
|
+
@param {import('./identities/AccountIdentity.js').default|void} accountIdentity - (Required) The account identity.
|
|
52
|
+
*/
|
|
41
53
|
static initialise (directory = throws.ifMissing(), accountIdentity = throws.ifMissing()) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
54
|
+
AcmeRequest.directory = /** @type { import('./Directory.js').default } */ (directory)
|
|
55
|
+
AcmeRequest.accountIdentity = accountIdentity
|
|
56
|
+
AcmeRequest.nonce = new Nonce(AcmeRequest.directory)
|
|
57
|
+
AcmeRequest.initialised = true
|
|
46
58
|
}
|
|
47
59
|
|
|
48
60
|
static uninitialise () {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
61
|
+
AcmeRequest.directory = null
|
|
62
|
+
AcmeRequest.accountIdentity = null
|
|
63
|
+
AcmeRequest.nonce = null
|
|
64
|
+
AcmeRequest.__account = null
|
|
65
|
+
AcmeRequest.initialised = false
|
|
66
|
+
AcmeRequest.initialisationPromise = null
|
|
54
67
|
}
|
|
55
68
|
|
|
56
|
-
static set account (
|
|
57
|
-
|
|
69
|
+
static set account (/** import('./Account.js').default */ _account) { AcmeRequest.__account = _account }
|
|
70
|
+
|
|
71
|
+
/** @returns { import('./Account.js').default } */
|
|
72
|
+
static get account () { return AcmeRequest.__account }
|
|
58
73
|
|
|
59
74
|
constructor () {
|
|
60
75
|
if (!AcmeRequest.initialised) {
|
|
@@ -63,17 +78,21 @@ export default class AcmeRequest {
|
|
|
63
78
|
}
|
|
64
79
|
|
|
65
80
|
/**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
Executes a remote Let’s Encrypt command and either returns the result or throws.
|
|
82
|
+
|
|
83
|
+
@param {string|void} command - (Required) Name of {@link Directory} command to invoke e.g. 'newAccount'.
|
|
84
|
+
|
|
85
|
+
@param {Object|string|void} payload - (Required) Object to use as payload. For no payload, pass empty string.
|
|
86
|
+
|
|
87
|
+
@param {boolean} [useKid=true] - Use Key ID (true) or public JWK (false) (see RFC 8555 § 6.2).
|
|
88
|
+
|
|
89
|
+
@param {Number[]} [successCodes=[200]] - Optional array of codes that signals success. Any other code throws.
|
|
90
|
+
|
|
91
|
+
@param {string} [url=null] - If specified, use this URL, ignoring the command parameter.
|
|
92
|
+
|
|
93
|
+
@param {boolean} [parseResponseBodyAsJSON=true] - Parse response body as JSON (true) or as string (false).
|
|
94
|
+
*/
|
|
95
|
+
async request (
|
|
77
96
|
command = throws.ifMissing(),
|
|
78
97
|
payload = throws.ifMissing(),
|
|
79
98
|
useKid = true,
|
|
@@ -83,76 +102,90 @@ export default class AcmeRequest {
|
|
|
83
102
|
) {
|
|
84
103
|
if (useKid && AcmeRequest.account === null) { throws.error(Symbol.for('AcmeRequest.accountNotSetError')) }
|
|
85
104
|
|
|
86
|
-
const preparedRequest = await this.prepare(command, payload, useKid, successCodes, url)
|
|
105
|
+
const preparedRequest = await this.prepare(/** @type { string } */ (command), payload, useKid, successCodes, url)
|
|
87
106
|
const responseObject = await this._execute(preparedRequest, parseResponseBodyAsJSON)
|
|
88
107
|
return responseObject
|
|
89
108
|
}
|
|
90
109
|
|
|
91
110
|
/**
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
*/
|
|
111
|
+
Executes a prepared request.
|
|
112
|
+
|
|
113
|
+
@param {Awaited<ReturnType<typeof this.prepare>>|void} preparedRequest - (Required) The prepared request, ready to be executed.
|
|
114
|
+
|
|
115
|
+
@param {boolean|void} parseResponseBodyAsJSON - (Required) Should the request body be parsed as JSON (true) or should the native response object be returned (false).
|
|
116
|
+
*/
|
|
99
117
|
async _execute (preparedRequest = throws.ifMissing(), parseResponseBodyAsJSON = throws.ifMissing()) {
|
|
100
|
-
const { signedRequest, httpsRequest, httpsHeaders, originalRequestDetails } = preparedRequest
|
|
118
|
+
const { signedRequest, httpsRequest, httpsHeaders, originalRequestDetails } = /** @type {Awaited<ReturnType<typeof this.prepare>>} */ (preparedRequest)
|
|
101
119
|
|
|
102
120
|
let response, errorHeaders, errorBody
|
|
103
121
|
try {
|
|
104
122
|
response = await httpsRequest('', signedRequest, httpsHeaders)
|
|
105
123
|
} catch (error) {
|
|
106
124
|
errorBody = error.responseBody
|
|
107
|
-
errorHeaders = error.
|
|
108
|
-
|
|
125
|
+
errorHeaders = error.headers
|
|
126
|
+
|
|
127
|
+
// The error body is a promise. Wait for it to resolve.
|
|
128
|
+
if (errorBody) {
|
|
129
|
+
const errorBodyBuffer = await errorBody
|
|
130
|
+
const errorBodyString = Buffer.from(errorBodyBuffer).toString('utf-8')
|
|
131
|
+
|
|
132
|
+
// If the error body is JSON (i.e., as expected to be returned from Let’s Encrypt),
|
|
133
|
+
// handle it. If not (for whatever reason), still handle the error gracefully.
|
|
134
|
+
let acmeError = null
|
|
135
|
+
try {
|
|
136
|
+
acmeError = JSON.parse(errorBodyString)
|
|
137
|
+
} catch (_) {
|
|
138
|
+
acmeError = {
|
|
139
|
+
status: -1,
|
|
140
|
+
type: 'Unexpected error',
|
|
141
|
+
detail: errorBodyString
|
|
142
|
+
}
|
|
143
|
+
}
|
|
109
144
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const errorBodyString = errorBodyBuffer.toString('utf-8')
|
|
118
|
-
|
|
119
|
-
try {
|
|
120
|
-
error = JSON.parse(errorBodyString)
|
|
121
|
-
} catch (_) {
|
|
122
|
-
error = {
|
|
123
|
-
status: -1,
|
|
124
|
-
type: 'Unexpected error',
|
|
125
|
-
detail: errorBodyString
|
|
145
|
+
// If it’s valid JSON but doesn’t have the expected ACME error fields, wrap it.
|
|
146
|
+
if (acmeError && acmeError.status === undefined && acmeError.type === undefined) {
|
|
147
|
+
acmeError = {
|
|
148
|
+
status: -1,
|
|
149
|
+
type: 'Unexpected JSON error',
|
|
150
|
+
detail: errorBodyString
|
|
151
|
+
}
|
|
126
152
|
}
|
|
127
|
-
}
|
|
128
153
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
154
|
+
// According to RFC 8555 § 6.5, a bad nonce error should result in retry attempt.
|
|
155
|
+
if (acmeError.status === 400 && acmeError.type === 'urn:ietf:params:acme:error:badNonce') {
|
|
156
|
+
log(' 🔄 ❨auto-encrypt❩ Server returned a bad nonce error. Retrying with provided nonce. (RFC 8555 § 6.5)')
|
|
157
|
+
const serverProvidedNonce = errorHeaders && errorHeaders['replay-nonce']
|
|
158
|
+
|
|
159
|
+
if (!serverProvidedNonce) {
|
|
160
|
+
throws.error(Symbol.for('AcmeRequest.requestError'), acmeError)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Take the original request details (arguments array passed to the prepare() method) and
|
|
164
|
+
// re-prepare and retry the request, replacing the nonce (last argument), with the one provided
|
|
165
|
+
// by the ACME server.
|
|
166
|
+
const originalRequestWithServerProvidedNonce = [...originalRequestDetails]
|
|
167
|
+
originalRequestWithServerProvidedNonce[originalRequestWithServerProvidedNonce.length - 1] = serverProvidedNonce
|
|
168
|
+
|
|
169
|
+
return await this._execute(
|
|
170
|
+
await this.prepare(...originalRequestWithServerProvidedNonce),
|
|
171
|
+
parseResponseBodyAsJSON
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
throws.error(Symbol.for('AcmeRequest.requestError'), acmeError)
|
|
144
176
|
}
|
|
145
177
|
|
|
146
|
-
|
|
178
|
+
// If we got here, it's a non-ACME error (e.g. network error).
|
|
179
|
+
throw error
|
|
147
180
|
}
|
|
148
181
|
|
|
149
182
|
// Always save the fresh nonce returned from API calls.
|
|
150
|
-
const freshNonce = response.headers
|
|
183
|
+
const freshNonce = response.headers.get('replay-nonce')
|
|
151
184
|
AcmeRequest.nonce.set(freshNonce)
|
|
152
185
|
|
|
153
186
|
// The response returned is the raw response object. Let’s consume
|
|
154
187
|
// it and return a more relevant response.
|
|
155
|
-
const headers = response.headers
|
|
188
|
+
const headers = Object.fromEntries(response.headers.entries())
|
|
156
189
|
const responseBodyBuffer = await this.getBuffer(response)
|
|
157
190
|
let body = responseBodyBuffer.toString('utf-8')
|
|
158
191
|
if (parseResponseBodyAsJSON) {
|
|
@@ -166,36 +199,31 @@ export default class AcmeRequest {
|
|
|
166
199
|
}
|
|
167
200
|
|
|
168
201
|
/**
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
stream.on('error', reject)
|
|
178
|
-
stream.on('end', () => resolve(Buffer.concat(parts)))
|
|
179
|
-
stream.on('data', d => parts.push(d))
|
|
180
|
-
})
|
|
202
|
+
Returns a buffer from the response body.
|
|
203
|
+
|
|
204
|
+
@param {Response} response - (Required) A fetch response.
|
|
205
|
+
|
|
206
|
+
@returns {Promise<Buffer>} The body of the response as a buffer.
|
|
207
|
+
*/
|
|
208
|
+
async getBuffer (response) {
|
|
209
|
+
return Buffer.from(await response.arrayBuffer())
|
|
181
210
|
}
|
|
182
211
|
|
|
183
212
|
/**
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
*/
|
|
213
|
+
Separate the preparation of a request from the execution of it so we can easily test that different request configurations conform to our expectations.
|
|
214
|
+
|
|
215
|
+
@param {string|void} command - (Required) Name of Let’s Encrypt command to invoke (see Directory; sans 'Url' suffix). e.g. 'newAccount', 'newOrder', etc.
|
|
216
|
+
|
|
217
|
+
@param {Object|string|void} payload - (Required) Either an object to use as the payload or, if there is no payload, an empty string.
|
|
218
|
+
|
|
219
|
+
@param {boolean|void} useKid - (Required) Should request use a Key ID (true) or, public JWK (false). (See RFC 8555 § 6.2 Request Authentication.)
|
|
220
|
+
|
|
221
|
+
@param {Number[]} [successCodes=[200]] - Optional array of codes that signals success. Any other code throws.
|
|
222
|
+
|
|
223
|
+
@param {string} [url=null] - If specified, will use this URL directly, ignoring the value in the command parameter.
|
|
224
|
+
|
|
225
|
+
@param {Nonce} [nonce=null] - If specified, the nonce to use.
|
|
226
|
+
*/
|
|
199
227
|
async prepare (
|
|
200
228
|
command = throws.ifMissing(),
|
|
201
229
|
payload = throws.ifMissing(),
|
|
@@ -204,15 +232,21 @@ export default class AcmeRequest {
|
|
|
204
232
|
url = null,
|
|
205
233
|
nonce = null
|
|
206
234
|
) {
|
|
235
|
+
command = /** @type { String } */ (command)
|
|
236
|
+
payload = /** @type { Object | String } */ (payload)
|
|
237
|
+
useKid = /** @type { Boolean } */ (useKid)
|
|
238
|
+
|
|
207
239
|
if (useKid && AcmeRequest.account === null) { throws.error(Symbol.for('AcmeRequest.accountNotSetError')) }
|
|
208
240
|
|
|
209
|
-
// We will also return the original request details in case the call needs to be retried later.
|
|
210
|
-
// Note: we have to create our own object using the actual individual argument values instead of
|
|
211
|
-
// ===== the arguments array as the latter does not reflect default parameters.
|
|
241
|
+
// We will also return the original request details in case the call needs to be retried later. Note: we have to create our own object using the actual individual argument values instead of the arguments array as the latter does not reflect default parameters.
|
|
212
242
|
const originalRequestDetails = [command, payload, useKid, successCodes, url, nonce]
|
|
213
243
|
|
|
214
244
|
url = url || AcmeRequest.directory[`${command}Url`]
|
|
215
245
|
|
|
246
|
+
// Capture static state at the beginning of method to avoid race conditions if uninitialise() is called while awaiting a nonce. (This should really only be a race condition encountered during unit tests, but still.)
|
|
247
|
+
const account = AcmeRequest.account
|
|
248
|
+
const accountIdentity = AcmeRequest.accountIdentity
|
|
249
|
+
|
|
216
250
|
const protectedHeader = {
|
|
217
251
|
alg: 'RS256',
|
|
218
252
|
nonce: nonce || await AcmeRequest.nonce.get(),
|
|
@@ -221,13 +255,30 @@ export default class AcmeRequest {
|
|
|
221
255
|
|
|
222
256
|
if (useKid) {
|
|
223
257
|
// The kid is the account location URL as previously returned by the ACME server.
|
|
224
|
-
protectedHeader.kid =
|
|
258
|
+
protectedHeader.kid = account.kid
|
|
225
259
|
} else {
|
|
226
260
|
// If we’re not using the kid, we must use the public JWK (see RFC 8555 § 6.2 Request Authentication)
|
|
227
|
-
protectedHeader.jwk =
|
|
261
|
+
protectedHeader.jwk = accountIdentity.publicJWK
|
|
228
262
|
}
|
|
229
263
|
|
|
230
|
-
|
|
264
|
+
// Flatten the payload and sign it and use the signature in the signed request.
|
|
265
|
+
// (We were previously using jose.FlattenedSign for this.)
|
|
266
|
+
|
|
267
|
+
const encoder = new TextEncoder()
|
|
268
|
+
const protectedHeaderBase64 = Buffer.from(encoder.encode(JSON.stringify(protectedHeader))).toString('base64url')
|
|
269
|
+
const payloadBase64 = Buffer.from(typeof payload === 'string' ? encoder.encode(payload) : encoder.encode(JSON.stringify(payload))).toString('base64url')
|
|
270
|
+
|
|
271
|
+
const signature = crypto.sign(
|
|
272
|
+
'sha256',
|
|
273
|
+
Buffer.from(`${protectedHeaderBase64}.${payloadBase64}`),
|
|
274
|
+
accountIdentity.key
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
const signedRequest = {
|
|
278
|
+
protected: protectedHeaderBase64,
|
|
279
|
+
payload: payloadBase64,
|
|
280
|
+
signature: signature.toString('base64url')
|
|
281
|
+
}
|
|
231
282
|
|
|
232
283
|
const httpsHeaders = {
|
|
233
284
|
'Content-Type': 'application/jose+json',
|
|
@@ -235,8 +286,27 @@ export default class AcmeRequest {
|
|
|
235
286
|
'Accept-Language': 'en-US'
|
|
236
287
|
}
|
|
237
288
|
|
|
238
|
-
//
|
|
239
|
-
const httpsRequest =
|
|
289
|
+
// Carries out an https request.
|
|
290
|
+
const httpsRequest = async (/** @type {string} */ _path, /** @type {any} */ body, /** @type {any} */ headers) => {
|
|
291
|
+
const response = await fetch(url, {
|
|
292
|
+
method: 'POST',
|
|
293
|
+
headers,
|
|
294
|
+
body: JSON.stringify(body)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
if (!successCodes.includes(response.status)) {
|
|
298
|
+
const error = new Error(`HTTP Error: ${response.status} ${response.statusText}`)
|
|
299
|
+
// @ts-ignore Error object mixin.
|
|
300
|
+
error.status = response.status
|
|
301
|
+
// @ts-ignore Error object mixin.
|
|
302
|
+
error.headers = Object.fromEntries(response.headers.entries())
|
|
303
|
+
// @ts-ignore Error object mixin.
|
|
304
|
+
error.responseBody = response.arrayBuffer().then(Buffer.from)
|
|
305
|
+
throw error
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return response
|
|
309
|
+
}
|
|
240
310
|
|
|
241
311
|
return {
|
|
242
312
|
protectedHeader,
|
package/lib/Authorisation.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
////////////////////////////////////////////////////////////////////////////////
|
|
1
|
+
/**
|
|
2
|
+
|
|
3
|
+
Authorisation
|
|
4
|
+
|
|
5
|
+
(Use the async static get() method to await a fully-resolved instance.)
|
|
6
|
+
|
|
7
|
+
Holds a single authorisation object. Note that the only authorisation type
|
|
8
|
+
supported by this library is HTTP-01 and this is hardcoded in its
|
|
9
|
+
behaviour. See RFC 8555 § 7.5, 7.5.1.
|
|
10
|
+
|
|
11
|
+
Copyright © 2020 Aral Balkan, Small Technology Foundation.
|
|
12
|
+
License: AGPLv3 or later.
|
|
13
|
+
*/
|
|
15
14
|
|
|
16
15
|
import EventEmitter from 'events'
|
|
17
16
|
import log from './util/log.js'
|
|
@@ -20,10 +19,23 @@ import ReadyForChallengeValidationRequest from './acme-requests/ReadyForChalleng
|
|
|
20
19
|
import HttpServer from './HttpServer.js'
|
|
21
20
|
import waitFor from './util/waitFor.js'
|
|
22
21
|
|
|
22
|
+
/**
|
|
23
|
+
@typedef {{
|
|
24
|
+
type: string,
|
|
25
|
+
url: string,
|
|
26
|
+
token: string,
|
|
27
|
+
status: string
|
|
28
|
+
}} Challenge
|
|
29
|
+
*/
|
|
30
|
+
|
|
23
31
|
export default class Authorisation extends EventEmitter {
|
|
24
32
|
|
|
25
33
|
// Async factory method. Use this to instantiate.
|
|
26
34
|
// TODO: add check to ensure factory method is used.
|
|
35
|
+
/**
|
|
36
|
+
@param {string} authorisationUrl
|
|
37
|
+
@param {import('./identities/AccountIdentity.js').default } accountIdentity
|
|
38
|
+
*/
|
|
27
39
|
static async getInstanceAsync (authorisationUrl, accountIdentity) {
|
|
28
40
|
const authorisation = new Authorisation(authorisationUrl, accountIdentity)
|
|
29
41
|
await authorisation.init()
|
|
@@ -32,6 +44,7 @@ export default class Authorisation extends EventEmitter {
|
|
|
32
44
|
|
|
33
45
|
// Events
|
|
34
46
|
static VALIDATED = 'validated'
|
|
47
|
+
static ERROR = 'error'
|
|
35
48
|
|
|
36
49
|
//
|
|
37
50
|
// Accessors.
|
|
@@ -40,13 +53,17 @@ export default class Authorisation extends EventEmitter {
|
|
|
40
53
|
get domain () { return this._domain }
|
|
41
54
|
get challenge () { return this._challenge }
|
|
42
55
|
|
|
43
|
-
set domain (
|
|
44
|
-
set challenge (
|
|
56
|
+
set domain (_value) { throw new Error('domain is a read-only property') }
|
|
57
|
+
set challenge (_value) { throw new Error('challenge is a read-only property') }
|
|
45
58
|
|
|
46
59
|
//
|
|
47
60
|
// Private.
|
|
48
61
|
//
|
|
49
62
|
|
|
63
|
+
/**
|
|
64
|
+
@param {string} authorisationUrl
|
|
65
|
+
@param {import('./identities/AccountIdentity.js').default} accountIdentity
|
|
66
|
+
*/
|
|
50
67
|
constructor (authorisationUrl, accountIdentity) {
|
|
51
68
|
super()
|
|
52
69
|
this.authorisationUrl = authorisationUrl
|
|
@@ -78,7 +95,7 @@ export default class Authorisation extends EventEmitter {
|
|
|
78
95
|
|
|
79
96
|
// We’re only interested in the HTTP-01 challenge url and token so make it easy to get at these.
|
|
80
97
|
// See RFC 8555 § 7.5 (Identifier Authorization).
|
|
81
|
-
this.authorisation.challenges.forEach(challenge => {
|
|
98
|
+
this.authorisation.challenges.forEach(( /** @type { Challenge } */ challenge ) => {
|
|
82
99
|
if (challenge.type === 'http-01') {
|
|
83
100
|
// Add the domain to the challenge object.
|
|
84
101
|
this._challenge = challenge
|
|
@@ -134,7 +151,9 @@ export default class Authorisation extends EventEmitter {
|
|
|
134
151
|
this.once(Authorisation.VALIDATED, () => {
|
|
135
152
|
resolve()
|
|
136
153
|
})
|
|
137
|
-
|
|
154
|
+
this.once(Authorisation.ERROR, error => {
|
|
155
|
+
reject(error)
|
|
156
|
+
})
|
|
138
157
|
})
|
|
139
158
|
}
|
|
140
159
|
|
|
@@ -154,30 +173,55 @@ export default class Authorisation extends EventEmitter {
|
|
|
154
173
|
this.pollForValidationState()
|
|
155
174
|
}
|
|
156
175
|
|
|
157
|
-
|
|
176
|
+
/**
|
|
177
|
+
Calculates polling duration according to RFC 7231 (referenced by RFC 8555).
|
|
178
|
+
|
|
179
|
+
The Retry-After header can contain either a non-negative integer representing the delay duration in seconds or an HTTP-date instance.
|
|
158
180
|
|
|
181
|
+
@param {{ retryAfterHeader: string? }} parameterObject
|
|
182
|
+
*/
|
|
183
|
+
pollingDurationBasedOn ({ retryAfterHeader }) {
|
|
184
|
+
let pollingDuration = 1000
|
|
185
|
+
if (retryAfterHeader) {
|
|
186
|
+
const delaySeconds = parseInt(retryAfterHeader, 10)
|
|
187
|
+
if (!isNaN(delaySeconds)) {
|
|
188
|
+
pollingDuration = delaySeconds * 1000
|
|
189
|
+
} else {
|
|
190
|
+
// Try parsing as HTTP-date
|
|
191
|
+
const date = new Date(retryAfterHeader)
|
|
192
|
+
if (!isNaN(date.getTime())) {
|
|
193
|
+
pollingDuration = date.getTime() - Date.now()
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (pollingDuration < 1000) {
|
|
198
|
+
pollingDuration = 1000
|
|
199
|
+
}
|
|
200
|
+
return pollingDuration
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async pollForValidationState () {
|
|
159
204
|
log(` 👋 ❨auto-encrypt❩ Polling for authorisation state for domain ${this.domain}…`)
|
|
160
205
|
|
|
161
|
-
|
|
206
|
+
try {
|
|
207
|
+
const result = await (new AuthorisationRequest()).execute(this.authorisationUrl)
|
|
162
208
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
// to polling every second.
|
|
171
|
-
const retryAfterHeader = result.headers['Retry-After']
|
|
172
|
-
let pollingDuration = 1000
|
|
173
|
-
if (retryAfterHeader) {
|
|
174
|
-
pollingDuration = parseInt(retryAfterHeader)
|
|
175
|
-
}
|
|
209
|
+
if (result.body.status === 'valid') {
|
|
210
|
+
log(` 🎉 ❨auto-encrypt❩ Authorisation validated for domain ${this.domain}`)
|
|
211
|
+
this.emit(Authorisation.VALIDATED)
|
|
212
|
+
return
|
|
213
|
+
} else {
|
|
214
|
+
const retryAfterHeader = result.headers['Retry-After']
|
|
215
|
+
const pollingDuration = this.pollingDurationBasedOn({ retryAfterHeader })
|
|
176
216
|
|
|
177
|
-
|
|
217
|
+
log(` ⌚ ❨auto-encrypt❩ Authorisation not valid yet for domain ${this.domain}. Waiting to check again in ${pollingDuration/1000} second${pollingDuration === 1000 ? '' : 's'}…`)
|
|
178
218
|
|
|
179
|
-
|
|
180
|
-
|
|
219
|
+
await waitFor(pollingDuration)
|
|
220
|
+
await this.pollForValidationState()
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
log(` ❌ ❨auto-encrypt❩ Error during polling for authorisation state for domain ${this.domain}: ${error.message}`)
|
|
224
|
+
this.emit(Authorisation.ERROR, error)
|
|
181
225
|
}
|
|
182
226
|
}
|
|
183
227
|
}
|