diff --git a/packages/core/acme-client/.circleci/.gitignore b/packages/core/acme-client/.circleci/.gitignore deleted file mode 100644 index 0ae14133..00000000 --- a/packages/core/acme-client/.circleci/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.temp.yml diff --git a/packages/core/acme-client/.circleci/config.yml b/packages/core/acme-client/.circleci/config.yml deleted file mode 100644 index db3ef431..00000000 --- a/packages/core/acme-client/.circleci/config.yml +++ /dev/null @@ -1,133 +0,0 @@ ---- -version: 2.1 - -commands: - pre: - steps: - - run: node --version - - run: npm --version - - run: yarn --version - - checkout - - enable-eab: - steps: - - run: - name: Enable EAB through environment - command: | - echo 'export ACME_CAP_EAB_ENABLED=1' >> $BASH_ENV - - install-cts: - steps: - - run: - name: Install Pebble Challenge Test Server - command: sudo -E /bin/bash ./scripts/test-suite-install-cts.sh - environment: - PEBBLECTS_VERSION: 2.3.1 - - - run: - name: Start Pebble Challenge Test Server - command: pebble-challtestsrv -dns01 ":8053" -tlsalpn01 ":5001" -http01 ":5002" -https01 ":5003" -defaultIPv4 "127.0.0.1" -defaultIPv6 "" - background: true - - install-pebble: - steps: - - run: - name: Install Pebble - command: sudo -E /bin/bash ./scripts/test-suite-install-pebble.sh - environment: - PEBBLE_VERSION: 2.3.1 - - - run: - name: Start Pebble - command: pebble -strict -config /etc/pebble/pebble.json -dnsserver "127.0.0.1:53" - background: true - environment: - PEBBLE_ALTERNATE_ROOTS: 2 - - - run: - name: Set up environment - command: | - echo 'export NODE_EXTRA_CA_CERTS="/etc/pebble/ca.cert.pem"' >> $BASH_ENV - echo 'export ACME_CA_CERT_PATH="/etc/pebble/ca.cert.pem"' >> $BASH_ENV - echo 'export ACME_DIRECTORY_URL="https://127.0.0.1:14000/dir"' >> $BASH_ENV - echo 'export ACME_PEBBLE_MANAGEMENT_URL="https://127.0.0.1:15000"' >> $BASH_ENV - - - run: - name: Wait for Pebble - command: /bin/bash ./scripts/test-suite-wait-for-ca.sh - - install-step: - steps: - - run: - name: Install Step Certificates - command: /bin/bash ./scripts/test-suite-install-step.sh - environment: - STEPCA_VERSION: 0.18.0 - STEPCLI_VERSION: 0.18.0 - - - run: - name: Start Step CA - command: /usr/bin/step-ca --resolver="127.0.0.1:53" --password-file="/tmp/password" ~/.step/config/ca.json - background: true - - - run: - name: Set up environment - command: | - echo 'export NODE_EXTRA_CA_CERTS="/home/circleci/.step/certs/root_ca.crt"' >> $BASH_ENV - echo 'export ACME_CA_CERT_PATH="/home/circleci/.step/certs/root_ca.crt"' >> $BASH_ENV - echo 'export ACME_DIRECTORY_URL="https://localhost:8443/acme/acme/directory"' >> $BASH_ENV - - echo 'export ACME_CAP_META_TOS_FIELD=0' >> $BASH_ENV - echo 'export ACME_CAP_UPDATE_ACCOUNT_KEY=0' >> $BASH_ENV - echo 'export ACME_CAP_ALTERNATE_CERT_ROOTS=0' >> $BASH_ENV - - - run: - name: Wait for Step CA - command: /bin/bash ./scripts/test-suite-wait-for-ca.sh - - install-coredns: - steps: - - run: - name: Install CoreDNS - command: sudo -E /bin/bash ./scripts/test-suite-install-coredns.sh - environment: - COREDNS_VERSION: 1.8.6 - PEBBLECTS_DNS_PORT: 8053 - - - run: - name: Start CoreDNS - command: sudo coredns -p 53 -conf /etc/coredns/Corefile - background: true - - test: - steps: - - run: yarn --color - - run: yarn run lint --color - - run: yarn run lint-types - - run: yarn run build-docs - - - run: - command: yarn run test --color - environment: - ACME_DOMAIN_NAME: test.example.com - ACME_CHALLTESTSRV_URL: http://127.0.0.1:8055 - ACME_DNS_RESOLVER: 127.0.0.1 - ACME_TLSALPN_PORT: 5001 - ACME_HTTP_PORT: 5002 - ACME_HTTPS_PORT: 5003 - -jobs: - v16: { docker: [{ image: cimg/node:16.16 }], steps: [ pre, install-cts, install-pebble, install-coredns, test ]} - v18: { docker: [{ image: cimg/node:18.4 }], steps: [ pre, install-cts, install-pebble, install-coredns, test ]} - eab-v16: { docker: [{ image: cimg/node:16.16 }], steps: [ pre, enable-eab, install-cts, install-pebble, install-coredns, test ]} - eab-v18: { docker: [{ image: cimg/node:18.4 }], steps: [ pre, enable-eab, install-cts, install-pebble, install-coredns, test ]} - # step-v12: { docker: [{ image: cimg/node:12.22 }], steps: [ pre, install-cts, install-step, install-coredns, test ]} - -workflows: - test-suite: - jobs: - - v16 - - v18 - - eab-v16 - - eab-v18 - # - step-v12 diff --git a/packages/core/acme-client/.editorconfig b/packages/core/acme-client/.editorconfig index f95adc8f..7660a90c 100644 --- a/packages/core/acme-client/.editorconfig +++ b/packages/core/acme-client/.editorconfig @@ -5,7 +5,7 @@ root = true [*] -indent_style = spaces +indent_style = space indent_size = 4 trim_trailing_whitespace = true diff --git a/packages/core/acme-client/scripts/test-suite-install-coredns.sh b/packages/core/acme-client/.github/scripts/tests-install-coredns.sh similarity index 93% rename from packages/core/acme-client/scripts/test-suite-install-coredns.sh rename to packages/core/acme-client/.github/scripts/tests-install-coredns.sh index c4876beb..2dfc4ccb 100644 --- a/packages/core/acme-client/scripts/test-suite-install-coredns.sh +++ b/packages/core/acme-client/.github/scripts/tests-install-coredns.sh @@ -2,7 +2,7 @@ # # Install CoreDNS for testing. # -set -eu +set -euo pipefail # Download and install wget -nv "https://github.com/coredns/coredns/releases/download/v${COREDNS_VERSION}/coredns_${COREDNS_VERSION}_linux_amd64.tgz" -O /tmp/coredns.tgz @@ -39,18 +39,21 @@ tee /etc/coredns/Corefile << EOF example.com { errors log + bind 127.53.53.53 file /etc/coredns/db.example.com } test.example.com { errors log + bind 127.53.53.53 forward . 127.0.0.1:${PEBBLECTS_DNS_PORT} } . { errors log + bind 127.53.53.53 forward . 8.8.8.8 } EOF diff --git a/packages/core/acme-client/scripts/test-suite-install-cts.sh b/packages/core/acme-client/.github/scripts/tests-install-cts.sh similarity index 95% rename from packages/core/acme-client/scripts/test-suite-install-cts.sh rename to packages/core/acme-client/.github/scripts/tests-install-cts.sh index ab9a85c1..ac929c8b 100644 --- a/packages/core/acme-client/scripts/test-suite-install-cts.sh +++ b/packages/core/acme-client/.github/scripts/tests-install-cts.sh @@ -2,7 +2,7 @@ # # Install Pebble Challenge Test Server for testing. # -set -eu +set -euo pipefail # Download and install wget -nv "https://github.com/letsencrypt/pebble/releases/download/v${PEBBLECTS_VERSION}/pebble-challtestsrv_linux-amd64" -O /usr/local/bin/pebble-challtestsrv diff --git a/packages/core/acme-client/scripts/test-suite-install-pebble.sh b/packages/core/acme-client/.github/scripts/tests-install-pebble.sh similarity index 78% rename from packages/core/acme-client/scripts/test-suite-install-pebble.sh rename to packages/core/acme-client/.github/scripts/tests-install-pebble.sh index 9378250c..4b830e6d 100644 --- a/packages/core/acme-client/scripts/test-suite-install-pebble.sh +++ b/packages/core/acme-client/.github/scripts/tests-install-pebble.sh @@ -2,14 +2,14 @@ # # Install Pebble for testing. # -set -eu +set -euo pipefail -config_name="pebble-config.json" +CONFIG_NAME="pebble-config.json" # Use Pebble EAB config if enabled set +u -if [[ ! -z $ACME_CAP_EAB_ENABLED ]] && [[ $ACME_CAP_EAB_ENABLED -eq 1 ]]; then - config_name="pebble-config-external-account-bindings.json" +if [[ -n $ACME_CAP_EAB_ENABLED ]] && [[ $ACME_CAP_EAB_ENABLED -eq 1 ]]; then + CONFIG_NAME="pebble-config-external-account-bindings.json" fi set -u @@ -19,7 +19,7 @@ mkdir -p /etc/pebble wget -nv "https://raw.githubusercontent.com/letsencrypt/pebble/v${PEBBLE_VERSION}/test/certs/pebble.minica.pem" -O /etc/pebble/ca.cert.pem wget -nv "https://raw.githubusercontent.com/letsencrypt/pebble/v${PEBBLE_VERSION}/test/certs/localhost/cert.pem" -O /etc/pebble/cert.pem wget -nv "https://raw.githubusercontent.com/letsencrypt/pebble/v${PEBBLE_VERSION}/test/certs/localhost/key.pem" -O /etc/pebble/key.pem -wget -nv "https://raw.githubusercontent.com/letsencrypt/pebble/v${PEBBLE_VERSION}/test/config/${config_name}" -O /etc/pebble/pebble.json +wget -nv "https://raw.githubusercontent.com/letsencrypt/pebble/v${PEBBLE_VERSION}/test/config/${CONFIG_NAME}" -O /etc/pebble/pebble.json # Download and install Pebble wget -nv "https://github.com/letsencrypt/pebble/releases/download/v${PEBBLE_VERSION}/pebble_linux-amd64" -O /usr/local/bin/pebble diff --git a/packages/core/acme-client/scripts/test-suite-wait-for-ca.sh b/packages/core/acme-client/.github/scripts/tests-wait-for-ca.sh similarity index 79% rename from packages/core/acme-client/scripts/test-suite-wait-for-ca.sh rename to packages/core/acme-client/.github/scripts/tests-wait-for-ca.sh index a35cf6a1..b1f39e31 100644 --- a/packages/core/acme-client/scripts/test-suite-wait-for-ca.sh +++ b/packages/core/acme-client/.github/scripts/tests-wait-for-ca.sh @@ -2,13 +2,13 @@ # # Wait for ACME server to accept connections. # -set -eu +set -euo pipefail MAX_ATTEMPTS=15 ATTEMPT=0 # Loop until ready -while ! $(curl --cacert "${ACME_CA_CERT_PATH}" -s -D - "${ACME_DIRECTORY_URL}" | grep '^HTTP.*200' > /dev/null 2>&1); do +while ! curl --cacert "${ACME_CA_CERT_PATH}" -s -D - "${ACME_DIRECTORY_URL}" | grep '^HTTP.*200' > /dev/null 2>&1; do ATTEMPT=$((ATTEMPT + 1)) # Max attempts diff --git a/packages/core/acme-client/.github/workflows/tests.yml b/packages/core/acme-client/.github/workflows/tests.yml new file mode 100644 index 00000000..6f9e7c4b --- /dev/null +++ b/packages/core/acme-client/.github/workflows/tests.yml @@ -0,0 +1,94 @@ +--- +name: test +on: [push, pull_request] + +jobs: + test: + name: node=${{matrix.node}} eab=${{matrix.eab}} + runs-on: ubuntu-latest + + strategy: + matrix: + node: [16, 18, 20] + eab: [0, 1] + + + # + # Environment + # + + env: + FORCE_COLOR: 1 + NPM_CONFIG_COLOR: always + + PEBBLE_VERSION: 2.3.1 + PEBBLE_ALTERNATE_ROOTS: 2 + PEBBLECTS_VERSION: 2.3.1 + PEBBLECTS_DNS_PORT: 8053 + COREDNS_VERSION: 1.11.1 + + NODE_EXTRA_CA_CERTS: /etc/pebble/ca.cert.pem + ACME_CA_CERT_PATH: /etc/pebble/ca.cert.pem + + ACME_DIRECTORY_URL: https://127.0.0.1:14000/dir + ACME_CHALLTESTSRV_URL: http://127.0.0.1:8055 + ACME_PEBBLE_MANAGEMENT_URL: https://127.0.0.1:15000 + + ACME_DOMAIN_NAME: test.example.com + ACME_CAP_EAB_ENABLED: ${{matrix.eab}} + + ACME_TLSALPN_PORT: 5001 + ACME_HTTP_PORT: 5002 + ACME_HTTPS_PORT: 5003 + + + # + # Pipeline + # + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{matrix.node}} + + # Pebble Challenge Test Server + - name: Install Pebble Challenge Test Server + run: sudo -E /bin/bash ./.github/scripts/tests-install-cts.sh + + - name: Start Pebble Challenge Test Server + run: |- + nohup bash -c "pebble-challtestsrv \ + -dns01 :${PEBBLECTS_DNS_PORT} \ + -tlsalpn01 :${ACME_TLSALPN_PORT} \ + -http01 :${ACME_HTTP_PORT} \ + -https01 :${ACME_HTTPS_PORT} \ + -defaultIPv4 127.0.0.1 \ + -defaultIPv6 \"\" &" + + # Pebble + - name: Install Pebble + run: sudo -E /bin/bash ./.github/scripts/tests-install-pebble.sh + + - name: Start Pebble + run: nohup bash -c "pebble -strict -config /etc/pebble/pebble.json -dnsserver 127.53.53.53:53 &" + + - name: Wait for Pebble + run: /bin/bash ./.github/scripts/tests-wait-for-ca.sh + + # CoreDNS + - name: Install CoreDNS + run: sudo -E /bin/bash ./.github/scripts/tests-install-coredns.sh + + - name: Start CoreDNS + run: nohup bash -c "sudo coredns -p 53 -conf /etc/coredns/Corefile &" + + - name: Use CoreDNS for DNS resolution + run: echo "nameserver 127.53.53.53" | sudo tee /etc/resolv.conf + + # Run tests + - run: npm i + - run: npm run lint + - run: npm run lint-types + - run: npm run build-docs + - run: npm run test diff --git a/packages/core/acme-client/.gitignore b/packages/core/acme-client/.gitignore index cbb386fa..6a3d9710 100644 --- a/packages/core/acme-client/.gitignore +++ b/packages/core/acme-client/.gitignore @@ -1,7 +1,5 @@ .vscode/ node_modules/ npm-debug.log -yarn-error.log -yarn.lock package-lock.json /.idea/ diff --git a/packages/core/acme-client/.yarnrc b/packages/core/acme-client/.yarnrc deleted file mode 100644 index 8d6f153a..00000000 --- a/packages/core/acme-client/.yarnrc +++ /dev/null @@ -1,2 +0,0 @@ -ignore-engines true -ignore-optional true diff --git a/packages/core/acme-client/CHANGELOG.md b/packages/core/acme-client/CHANGELOG.md index e9de6db1..6fc2a30b 100644 --- a/packages/core/acme-client/CHANGELOG.md +++ b/packages/core/acme-client/CHANGELOG.md @@ -1,81 +1,22 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -## [1.20.2](https://github.com/publishlab/node-acme-client/compare/v1.2.1...v1.20.2) (2024-02-28) - -**Note:** Version bump only for package @certd/acme-client - -## [1.2.1](https://github.com/publishlab/node-acme-client/compare/v1.2.0...v1.2.1) (2023-12-12) - -**Note:** Version bump only for package @certd/acme-client - -**Note:** Version bump only for package @certd/acme-client - -# [1.2.0](https://github.com/publishlab/node-acme-client/compare/v1.1.6...v1.2.0) (2023-10-27) - -**Note:** Version bump only for package @certd/acme-client - -## [1.1.6](https://github.com/publishlab/node-acme-client/compare/v1.1.5...v1.1.6) (2023-07-10) - -**Note:** Version bump only for package @certd/acme-client - -## [1.1.5](https://github.com/publishlab/node-acme-client/compare/v1.1.4...v1.1.5) (2023-07-03) - -**Note:** Version bump only for package @certd/acme-client - -## [1.1.4](https://github.com/publishlab/node-acme-client/compare/v1.1.3...v1.1.4) (2023-07-03) - -**Note:** Version bump only for package @certd/acme-client - -## [1.1.3](https://github.com/publishlab/node-acme-client/compare/v1.1.2...v1.1.3) (2023-07-03) - -**Note:** Version bump only for package @certd/acme-client - -## [1.1.2](https://github.com/publishlab/node-acme-client/compare/v1.1.1...v1.1.2) (2023-07-03) - -**Note:** Version bump only for package @certd/acme-client - -## [1.1.1](https://github.com/publishlab/node-acme-client/compare/v1.1.0...v1.1.1) (2023-06-28) - -**Note:** Version bump only for package @certd/acme-client - -# [1.1.0](https://github.com/publishlab/node-acme-client/compare/v1.0.6...v1.1.0) (2023-06-28) - -**Note:** Version bump only for package @certd/acme-client - -## [1.0.6](https://github.com/publishlab/node-acme-client/compare/v1.0.5...v1.0.6) (2023-05-25) - -**Note:** Version bump only for package @certd/acme-client - -## [1.0.5](https://github.com/publishlab/node-acme-client/compare/v1.0.4...v1.0.5) (2023-05-25) - -**Note:** Version bump only for package @certd/acme-client - -## [1.0.4](https://github.com/publishlab/node-acme-client/compare/v1.0.3...v1.0.4) (2023-05-25) - -**Note:** Version bump only for package @certd/acme-client - -## [1.0.3](https://github.com/publishlab/node-acme-client/compare/v1.0.2...v1.0.3) (2023-05-25) - -**Note:** Version bump only for package @certd/acme-client - -## [1.0.2](https://github.com/publishlab/node-acme-client/compare/v1.0.1...v1.0.2) (2023-05-24) - -**Note:** Version bump only for package @certd/acme-client - -## [1.0.1](https://github.com/publishlab/node-acme-client/compare/v1.0.0...v1.0.1) (2023-05-24) - -**Note:** Version bump only for package @certd/acme-client - # Changelog -## Important upgrade notice +## v5.3.0 (2024-02-05) -On September 15, 2022, Let's Encrypt will stop accepting Certificate Signing Requests signed using the obsolete SHA-1 hash. This change affects all `acme-client` versions lower than `3.3.2` and `4.2.4`. Please upgrade ASAP to ensure that your certificates can still be issued following this date. +* `added` Support and tests for satisfying `tls-alpn-01` challenges +* `changed` Replace `jsrsasign` with `@peculiar/x509` for certificate and CSR generation and parsing +* `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 -A more detailed explanation can be found [at the Let's Encrypt forums](https://community.letsencrypt.org/t/rejecting-sha-1-csrs-and-validation-using-tls-1-0-1-1-urls/175144). +## 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) +* `fixed` Wait for all challenge promises to settle before rejecting `client.auto()` - [#75](https://github.com/publishlab/node-acme-client/issues/75) + +## v5.1.0 (2024-01-20) + +* `fixed` Upgrade `jsrsasign@11.0.0` - [GHSA-rh63-9qcf-83gf](https://github.com/kjur/jsrsasign/security/advisories/GHSA-rh63-9qcf-83gf) +* `fixed` Upgrade `axios@1.6.5` - [CVE-2023-45857](https://cve.mitre.org/cgi-bin/cvename.cgi?name=2023-45857) ## v5.0.0 (2022-07-28) @@ -114,13 +55,13 @@ A more detailed explanation can be found [at the Let's Encrypt forums](https://c ## v4.2.0 (2022-01-06) -* `added` Support for external account binding - [RFC 8555 Section 7.3.4](https://tools.ietf.org/html/rfc8555#section-7.3.4) +* `added` Support for external account binding - [RFC 8555 Section 7.3.4](https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.4) * `added` Ability to pass through custom logger function * `changed` Increase default `backoffAttempts` to 10 * `fixed` Deactivate authorizations where challenges can not be completed * `fixed` Attempt authoritative name servers when verifying `dns-01` challenges * `fixed` Error verbosity when failing to read ACME directory -* `fixed` Correctly recognize `ready` and `processing` states - [RFC 8555 Section 7.1.6](https://tools.ietf.org/html/rfc8555#section-7.1.6) +* `fixed` Correctly recognize `ready` and `processing` states - [RFC 8555 Section 7.1.6](https://datatracker.ietf.org/doc/html/rfc8555#section-7.1.6) ## v4.1.4 (2021-12-23) @@ -170,7 +111,7 @@ A more detailed explanation can be found [at the Let's Encrypt forums](https://c ## v3.3.0 (2019-12-19) * `added` TypeScript definitions -* `fixed` Allow missing ACME directory meta field - [RFC 8555 Section 7.1.1](https://tools.ietf.org/html/rfc8555#section-7.1.1) +* `fixed` Allow missing ACME directory meta field - [RFC 8555 Section 7.1.1](https://datatracker.ietf.org/doc/html/rfc8555#section-7.1.1) ## v3.2.1 (2019-11-14) @@ -181,10 +122,10 @@ A more detailed explanation can be found [at the Let's Encrypt forums](https://c * `added` More extensive testing using [letsencrypt/pebble](https://github.com/letsencrypt/pebble) * `changed` When creating a CSR, `commonName` no longer defaults to `'localhost'` * This change is not considered breaking since `commonName: 'localhost'` will result in an error when ordering a certificate -* `fixed` Retry signed API requests on `urn:ietf:params:acme:error:badNonce` - [RFC 8555 Section 6.5](https://tools.ietf.org/html/rfc8555#section-6.5) +* `fixed` Retry signed API requests on `urn:ietf:params:acme:error:badNonce` - [RFC 8555 Section 6.5](https://datatracker.ietf.org/doc/html/rfc8555#section-6.5) * `fixed` Minor bugs related to `POST-as-GET` when calling `updateAccount()` * `fixed` Ensure subject common name is present in SAN when creating a CSR - [CAB v1.2.3 Section 9.2.2](https://cabforum.org/wp-content/uploads/BRv1.2.3.pdf) -* `fixed` Send empty JSON body when responding to challenges - [RFC 8555 Section 7.5.1](https://tools.ietf.org/html/rfc8555#section-7.5.1) +* `fixed` Send empty JSON body when responding to challenges - [RFC 8555 Section 7.5.1](https://datatracker.ietf.org/doc/html/rfc8555#section-7.5.1) ## v2.3.1 (2019-08-26) @@ -193,8 +134,8 @@ A more detailed explanation can be found [at the Let's Encrypt forums](https://c ## v3.1.0 (2019-08-21) -* `added` UTF-8 support when generating a CSR subject using forge - [RFC 5280](https://tools.ietf.org/html/rfc5280) -* `fixed` Implement `POST-as-GET` for all ACME API requests - [RFC 8555 Section 6.3](https://tools.ietf.org/html/rfc8555#section-6.3) +* `added` UTF-8 support when generating a CSR subject using forge - [RFC 5280](https://datatracker.ietf.org/doc/html/rfc5280) +* `fixed` Implement `POST-as-GET` for all ACME API requests - [RFC 8555 Section 6.3](https://datatracker.ietf.org/doc/html/rfc8555#section-6.3) ## v2.3.0 (2019-08-21) @@ -231,7 +172,7 @@ A more detailed explanation can be found [at the Let's Encrypt forums](https://c ## v2.0.1 (2018-08-17) -* `fixed` Key rollover in compliance with [draft-ietf-acme-13](https://tools.ietf.org/html/draft-ietf-acme-acme-13) +* `fixed` Key rollover in compliance with [draft-ietf-acme-13](https://datatracker.ietf.org/doc/html/draft-ietf-acme-acme-13) ## v2.0.0 (2018-04-02) diff --git a/packages/core/acme-client/LICENSE b/packages/core/acme-client/LICENSE index 7c8adf14..7f47c1a7 100644 --- a/packages/core/acme-client/LICENSE +++ b/packages/core/acme-client/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017-2022 Publish Lab +Copyright (c) 2017-2024 Labrador CMS AS Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/core/acme-client/README.md b/packages/core/acme-client/README.md index 88af9efe..3dff3cfa 100644 --- a/packages/core/acme-client/README.md +++ b/packages/core/acme-client/README.md @@ -1,21 +1,13 @@ -# acme-client [![CircleCI](https://circleci.com/gh/publishlab/node-acme-client.svg?style=svg)](https://circleci.com/gh/publishlab/node-acme-client) +# acme-client [![test](https://github.com/publishlab/node-acme-client/actions/workflows/tests.yml/badge.svg)](https://github.com/publishlab/node-acme-client/actions/workflows/tests.yml) *A simple and unopinionated ACME client.* This module is written to handle communication with a Boulder/Let's Encrypt-style ACME API. -* RFC 8555 - Automatic Certificate Management Environment (ACME): [https://tools.ietf.org/html/rfc8555](https://tools.ietf.org/html/rfc8555) +* RFC 8555 - Automatic Certificate Management Environment (ACME): [https://datatracker.ietf.org/doc/html/rfc8555](https://datatracker.ietf.org/doc/html/rfc8555) * Boulder divergences from ACME: [https://github.com/letsencrypt/boulder/blob/master/docs/acme-divergences.md](https://github.com/letsencrypt/boulder/blob/master/docs/acme-divergences.md) - -## Important upgrade notice - -On September 15, 2022, Let's Encrypt will stop accepting Certificate Signing Requests signed using the obsolete SHA-1 hash. This change affects all `acme-client` versions lower than `3.3.2` and `4.2.4`. Please upgrade ASAP to ensure that your certificates can still be issued following this date. - -A more detailed explanation can be found [at the Let's Encrypt forums](https://community.letsencrypt.org/t/rejecting-sha-1-csrs-and-validation-using-tls-1-0-1-1-urls/175144). - - -### Compatibility +## Compatibility | acme-client | Node.js | | | ------------- | --------- | ----------------------------------------- | @@ -25,8 +17,7 @@ A more detailed explanation can be found [at the Let's Encrypt forums](https://c | v2.x | >= v4 | [Changelog](CHANGELOG.md#v200-2018-04-02) | | v1.x | >= v4 | [Changelog](CHANGELOG.md#v100-2017-10-20) | - -### Table of contents +## Table of contents * [Installation](#installation) * [Usage](#usage) @@ -43,14 +34,12 @@ A more detailed explanation can be found [at the Let's Encrypt forums](https://c * [Debugging](#debugging) * [License](#license) - ## Installation ```bash $ npm install acme-client ``` - ## Usage ```js @@ -64,7 +53,6 @@ const client = new acme.Client({ }); ``` - ### Directory URLs ```js @@ -77,10 +65,9 @@ acme.directory.letsencrypt.production; acme.directory.zerossl.production; ``` - ### External account binding -To enable [external account binding](https://tools.ietf.org/html/rfc8555#section-7.3.4) when creating your ACME account, provide your KID and HMAC key to the client constructor. +To enable [external account binding](https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.4) when creating your ACME account, provide your KID and HMAC key to the client constructor. ```js const client = new acme.Client({ @@ -93,7 +80,6 @@ const client = new acme.Client({ }); ``` - ### Specifying the account URL During the ACME account creation process, the server will check the supplied account key and either create a new account if the key is unused, or return the existing ACME account bound to that key. @@ -114,14 +100,13 @@ You can fetch the clients current account URL, either after creating an account const myAccountUrl = client.getAccountUrl(); ``` - ## Cryptography -For key pairs `acme-client` utilizes native Node.js cryptography APIs, supporting signing and generation of both RSA and ECDSA keys. The module [jsrsasign](https://www.npmjs.com/package/jsrsasign) is used to generate and parse Certificate Signing Requests. +For key pairs `acme-client` utilizes native Node.js cryptography APIs, supporting signing and generation of both RSA and ECDSA keys. The module [@peculiar/x509](https://www.npmjs.com/package/@peculiar/x509) is used to generate and parse Certificate Signing Requests. These utility methods are exposed through `.crypto`. -* __Documentation: [docs/crypto.md](docs/crypto.md)__ +* **Documentation: [docs/crypto.md](docs/crypto.md)** ```js const privateRsaKey = await acme.crypto.createPrivateRsaKey(); @@ -133,22 +118,20 @@ const [certificateKey, certificateCsr] = await acme.crypto.createCsr({ }); ``` - ### Legacy `.forge` interface The legacy `node-forge` crypto interface is still available for backward compatibility, however this interface is now considered deprecated and will be removed in a future major version of `acme-client`. You should consider migrating to the new `.crypto` API at your earliest convenience. More details can be found in the [acme-client v5 upgrade guide](docs/upgrade-v5.md). -* __Documentation: [docs/forge.md](docs/forge.md)__ - +* **Documentation: [docs/forge.md](docs/forge.md)** ## Auto mode For convenience an `auto()` method is included in the client that takes a single config object. This method will handle the entire process of getting a certificate for one or multiple domains. -* __Documentation: [docs/client.md#AcmeClient+auto](docs/client.md#AcmeClient+auto)__ -* __Full example: [examples/auto.js](examples/auto.js)__ +* **Documentation: [docs/client.md#AcmeClient+auto](docs/client.md#AcmeClient+auto)** +* **Full example: [examples/auto.js](examples/auto.js)** ```js const autoOpts = { @@ -162,12 +145,11 @@ const autoOpts = { const certificate = await client.auto(autoOpts); ``` - ### Challenge priority When ordering a certificate using auto mode, `acme-client` uses a priority list when selecting challenges to respond to. Its default value is `['http-01', 'dns-01']` which translates to "use `http-01` if any challenges exist, otherwise fall back to `dns-01`". -While most challenges can be validated using the method of your choosing, please note that __wildcard certificates can only be validated through `dns-01`__. More information regarding Let's Encrypt challenge types [can be found here](https://letsencrypt.org/docs/challenge-types/). +While most challenges can be validated using the method of your choosing, please note that **wildcard certificates can only be validated through `dns-01`**. More information regarding Let's Encrypt challenge types [can be found here](https://letsencrypt.org/docs/challenge-types/). To modify challenge priority, provide a list of challenge types in `challengePriority`: @@ -178,7 +160,6 @@ await client.auto({ }); ``` - ### Internal challenge verification When using auto mode, `acme-client` will first validate that challenges are satisfied internally before completing the challenge at the ACME provider. In some cases (firewalls, etc) this internal challenge verification might not be possible to complete. @@ -194,13 +175,12 @@ await client.auto({ }); ``` - ## API For more fine-grained control you can interact with the ACME API using the methods documented below. -* __Documentation: [docs/client.md](docs/client.md)__ -* __Full example: [examples/api.js](examples/api.js)__ +* **Documentation: [docs/client.md](docs/client.md)** +* **Full example: [examples/api.js](examples/api.js)** ```js const account = await client.createAccount({ @@ -216,7 +196,6 @@ const order = await client.createOrder({ }); ``` - ## HTTP client defaults This module uses [axios](https://github.com/axios/axios) when communicating with the ACME HTTP API, and exposes the client instance through `.axios`. @@ -237,7 +216,6 @@ A complete list of axios options and documentation can be found at: * [https://github.com/axios/axios#request-config](https://github.com/axios/axios#request-config) * [https://github.com/axios/axios#custom-instance-defaults](https://github.com/axios/axios#custom-instance-defaults) - ## Debugging To get a better grasp of what `acme-client` is doing behind the scenes, you can either pass it a logger function, or enable debugging through an environment variable. @@ -256,7 +234,6 @@ Debugging to the console can also be enabled through [debug](https://www.npmjs.c DEBUG=acme-client node index.js ``` - ## License [MIT](LICENSE) 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/docs/upgrade-v5.md b/packages/core/acme-client/docs/upgrade-v5.md index a9156244..f34fd6e6 100644 --- a/packages/core/acme-client/docs/upgrade-v5.md +++ b/packages/core/acme-client/docs/upgrade-v5.md @@ -4,10 +4,9 @@ This document outlines the breaking changes introduced in v5 of `acme-client`, w First off this release drops support for Node LTS v10, v12 and v14, and the reason for that is a new native crypto interface - more on that below. Since Node v14 is still currently in maintenance mode, `acme-client` v4 will continue to receive security updates and bugfixes until (at least) Node v14 reaches its end-of-line. - ## New native crypto interface -A new crypto interface has been introduced with v5, which you can find under `acme.crypto`. It uses native Node.js cryptography APIs to generate private keys, JSON Web Keys and signatures, and finally enables support for ECC/ECDSA (P-256, P384 and P521), both for account private keys and certificates. The [jsrsasign](https://www.npmjs.com/package/jsrsasign) module is used to handle generation and parsing of Certificate Signing Requests. +A new crypto interface has been introduced with v5, which you can find under `acme.crypto`. It uses native Node.js cryptography APIs to generate private keys, JSON Web Keys and signatures, and finally enables support for ECC/ECDSA (P-256, P384 and P521), both for account private keys and certificates. The [@peculiar/x509](https://www.npmjs.com/package/@peculiar/x509) module is used to handle generation and parsing of Certificate Signing Requests. Full documentation of `acme.crypto` can be [found here](crypto.md). @@ -17,9 +16,9 @@ Below you will find a table summarizing the current `acme.forge` methods, and th *Note: The now deprecated `acme.forge` interface is still available for use in v5, and will not be removed until a future major version, most likely v6. Should you not wish to change to the new interface right away, the following breaking changes will not immediately affect you.* -- :green_circle: = API functionality unchanged between `acme.forge` and `acme.crypto` -- :orange_circle: = Slight API changes, like depromising or renaming, action may be required -- :red_circle: = Breaking API changes or removal, action required if using these methods +* :green_circle: = API functionality unchanged between `acme.forge` and `acme.crypto` +* :orange_circle: = Slight API changes, like depromising or renaming, action may be required +* :red_circle: = Breaking API changes or removal, action required if using these methods | Deprecated `.forge` API | New `.crypto` API | State | | ----------------------------- | ----------------------------- | --------------------- | @@ -33,7 +32,6 @@ Below you will find a table summarizing the current `acme.forge` methods, and th | `await readCertificateInfo()` | `readCertificateInfo()` | :orange_circle: (4) | | `await createCsr()` | `await createCsr()` | :green_circle: | - ### 1. `createPublicKey` renamed and depromised * The method `createPublicKey()` has been renamed to `getPublicKey()` @@ -49,7 +47,6 @@ const publicKey = await acme.forge.createPublicKey(privateKey); const publicKey = acme.crypto.getPublicKey(privateKey); ``` - ### 2. `getPemBody` renamed, now returns Base64URL * Method `getPemBody()` has been renamed to `getPemBodyAsB64u()` @@ -64,7 +61,6 @@ const body = acme.forge.getPemBody(pem); const body = acme.crypto.getPemBodyAsB64u(pem); ``` - ### 3. `getModulus` and `getPublicExponent` merged into `getJwk` * Methods `getModulus()` and `getPublicExponent()` have been removed @@ -80,7 +76,6 @@ const exp = await acme.forge.getPublicExponent(key); const { e, n } = acme.crypto.getJwk(key); ``` - ### 4. `readCsrDomains` and `readCertificateInfo` depromised * Methods `readCsrDomains()` and `readCertificateInfo()` no longer return promises, but their resulting payloads directly diff --git a/packages/core/acme-client/examples/README.md b/packages/core/acme-client/examples/README.md new file mode 100644 index 00000000..1c26d611 --- /dev/null +++ b/packages/core/acme-client/examples/README.md @@ -0,0 +1,19 @@ +# Disclaimer + +These examples should not be used as is for any production environment, as they are just proof of concepts meant for testing and to get you started. The examples are naively written and purposefully avoids important topics since they will be specific to your application and how you choose to use `acme-client`, like for example: + +1. **Concurrency control** + * If implementing on-demand certificate generation + * What happens when multiple requests hit your domain at the same time? + * Ensure your application does not place multiple cert orders for the same domain at the same time by implementing some sort of exclusive lock +2. **Domain allow lists** + * If implementing on-demand certificate generation + * What happens when someone manipulates the `ServerName` or `Host` header to your service? + * Ensure your application is unable to place certificate orders for domains you do not intend, as this can quickly rate limit your account and cause a DoS +3. **Clustering** + * If using `acme-client` across a cluster of servers + * Ensure challenge responses are known to all servers in your cluster, perhaps using a database or shared storage +4. **Certificate and key storage** + * Where and how should the account key be stored and read? + * Where and how should certificates and cert keys be stored and read? + * How and when should they be renewed? diff --git a/packages/core/acme-client/examples/api.js b/packages/core/acme-client/examples/api.js index 998201e6..d2b7162a 100644 --- a/packages/core/acme-client/examples/api.js +++ b/packages/core/acme-client/examples/api.js @@ -4,7 +4,6 @@ const acme = require('./../'); - function log(m) { process.stdout.write(`${m}\n`); } diff --git a/packages/core/acme-client/examples/auto.js b/packages/core/acme-client/examples/auto.js index 1495043b..cd4295d7 100644 --- a/packages/core/acme-client/examples/auto.js +++ b/packages/core/acme-client/examples/auto.js @@ -5,7 +5,6 @@ // const fs = require('fs').promises; const acme = require('./../'); - function log(m) { process.stdout.write(`${m}\n`); } 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/examples/fallback.crt b/packages/core/acme-client/examples/fallback.crt new file mode 100644 index 00000000..100e4781 --- /dev/null +++ b/packages/core/acme-client/examples/fallback.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDCTCCAfGgAwIBAgIUGwI6ZLE3HN7oRZ9BvWLde0Tsu7EwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIyMDgwMTAwNTMzMVoXDTIyMDgz +MTAwNTMzMVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEA4c7zSiY6OEp9xYZHY42FUfOLREm03NstZhd9IxFFePwe +CTTirJjmi5teKQwzBmEok0SJkanJUaMsMlOHjEykWSc4SBO4QjD349Q60044i9WS +7KHzeSqpWTG+V9jF3HOJPw843VG9hXy3ulXKcysTXzumTVQwfatCODBNkpWqMju2 +N33biLgmpqwLbDSfKXS3uSVTfoHAKGT/oRepko7/0Hwr5oEmjXEbpRWRhU09KYjH +7jokRaiQRn0h216a0r4AKzSNGihNQtKJZIuwJvLFPMQYafsu9qBaCLPqDBXCwQWG +aYh6Cm3kTkADKzG1LVPB/7/Uh2d4Fck/ejR9qXRK3QIDAQABo1MwUTAdBgNVHQ4E +FgQUvyceAVDMPbW7wHwNF9px5dWfgd4wHwYDVR0jBBgwFoAUvyceAVDMPbW7wHwN +F9px5dWfgd4wDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAaYkz +AOHrRirPwfkwjb+uMliGHfANrmak8r5VDQA73RLTQLRhMpf1yrb1uhH7p/CUYKap +x1C8RGQAXujoQbQOslyZA7cVLA9ASSZS6Noq7NerfGBiqxeuye+x3lIIk1EOL/rH +aBu9rrYGmlU49PlGAQSfFHkwzXti2Mp1VQv8eMOBLR49ezZIXHiPE8S3gjNymZ0G +UA13wzZCT7SG1BLmQ/cBVASG2wvhlC8IG/4vF0Xe+boSOb1vGWUtHS+MnvvRK4n5 +TMUtrnxSQ/LA8AtobvzqgvQVKBSPLK6RzLE7I+Q9pWsbKTBqfyStuQrQFqafBOqN +eYfPUgiID9uvfrxLvA== +-----END CERTIFICATE----- diff --git a/packages/core/acme-client/examples/fallback.key b/packages/core/acme-client/examples/fallback.key new file mode 100644 index 00000000..1cbd8f66 --- /dev/null +++ b/packages/core/acme-client/examples/fallback.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDhzvNKJjo4Sn3F +hkdjjYVR84tESbTc2y1mF30jEUV4/B4JNOKsmOaLm14pDDMGYSiTRImRqclRoywy +U4eMTKRZJzhIE7hCMPfj1DrTTjiL1ZLsofN5KqlZMb5X2MXcc4k/DzjdUb2FfLe6 +VcpzKxNfO6ZNVDB9q0I4ME2SlaoyO7Y3fduIuCamrAtsNJ8pdLe5JVN+gcAoZP+h +F6mSjv/QfCvmgSaNcRulFZGFTT0piMfuOiRFqJBGfSHbXprSvgArNI0aKE1C0olk +i7Am8sU8xBhp+y72oFoIs+oMFcLBBYZpiHoKbeROQAMrMbUtU8H/v9SHZ3gVyT96 +NH2pdErdAgMBAAECggEBAImI0FxQblOM45AkmmTDdPmWWjPspNGEWeF92wU55tOq +0+yNnqa7tmg/6JkdyhJPqTQRoazr+ifUN/4rLDtDDzMSFVCpWihOxR2qTW4YjY52 +NjgU6EPbvSwLhUDiUplUcbrL3bnHqKSecxV2XYnKKdFudntRFPvmDL5GhWkL6Y8P +9KiQaYuPf4av8PR0NlWBMiZs+CBjLlnSTMAWRYj5mRSyFSEOMT7+Lvr3TqrO2/nh +0H30LXxrXXXuCbQXnVy3oSNf7TrathT2ADIrUUTdRHsLscvkEA35VtFQtWdJLtEg +sso1J7viV9YDU4niPSdHPj3ubBjAExej4qCOzatsIQ0CgYEA8L5S3ojy89g7q6vB +QuusIrjGkyM1yebDWqhEnjvlMpfrU1hCS90BM1ozZ28bjz/7PBimKL+A8BO+W0m4 +2s9YbZP5aGwo18Iq86XEdtDgWtQ3NXbYkb8F8LNtyevC/UlAI/xyIRr7hDYlr/1v +jJg16DXiNLyk+uj4Q3EuwzNl8n8CgYEA8B5UUkOiufPtm+ZOq9AlBpIa+NYaahZM +h52jzMTKsFB18xsZU/ufvpKvXEu1sTeCDRo3JAHmiA6AG292Zc7W+uWRtMtlmQWE +wnoZ6hKvEkFnArLCY6Nm5Qqm1wipLwDVO3dD/CDL86siHrXK4wU7Q+bp6xbt8lDi +itz5F7p7HKMCgYAoj8iimexlTU9wczXSsqaECyHZ9JrBc9ICWkuFZY4OYi5SEpLI ++WmUX2Q9zyiTkDIiQ/zq7KkqygjOlLNCmqDJhZ8GCwMupxZZitp5MmQ6qXrL1URT ++h1kGrcqyEBIMKlP5t7L2SH7eqwK5OaAh7y9bSa5v/cEF3CM3GsGlIhevQKBgBGU +RtwW84zlnNmzDMNrY6qNe8gH9LsbktLC6cEOD0DFQz1fGIWbgGB1YL1DFbQ5uh23 +c54BPZ1sYlif2m0trXOE5xvzYCbJzqRmSAto/sQ5YY9DAxREXD4cf4ZyreAxEWtf +Ge0VgZj/SGozKP1h3qrj9vAtJ5J79XnxH5NrJaQ9AoGBAM2rQrt8H2kizg4wMGRZ +0G3709W7xxlbPdm+i/jFVDayJswCr0+eMm4gGyyZL3135D0fcijxytKgg3/OpOJF +jC9vsHsE2K1ATp6eYvYjrhqJHI1m44aq/h46SfajytZQjwMT/jaApULDP2/fCBm5 +6eS2WCyHyrYJyrgoYQF56nsT +-----END PRIVATE KEY----- diff --git a/packages/core/acme-client/examples/http-01/README.md b/packages/core/acme-client/examples/http-01/README.md new file mode 100644 index 00000000..a08cace1 --- /dev/null +++ b/packages/core/acme-client/examples/http-01/README.md @@ -0,0 +1,21 @@ +# http-01 + +The `http-01` challenge type is the simplest to implement and should likely be your default choice, unless you either require wildcard certificates or if port 80 is unavailable for use. + +## How it works + +When solving `http-01` challenges, you prove ownership of a domain name by serving a specific payload from a specific URL. The ACME authority provides the client with a token that is used to generate the URL and file contents. The file must exist at `http://$YOUR_DOMAIN/.well-known/acme-challenge/$TOKEN` and contain the token and a thumbprint of your account key. + +Once the order is finalized, the ACME authority will verify that the URL responds with the correct payload by sending HTTP requests before the challenge is valid. HTTP redirects are followed, and Let's Encrypt allows redirecting to HTTPS although this diverges from the ACME spec. + +## Pros and cons + +* Challenge must be satisfied using port 80 (HTTP) +* The simplest challenge type to implement +* Can not be used to issue wildcard certificates +* If using multiple web servers, all of them need to respond with the correct token + +## External links + +* [https://letsencrypt.org/docs/challenge-types/#http-01-challenge](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) +* [https://datatracker.ietf.org/doc/html/rfc8555#section-8.3](https://datatracker.ietf.org/doc/html/rfc8555#section-8.3) diff --git a/packages/core/acme-client/examples/http-01/http-01.js b/packages/core/acme-client/examples/http-01/http-01.js new file mode 100644 index 00000000..75153385 --- /dev/null +++ b/packages/core/acme-client/examples/http-01/http-01.js @@ -0,0 +1,172 @@ +/** + * Example using http-01 challenge to generate certificates on-demand + */ + +const fs = require('fs'); +const path = require('path'); +const http = require('http'); +const https = require('https'); +const tls = require('tls'); +const acme = require('./../../'); + +const HTTP_SERVER_PORT = 80; +const HTTPS_SERVER_PORT = 443; +const VALID_DOMAINS = ['example.com', 'example.org']; +const FALLBACK_KEY = fs.readFileSync(path.join(__dirname, '..', 'fallback.key')); +const FALLBACK_CERT = fs.readFileSync(path.join(__dirname, '..', 'fallback.crt')); + +const pendingDomains = {}; +const challengeResponses = {}; +const certificateStore = {}; + +function log(m) { + process.stdout.write(`${(new Date()).toISOString()} ${m}\n`); +} + + +/** + * On-demand certificate generation using http-01 + */ + +async function getCertOnDemand(client, servername, attempt = 0) { + /* Invalid domain */ + if (!VALID_DOMAINS.includes(servername)) { + throw new Error(`Invalid domain: ${servername}`); + } + + /* Certificate exists */ + if (servername in certificateStore) { + return certificateStore[servername]; + } + + /* Waiting on certificate order to go through */ + if (servername in pendingDomains) { + if (attempt >= 10) { + throw new Error(`Gave up waiting on certificate for ${servername}`); + } + + await new Promise((resolve) => { setTimeout(resolve, 1000); }); + return getCertOnDemand(client, servername, (attempt + 1)); + } + + /* Create CSR */ + log(`Creating CSR for ${servername}`); + const [key, csr] = await acme.crypto.createCsr({ + commonName: servername + }); + + /* Order certificate */ + log(`Ordering certificate for ${servername}`); + const cert = await client.auto({ + csr, + email: 'test@example.com', + termsOfServiceAgreed: true, + challengePriority: ['http-01'], + challengeCreateFn: (authz, challenge, keyAuthorization) => { + challengeResponses[challenge.token] = keyAuthorization; + }, + challengeRemoveFn: (authz, challenge) => { + delete challengeResponses[challenge.token]; + } + }); + + /* Done, store certificate */ + log(`Certificate for ${servername} created successfully`); + certificateStore[servername] = [key, cert]; + delete pendingDomains[servername]; + return certificateStore[servername]; +} + + +/** + * 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() + }); + + + /** + * HTTP server + */ + + const httpServer = http.createServer((req, res) => { + if (req.url.match(/\/\.well-known\/acme-challenge\/.+/)) { + const token = req.url.split('/').pop(); + log(`Received challenge request for token=${token}`); + + /* ACME challenge response */ + if (token in challengeResponses) { + log(`Serving challenge response HTTP 200 token=${token}`); + res.writeHead(200); + res.end(challengeResponses[token]); + return; + } + + /* Challenge response not found */ + log(`Oops, challenge response not found for token=${token}`); + res.writeHead(404); + res.end(); + return; + } + + /* HTTP 302 redirect */ + log(`HTTP 302 ${req.headers.host}${req.url}`); + res.writeHead(302, { Location: `https://${req.headers.host}${req.url}` }); + res.end(); + }); + + httpServer.listen(HTTP_SERVER_PORT, () => { + log(`HTTP server listening on port ${HTTP_SERVER_PORT}`); + }); + + + /** + * 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({ + /* Fallback certificate */ + key: FALLBACK_KEY, + cert: FALLBACK_CERT, + + /* Serve certificate based on servername */ + SNICallback: async (servername, cb) => { + try { + log(`Handling SNI request for ${servername}`); + const [key, cert] = await getCertOnDemand(client, servername); + + log(`Found certificate for ${servername}, serving secure context`); + cb(null, tls.createSecureContext({ key, cert })); + } + catch (e) { + log(`[ERROR] ${e.message}`); + cb(e.message); + } + } + }, 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/examples/tls-alpn-01/README.md b/packages/core/acme-client/examples/tls-alpn-01/README.md new file mode 100644 index 00000000..40863519 --- /dev/null +++ b/packages/core/acme-client/examples/tls-alpn-01/README.md @@ -0,0 +1,44 @@ +# tls-alpn-01 + +Responding to `tls-alpn-01` challenges using Node.js is a bit more involved than the other two challenge types, and requires a proxy (f.ex. [Nginx](https://nginx.org) or [HAProxy](https://www.haproxy.org)) in front of the Node.js service. The reason for this is that `tls-alpn-01` is solved by responding to the ACME challenge using self-signed certificates with an ALPN extension containing the challenge response. + +Since we don't want users of our application to be served with these self-signed certificates, we need to split the HTTPS traffic into two different Node.js backends - one that only serves ALPN certificates for challenge responses, and the other for actual end-user traffic that serves certificates retrieved from the ACME provider. As far as I *(library author)* know, routing HTTPS traffic based on ALPN protocol can not be done purely using Node.js. + +The end result should look something like this: + +```text +Nginx or HAProxy (0.0.0.0:443) + *inspect requests SSL ALPN protocol* + If ALPN == acme-tls/1 + -> Node.js ALPN responder (127.0.0.1:4444) + Else + -> Node.js HTTPS server (127.0.0.1:4443) +``` + +Example proxy configuration: + +* [haproxy.cfg](haproxy.cfg) *(requires HAProxy >= v1.9.1)* +* [nginx.conf](nginx.conf) *(requires [ngx_stream_ssl_preread_module](https://nginx.org/en/docs/stream/ngx_stream_ssl_preread_module.html))* + +Big thanks to [acme.sh](https://github.com/acmesh-official/acme.sh) and [dehydrated](https://github.com/dehydrated-io/dehydrated) for doing the legwork and providing Nginx and HAProxy config examples. + +## How it works + +When solving `tls-alpn-01` challenges, you prove ownership of a domain name by serving a specially crafted certificate over HTTPS. The ACME authority provides the client with a token that is placed into the certificates `id-pe-acmeIdentifier` extension along with a thumbprint of your account key. + +Once the order is finalized, the ACME authority will verify by sending HTTPS requests to your domain with the `acme-tls/1` ALPN protocol, indicating to the server that it should serve the challenge response certificate. If the `id-pe-acmeIdentifier` extension contains the correct payload, the challenge is valid. + +## Pros and cons + +* Challenge must be satisfied using port 443 (HTTPS) +* Useful in instances where port 80 is unavailable +* Can not be used to issue wildcard certificates +* More complex than `http-01`, can not be solved purely using Node.js +* If using multiple web servers, all of them need to respond with the correct certificate + +## External links + +* [https://letsencrypt.org/docs/challenge-types/#tls-alpn-01](https://letsencrypt.org/docs/challenge-types/#tls-alpn-01) +* [https://github.com/dehydrated-io/dehydrated/blob/master/docs/tls-alpn.md](https://github.com/dehydrated-io/dehydrated/blob/master/docs/tls-alpn.md) +* [https://github.com/acmesh-official/acme.sh/wiki/TLS-ALPN-without-downtime](https://github.com/acmesh-official/acme.sh/wiki/TLS-ALPN-without-downtime) +* [https://datatracker.ietf.org/doc/html/rfc8737](https://datatracker.ietf.org/doc/html/rfc8737) diff --git a/packages/core/acme-client/examples/tls-alpn-01/haproxy.cfg b/packages/core/acme-client/examples/tls-alpn-01/haproxy.cfg new file mode 100644 index 00000000..5a99be7c --- /dev/null +++ b/packages/core/acme-client/examples/tls-alpn-01/haproxy.cfg @@ -0,0 +1,23 @@ +## +# HTTPS listener +# - Send to ALPN responder port 4444 if protocol is acme-tls/1 +# - Default to HTTPS backend port 4443 +## + +frontend https + mode tcp + bind :443 + tcp-request inspect-delay 5s + tcp-request content accept if { req_ssl_hello_type 1 } + use_backend alpnresp if { req.ssl_alpn acme-tls/1 } + default_backend https + +# Default HTTPS backend +backend https + mode tcp + server https 127.0.0.1:4443 + +# ACME tls-alpn-01 responder backend +backend alpnresp + mode tcp + server acmesh 127.0.0.1:4444 diff --git a/packages/core/acme-client/examples/tls-alpn-01/nginx.conf b/packages/core/acme-client/examples/tls-alpn-01/nginx.conf new file mode 100644 index 00000000..cb4aa7b6 --- /dev/null +++ b/packages/core/acme-client/examples/tls-alpn-01/nginx.conf @@ -0,0 +1,19 @@ +## +# HTTPS server +# - Send to ALPN responder port 4444 if protocol is acme-tls/1 +# - Default to HTTPS backend port 4443 +## + +stream { + map $ssl_preread_alpn_protocols $tls_port { + ~\bacme-tls/1\b 4444; + default 4443; + } + + server { + listen 443; + listen [::]:443; + proxy_pass 127.0.0.1:$tls_port; + ssl_preread on; + } +} diff --git a/packages/core/acme-client/examples/tls-alpn-01/tls-alpn-01.js b/packages/core/acme-client/examples/tls-alpn-01/tls-alpn-01.js new file mode 100644 index 00000000..e04d73c1 --- /dev/null +++ b/packages/core/acme-client/examples/tls-alpn-01/tls-alpn-01.js @@ -0,0 +1,180 @@ +/** + * Example using tls-alpn-01 challenge to generate certificates on-demand + */ + +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const tls = require('tls'); +const acme = require('./../../'); + +const HTTPS_SERVER_PORT = 4443; +const ALPN_RESPONDER_PORT = 4444; +const VALID_DOMAINS = ['example.com', 'example.org']; +const FALLBACK_KEY = fs.readFileSync(path.join(__dirname, '..', 'fallback.key')); +const FALLBACK_CERT = fs.readFileSync(path.join(__dirname, '..', 'fallback.crt')); + +const pendingDomains = {}; +const alpnResponses = {}; +const certificateStore = {}; + +function log(m) { + process.stdout.write(`${(new Date()).toISOString()} ${m}\n`); +} + + +/** + * On-demand certificate generation using tls-alpn-01 + */ + +async function getCertOnDemand(client, servername, attempt = 0) { + /* Invalid domain */ + if (!VALID_DOMAINS.includes(servername)) { + throw new Error(`Invalid domain: ${servername}`); + } + + /* Certificate exists */ + if (servername in certificateStore) { + return certificateStore[servername]; + } + + /* Waiting on certificate order to go through */ + if (servername in pendingDomains) { + if (attempt >= 10) { + throw new Error(`Gave up waiting on certificate for ${servername}`); + } + + await new Promise((resolve) => { setTimeout(resolve, 1000); }); + return getCertOnDemand(client, servername, (attempt + 1)); + } + + /* Create CSR */ + log(`Creating CSR for ${servername}`); + const [key, csr] = await acme.crypto.createCsr({ + commonName: servername + }); + + /* Order certificate */ + log(`Ordering certificate for ${servername}`); + const cert = await client.auto({ + csr, + email: 'test@example.com', + termsOfServiceAgreed: true, + challengePriority: ['tls-alpn-01'], + challengeCreateFn: async (authz, challenge, keyAuthorization) => { + alpnResponses[authz.identifier.value] = await acme.crypto.createAlpnCertificate(authz, keyAuthorization); + }, + challengeRemoveFn: (authz) => { + delete alpnResponses[authz.identifier.value]; + } + }); + + /* Done, store certificate */ + log(`Certificate for ${servername} created successfully`); + certificateStore[servername] = [key, cert]; + delete pendingDomains[servername]; + return certificateStore[servername]; +} + + +/** + * 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() + }); + + + /** + * ALPN responder + */ + + const alpnResponder = https.createServer({ + /* Fallback cert */ + key: FALLBACK_KEY, + cert: FALLBACK_CERT, + + /* Allow acme-tls/1 ALPN protocol */ + ALPNProtocols: ['acme-tls/1'], + + /* Serve ALPN certificate based on servername */ + SNICallback: async (servername, cb) => { + try { + log(`Handling ALPN SNI request for ${servername}`); + if (!Object.keys(alpnResponses).includes(servername)) { + throw new Error(`No ALPN certificate found for ${servername}`); + } + + /* Serve ALPN challenge response */ + log(`Found ALPN certificate for ${servername}, serving secure context`); + cb(null, tls.createSecureContext({ + key: alpnResponses[servername][0], + cert: alpnResponses[servername][1] + })); + } + catch (e) { + log(`[ERROR] ${e.message}`); + cb(e.message); + } + } + }); + + /* Terminate once TLS handshake has been established */ + alpnResponder.on('secureConnection', (socket) => { + socket.end(); + }); + + alpnResponder.listen(ALPN_RESPONDER_PORT, () => { + log(`ALPN responder listening on port ${ALPN_RESPONDER_PORT}`); + }); + + + /** + * 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({ + /* Fallback cert */ + key: FALLBACK_KEY, + cert: FALLBACK_CERT, + + /* Serve certificate based on servername */ + SNICallback: async (servername, cb) => { + try { + log(`Handling SNI request for ${servername}`); + const [key, cert] = await getCertOnDemand(client, servername); + + log(`Found certificate for ${servername}, serving secure context`); + cb(null, tls.createSecureContext({ key, cert })); + } + catch (e) { + log(`[ERROR] ${e.message}`); + cb(e.message); + } + } + }, 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 7a2230d7..10521187 100644 --- a/packages/core/acme-client/package.json +++ b/packages/core/acme-client/package.json @@ -5,7 +5,7 @@ "author": "nmorsman", "version": "1.20.2", "main": "src/index.js", - "types": "types", + "types": "types/index.d.ts", "license": "MIT", "homepage": "https://github.com/publishlab/node-acme-client", "engines": { @@ -16,32 +16,32 @@ "types" ], "dependencies": { - "axios": "0.27.2", + "@peculiar/x509": "^1.9.7", + "asn1js": "^3.0.5", + "axios": "^1.6.5", "debug": "^4.1.1", - "jsrsasign": "^10.5.26", "node-forge": "^1.3.1" }, "devDependencies": { - "@types/node": "^18.6.1", - "chai": "^4.3.6", + "@types/node": "^20.11.5", + "chai": "^4.4.1", "chai-as-promised": "^7.1.1", - "dtslint": "^4.2.1", - "eslint": "^8.11.0", + "eslint": "^8.56.0", "eslint-config-airbnb-base": "^15.0.0", - "eslint-plugin-import": "^2.25.4", - "jsdoc-to-markdown": "^7.1.1", - "mocha": "^10.0.0", - "nock": "^13.2.4", + "eslint-plugin-import": "^2.29.1", + "jsdoc-to-markdown": "^8.0.0", + "mocha": "^10.2.0", + "nock": "^13.5.0", + "tsd": "^0.30.4", "typescript": "^4.8.4", "uuid": "^8.3.2" }, "scripts": { "build-docs": "jsdoc2md src/client.js > docs/client.md && jsdoc2md src/crypto/index.js > docs/crypto.md && jsdoc2md src/crypto/forge.js > docs/forge.md", "lint": "eslint .", - "lint-types": "dtslint types", + "lint-types": "tsd", "prepublishOnly": "npm run build-docs", - "test": "mocha -t 60000 \"test/setup.js\" \"test/**/*.spec.js\"", - "test-local": "/bin/bash scripts/run-tests.sh" + "test": "mocha -t 60000 \"test/setup.js\" \"test/**/*.spec.js\"" }, "repository": { "type": "git", diff --git a/packages/core/acme-client/scripts/run-tests.sh b/packages/core/acme-client/scripts/run-tests.sh deleted file mode 100644 index b0613ac2..00000000 --- a/packages/core/acme-client/scripts/run-tests.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/bin/bash -# -# Run test suite locally using CircleCI CLI. -# -set -eu - -JOBS=("$@") - -CIRCLECI_CLI_URL="https://github.com/CircleCI-Public/circleci-cli/releases/download/v0.1.16947/circleci-cli_0.1.16947_linux_amd64.tar.gz" -CIRCLECI_CLI_SHASUM="c6f9a3276445c69ae40439acfed07e2c53502216a96bfacc4556e1d862d1019a" -CIRCLECI_CLI_PATH="/tmp/circleci-cli" -CIRCLECI_CLI_BIN="${CIRCLECI_CLI_PATH}/circleci" - -PROJECT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && cd .. && pwd )" -CONFIG_PATH="${PROJECT_DIR}/.circleci/.temp.yml" - -# Run all jobs by default -if [[ ${#JOBS[@]} -eq 0 ]]; then - JOBS=( - "v16" - "v18" - "eab-v16" - "eab-v18" - ) -fi - -# Download CircleCI CLI -if [[ ! -f "${CIRCLECI_CLI_BIN}" ]]; then - echo "[-] Downloading CircleCI cli" - mkdir -p "${CIRCLECI_CLI_PATH}" - wget -nv "${CIRCLECI_CLI_URL}" -O "${CIRCLECI_CLI_PATH}/circleci-cli.tar.gz" - echo "${CIRCLECI_CLI_SHASUM} *${CIRCLECI_CLI_PATH}/circleci-cli.tar.gz" | sha256sum -c - tar zxvf "${CIRCLECI_CLI_PATH}/circleci-cli.tar.gz" -C "${CIRCLECI_CLI_PATH}" --strip-components=1 -fi - -# Skip CircleCI update checks -export CIRCLECI_CLI_SKIP_UPDATE_CHECK="true" - -# Run test suite -echo "[-] Running test suite" -$CIRCLECI_CLI_BIN config process "${PROJECT_DIR}/.circleci/config.yml" > "${CONFIG_PATH}" -$CIRCLECI_CLI_BIN config validate -c "${CONFIG_PATH}" - -for job in "${JOBS[@]}"; do - echo "[-] Running job: ${job}" - $CIRCLECI_CLI_BIN local execute -c "${CONFIG_PATH}" --job "${job}" --skip-checkout - echo "[+] ${job} completed successfully" -done - -# Clean up -if [[ -f "${CONFIG_PATH}" ]]; then - rm "${CONFIG_PATH}" -fi - -echo "[+] Test suite ran successfully!" -exit 0 diff --git a/packages/core/acme-client/scripts/test-suite-install-step.sh b/packages/core/acme-client/scripts/test-suite-install-step.sh deleted file mode 100644 index 5de092a5..00000000 --- a/packages/core/acme-client/scripts/test-suite-install-step.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash -# -# Install and init step-ca for testing. -# -set -eu - -# Download and install -wget -nv "https://dl.step.sm/gh-release/certificates/gh-release-header/v${STEPCA_VERSION}/step-ca_${STEPCA_VERSION}_amd64.deb" -O /tmp/step-ca.deb -wget -nv "https://dl.step.sm/gh-release/cli/gh-release-header/v${STEPCLI_VERSION}/step-cli_${STEPCLI_VERSION}_amd64.deb" -O /tmp/step-cli.deb - -sudo dpkg -i /tmp/step-ca.deb -sudo dpkg -i /tmp/step-cli.deb - -# Initialize -echo "hunter2" > /tmp/password - -step ca init --name="Example Inc." --dns="localhost" --address="127.0.0.1:8443" --provisioner="test@example.com" --password-file="/tmp/password" -step ca provisioner add acme --type ACME - -exit 0 diff --git a/packages/core/acme-client/src/api.js b/packages/core/acme-client/src/api.js index 84fe0f88..31c06f52 100644 --- a/packages/core/acme-client/src/api.js +++ b/packages/core/acme-client/src/api.js @@ -41,7 +41,7 @@ class AcmeApi { * @private * @param {string} url Request URL * @param {object} [payload] Request payload, default: `null` - * @param {array} [validStatusCodes] Array of valid HTTP response status codes, default: `[]` + * @param {number[]} [validStatusCodes] Array of valid HTTP response status codes, default: `[]` * @param {object} [opts] * @param {boolean} [opts.includeJwsKid] Include KID instead of JWK in JWS header, default: `true` * @param {boolean} [opts.includeExternalAccountBinding] Include EAB in request, default: `false` @@ -66,7 +66,7 @@ class AcmeApi { * @private * @param {string} resource Request resource name * @param {object} [payload] Request payload, default: `null` - * @param {array} [validStatusCodes] Array of valid HTTP response status codes, default: `[]` + * @param {number[]} [validStatusCodes] Array of valid HTTP response status codes, default: `[]` * @param {object} [opts] * @param {boolean} [opts.includeJwsKid] Include KID instead of JWK in JWS header, default: `true` * @param {boolean} [opts.includeExternalAccountBinding] Include EAB in request, default: `false` @@ -82,7 +82,7 @@ class AcmeApi { /** * Get Terms of Service URL if available * - * https://tools.ietf.org/html/rfc8555#section-7.1.1 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.1.1 * * @returns {Promise} ToS URL */ @@ -95,7 +95,7 @@ class AcmeApi { /** * Create new account * - * https://tools.ietf.org/html/rfc8555#section-7.3 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.3 * * @param {object} data Request payload * @returns {Promise} HTTP response @@ -119,7 +119,7 @@ class AcmeApi { /** * Update account * - * https://tools.ietf.org/html/rfc8555#section-7.3.2 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.2 * * @param {object} data Request payload * @returns {Promise} HTTP response @@ -133,7 +133,7 @@ class AcmeApi { /** * Update account key * - * https://tools.ietf.org/html/rfc8555#section-7.3.5 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.5 * * @param {object} data Request payload * @returns {Promise} HTTP response @@ -147,7 +147,7 @@ class AcmeApi { /** * Create new order * - * https://tools.ietf.org/html/rfc8555#section-7.4 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.4 * * @param {object} data Request payload * @returns {Promise} HTTP response @@ -161,7 +161,7 @@ class AcmeApi { /** * Get order * - * https://tools.ietf.org/html/rfc8555#section-7.4 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.4 * * @param {string} url Order URL * @returns {Promise} HTTP response @@ -175,7 +175,7 @@ class AcmeApi { /** * Finalize order * - * https://tools.ietf.org/html/rfc8555#section-7.4 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.4 * * @param {string} url Finalization URL * @param {object} data Request payload @@ -190,7 +190,7 @@ class AcmeApi { /** * Get identifier authorization * - * https://tools.ietf.org/html/rfc8555#section-7.5 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.5 * * @param {string} url Authorization URL * @returns {Promise} HTTP response @@ -204,7 +204,7 @@ class AcmeApi { /** * Update identifier authorization * - * https://tools.ietf.org/html/rfc8555#section-7.5.2 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.5.2 * * @param {string} url Authorization URL * @param {object} data Request payload @@ -219,7 +219,7 @@ class AcmeApi { /** * Complete challenge * - * https://tools.ietf.org/html/rfc8555#section-7.5.1 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.5.1 * * @param {string} url Challenge URL * @param {object} data Request payload @@ -234,7 +234,7 @@ class AcmeApi { /** * Revoke certificate * - * https://tools.ietf.org/html/rfc8555#section-7.6 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.6 * * @param {object} data Request payload * @returns {Promise} HTTP response diff --git a/packages/core/acme-client/src/auto.js b/packages/core/acme-client/src/auto.js index 6f40bc01..2707e23d 100644 --- a/packages/core/acme-client/src/auto.js +++ b/packages/core/acme-client/src/auto.js @@ -182,6 +182,7 @@ module.exports = async function(client, userOpts) { return promise; } + await runPromisesSerially(challengePromises); log('challenge结束'); diff --git a/packages/core/acme-client/src/axios.js b/packages/core/acme-client/src/axios.js index b9974852..83c632ce 100644 --- a/packages/core/acme-client/src/axios.js +++ b/packages/core/acme-client/src/axios.js @@ -3,7 +3,6 @@ */ const axios = require('axios'); -const adapter = require('axios/lib/adapters/http'); const pkg = require('./../package.json'); @@ -19,7 +18,8 @@ instance.defaults.headers.common['User-Agent'] = `node-${pkg.name}/${pkg.version /* Default ACME settings */ instance.defaults.acmeSettings = { httpChallengePort: 80, - bypassCustomDnsResolver: false + httpsChallengePort: 443, + tlsAlpnChallengePort: 443 }; @@ -30,7 +30,7 @@ instance.defaults.acmeSettings = { * https://stackoverflow.com/questions/42677387 */ -instance.defaults.adapter = adapter; +instance.defaults.adapter = 'http'; /** diff --git a/packages/core/acme-client/src/client.js b/packages/core/acme-client/src/client.js index 6547522e..fea97849 100644 --- a/packages/core/acme-client/src/client.js +++ b/packages/core/acme-client/src/client.js @@ -154,7 +154,7 @@ class AcmeClient { /** * Create a new account * - * https://tools.ietf.org/html/rfc8555#section-7.3 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.3 * * @param {object} [data] Request data * @returns {Promise} Account @@ -200,7 +200,7 @@ class AcmeClient { /** * Update existing account * - * https://tools.ietf.org/html/rfc8555#section-7.3.2 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.2 * * @param {object} [data] Request data * @returns {Promise} Account @@ -240,7 +240,7 @@ class AcmeClient { /** * 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 * * @param {buffer|string} newAccountKey New PEM encoded private key * @param {object} [data] Additional request data @@ -286,7 +286,7 @@ class AcmeClient { /** * Create a new order * - * https://tools.ietf.org/html/rfc8555#section-7.4 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.4 * * @param {object} data Request data * @returns {Promise} Order @@ -318,7 +318,7 @@ class AcmeClient { /** * Refresh order object from CA * - * https://tools.ietf.org/html/rfc8555#section-7.4 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.4 * * @param {object} order Order object * @returns {Promise} Order @@ -345,7 +345,7 @@ class AcmeClient { /** * Finalize order * - * https://tools.ietf.org/html/rfc8555#section-7.4 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.4 * * @param {object} order Order object * @param {buffer|string} csr PEM encoded Certificate Signing Request @@ -380,7 +380,7 @@ class AcmeClient { /** * Get identifier authorizations from order * - * https://tools.ietf.org/html/rfc8555#section-7.5 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.5 * * @param {object} order Order * @returns {Promise} Authorizations @@ -410,7 +410,7 @@ class AcmeClient { /** * Deactivate identifier authorization * - * https://tools.ietf.org/html/rfc8555#section-7.5.2 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.5.2 * * @param {object} authz Identifier authorization * @returns {Promise} Authorization @@ -442,7 +442,7 @@ class AcmeClient { /** * 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 * * @param {object} challenge Challenge object returned by API * @returns {Promise} Key authorization @@ -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://datatracker.ietf.org/doc/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://datatracker.ietf.org/doc/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://datatracker.ietf.org/doc/html/rfc8737 */ + if (challenge.type === 'tls-alpn-01') { + return result; } throw new Error(`Unable to produce key authorization, unknown challenge type: ${challenge.type}`); @@ -522,7 +519,7 @@ class AcmeClient { /** * 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 * * @param {object} challenge Challenge object returned by API * @returns {Promise} Challenge @@ -543,7 +540,7 @@ class AcmeClient { /** * 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 * * @param {object} item An order, authorization or challenge object * @returns {Promise} Valid order, authorization or challenge @@ -554,7 +551,7 @@ class AcmeClient { * await client.waitForValidStatus(challenge); * ``` * - * @example Wait for valid authoriation status + * @example Wait for valid authorization status * ```js * const authz = { ... }; * await client.waitForValidStatus(authz); @@ -600,7 +597,7 @@ class AcmeClient { /** * 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 * * @param {object} order Order object * @param {string} [preferredChain] Indicate which certificate chain is preferred if a CA offers multiple, by exact issuer common name, default: `null` @@ -647,7 +644,7 @@ class AcmeClient { /** * Revoke certificate * - * https://tools.ietf.org/html/rfc8555#section-7.6 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.6 * * @param {buffer|string} cert PEM encoded certificate * @param {object} [data] Additional request data diff --git a/packages/core/acme-client/src/crypto/forge.js b/packages/core/acme-client/src/crypto/forge.js index f22425de..5b66327a 100644 --- a/packages/core/acme-client/src/crypto/forge.js +++ b/packages/core/acme-client/src/crypto/forge.js @@ -281,7 +281,7 @@ exports.readCertificateInfo = async function(cert) { /** * Determine ASN.1 type for CSR subject short name - * Note: https://tools.ietf.org/html/rfc5280 + * Note: https://datatracker.ietf.org/doc/html/rfc5280 * * @private * @param {string} shortName CSR subject short name @@ -343,7 +343,7 @@ function formatCsrAltNames(altNames) { * @param {object} data * @param {number} [data.keySize] Size of newly created private key, default: `2048` * @param {string} [data.commonName] - * @param {array} [data.altNames] default: `[]` + * @param {string[]} [data.altNames] default: `[]` * @param {string} [data.country] * @param {string} [data.state] * @param {string} [data.locality] diff --git a/packages/core/acme-client/src/crypto/index.js b/packages/core/acme-client/src/crypto/index.js index 582b2d11..da0df321 100644 --- a/packages/core/acme-client/src/crypto/index.js +++ b/packages/core/acme-client/src/crypto/index.js @@ -7,10 +7,21 @@ const net = require('net'); const { promisify } = require('util'); const crypto = require('crypto'); -const jsrsasign = require('jsrsasign'); +const asn1js = require('asn1js'); +const x509 = require('@peculiar/x509'); +const randomInt = promisify(crypto.randomInt); const generateKeyPair = promisify(crypto.generateKeyPair); +/* Use Node.js Web Crypto API */ +x509.cryptoProvider.set(crypto.webcrypto); + +/* id-ce-subjectAltName - https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.6 */ +const subjectAltNameOID = '2.5.29.17'; + +/* id-pe-acmeIdentifier - 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 @@ -24,17 +35,14 @@ function getKeyInfo(keyPem) { const result = { isRSA: false, isECDSA: false, - signatureAlgorithm: null, publicKey: crypto.createPublicKey(keyPem) }; if (result.publicKey.asymmetricKeyType === 'rsa') { result.isRSA = true; - result.signatureAlgorithm = 'SHA256withRSA'; } else if (result.publicKey.asymmetricKeyType === 'ec') { result.isECDSA = true; - result.signatureAlgorithm = 'SHA256withECDSA'; } else { throw new Error('Unable to parse key information, unknown format'); @@ -169,24 +177,42 @@ exports.getJwk = getJwk; /** - * Fix missing support for NIST curve names in jsrsasign + * Produce CryptoKeyPair and signing algorithm from a PEM encoded private key * * @private - * @param {string} crv NIST curve name - * @returns {string} SECG curve name + * @param {buffer|string} keyPem PEM encoded private key + * @returns {Promise} [keyPair, signingAlgorithm] */ -function convertNistCurveNameToSecg(nistName) { - switch (nistName) { - case 'P-256': - return 'secp256r1'; - case 'P-384': - return 'secp384r1'; - case 'P-521': - return 'secp521r1'; - default: - return nistName; +async function getWebCryptoKeyPair(keyPem) { + const info = getKeyInfo(keyPem); + const jwk = getJwk(keyPem); + + /* Signing algorithm */ + const sigalg = { + name: 'RSASSA-PKCS1-v1_5', + hash: { name: 'SHA-256' } + }; + + if (info.isECDSA) { + sigalg.name = 'ECDSA'; + sigalg.namedCurve = jwk.crv; + + if (jwk.crv === 'P-384') { + sigalg.hash.name = 'SHA-384'; + } + + if (jwk.crv === 'P-521') { + sigalg.hash.name = 'SHA-512'; + } } + + /* Decode PEM and import into CryptoKeyPair */ + const privateKeyDec = x509.PemConverter.decodeFirst(keyPem.toString()); + const privateKey = await crypto.webcrypto.subtle.importKey('pkcs8', privateKeyDec, sigalg, true, ['sign']); + const publicKey = await crypto.webcrypto.subtle.importKey('jwk', jwk, sigalg, true, ['verify']); + + return [{ privateKey, publicKey }, sigalg]; } @@ -194,7 +220,7 @@ function convertNistCurveNameToSecg(nistName) { * Split chain of PEM encoded objects from string into array * * @param {buffer|string} chainPem PEM encoded object chain - * @returns {array} Array of PEM objects including headers + * @returns {string[]} Array of PEM objects including headers */ function splitPemChain(chainPem) { @@ -202,15 +228,9 @@ function splitPemChain(chainPem) { chainPem = chainPem.toString(); } - return chainPem - /* Split chain into chunks, starting at every header */ - .split(/\s*(?=-----BEGIN [A-Z0-9- ]+-----\r?\n?)/g) - /* Match header, PEM body and footer */ - .map((pem) => pem.match(/\s*-----BEGIN ([A-Z0-9- ]+)-----\r?\n?([\S\s]+)\r?\n?-----END \1-----/)) - /* Filter out non-matches or empty bodies */ - .filter((pem) => pem && pem[2] && pem[2].replace(/[\r\n]+/g, '').trim()) - /* Decode to hex, and back to PEM for formatting etc */ - .map(([pem, header]) => jsrsasign.hextopem(jsrsasign.pemtohex(pem, header), header)); + /* Decode into array and re-encode */ + return x509.PemConverter.decodeWithHeaders(chainPem) + .map((params) => x509.PemConverter.encode([params])); } exports.splitPemChain = splitPemChain; @@ -231,43 +251,28 @@ exports.getPemBodyAsB64u = (pem) => { throw new Error('Unable to parse PEM body from string'); } - /* First object, hex and back to b64 without new lines */ - return jsrsasign.hextob64u(jsrsasign.pemtohex(chain[0])); + /* Select first object, extract body and convert to b64u */ + const dec = x509.PemConverter.decodeFirst(chain[0]); + return Buffer.from(dec).toString('base64url'); }; -/** - * Parse common name from a subject object - * - * @private - * @param {object} subj Subject returned from jsrsasign - * @returns {string} Common name value - */ - -function parseCommonName(subj) { - const subjectArr = (subj && subj.array) ? subj.array : []; - const cnArr = subjectArr.find((s) => (s[0] && s[0].type && s[0].value && (s[0].type === 'CN'))); - return (cnArr && cnArr.length && cnArr[0].value) ? cnArr[0].value : null; -} - - /** * Parse domains from a certificate or CSR * * @private - * @param {object} params Certificate or CSR params returned from jsrsasign + * @param {object} input x509.Certificate or x509.Pkcs10CertificateRequest * @returns {object} {commonName, altNames} */ -function parseDomains(params) { - const commonName = parseCommonName(params.subject); - const extensionArr = (params.ext || params.extreq || []); +function parseDomains(input) { + const commonName = input.subjectName.getField('CN').pop() || null; + const altNamesRaw = input.getExtension(subjectAltNameOID); let altNames = []; - if (extensionArr && extensionArr.length) { - const altNameExt = extensionArr.find((e) => (e.extname && (e.extname === 'subjectAltName'))); - const altNameArr = (altNameExt && altNameExt.array && altNameExt.array.length) ? altNameExt.array : []; - altNames = altNameArr.map((a) => Object.values(a)[0] || null).filter((a) => a); + if (altNamesRaw) { + const altNamesExt = new x509.SubjectAlternativeNameExtension(altNamesRaw.rawData); + altNames = altNames.concat(altNamesExt.names.items.map((i) => i.value)); } return { @@ -297,9 +302,9 @@ exports.readCsrDomains = (csrPem) => { csrPem = csrPem.toString(); } - /* Parse CSR */ - const params = jsrsasign.KJUR.asn1.csr.CSRUtil.getParam(csrPem); - return parseDomains(params); + const dec = x509.PemConverter.decodeFirst(csrPem); + const csr = new x509.Pkcs10CertificateRequest(dec); + return parseDomains(csr); }; @@ -324,48 +329,43 @@ exports.readCsrDomains = (csrPem) => { */ exports.readCertificateInfo = (certPem) => { - const chain = splitPemChain(certPem); - - if (!chain.length) { - throw new Error('Unable to parse PEM body from string'); + if (Buffer.isBuffer(certPem)) { + certPem = certPem.toString(); } - /* Parse certificate */ - const obj = new jsrsasign.X509(); - obj.readCertPEM(chain[0]); - const params = obj.getParam(); + const dec = x509.PemConverter.decodeFirst(certPem); + const cert = new x509.X509Certificate(dec); return { issuer: { - commonName: parseCommonName(params.issuer) + commonName: cert.issuerName.getField('CN').pop() || null }, - domains: parseDomains(params), - notBefore: jsrsasign.zulutodate(params.notbefore), - notAfter: jsrsasign.zulutodate(params.notafter) + domains: parseDomains(cert), + notBefore: cert.notBefore, + notAfter: cert.notAfter }; }; /** - * Determine ASN.1 character string type for CSR subject field + * Determine ASN.1 character string type for CSR subject field name * - * https://tools.ietf.org/html/rfc5280 - * https://github.com/kjur/jsrsasign/blob/2613c64559768b91dde9793dfa318feacb7c3b8a/src/x509-1.1.js#L2404-L2412 - * https://github.com/kjur/jsrsasign/blob/2613c64559768b91dde9793dfa318feacb7c3b8a/src/asn1x509-1.0.js#L3526-L3535 + * https://datatracker.ietf.org/doc/html/rfc5280 + * https://github.com/PeculiarVentures/x509/blob/ecf78224fd594abbc2fa83c41565d79874f88e00/src/name.ts#L65-L71 * * @private - * @param {string} field CSR subject field - * @returns {string} ASN.1 jsrsasign character string type + * @param {string} field CSR subject field name + * @returns {string} ASN.1 character string type */ function getCsrAsn1CharStringType(field) { switch (field) { case 'C': - return 'prn'; + return 'printableString'; case 'E': - return 'ia5'; + return 'ia5String'; default: - return 'utf8'; + return 'utf8String'; } } @@ -373,6 +373,8 @@ function getCsrAsn1CharStringType(field) { /** * Create array of subject fields for a Certificate Signing Request * + * https://github.com/PeculiarVentures/x509/blob/ecf78224fd594abbc2fa83c41565d79874f88e00/src/name.ts#L65-L71 + * * @private * @param {object} input Key-value of subject fields * @returns {object[]} Certificate Signing Request subject array @@ -382,7 +384,7 @@ function createCsrSubject(input) { return Object.entries(input).reduce((result, [type, value]) => { if (value) { const ds = getCsrAsn1CharStringType(type); - result.push([{ type, value, ds }]); + result.push({ [type]: [{ [ds]: value }] }); } return result; @@ -391,20 +393,20 @@ function createCsrSubject(input) { /** - * Create array of alt names for Certificate Signing Requests + * Create x509 subject alternate name extension * - * https://github.com/kjur/jsrsasign/blob/3edc0070846922daea98d9588978e91d855577ec/src/x509-1.1.js#L1355-L1410 + * https://github.com/PeculiarVentures/x509/blob/ecf78224fd594abbc2fa83c41565d79874f88e00/src/extensions/subject_alt_name.ts * * @private * @param {string[]} altNames Array of alt names - * @returns {object[]} Certificate Signing Request alt names array + * @returns {x509.SubjectAlternativeNameExtension} Subject alternate name extension */ -function formatCsrAltNames(altNames) { - return altNames.map((value) => { - const key = net.isIP(value) ? 'ip' : 'dns'; - return { [key]: value }; - }); +function createSubjectAltNameExtension(altNames) { + return new x509.SubjectAlternativeNameExtension(altNames.map((value) => { + const type = net.isIP(value) ? 'ip' : 'dns'; + return { type, value }; + })); } @@ -414,14 +416,14 @@ function formatCsrAltNames(altNames) { * @param {object} data * @param {number} [data.keySize] Size of newly created RSA private key modulus in bits, default: `2048` * @param {string} [data.commonName] FQDN of your server - * @param {array} [data.altNames] SAN (Subject Alternative Names), default: `[]` + * @param {string[]} [data.altNames] SAN (Subject Alternative Names), default: `[]` * @param {string} [data.country] 2 letter country code * @param {string} [data.state] State or province * @param {string} [data.locality] City * @param {string} [data.organization] Organization name * @param {string} [data.organizationUnit] Organizational unit name * @param {string} [data.emailAddress] Email address - * @param {string} [keyPem] PEM encoded CSR private key + * @param {buffer|string} [keyPem] PEM encoded CSR private key * @returns {Promise} [privateKey, certificateSigningRequest] * * @example Create a Certificate Signing Request @@ -474,53 +476,144 @@ exports.createCsr = async (data, keyPem = null) => { data.altNames = []; } - /* Get key info and JWK */ - const info = getKeyInfo(keyPem); - const jwk = getJwk(keyPem); - const extensionRequests = []; - - /* Missing support for NIST curve names in jsrsasign - https://github.com/kjur/jsrsasign/blob/master/src/asn1x509-1.0.js#L4388-L4393 */ - if (jwk.crv && (jwk.kty === 'EC')) { - jwk.crv = convertNistCurveNameToSecg(jwk.crv); - } - /* Ensure subject common name is present in SAN - https://cabforum.org/wp-content/uploads/BRv1.2.3.pdf */ if (data.commonName && !data.altNames.includes(data.commonName)) { data.altNames.unshift(data.commonName); } - /* Subject */ - const subject = createCsrSubject({ - CN: data.commonName, - C: data.country, - ST: data.state, - L: data.locality, - O: data.organization, - OU: data.organizationUnit, - E: data.emailAddress - }); + /* CryptoKeyPair and signing algorithm from private key */ + const [keys, signingAlgorithm] = await getWebCryptoKeyPair(keyPem); - /* SAN extension */ - if (data.altNames.length) { - extensionRequests.push({ - extname: 'subjectAltName', - array: formatCsrAltNames(data.altNames) - }); - } + const extensions = [ + /* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.3 */ + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment), // eslint-disable-line no-bitwise + + /* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.6 */ + createSubjectAltNameExtension(data.altNames) + ]; /* Create CSR */ - const csr = new jsrsasign.KJUR.asn1.csr.CertificationRequest({ - subject: { array: subject }, - sigalg: info.signatureAlgorithm, - sbjprvkey: keyPem.toString(), - sbjpubkey: jwk, - extreq: extensionRequests + const csr = await x509.Pkcs10CertificateRequestGenerator.create({ + keys, + extensions, + signingAlgorithm, + name: createCsrSubject({ + CN: data.commonName, + C: data.country, + ST: data.state, + L: data.locality, + O: data.organization, + OU: data.organizationUnit, + E: data.emailAddress + }) }); - /* Sign CSR, get PEM */ - csr.sign(); - const pem = csr.getPEM(); - /* Done */ + const pem = csr.toString('pem'); return [keyPem, Buffer.from(pem)]; }; + + +/** + * Create a self-signed ALPN certificate for TLS-ALPN-01 challenges + * + * https://datatracker.ietf.org/doc/html/rfc8737 + * + * @param {object} authz Identifier authorization + * @param {string} keyAuthorization Challenge key authorization + * @param {buffer|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) => { + if (!keyPem) { + keyPem = await createPrivateRsaKey(); + } + else if (!Buffer.isBuffer(keyPem)) { + keyPem = Buffer.from(keyPem); + } + + const now = new Date(); + const commonName = authz.identifier.value; + + /* Pseudo-random serial - max 20 bytes, 11 for epoch (year 5138), 9 random */ + const random = await randomInt(1, 999999999); + const serialNumber = `${Math.floor(now.getTime() / 1000)}${random}`; + + /* CryptoKeyPair and signing algorithm from private key */ + const [keys, signingAlgorithm] = await getWebCryptoKeyPair(keyPem); + + const extensions = [ + /* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.3 */ + new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), // eslint-disable-line no-bitwise + + /* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.9 */ + new x509.BasicConstraintsExtension(true, 2, true), + + /* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.2 */ + await x509.SubjectKeyIdentifierExtension.create(keys.publicKey), + + /* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.6 */ + createSubjectAltNameExtension([commonName]) + ]; + + /* ALPN extension */ + const payload = crypto.createHash('sha256').update(keyAuthorization).digest('hex'); + const octstr = new asn1js.OctetString({ valueHex: Buffer.from(payload, 'hex') }); + extensions.push(new x509.Extension(alpnAcmeIdentifierOID, true, octstr.toBER())); + + /* Self-signed ALPN certificate */ + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + keys, + signingAlgorithm, + extensions, + serialNumber, + notBefore: now, + notAfter: now, + name: createCsrSubject({ + CN: commonName + }) + }); + + /* Done */ + const pem = cert.toString('pem'); + return [keyPem, 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 expected = crypto.createHash('sha256').update(keyAuthorization).digest('hex'); + + /* Attempt to locate ALPN extension */ + const cert = new x509.X509Certificate(certPem); + const ext = cert.getExtension(alpnAcmeIdentifierOID); + + if (!ext) { + throw new Error('Unable to locate ALPN extension within parsed certificate'); + } + + /* Decode extension value */ + const parsed = asn1js.fromBER(ext.value); + const result = Buffer.from(parsed.result.valueBlock.valueHexView).toString('hex'); + + /* Return true if match */ + return (result === expected); +}; diff --git a/packages/core/acme-client/src/http.js b/packages/core/acme-client/src/http.js index 49f79322..b3580263 100644 --- a/packages/core/acme-client/src/http.js +++ b/packages/core/acme-client/src/http.js @@ -64,7 +64,7 @@ class HttpClient { /** * Ensure provider directory exists * - * https://tools.ietf.org/html/rfc8555#section-7.1.1 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.1.1 * * @returns {Promise} */ @@ -104,7 +104,7 @@ class HttpClient { /** * Get nonce from directory API endpoint * - * https://tools.ietf.org/html/rfc8555#section-7.2 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.2 * * @returns {Promise} nonce */ @@ -267,7 +267,7 @@ class HttpClient { /** * Signed HTTP request * - * https://tools.ietf.org/html/rfc8555#section-6.2 + * https://datatracker.ietf.org/doc/html/rfc8555#section-6.2 * * @param {string} url Request URL * @param {object} payload Request payload @@ -299,7 +299,7 @@ class HttpClient { const data = this.createSignedBody(url, payload, { nonce, kid }); const resp = await this.request(url, 'post', { data }); - /* Retry on bad nonce - https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-6.4 */ + /* Retry on bad nonce - https://datatracker.ietf.org/doc/html/draft-ietf-acme-acme-10#section-6.4 */ if (resp.data && resp.data.type && (resp.status === 400) && (resp.data.type === 'urn:ietf:params:acme:error:badNonce') && (attempts < this.maxBadNonceRetries)) { nonce = resp.headers['replay-nonce'] || null; attempts += 1; diff --git a/packages/core/acme-client/src/util.js b/packages/core/acme-client/src/util.js index 95d08e81..c0c56370 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'); @@ -92,7 +93,7 @@ function retry(fn, { attempts = 5, min = 5000, max = 30000 } = {}) { * * @param {string} header Link header contents * @param {string} rel Link relation, default: `alternate` - * @returns {array} Array of URLs + * @returns {string[]} Array of URLs */ function parseLinkHeader(header, rel = 'alternate') { @@ -112,7 +113,7 @@ function parseLinkHeader(header, rel = 'alternate') { * - If issuer is found in multiple chains, the closest to root wins * - If issuer can not be located, the first chain will be returned * - * @param {array} certificates Array of PEM encoded certificate chains + * @param {string[]} certificates Array of PEM encoded certificate chains * @param {string} issuer Preferred certificate issuer * @returns {string} PEM encoded certificate chain */ @@ -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 fe76cab8..c0456497 100644 --- a/packages/core/acme-client/src/verify.js +++ b/packages/core/acme-client/src/verify.js @@ -3,15 +3,17 @@ */ const dns = require('dns').promises; +const https = require('https'); const { log } = require('./logger'); const axios = require('./axios'); const util = require('./util'); +const { isAlpnCertificateAuthorizationValid } = require('./crypto'); /** * Verify ACME HTTP challenge * - * https://tools.ietf.org/html/rfc8555#section-8.3 + * https://datatracker.ietf.org/doc/html/rfc8555#section-8.3 * * @param {object} authz Identifier authorization * @param {object} challenge Authorization challenge @@ -24,8 +26,11 @@ async function verifyHttpChallenge(authz, challenge, keyAuthorization, suffix = const httpPort = axios.defaults.acmeSettings.httpChallengePort || 80; const challengeUrl = `http://${authz.identifier.value}:${httpPort}${suffix}`; + /* May redirect to HTTPS with invalid/self-signed cert - https://letsencrypt.org/docs/challenge-types/#http-01-challenge */ + const httpsAgent = new https.Agent({ rejectUnauthorized: false }); + log(`Sending HTTP query to ${authz.identifier.value}, suffix: ${suffix}, port: ${httpPort}`); - const resp = await axios.get(challengeUrl); + const resp = await axios.get(challengeUrl, { httpsAgent }); const data = (resp.data || '').replace(/\s+$/, ''); log(`Query successful, HTTP status code: ${resp.status}`); @@ -80,7 +85,7 @@ async function walkDnsChallengeRecord(recordName, resolver = dns) { /** * Verify ACME DNS challenge * - * https://tools.ietf.org/html/rfc8555#section-8.4 + * https://datatracker.ietf.org/doc/html/rfc8555#section-8.4 * * @param {object} authz Identifier authorization * @param {object} challenge Authorization challenge @@ -117,11 +122,40 @@ async function verifyDnsChallenge(authz, challenge, keyAuthorization, prefix = ' } +/** + * Verify ACME TLS ALPN challenge + * + * https://datatracker.ietf.org/doc/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 789c0a4c..992fc6bd 100644 --- a/packages/core/acme-client/test/00-pebble.spec.js +++ b/packages/core/acme-client/test/00-pebble.spec.js @@ -3,16 +3,23 @@ */ const dns = require('dns').promises; +const { randomUUID: uuid } = require('crypto'); +const https = require('https'); const { assert } = require('chai'); -const { v4: uuid } = require('uuid'); 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', () => { + const httpsAgent = new https.Agent({ rejectUnauthorized: false }); + const testAHost = `${uuid()}.${domainName}`; const testARecords = ['1.1.1.1', '2.2.2.2']; const testCnameHost = `${uuid()}.${domainName}`; @@ -21,9 +28,17 @@ describe('pebble', () => { const testHttp01ChallengeHost = `${uuid()}.${domainName}`; const testHttp01ChallengeToken = uuid(); const testHttp01ChallengeContent = uuid(); + + const testHttps01ChallengeHost = `${uuid()}.${domainName}`; + const testHttps01ChallengeToken = uuid(); + const testHttps01ChallengeContent = uuid(); + const testDns01ChallengeHost = `_acme-challenge.${uuid()}.${domainName}.`; const testDns01ChallengeValue = uuid(); + const testTlsAlpn01ChallengeHost = `${uuid()}.${domainName}`; + const testTlsAlpn01ChallengeValue = uuid(); + /** * Pebble CTS required @@ -79,43 +94,122 @@ describe('pebble', () => { /** - * Challenge response + * HTTP-01 challenge response */ - describe('challenges', () => { - it('should not locate http-01 challenge response', async () => { + describe('http-01', () => { + it('should not locate challenge response', async () => { const resp = await axios.get(`http://${testHttp01ChallengeHost}:${httpPort}/.well-known/acme-challenge/${testHttp01ChallengeToken}`); assert.isString(resp.data); assert.notEqual(resp.data, testHttp01ChallengeContent); }); - it('should add http-01 challenge response', async () => { + it('should add challenge response', async () => { const resp = await cts.addHttp01ChallengeResponse(testHttp01ChallengeToken, testHttp01ChallengeContent); assert.isTrue(resp); }); - it('should locate http-01 challenge response', async () => { + it('should locate challenge response', async () => { const resp = await axios.get(`http://${testHttp01ChallengeHost}:${httpPort}/.well-known/acme-challenge/${testHttp01ChallengeToken}`); assert.isString(resp.data); assert.strictEqual(resp.data, testHttp01ChallengeContent); }); + }); - it('should not locate dns-01 challenge response', async () => { + + /** + * HTTPS-01 challenge response + */ + + describe('https-01', () => { + it('should not locate challenge response', async () => { + const r1 = await axios.get(`http://${testHttps01ChallengeHost}:${httpPort}/.well-known/acme-challenge/${testHttps01ChallengeToken}`, { httpsAgent }); + const r2 = await axios.get(`https://${testHttps01ChallengeHost}:${httpsPort}/.well-known/acme-challenge/${testHttps01ChallengeToken}`, { httpsAgent }); + + [r1, r2].forEach((resp) => { + assert.isString(resp.data); + assert.notEqual(resp.data, testHttps01ChallengeContent); + }); + }); + + it('should add challenge response', async () => { + const resp = await cts.addHttps01ChallengeResponse(testHttps01ChallengeToken, testHttps01ChallengeContent, testHttps01ChallengeHost); + assert.isTrue(resp); + }); + + it('should 302 with self-signed cert', async () => { + /* Assert HTTP 302 */ + const resp = await axios.get(`http://${testHttps01ChallengeHost}:${httpPort}/.well-known/acme-challenge/${testHttps01ChallengeToken}`, { + maxRedirects: 0, + validateStatus: null + }); + + assert.strictEqual(resp.status, 302); + assert.strictEqual(resp.headers.location, `https://${testHttps01ChallengeHost}:${httpsPort}/.well-known/acme-challenge/${testHttps01ChallengeToken}`); + + /* Self-signed cert test */ + await assert.isRejected(axios.get(`https://${testHttps01ChallengeHost}:${httpsPort}/.well-known/acme-challenge/${testHttps01ChallengeToken}`)); + await assert.isFulfilled(axios.get(`https://${testHttps01ChallengeHost}:${httpsPort}/.well-known/acme-challenge/${testHttps01ChallengeToken}`, { httpsAgent })); + }); + + it('should locate challenge response', async () => { + const r1 = await axios.get(`http://${testHttps01ChallengeHost}:${httpPort}/.well-known/acme-challenge/${testHttps01ChallengeToken}`, { httpsAgent }); + const r2 = await axios.get(`https://${testHttps01ChallengeHost}:${httpsPort}/.well-known/acme-challenge/${testHttps01ChallengeToken}`, { httpsAgent }); + + [r1, r2].forEach((resp) => { + assert.isString(resp.data); + assert.strictEqual(resp.data, testHttps01ChallengeContent); + }); + }); + }); + + + /** + * DNS-01 challenge response + */ + + describe('dns-01', () => { + it('should not locate challenge response', async () => { await assert.isRejected(dns.resolveTxt(testDns01ChallengeHost)); }); - it('should add dns-01 challenge response', async () => { + it('should add challenge response', async () => { const resp = await cts.addDns01ChallengeResponse(testDns01ChallengeHost, testDns01ChallengeValue); assert.isTrue(resp); }); - it('should locate dns-01 challenge response', async () => { + it('should locate challenge response', async () => { const resp = await dns.resolveTxt(testDns01ChallengeHost); assert.isArray(resp); 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)); + }); + + 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-http.spec.js b/packages/core/acme-client/test/10-http.spec.js index e8163af5..a2a54de4 100644 --- a/packages/core/acme-client/test/10-http.spec.js +++ b/packages/core/acme-client/test/10-http.spec.js @@ -2,8 +2,8 @@ * HTTP client tests */ +const { randomUUID: uuid } = require('crypto'); const { assert } = require('chai'); -const { v4: uuid } = require('uuid'); const nock = require('nock'); const axios = require('./../src/axios'); const HttpClient = require('./../src/http'); @@ -26,8 +26,6 @@ describe('http', () => { */ before(() => { - axios.defaults.acmeSettings.bypassCustomDnsResolver = true; - const defaultUaOpts = { reqheaders: { 'User-Agent': defaultUserAgent } }; const customUaOpts = { reqheaders: { 'User-Agent': customUserAgent } }; @@ -43,7 +41,6 @@ describe('http', () => { after(() => { axios.defaults.headers.common['User-Agent'] = defaultUserAgent; - axios.defaults.acmeSettings.bypassCustomDnsResolver = false; }); diff --git a/packages/core/acme-client/test/10-verify.spec.js b/packages/core/acme-client/test/10-verify.spec.js index adbd370e..284cc3a0 100644 --- a/packages/core/acme-client/test/10-verify.spec.js +++ b/packages/core/acme-client/test/10-verify.spec.js @@ -2,8 +2,8 @@ * Challenge verification tests */ +const { randomUUID: uuid } = require('crypto'); const { assert } = require('chai'); -const { v4: uuid } = require('uuid'); const cts = require('./challtestsrv'); const verify = require('./../src/verify'); @@ -17,11 +17,19 @@ describe('verify', () => { const testHttp01Challenge = { type: 'http-01', status: 'pending', token: uuid() }; const testHttp01Key = uuid(); + const testHttps01Authz = { identifier: { type: 'dns', value: `${uuid()}.${domainName}` } }; + const testHttps01Challenge = { type: 'http-01', status: 'pending', token: uuid() }; + const testHttps01Key = uuid(); + const testDns01Authz = { identifier: { type: 'dns', value: `${uuid()}.${domainName}` } }; const testDns01Challenge = { type: 'dns-01', status: 'pending', token: uuid() }; 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 @@ -74,6 +82,27 @@ describe('verify', () => { }); + /** + * https-01 + */ + + describe('https-01', () => { + it('should reject challenge', async () => { + await assert.isRejected(verify['http-01'](testHttps01Authz, testHttps01Challenge, testHttps01Key)); + }); + + it('should mock challenge response', async () => { + const resp = await cts.addHttps01ChallengeResponse(testHttps01Challenge.token, testHttps01Key, testHttps01Authz.identifier.value); + assert.isTrue(resp); + }); + + it('should verify challenge', async () => { + const resp = await verify['http-01'](testHttps01Authz, testHttps01Challenge, testHttps01Key); + assert.isTrue(resp); + }); + }); + + /** * dns-01 */ @@ -103,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..71e5277f 100644 --- a/packages/core/acme-client/test/20-crypto.spec.js +++ b/packages/core/acme-client/test/20-crypto.spec.js @@ -10,10 +10,10 @@ const { crypto } = require('./../'); const emptyBodyChain1 = ` -----BEGIN TEST----- -a +dGVzdGluZ3Rlc3Rpbmd0ZXN0aW5ndGVzdGluZ3Rlc3Rpbmd0ZXN0aW5ndGVzdGluZ3Rlc3Rpbmd0ZXN0aW5ndGVzdGluZw== -----END TEST----- -----BEGIN TEST----- -b +dGVzdGluZ3Rlc3Rpbmd0ZXN0aW5ndGVzdGluZ3Rlc3Rpbmd0ZXN0aW5ndGVzdGluZ3Rlc3Rpbmd0ZXN0aW5ndGVzdGluZw== -----END TEST----- -----BEGIN TEST----- @@ -22,7 +22,7 @@ b -----BEGIN TEST----- -c +dGVzdGluZ3Rlc3Rpbmd0ZXN0aW5ndGVzdGluZ3Rlc3Rpbmd0ZXN0aW5ndGVzdGluZ3Rlc3Rpbmd0ZXN0aW5ndGVzdGluZw== -----END TEST----- `; @@ -38,15 +38,15 @@ const emptyBodyChain2 = ` -----END TEST----- -----BEGIN TEST----- -a +dGVzdGluZ3Rlc3Rpbmd0ZXN0aW5ndGVzdGluZ3Rlc3Rpbmd0ZXN0aW5ndGVzdGluZ3Rlc3Rpbmd0ZXN0aW5ndGVzdGluZw== -----END TEST----- -----BEGIN TEST----- -b +dGVzdGluZ3Rlc3Rpbmd0ZXN0aW5ndGVzdGluZ3Rlc3Rpbmd0ZXN0aW5ndGVzdGluZ3Rlc3Rpbmd0ZXN0aW5ndGVzdGluZw== -----END TEST----- -----BEGIN TEST----- -c +dGVzdGluZ3Rlc3Rpbmd0ZXN0aW5ndGVzdGluZ3Rlc3Rpbmd0ZXN0aW5ndGVzdGluZ3Rlc3Rpbmd0ZXN0aW5ndGVzdGluZw== -----END TEST----- `; @@ -95,6 +95,7 @@ describe('crypto', () => { let testSanCsr; let testNonCnCsr; let testNonAsciiCsr; + let testAlpnCertificate; /** @@ -111,6 +112,11 @@ describe('crypto', () => { assert.isTrue(Buffer.isBuffer(testPublicKeys[n])); }); + it(`${n}/should get public key from string`, () => { + testPublicKeys[n] = crypto.getPublicKey(testPrivateKeys[n].toString()); + assert.isTrue(Buffer.isBuffer(testPublicKeys[n])); + }); + it(`${n}/should get jwk from private key`, () => { const jwk = crypto.getJwk(testPrivateKeys[n]); jwkSpecFn(jwk); @@ -121,6 +127,11 @@ describe('crypto', () => { jwkSpecFn(jwk); }); + it(`${n}/should get jwk from string`, () => { + const jwk = crypto.getJwk(testPrivateKeys[n].toString()); + jwkSpecFn(jwk); + }); + /** * Certificate Signing Request @@ -173,6 +184,15 @@ describe('crypto', () => { testNonAsciiCsr = csr; }); + it(`${n}/should generate a csr with key as string`, async () => { + const [key, csr] = await crypto.createCsr({ + commonName: testCsrDomain + }, testPrivateKeys[n].toString()); + + assert.isTrue(Buffer.isBuffer(key)); + assert.isTrue(Buffer.isBuffer(csr)); + }); + it(`${n}/should throw with invalid key`, async () => { await assert.isRejected(crypto.createCsr({ commonName: testCsrDomain @@ -215,6 +235,51 @@ describe('crypto', () => { assert.strictEqual(result.commonName, testCsrDomain); assert.deepStrictEqual(result.altNames, [testCsrDomain]); }); + + it(`${n}/should resolve domains from csr string`, () => { + [testCsr, testSanCsr, testNonCnCsr, testNonAsciiCsr].forEach((csr) => { + const result = crypto.readCsrDomains(csr.toString()); + spec.crypto.csrDomains(result); + }); + }); + + + /** + * 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 generate alpn certificate with key as string`, async () => { + const k = await createFn(); + const authz = { identifier: { value: 'test.example.com' } }; + const [key, cert] = await crypto.createAlpnCertificate(authz, 'super-secret.12345', k.toString()); + + assert.isTrue(Buffer.isBuffer(key)); + assert.isTrue(Buffer.isBuffer(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')); + }); + + it(`${n}/should validate valid alpn certificate with cert as string`, () => { + assert.isTrue(crypto.isAlpnCertificateAuthorizationValid(testAlpnCertificate.toString(), 'super-secret.12345')); + }); }); }); }); @@ -250,7 +315,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 }); @@ -280,6 +345,26 @@ describe('crypto', () => { assert.deepEqual(info.domains.altNames, testSanCsrDomains.slice(1, testSanCsrDomains.length)); }); + it('should read certificate info from string', () => { + [testCert, testSanCert].forEach((cert) => { + const info = crypto.readCertificateInfo(cert.toString()); + spec.crypto.certificateInfo(info); + }); + }); + + + /** + * 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 @@ -296,6 +381,17 @@ describe('crypto', () => { }); }); + it('should get pem body as b64u from string', () => { + [testPemKey, testCert, testSanCert].forEach((pem) => { + const body = crypto.getPemBodyAsB64u(pem.toString()); + + assert.isString(body); + assert.notInclude(body, '\r'); + assert.notInclude(body, '\n'); + assert.notInclude(body, '\r\n'); + }); + }); + it('should split pem chain', () => { [testPemKey, testCert, testSanCert].forEach((pem) => { const chain = crypto.splitPemChain(pem); @@ -306,6 +402,16 @@ describe('crypto', () => { }); }); + it('should split pem chain from string', () => { + [testPemKey, testCert, testSanCert].forEach((pem) => { + const chain = crypto.splitPemChain(pem.toString()); + + assert.isArray(chain); + assert.isNotEmpty(chain); + chain.forEach((c) => assert.isString(c)); + }); + }); + it('should split pem chain with empty bodies', () => { const c1 = crypto.splitPemChain(emptyBodyChain1); const c2 = crypto.splitPemChain(emptyBodyChain2); diff --git a/packages/core/acme-client/test/50-client.spec.js b/packages/core/acme-client/test/50-client.spec.js index cb162644..31784ca3 100644 --- a/packages/core/acme-client/test/50-client.spec.js +++ b/packages/core/acme-client/test/50-client.spec.js @@ -2,8 +2,8 @@ * ACME client tests */ +const { randomUUID: uuid } = require('crypto'); const { assert } = require('chai'); -const { v4: uuid } = require('uuid'); const cts = require('./challtestsrv'); const getCertIssuers = require('./get-cert-issuers'); const spec = require('./spec'); @@ -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 5b0b6f18..9d5742c9 100644 --- a/packages/core/acme-client/test/70-auto.spec.js +++ b/packages/core/acme-client/test/70-auto.spec.js @@ -2,8 +2,8 @@ * ACME client.auto tests */ +const { randomUUID: uuid } = require('crypto'); const { assert } = require('chai'); -const { v4: uuid } = require('uuid'); const cts = require('./challtestsrv'); const getCertIssuers = require('./get-cert-issuers'); const spec = require('./spec'); @@ -32,7 +32,9 @@ if (capEabEnabled && process.env.ACME_EAB_KID && process.env.ACME_EAB_HMAC_KEY) describe('client.auto', () => { const testDomain = `${uuid()}.${domainName}`; const testHttpDomain = `${uuid()}.${domainName}`; + const testHttpsDomain = `${uuid()}.${domainName}`; const testDnsDomain = `${uuid()}.${domainName}`; + const testAlpnDomain = `${uuid()}.${domainName}`; const testWildcardDomain = `${uuid()}.${domainName}`; const testSanDomains = [ @@ -178,6 +180,38 @@ describe('client.auto', () => { assert.isString(cert); }); + it('should settle all challenges before rejecting', async () => { + const results = []; + const [, csr] = await acme.crypto.createCsr({ + commonName: `${uuid()}.${domainName}`, + altNames: [ + `${uuid()}.${domainName}`, + `${uuid()}.${domainName}`, + `${uuid()}.${domainName}`, + `${uuid()}.${domainName}` + ] + }, await createKeyFn()); + + await assert.isRejected(testClient.auto({ + csr, + termsOfServiceAgreed: true, + challengeCreateFn: async (...args) => { + if ([0, 1, 2].includes(results.length)) { + results.push(false); + throw new Error('oops'); + } + + await new Promise((resolve) => { setTimeout(resolve, 500); }); + results.push(true); + return cts.challengeCreateFn(...args); + }, + challengeRemoveFn: cts.challengeRemoveFn + })); + + assert.strictEqual(results.length, 5); + assert.deepStrictEqual(results, [false, false, false, true, true]); + }); + /** * Order certificates @@ -215,6 +249,22 @@ describe('client.auto', () => { assert.isString(cert); }); + it('should order certificate using https-01', async () => { + const [, csr] = await acme.crypto.createCsr({ + commonName: testHttpsDomain + }, await createKeyFn()); + + const cert = await testClient.auto({ + csr, + termsOfServiceAgreed: true, + challengeCreateFn: cts.assertHttpsChallengeCreateFn, + challengeRemoveFn: cts.challengeRemoveFn, + challengePriority: ['http-01'] + }); + + assert.isString(cert); + }); + it('should order certificate using dns-01', async () => { const [, csr] = await acme.crypto.createCsr({ commonName: testDnsDomain @@ -231,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 1a372959..71ed91b6 100644 --- a/packages/core/acme-client/test/challtestsrv.js +++ b/packages/core/acme-client/test/challtestsrv.js @@ -6,6 +6,7 @@ const { assert } = require('chai'); const axios = require('./../src/axios'); const apiBaseUrl = process.env.ACME_CHALLTESTSRV_URL || null; +const httpsPort = axios.defaults.acmeSettings.httpsChallengePort || 443; /** @@ -50,12 +51,26 @@ async function addHttp01ChallengeResponse(token, content) { return request('add-http01', { token, content }); } +async function addHttps01ChallengeResponse(token, content, targetHostname) { + await addHttp01ChallengeResponse(token, content); + return request('add-redirect', { + path: `/.well-known/acme-challenge/${token}`, + targetURL: `https://${targetHostname}:${httpsPort}/.well-known/acme-challenge/${token}` + }); +} + 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; /** @@ -67,11 +82,21 @@ async function assertHttpChallengeCreateFn(authz, challenge, keyAuthorization) { return addHttp01ChallengeResponse(challenge.token, keyAuthorization); } +async function assertHttpsChallengeCreateFn(authz, challenge, keyAuthorization) { + assert.strictEqual(challenge.type, 'http-01'); + return addHttps01ChallengeResponse(challenge.token, keyAuthorization, authz.identifier.value); +} + async function assertDnsChallengeCreateFn(authz, challenge, keyAuthorization) { assert.strictEqual(challenge.type, 'dns-01'); 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); @@ -81,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}`); } @@ -89,5 +118,7 @@ exports.challengeNoopFn = async () => true; 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 e6f18647..4e0f5bee 100644 --- a/packages/core/acme-client/test/setup.js +++ b/packages/core/acme-client/test/setup.js @@ -2,10 +2,7 @@ * Setup testing */ -const url = require('url'); -const net = require('net'); const fs = require('fs'); -const dns = require('dns').promises; const chai = require('chai'); const chaiAsPromised = require('chai-as-promised'); const axios = require('./../src/axios'); @@ -19,13 +16,21 @@ chai.use(chaiAsPromised); /** - * HTTP challenge port + * Challenge test server ports */ if (process.env.ACME_HTTP_PORT) { axios.defaults.acmeSettings.httpChallengePort = process.env.ACME_HTTP_PORT; } +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 @@ -38,50 +43,3 @@ if (('ACME_CAP_EAB_ENABLED' in process.env) && (process.env.ACME_CAP_EAB_ENABLED process.env.ACME_EAB_KID = kid; process.env.ACME_EAB_HMAC_KEY = hmacKey; } - - -/** - * Custom DNS resolver - */ - -if (process.env.ACME_DNS_RESOLVER) { - dns.setServers([process.env.ACME_DNS_RESOLVER]); - - - /** - * Axios DNS resolver - */ - - axios.interceptors.request.use(async (config) => { - const urlObj = url.parse(config.url); - - /* Bypass */ - if (axios.defaults.acmeSettings.bypassCustomDnsResolver === true) { - return config; - } - - /* Skip IP addresses and localhost */ - if (net.isIP(urlObj.hostname) || (urlObj.hostname === 'localhost')) { - return config; - } - - /* Lookup hostname */ - const result = await dns.resolve4(urlObj.hostname); - - if (!result.length) { - throw new Error(`Unable to lookup address: ${urlObj.hostname}`); - } - - /* Place hostname in header */ - config.headers = config.headers || {}; - config.headers.Host = urlObj.hostname; - - /* Inject address into URL */ - delete urlObj.host; - urlObj.hostname = result[0]; - config.url = url.format(urlObj); - - /* Done */ - return config; - }); -} diff --git a/packages/core/acme-client/types/tsconfig.json b/packages/core/acme-client/tsconfig.json similarity index 100% rename from packages/core/acme-client/types/tsconfig.json rename to packages/core/acme-client/tsconfig.json diff --git a/packages/core/acme-client/types/index.d.ts b/packages/core/acme-client/types/index.d.ts index cb2f2788..3a58ad02 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; diff --git a/packages/core/acme-client/types/test.ts b/packages/core/acme-client/types/index.test-d.ts similarity index 100% rename from packages/core/acme-client/types/test.ts rename to packages/core/acme-client/types/index.test-d.ts diff --git a/packages/core/acme-client/types/rfc8555.d.ts b/packages/core/acme-client/types/rfc8555.d.ts index 51b6f3d9..2f88ab75 100644 --- a/packages/core/acme-client/types/rfc8555.d.ts +++ b/packages/core/acme-client/types/rfc8555.d.ts @@ -1,9 +1,9 @@ /** * Account * - * https://tools.ietf.org/html/rfc8555#section-7.1.2 - * https://tools.ietf.org/html/rfc8555#section-7.3 - * https://tools.ietf.org/html/rfc8555#section-7.3.2 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.1.2 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.3 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.2 */ export interface Account { @@ -31,8 +31,8 @@ export interface AccountUpdateRequest { /** * Order * - * https://tools.ietf.org/html/rfc8555#section-7.1.3 - * https://tools.ietf.org/html/rfc8555#section-7.4 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.1.3 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.4 */ export interface Order { @@ -57,7 +57,7 @@ export interface OrderCreateRequest { /** * Authorization * - * https://tools.ietf.org/html/rfc8555#section-7.1.4 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.1.4 */ export interface Authorization { @@ -77,9 +77,9 @@ export interface Identifier { /** * Challenge * - * https://tools.ietf.org/html/rfc8555#section-8 - * https://tools.ietf.org/html/rfc8555#section-8.3 - * https://tools.ietf.org/html/rfc8555#section-8.4 + * https://datatracker.ietf.org/doc/html/rfc8555#section-8 + * https://datatracker.ietf.org/doc/html/rfc8555#section-8.3 + * https://datatracker.ietf.org/doc/html/rfc8555#section-8.4 */ export interface ChallengeAbstract { @@ -106,7 +106,7 @@ export type Challenge = HttpChallenge | DnsChallenge; /** * Certificate * - * https://tools.ietf.org/html/rfc8555#section-7.6 + * https://datatracker.ietf.org/doc/html/rfc8555#section-7.6 */ export enum CertificateRevocationReason { diff --git a/packages/core/acme-client/types/tslint.json b/packages/core/acme-client/types/tslint.json deleted file mode 100644 index e650aab5..00000000 --- a/packages/core/acme-client/types/tslint.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "dtslint/dtslint.json", - "rules": { - "no-consecutive-blank-lines": [true, 2] - } -}