From fc9e71bed29bd0159e667853fb0068e65cfc55b8 Mon Sep 17 00:00:00 2001 From: GitHub Actions Bot Date: Tue, 30 Jan 2024 19:24:20 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=B1:=20[acme]=20sync=20upgrade=20with?= =?UTF-8?q?=207=20commits=20[trident-sync]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 methods --- packages/core/acme-client/CHANGELOG.md | 7 + packages/core/acme-client/src/axios.js | 3 +- packages/core/acme-client/src/client.js | 19 ++- packages/core/acme-client/src/crypto/index.js | 132 ++++++++++++++++-- packages/core/acme-client/src/util.js | 58 +++++++- packages/core/acme-client/src/verify.js | 32 ++++- .../core/acme-client/test/00-pebble.spec.js | 31 ++++ .../core/acme-client/test/10-verify.spec.js | 25 ++++ .../core/acme-client/test/20-crypto.spec.js | 41 +++++- .../core/acme-client/test/50-client.spec.js | 45 ++++-- .../core/acme-client/test/70-auto.spec.js | 17 +++ .../core/acme-client/test/challtestsrv.js | 15 ++ packages/core/acme-client/test/setup.js | 4 + packages/core/acme-client/types/index.d.ts | 2 + 14 files changed, 389 insertions(+), 42 deletions(-) diff --git a/packages/core/acme-client/CHANGELOG.md b/packages/core/acme-client/CHANGELOG.md index fcfa31f7..894dac23 100644 --- a/packages/core/acme-client/CHANGELOG.md +++ b/packages/core/acme-client/CHANGELOG.md @@ -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) diff --git a/packages/core/acme-client/src/axios.js b/packages/core/acme-client/src/axios.js index c66d3258..83c632ce 100644 --- a/packages/core/acme-client/src/axios.js +++ b/packages/core/acme-client/src/axios.js @@ -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 }; diff --git a/packages/core/acme-client/src/client.js b/packages/core/acme-client/src/client.js index 6547522e..2d3fa89c 100644 --- a/packages/core/acme-client/src/client.js +++ b/packages/core/acme-client/src/client.js @@ -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}`); diff --git a/packages/core/acme-client/src/crypto/index.js b/packages/core/acme-client/src/crypto/index.js index 582b2d11..d7a13b7d 100644 --- a/packages/core/acme-client/src/crypto/index.js +++ b/packages/core/acme-client/src/crypto/index.js @@ -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} [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); }; diff --git a/packages/core/acme-client/src/util.js b/packages/core/acme-client/src/util.js index 95d08e81..73ecdd3b 100644 --- a/packages/core/acme-client/src/util.js +++ b/packages/core/acme-client/src/util.js @@ -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} 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 }; diff --git a/packages/core/acme-client/src/verify.js b/packages/core/acme-client/src/verify.js index 5c9da2df..2ab95e8f 100644 --- a/packages/core/acme-client/src/verify.js +++ b/packages/core/acme-client/src/verify.js @@ -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} + */ + +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 }; diff --git a/packages/core/acme-client/test/00-pebble.spec.js b/packages/core/acme-client/test/00-pebble.spec.js index 70376c9a..7a26c3b7 100644 --- a/packages/core/acme-client/test/00-pebble.spec.js +++ b/packages/core/acme-client/test/00-pebble.spec.js @@ -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)); + }); + }); }); diff --git a/packages/core/acme-client/test/10-verify.spec.js b/packages/core/acme-client/test/10-verify.spec.js index 55e98c54..284cc3a0 100644 --- a/packages/core/acme-client/test/10-verify.spec.js +++ b/packages/core/acme-client/test/10-verify.spec.js @@ -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); + }); + }); }); diff --git a/packages/core/acme-client/test/20-crypto.spec.js b/packages/core/acme-client/test/20-crypto.spec.js index 8441af55..cbcd9f39 100644 --- a/packages/core/acme-client/test/20-crypto.spec.js +++ b/packages/core/acme-client/test/20-crypto.spec.js @@ -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 */ diff --git a/packages/core/acme-client/test/50-client.spec.js b/packages/core/acme-client/test/50-client.spec.js index 4d61ce84..31784ca3 100644 --- a/packages/core/acme-client/test/50-client.spec.js +++ b/packages/core/acme-client/test/50-client.spec.js @@ -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)); }); diff --git a/packages/core/acme-client/test/70-auto.spec.js b/packages/core/acme-client/test/70-auto.spec.js index eb80c483..9d5742c9 100644 --- a/packages/core/acme-client/test/70-auto.spec.js +++ b/packages/core/acme-client/test/70-auto.spec.js @@ -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], diff --git a/packages/core/acme-client/test/challtestsrv.js b/packages/core/acme-client/test/challtestsrv.js index c3013365..71ed91b6 100644 --- a/packages/core/acme-client/test/challtestsrv.js +++ b/packages/core/acme-client/test/challtestsrv.js @@ -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; diff --git a/packages/core/acme-client/test/setup.js b/packages/core/acme-client/test/setup.js index a09f2801..4e0f5bee 100644 --- a/packages/core/acme-client/test/setup.js +++ b/packages/core/acme-client/test/setup.js @@ -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 diff --git a/packages/core/acme-client/types/index.d.ts b/packages/core/acme-client/types/index.d.ts index 1e3aa2ef..6fe2a328 100644 --- a/packages/core/acme-client/types/index.d.ts +++ b/packages/core/acme-client/types/index.d.ts @@ -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;