diff --git a/packages/core/acme-client/CHANGELOG.md b/packages/core/acme-client/CHANGELOG.md index a281ba52..6fc2a30b 100644 --- a/packages/core/acme-client/CHANGELOG.md +++ b/packages/core/acme-client/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## v5.3.0 +## v5.3.0 (2024-02-05) * `added` Support and tests for satisfying `tls-alpn-01` challenges * `changed` Replace `jsrsasign` with `@peculiar/x509` for certificate and CSR generation and parsing diff --git a/packages/core/acme-client/docs/client.md b/packages/core/acme-client/docs/client.md index 65dc325a..f5f29d22 100644 --- a/packages/core/acme-client/docs/client.md +++ b/packages/core/acme-client/docs/client.md @@ -132,7 +132,7 @@ catch (e) { ### acmeClient.createAccount([data]) ⇒ Promise.<object> Create a new account -https://tools.ietf.org/html/rfc8555#section-7.3 +https://datatracker.ietf.org/doc/html/rfc8555#section-7.3 **Kind**: instance method of [AcmeClient](#AcmeClient) **Returns**: Promise.<object> - Account @@ -161,7 +161,7 @@ const account = await client.createAccount({ ### acmeClient.updateAccount([data]) ⇒ Promise.<object> Update existing account -https://tools.ietf.org/html/rfc8555#section-7.3.2 +https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.2 **Kind**: instance method of [AcmeClient](#AcmeClient) **Returns**: Promise.<object> - Account @@ -182,7 +182,7 @@ const account = await client.updateAccount({ ### acmeClient.updateAccountKey(newAccountKey, [data]) ⇒ Promise.<object> Update account private key -https://tools.ietf.org/html/rfc8555#section-7.3.5 +https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.5 **Kind**: instance method of [AcmeClient](#AcmeClient) **Returns**: Promise.<object> - Account @@ -203,7 +203,7 @@ const result = await client.updateAccountKey(newAccountKey); ### acmeClient.createOrder(data) ⇒ Promise.<object> Create a new order -https://tools.ietf.org/html/rfc8555#section-7.4 +https://datatracker.ietf.org/doc/html/rfc8555#section-7.4 **Kind**: instance method of [AcmeClient](#AcmeClient) **Returns**: Promise.<object> - Order @@ -227,7 +227,7 @@ const order = await client.createOrder({ ### acmeClient.getOrder(order) ⇒ Promise.<object> Refresh order object from CA -https://tools.ietf.org/html/rfc8555#section-7.4 +https://datatracker.ietf.org/doc/html/rfc8555#section-7.4 **Kind**: instance method of [AcmeClient](#AcmeClient) **Returns**: Promise.<object> - Order @@ -246,7 +246,7 @@ const result = await client.getOrder(order); ### acmeClient.finalizeOrder(order, csr) ⇒ Promise.<object> Finalize order -https://tools.ietf.org/html/rfc8555#section-7.4 +https://datatracker.ietf.org/doc/html/rfc8555#section-7.4 **Kind**: instance method of [AcmeClient](#AcmeClient) **Returns**: Promise.<object> - Order @@ -268,7 +268,7 @@ const result = await client.finalizeOrder(order, csr); ### acmeClient.getAuthorizations(order) ⇒ Promise.<Array.<object>> Get identifier authorizations from order -https://tools.ietf.org/html/rfc8555#section-7.5 +https://datatracker.ietf.org/doc/html/rfc8555#section-7.5 **Kind**: instance method of [AcmeClient](#AcmeClient) **Returns**: Promise.<Array.<object>> - Authorizations @@ -292,7 +292,7 @@ authorizations.forEach((authz) => { ### acmeClient.deactivateAuthorization(authz) ⇒ Promise.<object> Deactivate identifier authorization -https://tools.ietf.org/html/rfc8555#section-7.5.2 +https://datatracker.ietf.org/doc/html/rfc8555#section-7.5.2 **Kind**: instance method of [AcmeClient](#AcmeClient) **Returns**: Promise.<object> - Authorization @@ -312,7 +312,7 @@ const result = await client.deactivateAuthorization(authz); ### acmeClient.getChallengeKeyAuthorization(challenge) ⇒ Promise.<string> Get key authorization for ACME challenge -https://tools.ietf.org/html/rfc8555#section-8.1 +https://datatracker.ietf.org/doc/html/rfc8555#section-8.1 **Kind**: instance method of [AcmeClient](#AcmeClient) **Returns**: Promise.<string> - Key authorization @@ -353,7 +353,7 @@ await client.verifyChallenge(authz, challenge); ### acmeClient.completeChallenge(challenge) ⇒ Promise.<object> Notify CA that challenge has been completed -https://tools.ietf.org/html/rfc8555#section-7.5.1 +https://datatracker.ietf.org/doc/html/rfc8555#section-7.5.1 **Kind**: instance method of [AcmeClient](#AcmeClient) **Returns**: Promise.<object> - Challenge @@ -373,7 +373,7 @@ const result = await client.completeChallenge(challenge); ### acmeClient.waitForValidStatus(item) ⇒ Promise.<object> Wait for ACME provider to verify status on a order, authorization or challenge -https://tools.ietf.org/html/rfc8555#section-7.5.1 +https://datatracker.ietf.org/doc/html/rfc8555#section-7.5.1 **Kind**: instance method of [AcmeClient](#AcmeClient) **Returns**: Promise.<object> - Valid order, authorization or challenge @@ -389,7 +389,7 @@ const challenge = { ... }; await client.waitForValidStatus(challenge); ``` **Example** -Wait for valid authoriation status +Wait for valid authorization status ```js const authz = { ... }; await client.waitForValidStatus(authz); @@ -405,7 +405,7 @@ await client.waitForValidStatus(order); ### acmeClient.getCertificate(order, [preferredChain]) ⇒ Promise.<string> Get certificate from ACME order -https://tools.ietf.org/html/rfc8555#section-7.4.2 +https://datatracker.ietf.org/doc/html/rfc8555#section-7.4.2 **Kind**: instance method of [AcmeClient](#AcmeClient) **Returns**: Promise.<string> - Certificate @@ -432,7 +432,7 @@ const certificate = await client.getCertificate(order, 'DST Root CA X3'); ### acmeClient.revokeCertificate(cert, [data]) ⇒ Promise Revoke certificate -https://tools.ietf.org/html/rfc8555#section-7.6 +https://datatracker.ietf.org/doc/html/rfc8555#section-7.6 **Kind**: instance method of [AcmeClient](#AcmeClient) diff --git a/packages/core/acme-client/docs/crypto.md b/packages/core/acme-client/docs/crypto.md index 1391263c..84660f68 100644 --- a/packages/core/acme-client/docs/crypto.md +++ b/packages/core/acme-client/docs/crypto.md @@ -25,7 +25,7 @@

