@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/Certificate.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
Represents a Let’s Encrypt TLS certificate.
|
|
3
|
+
|
|
4
|
+
@module
|
|
5
|
+
@copyright Copyright © 2020-present Aral Balkan, Small Technology Foundation.
|
|
6
|
+
@license AGPLv3 or later.
|
|
7
|
+
*/
|
|
8
8
|
|
|
9
9
|
import fs from 'fs'
|
|
10
10
|
import tls from 'tls'
|
|
11
11
|
import util from 'util'
|
|
12
|
-
import
|
|
12
|
+
import { Temporal } from 'temporal-polyfill'
|
|
13
13
|
import log from './util/log.js'
|
|
14
|
-
import
|
|
14
|
+
import fromNow from './util/fromNow.js'
|
|
15
|
+
import { Certificate as X509Certificate, CertificateSerialNumber } from './x.509/rfc5280.js'
|
|
15
16
|
import Account from './Account.js'
|
|
16
17
|
import AccountIdentity from './identities/AccountIdentity.js'
|
|
17
18
|
import Directory from './Directory.js'
|
|
@@ -25,18 +26,18 @@ const throws = new Throws({
|
|
|
25
26
|
})
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
Represents a Let’s Encrypt TLS certificate.
|
|
30
|
+
|
|
31
|
+
@alias module:lib/Certificate
|
|
32
|
+
@param {String[]} domains List of domains this certificate covers.
|
|
33
|
+
*/
|
|
33
34
|
export default class Certificate {
|
|
34
35
|
/**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
Get a SecureContext that can be used in an SNICallback.
|
|
37
|
+
|
|
38
|
+
@category async
|
|
39
|
+
@returns {Promise<tls.SecureContext>} A promise for a SecureContext that can be used in creating https servers.
|
|
40
|
+
*/
|
|
40
41
|
async getSecureContext () {
|
|
41
42
|
if (!this.#secureContext) {
|
|
42
43
|
if (this.#busyCreatingSecureContextForTheFirstTime) {
|
|
@@ -50,14 +51,14 @@ export default class Certificate {
|
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
Creates an instance of Certificate.
|
|
55
|
+
|
|
56
|
+
@param {import('./Configuration.js').default|void} configuration Configuration instance.
|
|
57
|
+
*/
|
|
57
58
|
constructor (configuration = throws.ifMissing()) {
|
|
58
|
-
this.#configuration = configuration
|
|
59
|
+
this.#configuration = /** @type {import('./Configuration.js').default} */ (configuration)
|
|
59
60
|
this.attemptToRecoverFromFailedRenewalAttemptIfNecessary()
|
|
60
|
-
this.#domains = configuration.domains
|
|
61
|
+
this.#domains = this.#configuration.domains
|
|
61
62
|
|
|
62
63
|
// If the certificate already exists, load and cache it.
|
|
63
64
|
if (fs.existsSync(this.#configuration.certificatePath)) {
|
|
@@ -67,7 +68,7 @@ export default class Certificate {
|
|
|
67
68
|
log(' 📃 ❨auto-encrypt❩ Certificate exists, loaded it (and the corresponding private key) from disk.')
|
|
68
69
|
this.startCheckingForRenewal(/* alsoCheckNow = */ true)
|
|
69
70
|
} else {
|
|
70
|
-
log(' 📃 ❨auto-encrypt❩ Certificate does not exist; will be provisioned
|
|
71
|
+
log(' 📃 ❨auto-encrypt❩ Certificate does not exist; will be provisioned at server start.')
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
74
|
|
|
@@ -75,39 +76,46 @@ export default class Certificate {
|
|
|
75
76
|
// Private.
|
|
76
77
|
//
|
|
77
78
|
|
|
79
|
+
/** @type {import('./Configuration.js').default} */
|
|
78
80
|
#configuration = null
|
|
79
81
|
#account = null
|
|
80
82
|
#accountIdentity = null
|
|
81
83
|
#directory = null
|
|
82
84
|
#secureContext = null
|
|
83
85
|
#domains = null
|
|
84
|
-
#renewalDate = null
|
|
85
86
|
#checkForRenewalIntervalId = null
|
|
86
87
|
#busyCreatingSecureContextForTheFirstTime = false
|
|
87
88
|
|
|
89
|
+
/** @type {string | Buffer | Array<string | Buffer> | undefined} */
|
|
88
90
|
#_pem = null
|
|
91
|
+
|
|
89
92
|
#_identity = null
|
|
93
|
+
|
|
94
|
+
/** @type {string | Buffer | Array<string | Buffer | {
|
|
95
|
+
pem: string | Buffer;
|
|
96
|
+
passphrase?: string | undefined;
|
|
97
|
+
}> | undefined} */
|
|
90
98
|
#_key = null
|
|
99
|
+
|
|
91
100
|
#_issuer = null
|
|
92
101
|
#_subject = null
|
|
93
102
|
#_alternativeNames = null
|
|
94
103
|
#_serialNumber = null
|
|
95
104
|
#_issueDate = null
|
|
96
105
|
#_expiryDate = null
|
|
97
|
-
#
|
|
98
|
-
|
|
99
|
-
get isProvisioned () { return this.#_pem !== null
|
|
100
|
-
get pem () { return this.#_pem
|
|
101
|
-
get identity () { return this.#_identity
|
|
102
|
-
get key () { return this.#_key
|
|
103
|
-
get serialNumber () { return this.#_serialNumber
|
|
104
|
-
get issuer () { return this.#_issuer
|
|
105
|
-
get subject () { return this.#_subject
|
|
106
|
-
get alternativeNames () { return this.#_alternativeNames
|
|
107
|
-
get issueDate () { return this.#_issueDate
|
|
108
|
-
get expiryDate () { return this.#_expiryDate
|
|
109
|
-
get
|
|
110
|
-
get hasOcspMustStaple () { return this.#_hasOcspMustStaple }
|
|
106
|
+
#_ariCertId = null
|
|
107
|
+
|
|
108
|
+
get isProvisioned () { return this.#_pem !== null }
|
|
109
|
+
get pem () { return this.#_pem }
|
|
110
|
+
get identity () { return this.#_identity }
|
|
111
|
+
get key () { return this.#_key }
|
|
112
|
+
get serialNumber () { return this.#_serialNumber }
|
|
113
|
+
get issuer () { return this.#_issuer }
|
|
114
|
+
get subject () { return this.#_subject }
|
|
115
|
+
get alternativeNames () { return this.#_alternativeNames }
|
|
116
|
+
get issueDate () { return this.#_issueDate }
|
|
117
|
+
get expiryDate () { return this.#_expiryDate }
|
|
118
|
+
get ariCertId () { return this.#_ariCertId }
|
|
111
119
|
|
|
112
120
|
set pem (certificatePem) {
|
|
113
121
|
this.#_pem = certificatePem
|
|
@@ -117,19 +125,20 @@ export default class Certificate {
|
|
|
117
125
|
this.#_issuer = details.issuer
|
|
118
126
|
this.#_subject = details.subject
|
|
119
127
|
this.#_alternativeNames = details.alternativeNames
|
|
120
|
-
this.#_issueDate =
|
|
121
|
-
this.#_expiryDate =
|
|
122
|
-
this.#
|
|
128
|
+
this.#_issueDate = Temporal.Instant.from(details.issuedAt.toISOString())
|
|
129
|
+
this.#_expiryDate = Temporal.Instant.from(details.expiresAt.toISOString())
|
|
130
|
+
this.#_ariCertId = details.ariCertId
|
|
123
131
|
|
|
124
132
|
// Display the certificate with a nice border :)
|
|
125
133
|
const logMessagePrefix = ' ❨auto-encrypt❩ '
|
|
134
|
+
|
|
126
135
|
let logMessageBody = [
|
|
127
136
|
`Serial number : ${details.serialNumber}`,
|
|
128
137
|
`Issuer : ${details.issuer}`,
|
|
129
138
|
`Subject : ${details.subject}`,
|
|
130
|
-
`Alternative names: ${details.alternativeNames.reduce((string, name) => `${string}, ${name}`)}`,
|
|
131
|
-
`Issued on : ${this.issueDate.
|
|
132
|
-
`Expires on : ${this.expiryDate.
|
|
139
|
+
`Alternative names: ${details.alternativeNames.reduce((/** @type { string } */ string, /** @type { string } */ name) => `${string}, ${name}`)}`,
|
|
140
|
+
`Issued on : ${this.issueDate.toLocaleString()} (${fromNow(this.issueDate)})`,
|
|
141
|
+
`Expires on : ${this.expiryDate.toLocaleString()} (${fromNow(this.expiryDate)})`
|
|
133
142
|
]
|
|
134
143
|
|
|
135
144
|
const longestLineLength = logMessageBody.reduce((accumulator, currentValue) => currentValue.length > accumulator ? currentValue.length : accumulator, 0)
|
|
@@ -150,21 +159,20 @@ export default class Certificate {
|
|
|
150
159
|
this.#_key = certificateIdentity.privatePEM
|
|
151
160
|
}
|
|
152
161
|
|
|
153
|
-
set key (
|
|
154
|
-
set serialNumber (
|
|
155
|
-
set issuer (
|
|
156
|
-
set subject (
|
|
157
|
-
set alternativeNames (
|
|
158
|
-
set issueDate (
|
|
159
|
-
set expiryDate (
|
|
160
|
-
set renewalDate (value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'renewalDate', 'set internally') }
|
|
162
|
+
set key (_value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'key', 'set via identity') }
|
|
163
|
+
set serialNumber (_value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'serialNumber', 'set via pem') }
|
|
164
|
+
set issuer (_value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'issuer', 'set via pem') }
|
|
165
|
+
set subject (_value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'subject', 'set via pem') }
|
|
166
|
+
set alternativeNames (_value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'alternativeNames', 'set via pem') }
|
|
167
|
+
set issueDate (_value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'issueDate', 'set via pem') }
|
|
168
|
+
set expiryDate (_value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'expiryDate', 'set via pem') }
|
|
161
169
|
|
|
162
170
|
/**
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
171
|
+
Check if certificate-identity.pem.old or certificate.pem.old files exist.
|
|
172
|
+
If they do, it means that something went wrong while certificate was trying to be
|
|
173
|
+
renewed. So restore them and use them and hopefully the next renewal attempt will
|
|
174
|
+
succeed or at least buy the administrator of the server some time to fix the issue.
|
|
175
|
+
*/
|
|
168
176
|
attemptToRecoverFromFailedRenewalAttemptIfNecessary () {
|
|
169
177
|
const oldCertificateIdentityPath = `${this.#configuration.certificateIdentityPath}.old`
|
|
170
178
|
const oldCertificatePath = `${this.#configuration.certificatePath}.old`
|
|
@@ -190,15 +198,63 @@ export default class Certificate {
|
|
|
190
198
|
}
|
|
191
199
|
|
|
192
200
|
/**
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
201
|
+
Initialises the ACME request, providing an asynchronous lock to prevent race conditions.
|
|
202
|
+
|
|
203
|
+
In Auto Encrypt, the initialisation of ACME state (AcmeRequest, Directory, Account) can be triggered by two independent events:
|
|
204
|
+
|
|
205
|
+
1. Renewal check on existing certificate at startup. When the server is created and a certificate already exists on disk, the `Certificate` constructor triggers `checkForRenewal()`. This runs in the background, calls `#initialiseAcmeRequest()`, and is not awaited by the constructor.
|
|
206
|
+
|
|
207
|
+
2. An incoming request: When the HTTPS server receives its first request, the SNICallback calls `getSecureContext()` and this, in turn, calls `createSecureContext()` and `#initialiseAcmeRequest()`.
|
|
208
|
+
|
|
209
|
+
This is why we use a shared static promise on the AcmeRequest class as a lock to ensure that initialisation only happens once.
|
|
210
|
+
|
|
211
|
+
TODO: This is no longer the case with the move to the asynchronous API for AutoEncrypt.https.createServer(). So it should be OK to remove the lock.
|
|
212
|
+
*/
|
|
213
|
+
async #initialiseAcmeRequest () {
|
|
214
|
+
if (AcmeRequest.initialised && this.#directory && this.#accountIdentity && this.#account) {
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (AcmeRequest.initialisationPromise) {
|
|
219
|
+
await AcmeRequest.initialisationPromise
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (!AcmeRequest.initialised) {
|
|
223
|
+
AcmeRequest.initialisationPromise = (async () => {
|
|
224
|
+
try {
|
|
225
|
+
log(' ⚙️ ❨auto-encrypt❩ Initialising ACME request state…')
|
|
226
|
+
const directory = await Directory.getInstanceAsync(this.#configuration)
|
|
227
|
+
const accountIdentity = new AccountIdentity(this.#configuration)
|
|
228
|
+
AcmeRequest.initialise(directory, accountIdentity)
|
|
229
|
+
const account = await Account.getInstanceAsync(this.#configuration)
|
|
230
|
+
AcmeRequest.account = account
|
|
231
|
+
} catch (error) {
|
|
232
|
+
// Reset state on failure so we can try again.
|
|
233
|
+
AcmeRequest.uninitialise()
|
|
234
|
+
throw error
|
|
235
|
+
} finally {
|
|
236
|
+
AcmeRequest.initialisationPromise = null
|
|
237
|
+
}
|
|
238
|
+
})()
|
|
239
|
+
await AcmeRequest.initialisationPromise
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Populate instance references from the static AcmeRequest state.
|
|
243
|
+
this.#directory = AcmeRequest.directory
|
|
244
|
+
this.#accountIdentity = AcmeRequest.accountIdentity
|
|
245
|
+
this.#account = AcmeRequest.account
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
Creates and caches a secure context, provisioning a TLS certificate in the process, if necessary.
|
|
250
|
+
|
|
251
|
+
@category async
|
|
252
|
+
@access private
|
|
253
|
+
@param {Boolean} renewCertificate If true, will start the process of renewing the certificate
|
|
254
|
+
(but will continue to return the existing certificate until it is ready).
|
|
255
|
+
@returns {Promise} Fulfils immediately if certificate exists and does not need to be
|
|
256
|
+
renewed. Otherwise, fulfils when certificate has been provisioned.
|
|
257
|
+
*/
|
|
202
258
|
async createSecureContext (renewCertificate = false) {
|
|
203
259
|
// If we’re provisioning a certificate for the first time,
|
|
204
260
|
// block all other calls. If we’re renewing, we don’t
|
|
@@ -210,15 +266,10 @@ export default class Certificate {
|
|
|
210
266
|
|
|
211
267
|
// If the certificate does not already exist, provision one.
|
|
212
268
|
if (!this.pem || renewCertificate) {
|
|
213
|
-
|
|
214
269
|
// Initialise all necessary state.
|
|
215
|
-
|
|
216
|
-
this.#accountIdentity = new AccountIdentity(this.#configuration)
|
|
217
|
-
AcmeRequest.initialise(this.#directory, this.#accountIdentity)
|
|
218
|
-
this.#account = await Account.getInstanceAsync(this.#configuration)
|
|
219
|
-
AcmeRequest.account = this.#account
|
|
270
|
+
await this.#initialiseAcmeRequest()
|
|
220
271
|
|
|
221
|
-
await this.provisionCertificate()
|
|
272
|
+
await this.provisionCertificate(renewCertificate ? this.#_ariCertId : null)
|
|
222
273
|
}
|
|
223
274
|
|
|
224
275
|
// Create and cache the secure context.
|
|
@@ -235,37 +286,38 @@ export default class Certificate {
|
|
|
235
286
|
|
|
236
287
|
|
|
237
288
|
/**
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
289
|
+
Provisions a new Let’s Encrypt TLS certificate, persists it, and starts checking for
|
|
290
|
+
renewals on it every hour, starting with the next hour.
|
|
291
|
+
|
|
292
|
+
@access private
|
|
293
|
+
@param {string|void} ariCertId
|
|
294
|
+
@category async
|
|
295
|
+
@returns {Promise} Fulfils once a certificate has been provisioned.
|
|
296
|
+
*/
|
|
297
|
+
async provisionCertificate (ariCertId = throws.ifMissing()) {
|
|
246
298
|
log(` 🤖 ❨auto-encrypt❩ Provisioning Let’s Encrypt certificates for ${this.#domains}.`)
|
|
247
299
|
|
|
248
300
|
// Create a new order.
|
|
249
|
-
const order = await Order.
|
|
301
|
+
const order = await Order.createAsync(this.#configuration, this.#accountIdentity, ariCertId)
|
|
250
302
|
|
|
251
303
|
// Get the certificate details from the order.
|
|
252
304
|
this.pem = order.certificate
|
|
253
305
|
this.identity = order.certificateIdentity
|
|
254
306
|
|
|
255
|
-
// Start checking for renewal updates, every
|
|
307
|
+
// Start checking for renewal updates, every hour, starting in an hour.
|
|
256
308
|
this.startCheckingForRenewal(/* alsoCheckNow = */ false)
|
|
257
309
|
|
|
258
310
|
log(` 🎉 ❨auto-encrypt❩ Successfully provisioned Let’s Encrypt certificate for ${this.#domains}.`)
|
|
259
311
|
}
|
|
260
312
|
|
|
261
313
|
/**
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
314
|
+
Starts the certificate renewal process by requesting the creation of a fresh secure context.
|
|
315
|
+
|
|
316
|
+
@access private
|
|
317
|
+
@returns {Promise} Resolves once certificate is renewed and new secure context is
|
|
318
|
+
created and cached.
|
|
319
|
+
@category async
|
|
320
|
+
*/
|
|
269
321
|
async renewCertificate () {
|
|
270
322
|
//
|
|
271
323
|
// Backup the existing certificate and certificate identity (*.pem → *.pem.old). Then create a new
|
|
@@ -301,88 +353,185 @@ export default class Certificate {
|
|
|
301
353
|
|
|
302
354
|
|
|
303
355
|
/**
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
356
|
+
Checks if the certificate needs to be renewed (if it is within 30 days of its expiry date) and, if so,
|
|
357
|
+
renews it. While the method is async, the result is not awaited on usage. Instead, it is a fire-and-forget
|
|
358
|
+
method that’s called via a daily interval.
|
|
359
|
+
|
|
360
|
+
@access public
|
|
361
|
+
@category async
|
|
362
|
+
@param {boolean} [forceRenew=false] - Force renewal regardless of ARI response (for testing)
|
|
363
|
+
@returns {Promise} Fulfils immediately if certificate doesn’t need renewal. Otherwise, fulfils once certificate
|
|
364
|
+
has been renewed.
|
|
365
|
+
*/
|
|
366
|
+
async checkForRenewal (forceRenew = false) {
|
|
314
367
|
log( ' 🧐 ❨auto-encrypt❩ Checking if we need to renew the certificate… ')
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
368
|
+
|
|
369
|
+
await this.#initialiseAcmeRequest()
|
|
370
|
+
|
|
371
|
+
if (forceRenew) {
|
|
372
|
+
log(' 🚨 ❨auto-encrypt❩ Forcing renewal (forceRenew === true)')
|
|
373
|
+
await this.renewCertificate()
|
|
374
|
+
log(` 🌱 ❨auto-encrypt❩ Successfully renewed Let’s Encrypt certificate.`)
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const renewalInfoUrl = AcmeRequest.directory.renewalInfoUrl
|
|
379
|
+
if (renewalInfoUrl && this.ariCertId) {
|
|
380
|
+
try {
|
|
381
|
+
const response = await fetch(`${renewalInfoUrl}/${this.ariCertId}`)
|
|
382
|
+
|
|
383
|
+
if (response.ok) {
|
|
384
|
+
const renewalInfo = await response.json()
|
|
385
|
+
const startDate = Temporal.Instant.from(renewalInfo.suggestedWindow.start)
|
|
386
|
+
const now = Temporal.Now.instant()
|
|
387
|
+
if (Temporal.Instant.compare(now, startDate) >= 0) {
|
|
388
|
+
// We’re past the start date of the renewal window. So we should renew.
|
|
389
|
+
log(` 🌱 ❨auto-encrypt❩ Certificate needs renewal (ARI). Renewing certificate…`)
|
|
390
|
+
// Note: this is not a blocking process. We transparently start using the new certificate
|
|
391
|
+
// when it is ready.
|
|
392
|
+
await this.renewCertificate()
|
|
393
|
+
log(` 🌱 ❨auto-encrypt❩ Successfully renewed Let’s Encrypt certificate.`)
|
|
394
|
+
} else {
|
|
395
|
+
log(' 👍 ❨auto-encrypt❩ Certificate does not need renewal (ARI). Will check again in an hour.')
|
|
396
|
+
}
|
|
397
|
+
return
|
|
398
|
+
} else {
|
|
399
|
+
// If ARI is not supported or the certificate is not found, we fall back to expiry date check.
|
|
400
|
+
// (404 is the expected response if the server does not have renewal info for the certificate,
|
|
401
|
+
// while Pebble sometimes returns 400 if the issuer is unknown).
|
|
402
|
+
if (response.status === 404) {
|
|
403
|
+
log(' ℹ️ ❨auto-encrypt❩ No ARI renewal information found for this certificate (404). Falling back to expiry check.')
|
|
404
|
+
} else if (response.status === 400) {
|
|
405
|
+
const body = await response.json().catch(() => ({}))
|
|
406
|
+
if (body.detail && body.detail.includes('no known issuer matches')) {
|
|
407
|
+
log(' ℹ️ ❨auto-encrypt❩ ARI check: Issuer unknown to server (common when using Pebble across multiple runs). Falling back to expiry check.')
|
|
408
|
+
} else {
|
|
409
|
+
log(` ⚠ ❨auto-encrypt❩ ARI check failed (400 Bad Request): ${body.detail || 'Unknown error'}. Falling back to expiry check.`)
|
|
410
|
+
}
|
|
411
|
+
} else {
|
|
412
|
+
log(` ⚠ ❨auto-encrypt❩ Failed ARI check for certificate renewal (status ${response.status}). Falling back to expiry check.`)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
} catch (error) {
|
|
416
|
+
log(` ⚠ ❨auto-encrypt❩ Error during ARI check for certificate renewal: ${error.message}`)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Fallback: If ARI is not supported or the check failed, use expiry date.
|
|
421
|
+
log(' 🧐 ❨auto-encrypt❩ ARI not available or check failed. Falling back to expiry date check.')
|
|
422
|
+
const twoDaysInMs = 2 * 24 * 60 * 60 * 1000
|
|
423
|
+
const now = Temporal.Now.instant()
|
|
424
|
+
const expiryDate = this.expiryDate
|
|
425
|
+
const diff = expiryDate.since(now).total({ unit: 'milliseconds' })
|
|
426
|
+
|
|
427
|
+
if (diff < twoDaysInMs) {
|
|
428
|
+
log(` 🌱 ❨auto-encrypt❩ Certificate needs renewal (expiry date). Renewing certificate…`)
|
|
323
429
|
await this.renewCertificate()
|
|
324
430
|
log(` 🌱 ❨auto-encrypt❩ Successfully renewed Let’s Encrypt certificate.`)
|
|
325
431
|
} else {
|
|
326
|
-
log(' 👍 ❨auto-encrypt❩ Certificate
|
|
432
|
+
log(' 👍 ❨auto-encrypt❩ Certificate does not need renewal (expiry date). Will check again tomorrow.')
|
|
327
433
|
}
|
|
328
434
|
}
|
|
329
435
|
|
|
330
436
|
|
|
331
437
|
/**
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
438
|
+
Starts checking for certificate renewals every hour (as ARI renewal
|
|
439
|
+
windows on short-lived certificates are short – 2.88 hours starting at around
|
|
440
|
+
half-way mark of the certificate’s 160 hour lifetime according to
|
|
441
|
+
https://community.letsencrypt.org/t/ari-recommendation-may-cause-renewal-outside-suggested-window/235059/25).
|
|
442
|
+
|
|
443
|
+
@param {boolean} [alsoCheckNow=false] If true, will also immediately check for renewal when the function is
|
|
444
|
+
called (use this when loading a previously-provisioned and persisted
|
|
445
|
+
certificate from disk).
|
|
446
|
+
@category sync
|
|
447
|
+
@access public
|
|
448
|
+
*/
|
|
340
449
|
startCheckingForRenewal (alsoCheckNow = false) {
|
|
341
|
-
//
|
|
342
|
-
// Check for certificate renewal now and then once every day from there on.
|
|
343
|
-
//
|
|
344
|
-
this.#renewalDate = this.expiryDate.clone().subtract(30, 'days')
|
|
345
|
-
|
|
346
450
|
// Also check for renewal immediately if asked to.
|
|
347
451
|
if (alsoCheckNow) {
|
|
348
452
|
this.checkForRenewal()
|
|
349
453
|
}
|
|
350
454
|
|
|
351
455
|
// And also once a day from thereon for as long as the server is running.
|
|
352
|
-
const
|
|
353
|
-
this.#checkForRenewalIntervalId = setInterval(this.checkForRenewal.bind(this),
|
|
456
|
+
const onceAnHour = 1 /* hours */ * 60 /* minutes */ * 60 /* seconds */ * 1000 /* ms */
|
|
457
|
+
this.#checkForRenewalIntervalId = setInterval(this.checkForRenewal.bind(this), onceAnHour)
|
|
354
458
|
|
|
355
|
-
log(' ⏰ ❨auto-encrypt❩ Set up timer to check for certificate renewal once
|
|
459
|
+
log(' ⏰ ❨auto-encrypt❩ Set up timer to check for certificate renewal (ARI) once an hour.')
|
|
356
460
|
}
|
|
357
461
|
|
|
358
462
|
/**
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
463
|
+
Stops the timer that checks for renewal daily. Use this during housekeeping before destroying this object.
|
|
464
|
+
|
|
465
|
+
@category sync
|
|
466
|
+
@access public
|
|
467
|
+
*/
|
|
364
468
|
stopCheckingForRenewal () {
|
|
365
469
|
clearInterval(this.#checkForRenewalIntervalId)
|
|
366
470
|
}
|
|
367
471
|
|
|
472
|
+
/**
|
|
473
|
+
@param { Uint8Array<ArrayBuffer> | string } input
|
|
474
|
+
*/
|
|
475
|
+
base64UrlEncode ( input ) {
|
|
476
|
+
const base64 = Buffer.from(typeof input === 'string' ? new TextEncoder().encode(input) : input).toString('base64')
|
|
477
|
+
|
|
478
|
+
return base64
|
|
479
|
+
.replace(/\+/g, '-') // Replace + with -
|
|
480
|
+
.replace(/\//g, '_') // Replace / with _
|
|
481
|
+
.replace(/=+$/, '') // Remove trailing =
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
@param {any} certificatePem - (Typed as any as underlying library doesn’t contain type information.)
|
|
486
|
+
*/
|
|
368
487
|
parseDetails (certificatePem) {
|
|
369
488
|
const certificate = (X509Certificate.decode(certificatePem, 'pem', {label: 'CERTIFICATE'})).tbsCertificate
|
|
370
489
|
|
|
371
490
|
const serialNumber = certificate.serialNumber
|
|
491
|
+
|
|
492
|
+
// Calculate ARI CertID as per RFC 9773 § 4.1.
|
|
493
|
+
const akiExtension = certificate.extensions.find((/** @type {{ extnID: string }} */ extension) => extension.extnID === 'authorityKeyIdentifier')
|
|
494
|
+
let ariCertId = null
|
|
495
|
+
if (akiExtension && akiExtension.extnValue.keyIdentifier) {
|
|
496
|
+
const encodedAuthorityKeyIdentifier = this.base64UrlEncode(akiExtension.extnValue.keyIdentifier)
|
|
497
|
+
|
|
498
|
+
const serialDer = CertificateSerialNumber.encode(serialNumber, 'der')
|
|
499
|
+
// SerialNumber is an INTEGER. DER for INTEGER is 02 [LENGTH] [CONTENT]
|
|
500
|
+
// We want only the content octets.
|
|
501
|
+
let contentStart = 2
|
|
502
|
+
if (serialDer[1] & 0x80) {
|
|
503
|
+
// Long form length
|
|
504
|
+
contentStart = 2 + (serialDer[1] & 0x7f)
|
|
505
|
+
}
|
|
506
|
+
const encodedSerialNumber = this.base64UrlEncode(serialDer.slice(contentStart))
|
|
507
|
+
|
|
508
|
+
ariCertId = `${encodedAuthorityKeyIdentifier}.${encodedSerialNumber}`
|
|
509
|
+
}
|
|
510
|
+
|
|
372
511
|
const issuer = certificate.issuer.value[0][0].value.toString('utf-8').slice(2).trim()
|
|
373
512
|
const issuedAt = new Date(certificate.validity.notBefore.value)
|
|
374
513
|
const expiresAt = new Date(certificate.validity.notAfter.value)
|
|
375
514
|
const subject = certificate.subject.value.length > 0 ? certificate.subject.value[0][0].value.toString('utf-8').slice(2).trim() : '(No subject)'
|
|
376
|
-
let hasOcspMustStaple = false
|
|
377
515
|
|
|
378
|
-
const alternativeNames = ((certificate.extensions.filter(extension => {
|
|
516
|
+
const alternativeNames = ((certificate.extensions.filter((/** @type {{ extnID:string }} */ extension) => {
|
|
379
517
|
return extension.extnID === 'subjectAlternativeName'
|
|
380
|
-
}))[0].extnValue).map(name =>
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
518
|
+
}))[0].extnValue).map((/** @type {{ type: string, value: any }} */ name) => {
|
|
519
|
+
if (name.type === 'iPAddress') {
|
|
520
|
+
// IPv4 or IPv6.
|
|
521
|
+
if (name.value.length === 4) {
|
|
522
|
+
// IPv4
|
|
523
|
+
return name.value.join('.')
|
|
524
|
+
} else if (name.value.length === 16) {
|
|
525
|
+
// IPv6
|
|
526
|
+
const groups = []
|
|
527
|
+
for (let i = 0; i < 16; i += 2) {
|
|
528
|
+
groups.push(name.value.readUInt16BE(i).toString(16))
|
|
529
|
+
}
|
|
530
|
+
// Note: we don’t perform IPv6 compression (RFC 5952) here as this is for a debug display.
|
|
531
|
+
return groups.join(':')
|
|
532
|
+
}
|
|
385
533
|
}
|
|
534
|
+
return name.value
|
|
386
535
|
})
|
|
387
536
|
|
|
388
537
|
return {
|
|
@@ -392,22 +541,17 @@ export default class Certificate {
|
|
|
392
541
|
alternativeNames,
|
|
393
542
|
issuedAt,
|
|
394
543
|
expiresAt,
|
|
395
|
-
|
|
544
|
+
ariCertId
|
|
396
545
|
}
|
|
397
546
|
}
|
|
398
547
|
|
|
399
|
-
__changeRenewalDate (momentDate) {
|
|
400
|
-
log(' ⚠ ❨auto-encrypt❩ Warning: changing renewal date on the certificate instance. I hope you know what you’re doing.')
|
|
401
|
-
this.#renewalDate = momentDate
|
|
402
|
-
}
|
|
403
|
-
|
|
404
548
|
get __checkForRenewalIntervalId () {
|
|
405
549
|
return this.#checkForRenewalIntervalId
|
|
406
550
|
}
|
|
407
551
|
|
|
408
552
|
/**
|
|
409
|
-
|
|
410
|
-
|
|
553
|
+
Custom inspection string.
|
|
554
|
+
*/
|
|
411
555
|
[util.inspect.custom] () {
|
|
412
556
|
return `# Certificate
|
|
413
557
|
${!this.isProvisioned ? 'Certificate not provisioned.' : `
|
|
@@ -416,10 +560,9 @@ export default class Certificate {
|
|
|
416
560
|
Serial number .serialNumber ${this.serialNumber}
|
|
417
561
|
Issuer .issuer ${this.issuer}
|
|
418
562
|
Subject .subject ${this.subject}
|
|
419
|
-
Alternative names .
|
|
563
|
+
Alternative names .alternativeNames ${this.alternativeNames.join(', ')}
|
|
420
564
|
Issue date .issueDate ${this.issueDate}
|
|
421
565
|
Expiry date .expiryDate ${this.expiryDate}
|
|
422
|
-
Renewal date .renewalDate ${this.renewalDate}
|
|
423
566
|
`}
|
|
424
567
|
`
|
|
425
568
|
}
|