mirror of https://github.com/certd/certd
				
				
				
			🔱: [acme] sync upgrade with 7 commits [trident-sync]
CHANGELOG Fix tls-alpn-01 pebble test on Node v18+ Return correct tls-alpn-01 key authorization, tests Support tls-alpn-01 internal challenge verification Add tls-alpn-01 challenge test server support Add ALPN crypto utility methodspull/29/head
							parent
							
								
									08c1f338d5
								
							
						
					
					
						commit
						fc9e71bed2
					
				|  | @ -1,5 +1,12 @@ | |||
| # Changelog | ||||
| 
 | ||||
| ## v5.3.0 | ||||
| 
 | ||||
| * `added` Support and tests for satisfying `tls-alpn-01` challenges | ||||
| * `changed` Method `getChallengeKeyAuthorization()` now returns `$token.$thumbprint` when called with a `tls-alpn-01` challenge | ||||
|     * Previously returned base64url encoded SHA256 digest of `$token.$thumbprint` erroneously | ||||
|     * This change is not considered breaking since the previous behavior was incorrect | ||||
| 
 | ||||
| ## v5.2.0 (2024-01-22) | ||||
| 
 | ||||
| * `fixed` Allow self-signed or invalid certs when validating `http-01` challenges that redirect to HTTPS - [#65](https://github.com/publishlab/node-acme-client/issues/65) | ||||
|  |  | |||
|  | @ -18,7 +18,8 @@ instance.defaults.headers.common['User-Agent'] = `node-${pkg.name}/${pkg.version | |||
| /* Default ACME settings */ | ||||
| instance.defaults.acmeSettings = { | ||||
|     httpChallengePort: 80, | ||||
|     httpsChallengePort: 443 | ||||
|     httpsChallengePort: 443, | ||||
|     tlsAlpnChallengePort: 443 | ||||
| }; | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -462,22 +462,19 @@ class AcmeClient { | |||
|         const thumbprint = keysum.digest('base64url'); | ||||
|         const result = `${challenge.token}.${thumbprint}`; | ||||
| 
 | ||||
|         /** | ||||
|          * https://tools.ietf.org/html/rfc8555#section-8.3
 | ||||
|          */ | ||||
| 
 | ||||
|         /* https://tools.ietf.org/html/rfc8555#section-8.3 */ | ||||
|         if (challenge.type === 'http-01') { | ||||
|             return result; | ||||
|         } | ||||
| 
 | ||||
|         /** | ||||
|          * https://tools.ietf.org/html/rfc8555#section-8.4
 | ||||
|          * https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-01
 | ||||
|          */ | ||||
|         /* https://tools.ietf.org/html/rfc8555#section-8.4 */ | ||||
|         if (challenge.type === 'dns-01') { | ||||
|             return createHash('sha256').update(result).digest('base64url'); | ||||
|         } | ||||
| 
 | ||||
|         if ((challenge.type === 'dns-01') || (challenge.type === 'tls-alpn-01')) { | ||||
|             const shasum = createHash('sha256').update(result); | ||||
|             return shasum.digest('base64url'); | ||||
|         /* https://tools.ietf.org/html/rfc8737 */ | ||||
|         if (challenge.type === 'tls-alpn-01') { | ||||
|             return result; | ||||
|         } | ||||
| 
 | ||||
|         throw new Error(`Unable to produce key authorization, unknown challenge type: ${challenge.type}`); | ||||
|  |  | |||
|  | @ -9,8 +9,12 @@ const { promisify } = require('util'); | |||
| const crypto = require('crypto'); | ||||
| const jsrsasign = require('jsrsasign'); | ||||
| 
 | ||||
| const randomInt = promisify(crypto.randomInt); | ||||
| const generateKeyPair = promisify(crypto.generateKeyPair); | ||||
| 
 | ||||
| /* https://datatracker.ietf.org/doc/html/rfc8737#section-6.1 */ | ||||
| const alpnAcmeIdentifierOID = '1.3.6.1.5.5.7.1.31'; | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  * Determine key type and info by attempting to derive public key | ||||
|  | @ -231,7 +235,7 @@ exports.getPemBodyAsB64u = (pem) => { | |||
|         throw new Error('Unable to parse PEM body from string'); | ||||
|     } | ||||
| 
 | ||||
|     /* First object, hex and back to b64 without new lines */ | ||||
|     /* Select first object, decode to hex and b64u */ | ||||
|     return jsrsasign.hextob64u(jsrsasign.pemtohex(chain[0])); | ||||
| }; | ||||
| 
 | ||||
|  | @ -303,6 +307,28 @@ exports.readCsrDomains = (csrPem) => { | |||
| }; | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  * Parse params from a single or chain of PEM encoded certificates | ||||
|  * | ||||
|  * @private | ||||
|  * @param {buffer|string} certPem PEM encoded certificate or chain | ||||
|  * @returns {object} Certificate params | ||||
|  */ | ||||
| 
 | ||||
| function getCertificateParams(certPem) { | ||||
|     const chain = splitPemChain(certPem); | ||||
| 
 | ||||
|     if (!chain.length) { | ||||
|         throw new Error('Unable to parse PEM body from string'); | ||||
|     } | ||||
| 
 | ||||
|     /* Parse certificate */ | ||||
|     const obj = new jsrsasign.X509(); | ||||
|     obj.readCertPEM(chain[0]); | ||||
|     return obj.getParam(); | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  * Read information from a certificate | ||||
|  * If multiple certificates are chained, the first will be read | ||||
|  | @ -324,16 +350,7 @@ exports.readCsrDomains = (csrPem) => { | |||
|  */ | ||||
| 
 | ||||
| exports.readCertificateInfo = (certPem) => { | ||||
|     const chain = splitPemChain(certPem); | ||||
| 
 | ||||
|     if (!chain.length) { | ||||
|         throw new Error('Unable to parse PEM body from string'); | ||||
|     } | ||||
| 
 | ||||
|     /* Parse certificate */ | ||||
|     const obj = new jsrsasign.X509(); | ||||
|     obj.readCertPEM(chain[0]); | ||||
|     const params = obj.getParam(); | ||||
|     const params = getCertificateParams(certPem); | ||||
| 
 | ||||
|     return { | ||||
|         issuer: { | ||||
|  | @ -462,7 +479,7 @@ function formatCsrAltNames(altNames) { | |||
|  * }, certificateKey); | ||||
|  */ | ||||
| 
 | ||||
| exports.createCsr = async (data, keyPem = null) => { | ||||
| async function createCsr(data, keyPem = null) { | ||||
|     if (!keyPem) { | ||||
|         keyPem = await createPrivateRsaKey(data.keySize); | ||||
|     } | ||||
|  | @ -517,10 +534,95 @@ exports.createCsr = async (data, keyPem = null) => { | |||
|         extreq: extensionRequests | ||||
|     }); | ||||
| 
 | ||||
|     /* Sign CSR, get PEM */ | ||||
|     csr.sign(); | ||||
|     /* Done */ | ||||
|     const pem = csr.getPEM(); | ||||
|     return [keyPem, Buffer.from(pem)]; | ||||
| } | ||||
| 
 | ||||
| exports.createCsr = createCsr; | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  * Create a self-signed ALPN certificate for TLS-ALPN-01 challenges | ||||
|  * | ||||
|  * https://tools.ietf.org/html/rfc8737
 | ||||
|  * | ||||
|  * @param {object} authz Identifier authorization | ||||
|  * @param {string} keyAuthorization Challenge key authorization | ||||
|  * @param {string} [keyPem] PEM encoded CSR private key | ||||
|  * @returns {Promise<buffer[]>} [privateKey, certificate] | ||||
|  * | ||||
|  * @example Create a ALPN certificate | ||||
|  * ```js
 | ||||
|  * const [alpnKey, alpnCertificate] = await acme.crypto.createAlpnCertificate(authz, keyAuthorization); | ||||
|  * ``` | ||||
|  * | ||||
|  * @example Create a ALPN certificate with ECDSA private key | ||||
|  * ```js
 | ||||
|  * const alpnKey = await acme.crypto.createPrivateEcdsaKey(); | ||||
|  * const [, alpnCertificate] = await acme.crypto.createAlpnCertificate(authz, keyAuthorization, alpnKey); | ||||
|  */ | ||||
| 
 | ||||
| exports.createAlpnCertificate = async (authz, keyAuthorization, keyPem = null) => { | ||||
|     /* Create CSR first */ | ||||
|     const now = new Date(); | ||||
|     const commonName = authz.identifier.value; | ||||
|     const [key, csr] = await createCsr({ commonName }, keyPem); | ||||
| 
 | ||||
|     /* Parse params and grab stuff we need */ | ||||
|     const params = jsrsasign.KJUR.asn1.csr.CSRUtil.getParam(csr.toString()); | ||||
|     const { subject, sbjpubkey, extreq, sigalg } = params; | ||||
| 
 | ||||
|     /* ALPN extension */ | ||||
|     const alpnExt = { | ||||
|         critical: true, | ||||
|         extname: alpnAcmeIdentifierOID, | ||||
|         extn: new jsrsasign.KJUR.asn1.DEROctetString({ | ||||
|             hex: crypto.createHash('sha256').update(keyAuthorization).digest('hex') | ||||
|         }) | ||||
|     }; | ||||
| 
 | ||||
|     /* Pseudo-random serial - max 20 bytes, 11 for epoch (year 5138), 9 random */ | ||||
|     const random = await randomInt(1, 999999999); | ||||
|     const serial = `${Math.floor(now.getTime() / 1000)}${random}`; | ||||
| 
 | ||||
|     /* Self-signed ALPN certificate */ | ||||
|     const certificate = new jsrsasign.KJUR.asn1.x509.Certificate({ | ||||
|         subject, | ||||
|         sbjpubkey, | ||||
|         sigalg, | ||||
|         version: 3, | ||||
|         serial: { hex: Buffer.from(serial).toString('hex') }, | ||||
|         issuer: subject, | ||||
|         notbefore: jsrsasign.datetozulu(now), | ||||
|         notafter: jsrsasign.datetozulu(now), | ||||
|         cakey: key.toString(), | ||||
|         ext: extreq.concat([alpnExt]) | ||||
|     }); | ||||
| 
 | ||||
|     /* Done */ | ||||
|     return [keyPem, Buffer.from(pem)]; | ||||
|     const pem = certificate.getPEM(); | ||||
|     return [key, Buffer.from(pem)]; | ||||
| }; | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  * Validate that a ALPN certificate contains the expected key authorization | ||||
|  * | ||||
|  * @param {buffer|string} certPem PEM encoded certificate | ||||
|  * @param {string} keyAuthorization Expected challenge key authorization | ||||
|  * @returns {boolean} True when valid | ||||
|  */ | ||||
| 
 | ||||
| exports.isAlpnCertificateAuthorizationValid = (certPem, keyAuthorization) => { | ||||
|     const params = getCertificateParams(certPem); | ||||
|     const expectedHex = crypto.createHash('sha256').update(keyAuthorization).digest('hex'); | ||||
|     const acmeExt = (params.ext || []).find((e) => (e && e.extname && (e.extname === alpnAcmeIdentifierOID))); | ||||
| 
 | ||||
|     if (!acmeExt || !acmeExt.extn || !acmeExt.extn.octstr || !acmeExt.extn.octstr.hex) { | ||||
|         throw new Error('Unable to locate ALPN extension within parsed certificate'); | ||||
|     } | ||||
| 
 | ||||
|     /* Return true if match */ | ||||
|     return (acmeExt.extn.octstr.hex === expectedHex); | ||||
| }; | ||||
|  |  | |||
|  | @ -2,6 +2,7 @@ | |||
|  * Utility methods | ||||
|  */ | ||||
| 
 | ||||
| const tls = require('tls'); | ||||
| const dns = require('dns').promises; | ||||
| const { readCertificateInfo, splitPemChain } = require('./crypto'); | ||||
| const { log } = require('./logger'); | ||||
|  | @ -245,6 +246,60 @@ async function getAuthoritativeDnsResolver(recordName) { | |||
| } | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  * Attempt to retrieve TLS ALPN certificate from peer | ||||
|  * | ||||
|  * https://nodejs.org/api/tls.html#tlsconnectoptions-callback
 | ||||
|  * | ||||
|  * @param {string} host Host the TLS client should connect to | ||||
|  * @param {number} port Port the client should connect to | ||||
|  * @param {string} servername Server name for the SNI (Server Name Indication) | ||||
|  * @returns {Promise<string>} PEM encoded certificate | ||||
|  */ | ||||
| 
 | ||||
| async function retrieveTlsAlpnCertificate(host, port, timeout = 30000) { | ||||
|     return new Promise((resolve, reject) => { | ||||
|         let result; | ||||
| 
 | ||||
|         /* TLS connection */ | ||||
|         const socket = tls.connect({ | ||||
|             host, | ||||
|             port, | ||||
|             servername: host, | ||||
|             rejectUnauthorized: false, | ||||
|             ALPNProtocols: ['acme-tls/1'] | ||||
|         }); | ||||
| 
 | ||||
|         socket.setTimeout(timeout); | ||||
|         socket.setEncoding('utf-8'); | ||||
| 
 | ||||
|         /* Grab certificate once connected and close */ | ||||
|         socket.on('secureConnect', () => { | ||||
|             result = socket.getPeerX509Certificate(); | ||||
|             socket.end(); | ||||
|         }); | ||||
| 
 | ||||
|         /* Errors */ | ||||
|         socket.on('error', (err) => { | ||||
|             reject(err); | ||||
|         }); | ||||
| 
 | ||||
|         socket.on('timeout', () => { | ||||
|             socket.destroy(new Error('TLS ALPN certificate lookup request timed out')); | ||||
|         }); | ||||
| 
 | ||||
|         /* Done, return cert as PEM if found */ | ||||
|         socket.on('end', () => { | ||||
|             if (result) { | ||||
|                 return resolve(result.toString()); | ||||
|             } | ||||
| 
 | ||||
|             return reject(new Error('TLS ALPN lookup failed to retrieve certificate')); | ||||
|         }); | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  * Export utils | ||||
|  */ | ||||
|  | @ -254,5 +309,6 @@ module.exports = { | |||
|     parseLinkHeader, | ||||
|     findCertificateChainForIssuer, | ||||
|     formatResponseError, | ||||
|     getAuthoritativeDnsResolver | ||||
|     getAuthoritativeDnsResolver, | ||||
|     retrieveTlsAlpnCertificate | ||||
| }; | ||||
|  |  | |||
|  | @ -7,6 +7,7 @@ const https = require('https'); | |||
| const { log } = require('./logger'); | ||||
| const axios = require('./axios'); | ||||
| const util = require('./util'); | ||||
| const { isAlpnCertificateAuthorizationValid } = require('./crypto'); | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  | @ -121,11 +122,40 @@ async function verifyDnsChallenge(authz, challenge, keyAuthorization, prefix = ' | |||
| } | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  * Verify ACME TLS ALPN challenge | ||||
|  * | ||||
|  * https://tools.ietf.org/html/rfc8737
 | ||||
|  * | ||||
|  * @param {object} authz Identifier authorization | ||||
|  * @param {object} challenge Authorization challenge | ||||
|  * @param {string} keyAuthorization Challenge key authorization | ||||
|  * @returns {Promise<boolean>} | ||||
|  */ | ||||
| 
 | ||||
| async function verifyTlsAlpnChallenge(authz, challenge, keyAuthorization) { | ||||
|     const tlsAlpnPort = axios.defaults.acmeSettings.tlsAlpnChallengePort || 443; | ||||
|     const host = authz.identifier.value; | ||||
|     log(`Establishing TLS connection with host: ${host}:${tlsAlpnPort}`); | ||||
| 
 | ||||
|     const certificate = await util.retrieveTlsAlpnCertificate(host, tlsAlpnPort); | ||||
|     log('Certificate received from server successfully, matching key authorization in ALPN'); | ||||
| 
 | ||||
|     if (!isAlpnCertificateAuthorizationValid(certificate, keyAuthorization)) { | ||||
|         throw new Error(`Authorization not found in certificate from ${authz.identifier.value}`); | ||||
|     } | ||||
| 
 | ||||
|     log(`Key authorization match for ${challenge.type}/${authz.identifier.value}, ACME challenge verified`); | ||||
|     return true; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  * Export API | ||||
|  */ | ||||
| 
 | ||||
| module.exports = { | ||||
|     'http-01': verifyHttpChallenge, | ||||
|     'dns-01': verifyDnsChallenge | ||||
|     'dns-01': verifyDnsChallenge, | ||||
|     'tls-alpn-01': verifyTlsAlpnChallenge | ||||
| }; | ||||
|  |  | |||
|  | @ -8,10 +8,13 @@ const https = require('https'); | |||
| const { assert } = require('chai'); | ||||
| const cts = require('./challtestsrv'); | ||||
| const axios = require('./../src/axios'); | ||||
| const { retrieveTlsAlpnCertificate } = require('./../src/util'); | ||||
| const { isAlpnCertificateAuthorizationValid } = require('./../src/crypto'); | ||||
| 
 | ||||
| const domainName = process.env.ACME_DOMAIN_NAME || 'example.com'; | ||||
| const httpPort = axios.defaults.acmeSettings.httpChallengePort || 80; | ||||
| const httpsPort = axios.defaults.acmeSettings.httpsChallengePort || 443; | ||||
| const tlsAlpnPort = axios.defaults.acmeSettings.tlsAlpnChallengePort || 443; | ||||
| 
 | ||||
| 
 | ||||
| describe('pebble', () => { | ||||
|  | @ -33,6 +36,9 @@ describe('pebble', () => { | |||
|     const testDns01ChallengeHost = `_acme-challenge.${uuid()}.${domainName}.`; | ||||
|     const testDns01ChallengeValue = uuid(); | ||||
| 
 | ||||
|     const testTlsAlpn01ChallengeHost = `${uuid()}.${domainName}`; | ||||
|     const testTlsAlpn01ChallengeValue = uuid(); | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * Pebble CTS required | ||||
|  | @ -181,4 +187,29 @@ describe('pebble', () => { | |||
|             assert.deepStrictEqual(resp, [[testDns01ChallengeValue]]); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * TLS-ALPN-01 challenge response | ||||
|      */ | ||||
| 
 | ||||
|     describe('tls-alpn-01', () => { | ||||
|         it('should not locate challenge response', async () => { | ||||
|             await assert.isRejected(retrieveTlsAlpnCertificate(testTlsAlpn01ChallengeHost, tlsAlpnPort), /(failed to retrieve)|(ssl3_read_bytes:tlsv1 alert internal error)/); | ||||
|         }); | ||||
| 
 | ||||
|         it('should timeout challenge response', async () => { | ||||
|             await assert.isRejected(retrieveTlsAlpnCertificate('example.org', tlsAlpnPort, 500), /timed out/); | ||||
|         }); | ||||
| 
 | ||||
|         it('should add challenge response', async () => { | ||||
|             const resp = await cts.addTlsAlpn01ChallengeResponse(testTlsAlpn01ChallengeHost, testTlsAlpn01ChallengeValue); | ||||
|             assert.isTrue(resp); | ||||
|         }); | ||||
| 
 | ||||
|         it('should locate challenge response', async () => { | ||||
|             const resp = await retrieveTlsAlpnCertificate(testTlsAlpn01ChallengeHost, tlsAlpnPort); | ||||
|             assert.isTrue(isAlpnCertificateAuthorizationValid(resp, testTlsAlpn01ChallengeValue)); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
|  |  | |||
|  | @ -26,6 +26,10 @@ describe('verify', () => { | |||
|     const testDns01Key = uuid(); | ||||
|     const testDns01Cname = `${uuid()}.${domainName}`; | ||||
| 
 | ||||
|     const testTlsAlpn01Authz = { identifier: { type: 'dns', value: `${uuid()}.${domainName}` } }; | ||||
|     const testTlsAlpn01Challenge = { type: 'dns-01', status: 'pending', token: uuid() }; | ||||
|     const testTlsAlpn01Key = uuid(); | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * Pebble CTS required | ||||
|  | @ -128,4 +132,25 @@ describe('verify', () => { | |||
|             assert.isTrue(resp); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * tls-alpn-01 | ||||
|      */ | ||||
| 
 | ||||
|     describe('tls-alpn-01', () => { | ||||
|         it('should reject challenge', async () => { | ||||
|             await assert.isRejected(verify['tls-alpn-01'](testTlsAlpn01Authz, testTlsAlpn01Challenge, testTlsAlpn01Key)); | ||||
|         }); | ||||
| 
 | ||||
|         it('should mock challenge response', async () => { | ||||
|             const resp = await cts.addTlsAlpn01ChallengeResponse(testTlsAlpn01Authz.identifier.value, testTlsAlpn01Key); | ||||
|             assert.isTrue(resp); | ||||
|         }); | ||||
| 
 | ||||
|         it('should verify challenge', async () => { | ||||
|             const resp = await verify['tls-alpn-01'](testTlsAlpn01Authz, testTlsAlpn01Challenge, testTlsAlpn01Key); | ||||
|             assert.isTrue(resp); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
|  |  | |||
|  | @ -95,6 +95,7 @@ describe('crypto', () => { | |||
|                 let testSanCsr; | ||||
|                 let testNonCnCsr; | ||||
|                 let testNonAsciiCsr; | ||||
|                 let testAlpnCertificate; | ||||
| 
 | ||||
| 
 | ||||
|                 /** | ||||
|  | @ -215,6 +216,31 @@ describe('crypto', () => { | |||
|                     assert.strictEqual(result.commonName, testCsrDomain); | ||||
|                     assert.deepStrictEqual(result.altNames, [testCsrDomain]); | ||||
|                 }); | ||||
| 
 | ||||
| 
 | ||||
|                 /** | ||||
|                  * ALPN | ||||
|                  */ | ||||
| 
 | ||||
|                 it(`${n}/should generate alpn certificate`, async () => { | ||||
|                     const authz = { identifier: { value: 'test.example.com' } }; | ||||
|                     const [key, cert] = await crypto.createAlpnCertificate(authz, 'super-secret.12345', await createFn()); | ||||
| 
 | ||||
|                     assert.isTrue(Buffer.isBuffer(key)); | ||||
|                     assert.isTrue(Buffer.isBuffer(cert)); | ||||
| 
 | ||||
|                     testAlpnCertificate = cert; | ||||
|                 }); | ||||
| 
 | ||||
|                 it(`${n}/should not validate invalid alpn certificate key authorization`, () => { | ||||
|                     assert.isFalse(crypto.isAlpnCertificateAuthorizationValid(testAlpnCertificate, 'aaaaaaa')); | ||||
|                     assert.isFalse(crypto.isAlpnCertificateAuthorizationValid(testAlpnCertificate, 'bbbbbbb')); | ||||
|                     assert.isFalse(crypto.isAlpnCertificateAuthorizationValid(testAlpnCertificate, 'ccccccc')); | ||||
|                 }); | ||||
| 
 | ||||
|                 it(`${n}/should validate valid alpn certificate key authorization`, () => { | ||||
|                     assert.isTrue(crypto.isAlpnCertificateAuthorizationValid(testAlpnCertificate, 'super-secret.12345')); | ||||
|                 }); | ||||
|             }); | ||||
|         }); | ||||
|     }); | ||||
|  | @ -250,7 +276,7 @@ describe('crypto', () => { | |||
|          * CSR with auto-generated key | ||||
|          */ | ||||
| 
 | ||||
|         it('should generate a csr with auto-generated key', async () => { | ||||
|         it('should generate a csr with default key', async () => { | ||||
|             const [key, csr] = await crypto.createCsr({ | ||||
|                 commonName: testCsrDomain | ||||
|             }); | ||||
|  | @ -281,6 +307,19 @@ describe('crypto', () => { | |||
|         }); | ||||
| 
 | ||||
| 
 | ||||
|         /** | ||||
|          * ALPN | ||||
|          */ | ||||
| 
 | ||||
|         it('should generate alpn certificate with default key', async () => { | ||||
|             const authz = { identifier: { value: 'test.example.com' } }; | ||||
|             const [key, cert] = await crypto.createAlpnCertificate(authz, 'abc123'); | ||||
| 
 | ||||
|             assert.isTrue(Buffer.isBuffer(key)); | ||||
|             assert.isTrue(Buffer.isBuffer(cert)); | ||||
|         }); | ||||
| 
 | ||||
| 
 | ||||
|         /** | ||||
|          * PEM utils | ||||
|          */ | ||||
|  |  | |||
|  | @ -33,6 +33,7 @@ if (capEabEnabled && process.env.ACME_EAB_KID && process.env.ACME_EAB_HMAC_KEY) | |||
| 
 | ||||
| describe('client', () => { | ||||
|     const testDomain = `${uuid()}.${domainName}`; | ||||
|     const testDomainAlpn = `${uuid()}.${domainName}`; | ||||
|     const testDomainWildcard = `*.${testDomain}`; | ||||
|     const testContact = `mailto:test-${uuid()}@nope.com`; | ||||
| 
 | ||||
|  | @ -78,16 +79,22 @@ describe('client', () => { | |||
|             let testAccount; | ||||
|             let testAccountUrl; | ||||
|             let testOrder; | ||||
|             let testOrderAlpn; | ||||
|             let testOrderWildcard; | ||||
|             let testAuthz; | ||||
|             let testAuthzAlpn; | ||||
|             let testAuthzWildcard; | ||||
|             let testChallenge; | ||||
|             let testChallengeAlpn; | ||||
|             let testChallengeWildcard; | ||||
|             let testKeyAuthorization; | ||||
|             let testKeyAuthorizationAlpn; | ||||
|             let testKeyAuthorizationWildcard; | ||||
|             let testCsr; | ||||
|             let testCsrAlpn; | ||||
|             let testCsrWildcard; | ||||
|             let testCertificate; | ||||
|             let testCertificateAlpn; | ||||
|             let testCertificateWildcard; | ||||
| 
 | ||||
| 
 | ||||
|  | @ -107,6 +114,7 @@ describe('client', () => { | |||
| 
 | ||||
|             it('should generate certificate signing request', async () => { | ||||
|                 [, testCsr] = await acme.crypto.createCsr({ commonName: testDomain }, await createKeyFn()); | ||||
|                 [, testCsrAlpn] = await acme.crypto.createCsr({ commonName: testDomainAlpn }, await createKeyFn()); | ||||
|                 [, testCsrWildcard] = await acme.crypto.createCsr({ commonName: testDomainWildcard }, await createKeyFn()); | ||||
|             }); | ||||
| 
 | ||||
|  | @ -336,12 +344,14 @@ describe('client', () => { | |||
| 
 | ||||
|             it('should create new order', async () => { | ||||
|                 const data1 = { identifiers: [{ type: 'dns', value: testDomain }] }; | ||||
|                 const data2 = { identifiers: [{ type: 'dns', value: testDomainWildcard }] }; | ||||
|                 const data2 = { identifiers: [{ type: 'dns', value: testDomainAlpn }] }; | ||||
|                 const data3 = { identifiers: [{ type: 'dns', value: testDomainWildcard }] }; | ||||
| 
 | ||||
|                 testOrder = await testClient.createOrder(data1); | ||||
|                 testOrderWildcard = await testClient.createOrder(data2); | ||||
|                 testOrderAlpn = await testClient.createOrder(data2); | ||||
|                 testOrderWildcard = await testClient.createOrder(data3); | ||||
| 
 | ||||
|                 [testOrder, testOrderWildcard].forEach((item) => { | ||||
|                 [testOrder, testOrderAlpn, testOrderWildcard].forEach((item) => { | ||||
|                     spec.rfc8555.order(item); | ||||
|                     assert.strictEqual(item.status, 'pending'); | ||||
|                 }); | ||||
|  | @ -353,7 +363,7 @@ describe('client', () => { | |||
|              */ | ||||
| 
 | ||||
|             it('should get existing order', async () => { | ||||
|                 await Promise.all([testOrder, testOrderWildcard].map(async (existing) => { | ||||
|                 await Promise.all([testOrder, testOrderAlpn, testOrderWildcard].map(async (existing) => { | ||||
|                     const result = await testClient.getOrder(existing); | ||||
| 
 | ||||
|                     spec.rfc8555.order(result); | ||||
|  | @ -368,9 +378,10 @@ describe('client', () => { | |||
| 
 | ||||
|             it('should get identifier authorization', async () => { | ||||
|                 const orderAuthzCollection = await testClient.getAuthorizations(testOrder); | ||||
|                 const alpnAuthzCollection = await testClient.getAuthorizations(testOrderAlpn); | ||||
|                 const wildcardAuthzCollection = await testClient.getAuthorizations(testOrderWildcard); | ||||
| 
 | ||||
|                 [orderAuthzCollection, wildcardAuthzCollection].forEach((collection) => { | ||||
|                 [orderAuthzCollection, alpnAuthzCollection, wildcardAuthzCollection].forEach((collection) => { | ||||
|                     assert.isArray(collection); | ||||
|                     assert.isNotEmpty(collection); | ||||
| 
 | ||||
|  | @ -381,9 +392,10 @@ describe('client', () => { | |||
|                 }); | ||||
| 
 | ||||
|                 testAuthz = orderAuthzCollection.pop(); | ||||
|                 testAuthzAlpn = alpnAuthzCollection.pop(); | ||||
|                 testAuthzWildcard = wildcardAuthzCollection.pop(); | ||||
| 
 | ||||
|                 testAuthz.challenges.concat(testAuthzWildcard.challenges).forEach((item) => { | ||||
|                 testAuthz.challenges.concat(testAuthzAlpn.challenges).concat(testAuthzWildcard.challenges).forEach((item) => { | ||||
|                     spec.rfc8555.challenge(item); | ||||
|                     assert.strictEqual(item.status, 'pending'); | ||||
|                 }); | ||||
|  | @ -396,12 +408,14 @@ describe('client', () => { | |||
| 
 | ||||
|             it('should get challenge key authorization', async () => { | ||||
|                 testChallenge = testAuthz.challenges.find((c) => (c.type === 'http-01')); | ||||
|                 testChallengeAlpn = testAuthzAlpn.challenges.find((c) => (c.type === 'tls-alpn-01')); | ||||
|                 testChallengeWildcard = testAuthzWildcard.challenges.find((c) => (c.type === 'dns-01')); | ||||
| 
 | ||||
|                 testKeyAuthorization = await testClient.getChallengeKeyAuthorization(testChallenge); | ||||
|                 testKeyAuthorizationAlpn = await testClient.getChallengeKeyAuthorization(testChallengeAlpn); | ||||
|                 testKeyAuthorizationWildcard = await testClient.getChallengeKeyAuthorization(testChallengeWildcard); | ||||
| 
 | ||||
|                 [testKeyAuthorization, testKeyAuthorizationWildcard].forEach((k) => assert.isString(k)); | ||||
|                 [testKeyAuthorization, testKeyAuthorizationAlpn, testKeyAuthorizationWildcard].forEach((k) => assert.isString(k)); | ||||
|             }); | ||||
| 
 | ||||
| 
 | ||||
|  | @ -438,9 +452,11 @@ describe('client', () => { | |||
| 
 | ||||
|             it('should verify challenge', async () => { | ||||
|                 await cts.assertHttpChallengeCreateFn(testAuthz, testChallenge, testKeyAuthorization); | ||||
|                 await cts.assertTlsAlpnChallengeCreateFn(testAuthzAlpn, testChallengeAlpn, testKeyAuthorizationAlpn); | ||||
|                 await cts.assertDnsChallengeCreateFn(testAuthzWildcard, testChallengeWildcard, testKeyAuthorizationWildcard); | ||||
| 
 | ||||
|                 await testClient.verifyChallenge(testAuthz, testChallenge); | ||||
|                 await testClient.verifyChallenge(testAuthzAlpn, testChallengeAlpn); | ||||
|                 await testClient.verifyChallenge(testAuthzWildcard, testChallengeWildcard); | ||||
|             }); | ||||
| 
 | ||||
|  | @ -450,7 +466,7 @@ describe('client', () => { | |||
|              */ | ||||
| 
 | ||||
|             it('should complete challenge', async () => { | ||||
|                 await Promise.all([testChallenge, testChallengeWildcard].map(async (challenge) => { | ||||
|                 await Promise.all([testChallenge, testChallengeAlpn, testChallengeWildcard].map(async (challenge) => { | ||||
|                     const result = await testClient.completeChallenge(challenge); | ||||
| 
 | ||||
|                     spec.rfc8555.challenge(result); | ||||
|  | @ -464,7 +480,7 @@ describe('client', () => { | |||
|              */ | ||||
| 
 | ||||
|             it('should wait for valid challenge status', async () => { | ||||
|                 await Promise.all([testChallenge, testChallengeWildcard].map(async (c) => testClient.waitForValidStatus(c))); | ||||
|                 await Promise.all([testChallenge, testChallengeAlpn, testChallengeWildcard].map(async (c) => testClient.waitForValidStatus(c))); | ||||
|             }); | ||||
| 
 | ||||
| 
 | ||||
|  | @ -474,11 +490,13 @@ describe('client', () => { | |||
| 
 | ||||
|             it('should finalize order', async () => { | ||||
|                 const finalize = await testClient.finalizeOrder(testOrder, testCsr); | ||||
|                 const finalizeAlpn = await testClient.finalizeOrder(testOrderAlpn, testCsrAlpn); | ||||
|                 const finalizeWildcard = await testClient.finalizeOrder(testOrderWildcard, testCsrWildcard); | ||||
| 
 | ||||
|                 [finalize, finalizeWildcard].forEach((f) => spec.rfc8555.order(f)); | ||||
|                 [finalize, finalizeAlpn, finalizeWildcard].forEach((f) => spec.rfc8555.order(f)); | ||||
| 
 | ||||
|                 assert.strictEqual(testOrder.url, finalize.url); | ||||
|                 assert.strictEqual(testOrderAlpn.url, finalizeAlpn.url); | ||||
|                 assert.strictEqual(testOrderWildcard.url, finalizeWildcard.url); | ||||
|             }); | ||||
| 
 | ||||
|  | @ -488,7 +506,7 @@ describe('client', () => { | |||
|              */ | ||||
| 
 | ||||
|             it('should wait for valid order status', async () => { | ||||
|                 await Promise.all([testOrder, testOrderWildcard].map(async (o) => testClient.waitForValidStatus(o))); | ||||
|                 await Promise.all([testOrder, testOrderAlpn, testOrderWildcard].map(async (o) => testClient.waitForValidStatus(o))); | ||||
|             }); | ||||
| 
 | ||||
| 
 | ||||
|  | @ -498,9 +516,10 @@ describe('client', () => { | |||
| 
 | ||||
|             it('should get certificate', async () => { | ||||
|                 testCertificate = await testClient.getCertificate(testOrder); | ||||
|                 testCertificateAlpn = await testClient.getCertificate(testOrderAlpn); | ||||
|                 testCertificateWildcard = await testClient.getCertificate(testOrderWildcard); | ||||
| 
 | ||||
|                 [testCertificate, testCertificateWildcard].forEach((cert) => { | ||||
|                 [testCertificate, testCertificateAlpn, testCertificateWildcard].forEach((cert) => { | ||||
|                     assert.isString(cert); | ||||
|                     acme.crypto.readCertificateInfo(cert); | ||||
|                 }); | ||||
|  | @ -539,11 +558,13 @@ describe('client', () => { | |||
| 
 | ||||
|             it('should revoke certificate', async () => { | ||||
|                 await testClient.revokeCertificate(testCertificate); | ||||
|                 await testClient.revokeCertificate(testCertificateAlpn, { reason: 0 }); | ||||
|                 await testClient.revokeCertificate(testCertificateWildcard, { reason: 4 }); | ||||
|             }); | ||||
| 
 | ||||
|             it('should not allow getting revoked certificate', async () => { | ||||
|                 await assert.isRejected(testClient.getCertificate(testOrder)); | ||||
|                 await assert.isRejected(testClient.getCertificate(testOrderAlpn)); | ||||
|                 await assert.isRejected(testClient.getCertificate(testOrderWildcard)); | ||||
|             }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -34,6 +34,7 @@ describe('client.auto', () => { | |||
|     const testHttpDomain = `${uuid()}.${domainName}`; | ||||
|     const testHttpsDomain = `${uuid()}.${domainName}`; | ||||
|     const testDnsDomain = `${uuid()}.${domainName}`; | ||||
|     const testAlpnDomain = `${uuid()}.${domainName}`; | ||||
|     const testWildcardDomain = `${uuid()}.${domainName}`; | ||||
| 
 | ||||
|     const testSanDomains = [ | ||||
|  | @ -280,6 +281,22 @@ describe('client.auto', () => { | |||
|                 assert.isString(cert); | ||||
|             }); | ||||
| 
 | ||||
|             it('should order certificate using tls-alpn-01', async () => { | ||||
|                 const [, csr] = await acme.crypto.createCsr({ | ||||
|                     commonName: testAlpnDomain | ||||
|                 }, await createKeyFn()); | ||||
| 
 | ||||
|                 const cert = await testClient.auto({ | ||||
|                     csr, | ||||
|                     termsOfServiceAgreed: true, | ||||
|                     challengeCreateFn: cts.assertTlsAlpnChallengeCreateFn, | ||||
|                     challengeRemoveFn: cts.challengeRemoveFn, | ||||
|                     challengePriority: ['tls-alpn-01'] | ||||
|                 }); | ||||
| 
 | ||||
|                 assert.isString(cert); | ||||
|             }); | ||||
| 
 | ||||
|             it('should order san certificate', async () => { | ||||
|                 const [, csr] = await acme.crypto.createCsr({ | ||||
|                     commonName: testSanDomains[0], | ||||
|  |  | |||
|  | @ -63,9 +63,14 @@ async function addDns01ChallengeResponse(host, value) { | |||
|     return request('set-txt', { host, value }); | ||||
| } | ||||
| 
 | ||||
| async function addTlsAlpn01ChallengeResponse(host, content) { | ||||
|     return request('add-tlsalpn01', { host, content }); | ||||
| } | ||||
| 
 | ||||
| exports.addHttp01ChallengeResponse = addHttp01ChallengeResponse; | ||||
| exports.addHttps01ChallengeResponse = addHttps01ChallengeResponse; | ||||
| exports.addDns01ChallengeResponse = addDns01ChallengeResponse; | ||||
| exports.addTlsAlpn01ChallengeResponse = addTlsAlpn01ChallengeResponse; | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  | @ -87,6 +92,11 @@ async function assertDnsChallengeCreateFn(authz, challenge, keyAuthorization) { | |||
|     return addDns01ChallengeResponse(`_acme-challenge.${authz.identifier.value}.`, keyAuthorization); | ||||
| } | ||||
| 
 | ||||
| async function assertTlsAlpnChallengeCreateFn(authz, challenge, keyAuthorization) { | ||||
|     assert.strictEqual(challenge.type, 'tls-alpn-01'); | ||||
|     return addTlsAlpn01ChallengeResponse(authz.identifier.value, keyAuthorization); | ||||
| } | ||||
| 
 | ||||
| async function challengeCreateFn(authz, challenge, keyAuthorization) { | ||||
|     if (challenge.type === 'http-01') { | ||||
|         return assertHttpChallengeCreateFn(authz, challenge, keyAuthorization); | ||||
|  | @ -96,6 +106,10 @@ async function challengeCreateFn(authz, challenge, keyAuthorization) { | |||
|         return assertDnsChallengeCreateFn(authz, challenge, keyAuthorization); | ||||
|     } | ||||
| 
 | ||||
|     if (challenge.type === 'tls-alpn-01') { | ||||
|         return assertTlsAlpnChallengeCreateFn(authz, challenge, keyAuthorization); | ||||
|     } | ||||
| 
 | ||||
|     throw new Error(`Unsupported challenge type ${challenge.type}`); | ||||
| } | ||||
| 
 | ||||
|  | @ -106,4 +120,5 @@ exports.challengeThrowFn = async () => { throw new Error('oops'); }; | |||
| exports.assertHttpChallengeCreateFn = assertHttpChallengeCreateFn; | ||||
| exports.assertHttpsChallengeCreateFn = assertHttpsChallengeCreateFn; | ||||
| exports.assertDnsChallengeCreateFn = assertDnsChallengeCreateFn; | ||||
| exports.assertTlsAlpnChallengeCreateFn = assertTlsAlpnChallengeCreateFn; | ||||
| exports.challengeCreateFn = challengeCreateFn; | ||||
|  |  | |||
|  | @ -27,6 +27,10 @@ if (process.env.ACME_HTTPS_PORT) { | |||
|     axios.defaults.acmeSettings.httpsChallengePort = process.env.ACME_HTTPS_PORT; | ||||
| } | ||||
| 
 | ||||
| if (process.env.ACME_TLSALPN_PORT) { | ||||
|     axios.defaults.acmeSettings.tlsAlpnChallengePort = process.env.ACME_TLSALPN_PORT; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  * External account binding | ||||
|  |  | |||
|  | @ -156,6 +156,8 @@ export interface CryptoInterface { | |||
|     readCsrDomains(csrPem: CsrBuffer | CsrString): CertificateDomains; | ||||
|     readCertificateInfo(certPem: CertificateBuffer | CertificateString): CertificateInfo; | ||||
|     createCsr(data: CsrOptions, keyPem?: PrivateKeyBuffer | PrivateKeyString): Promise<[PrivateKeyBuffer, CsrBuffer]>; | ||||
|     createAlpnCertificate(authz: Authorization, keyAuthorization: string, keyPem?: PrivateKeyBuffer | PrivateKeyString): Promise<[PrivateKeyBuffer, CertificateBuffer]>; | ||||
|     isAlpnCertificateAuthorizationValid(certPem: CertificateBuffer | CertificateString, keyAuthorization: string): boolean; | ||||
| } | ||||
| 
 | ||||
| export const crypto: CryptoInterface; | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	 GitHub Actions Bot
						GitHub Actions Bot