@small-tech/auto-encrypt 4.2.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,58 +76,69 @@ 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
- get isProvisioned () { return this.#_pem !== null }
99
- get pem () { return this.#_pem }
100
- get identity () { return this.#_identity }
101
- get key () { return this.#_key }
102
- get serialNumber () { return this.#_serialNumber }
103
- get issuer () { return this.#_issuer }
104
- get subject () { return this.#_subject }
105
- get alternativeNames () { return this.#_alternativeNames }
106
- get issueDate () { return this.#_issueDate }
107
- get expiryDate () { return this.#_expiryDate }
108
- get renewalDate () { return this.#renewalDate }
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 }
109
119
 
110
120
  set pem (certificatePem) {
111
121
  this.#_pem = certificatePem
112
122
 
113
123
  const details = this.parseDetails(certificatePem)
114
- this.#_serialNumber = details.serialNumber
115
- this.#_issuer = details.issuer
116
- this.#_subject = details.subject
117
- this.#_alternativeNames = details.alternativeNames
118
- this.#_issueDate = moment(details.issuedAt)
119
- this.#_expiryDate = moment(details.expiresAt)
124
+ this.#_serialNumber = details.serialNumber
125
+ this.#_issuer = details.issuer
126
+ this.#_subject = details.subject
127
+ this.#_alternativeNames = details.alternativeNames
128
+ this.#_issueDate = Temporal.Instant.from(details.issuedAt.toISOString())
129
+ this.#_expiryDate = Temporal.Instant.from(details.expiresAt.toISOString())
130
+ this.#_ariCertId = details.ariCertId
120
131
 
121
132
  // Display the certificate with a nice border :)
122
133
  const logMessagePrefix = ' ❨auto-encrypt❩ '
134
+
123
135
  let logMessageBody = [
124
136
  `Serial number : ${details.serialNumber}`,
125
137
  `Issuer : ${details.issuer}`,
126
138
  `Subject : ${details.subject}`,
127
- `Alternative names: ${details.alternativeNames.reduce((string, name) => `${string}, ${name}`)}`,
128
- `Issued on : ${this.issueDate.calendar().toLowerCase()} (${this.issueDate.fromNow()})`,
129
- `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)})`
130
142
  ]
131
143
 
132
144
  const longestLineLength = logMessageBody.reduce((accumulator, currentValue) => currentValue.length > accumulator ? currentValue.length : accumulator, 0)
@@ -147,21 +159,20 @@ export default class Certificate {
147
159
  this.#_key = certificateIdentity.privatePEM
148
160
  }
149
161
 
150
- set key (value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'key', 'set via identity') }
151
- set serialNumber (value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'serialNumber', 'set via pem') }
152
- set issuer (value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'issuer', 'set via pem') }
153
- set subject (value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'subject', 'set via pem') }
154
- set alternativeNames (value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'alternativeNames', 'set via pem') }
155
- set issueDate (value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'issueDate', 'set via pem') }
156
- set expiryDate (value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'expiryDate', 'set via pem') }
157
- 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') }
158
169
 
159
170
  /**
160
- * Check if certificate-identity.pem.old or certificate.pem.old files exist.
161
- * If they do, it means that something went wrong while certificate was trying to be
162
- * renewed. So restore them and use them and hopefully the next renewal attempt will
163
- * succeed or at least buy the administrator of the server some time to fix the issue.
164
- */
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
+ */
165
176
  attemptToRecoverFromFailedRenewalAttemptIfNecessary () {
166
177
  const oldCertificateIdentityPath = `${this.#configuration.certificateIdentityPath}.old`
167
178
  const oldCertificatePath = `${this.#configuration.certificatePath}.old`
@@ -187,15 +198,63 @@ export default class Certificate {
187
198
  }
188
199
 
189
200
  /**
190
- * Creates and caches a secure context, provisioning a TLS certificate in the process, if necessary.
191
- *
192
- * @category async
193
- * @access private
194
- * @param {Boolean} renewCertificate If true, will start the process of renewing the certificate
195
- * (but will continue to return the existing certificate until it is ready).
196
- * @returns {Promise} Fulfils immediately if certificate exists and does not need to be
197
- * renewed. Otherwise, fulfils when certificate has been provisioned.
198
- */
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
+ */
199
258
  async createSecureContext (renewCertificate = false) {
200
259
  // If we’re provisioning a certificate for the first time,
201
260
  // block all other calls. If we’re renewing, we don’t
@@ -207,15 +266,10 @@ export default class Certificate {
207
266
 
208
267
  // If the certificate does not already exist, provision one.
209
268
  if (!this.pem || renewCertificate) {
210
-
211
269
  // Initialise all necessary state.
212
- this.#directory = await Directory.getInstanceAsync(this.#configuration)
213
- this.#accountIdentity = new AccountIdentity(this.#configuration)
214
- AcmeRequest.initialise(this.#directory, this.#accountIdentity)
215
- this.#account = await Account.getInstanceAsync(this.#configuration)
216
- AcmeRequest.account = this.#account
270
+ await this.#initialiseAcmeRequest()
217
271
 
218
- await this.provisionCertificate()
272
+ await this.provisionCertificate(renewCertificate ? this.#_ariCertId : null)
219
273
  }
220
274
 
221
275
  // Create and cache the secure context.
@@ -232,37 +286,38 @@ export default class Certificate {
232
286
 
233
287
 
234
288
  /**
235
- * Provisions a new Let’s Encrypt TLS certificate, persists it, and starts checking for
236
- * renewals on it every day, starting with the next day.
237
- *
238
- * @access private
239
- * @category async
240
- * @returns {Promise} Fulfils once a certificate has been provisioned.
241
- */
242
- 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()) {
243
298
  log(` 🤖 ❨auto-encrypt❩ Provisioning Let’s Encrypt certificates for ${this.#domains}.`)
244
299
 
245
300
  // Create a new order.
246
- const order = await Order.getInstanceAsync(this.#configuration, this.#accountIdentity)
301
+ const order = await Order.createAsync(this.#configuration, this.#accountIdentity, ariCertId)
247
302
 
248
303
  // Get the certificate details from the order.
249
304
  this.pem = order.certificate
250
305
  this.identity = order.certificateIdentity
251
306
 
252
- // Start checking for renewal updates, every day, starting tomorrow.
307
+ // Start checking for renewal updates, every hour, starting in an hour.
253
308
  this.startCheckingForRenewal(/* alsoCheckNow = */ false)
254
309
 
255
310
  log(` 🎉 ❨auto-encrypt❩ Successfully provisioned Let’s Encrypt certificate for ${this.#domains}.`)
256
311
  }
257
312
 
258
313
  /**
259
- * Starts the certificate renewal process by requesting the creation of a fresh secure context.
260
- *
261
- * @access private
262
- * @returns {Promise} Resolves once certificate is renewed and new secure context is
263
- * created and cached.
264
- * @category async
265
- */
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
+ */
266
321
  async renewCertificate () {
267
322
  //
268
323
  // Backup the existing certificate and certificate identity (*.pem → *.pem.old). Then create a new
@@ -298,82 +353,186 @@ export default class Certificate {
298
353
 
299
354
 
300
355
  /**
301
- * Checks if the certificate needs to be renewed (if it is within 30 days of its expiry date) and, if so,
302
- * renews it. While the method is async, the result is not awaited on usage. Instead, it is a fire-and-forget
303
- * method that’s called via a daily interval.
304
- *
305
- * @access private
306
- * @category async
307
- * @returns {Promise} Fulfils immediately if certificate doesn’t need renewal. Otherwise, fulfils once certificate
308
- * has been renewed.
309
- */
310
- 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) {
311
367
  log( ' 🧐 ❨auto-encrypt❩ Checking if we need to renew the certificate… ')
312
- const currentDate = moment()
313
- if (currentDate.isSameOrAfter(this.#renewalDate)) {
314
- //
315
- // Certificate needs renewal.
316
- //
317
- log(` 🌱 ❨auto-encrypt❩ Certificate expires in 30 days or less. Renewing certificate…`)
318
- // Note: this is not a blocking process. We transparently start using the new certificate
319
- // 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…`)
320
429
  await this.renewCertificate()
321
430
  log(` 🌱 ❨auto-encrypt❩ Successfully renewed Let’s Encrypt certificate.`)
322
431
  } else {
323
- 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.')
324
433
  }
325
434
  }
326
435
 
327
436
 
328
437
  /**
329
- * Starts checking for certificate renewals every 24 hours.
330
- *
331
- * @param {boolean} [alsoCheckNow=false] If true, will also immediately check for renewal when the function is
332
- * called (use this when loading a previously-provisioned and persisted
333
- * certificate from disk).
334
- * @category sync
335
- * @access private
336
- */
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
+ */
337
449
  startCheckingForRenewal (alsoCheckNow = false) {
338
- //
339
- // Check for certificate renewal now and then once every day from there on.
340
- //
341
- this.#renewalDate = this.expiryDate.clone().subtract(30, 'days')
342
-
343
450
  // Also check for renewal immediately if asked to.
344
451
  if (alsoCheckNow) {
345
452
  this.checkForRenewal()
346
453
  }
347
454
 
348
455
  // And also once a day from thereon for as long as the server is running.
349
- const onceADay = 24 /* hours */ * 60 /* minutes */ * 60 /* seconds */ * 1000 /* ms */
350
- 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)
351
458
 
352
- 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.')
353
460
  }
354
461
 
355
462
  /**
356
- * Stops the timer that checks for renewal daily. Use this during housekeeping before destroying this object.
357
- *
358
- * @category sync
359
- * @access private
360
- */
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
+ */
361
468
  stopCheckingForRenewal () {
362
469
  clearInterval(this.#checkForRenewalIntervalId)
363
470
  }
364
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
+ */
365
487
  parseDetails (certificatePem) {
366
488
  const certificate = (X509Certificate.decode(certificatePem, 'pem', {label: 'CERTIFICATE'})).tbsCertificate
367
489
 
368
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
+
369
511
  const issuer = certificate.issuer.value[0][0].value.toString('utf-8').slice(2).trim()
370
512
  const issuedAt = new Date(certificate.validity.notBefore.value)
371
513
  const expiresAt = new Date(certificate.validity.notAfter.value)
372
514
  const subject = certificate.subject.value.length > 0 ? certificate.subject.value[0][0].value.toString('utf-8').slice(2).trim() : '(No subject)'
373
515
 
374
- const alternativeNames = ((certificate.extensions.filter(extension => {
516
+ const alternativeNames = ((certificate.extensions.filter((/** @type {{ extnID:string }} */ extension) => {
375
517
  return extension.extnID === 'subjectAlternativeName'
376
- }))[0].extnValue).map(name => name.value)
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
+ }
533
+ }
534
+ return name.value
535
+ })
377
536
 
378
537
  return {
379
538
  serialNumber,
@@ -381,22 +540,18 @@ export default class Certificate {
381
540
  subject,
382
541
  alternativeNames,
383
542
  issuedAt,
384
- expiresAt
543
+ expiresAt,
544
+ ariCertId
385
545
  }
386
546
  }
387
547
 
388
- __changeRenewalDate (momentDate) {
389
- log(' ⚠ ❨auto-encrypt❩ Warning: changing renewal date on the certificate instance. I hope you know what you’re doing.')
390
- this.#renewalDate = momentDate
391
- }
392
-
393
548
  get __checkForRenewalIntervalId () {
394
549
  return this.#checkForRenewalIntervalId
395
550
  }
396
551
 
397
552
  /**
398
- * Custom inspection string.
399
- */
553
+ Custom inspection string.
554
+ */
400
555
  [util.inspect.custom] () {
401
556
  return `# Certificate
402
557
  ${!this.isProvisioned ? 'Certificate not provisioned.' : `
@@ -405,10 +560,9 @@ export default class Certificate {
405
560
  Serial number .serialNumber ${this.serialNumber}
406
561
  Issuer .issuer ${this.issuer}
407
562
  Subject .subject ${this.subject}
408
- Alternative names .alterNativeNames ${this.alternativeNames}
563
+ Alternative names .alternativeNames ${this.alternativeNames.join(', ')}
409
564
  Issue date .issueDate ${this.issueDate}
410
565
  Expiry date .expiryDate ${this.expiryDate}
411
- Renewal date .renewalDate ${this.renewalDate}
412
566
  `}
413
567
  `
414
568
  }