Get a JSON Web Key derived from a RSA or ECDSA key

https://datatracker.ietf.org/doc/html/rfc7517

-
splitPemChain(chainPem)array
+
splitPemChain(chainPem)Array.<string>

Split chain of PEM encoded objects from string into array

getPemBodyAsB64u(pem)string
@@ -42,6 +42,13 @@ If multiple certificates are chained, the first will be read

createCsr(data, [keyPem])Promise.<Array.<buffer>>

Create a Certificate Signing Request

+
createAlpnCertificate(authz, keyAuthorization, [keyPem])Promise.<Array.<buffer>>
+

Create a self-signed ALPN certificate for TLS-ALPN-01 challenges

+

https://datatracker.ietf.org/doc/html/rfc8737

+
+
isAlpnCertificateAuthorizationValid(certPem, keyAuthorization)boolean
+

Validate that a ALPN certificate contains the expected key authorization

+
@@ -138,11 +145,11 @@ const jwk = acme.crypto.getJwk(privateKey); ``` -## splitPemChain(chainPem) ⇒ array +## splitPemChain(chainPem) ⇒ Array.<string> Split chain of PEM encoded objects from string into array **Kind**: global function -**Returns**: array - Array of PEM objects including headers +**Returns**: Array.<string> - Array of PEM objects including headers | Param | Type | Description | | --- | --- | --- | @@ -219,14 +226,14 @@ Create a Certificate Signing Request | data | object | | | [data.keySize] | number | Size of newly created RSA private key modulus in bits, default: `2048` | | [data.commonName] | string | FQDN of your server | -| [data.altNames] | array | SAN (Subject Alternative Names), default: `[]` | +| [data.altNames] | Array.<string> | SAN (Subject Alternative Names), default: `[]` | | [data.country] | string | 2 letter country code | | [data.state] | string | State or province | | [data.locality] | string | City | | [data.organization] | string | Organization name | | [data.organizationUnit] | string | Organizational unit name | | [data.emailAddress] | string | Email address | -| [keyPem] | string | PEM encoded CSR private key | +| [keyPem] | buffer \| string | PEM encoded CSR private key | **Example** Create a Certificate Signing Request @@ -265,3 +272,42 @@ const certificateKey = await acme.crypto.createPrivateEcdsaKey(); const [, certificateRequest] = await acme.crypto.createCsr({ commonName: 'test.example.com' }, certificateKey); + + +## createAlpnCertificate(authz, keyAuthorization, [keyPem]) ⇒ Promise.<Array.<buffer>> +Create a self-signed ALPN certificate for TLS-ALPN-01 challenges + +https://datatracker.ietf.org/doc/html/rfc8737 + +**Kind**: global function +**Returns**: Promise.<Array.<buffer>> - [privateKey, certificate] + +| Param | Type | Description | +| --- | --- | --- | +| authz | object | Identifier authorization | +| keyAuthorization | string | Challenge key authorization | +| [keyPem] | buffer \| string | PEM encoded CSR private key | + +**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); + + +## isAlpnCertificateAuthorizationValid(certPem, keyAuthorization) ⇒ boolean +Validate that a ALPN certificate contains the expected key authorization + +**Kind**: global function +**Returns**: boolean - True when valid + +| Param | Type | Description | +| --- | --- | --- | +| certPem | buffer \| string | PEM encoded certificate | +| keyAuthorization | string | Expected challenge key authorization | + diff --git a/packages/core/acme-client/docs/forge.md b/packages/core/acme-client/docs/forge.md index 2d7601fd..09a44de1 100644 --- a/packages/core/acme-client/docs/forge.md +++ b/packages/core/acme-client/docs/forge.md @@ -209,7 +209,7 @@ Create a Certificate Signing Request | data | object | | | [data.keySize] | number | Size of newly created private key, default: `2048` | | [data.commonName] | string | | -| [data.altNames] | array | default: `[]` | +| [data.altNames] | Array.<string> | default: `[]` | | [data.country] | string | | | [data.state] | string | | | [data.locality] | string | | diff --git a/packages/core/acme-client/examples/dns-01/README.md b/packages/core/acme-client/examples/dns-01/README.md new file mode 100644 index 00000000..4b55dc4c --- /dev/null +++ b/packages/core/acme-client/examples/dns-01/README.md @@ -0,0 +1,21 @@ +# dns-01 + +The greatest benefit of `dns-01` is that it is the only challenge type that can be used to issue ACME wildcard certificates, however it also has a few downsides. Your DNS provider needs to offer some sort of API you can use to automate adding and removing the required `TXT` DNS records. Additionally, solving DNS challenges will be much slower than the other challenge types because of DNS propagation delays. + +## How it works + +When solving `dns-01` challenges, you prove ownership of a domain by serving a specific payload within a specific DNS `TXT` record from the domains authoritative nameservers. The ACME authority provides the client with a token that, along with a thumbprint of your account key, is used to generate a `base64url` encoded `SHA256` digest. This payload is then placed as a `TXT` record under DNS name `_acme-challenge.$YOUR_DOMAIN`. + +Once the order is finalized, the ACME authority will lookup your domains DNS record to verify that the payload is correct. `CNAME` and `NS` records are followed, should you wish to delegate challenge response to another DNS zone or record. + +## Pros and cons + +* Only challenge type that can be used to issue wildcard certificates +* Your DNS provider needs to supply an API that can be used +* DNS propagation time may be slow +* Useful in instances where both port 80 and 443 are unavailable + +## External links + +* [https://letsencrypt.org/docs/challenge-types/#dns-01-challenge](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge) +* [https://datatracker.ietf.org/doc/html/rfc8555#section-8.4](https://datatracker.ietf.org/doc/html/rfc8555#section-8.4) diff --git a/packages/core/acme-client/examples/dns-01/dns-01.js b/packages/core/acme-client/examples/dns-01/dns-01.js new file mode 100644 index 00000000..68bed46e --- /dev/null +++ b/packages/core/acme-client/examples/dns-01/dns-01.js @@ -0,0 +1,92 @@ +/** + * Example using dns-01 challenge to generate certificates + * + * NOTE: This example is incomplete as the DNS challenge response implementation + * will be specific to your DNS providers API. + * + * NOTE: This example does not order certificates on-demand, as solving dns-01 + * will likely be too slow for it to make sense. Instead, it orders a wildcard + * certificate on init before starting the HTTPS server as a demonstration. + */ + +const https = require('https'); +const acme = require('./../../'); + +const HTTPS_SERVER_PORT = 443; +const WILDCARD_DOMAIN = 'example.com'; + +function log(m) { + process.stdout.write(`${(new Date()).toISOString()} ${m}\n`); +} + + +/** + * Main + */ + +(async () => { + try { + /** + * Initialize ACME client + */ + + log('Initializing ACME client'); + const client = new acme.Client({ + directoryUrl: acme.directory.letsencrypt.staging, + accountKey: await acme.crypto.createPrivateKey() + }); + + + /** + * Order wildcard certificate + */ + + log(`Creating CSR for ${WILDCARD_DOMAIN}`); + const [key, csr] = await acme.crypto.createCsr({ + commonName: WILDCARD_DOMAIN, + altNames: [`*.${WILDCARD_DOMAIN}`] + }); + + log(`Ordering certificate for ${WILDCARD_DOMAIN}`); + const cert = await client.auto({ + csr, + email: 'test@example.com', + termsOfServiceAgreed: true, + challengePriority: ['dns-01'], + challengeCreateFn: (authz, challenge, keyAuthorization) => { + /* TODO: Implement this */ + log(`[TODO] Add TXT record key=_acme-challenge.${authz.identifier.value} value=${keyAuthorization}`); + }, + challengeRemoveFn: (authz, challenge, keyAuthorization) => { + /* TODO: Implement this */ + log(`[TODO] Remove TXT record key=_acme-challenge.${authz.identifier.value} value=${keyAuthorization}`); + } + }); + + log(`Certificate for ${WILDCARD_DOMAIN} created successfully`); + + + /** + * HTTPS server + */ + + const requestListener = (req, res) => { + log(`HTTP 200 ${req.headers.host}${req.url}`); + res.writeHead(200); + res.end('Hello world\n'); + }; + + const httpsServer = https.createServer({ + key, + cert + }, requestListener); + + httpsServer.listen(HTTPS_SERVER_PORT, () => { + log(`HTTPS server listening on port ${HTTPS_SERVER_PORT}`); + }); + } + catch (e) { + log(`[FATAL] ${e.message}`); + process.exit(1); + } +})(); diff --git a/packages/core/acme-client/package.json b/packages/core/acme-client/package.json index 3cf35551..a4aecc68 100644 --- a/packages/core/acme-client/package.json +++ b/packages/core/acme-client/package.json @@ -2,7 +2,7 @@ "name": "acme-client", "description": "Simple and unopinionated ACME client", "author": "nmorsman", - "version": "5.2.0", + "version": "5.3.0", "main": "src/index.js", "types": "types/index.d.ts", "license": "MIT",