@small-tech/auto-encrypt 4.3.0 → 5.0.0

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.
@@ -1,17 +1,18 @@
1
1
  /**
2
- * Represents a Let’s Encrypt TLS certificate.
3
- *
4
- * @module
5
- * @copyright Copyright © 2020 Aral Balkan, Small Technology Foundation.
6
- * @license AGPLv3 or later.
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 moment from 'moment'
12
+ import { Temporal } from 'temporal-polyfill'
13
13
  import log from './util/log.js'
14
- import { Certificate as X509Certificate } from './x.509/rfc5280.js'
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
- * Represents a Let’s Encrypt TLS certificate.
29
- *
30
- * @alias module:lib/Certificate
31
- * @param {String[]} domains List of domains this certificate covers.
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
- * Get a SecureContext that can be used in an SNICallback.
36
- *
37
- * @category async
38
- * @returns {Promise<tls.SecureContext>} A promise for a SecureContext that can be used in creating https servers.
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
- * Creates an instance of Certificate.
54
- *
55
- * @param {Configuration} configuration Configuration instance.
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 on first hit of the server.')
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
- #_hasOcspMustStaple = null
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 renewalDate () { return this.#renewalDate }
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 = moment(details.issuedAt)
121
- this.#_expiryDate = moment(details.expiresAt)
122
- this.#_hasOcspMustStaple = details.hasOcspMustStaple
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.calendar().toLowerCase()} (${this.issueDate.fromNow()})`,
132
- `Expires on : ${this.expiryDate.calendar().toLowerCase()} (${this.expiryDate.fromNow()})`
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 (value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'key', 'set via identity') }
154
- set serialNumber (value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'serialNumber', 'set via pem') }
155
- set issuer (value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'issuer', 'set via pem') }
156
- set subject (value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'subject', 'set via pem') }
157
- set alternativeNames (value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'alternativeNames', 'set via pem') }
158
- set issueDate (value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'issueDate', 'set via pem') }
159
- set expiryDate (value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'expiryDate', 'set via pem') }
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
- * Check if certificate-identity.pem.old or certificate.pem.old files exist.
164
- * If they do, it means that something went wrong while certificate was trying to be
165
- * renewed. So restore them and use them and hopefully the next renewal attempt will
166
- * succeed or at least buy the administrator of the server some time to fix the issue.
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
- * Creates and caches a secure context, provisioning a TLS certificate in the process, if necessary.
194
- *
195
- * @category async
196
- * @access private
197
- * @param {Boolean} renewCertificate If true, will start the process of renewing the certificate
198
- * (but will continue to return the existing certificate until it is ready).
199
- * @returns {Promise} Fulfils immediately if certificate exists and does not need to be
200
- * renewed. Otherwise, fulfils when certificate has been provisioned.
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
- this.#directory = await Directory.getInstanceAsync(this.#configuration)
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
- * Provisions a new Let’s Encrypt TLS certificate, persists it, and starts checking for
239
- * renewals on it every day, starting with the next day.
240
- *
241
- * @access private
242
- * @category async
243
- * @returns {Promise} Fulfils once a certificate has been provisioned.
244
- */
245
- async provisionCertificate () {
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.getInstanceAsync(this.#configuration, this.#accountIdentity)
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 day, starting tomorrow.
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
- * Starts the certificate renewal process by requesting the creation of a fresh secure context.
263
- *
264
- * @access private
265
- * @returns {Promise} Resolves once certificate is renewed and new secure context is
266
- * created and cached.
267
- * @category async
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
- * Checks if the certificate needs to be renewed (if it is within 30 days of its expiry date) and, if so,
305
- * renews it. While the method is async, the result is not awaited on usage. Instead, it is a fire-and-forget
306
- * method that’s called via a daily interval.
307
- *
308
- * @access private
309
- * @category async
310
- * @returns {Promise} Fulfils immediately if certificate doesn’t need renewal. Otherwise, fulfils once certificate
311
- * has been renewed.
312
- */
313
- async checkForRenewal () {
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
- const currentDate = moment()
316
- if (currentDate.isSameOrAfter(this.#renewalDate)) {
317
- //
318
- // Certificate needs renewal.
319
- //
320
- log(` 🌱 ❨auto-encrypt❩ Certificate expires in 30 days or less. Renewing certificate…`)
321
- // Note: this is not a blocking process. We transparently start using the new certificate
322
- // when it is ready.
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 has more than 30 days before it expires. Will check again tomorrow.')
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
- * Starts checking for certificate renewals every 24 hours.
333
- *
334
- * @param {boolean} [alsoCheckNow=false] If true, will also immediately check for renewal when the function is
335
- * called (use this when loading a previously-provisioned and persisted
336
- * certificate from disk).
337
- * @category sync
338
- * @access private
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 onceADay = 24 /* hours */ * 60 /* minutes */ * 60 /* seconds */ * 1000 /* ms */
353
- this.#checkForRenewalIntervalId = setInterval(this.checkForRenewal.bind(this), onceADay)
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 a day.')
459
+ log(' ⏰ ❨auto-encrypt❩ Set up timer to check for certificate renewal (ARI) once an hour.')
356
460
  }
357
461
 
358
462
  /**
359
- * Stops the timer that checks for renewal daily. Use this during housekeeping before destroying this object.
360
- *
361
- * @category sync
362
- * @access private
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 => name.value)
381
-
382
- certificate.extensions.forEach(extension => {
383
- if (Array.isArray(extension.extnID) && extension.extnID.join('.') === '1.3.6.1.5.5.7.1.24') {
384
- hasOcspMustStaple = true
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
- hasOcspMustStaple
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
- * Custom inspection string.
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 .alterNativeNames ${this.alternativeNames}
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
  }