├── .env.example ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.md │ ├── dns-1-provider-request.md │ └── feature_request.md └── workflows │ ├── lint.yml │ ├── pr.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .yamllint.yaml ├── Cargo.toml ├── Justfile ├── LICENSE.md ├── README.md ├── docker-compose.yaml ├── examples ├── dns-01.rs ├── http-01.rs └── tls-alpn-01.rs ├── hack └── seed.py ├── pebble-config.json ├── poetry.lock ├── pyproject.toml ├── renovate.json ├── src ├── account.rs ├── api.rs ├── api │ ├── jws.rs │ ├── nonce.rs │ └── responses.rs ├── certificate.rs ├── directory.rs ├── error.rs ├── lib.rs ├── order.rs ├── solver.rs ├── solver │ ├── common.rs │ ├── dns │ │ ├── cloudflare.rs │ │ └── mod.rs │ ├── http.rs │ ├── tls_alpn.rs │ └── tls_alpn │ │ ├── README.md │ │ ├── error.rs │ │ ├── smoke.rs │ │ └── stream.rs └── test.rs └── testdata ├── .gitignore ├── accounts ├── 1.pem └── 2.pem ├── ecdsa_p-256.pem ├── ecdsa_p-384.pem ├── ecdsa_p-521.pem ├── rsa_2048.pem └── tls-alpn-01 ├── identity.p12 └── root-ca.der /.env.example: -------------------------------------------------------------------------------- 1 | # These environment variables are only used for integration tests 2 | 3 | # DNS-01 Cloudflare 4 | CLOUDFLARE_API_TOKEN= 5 | #CLOUDFLARE_EMAIL= 6 | #CLOUDFLARE_API_KEY= 7 | 8 | DNS01_CF_ZONE=your.domain 9 | DNS01_CF_ZONE_ID=abcdef0123456789 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/dns-1-provider-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: DNS-1 Provider Request 3 | about: Request support for a DNS-01 provider 4 | title: '' 5 | labels: dns-01 provider 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What is the name of the provider?** 11 | Ex: AWS Route53 12 | 13 | **I have checked the following:** 14 | - [ ] there is a way to edit DNS records programmatically 15 | - [ ] there is a client library or HTTP API (please link) 16 | 17 | **Additional context** 18 | Add any other context about the request here. 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Lint 3 | on: push # yamllint disable-line rule:truthy 4 | 5 | jobs: 6 | rust: 7 | name: Rust 8 | runs-on: ubuntu-20.04 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: dtolnay/rust-toolchain@stable 12 | with: 13 | components: rustfmt, clippy 14 | 15 | - run: cargo fmt --check --all 16 | - run: cargo clippy -- -D warnings 17 | 18 | yaml: 19 | name: YAML 20 | runs-on: ubuntu-20.04 21 | steps: 22 | - uses: actions/checkout@v3 23 | - run: pip install yamllint 24 | 25 | - run: yamllint -s -f github . 26 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: PR 3 | on: # yamllint disable-line rule:truthy 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | jobs: 11 | main: 12 | name: Validate title 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: amannn/action-semantic-pull-request@v5 16 | with: 17 | requireScope: false 18 | subjectPattern: ^(?![A-Z]).+$ 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | on: # yamllint disable-line rule:truthy 3 | push: 4 | tags: 5 | - 'v*.*.*' 6 | 7 | jobs: 8 | release: 9 | name: Release 10 | runs-on: ubuntu-20.04 11 | permissions: 12 | contents: write 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: dtolnay/rust-toolchain@stable 16 | 17 | - run: cargo publish 18 | env: 19 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_TOKEN }} 20 | 21 | - uses: softprops/action-gh-release@v1 22 | with: 23 | generate_release_notes: true 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tests 3 | on: push # yamllint disable-line rule:truthy 4 | 5 | jobs: 6 | unit: 7 | name: Unit 8 | runs-on: ubuntu-20.04 9 | steps: 10 | - uses: actions/checkout@v3 11 | 12 | - uses: dtolnay/rust-toolchain@stable 13 | - uses: taiki-e/install-action@just 14 | 15 | - uses: actions/setup-python@v4 16 | with: 17 | python-version: '3.11' 18 | - uses: snok/install-poetry@v1 19 | with: 20 | virtualenvs-create: true 21 | virtualenvs-in-project: true 22 | installer-parallel: true 23 | 24 | - name: Load cached $HOME/.local 25 | uses: actions/cache@v3 26 | with: 27 | path: ~/.local 28 | key: local-${{ runner.os }}-${{ hashFiles('.github/workflows/test.yml') }} 29 | 30 | - name: Load cached venv 31 | id: cached-poetry-dependencies 32 | uses: actions/cache@v3 33 | with: 34 | path: .venv 35 | key: venv-${{ runner.os }}-${{ hashFiles('poetry.lock') }}-${{ hashFiles('.github/workflows/test.yml') }} 36 | 37 | - run: poetry install --no-interaction 38 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 39 | 40 | - run: just test http-01,dns-01,tls-alpn-01 --no-default-features 41 | 42 | examples: 43 | name: Examples 44 | runs-on: ubuntu-20.04 45 | steps: 46 | - uses: actions/checkout@v3 47 | 48 | - uses: dtolnay/rust-toolchain@stable 49 | - run: pip install yq 50 | 51 | - name: Build examples 52 | run: | 53 | for example in $(tomlq -r '.example[] | "\(.name):\(.["required-features"] | join(","))"' Cargo.toml); do 54 | name=$(echo $example | awk -F: '{ print $1 }') 55 | features=$(echo $example | awk -F: '{ print $2 }') 56 | 57 | echo "Building example $name" 58 | cargo build --example $name --features $features 59 | done 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | ### JetBrains+all ### 3 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 4 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 5 | 6 | # User-specific stuff 7 | .idea/**/workspace.xml 8 | .idea/**/tasks.xml 9 | .idea/**/usage.statistics.xml 10 | .idea/**/dictionaries 11 | .idea/**/shelf 12 | 13 | # AWS User-specific 14 | .idea/**/aws.xml 15 | 16 | # Generated files 17 | .idea/**/contentModel.xml 18 | 19 | # Sensitive or high-churn files 20 | .idea/**/dataSources/ 21 | .idea/**/dataSources.ids 22 | .idea/**/dataSources.local.xml 23 | .idea/**/sqlDataSources.xml 24 | .idea/**/dynamic.xml 25 | .idea/**/uiDesigner.xml 26 | .idea/**/dbnavigator.xml 27 | 28 | # Gradle 29 | .idea/**/gradle.xml 30 | .idea/**/libraries 31 | 32 | # Gradle and Maven with auto-import 33 | # When using Gradle or Maven with auto-import, you should exclude module files, 34 | # since they will be recreated, and may cause churn. Uncomment if using 35 | # auto-import. 36 | # .idea/artifacts 37 | # .idea/compiler.xml 38 | # .idea/jarRepositories.xml 39 | # .idea/modules.xml 40 | # .idea/*.iml 41 | # .idea/modules 42 | # *.iml 43 | # *.ipr 44 | 45 | # CMake 46 | cmake-build-*/ 47 | 48 | # Mongo Explorer plugin 49 | .idea/**/mongoSettings.xml 50 | 51 | # File-based project format 52 | *.iws 53 | 54 | # IntelliJ 55 | out/ 56 | 57 | # mpeltonen/sbt-idea plugin 58 | .idea_modules/ 59 | 60 | # JIRA plugin 61 | atlassian-ide-plugin.xml 62 | 63 | # Cursive Clojure plugin 64 | .idea/replstate.xml 65 | 66 | # SonarLint plugin 67 | .idea/sonarlint/ 68 | 69 | # Crashlytics plugin (for Android Studio and IntelliJ) 70 | com_crashlytics_export_strings.xml 71 | crashlytics.properties 72 | crashlytics-build.properties 73 | fabric.properties 74 | 75 | # Editor-based Rest Client 76 | .idea/httpRequests 77 | 78 | # Android studio 3.1+ serialized cache file 79 | .idea/caches/build_file_checksums.ser 80 | 81 | ### JetBrains+all Patch ### 82 | # Ignore everything but code style settings and run configurations 83 | # that are supposed to be shared within teams. 84 | 85 | .idea/* 86 | 87 | !.idea/codeStyles 88 | !.idea/runConfigurations 89 | 90 | ### Rust ### 91 | # Generated by Cargo 92 | # will have compiled files and executables 93 | debug/ 94 | target/ 95 | 96 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 97 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 98 | Cargo.lock 99 | 100 | # These are backup files generated by rustfmt 101 | **/*.rs.bk 102 | 103 | # MSVC Windows builds of rustc generate these, which store debugging information 104 | *.pdb 105 | 106 | ### VisualStudioCode ### 107 | .vscode/* 108 | !.vscode/settings.json 109 | !.vscode/tasks.json 110 | !.vscode/launch.json 111 | !.vscode/extensions.json 112 | !.vscode/*.code-snippets 113 | 114 | # Local History for Visual Studio Code 115 | .history/ 116 | 117 | # Built Visual Studio Code Extensions 118 | *.vsix 119 | 120 | ### VisualStudioCode Patch ### 121 | # Ignore all local history of files 122 | .history 123 | .ionide 124 | 125 | # Support for Project snippet scope 126 | .vscode/*.code-snippets 127 | 128 | # Ignore code-workspaces 129 | *.code-workspace 130 | 131 | ### Python ### 132 | # Byte-compiled / optimized / DLL files 133 | __pycache__/ 134 | *.py[cod] 135 | *$py.class 136 | 137 | # C extensions 138 | *.so 139 | 140 | # Distribution / packaging 141 | .Python 142 | build/ 143 | develop-eggs/ 144 | dist/ 145 | downloads/ 146 | eggs/ 147 | .eggs/ 148 | lib/ 149 | lib64/ 150 | parts/ 151 | sdist/ 152 | var/ 153 | wheels/ 154 | share/python-wheels/ 155 | *.egg-info/ 156 | .installed.cfg 157 | *.egg 158 | MANIFEST 159 | 160 | # PyInstaller 161 | # Usually these files are written by a python script from a template 162 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 163 | *.manifest 164 | *.spec 165 | 166 | # Installer logs 167 | pip-log.txt 168 | pip-delete-this-directory.txt 169 | 170 | # Unit test / coverage reports 171 | htmlcov/ 172 | .tox/ 173 | .nox/ 174 | .coverage 175 | .coverage.* 176 | .cache 177 | nosetests.xml 178 | coverage.xml 179 | *.cover 180 | *.py,cover 181 | .hypothesis/ 182 | .pytest_cache/ 183 | cover/ 184 | 185 | # Translations 186 | *.mo 187 | *.pot 188 | 189 | # Django stuff: 190 | *.log 191 | local_settings.py 192 | db.sqlite3 193 | db.sqlite3-journal 194 | 195 | # Flask stuff: 196 | instance/ 197 | .webassets-cache 198 | 199 | # Scrapy stuff: 200 | .scrapy 201 | 202 | # Sphinx documentation 203 | docs/_build/ 204 | 205 | # PyBuilder 206 | .pybuilder/ 207 | target/ 208 | 209 | # Jupyter Notebook 210 | .ipynb_checkpoints 211 | 212 | # IPython 213 | profile_default/ 214 | ipython_config.py 215 | 216 | # pyenv 217 | # For a library or package, you might want to ignore these files since the code is 218 | # intended to run in multiple environments; otherwise, check them in: 219 | # .python-version 220 | 221 | # pipenv 222 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 223 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 224 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 225 | # install all needed dependencies. 226 | #Pipfile.lock 227 | 228 | # poetry 229 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 230 | # This is especially recommended for binary packages to ensure reproducibility, and is more 231 | # commonly ignored for libraries. 232 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 233 | #poetry.lock 234 | 235 | # pdm 236 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 237 | #pdm.lock 238 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 239 | # in version control. 240 | # https://pdm.fming.dev/#use-with-ide 241 | .pdm.toml 242 | 243 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 244 | __pypackages__/ 245 | 246 | # Celery stuff 247 | celerybeat-schedule 248 | celerybeat.pid 249 | 250 | # SageMath parsed files 251 | *.sage.py 252 | 253 | # Environments 254 | .env 255 | .venv 256 | env/ 257 | venv/ 258 | ENV/ 259 | env.bak/ 260 | venv.bak/ 261 | 262 | # Spyder project settings 263 | .spyderproject 264 | .spyproject 265 | 266 | # Rope project settings 267 | .ropeproject 268 | 269 | # mkdocs documentation 270 | /site 271 | 272 | # mypy 273 | .mypy_cache/ 274 | .dmypy.json 275 | dmypy.json 276 | 277 | # Pyre type checker 278 | .pyre/ 279 | 280 | # pytype static type analyzer 281 | .pytype/ 282 | 283 | # Cython debug symbols 284 | cython_debug/ 285 | 286 | # PyCharm 287 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 288 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 289 | # and can be added to the global gitignore or merged into this file. For a more nuclear 290 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 291 | #.idea/ 292 | -------------------------------------------------------------------------------- /.yamllint.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: default 3 | 4 | rules: 5 | float-values: 6 | level: error 7 | require-numeral-before-decimal: true 8 | line-length: 9 | max: 120 10 | octal-values: 11 | level: error 12 | forbid-implicit-octal: true 13 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lers" 3 | description = "An async, user-friendly Let's Encrypt/ACMEv2 library written in Rust" 4 | version = "0.4.0" 5 | edition = "2021" 6 | 7 | license = "MIT" 8 | homepage = "https://github.com/akrantz01/lers" 9 | repository = "https://github.com/akrantz01/lers" 10 | 11 | keywords = ["acme", "autocert", "letsencrypt", "tls"] 12 | categories = ["web-programming"] 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | 16 | [features] 17 | default = ["http-01"] 18 | vendored = ["openssl/vendored", "reqwest/native-tls-vendored"] 19 | 20 | http-01 = ["hyper", "uuid"] 21 | dns-01 = ["trust-dns-resolver"] 22 | tls-alpn-01 = ["rcgen"] 23 | dns-01-cloudflare = ["dns-01"] 24 | 25 | integration = [] 26 | 27 | [dependencies] 28 | async-trait = "0.1" 29 | base64 = "0.21" 30 | chrono = { version = "0.4", features = ["serde"] } 31 | futures = { version = "0.3", default-features = false, features = ["async-await", "std"] } 32 | hex = "0.4.3" 33 | hyper = { version = "0.14", features = ["server", "tcp"], optional = true } 34 | once_cell = { version = "1", features = ["parking_lot"] } 35 | openssl = "0.10" 36 | parking_lot = "0.12" 37 | rcgen = { version = "0.11", default-features = false, optional = true } 38 | reqwest = { version = "0.11", default-features = false, features = ["json", "native-tls"] } 39 | serde = { version = "1", features = ["derive"] } 40 | serde_json = "1" 41 | tokio = { version = "1", features = ["fs", "time"] } 42 | tracing = "0.1" 43 | trust-dns-resolver = { version = "0.23.2", optional = true } 44 | uuid = { version = "1.4.1", features = ["v4"], optional = true } 45 | 46 | [dev-dependencies] 47 | anyhow = "1" 48 | env_logger = "0.10" 49 | native-tls = { version = "0.2" } 50 | test-log = { version = "0.2", default-features = false, features = ["trace"] } 51 | tokio = { version = "1", features = ["macros", "rt-multi-thread"] } 52 | tokio-native-tls = { version = "0.3" } 53 | tracing-subscriber = { version = "0.3", default-features = false, features = ["env-filter", "fmt"] } 54 | x509-parser = "0.15" 55 | 56 | [package.metadata.docs.rs] 57 | all-features = true 58 | rustdoc-args = ["--cfg", "docsrs"] 59 | 60 | [[example]] 61 | name = "http-01" 62 | required-features = ["http-01"] 63 | 64 | [[example]] 65 | name = "dns-01" 66 | required-features = ["dns-01", "dns-01-cloudflare"] 67 | 68 | [[example]] 69 | name = "tls-alpn-01" 70 | required-features = ["tls-alpn-01"] 71 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | set dotenv-load := true 2 | 3 | # Get a list of all the tasks 4 | list: 5 | @just --list --unsorted 6 | 7 | # Run tests 8 | test FEATURES="http-01,dns-01,tls-alpn-01" *FLAGS="": seed 9 | cargo test --features {{FEATURES}} {{FLAGS}} 10 | 11 | # Run integration tests 12 | integration-test FEATURES="http-01,tls-alpn-01,dns-01,dns-01-cloudflare" *FLAGS="": seed 13 | cargo test --features integration --features {{FEATURES}} {{FLAGS}} 14 | 15 | # Lint the codebase 16 | lint: 17 | cargo fmt --all 18 | cargo clippy -- -D warnings 19 | yamllint -s . 20 | 21 | # Preview generated documentation 22 | docs: 23 | RUSTDOCFLAGS="--cfg docsrs" cargo +nightly doc --open --all-features 24 | 25 | alias t := test 26 | alias it := integration-test 27 | alias l := lint 28 | 29 | # Seed the Pebble server with test data 30 | seed: pebble 31 | poetry run python3 hack/seed.py 32 | 33 | # Launch the Let's Encrypt Pebble test server 34 | pebble: 35 | docker compose down --volumes 36 | docker compose up -d 37 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2023 Alexander Krantz 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice (including the next 11 | paragraph) shall be included in all copies or substantial portions of the 12 | Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 17 | OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 18 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF 19 | OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lers 2 | 3 | [![Crates.io](https://img.shields.io/crates/v/lers)](https://crates.io/crates/lers) 4 | [![docs.rs](https://img.shields.io/docsrs/lers/latest)](https://docs.rs/lers/latest/lers) 5 | 6 | An async, user-friendly Let's Encrypt/ACMEv2 library written in Rust. 7 | 8 | The API and implementation were inspired by [acme2][], [acme-micro][], and [lego][]. 9 | 10 | ## Features 11 | - ACME v2 ([RFC 8555][]) 12 | - Register with CA 13 | - Obtain certificates 14 | - Renew certificates 15 | - Revoke certificates 16 | - Robust implementation of ACME challenges 17 | - [HTTP][] (http-01) 18 | - [DNS][] (dns-01) 19 | - [TLS][] (tls-alpn-01) 20 | - SAN certificate support 21 | - Custom challenge solvers ([`Solver` trait][]) 22 | - [External account bindings][] 23 | 24 | ### Missing features 25 | 26 | - [ ] Certificate bundling 27 | 28 | Contributions are welcome for any of the above features. 29 | 30 | ### Supported DNS-01 Providers 31 | 32 | Currently, the following providers are supported: 33 | - [Cloudflare](https://www.cloudflare.com): [`CloudflareDns01Solver`][] 34 | 35 | [acme2]: https://github.com/lucacasonato/acme2 36 | [acme-micro]: https://github.com/kpcyrd/acme-micro 37 | [lego]: https://github.com/go-acme/lego 38 | [RFC 8555]: https://www.rfc-editor.org/rfc/rfc8555.html 39 | [HTTP]: https://docs.rs/lers/latest/lers/solver/struct.Http01Solver.html 40 | [DNS]: https://docs.rs/lers/latest/lers/solver/dns/index.html 41 | [TLS]: https://docs.rs/lers/latest/lers/solver/struct.TlsAlpn01Solver.html 42 | [`Solver` trait]: https://docs.rs/lers/latest/lers/solver/trait.Solver.html 43 | [External account bindings]: https://www.rfc-editor.org/rfc/rfc8555.html#page-38 44 | 45 | [`CloudflareDns01Solver`]: https://docs.rs/lers/latest/lers/solver/dns/struct.CloudflareDns01Solver.html 46 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: "3" 3 | 4 | services: 5 | pebble: 6 | image: us-central1-docker.pkg.dev/krantz-dev-default/pebble/pebble:latest 7 | command: pebble -config /pebble-config.json -strict -dnsserver 10.30.50.3:8053 8 | ports: 9 | - "14000:14000" # HTTPS ACME API 10 | - "15000:15000" # HTTPS Management API 11 | volumes: 12 | - ./pebble-config.json:/pebble-config.json 13 | networks: 14 | acme: 15 | ipv4_address: 10.30.50.2 16 | 17 | challtestsrv: 18 | image: us-central1-docker.pkg.dev/krantz-dev-default/pebble/pebble-challtestsrv:latest 19 | command: pebble-challtestsrv -defaultIPv6 "" -defaultIPv4 10.30.50.3 20 | ports: 21 | - "8055:8055" # HTTP Management API 22 | networks: 23 | acme: 24 | ipv4_address: 10.30.50.3 25 | 26 | networks: 27 | acme: 28 | driver: bridge 29 | ipam: 30 | driver: default 31 | config: 32 | - subnet: 10.30.50.0/24 33 | -------------------------------------------------------------------------------- /examples/dns-01.rs: -------------------------------------------------------------------------------- 1 | // You'll need the dns-01 and dns-01-cloudflare features enabled 2 | use lers::{solver::dns::CloudflareDns01Solver, Directory, LETS_ENCRYPT_STAGING_URL}; 3 | 4 | #[tokio::main] 5 | async fn main() -> anyhow::Result<()> { 6 | // Create a Cloudflare DNS-01 solver. You'll need to have the CLOUDFLARE_API_TOKEN environment 7 | // set for this to work. 8 | let solver = CloudflareDns01Solver::from_env()?.build()?; 9 | 10 | // Create a new directory for Let's Encrypt Staging 11 | let directory = Directory::builder(LETS_ENCRYPT_STAGING_URL) 12 | .dns01_solver(Box::new(solver)) 13 | .build() 14 | .await?; 15 | 16 | // Create an ACME account to order your certificate. In production, you should store 17 | // the private key, so you can renew your certificate. 18 | let account = directory 19 | .account() 20 | .terms_of_service_agreed(true) 21 | .contacts(vec!["mailto:hello@example.com".into()]) 22 | .create_if_not_exists() 23 | .await?; 24 | 25 | // Obtain your wildcard certificate 26 | let certificate = account 27 | .certificate() 28 | .add_domain("*.example.com") 29 | .obtain() 30 | .await?; 31 | 32 | // You now have your certificate to export to a webserver or store somewhere. 33 | assert!(certificate.x509_chain().len() > 1); 34 | 35 | Ok(()) 36 | } 37 | -------------------------------------------------------------------------------- /examples/http-01.rs: -------------------------------------------------------------------------------- 1 | use lers::{solver::Http01Solver, Directory, LETS_ENCRYPT_STAGING_URL}; 2 | 3 | #[tokio::main] 4 | async fn main() -> anyhow::Result<()> { 5 | // Create and start a new HTTP-01 solver. 6 | let address = "127.0.0.1:8080".parse()?; 7 | let solver = Http01Solver::new(); 8 | let handle = solver.start(&address)?; 9 | 10 | // Create a new directory for Let's Encrypt Staging 11 | let directory = Directory::builder(LETS_ENCRYPT_STAGING_URL) 12 | .http01_solver(Box::new(solver)) 13 | .build() 14 | .await?; 15 | 16 | // Create an ACME account to order your certificate. In production, you should store 17 | // the private key, so you can renew your certificate. 18 | let account = directory 19 | .account() 20 | .terms_of_service_agreed(true) 21 | .contacts(vec!["mailto:hello@example.com".into()]) 22 | .create_if_not_exists() 23 | .await?; 24 | 25 | // Obtain your certificate 26 | let certificate = account 27 | .certificate() 28 | .add_domain("example.com") 29 | .obtain() 30 | .await?; 31 | 32 | // You now have your certificate to export to a webserver or store somewhere. 33 | assert!(certificate.x509_chain().len() > 1); 34 | 35 | // Stop the HTTP-01 solver since we've issued the certificate. 36 | handle.stop().await?; 37 | 38 | Ok(()) 39 | } 40 | -------------------------------------------------------------------------------- /examples/tls-alpn-01.rs: -------------------------------------------------------------------------------- 1 | use lers::{solver::TlsAlpn01Solver, Directory, LETS_ENCRYPT_STAGING_URL}; 2 | 3 | #[tokio::main] 4 | async fn main() -> anyhow::Result<()> { 5 | // Create and start a new TLS-ALPN-01 solver. 6 | let address = "127.0.0.1:8443".parse()?; 7 | let solver = TlsAlpn01Solver::new(); 8 | let handle = solver.start(address).await?; 9 | 10 | // Create a directory for Let's Encrypt Staging 11 | let directory = Directory::builder(LETS_ENCRYPT_STAGING_URL) 12 | .tls_alpn01_solver(Box::new(solver)) 13 | .build() 14 | .await?; 15 | 16 | // Create an ACME account to order your certificate. In production, you should store 17 | // the private key, so you can renew your certificate. 18 | let account = directory 19 | .account() 20 | .terms_of_service_agreed(true) 21 | .contacts(vec!["mailto:hello@example.com".into()]) 22 | .create_if_not_exists() 23 | .await?; 24 | 25 | let certificate = account 26 | .certificate() 27 | .add_domain("example.com") 28 | .obtain() 29 | .await?; 30 | 31 | // You now have your certificate to export to a webserver or store somewhere. 32 | assert!(certificate.x509_chain().len() > 1); 33 | 34 | // Stop the TLS-ALPN-01 solver since we've issued the certificate. 35 | handle.stop().await?; 36 | 37 | Ok(()) 38 | } 39 | -------------------------------------------------------------------------------- /hack/seed.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import json 3 | from os import environ, listdir 4 | from pathlib import Path 5 | import signal 6 | import time 7 | 8 | import josepy 9 | import requests 10 | from acme.client import ClientNetwork, ClientV2 11 | from acme.messages import Directory, NewRegistration 12 | from josepy import JWK, JWASignature, JWKEC, JWKRSA 13 | 14 | logging.basicConfig() 15 | logger = logging.getLogger() 16 | logger.setLevel(logging.INFO) 17 | 18 | DIRECTORY = environ.get("DIRECTORY", "https://10.30.50.2:14000/dir") 19 | ACCOUNTS = Path(environ.get("ACCOUNTS", "testdata/accounts")) 20 | 21 | 22 | def wait_for_acme_server(): 23 | """ 24 | Wait for the directory URL to respond 25 | """ 26 | while True: 27 | try: 28 | response = requests.get(DIRECTORY, verify=False) 29 | if response.status_code == 200: 30 | return 31 | except requests.exceptions.ConnectionError as e: 32 | print(e) 33 | pass 34 | 35 | time.sleep(0.1) 36 | 37 | 38 | def alg_for_key(key: JWK) -> JWASignature: 39 | if isinstance(key, JWKRSA): 40 | return josepy.RS256 41 | elif isinstance(key, JWKEC): 42 | curve = key.fields_to_partial_json()["crv"] 43 | if curve == "P-256": 44 | return josepy.ES256 45 | elif curve == "P-384": 46 | return josepy.ES384 47 | elif curve == "P-521": 48 | return josepy.ES512 49 | 50 | raise ValueError(f"unsupported key type: {key.typ!r}") 51 | 52 | 53 | def new_client(account) -> ClientV2: 54 | with (ACCOUNTS / account).open("rb") as file: 55 | key = JWK.load(file.read(), password=None) 56 | 57 | net = ClientNetwork(key, user_agent="lers/seeder", verify_ssl=False, alg=alg_for_key(key)) 58 | directory = Directory.from_json(net.get(DIRECTORY).json()) 59 | return ClientV2(directory, net) 60 | 61 | 62 | if __name__ == "__main__": 63 | # Die on SIGINT 64 | signal.signal(signal.SIGINT, signal.SIG_DFL) 65 | 66 | accounts = listdir(ACCOUNTS) 67 | 68 | wait_for_acme_server() 69 | 70 | account_ids = [] 71 | for a in accounts: 72 | client = new_client(a) 73 | acc = client.new_account(NewRegistration.from_data(email="test@user.com", terms_of_service_agreed=True)) 74 | account_ids.append(acc.uri) 75 | 76 | print(account_ids) 77 | 78 | json.dump(account_ids, open("testdata/account-ids.json", "w")) 79 | -------------------------------------------------------------------------------- /pebble-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "pebble": { 3 | "listenAddress": "0.0.0.0:14000", 4 | "managementListenAddress": "0.0.0.0:15000", 5 | "certificate": "test/certs/localhost/cert.pem", 6 | "privateKey": "test/certs/localhost/key.pem", 7 | "httpPort": 5002, 8 | "tlsPort": 5003, 9 | "ocspResponderURL": "", 10 | "externalAccountBindingRequired": false, 11 | "externalAccountMACKeys": { 12 | "V6iRR0p3": "zWNDZM6eQGHWpSRTPal5eIUYFTu7EajVIoguysqZ9wG44nMEtx3MUAsUDkMTQ12W" 13 | }, 14 | "domainBlocklist": ["blocked-domain.example"] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "hack" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Alex Krantz "] 6 | readme = "README.md" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.9" 10 | acme = "^2.4.0" 11 | requests = "^2.28.2" 12 | josepy = "^1.13.0" 13 | 14 | 15 | [build-system] 16 | requires = ["poetry-core"] 17 | build-backend = "poetry.core.masonry.api" 18 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base", 5 | ":prNotPending", 6 | ":prHourlyLimitNone" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /src/account.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | api::{Api, ExternalAccountOptions}, 3 | certificate::{Certificate, CertificateBuilder}, 4 | error::Result, 5 | responses::{self, AccountStatus, RevocationReason}, 6 | Error, 7 | }; 8 | use base64::engine::{general_purpose::URL_SAFE_NO_PAD as BASE64, Engine}; 9 | use openssl::hash::MessageDigest; 10 | use openssl::{ 11 | ec::{EcGroup, EcKey}, 12 | nid::Nid, 13 | pkey::{PKey, Private}, 14 | x509::X509, 15 | }; 16 | use std::collections::HashSet; 17 | use tracing::{field, instrument, Level, Span}; 18 | 19 | pub struct NoPrivateKey; 20 | pub struct WithPrivateKey(PKey); 21 | 22 | /// Used to configure a the creation/lookup of an account 23 | pub struct AccountBuilder<'o, T> { 24 | api: Api, 25 | 26 | contacts: Option>, 27 | terms_of_service_agreed: bool, 28 | private_key: T, 29 | external_account_options: Option>, 30 | } 31 | 32 | impl<'o, T> AccountBuilder<'o, T> { 33 | pub(crate) fn new(api: Api) -> AccountBuilder<'o, NoPrivateKey> { 34 | AccountBuilder { 35 | api, 36 | contacts: None, 37 | terms_of_service_agreed: false, 38 | private_key: NoPrivateKey, 39 | external_account_options: None, 40 | } 41 | } 42 | 43 | /// Specify whether the ToS for the CA are agreed to 44 | pub fn terms_of_service_agreed(mut self, agreed: bool) -> Self { 45 | self.terms_of_service_agreed = agreed; 46 | self 47 | } 48 | 49 | /// Set the account contacts 50 | pub fn contacts(mut self, contacts: Vec) -> Self { 51 | self.contacts = Some(contacts); 52 | self 53 | } 54 | 55 | /// Set the external account credentials to bind this account to. The HMAC should be encoded 56 | /// using Base64 URL-encoding without padding. 57 | pub fn external_account(mut self, key_id: &'o str, hmac: &'o str) -> Self { 58 | self.external_account_options = Some(ExternalAccountOptions { kid: key_id, hmac }); 59 | self 60 | } 61 | } 62 | 63 | impl<'o> AccountBuilder<'o, NoPrivateKey> { 64 | /// Set the account's private key 65 | pub fn private_key(self, key: PKey) -> AccountBuilder<'o, WithPrivateKey> { 66 | AccountBuilder { 67 | api: self.api, 68 | contacts: self.contacts, 69 | terms_of_service_agreed: self.terms_of_service_agreed, 70 | private_key: WithPrivateKey(key), 71 | external_account_options: self.external_account_options, 72 | } 73 | } 74 | 75 | /// Create the account if it doesn't already exists, returning the existing account if it does. 76 | /// Will generate a private key for the account. 77 | #[instrument( 78 | level = Level::INFO, 79 | name = "AccountBuilder::create_if_not_exists", 80 | err, 81 | skip_all, 82 | fields( 83 | account.id, account.status, 84 | ?self.contacts, 85 | ?self.terms_of_service_agreed, 86 | self.external_account_options.key_id = ?self.external_account_options.as_ref().map(|o| o.kid), 87 | ), 88 | )] 89 | pub async fn create_if_not_exists(self) -> Result { 90 | let key = { 91 | let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1)?; 92 | let ec = EcKey::generate(&group)?; 93 | PKey::from_ec_key(ec)? 94 | }; 95 | 96 | let (id, account) = self 97 | .api 98 | .new_account( 99 | self.contacts, 100 | self.terms_of_service_agreed, 101 | false, 102 | self.external_account_options, 103 | &key, 104 | ) 105 | .await?; 106 | Span::current().record("account.id", &id); 107 | Span::current().record("account.status", field::debug(account.status)); 108 | 109 | into_account(self.api, key, id, account) 110 | } 111 | } 112 | 113 | impl<'o> AccountBuilder<'o, WithPrivateKey> { 114 | /// Lookup the account by private key, fails if it doesn't exist or a private key was 115 | /// not specified. 116 | #[instrument( 117 | level = Level::INFO, 118 | name = "AccountBuilder::lookup", 119 | err, 120 | skip_all, 121 | fields( 122 | account.id, account.status, 123 | ?self.contacts, 124 | ?self.terms_of_service_agreed, 125 | self.external_account_options.key_id = ?self.external_account_options.as_ref().map(|o| o.kid), 126 | ), 127 | )] 128 | pub async fn lookup(self) -> Result { 129 | let (id, account) = self 130 | .api 131 | .new_account( 132 | self.contacts, 133 | self.terms_of_service_agreed, 134 | true, 135 | self.external_account_options, 136 | &self.private_key.0, 137 | ) 138 | .await?; 139 | Span::current().record("account.id", &id); 140 | Span::current().record("account.status", field::debug(&account.status)); 141 | 142 | into_account(self.api, self.private_key.0, id, account) 143 | } 144 | 145 | /// Create the account if it doesn't already exists, returning the existing account if it does. 146 | #[instrument( 147 | level = Level::INFO, 148 | name = "AccountBuilder::create_if_not_exists", 149 | err, 150 | skip_all, 151 | fields( 152 | account.id, account.status, 153 | ?self.contacts, 154 | ?self.terms_of_service_agreed, 155 | self.external_account_options.key_id = ?self.external_account_options.as_ref().map(|o| o.kid), 156 | ), 157 | )] 158 | pub async fn create_if_not_exists(self) -> Result { 159 | let (id, account) = self 160 | .api 161 | .new_account( 162 | self.contacts, 163 | self.terms_of_service_agreed, 164 | false, 165 | self.external_account_options, 166 | &self.private_key.0, 167 | ) 168 | .await?; 169 | Span::current().record("account.id", &id); 170 | Span::current().record("account.status", field::debug(&account.status)); 171 | 172 | into_account(self.api, self.private_key.0, id, account) 173 | } 174 | } 175 | 176 | /// Finalize the creation of the account 177 | fn into_account( 178 | api: Api, 179 | private_key: PKey, 180 | id: String, 181 | account: responses::Account, 182 | ) -> Result { 183 | if account.status != AccountStatus::Valid { 184 | return Err(Error::InvalidAccount(account.status)); 185 | } 186 | 187 | Ok(Account { 188 | api, 189 | private_key, 190 | id, 191 | }) 192 | } 193 | 194 | /// An ACME account. This is used to identify a subscriber to an ACME server. 195 | #[derive(Debug)] 196 | pub struct Account { 197 | pub(crate) api: Api, 198 | pub(crate) private_key: PKey, 199 | pub(crate) id: String, 200 | } 201 | 202 | impl Account { 203 | /// Get the private key for the account 204 | pub fn private_key(&self) -> &PKey { 205 | &self.private_key 206 | } 207 | 208 | /// Access the builder to issue a new certificate. 209 | pub fn certificate(&self) -> CertificateBuilder { 210 | CertificateBuilder::new(self) 211 | } 212 | 213 | /// Renew a certificate 214 | #[instrument( 215 | level = Level::INFO, 216 | name = "Account::renew_certificate", 217 | err, 218 | skip_all, 219 | fields(self.id, certificate = %certificate.digest()), 220 | )] 221 | pub async fn renew_certificate(&self, certificate: Certificate) -> Result { 222 | let inner = certificate.x509(); 223 | let mut domains = HashSet::new(); 224 | 225 | // Extract the common name 226 | if let Some(name) = inner 227 | .subject_name() 228 | .entries() 229 | .find(|e| e.object().nid() == Nid::COMMONNAME) 230 | { 231 | let domain = name.data().as_utf8()?.to_string(); 232 | domains.insert(domain); 233 | } 234 | 235 | // Extract any SANs 236 | if let Some(alt_names) = inner.subject_alt_names() { 237 | for name in alt_names { 238 | if let Some(domain) = name.dnsname() { 239 | domains.insert(domain.to_owned()); 240 | } 241 | } 242 | } 243 | 244 | // Build the request 245 | let mut builder = self.certificate().private_key(certificate.private_key); 246 | for domain in domains.into_iter() { 247 | builder = builder.add_domain(domain); 248 | } 249 | 250 | builder.obtain().await 251 | } 252 | 253 | /// Revoke a certificate 254 | #[instrument( 255 | level = Level::INFO, 256 | name = "Account::revoke_certificate", 257 | err, 258 | skip_all, 259 | fields(self.id, certificate = %x509_digest(certificate)), 260 | )] 261 | pub async fn revoke_certificate(&self, certificate: &X509) -> Result<()> { 262 | let der = BASE64.encode(certificate.to_der()?); 263 | self.api 264 | .revoke_certificate(der, None, &self.private_key, Some(&self.id)) 265 | .await 266 | } 267 | 268 | /// Revoke a certificate with a reason. 269 | #[instrument( 270 | level = Level::INFO, 271 | name = "Account::revoke_certificate_with_reason", 272 | err, 273 | skip(self, certificate), 274 | fields(self.id, certificate = %x509_digest(certificate)), 275 | )] 276 | pub async fn revoke_certificate_with_reason( 277 | &self, 278 | certificate: &X509, 279 | reason: RevocationReason, 280 | ) -> Result<()> { 281 | let der = BASE64.encode(certificate.to_der()?); 282 | self.api 283 | .revoke_certificate(der, Some(reason), &self.private_key, Some(&self.id)) 284 | .await 285 | } 286 | } 287 | 288 | fn x509_digest(certificate: &X509) -> String { 289 | let digest = certificate 290 | .digest(MessageDigest::sha256()) 291 | .expect("digest should always succeed"); 292 | hex::encode(digest) 293 | } 294 | 295 | #[cfg(test)] 296 | mod tests { 297 | use crate::{responses::ErrorType, test::directory, Error}; 298 | use once_cell::sync::Lazy; 299 | use openssl::{ 300 | ec::{EcGroup, EcKey}, 301 | nid::Nid, 302 | pkey::{PKey, Private}, 303 | }; 304 | use parking_lot::Mutex; 305 | use std::{collections::HashSet, fs}; 306 | use test_log::test; 307 | 308 | static ACCOUNT_IDS: Lazy>> = Lazy::new(|| { 309 | let raw = fs::read("testdata/account-ids.json").unwrap(); 310 | let ids = serde_json::from_slice(&raw).unwrap(); 311 | Mutex::new(ids) 312 | }); 313 | 314 | fn private_key(account: u8) -> PKey { 315 | let pem = fs::read(format!("testdata/accounts/{account}.pem")).unwrap(); 316 | PKey::private_key_from_pem(&pem).unwrap() 317 | } 318 | 319 | #[test(tokio::test)] 320 | async fn lookup_when_exists() { 321 | let directory = directory().await; 322 | let account = directory 323 | .account() 324 | .contacts(vec!["mailto:exists@lookup.test".into()]) 325 | .private_key(private_key(1)) 326 | .lookup() 327 | .await 328 | .unwrap(); 329 | 330 | let mut ids = ACCOUNT_IDS.lock(); 331 | assert!(!ids.insert(account.id)); 332 | } 333 | 334 | #[test(tokio::test)] 335 | async fn lookup_when_does_not_exists() { 336 | let directory = directory().await; 337 | 338 | let key = { 339 | let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).unwrap(); 340 | let ec = EcKey::generate(&group).unwrap(); 341 | PKey::from_ec_key(ec).unwrap() 342 | }; 343 | let result = directory 344 | .account() 345 | .contacts(vec!["mailto:does-not-exist@lookup.test".into()]) 346 | .private_key(key) 347 | .lookup() 348 | .await; 349 | 350 | let Error::Server(error) = result.unwrap_err() else { panic!("must be server error") }; 351 | assert_eq!(error.type_, ErrorType::AccountDoesNotExist); 352 | assert_eq!(error.title, None); 353 | assert_eq!( 354 | error.detail, 355 | Some("unable to find existing account for only-return-existing request".into()) 356 | ); 357 | assert_eq!(error.status, Some(400)); 358 | assert!(error.subproblems.is_none()); 359 | } 360 | 361 | #[test(tokio::test)] 362 | async fn create_if_not_exists_when_does_not_exist() { 363 | let directory = directory().await; 364 | let account = directory 365 | .account() 366 | .terms_of_service_agreed(true) 367 | .contacts(vec!["mailto:does-not-exist@create.test".into()]) 368 | .create_if_not_exists() 369 | .await 370 | .unwrap(); 371 | 372 | let mut ids = ACCOUNT_IDS.lock(); 373 | assert!(ids.insert(account.id)); 374 | } 375 | 376 | #[test(tokio::test)] 377 | async fn create_if_not_exists_when_exists() { 378 | let directory = directory().await; 379 | let account = directory 380 | .account() 381 | .terms_of_service_agreed(true) 382 | .contacts(vec!["mailto:exists@create.test".into()]) 383 | .private_key(private_key(2)) 384 | .create_if_not_exists() 385 | .await 386 | .unwrap(); 387 | 388 | let mut ids = ACCOUNT_IDS.lock(); 389 | assert!(!ids.insert(account.id)); 390 | } 391 | 392 | #[test(tokio::test)] 393 | async fn create_if_not_exists_with_external_account() { 394 | let directory = directory().await; 395 | let account = directory 396 | .account() 397 | .terms_of_service_agreed(true) 398 | .contacts(vec!["mailto:external-account@create.test".into()]) 399 | .external_account( 400 | "V6iRR0p3", 401 | "zWNDZM6eQGHWpSRTPal5eIUYFTu7EajVIoguysqZ9wG44nMEtx3MUAsUDkMTQ12W", 402 | ) 403 | .create_if_not_exists() 404 | .await 405 | .unwrap(); 406 | 407 | let mut ids = ACCOUNT_IDS.lock(); 408 | assert!(ids.insert(account.id)); 409 | } 410 | 411 | #[test(tokio::test)] 412 | async fn create_if_not_exists_with_non_existent_external_account() { 413 | let directory = directory().await; 414 | let result = directory 415 | .account() 416 | .terms_of_service_agreed(true) 417 | .contacts(vec!["mailto:external-account@create.test".into()]) 418 | .external_account( 419 | "this-does-not-exist", 420 | "zWNDZM6eQGHWpSRTPal5eIUYFTu7EajVIoguysqZ9wG44nMEtx3MUAsUDkMTQ12W", 421 | ) 422 | .create_if_not_exists() 423 | .await; 424 | 425 | let Error::Server(error) = result.unwrap_err() else { panic!("must be server error") }; 426 | assert_eq!(error.type_, ErrorType::Unauthorized); 427 | assert_eq!(error.title, None); 428 | assert_eq!( 429 | error.detail, 430 | Some("the field 'kid' references a key that is not known to the ACME server".into()) 431 | ); 432 | assert_eq!(error.status, Some(403)); 433 | assert!(error.subproblems.is_none()); 434 | } 435 | } 436 | -------------------------------------------------------------------------------- /src/api.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | error::{Error, Result}, 3 | solver::SolverManager, 4 | Solver, 5 | }; 6 | use chrono::{DateTime, Utc}; 7 | use openssl::pkey::{PKey, Private}; 8 | use reqwest::{header, Client, Response}; 9 | use serde::Serialize; 10 | use std::{future::Future, sync::Arc, time::Duration}; 11 | use tokio::time; 12 | use tracing::{instrument, Level, Span}; 13 | 14 | mod jws; 15 | mod nonce; 16 | pub mod responses; 17 | 18 | pub(crate) use jws::key_authorization; 19 | use responses::ErrorType; 20 | 21 | #[derive(Debug)] 22 | pub(crate) struct ExternalAccountOptions<'o> { 23 | pub kid: &'o str, 24 | pub hmac: &'o str, 25 | } 26 | 27 | #[derive(Debug)] 28 | pub(crate) struct Api(Arc); 29 | 30 | #[derive(Debug)] 31 | struct ApiInner { 32 | client: Client, 33 | urls: responses::Directory, 34 | nonces: nonce::Pool, 35 | solvers: SolverManager, 36 | } 37 | 38 | impl Api { 39 | /// Construct the API for a directory from a URL 40 | #[instrument( 41 | level = Level::TRACE, 42 | name = "Api::from_url", 43 | err, 44 | skip(client, solvers), 45 | )] 46 | pub(crate) async fn from_url( 47 | url: String, 48 | client: Client, 49 | max_nonces: usize, 50 | solvers: SolverManager, 51 | ) -> Result { 52 | let urls = client.get(url).send().await?.json().await?; 53 | 54 | let inner = ApiInner { 55 | client, 56 | urls, 57 | nonces: nonce::Pool::new(max_nonces), 58 | solvers, 59 | }; 60 | Ok(Api(Arc::new(inner))) 61 | } 62 | 63 | /// Get optional metadata about the directory 64 | #[inline(always)] 65 | pub(crate) fn meta(&self) -> &responses::DirectoryMeta { 66 | &self.0.urls.meta 67 | } 68 | 69 | /// Retrieve the next nonce from the pool 70 | #[inline(always)] 71 | async fn next_nonce(&self) -> Result { 72 | self.0 73 | .nonces 74 | .get(&self.0.urls.new_nonce, &self.0.client) 75 | .await 76 | } 77 | 78 | /// Perform an authenticated request to the API with a JSON body 79 | async fn request_json( 80 | &self, 81 | url: &str, 82 | body: S, 83 | private_key: &PKey, 84 | account_id: Option<&str>, 85 | ) -> Result { 86 | let body = serde_json::to_string(&body)?; 87 | self.request(url, &body, private_key, account_id).await 88 | } 89 | 90 | /// Perform an authenticated request to the API 91 | #[instrument( 92 | level = Level::TRACE, 93 | name = "Api::request", 94 | err, 95 | skip_all, 96 | fields( 97 | ?account_id, 98 | http.body.len = body.len(), 99 | http.url = %url, 100 | http.method = "POST", 101 | http.status, 102 | ) 103 | )] 104 | async fn request( 105 | &self, 106 | url: &str, 107 | body: &str, 108 | private_key: &PKey, 109 | account_id: Option<&str>, 110 | ) -> Result { 111 | let mut attempt = 0; 112 | 113 | loop { 114 | attempt += 1; 115 | 116 | let nonce = self.next_nonce().await?; 117 | let body = jws::sign(url, nonce, body, private_key, account_id)?; 118 | let body = serde_json::to_vec(&body)?; 119 | 120 | let response = self 121 | .0 122 | .client 123 | .post(url) 124 | .header(header::CONTENT_TYPE, "application/jose+json") 125 | .body(body) 126 | .send() 127 | .await?; 128 | 129 | let status = response.status(); 130 | Span::current().record("http.status", status.as_u16()); 131 | 132 | self.0.nonces.extract_from_response(&response)?; 133 | 134 | if status.is_success() { 135 | return Ok(response); 136 | } 137 | 138 | let err = response.json::().await?; 139 | if err.type_ == ErrorType::BadNonce && attempt <= 3 { 140 | continue; 141 | } 142 | 143 | return Err(Error::Server(err)); 144 | } 145 | } 146 | 147 | /// Perform the [newAccount](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.3) operation. 148 | /// Returns the account's ID and creation response. 149 | pub async fn new_account( 150 | &self, 151 | contacts: Option>, 152 | terms_of_service_agreed: bool, 153 | only_return_existing: bool, 154 | external_account_options: Option>, 155 | private_key: &PKey, 156 | ) -> Result<(String, responses::Account)> { 157 | let external_account_binding = external_account_options 158 | .map(|opts| { 159 | jws::sign_with_eab(&self.0.urls.new_account, private_key, opts.kid, opts.hmac) 160 | }) 161 | .transpose()?; 162 | 163 | let payload = responses::NewAccount { 164 | contacts, 165 | terms_of_service_agreed, 166 | only_return_existing, 167 | external_account_binding, 168 | }; 169 | let response = self 170 | .request_json(&self.0.urls.new_account, &payload, private_key, None) 171 | .await?; 172 | 173 | let id = location_header(&response)?; 174 | let account = response.json::().await?; 175 | Ok((id, account)) 176 | } 177 | 178 | /// Perform the [newOrder](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4) operation. 179 | /// Returns the order's URL and creation response. 180 | pub async fn new_order( 181 | &self, 182 | identifiers: Vec, 183 | not_before: Option>, 184 | not_after: Option>, 185 | private_key: &PKey, 186 | account_id: &str, 187 | ) -> Result<(String, responses::Order)> { 188 | let payload = responses::NewOrder { 189 | identifiers, 190 | not_before, 191 | not_after, 192 | }; 193 | let response = self 194 | .request_json( 195 | &self.0.urls.new_order, 196 | &payload, 197 | private_key, 198 | Some(account_id), 199 | ) 200 | .await?; 201 | 202 | let url = location_header(&response)?; 203 | let order = response.json().await?; 204 | Ok((url, order)) 205 | } 206 | 207 | /// Fetch an order 208 | pub async fn fetch_order( 209 | &self, 210 | url: &str, 211 | private_key: &PKey, 212 | account_id: &str, 213 | ) -> Result { 214 | let response = self.request(url, "", private_key, Some(account_id)).await?; 215 | let order = response.json().await?; 216 | Ok(order) 217 | } 218 | 219 | /// Fetch an authorization 220 | pub async fn fetch_authorization( 221 | &self, 222 | url: &str, 223 | private_key: &PKey, 224 | account_id: &str, 225 | ) -> Result { 226 | let response = self.request(url, "", private_key, Some(account_id)).await?; 227 | let authorization = response.json().await?; 228 | Ok(authorization) 229 | } 230 | 231 | /// Fetch a challenge 232 | pub async fn fetch_challenge( 233 | &self, 234 | url: &str, 235 | private_key: &PKey, 236 | account_id: &str, 237 | ) -> Result { 238 | let response = self.request(url, "", private_key, Some(account_id)).await?; 239 | let challenge = response.json().await?; 240 | Ok(challenge) 241 | } 242 | 243 | /// Enqueue a challenge for validation 244 | pub async fn validate_challenge( 245 | &self, 246 | url: &str, 247 | private_key: &PKey, 248 | account_id: &str, 249 | ) -> Result { 250 | let response = self 251 | .request(url, "{}", private_key, Some(account_id)) 252 | .await?; 253 | let challenge = response.json().await?; 254 | Ok(challenge) 255 | } 256 | 257 | /// Finalize an order using the provided CSR 258 | pub async fn finalize_order( 259 | &self, 260 | url: &str, 261 | csr: String, 262 | private_key: &PKey, 263 | account_id: &str, 264 | ) -> Result { 265 | let payload = responses::FinalizeOrder { csr }; 266 | let response = self 267 | .request_json(url, &payload, private_key, Some(account_id)) 268 | .await?; 269 | let order = response.json().await?; 270 | Ok(order) 271 | } 272 | 273 | /// Download the certificate from the order 274 | pub async fn download_certificate( 275 | &self, 276 | url: &str, 277 | private_key: &PKey, 278 | account_id: &str, 279 | ) -> Result { 280 | let response = self.request(url, "", private_key, Some(account_id)).await?; 281 | let certificate = response.text().await?; 282 | Ok(certificate) 283 | } 284 | 285 | /// Revoke a certificate 286 | /// 287 | /// If the `account_key` is not `None`, the `private_key` must be that of the account. Otherwise, 288 | /// it must be the certificate's private key. 289 | pub async fn revoke_certificate( 290 | &self, 291 | certificate: String, 292 | reason: Option, 293 | private_key: &PKey, 294 | account_id: Option<&str>, 295 | ) -> Result<()> { 296 | self.request_json( 297 | &self.0.urls.revoke_cert, 298 | &responses::RevocationRequest { 299 | certificate, 300 | reason, 301 | }, 302 | private_key, 303 | account_id, 304 | ) 305 | .await?; 306 | Ok(()) 307 | } 308 | 309 | /// Wait until the fetched resource meets a condition or the maximum attempts are exceeded. 310 | #[allow(clippy::too_many_arguments)] 311 | pub async fn wait_until<'a, F, P, T, Fut>( 312 | &self, 313 | fetcher: F, 314 | predicate: P, 315 | url: &'a str, 316 | private_key: &'a PKey, 317 | account_id: &'a str, 318 | interval: Duration, 319 | max_attempts: usize, 320 | ) -> Result 321 | where 322 | F: Fn(&'a str, &'a PKey, &'a str) -> Fut, 323 | Fut: Future>, 324 | P: Fn(&T) -> bool, 325 | { 326 | let mut resource = fetcher(url, private_key, account_id).await?; 327 | let mut attempts: usize = 0; 328 | 329 | while !predicate(&resource) { 330 | if attempts >= max_attempts { 331 | return Err(Error::MaxAttemptsExceeded); 332 | } 333 | 334 | time::sleep(interval).await; 335 | 336 | resource = fetcher(url, private_key, account_id).await?; 337 | attempts += 1; 338 | } 339 | 340 | Ok(resource) 341 | } 342 | 343 | /// Get the solver for the challenge, if it exists. 344 | pub fn solver_for(&self, challenge: &responses::Challenge) -> Option<&dyn Solver> { 345 | self.0.solvers.get(challenge.type_) 346 | } 347 | } 348 | 349 | impl Clone for Api { 350 | fn clone(&self) -> Self { 351 | Api(Arc::clone(&self.0)) 352 | } 353 | } 354 | 355 | fn location_header(response: &Response) -> Result { 356 | Ok(response 357 | .headers() 358 | .get(header::LOCATION) 359 | .ok_or(Error::MissingHeader("location"))? 360 | .to_str() 361 | .map_err(|e| Error::InvalidHeader("location", e))? 362 | .to_owned()) 363 | } 364 | 365 | #[cfg(test)] 366 | mod tests { 367 | use super::Api; 368 | use crate::{ 369 | solver::SolverManager, 370 | test::{client, TEST_URL}, 371 | LETS_ENCRYPT_STAGING_URL, 372 | }; 373 | use test_log::test; 374 | 375 | async fn create_api(url: String) -> Api { 376 | Api::from_url(url, client(), 10, SolverManager::default()) 377 | .await 378 | .unwrap() 379 | } 380 | 381 | #[test(tokio::test)] 382 | async fn new_api_lets_encrypt() { 383 | let api = create_api(LETS_ENCRYPT_STAGING_URL.to_string()).await; 384 | 385 | assert_eq!( 386 | api.0.urls.new_nonce, 387 | "https://acme-staging-v02.api.letsencrypt.org/acme/new-nonce" 388 | ); 389 | assert_eq!( 390 | api.0.urls.new_account, 391 | "https://acme-staging-v02.api.letsencrypt.org/acme/new-acct" 392 | ); 393 | assert_eq!( 394 | api.0.urls.new_order, 395 | "https://acme-staging-v02.api.letsencrypt.org/acme/new-order" 396 | ); 397 | assert_eq!( 398 | api.0.urls.revoke_cert, 399 | "https://acme-staging-v02.api.letsencrypt.org/acme/revoke-cert" 400 | ); 401 | assert_eq!( 402 | api.0.urls.key_change, 403 | "https://acme-staging-v02.api.letsencrypt.org/acme/key-change" 404 | ); 405 | assert_eq!(api.0.urls.new_authz, None); 406 | } 407 | 408 | #[test(tokio::test)] 409 | async fn new_api_pebble() { 410 | let api = create_api(TEST_URL.to_string()).await; 411 | 412 | assert_eq!(api.0.urls.new_nonce, "https://10.30.50.2:14000/nonce-plz"); 413 | assert_eq!( 414 | api.0.urls.new_account, 415 | "https://10.30.50.2:14000/sign-me-up" 416 | ); 417 | assert_eq!(api.0.urls.new_order, "https://10.30.50.2:14000/order-plz"); 418 | assert_eq!( 419 | api.0.urls.revoke_cert, 420 | "https://10.30.50.2:14000/revoke-cert" 421 | ); 422 | assert_eq!( 423 | api.0.urls.key_change, 424 | "https://10.30.50.2:14000/rollover-account-key" 425 | ); 426 | assert_eq!(api.0.urls.new_authz, None); 427 | } 428 | } 429 | -------------------------------------------------------------------------------- /src/api/jws.rs: -------------------------------------------------------------------------------- 1 | use super::responses::Jws; 2 | use crate::error::{Error, Result}; 3 | use base64::engine::{general_purpose::URL_SAFE_NO_PAD as BASE64, Engine}; 4 | use openssl::{ 5 | bn::{BigNum, BigNumContext}, 6 | ecdsa::EcdsaSig, 7 | hash::{hash, MessageDigest}, 8 | nid::Nid, 9 | pkey::{Id, PKey, Private}, 10 | sha::{sha256, sha384, sha512}, 11 | sign::Signer, 12 | }; 13 | use serde::{ser::SerializeStruct, Serialize, Serializer}; 14 | 15 | /// Possible algorithms a JWS can be signed with. Ignores algorithms explicitly denied by 16 | /// [RFC 8555 Section 6.2](https://www.rfc-editor.org/rfc/rfc8555.html#section-6.2), namely: 17 | /// `none` and MAC-based algorithms. 18 | #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] 19 | enum Algorithm { 20 | // only for use by sign_with_eab 21 | HS256, 22 | RS256, 23 | // TODO: eventually support RS384 and RS512 24 | ES256, 25 | ES384, 26 | ES512, 27 | // TODO: eventually support PS256, PS384, and PS512 28 | } 29 | 30 | impl TryFrom<&PKey> for Algorithm { 31 | type Error = Error; 32 | 33 | fn try_from(key: &PKey) -> Result { 34 | match key.id() { 35 | Id::RSA => Ok(Algorithm::RS256), 36 | Id::EC => { 37 | let ec = key.ec_key()?; 38 | match ec.group().curve_name() { 39 | Some(Nid::X9_62_PRIME256V1) => Ok(Algorithm::ES256), 40 | Some(Nid::SECP384R1) => Ok(Algorithm::ES384), 41 | Some(Nid::SECP521R1) => Ok(Algorithm::ES512), 42 | _ => Err(Error::UnsupportedECDSACurve), 43 | } 44 | } 45 | _ => Err(Error::UnsupportedKeyType), 46 | } 47 | } 48 | } 49 | 50 | /// The header of a JSON Web Signature according to 51 | /// [RFC 8555 Section 6.2](https://www.rfc-editor.org/rfc/rfc8555.html#section-6.2) 52 | #[derive(Debug, Serialize)] 53 | struct Header<'h> { 54 | #[serde(skip_serializing_if = "Option::is_none")] 55 | nonce: Option, 56 | #[serde(rename = "alg")] 57 | algorithm: Algorithm, 58 | url: &'h str, 59 | #[serde(skip_serializing_if = "Option::is_none")] 60 | kid: Option<&'h str>, 61 | #[serde(skip_serializing_if = "Option::is_none")] 62 | jwk: Option, 63 | } 64 | 65 | #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] 66 | enum Curve { 67 | #[serde(rename = "P-256")] 68 | P256, 69 | #[serde(rename = "P-384")] 70 | P384, 71 | #[serde(rename = "P-521")] 72 | P521, 73 | } 74 | 75 | impl TryFrom for Curve { 76 | type Error = Error; 77 | 78 | fn try_from(group: Nid) -> Result { 79 | match group { 80 | Nid::X9_62_PRIME256V1 => Ok(Curve::P256), 81 | Nid::SECP384R1 => Ok(Curve::P384), 82 | Nid::SECP521R1 => Ok(Curve::P521), 83 | _ => Err(Error::UnsupportedECDSACurve), 84 | } 85 | } 86 | } 87 | 88 | /// The public key of a key 89 | #[derive(Debug)] 90 | enum Jwk { 91 | Rsa { e: String, n: String }, 92 | EC { crv: Curve, x: String, y: String }, 93 | } 94 | 95 | impl TryFrom<&PKey> for Jwk { 96 | type Error = Error; 97 | 98 | fn try_from(key: &PKey) -> Result { 99 | match key.id() { 100 | Id::RSA => { 101 | let rsa = key.rsa()?; 102 | Ok(Jwk::Rsa { 103 | e: BASE64.encode(rsa.e().to_vec()), 104 | n: BASE64.encode(rsa.n().to_vec()), 105 | }) 106 | } 107 | Id::EC => { 108 | let ec = key.ec_key()?; 109 | let ec_public = ec.public_key(); 110 | 111 | let mut ctx = BigNumContext::new()?; 112 | let mut x = BigNum::new()?; 113 | let mut y = BigNum::new()?; 114 | ec_public.affine_coordinates_gfp(ec.group(), &mut x, &mut y, &mut ctx)?; 115 | 116 | let curve = ec 117 | .group() 118 | .curve_name() 119 | .ok_or(Error::UnsupportedECDSACurve)?; 120 | 121 | Ok(Jwk::EC { 122 | x: BASE64.encode(x.to_vec()), 123 | y: BASE64.encode(y.to_vec()), 124 | crv: Curve::try_from(curve)?, 125 | }) 126 | } 127 | _ => unreachable!(), 128 | } 129 | } 130 | } 131 | 132 | // We manually implement serialization to ensure lexicographical ordering of the fields per 133 | // RFC 7638 Section 3 (https://www.rfc-editor.org/rfc/rfc7638#section-3) 134 | impl Serialize for Jwk { 135 | fn serialize(&self, serializer: S) -> Result 136 | where 137 | S: Serializer, 138 | { 139 | // 1 + number of fields taking into account the `kty` 140 | let (fields, kty) = match self { 141 | Self::Rsa { .. } => (3, "RSA"), 142 | Self::EC { .. } => (4, "EC"), 143 | }; 144 | 145 | let mut state = serializer.serialize_struct("Jwk", fields)?; 146 | match self { 147 | Self::Rsa { e, n } => { 148 | state.serialize_field("e", e)?; 149 | state.serialize_field("kty", kty)?; 150 | state.serialize_field("n", n)?; 151 | } 152 | Self::EC { crv, x, y } => { 153 | state.serialize_field("crv", crv)?; 154 | state.serialize_field("kty", kty)?; 155 | state.serialize_field("x", x)?; 156 | state.serialize_field("y", y)?; 157 | } 158 | } 159 | state.end() 160 | } 161 | } 162 | 163 | /// Create a JWS for the request 164 | pub(crate) fn sign( 165 | url: &str, 166 | nonce: String, 167 | payload: &str, 168 | private_key: &PKey, 169 | account_id: Option<&str>, 170 | ) -> Result { 171 | let payload = BASE64.encode(payload.as_bytes()); 172 | 173 | let algorithm = Algorithm::try_from(private_key)?; 174 | let header = match account_id { 175 | Some(kid) => Header { 176 | algorithm, 177 | url, 178 | nonce: Some(nonce), 179 | kid: Some(kid), 180 | jwk: None, 181 | }, 182 | None => Header { 183 | algorithm, 184 | url, 185 | nonce: Some(nonce), 186 | kid: None, 187 | jwk: Some(Jwk::try_from(private_key)?), 188 | }, 189 | }; 190 | 191 | let protected = serde_json::to_vec(&header).unwrap(); 192 | let protected = BASE64.encode(protected); 193 | 194 | let signature = signer(private_key, &protected, &payload)?; 195 | let signature = BASE64.encode(signature); 196 | 197 | Ok(Jws { 198 | protected, 199 | payload, 200 | signature, 201 | }) 202 | } 203 | 204 | /// Generate the signature for the protected data and message payload 205 | fn signer(private_key: &PKey, protected: &str, payload: &str) -> Result> { 206 | let data = format!("{protected}.{payload}").into_bytes(); 207 | 208 | match private_key.id() { 209 | Id::RSA => { 210 | let sig = 211 | Signer::new(MessageDigest::sha256(), private_key)?.sign_oneshot_to_vec(&data)?; 212 | Ok(sig) 213 | } 214 | Id::EC => { 215 | let ec = private_key.ec_key()?; 216 | let digest = match ec.group().curve_name() { 217 | Some(Nid::X9_62_PRIME256V1) => sha256(&data).to_vec(), 218 | Some(Nid::SECP384R1) => sha384(&data).to_vec(), 219 | Some(Nid::SECP521R1) => sha512(&data).to_vec(), 220 | _ => unreachable!(), 221 | }; 222 | 223 | let sig = EcdsaSig::sign(&digest, &ec)?; 224 | let r = sig.r().to_vec(); 225 | let s = sig.s().to_vec(); 226 | 227 | let mut result = Vec::with_capacity(r.len() + s.len()); 228 | result.extend_from_slice(&r); 229 | result.extend_from_slice(&s); 230 | Ok(result) 231 | } 232 | _ => Err(Error::UnsupportedKeyType), 233 | } 234 | } 235 | 236 | /// Sign the provided private key with the provided Base64 URL-encoded HMAC and associated key ID 237 | pub(crate) fn sign_with_eab( 238 | url: &str, 239 | private_key: &PKey, 240 | kid: &str, 241 | hmac: &str, 242 | ) -> Result { 243 | let header = Header { 244 | url, 245 | algorithm: Algorithm::HS256, 246 | kid: Some(kid), 247 | nonce: None, 248 | jwk: None, 249 | }; 250 | let protected = BASE64.encode(serde_json::to_vec(&header).unwrap()); 251 | 252 | let jwk = Jwk::try_from(private_key)?; 253 | let payload = BASE64.encode(serde_json::to_vec(&jwk).unwrap()); 254 | 255 | let data = format!("{protected}.{payload}").into_bytes(); 256 | 257 | let hmac = BASE64.decode(hmac)?; 258 | let key = PKey::hmac(&hmac)?; 259 | let signature = Signer::new(MessageDigest::sha256(), &key)?.sign_oneshot_to_vec(&data)?; 260 | let signature = BASE64.encode(signature); 261 | 262 | Ok(Jws { 263 | protected, 264 | payload, 265 | signature, 266 | }) 267 | } 268 | 269 | /// Generate the key authorization for the token and private key 270 | pub(crate) fn key_authorization(token: &str, private_key: &PKey) -> Result { 271 | let jwk = Jwk::try_from(private_key)?; 272 | let serialized = serde_json::to_vec(&jwk).unwrap(); 273 | let digest = hash(MessageDigest::sha256(), &serialized)?; 274 | let thumbprint = BASE64.encode(digest); 275 | 276 | Ok(format!("{token}.{thumbprint}")) 277 | } 278 | 279 | #[cfg(test)] 280 | mod tests { 281 | use super::{key_authorization, sign, sign_with_eab, Curve, Jwk}; 282 | use openssl::pkey::PKey; 283 | use std::fs; 284 | 285 | #[test] 286 | fn jwk_rsa() { 287 | let pem = fs::read("testdata/rsa_2048.pem").unwrap(); 288 | let key = PKey::private_key_from_pem(&pem).unwrap(); 289 | 290 | let jwk = Jwk::try_from(&key).unwrap(); 291 | 292 | let Jwk::Rsa { n: n_b64, e: e_b64 } = jwk else { panic!("not rsa jwk") }; 293 | assert_eq!(n_b64, "y2McwrH7NMy4y-0iMBTNLWIBcvLi-_i8_sTJVaIRbsAp3rYhFFx2v_79ETp3hqquU23brJjPgYV-hdcB7lwq4ssZPD2zzvzEnLfuh0Ldsnuy_oQIKGtOvb48lqZ4c094k-TFLVhApBjkdBaJ-rhb7iM1xk3SXLWb2xBrz1iXV-okfXao9N5kV0azOZ3Spfr2HPLSEDUQrg4RW01BZe3zZtKu7TJUnlICeLJd_rexMizWx8iIzYX-NayhXSSp1yeXPRfk5ZnlhrGCu6ywmhmu7QA3dou77WxN2EzAUJAoiIuxLpCSeQV4XxnDEe4o88U9_PI1f6xBKdcfR0_HeBut3w"); 294 | assert_eq!(e_b64, "AQAB"); 295 | } 296 | 297 | macro_rules! jwk_ecdsa { 298 | ( 299 | $( 300 | $name:ident ($file:expr) => { 301 | crv: $crv:ident, 302 | x: $x:expr, 303 | y: $y:expr, 304 | } 305 | );+ $(;)? 306 | ) => { 307 | $( 308 | #[test] 309 | fn $name() { 310 | let pem = fs::read($file).unwrap(); 311 | let key = PKey::private_key_from_pem(&pem).unwrap(); 312 | 313 | let jwk = Jwk::try_from(&key).unwrap(); 314 | 315 | let Jwk::EC {crv, x: x_b64, y: y_b64 } = jwk else { panic!("not ec jwk") }; 316 | assert_eq!(crv, Curve::$crv); 317 | assert_eq!(x_b64, $x); 318 | assert_eq!(y_b64, $y); 319 | } 320 | )* 321 | }; 322 | } 323 | 324 | jwk_ecdsa! { 325 | jwk_ecdsa_p_256("testdata/ecdsa_p-256.pem") => { 326 | crv: P256, 327 | x: "bFFJEKk0HrAyTVz69iCiV8KsX1bNwSx60o6Xlat9hPo", 328 | y: "fsxkWwspm4NA2lUWIf9DwlrOQgf2Y610ynAwJP_Gx0E", 329 | }; 330 | jwk_ecdsa_p_384("testdata/ecdsa_p-384.pem") => { 331 | crv: P384, 332 | x: "MDD68TroskBcnk49wd7UI1nLI4o9q9DJH0P29ibkAb6AzLxg0mIu1U3NwUTKUf_l", 333 | y: "HldntIAzF67Nd-jfTDaiJxa0WMVHcZ5at_AQkxtT6aCu5jQ1zSKcPvVnj1Sv3JT2", 334 | }; 335 | jwk_ecdsa_p_521("testdata/ecdsa_p-521.pem") => { 336 | crv: P521, 337 | x: "Ad27MiJgOobBKFO_YyAy6mQ_Dz2uGLF0UD3-MkF4hLa5Z__RCrNmtidjQ5FW64wahfzLeQamEA_KATh2zFBNhSM0", 338 | y: "AUyg4XumobEqaPCjUGC9Mc8SE2saUrYVd824Is1ercPjpq5Wx3HE-I2HvbtLmm29UX3T5IkHmKRbPIa7oB8Oo6PL", 339 | }; 340 | } 341 | 342 | const JWS_PAYLOAD: &str = "this is a test payload"; 343 | const JWS_NONCE: &str = "A272VFpvC1e7H0YZ14_-fLlbt9Gg8bR-dGtl0PqjuGX_-o8"; 344 | const JWS_URL: &str = "https://acme-staging-v02.api.letsencrypt.org/acme/new-acct"; 345 | const JWS_ACCOUNT_ID: &str = "0123456"; 346 | 347 | #[test] 348 | fn jws_rsa_without_account() { 349 | let pem = fs::read("testdata/rsa_2048.pem").unwrap(); 350 | let key = PKey::private_key_from_pem(&pem).unwrap(); 351 | 352 | let sig = sign(JWS_URL, String::from(JWS_NONCE), JWS_PAYLOAD, &key, None).unwrap(); 353 | 354 | assert_eq!(sig.protected, "eyJub25jZSI6IkEyNzJWRnB2QzFlN0gwWVoxNF8tZkxsYnQ5R2c4YlItZEd0bDBQcWp1R1hfLW84IiwiYWxnIjoiUlMyNTYiLCJ1cmwiOiJodHRwczovL2FjbWUtc3RhZ2luZy12MDIuYXBpLmxldHNlbmNyeXB0Lm9yZy9hY21lL25ldy1hY2N0IiwiandrIjp7ImUiOiJBUUFCIiwia3R5IjoiUlNBIiwibiI6InkyTWN3ckg3Tk15NHktMGlNQlROTFdJQmN2TGktX2k4X3NUSlZhSVJic0FwM3JZaEZGeDJ2Xzc5RVRwM2hxcXVVMjNickpqUGdZVi1oZGNCN2x3cTRzc1pQRDJ6enZ6RW5MZnVoMExkc251eV9vUUlLR3RPdmI0OGxxWjRjMDk0ay1URkxWaEFwQmprZEJhSi1yaGI3aU0xeGszU1hMV2IyeEJyejFpWFYtb2tmWGFvOU41a1YwYXpPWjNTcGZyMkhQTFNFRFVRcmc0UlcwMUJaZTN6WnRLdTdUSlVubElDZUxKZF9yZXhNaXpXeDhpSXpZWC1OYXloWFNTcDF5ZVhQUmZrNVpubGhyR0N1Nnl3bWhtdTdRQTNkb3U3N1d4TjJFekFVSkFvaUl1eExwQ1NlUVY0WHhuREVlNG84OFU5X1BJMWY2eEJLZGNmUjBfSGVCdXQzdyJ9fQ"); 355 | assert_eq!(sig.payload, "dGhpcyBpcyBhIHRlc3QgcGF5bG9hZA"); 356 | assert_eq!(sig.signature, "vkUiuNTwtrHBDu69ajBQ1jhuqlIDDMnm57gwI7DQs8ljSXuSrWft8W5pUbsIe50TT6XXRmSnc3__XviADvarwhqhqJbfgr1NE66n3wUlRWc6uC7b7POaGlIs9vaWN_WfgtSzYwX6NtS5qfo4tY7hRH0wTD1R6gx3Vyb910JuA1boJNTazlD7sl6npCA5LXUQCnQQx5NHZl5vZs-xTYYQVlefgXdox-IP0qWvR1hCdTNiosFQTLIlLvF9wp13cADAplUQvynacxLQbrrn2dzSAXjCm9rPZty4lq0npvwQIQS9AaXVT7Nfbz_urIAO5Qlx89JbmFdS4VvzZdOUxk9lhA"); 357 | } 358 | 359 | #[test] 360 | fn jws_rsa_with_account() { 361 | let pem = fs::read("testdata/rsa_2048.pem").unwrap(); 362 | let key = PKey::private_key_from_pem(&pem).unwrap(); 363 | 364 | let sig = sign( 365 | JWS_URL, 366 | String::from(JWS_NONCE), 367 | JWS_PAYLOAD, 368 | &key, 369 | Some(JWS_ACCOUNT_ID), 370 | ) 371 | .unwrap(); 372 | 373 | assert_eq!(sig.protected, "eyJub25jZSI6IkEyNzJWRnB2QzFlN0gwWVoxNF8tZkxsYnQ5R2c4YlItZEd0bDBQcWp1R1hfLW84IiwiYWxnIjoiUlMyNTYiLCJ1cmwiOiJodHRwczovL2FjbWUtc3RhZ2luZy12MDIuYXBpLmxldHNlbmNyeXB0Lm9yZy9hY21lL25ldy1hY2N0Iiwia2lkIjoiMDEyMzQ1NiJ9"); 374 | assert_eq!(sig.payload, "dGhpcyBpcyBhIHRlc3QgcGF5bG9hZA"); 375 | assert_eq!(sig.signature, "jGnidAkLcm5f7AujOx_jdhBYDPwm0EVts5HREMUL9hs7xZVnj4C_iy7D8ZfjrJ15e5ZHToE0nmyV7_u8W5iX_4NA2isqJv_f3R9sjVky5D2nBsxS_CG3d_b2ANA1GZoVlrS_umEW2vIHQHOcnpqjZw1OEeH5DcHixeKy_3mKZIWWRvN-Jq2BD-DJdyOMHJL0jBPKYMVwR92-_mmhrtQsUZqefgDyezcENTePF6wbs_KdUUyLHja_N-3sqeD1_1k0z3_WhlEiKplYx1eNdd27tzXXy4CKXEtzouDN-1w6bhLBheik3Wa3rPkD9JVaFxbQy1LOa2jpTHkEK_TJLiUhfA"); 376 | } 377 | 378 | macro_rules! jws_ecdsa { 379 | ( 380 | $( 381 | $name:ident ($file:expr, $acct:expr) => { 382 | protected: $protected:expr, 383 | payload: $payload:expr, 384 | } 385 | );+ $(;)? 386 | ) => { 387 | $( 388 | #[test] 389 | fn $name() { 390 | let pem = fs::read($file).unwrap(); 391 | let key = PKey::private_key_from_pem(&pem).unwrap(); 392 | 393 | let sig = sign(JWS_URL, String::from(JWS_NONCE), JWS_PAYLOAD, &key, $acct).unwrap(); 394 | 395 | assert_eq!(sig.protected, $protected); 396 | assert_eq!(sig.payload, $payload); 397 | assert!(!sig.signature.is_empty()); 398 | } 399 | )* 400 | }; 401 | } 402 | 403 | jws_ecdsa! { 404 | jws_ecdsa_p_256_without_account("testdata/ecdsa_p-256.pem", None) => { 405 | protected: "eyJub25jZSI6IkEyNzJWRnB2QzFlN0gwWVoxNF8tZkxsYnQ5R2c4YlItZEd0bDBQcWp1R1hfLW84IiwiYWxnIjoiRVMyNTYiLCJ1cmwiOiJodHRwczovL2FjbWUtc3RhZ2luZy12MDIuYXBpLmxldHNlbmNyeXB0Lm9yZy9hY21lL25ldy1hY2N0IiwiandrIjp7ImNydiI6IlAtMjU2Iiwia3R5IjoiRUMiLCJ4IjoiYkZGSkVLazBIckF5VFZ6NjlpQ2lWOEtzWDFiTndTeDYwbzZYbGF0OWhQbyIsInkiOiJmc3hrV3dzcG00TkEybFVXSWY5RHdsck9RZ2YyWTYxMHluQXdKUF9HeDBFIn19", 406 | payload: "dGhpcyBpcyBhIHRlc3QgcGF5bG9hZA", 407 | }; 408 | jws_ecdsa_p_384_without_account("testdata/ecdsa_p-384.pem", None) => { 409 | protected: "eyJub25jZSI6IkEyNzJWRnB2QzFlN0gwWVoxNF8tZkxsYnQ5R2c4YlItZEd0bDBQcWp1R1hfLW84IiwiYWxnIjoiRVMzODQiLCJ1cmwiOiJodHRwczovL2FjbWUtc3RhZ2luZy12MDIuYXBpLmxldHNlbmNyeXB0Lm9yZy9hY21lL25ldy1hY2N0IiwiandrIjp7ImNydiI6IlAtMzg0Iiwia3R5IjoiRUMiLCJ4IjoiTURENjhUcm9za0Jjbms0OXdkN1VJMW5MSTRvOXE5REpIMFAyOWlia0FiNkF6THhnMG1JdTFVM053VVRLVWZfbCIsInkiOiJIbGRudElBekY2N05kLWpmVERhaUp4YTBXTVZIY1o1YXRfQVFreHRUNmFDdTVqUTF6U0tjUHZWbmoxU3YzSlQyIn19", 410 | payload: "dGhpcyBpcyBhIHRlc3QgcGF5bG9hZA", 411 | }; 412 | jws_ecdsa_p_521_without_account("testdata/ecdsa_p-521.pem", None) => { 413 | protected: "eyJub25jZSI6IkEyNzJWRnB2QzFlN0gwWVoxNF8tZkxsYnQ5R2c4YlItZEd0bDBQcWp1R1hfLW84IiwiYWxnIjoiRVM1MTIiLCJ1cmwiOiJodHRwczovL2FjbWUtc3RhZ2luZy12MDIuYXBpLmxldHNlbmNyeXB0Lm9yZy9hY21lL25ldy1hY2N0IiwiandrIjp7ImNydiI6IlAtNTIxIiwia3R5IjoiRUMiLCJ4IjoiQWQyN01pSmdPb2JCS0ZPX1l5QXk2bVFfRHoydUdMRjBVRDMtTWtGNGhMYTVaX19SQ3JObXRpZGpRNUZXNjR3YWhmekxlUWFtRUFfS0FUaDJ6RkJOaFNNMCIsInkiOiJBVXlnNFh1bW9iRXFhUENqVUdDOU1jOFNFMnNhVXJZVmQ4MjRJczFlcmNQanBxNVd4M0hFLUkySHZidExtbTI5VVgzVDVJa0htS1JiUElhN29COE9vNlBMIn19", 414 | payload: "dGhpcyBpcyBhIHRlc3QgcGF5bG9hZA", 415 | }; 416 | } 417 | 418 | jws_ecdsa! { 419 | jws_ecdsa_p_256_with_account("testdata/ecdsa_p-256.pem", Some(JWS_ACCOUNT_ID)) => { 420 | protected: "eyJub25jZSI6IkEyNzJWRnB2QzFlN0gwWVoxNF8tZkxsYnQ5R2c4YlItZEd0bDBQcWp1R1hfLW84IiwiYWxnIjoiRVMyNTYiLCJ1cmwiOiJodHRwczovL2FjbWUtc3RhZ2luZy12MDIuYXBpLmxldHNlbmNyeXB0Lm9yZy9hY21lL25ldy1hY2N0Iiwia2lkIjoiMDEyMzQ1NiJ9", 421 | payload: "dGhpcyBpcyBhIHRlc3QgcGF5bG9hZA", 422 | }; 423 | jws_ecdsa_p_384_with_account("testdata/ecdsa_p-384.pem", Some(JWS_ACCOUNT_ID)) => { 424 | protected: "eyJub25jZSI6IkEyNzJWRnB2QzFlN0gwWVoxNF8tZkxsYnQ5R2c4YlItZEd0bDBQcWp1R1hfLW84IiwiYWxnIjoiRVMzODQiLCJ1cmwiOiJodHRwczovL2FjbWUtc3RhZ2luZy12MDIuYXBpLmxldHNlbmNyeXB0Lm9yZy9hY21lL25ldy1hY2N0Iiwia2lkIjoiMDEyMzQ1NiJ9", 425 | payload: "dGhpcyBpcyBhIHRlc3QgcGF5bG9hZA", 426 | }; 427 | jws_ecdsa_p_521_with_account("testdata/ecdsa_p-521.pem", Some(JWS_ACCOUNT_ID)) => { 428 | protected: "eyJub25jZSI6IkEyNzJWRnB2QzFlN0gwWVoxNF8tZkxsYnQ5R2c4YlItZEd0bDBQcWp1R1hfLW84IiwiYWxnIjoiRVM1MTIiLCJ1cmwiOiJodHRwczovL2FjbWUtc3RhZ2luZy12MDIuYXBpLmxldHNlbmNyeXB0Lm9yZy9hY21lL25ldy1hY2N0Iiwia2lkIjoiMDEyMzQ1NiJ9", 429 | payload: "dGhpcyBpcyBhIHRlc3QgcGF5bG9hZA", 430 | }; 431 | } 432 | 433 | macro_rules! test_key_authorization { 434 | ( 435 | $( 436 | $name:ident($key:expr) => $signature:expr 437 | );+ $(;)? 438 | ) => { 439 | $( 440 | #[test] 441 | fn $name() { 442 | let pem = fs::read($key).unwrap(); 443 | let key = PKey::private_key_from_pem(&pem).unwrap(); 444 | 445 | let authorization = key_authorization("testing-token", &key).unwrap(); 446 | let parts = authorization.split('.').collect::>(); 447 | assert_eq!(*parts.first().unwrap(), "testing-token"); 448 | assert_eq!(*parts.last().unwrap(), $signature); 449 | } 450 | )* 451 | }; 452 | } 453 | 454 | test_key_authorization! { 455 | key_authorization_rsa("testdata/rsa_2048.pem") => "1tYs-daa88-j-PKKVXr1fsygMDlxk5sIYgcWzLl7zU8"; 456 | key_authorization_ecdsa_p_256("testdata/ecdsa_p-256.pem") => "uuIRg-39HHLblKbBUmg1XIT63ZynnLhCXvLJKY9Edew"; 457 | key_authorization_ecdsa_p_384("testdata/ecdsa_p-384.pem") => "t4pPjjyfZL9xx_bWqd79c5ucdOLixBtukSr58OiZhjI"; 458 | key_authorization_ecdsa_p_521("testdata/ecdsa_p-521.pem") => "c_7slHmYt2at4zV8Em-l1_yisd2s0Exvs8XDPsX11XI"; 459 | } 460 | 461 | static EAB_URL: &str = "https://10.30.50.2:14000/sign-me-up"; 462 | static EAB_KID: &str = "V6iRR0p3"; 463 | static EAB_HMAC: &str = "zWNDZM6eQGHWpSRTPal5eIUYFTu7EajVIoguysqZ9wG44nMEtx3MUAsUDkMTQ12W"; 464 | 465 | macro_rules! test_sign_with_eab { 466 | ( 467 | $( 468 | $name:ident($file:expr) => { 469 | protected: $protected:expr, 470 | payload: $payload:expr, 471 | signature: $signature:expr, 472 | } 473 | );+ $(;)? 474 | ) => { 475 | $( 476 | #[test] 477 | fn $name() { 478 | let pem = fs::read($file).unwrap(); 479 | let key = PKey::private_key_from_pem(&pem).unwrap(); 480 | 481 | let sig = sign_with_eab(EAB_URL, &key, EAB_KID, EAB_HMAC).unwrap(); 482 | assert_eq!(sig.protected, $protected); 483 | assert_eq!(sig.payload, $payload); 484 | assert_eq!(sig.signature, $signature); 485 | } 486 | )* 487 | }; 488 | } 489 | 490 | test_sign_with_eab! { 491 | sign_with_eab_rsa("testdata/rsa_2048.pem") => { 492 | protected: "eyJhbGciOiJIUzI1NiIsInVybCI6Imh0dHBzOi8vMTAuMzAuNTAuMjoxNDAwMC9zaWduLW1lLXVwIiwia2lkIjoiVjZpUlIwcDMifQ", 493 | payload: "eyJlIjoiQVFBQiIsImt0eSI6IlJTQSIsIm4iOiJ5Mk1jd3JIN05NeTR5LTBpTUJUTkxXSUJjdkxpLV9pOF9zVEpWYUlSYnNBcDNyWWhGRngydl83OUVUcDNocXF1VTIzYnJKalBnWVYtaGRjQjdsd3E0c3NaUEQyenp2ekVuTGZ1aDBMZHNudXlfb1FJS0d0T3ZiNDhscVo0YzA5NGstVEZMVmhBcEJqa2RCYUotcmhiN2lNMXhrM1NYTFdiMnhCcnoxaVhWLW9rZlhhbzlONWtWMGF6T1ozU3BmcjJIUExTRURVUXJnNFJXMDFCWmUzelp0S3U3VEpVbmxJQ2VMSmRfcmV4TWl6V3g4aUl6WVgtTmF5aFhTU3AxeWVYUFJmazVabmxockdDdTZ5d21obXU3UUEzZG91NzdXeE4yRXpBVUpBb2lJdXhMcENTZVFWNFh4bkRFZTRvODhVOV9QSTFmNnhCS2RjZlIwX0hlQnV0M3cifQ", 494 | signature: "XXK6TYRI_-kjlMraYSXqYaIBqks2eSB9JANqt-Vv0tw", 495 | }; 496 | sign_with_eab_ecdsa_p_256("testdata/ecdsa_p-256.pem") => { 497 | protected: "eyJhbGciOiJIUzI1NiIsInVybCI6Imh0dHBzOi8vMTAuMzAuNTAuMjoxNDAwMC9zaWduLW1lLXVwIiwia2lkIjoiVjZpUlIwcDMifQ", 498 | payload: "eyJjcnYiOiJQLTI1NiIsImt0eSI6IkVDIiwieCI6ImJGRkpFS2swSHJBeVRWejY5aUNpVjhLc1gxYk53U3g2MG82WGxhdDloUG8iLCJ5IjoiZnN4a1d3c3BtNE5BMmxVV0lmOUR3bHJPUWdmMlk2MTB5bkF3SlBfR3gwRSJ9", 499 | signature: "sXYXLVwqpVIx1bZngZ0ORvFR_kvETi9kFyIdFwQXlm8", 500 | }; 501 | sign_with_eab_ecdsa_p_384("testdata/ecdsa_p-384.pem") => { 502 | protected: "eyJhbGciOiJIUzI1NiIsInVybCI6Imh0dHBzOi8vMTAuMzAuNTAuMjoxNDAwMC9zaWduLW1lLXVwIiwia2lkIjoiVjZpUlIwcDMifQ", 503 | payload: "eyJjcnYiOiJQLTM4NCIsImt0eSI6IkVDIiwieCI6Ik1ERDY4VHJvc2tCY25rNDl3ZDdVSTFuTEk0bzlxOURKSDBQMjlpYmtBYjZBekx4ZzBtSXUxVTNOd1VUS1VmX2wiLCJ5IjoiSGxkbnRJQXpGNjdOZC1qZlREYWlKeGEwV01WSGNaNWF0X0FRa3h0VDZhQ3U1alExelNLY1B2Vm5qMVN2M0pUMiJ9", 504 | signature: "pX34eEDN2QZL0fuRi7qJnewPo5oomVCDrZ2Y-kXSdwE", 505 | }; 506 | sign_with_eab_ecdsa_p_521("testdata/ecdsa_p-521.pem") => { 507 | protected: "eyJhbGciOiJIUzI1NiIsInVybCI6Imh0dHBzOi8vMTAuMzAuNTAuMjoxNDAwMC9zaWduLW1lLXVwIiwia2lkIjoiVjZpUlIwcDMifQ", 508 | payload: "eyJjcnYiOiJQLTUyMSIsImt0eSI6IkVDIiwieCI6IkFkMjdNaUpnT29iQktGT19ZeUF5Nm1RX0R6MnVHTEYwVUQzLU1rRjRoTGE1Wl9fUkNyTm10aWRqUTVGVzY0d2FoZnpMZVFhbUVBX0tBVGgyekZCTmhTTTAiLCJ5IjoiQVV5ZzRYdW1vYkVxYVBDalVHQzlNYzhTRTJzYVVyWVZkODI0SXMxZXJjUGpwcTVXeDNIRS1JMkh2YnRMbW0yOVVYM1Q1SWtIbUtSYlBJYTdvQjhPbzZQTCJ9", 509 | signature: "0P6pEVQ7SZJtymoLiYKELgzRHVDeZiaVEny3DobPBeM", 510 | }; 511 | } 512 | } 513 | -------------------------------------------------------------------------------- /src/api/nonce.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{Error, Result}; 2 | use reqwest::{Client, Response}; 3 | use std::{collections::VecDeque, sync::Mutex}; 4 | 5 | #[derive(Debug)] 6 | pub(crate) struct Pool { 7 | pool: Mutex>, 8 | max: usize, 9 | } 10 | 11 | impl Pool { 12 | pub fn new(max: usize) -> Self { 13 | Pool { 14 | pool: Mutex::default(), 15 | max, 16 | } 17 | } 18 | 19 | /// Get a nonce used to sign the request 20 | pub async fn get(&self, url: &str, client: &Client) -> Result { 21 | { 22 | let mut pool = self.pool.lock().unwrap(); 23 | if let Some(nonce) = pool.pop_front() { 24 | return Ok(nonce); 25 | } 26 | } 27 | 28 | let response = client.head(url).send().await?; 29 | 30 | let nonce = response 31 | .headers() 32 | .get("replay-nonce") 33 | .ok_or(Error::MissingHeader("replay-nonce"))? 34 | .to_str() 35 | .map_err(|e| Error::InvalidHeader("replay-nonce", e))? 36 | .to_owned(); 37 | Ok(nonce) 38 | } 39 | 40 | /// Extract a nonce from the `Replay-Nonce` header if it exists 41 | pub fn extract_from_response(&self, response: &Response) -> Result<()> { 42 | if let Some(nonce) = response.headers().get("replay-nonce") { 43 | let nonce = nonce 44 | .to_str() 45 | .map_err(|e| Error::InvalidHeader("replay-nonce", e))? 46 | .to_owned(); 47 | 48 | let mut pool = self.pool.lock().unwrap(); 49 | pool.push_back(nonce); 50 | 51 | // Prevent the nonce pool from growing unnecessarily large 52 | if pool.len() > self.max { 53 | pool.pop_front(); 54 | } 55 | } 56 | 57 | Ok(()) 58 | } 59 | } 60 | 61 | #[cfg(test)] 62 | mod tests { 63 | use super::Pool; 64 | use reqwest::Client; 65 | 66 | const NEW_NONCE_URL: &str = "https://acme-staging-v02.api.letsencrypt.org/acme/new-nonce"; 67 | 68 | #[tokio::test] 69 | async fn get_nonce_with_empty_cache() { 70 | let client = Client::new(); 71 | let pool = Pool::new(10); 72 | 73 | let nonce = pool.get(NEW_NONCE_URL, &client).await.unwrap(); 74 | assert_ne!(nonce.len(), 0); 75 | 76 | assert_pool_size(&pool, 0); 77 | } 78 | 79 | #[tokio::test] 80 | async fn get_nonce_with_cache() { 81 | let client = Client::new(); 82 | 83 | let pool = Pool::new(10); 84 | { 85 | let mut pool = pool.pool.lock().unwrap(); 86 | pool.push_back(String::from("nonce-asdf")); 87 | } 88 | 89 | let nonce = pool.get("http://this.should/fail", &client).await.unwrap(); 90 | assert_eq!(nonce, "nonce-asdf"); 91 | 92 | assert_pool_size(&pool, 0); 93 | } 94 | 95 | #[tokio::test] 96 | async fn cache_size_is_not_exceeded() { 97 | let client = Client::new(); 98 | let pool = Pool::new(2); 99 | 100 | assert_pool_size(&pool, 0); 101 | 102 | let response = client.head(NEW_NONCE_URL).send().await.unwrap(); 103 | pool.extract_from_response(&response).unwrap(); 104 | assert_pool_size(&pool, 1); 105 | 106 | let response = client.head(NEW_NONCE_URL).send().await.unwrap(); 107 | pool.extract_from_response(&response).unwrap(); 108 | assert_pool_size(&pool, 2); 109 | 110 | let response = client.head(NEW_NONCE_URL).send().await.unwrap(); 111 | pool.extract_from_response(&response).unwrap(); 112 | assert_pool_size(&pool, 2); 113 | } 114 | 115 | fn assert_pool_size(pool: &Pool, expected: usize) { 116 | let pool = pool.pool.lock().unwrap(); 117 | assert_eq!(pool.len(), expected); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/certificate.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | account::Account, 3 | error::{Error, Result}, 4 | order::Order, 5 | responses::{Identifier, RevocationReason}, 6 | Directory, 7 | }; 8 | use base64::engine::{general_purpose::URL_SAFE_NO_PAD as BASE64, Engine}; 9 | use chrono::{DateTime, Utc}; 10 | use futures::future; 11 | use openssl::{ 12 | ec::{EcGroup, EcKey}, 13 | hash::MessageDigest, 14 | nid::Nid, 15 | pkey::{PKey, Private}, 16 | x509::X509, 17 | }; 18 | use tracing::{info, instrument, Level, Span}; 19 | 20 | /// Used to configure the ordering of a certificate 21 | pub struct CertificateBuilder<'a> { 22 | account: &'a Account, 23 | identifiers: Vec, 24 | not_before: Option>, 25 | not_after: Option>, 26 | private_key: Option>, 27 | } 28 | 29 | impl<'a> CertificateBuilder<'a> { 30 | pub(crate) fn new(account: &'a Account) -> CertificateBuilder<'a> { 31 | CertificateBuilder { 32 | account, 33 | // We know there'll be at least 1 identifier in the order 34 | identifiers: Vec::with_capacity(1), 35 | not_before: None, 36 | not_after: None, 37 | private_key: None, 38 | } 39 | } 40 | 41 | /// Add a domain (DNS identifier) to the certificate. 42 | /// 43 | /// All certificates must have at least one domain associated with them. 44 | pub fn add_domain>(mut self, domain: S) -> Self { 45 | self.identifiers.push(Identifier::Dns(domain.into())); 46 | self 47 | } 48 | 49 | /// When the certificate should expire. 50 | /// 51 | /// This may not be supported by all ACME servers, namely 52 | /// [Let's Encrypt](https://github.com/letsencrypt/boulder/blob/main/docs/acme-divergences.md#section-74). 53 | pub fn expiration(mut self, at: DateTime) -> Self { 54 | self.not_after = Some(at); 55 | self 56 | } 57 | 58 | /// When the certificate should start being valid. 59 | /// 60 | /// This may not be supported by all ACME servers, namely 61 | /// [Let's Encrypt](https://github.com/letsencrypt/boulder/blob/main/docs/acme-divergences.md#section-74). 62 | pub fn not_before(mut self, at: DateTime) -> Self { 63 | self.not_before = Some(at); 64 | self 65 | } 66 | 67 | /// Set the private key for certificate. 68 | pub fn private_key(mut self, private_key: PKey) -> Self { 69 | self.private_key = Some(private_key); 70 | self 71 | } 72 | 73 | /// Obtain the certificate 74 | #[instrument( 75 | level = Level::INFO, 76 | name = "CertificateBuilder::obtain", 77 | err, 78 | skip_all, 79 | fields( 80 | order.id, 81 | self.account.id, 82 | ?self.identifiers, 83 | ?self.not_before, 84 | ?self.not_after, 85 | ), 86 | )] 87 | pub async fn obtain(self) -> Result { 88 | if self.identifiers.is_empty() { 89 | return Err(Error::MissingIdentifiers); 90 | } 91 | 92 | let mut order = Order::create( 93 | self.account, 94 | self.identifiers, 95 | self.not_before, 96 | self.not_after, 97 | ) 98 | .await?; 99 | Span::current().record("order.id", order.id()); 100 | 101 | info!("solving order authorization(s)"); 102 | let authorizations = order.authorizations().await?; 103 | future::try_join_all(authorizations.iter().map(|a| a.solve())).await?; 104 | 105 | info!("waiting for order to be ready..."); 106 | order.wait_ready().await?; 107 | 108 | let private_key = match self.private_key { 109 | Some(key) => key, 110 | None => { 111 | let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1)?; 112 | let ec = EcKey::generate(&group)?; 113 | PKey::from_ec_key(ec)? 114 | } 115 | }; 116 | 117 | info!("finalizing order..."); 118 | order.finalize(&private_key).await?; 119 | 120 | order.wait_done().await?; 121 | 122 | info!("order completed, downloading certificate..."); 123 | let chain = order.download().await?; 124 | 125 | Ok(Certificate { chain, private_key }) 126 | } 127 | } 128 | 129 | /// An issued certificate by the ACME server 130 | #[derive(Debug)] 131 | pub struct Certificate { 132 | chain: Vec, 133 | pub(crate) private_key: PKey, 134 | } 135 | 136 | impl Certificate { 137 | /// Load a certificate from an exported chain and private key 138 | pub fn from_chain_and_private_key(chain: Format<'_>, private_key: Format<'_>) -> Result { 139 | Ok(Certificate { 140 | chain: chain.try_into()?, 141 | private_key: private_key.try_into()?, 142 | }) 143 | } 144 | 145 | /// Create a certificate from an already parsed chain and private key 146 | pub fn from_raw_chain_and_private_key(chain: Vec, private_key: PKey) -> Self { 147 | Certificate { chain, private_key } 148 | } 149 | 150 | /// Export the private key in PEM PKCS#8 format 151 | pub fn private_key_to_pem(&self) -> Result> { 152 | Ok(self.private_key.private_key_to_pem_pkcs8()?) 153 | } 154 | 155 | /// Export the private key in DER format 156 | pub fn private_key_to_der(&self) -> Result> { 157 | Ok(self.private_key.private_key_to_der()?) 158 | } 159 | 160 | /// Export the issued certificate in PEM format 161 | /// 162 | /// **NOTE**: this does NOT export the full certificate chain, use 163 | /// [`Certificate::fullchain_to_pem`] for that. 164 | pub fn to_pem(&self) -> Result> { 165 | Ok(self.chain.first().unwrap().to_pem()?) 166 | } 167 | 168 | /// Export the full certificate chain in PEM format 169 | pub fn fullchain_to_pem(&self) -> Result> { 170 | let mut result = Vec::new(); 171 | for certificate in &self.chain { 172 | result.extend(certificate.to_pem()?); 173 | } 174 | Ok(result) 175 | } 176 | 177 | /// Export the issued certificate in DER format 178 | /// 179 | /// **NOTE**: this does NOT export the full certificate chain, use 180 | /// [`Certificate::fullchain_to_der`] for that. 181 | pub fn to_der(&self) -> Result> { 182 | Ok(self.chain.first().unwrap().to_der()?) 183 | } 184 | 185 | /// Export the full certificate chain in DER format 186 | pub fn fullchain_to_der(&self) -> Result> { 187 | let mut result = Vec::new(); 188 | for certificate in &self.chain { 189 | result.extend(certificate.to_der()?); 190 | } 191 | Ok(result) 192 | } 193 | 194 | /// Get a reference to the underlying [`openssl::x509::X509`] instance for the certificate. 195 | pub fn x509(&self) -> &X509 { 196 | self.chain.first().unwrap() 197 | } 198 | 199 | /// Get a reference to the full [`openssl::x509::X509`] chain for the certificate. 200 | pub fn x509_chain(&self) -> &[X509] { 201 | self.chain.as_slice() 202 | } 203 | 204 | /// Calculate the SHA256 digest of the leaf certificate in hex format 205 | pub fn digest(&self) -> String { 206 | let digest = self 207 | .x509() 208 | .digest(MessageDigest::sha256()) 209 | .expect("digest should always succeed"); 210 | hex::encode(digest) 211 | } 212 | 213 | /// Revoke this certificate. 214 | #[instrument( 215 | level = Level::INFO, 216 | name = "Certificate::revoke", 217 | err, 218 | skip_all, 219 | fields(self = %self.digest()) 220 | )] 221 | pub async fn revoke(&self, directory: &Directory) -> Result<()> { 222 | let der = BASE64.encode(self.to_der()?); 223 | directory 224 | .api() 225 | .revoke_certificate(der, None, &self.private_key, None) 226 | .await 227 | } 228 | 229 | /// Revoke this certificate with a reason. 230 | #[instrument( 231 | level = Level::INFO, 232 | name = "Certificate::revoke_with_reason", 233 | err, 234 | skip_all, 235 | fields(self = %self.digest()) 236 | )] 237 | pub async fn revoke_with_reason( 238 | &self, 239 | directory: &Directory, 240 | reason: RevocationReason, 241 | ) -> Result<()> { 242 | let der = BASE64.encode(self.to_der()?); 243 | directory 244 | .api() 245 | .revoke_certificate(der, Some(reason), &self.private_key, None) 246 | .await 247 | } 248 | } 249 | 250 | /// The possible formats a certificate/private key can be loaded from. 251 | /// 252 | /// When loading a certificate, full certificate chains can only be loaded from [`Format::Pem`]. 253 | #[derive(Debug)] 254 | pub enum Format<'d> { 255 | /// Bytes of a PEM encoded x509 certificate or private key 256 | Pem(&'d [u8]), 257 | /// Bytes of a DER encoded x509 certificate or private key 258 | Der(&'d [u8]), 259 | } 260 | 261 | impl<'d> TryInto> for Format<'d> { 262 | type Error = openssl::error::ErrorStack; 263 | 264 | fn try_into(self) -> std::result::Result, Self::Error> { 265 | match self { 266 | Self::Pem(pem) => X509::stack_from_pem(pem), 267 | Self::Der(der) => Ok(vec![X509::from_der(der)?]), 268 | } 269 | } 270 | } 271 | 272 | impl<'d> TryInto> for Format<'d> { 273 | type Error = openssl::error::ErrorStack; 274 | 275 | fn try_into(self) -> std::result::Result, Self::Error> { 276 | match self { 277 | Self::Pem(pem) => PKey::private_key_from_pem(pem), 278 | Self::Der(der) => PKey::private_key_from_der(der), 279 | } 280 | } 281 | } 282 | 283 | #[cfg(test)] 284 | mod tests { 285 | use crate::{ 286 | responses::{ErrorType, RevocationReason}, 287 | test::{account, directory, directory_with_dns01_solver, directory_with_http01_solver}, 288 | Error, 289 | }; 290 | use openssl::{ 291 | pkey::{PKey, Private}, 292 | x509::X509, 293 | }; 294 | use test_log::test; 295 | 296 | macro_rules! check_subjects { 297 | ($cert:expr => $($name:expr),+ $(,)?) => { 298 | { 299 | let expected = { 300 | let mut set = std::collections::HashSet::new(); 301 | $( set.insert($name.to_owned()); )+ 302 | set 303 | }; 304 | let names = $cert 305 | .subject_alt_names() 306 | .unwrap() 307 | .iter() 308 | .map(|n| n.dnsname().unwrap().to_owned()) 309 | .collect::>(); 310 | assert_eq!(names, expected); 311 | } 312 | }; 313 | } 314 | 315 | fn check_key(cert: &X509, key: &PKey) { 316 | let cert_key = cert.public_key().unwrap(); 317 | assert!(key.public_eq(&cert_key)); 318 | } 319 | 320 | /// Check that the issuer for a certificate matches the provided issuer 321 | fn check_issuer(cert: &X509, issuer: &X509) { 322 | assert_eq!( 323 | cert.issuer_name() 324 | .entries() 325 | .next() 326 | .unwrap() 327 | .data() 328 | .as_utf8() 329 | .unwrap() 330 | .to_string(), 331 | issuer 332 | .subject_name() 333 | .entries() 334 | .next() 335 | .unwrap() 336 | .data() 337 | .as_utf8() 338 | .unwrap() 339 | .to_string() 340 | ); 341 | } 342 | 343 | #[test(tokio::test)] 344 | async fn obtain_no_identifiers() { 345 | let directory = directory().await; 346 | let account = account(directory).await; 347 | 348 | let error = account.certificate().obtain().await.unwrap_err(); 349 | assert!(matches!(error, Error::MissingIdentifiers)); 350 | } 351 | 352 | #[test(tokio::test)] 353 | async fn obtain_missing_solvers() { 354 | let directory = directory().await; 355 | let account = account(directory).await; 356 | 357 | let error = account 358 | .certificate() 359 | .add_domain("domain.com") 360 | .obtain() 361 | .await 362 | .unwrap_err(); 363 | assert!(matches!(error, Error::MissingSolver)); 364 | } 365 | 366 | #[test(tokio::test)] 367 | async fn obtain_blocked_domain() { 368 | let directory = directory().await; 369 | let account = account(directory).await; 370 | 371 | let error = account 372 | .certificate() 373 | .add_domain("blocked-domain.example") 374 | .obtain() 375 | .await 376 | .unwrap_err(); 377 | 378 | let Error::Server(error) = error else { panic!("expected Error::Server") }; 379 | assert_eq!(error.type_, ErrorType::RejectedIdentifier); 380 | assert_eq!(error.status.unwrap(), 400); 381 | assert!(error.detail.unwrap().contains("blocked-domain.example")); 382 | } 383 | 384 | #[test(tokio::test)] 385 | async fn obtain_single_domain() { 386 | let directory = directory_with_http01_solver().await; 387 | let account = account(directory).await; 388 | 389 | let certificate = account 390 | .certificate() 391 | .add_domain("single.com") 392 | .obtain() 393 | .await 394 | .unwrap(); 395 | 396 | assert_eq!(certificate.chain.len(), 2); 397 | let issued = certificate.chain.first().unwrap(); 398 | let issuer = certificate.chain.last().unwrap(); 399 | 400 | check_subjects!(issued => "single.com"); 401 | check_issuer(issued, issuer); 402 | check_key(issued, &certificate.private_key); 403 | } 404 | 405 | #[test(tokio::test)] 406 | async fn obtain_multiple_domains() { 407 | let directory = directory_with_http01_solver().await; 408 | let account = account(directory).await; 409 | 410 | let certificate = account 411 | .certificate() 412 | .add_domain("one.multiple.com") 413 | .add_domain("two.multiple.com") 414 | .add_domain("three.multiple.com") 415 | .obtain() 416 | .await 417 | .unwrap(); 418 | 419 | assert_eq!(certificate.chain.len(), 2); 420 | let issued = certificate.chain.first().unwrap(); 421 | let issuer = certificate.chain.last().unwrap(); 422 | 423 | check_subjects!(issued => "one.multiple.com", "two.multiple.com", "three.multiple.com"); 424 | check_issuer(issued, issuer); 425 | check_key(issued, &certificate.private_key); 426 | } 427 | 428 | #[test(tokio::test)] 429 | async fn obtain_wildcard() { 430 | let directory = directory_with_dns01_solver().await; 431 | let account = account(directory).await; 432 | 433 | let certificate = account 434 | .certificate() 435 | .add_domain("*.wildcard.com") 436 | .obtain() 437 | .await 438 | .unwrap(); 439 | 440 | assert_eq!(certificate.chain.len(), 2); 441 | let issued = certificate.chain.first().unwrap(); 442 | let issuer = certificate.chain.last().unwrap(); 443 | 444 | check_subjects!(issued => "*.wildcard.com"); 445 | check_issuer(issued, issuer); 446 | check_key(issued, &certificate.private_key); 447 | } 448 | 449 | #[test(tokio::test)] 450 | async fn obtain_wildcard_without_dns01() { 451 | let directory = directory_with_http01_solver().await; 452 | let account = account(directory).await; 453 | 454 | let error = account 455 | .certificate() 456 | .add_domain("*.failure.wildcard.com") 457 | .obtain() 458 | .await 459 | .unwrap_err(); 460 | assert!(matches!(error, Error::MissingSolver)); 461 | } 462 | 463 | #[test(tokio::test)] 464 | async fn obtain_and_revoke_from_account() { 465 | let directory = directory_with_http01_solver().await; 466 | let account = account(directory).await; 467 | 468 | let certificate = account 469 | .certificate() 470 | .add_domain("revoke.com") 471 | .obtain() 472 | .await 473 | .unwrap(); 474 | 475 | account 476 | .revoke_certificate(certificate.x509()) 477 | .await 478 | .unwrap(); 479 | } 480 | 481 | #[test(tokio::test)] 482 | async fn obtain_and_revoke_with_reason_from_account() { 483 | let directory = directory_with_http01_solver().await; 484 | let account = account(directory).await; 485 | 486 | let certificate = account 487 | .certificate() 488 | .add_domain("reason.revoke.com") 489 | .obtain() 490 | .await 491 | .unwrap(); 492 | 493 | account 494 | .revoke_certificate_with_reason(certificate.x509(), RevocationReason::Superseded) 495 | .await 496 | .unwrap(); 497 | } 498 | 499 | #[test(tokio::test)] 500 | async fn obtain_and_revoke_from_certificate() { 501 | let directory = directory_with_http01_solver().await; 502 | let account = account(directory.clone()).await; 503 | 504 | let certificate = account 505 | .certificate() 506 | .add_domain("reason.revoke.com") 507 | .obtain() 508 | .await 509 | .unwrap(); 510 | 511 | certificate.revoke(&directory).await.unwrap(); 512 | } 513 | 514 | #[test(tokio::test)] 515 | async fn obtain_and_revoke_with_reason_from_certificate() { 516 | let directory = directory_with_http01_solver().await; 517 | let account = account(directory.clone()).await; 518 | 519 | let certificate = account 520 | .certificate() 521 | .add_domain("reason.revoke.com") 522 | .obtain() 523 | .await 524 | .unwrap(); 525 | 526 | certificate 527 | .revoke_with_reason(&directory, RevocationReason::Superseded) 528 | .await 529 | .unwrap(); 530 | } 531 | 532 | #[test(tokio::test)] 533 | async fn obtain_and_renew_single_domain() { 534 | let directory = directory_with_http01_solver().await; 535 | let account = account(directory).await; 536 | 537 | let certificate = account 538 | .certificate() 539 | .add_domain("renew.me") 540 | .obtain() 541 | .await 542 | .unwrap(); 543 | 544 | account.renew_certificate(certificate).await.unwrap(); 545 | } 546 | 547 | #[test(tokio::test)] 548 | async fn obtain_and_renew_multiple_domains() { 549 | let directory = directory_with_http01_solver().await; 550 | let account = account(directory).await; 551 | 552 | let certificate = account 553 | .certificate() 554 | .add_domain("one.renew.me") 555 | .add_domain("two.renew.me") 556 | .add_domain("three.renew.me") 557 | .obtain() 558 | .await 559 | .unwrap(); 560 | 561 | account.renew_certificate(certificate).await.unwrap(); 562 | } 563 | 564 | #[test(tokio::test)] 565 | async fn obtain_and_renew_wildcard_domain() { 566 | let directory = directory_with_dns01_solver().await; 567 | let account = account(directory).await; 568 | 569 | let certificate = account 570 | .certificate() 571 | .add_domain("*.renew.me") 572 | .obtain() 573 | .await 574 | .unwrap(); 575 | 576 | account.renew_certificate(certificate).await.unwrap(); 577 | } 578 | } 579 | -------------------------------------------------------------------------------- /src/directory.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | account::{AccountBuilder, NoPrivateKey}, 3 | api::{responses::DirectoryMeta, Api}, 4 | error::Result, 5 | solver::{Solver, SolverManager}, 6 | }; 7 | use reqwest::Client; 8 | 9 | /// The Let's Encrypt production ACMEv2 API 10 | pub const LETS_ENCRYPT_PRODUCTION_URL: &str = "https://acme-v02.api.letsencrypt.org/directory"; 11 | 12 | /// The Let's Encrypt staging ACMEv2 API 13 | pub const LETS_ENCRYPT_STAGING_URL: &str = "https://acme-staging-v02.api.letsencrypt.org/directory"; 14 | 15 | /// A builder used to create a [`Directory`] 16 | pub struct DirectoryBuilder { 17 | url: String, 18 | client: Option, 19 | max_nonces: usize, 20 | solvers: SolverManager, 21 | } 22 | 23 | impl DirectoryBuilder { 24 | /// Creates a new builder with the specified directory root URL. 25 | pub fn new(url: String) -> Self { 26 | DirectoryBuilder { 27 | url, 28 | client: None, 29 | max_nonces: 10, 30 | solvers: SolverManager::default(), 31 | } 32 | } 33 | 34 | /// Use a custom [`reqwest::Client`] for all outbount HTTP requests 35 | /// to the ACME server. 36 | pub fn client(mut self, client: Client) -> Self { 37 | self.client = Some(client); 38 | self 39 | } 40 | 41 | /// Set the maximum number of nonces to keep, defaults to 10 42 | pub fn max_nonces(mut self, max: usize) -> Self { 43 | self.max_nonces = max; 44 | self 45 | } 46 | 47 | /// Set the DNS-01 solver 48 | pub fn dns01_solver(mut self, solver: Box) -> Self { 49 | self.solvers.set_dns01_solver(solver); 50 | self 51 | } 52 | 53 | /// Set the HTTP-01 solver 54 | pub fn http01_solver(mut self, solver: Box) -> Self { 55 | self.solvers.set_http01_solver(solver); 56 | self 57 | } 58 | 59 | /// Set the TLS-ALPN-01 solver 60 | pub fn tls_alpn01_solver(mut self, solver: Box) -> Self { 61 | self.solvers.set_tls_alpn01_solver(solver); 62 | self 63 | } 64 | 65 | /// Build a [`Directory`] using the given parameters. 66 | /// 67 | /// If no http client is specified, a default client will be created with 68 | /// the user-agent `lers/`. 69 | pub async fn build(self) -> Result { 70 | let client = self.client.unwrap_or_else(|| { 71 | Client::builder() 72 | .user_agent(crate::USER_AGENT) 73 | .build() 74 | .unwrap() 75 | }); 76 | 77 | let api = Api::from_url(self.url, client, self.max_nonces, self.solvers).await?; 78 | 79 | Ok(Directory(api)) 80 | } 81 | } 82 | 83 | /// Entry point for accessing an ACME API 84 | #[derive(Clone, Debug)] 85 | pub struct Directory(Api); 86 | 87 | impl Directory { 88 | /// Build a new directory with the specified root URL 89 | pub fn builder>(url: S) -> DirectoryBuilder { 90 | DirectoryBuilder::new(url.into()) 91 | } 92 | 93 | /// Access the builder to lookup an existing or create a new account 94 | pub fn account(&self) -> AccountBuilder { 95 | AccountBuilder::::new(self.0.clone()) 96 | } 97 | 98 | /// Get optional metadata about the directory 99 | #[inline(always)] 100 | pub fn meta(&self) -> &DirectoryMeta { 101 | self.0.meta() 102 | } 103 | 104 | /// Get a reference to the API, only for use by actions that don't depend on an account. 105 | pub(crate) fn api(&self) -> &Api { 106 | &self.0 107 | } 108 | } 109 | 110 | #[cfg(test)] 111 | mod tests { 112 | use super::{Directory, LETS_ENCRYPT_STAGING_URL}; 113 | use crate::test::directory; 114 | use test_log::test; 115 | 116 | #[test(tokio::test)] 117 | async fn initialize_lets_encrypt() { 118 | let directory = Directory::builder(LETS_ENCRYPT_STAGING_URL) 119 | .build() 120 | .await 121 | .unwrap(); 122 | 123 | assert_eq!( 124 | directory.meta().terms_of_service, 125 | Some("https://letsencrypt.org/documents/LE-SA-v1.3-September-21-2022.pdf".into()) 126 | ); 127 | assert_eq!( 128 | directory.meta().website, 129 | Some("https://letsencrypt.org/docs/staging-environment/".into()) 130 | ); 131 | assert_eq!( 132 | directory.meta().caa_identities, 133 | Some(vec!["letsencrypt.org".into()]) 134 | ); 135 | assert_eq!(directory.meta().external_account_required, None); 136 | } 137 | 138 | #[test(tokio::test)] 139 | async fn initialize_pebble() { 140 | let directory = directory().await; 141 | 142 | assert_eq!( 143 | directory.meta().terms_of_service, 144 | Some("data:text/plain,Do%20what%20thou%20wilt".into()) 145 | ); 146 | assert_eq!(directory.meta().website, None); 147 | assert_eq!(directory.meta().caa_identities, None); 148 | assert_eq!(directory.meta().external_account_required, Some(false)); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use crate::api::responses; 2 | use reqwest::header::ToStrError; 3 | use std::{ 4 | error::Error as StdError, 5 | fmt::{Display, Formatter}, 6 | }; 7 | 8 | pub(crate) type Result = std::result::Result; 9 | 10 | /// Possible errors that could occur 11 | #[derive(Debug)] 12 | pub enum Error { 13 | /// Error occurred in the server 14 | Server(responses::Error), 15 | /// Error occurred while processing the request 16 | Reqwest(reqwest::Error), 17 | /// Failed serializing the request 18 | Serialization(serde_json::Error), 19 | /// The `Location` header was missing from the response 20 | MissingHeader(&'static str), 21 | /// The header contained invalid data 22 | InvalidHeader(&'static str, ToStrError), 23 | /// The account's status is not set to `Valid` 24 | InvalidAccount(responses::AccountStatus), 25 | /// The certificate must have at least one identifier associated with it 26 | MissingIdentifiers, 27 | /// No solver could be found for any of the proposed challenge types 28 | MissingSolver, 29 | /// The solver encountered an error while presenting or cleaning up the challenge. 30 | SolverFailure(Box), 31 | /// The solver was configured incorrectly 32 | InvalidSolverConfiguration { 33 | /// The solver's name 34 | name: &'static str, 35 | /// The error propagated from the solver 36 | error: Box, 37 | }, 38 | /// The maximum attempts while polling a resource was exceeded 39 | MaxAttemptsExceeded, 40 | /// The challenge for the identifier could not be validated 41 | ChallengeFailed(responses::Identifier, responses::ChallengeType), 42 | /// An error occurred within OpenSSL 43 | OpenSSL(openssl::error::ErrorStack), 44 | /// The order is invalid due to an error or authorization failure 45 | OrderFailed(responses::Error), 46 | /// The certificate cannot be downloaded due to the order state 47 | CannotDownloadCertificate, 48 | /// The provided key type is not supported 49 | UnsupportedKeyType, 50 | /// The provided ECDSA curve is not supported 51 | UnsupportedECDSACurve, 52 | /// The provided external account binding HMAC is not Base64 URL-encoded without padding 53 | InvalidExternalAccountBindingHMAC(base64::DecodeError), 54 | } 55 | 56 | impl Display for Error { 57 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 58 | match self { 59 | Self::Server(_) => write!(f, "an error occurred in the server"), 60 | Self::Reqwest(_) => write!(f, "an error occurred while processing the request"), 61 | Self::Serialization(_) => write!(f, "an error occurred while serializing the request"), 62 | Self::MissingHeader(name) => write!(f, "the `{name}` header was missing"), 63 | Self::InvalidHeader(name, _) => { 64 | write!(f, "the value of the `{name}` header was invalid") 65 | } 66 | Self::InvalidAccount(status) => { 67 | write!(f, "expected Valid account, got {status:?} account") 68 | } 69 | Self::MissingIdentifiers => write!( 70 | f, 71 | "the certificate must have at least one identifier associated with it" 72 | ), 73 | Self::MissingSolver => write!( 74 | f, 75 | "no solver could be found for the proposed challenge types" 76 | ), 77 | Self::SolverFailure(e) => write!( 78 | f, 79 | "the solver failed while presenting or cleaning up the challenge: {e}" 80 | ), 81 | Self::InvalidSolverConfiguration { name, error } => { 82 | write!(f, "invalid configuration for solver {name:?}: {error}") 83 | } 84 | Self::MaxAttemptsExceeded => write!( 85 | f, 86 | "the maximum attempts while polling a resource was exceeded" 87 | ), 88 | Self::ChallengeFailed(identifier, type_) => write!( 89 | f, 90 | "the {type_:?} challenge could not be validated for {identifier:?}" 91 | ), 92 | Self::OpenSSL(e) => write!(f, "openssl error: {e}"), 93 | Self::OrderFailed(e) => write!( 94 | f, 95 | "the order is invalid due to an error or authorization failure: {} ({})", 96 | e.type_.description(), 97 | e.type_.code() 98 | ), 99 | Self::CannotDownloadCertificate => { 100 | write!(f, "cannot download the certificate due to the order status") 101 | } 102 | Self::UnsupportedKeyType => write!(f, "the provided key type is unsupported"), 103 | Self::UnsupportedECDSACurve => write!(f, "the provided ecdsa curve is unsupported"), 104 | Self::InvalidExternalAccountBindingHMAC(_) => write!(f, "the provided external account binding HMAC is not Base64 URL-encoded without padding"), 105 | } 106 | } 107 | } 108 | 109 | impl StdError for Error { 110 | fn source(&self) -> Option<&(dyn StdError + 'static)> { 111 | match self { 112 | Self::Server(_) => None, 113 | Self::Reqwest(e) => Some(e), 114 | Self::Serialization(e) => Some(e), 115 | Self::MissingHeader(_) => None, 116 | Self::InvalidHeader(_, e) => Some(e), 117 | Self::InvalidAccount(_) => None, 118 | Self::MissingIdentifiers => None, 119 | Self::MissingSolver => None, 120 | Self::SolverFailure(e) => Some(e.as_ref()), 121 | Self::InvalidSolverConfiguration { error, .. } => Some(error.as_ref()), 122 | Self::MaxAttemptsExceeded => None, 123 | Self::ChallengeFailed(_, _) => None, 124 | Self::OpenSSL(e) => Some(e), 125 | Self::OrderFailed(_) => None, 126 | Self::CannotDownloadCertificate => None, 127 | Self::UnsupportedKeyType => None, 128 | Self::UnsupportedECDSACurve => None, 129 | Self::InvalidExternalAccountBindingHMAC(e) => Some(e), 130 | } 131 | } 132 | } 133 | 134 | impl From for Error { 135 | fn from(err: reqwest::Error) -> Self { 136 | Self::Reqwest(err) 137 | } 138 | } 139 | 140 | impl From for Error { 141 | fn from(err: serde_json::Error) -> Self { 142 | Self::Serialization(err) 143 | } 144 | } 145 | 146 | impl From for Error { 147 | fn from(err: openssl::error::ErrorStack) -> Self { 148 | Self::OpenSSL(err) 149 | } 150 | } 151 | 152 | impl From for Error { 153 | fn from(err: base64::DecodeError) -> Self { 154 | Self::InvalidExternalAccountBindingHMAC(err) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(docsrs, feature(doc_cfg))] 2 | #![warn(missing_docs)] 3 | 4 | //! # lers 5 | //! 6 | //! An async, user-friendly Let's Encrypt/ACMEv2 library. Inspired by 7 | //! [acme2](https://github.com/lucacasonato/acme2), [acme-micro](https://github.com/kpcyrd/acme-micro), 8 | //! and [lego](https://github.com/go-acme/lego). 9 | //! 10 | //! Features: 11 | //! 12 | //! - ACME v2 support (according to [RFC 8555](https://www.rfc-editor.org/rfc/rfc8555.html)) 13 | //! - Account creation, certificate issuance, certificate renewal, and certificate revocation 14 | //! - Robust implementation of the [HTTP-01](solver::Http01Solver), [DNS-01](solver::dns), 15 | //! [TLS-ALPN-01](solver::TlsAlpn01Solver) challenges 16 | //! - Custom challenge solvers via [`Solver`] 17 | //! - [External account bindings](https://www.rfc-editor.org/rfc/rfc8555.html#page-38) support 18 | //! 19 | //! ## Example 20 | //! 21 | //! How to obtain a certificate for `example.com` from Let's Encrypt Staging using the 22 | //! [`solver::Http01Solver`]. 23 | //! 24 | //! ```no_run 25 | #![doc = include_str!("../examples/http-01.rs")] 26 | //! ``` 27 | //! 28 | //! See the [examples/](https://github.com/akrantz01/lers/tree/main/examples) folder for more examples. 29 | //! 30 | 31 | mod account; 32 | mod api; 33 | mod certificate; 34 | mod directory; 35 | mod error; 36 | mod order; 37 | pub mod solver; 38 | #[cfg(test)] 39 | mod test; 40 | 41 | pub(crate) const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); 42 | 43 | pub use account::{Account, AccountBuilder}; 44 | pub use api::responses; 45 | pub use certificate::{Certificate, CertificateBuilder, Format}; 46 | pub use directory::{ 47 | Directory, DirectoryBuilder, LETS_ENCRYPT_PRODUCTION_URL, LETS_ENCRYPT_STAGING_URL, 48 | }; 49 | pub use error::Error; 50 | pub use solver::Solver; 51 | -------------------------------------------------------------------------------- /src/order.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | api::key_authorization, 3 | error::Result, 4 | responses::{self, AuthorizationStatus, ChallengeStatus, Identifier, OrderStatus}, 5 | Account, Error, 6 | }; 7 | use base64::{engine::general_purpose::URL_SAFE_NO_PAD as BASE64, Engine}; 8 | use chrono::{DateTime, Utc}; 9 | use futures::future; 10 | use openssl::{ 11 | hash::{hash, MessageDigest}, 12 | nid::Nid, 13 | pkey::{PKey, Private}, 14 | stack::Stack, 15 | x509::{extension::SubjectAlternativeName, X509Name, X509Req, X509}, 16 | }; 17 | use std::{cmp::Ordering, time::Duration}; 18 | use tracing::{debug, field, instrument, Level, Span}; 19 | 20 | const DEFAULT_INTERVAL: Duration = Duration::from_secs(2); 21 | const DEFAULT_ATTEMPTS: usize = 10; 22 | 23 | /// A convenience wrapper around an order resource 24 | #[derive(Debug)] 25 | pub(crate) struct Order<'a> { 26 | account: &'a Account, 27 | url: String, 28 | inner: responses::Order, 29 | } 30 | 31 | impl<'a> Order<'a> { 32 | /// Create a new order for a certificate 33 | #[instrument( 34 | level = Level::DEBUG, 35 | name = "Order::create", 36 | err, 37 | skip_all, 38 | fields(order.id, order.status), 39 | )] 40 | pub async fn create( 41 | account: &'a Account, 42 | identifiers: Vec, 43 | not_before: Option>, 44 | not_after: Option>, 45 | ) -> Result> { 46 | let (url, inner) = account 47 | .api 48 | .new_order( 49 | identifiers, 50 | not_before, 51 | not_after, 52 | &account.private_key, 53 | &account.id, 54 | ) 55 | .await?; 56 | 57 | Span::current().record("order.id", &url); 58 | Span::current().record("order.status", field::debug(&inner.status)); 59 | 60 | Ok(Order { 61 | account, 62 | url, 63 | inner, 64 | }) 65 | } 66 | 67 | /// Get all the authorizations for the order 68 | #[instrument( 69 | level = Level::DEBUG, 70 | name = "Order::authorizations", 71 | err, 72 | skip_all, 73 | fields(self.id = self.url, self.status = ?self.inner.status), 74 | )] 75 | pub async fn authorizations(&self) -> Result> { 76 | future::try_join_all( 77 | self.inner 78 | .authorizations 79 | .iter() 80 | .map(|url| Authorization::fetch(self.account, url)), 81 | ) 82 | .await 83 | } 84 | 85 | /// Get the ID of the order 86 | pub fn id(&self) -> &str { 87 | &self.url 88 | } 89 | 90 | /// Generate a base64 URL-encoded CSR for the certificate private key and identifiers 91 | fn generate_csr(&self, key: &PKey) -> Result { 92 | let domains = self 93 | .inner 94 | .identifiers 95 | .iter() 96 | .map(|Identifier::Dns(domain)| domain.to_owned()) 97 | .collect::>(); 98 | 99 | let name = { 100 | let mut name = X509Name::builder()?; 101 | name.append_entry_by_nid(Nid::COMMONNAME, &domains[0])?; 102 | name.build() 103 | }; 104 | 105 | let mut builder = X509Req::builder()?; 106 | builder.set_subject_name(&name)?; 107 | 108 | let mut extensions = Stack::new()?; 109 | extensions.push({ 110 | let mut san = SubjectAlternativeName::new(); 111 | for domain in &domains { 112 | san.dns(domain); 113 | } 114 | san.build(&builder.x509v3_context(None))? 115 | })?; 116 | builder.add_extensions(&extensions)?; 117 | 118 | builder.set_pubkey(key)?; 119 | builder.sign(key, MessageDigest::sha256())?; 120 | 121 | let csr = builder.build(); 122 | let der = csr.to_der()?; 123 | 124 | Ok(BASE64.encode(der)) 125 | } 126 | 127 | /// Finalize an order with the provided CSR 128 | #[instrument( 129 | level = Level::DEBUG, 130 | name = "Order::finalize", 131 | err, 132 | skip_all, 133 | fields(self.id = self.url, self.status = ?self.inner.status, updated.status), 134 | )] 135 | pub async fn finalize(&mut self, private_key: &PKey) -> Result<()> { 136 | let csr = self.generate_csr(private_key)?; 137 | 138 | let order = self 139 | .account 140 | .api 141 | .finalize_order( 142 | &self.inner.finalize, 143 | csr, 144 | &self.account.private_key, 145 | &self.account.id, 146 | ) 147 | .await?; 148 | 149 | Span::current().record("updated.status", field::debug(&order.status)); 150 | 151 | self.inner = order; 152 | Ok(()) 153 | } 154 | 155 | /// Download the certificate from the order 156 | #[instrument( 157 | level = Level::DEBUG, 158 | name = "Order::download", 159 | err, 160 | skip_all, 161 | fields( 162 | self.id = self.url, 163 | self.status = ?self.inner.status, 164 | self.has_certificate = ?self.inner.certificate.is_some(), 165 | ), 166 | )] 167 | pub async fn download(self) -> Result> { 168 | if self.inner.status != OrderStatus::Valid { 169 | return Err(match self.inner.error { 170 | Some(error) => Error::OrderFailed(error), 171 | None => Error::CannotDownloadCertificate, 172 | }); 173 | } 174 | 175 | let certificate_url = self 176 | .inner 177 | .certificate 178 | .ok_or(Error::CannotDownloadCertificate)?; 179 | 180 | let certificate = self 181 | .account 182 | .api 183 | .download_certificate( 184 | &certificate_url, 185 | &self.account.private_key, 186 | &self.account.id, 187 | ) 188 | .await?; 189 | 190 | let stack = X509::stack_from_pem(&certificate.into_bytes())?; 191 | Ok(stack) 192 | } 193 | 194 | /// Wait for the order to transition into [`OrderStatus::Ready`] 195 | #[instrument( 196 | level = Level::DEBUG, 197 | name = "Order::wait_ready", 198 | err, 199 | skip_all, 200 | fields(self.id = self.url, self.status = ?self.inner.status, updated.status) 201 | )] 202 | pub async fn wait_ready(&mut self) -> Result<()> { 203 | let order = self 204 | .account 205 | .api 206 | .wait_until( 207 | |url, private_key, account_id| async { 208 | self.account 209 | .api 210 | .fetch_order(url, private_key, account_id) 211 | .await 212 | }, 213 | |o| o.status == OrderStatus::Ready, 214 | &self.url, 215 | &self.account.private_key, 216 | &self.account.id, 217 | DEFAULT_INTERVAL, 218 | DEFAULT_ATTEMPTS, 219 | ) 220 | .await?; 221 | 222 | Span::current().record("updated.status", field::debug(&order.status)); 223 | self.inner = order; 224 | 225 | Ok(()) 226 | } 227 | 228 | /// Wait for the order to transition into [`OrderStatus::Valid`] or [`OrderStatus::Invalid`] 229 | #[instrument( 230 | level = Level::DEBUG, 231 | name = "Order::wait_done", 232 | err, 233 | skip_all, 234 | fields(self.id = self.url, self.status = ?self.inner.status, updated.status) 235 | )] 236 | pub async fn wait_done(&mut self) -> Result<()> { 237 | let order = self 238 | .account 239 | .api 240 | .wait_until( 241 | |url, private_key, account_id| async { 242 | self.account 243 | .api 244 | .fetch_order(url, private_key, account_id) 245 | .await 246 | }, 247 | |o| o.status == OrderStatus::Valid || o.status == OrderStatus::Invalid, 248 | &self.url, 249 | &self.account.private_key, 250 | &self.account.id, 251 | DEFAULT_INTERVAL, 252 | DEFAULT_ATTEMPTS, 253 | ) 254 | .await?; 255 | 256 | Span::current().record("updated.status", field::debug(&order.status)); 257 | self.inner = order; 258 | 259 | Ok(()) 260 | } 261 | } 262 | 263 | /// A convenience wrapper around an authorization 264 | #[derive(Debug)] 265 | pub(crate) struct Authorization<'a> { 266 | account: &'a Account, 267 | url: String, 268 | inner: responses::Authorization, 269 | } 270 | 271 | impl<'a> Authorization<'a> { 272 | /// Fetch an authorization from it's URL 273 | #[instrument( 274 | level = Level::DEBUG, 275 | name = "Authorization::fetch", 276 | err, skip(account), 277 | fields( 278 | %account.id, 279 | authorization.status, 280 | authorization.challenges.len, 281 | authorization.identifier, 282 | authorization.wildcard, 283 | ), 284 | )] 285 | async fn fetch(account: &'a Account, url: &str) -> Result> { 286 | let mut authorization = account 287 | .api 288 | .fetch_authorization(url, &account.private_key, &account.id) 289 | .await?; 290 | 291 | authorization.challenges.sort_by(challenge_type); 292 | 293 | let span = Span::current(); 294 | span.record("authorization.status", field::debug(&authorization.status)); 295 | span.record( 296 | "authorization.challenges.len", 297 | authorization.challenges.len(), 298 | ); 299 | span.record( 300 | "authorization.identifier", 301 | field::debug(&authorization.identifier), 302 | ); 303 | span.record( 304 | "authorization.wildcard", 305 | authorization.wildcard.unwrap_or_default(), 306 | ); 307 | 308 | Ok(Authorization { 309 | account, 310 | url: url.to_owned(), 311 | inner: authorization, 312 | }) 313 | } 314 | 315 | /// Attempt to solve one of the authorization's challenges 316 | #[instrument( 317 | level = Level::DEBUG, 318 | name = "Authorization::solve", 319 | err, 320 | skip_all, 321 | fields( 322 | self.id = %self.url, 323 | self.status = ?self.inner.status, 324 | self.identifier = ?self.inner.identifier, 325 | self.wildcard = ?self.inner.wildcard.unwrap_or_default(), 326 | ), 327 | )] 328 | pub async fn solve(&self) -> Result<()> { 329 | let api = &self.account.api; 330 | let private_key = &self.account.private_key; 331 | let account_id = &self.account.id; 332 | 333 | let Identifier::Dns(domain) = &self.inner.identifier; 334 | 335 | for challenge in &self.inner.challenges { 336 | debug!("finding solver for {:?}", challenge.type_); 337 | let Some(solver) = api.solver_for(challenge) else { continue }; 338 | debug!("attempting to solve {:?} challenge", challenge.type_); 339 | 340 | let authorization = format_key_authorization(challenge, private_key)?; 341 | solver 342 | .present(domain.to_owned(), challenge.token.to_owned(), authorization) 343 | .await 344 | .map_err(|e| Error::SolverFailure(e))?; 345 | 346 | api.validate_challenge(&challenge.url, private_key, account_id) 347 | .await?; 348 | 349 | self.wait_for_challenge(&challenge.url, solver.interval(), solver.attempts()) 350 | .await?; 351 | 352 | solver 353 | .cleanup(&challenge.token) 354 | .await 355 | .map_err(|e| Error::SolverFailure(e))?; 356 | 357 | // Wait for the authorization to complete 358 | let status = self.wait_done().await?; 359 | if status != AuthorizationStatus::Valid { 360 | return Err(Error::ChallengeFailed( 361 | self.inner.identifier.clone(), 362 | challenge.type_, 363 | )); 364 | } 365 | 366 | return Ok(()); 367 | } 368 | 369 | Err(Error::MissingSolver) 370 | } 371 | 372 | /// Wait for the challenge to transition into either [`ChallengeStatus::Valid`] 373 | /// or [`ChallengeStatus::Invalid`]. 374 | #[instrument( 375 | level = Level::DEBUG, 376 | name = "Authorization::wait_for_challenge", 377 | err, 378 | skip(self), 379 | fields( 380 | self.id = %self.url, 381 | self.status = ?self.inner.status, 382 | self.identifier = ?self.inner.identifier, 383 | self.wildcard = ?self.inner.wildcard.unwrap_or_default(), 384 | challenge.status, challenge.r#type, 385 | ) 386 | )] 387 | async fn wait_for_challenge( 388 | &self, 389 | url: &str, 390 | interval: Duration, 391 | max_attempts: usize, 392 | ) -> Result { 393 | let challenge = self 394 | .account 395 | .api 396 | .wait_until( 397 | |url, private_key, account_id| async { 398 | self.account 399 | .api 400 | .fetch_challenge(url, private_key, account_id) 401 | .await 402 | }, 403 | |c| c.status == ChallengeStatus::Valid || c.status == ChallengeStatus::Invalid, 404 | url, 405 | &self.account.private_key, 406 | &self.account.id, 407 | interval, 408 | max_attempts, 409 | ) 410 | .await?; 411 | 412 | Span::current().record("challenge.status", field::debug(&challenge.status)); 413 | Span::current().record("challenge.type", field::debug(&challenge.type_)); 414 | 415 | Ok(challenge.status) 416 | } 417 | 418 | /// Wait for the authorization to transition into either [`AuthorizationStatus::Valid`] or 419 | /// [`AuthorizationStatus::Invalid`] 420 | #[instrument( 421 | level = Level::DEBUG, 422 | name = "Authorization::wait_done", 423 | err, 424 | skip_all, 425 | fields( 426 | self.id = %self.url, 427 | self.status = ?self.inner.status, 428 | self.identifier = ?self.inner.identifier, 429 | self.wildcard = ?self.inner.wildcard.unwrap_or_default(), 430 | updated.status, 431 | ), 432 | )] 433 | async fn wait_done(&self) -> Result { 434 | let authorization = self 435 | .account 436 | .api 437 | .wait_until( 438 | |url, private_key, account_id| async { 439 | self.account 440 | .api 441 | .fetch_authorization(url, private_key, account_id) 442 | .await 443 | }, 444 | |a| { 445 | a.status == AuthorizationStatus::Valid 446 | || a.status == AuthorizationStatus::Invalid 447 | }, 448 | &self.url, 449 | &self.account.private_key, 450 | &self.account.id, 451 | DEFAULT_INTERVAL, 452 | DEFAULT_ATTEMPTS, 453 | ) 454 | .await?; 455 | 456 | Span::current().record("updated.status", field::debug(&authorization.status)); 457 | 458 | Ok(authorization.status) 459 | } 460 | } 461 | 462 | /// Generate the key authorization and ensure it is in the correct format for the challenge. 463 | fn format_key_authorization( 464 | challenge: &responses::Challenge, 465 | private_key: &PKey, 466 | ) -> Result { 467 | use responses::ChallengeType; 468 | 469 | let authorization = key_authorization(&challenge.token, private_key)?; 470 | 471 | Ok(match challenge.type_ { 472 | ChallengeType::Dns01 => { 473 | let digest = hash(MessageDigest::sha256(), authorization.as_bytes())?; 474 | BASE64.encode(digest) 475 | } 476 | _ => authorization, 477 | }) 478 | } 479 | 480 | fn challenge_type(a: &responses::Challenge, b: &responses::Challenge) -> Ordering { 481 | use responses::ChallengeType::*; 482 | 483 | match (a.type_, b.type_) { 484 | (Dns01, Dns01) | (Http01, Http01) | (TlsAlpn01, TlsAlpn01) | (Unknown, Unknown) => { 485 | Ordering::Equal 486 | } 487 | // prefer DNS-01 over everything 488 | (Dns01, _) => Ordering::Greater, 489 | // prefer HTTP-01 over everything except for DNS 490 | (Http01, TlsAlpn01) | (Http01, Unknown) => Ordering::Greater, 491 | (Http01, Dns01) => Ordering::Less, 492 | // prefer TLS-ALPN-01 last 493 | (TlsAlpn01, Unknown) => Ordering::Greater, 494 | (TlsAlpn01, Http01) | (TlsAlpn01, Dns01) => Ordering::Less, 495 | // never prefer unknown, we can't handle it 496 | (Unknown, _) => Ordering::Less, 497 | } 498 | } 499 | -------------------------------------------------------------------------------- /src/solver.rs: -------------------------------------------------------------------------------- 1 | //! ACME challenge solvers 2 | //! 3 | //! There are currently two supported challenge solver types: 4 | //! [HTTP-01](Http01Solver) and [DNS-01](dns). 5 | //! 6 | //! ## HTTP-01 7 | //! The HTTP-01 solver works by making a file available containing a random token and fingerprint 8 | //! of your account key, proving control over the website to the CA. This is the most commonly used 9 | //! ACME challenge due to its ease of integration with popular web server platforms. 10 | //! 11 | //! However, it can only work over port `80` and the challenge file must be accessible on all 12 | //! servers resolved by the domain. 13 | //! 14 | //! ## DNS-01 15 | //! The DNS-01 solver works by creating a TXT record for your domain containing a random token and 16 | //! fingerprint of your account key, similar to the HTTP-01 challenge. This is particularly useful 17 | //! when you have more than one web server or port `80` is blocked. Furthermore, it is the only way 18 | //! to issue [wildcard certificates](https://en.wikipedia.org/wiki/Wildcard_certificate). 19 | //! 20 | //! However, you will need to deal with the potential security threat of keeping DNS API credentials 21 | //! on your server. 22 | //! 23 | //! ## TLS-ALPN-01 24 | //! This challenge was developed after TLS-SNI-01 became deprecated, and is being developed as a 25 | //! separate standard. Like TLS-SNI-01, it is performed via TLS on port 443. However, it uses a 26 | //! custom ALPN protocol to ensure that only servers that are aware of this challenge type will 27 | //! respond to validation requests. This also allows validation requests for this challenge type 28 | //! to use an SNI field that matches the domain name being validated, making it more secure. 29 | //! 30 | //! However, it can only work over port `443` and must be available on all servers resolved by the 31 | //! domain. 32 | 33 | use crate::responses::ChallengeType; 34 | use std::{ 35 | collections::HashMap, 36 | fmt::{Debug, Formatter}, 37 | time::Duration, 38 | }; 39 | 40 | mod common; 41 | #[cfg(feature = "dns-01")] 42 | #[cfg_attr(docsrs, doc(cfg(feature = "dns-01")))] 43 | pub mod dns; 44 | #[cfg(feature = "http-01")] 45 | mod http; 46 | #[cfg(feature = "tls-alpn-01")] 47 | mod tls_alpn; 48 | 49 | #[cfg(any(feature = "http-01", feature = "tls-alpn-01"))] 50 | #[cfg_attr(docsrs, doc(cfg(any(feature = "http-01", feature = "tls-alpn-01"))))] 51 | pub use common::SolverHandle; 52 | #[cfg(feature = "http-01")] 53 | #[cfg_attr(docsrs, doc(cfg(feature = "http-01")))] 54 | pub use http::Http01Solver; 55 | #[cfg(feature = "tls-alpn-01")] 56 | #[cfg_attr(docsrs, doc(cfg(feature = "tls-alpn-01")))] 57 | pub use tls_alpn::TlsAlpn01Solver; 58 | 59 | /// Enables implementing a custom challenge solver. 60 | /// 61 | /// Solvers must be able to handle multiple challenges at once as authorizations are solved in 62 | /// parallel. 63 | #[async_trait::async_trait] 64 | pub trait Solver { 65 | /// Makes the solution to a challenge available to be solved. 66 | async fn present( 67 | &self, 68 | domain: String, 69 | token: String, 70 | key_authorization: String, 71 | ) -> Result<(), Box>; 72 | 73 | /// Used to clean-up the challenge if [`Solver::present`] ends in a non-error state. 74 | async fn cleanup( 75 | &self, 76 | token: &str, 77 | ) -> Result<(), Box>; 78 | 79 | /// How many attempts to make before timing out. Defaults to 30 tries. 80 | fn attempts(&self) -> usize { 81 | 30 82 | } 83 | 84 | /// How long to wait between successive checks. Defaults to 2 seconds. 85 | fn interval(&self) -> Duration { 86 | Duration::from_secs(2) 87 | } 88 | } 89 | 90 | /// Used by [`Solver`]s to convert an arbitrary error to a boxed trait object. 91 | pub fn boxed_err(e: E) -> Box 92 | where 93 | E: std::error::Error + Send + Sync + 'static, 94 | { 95 | Box::new(e) 96 | } 97 | 98 | /// Handle solving a given challenge using the configured solver(s) 99 | #[derive(Default)] 100 | pub(crate) struct SolverManager { 101 | solvers: HashMap>, 102 | } 103 | 104 | impl SolverManager { 105 | /// Set the DNS-01 solver 106 | pub fn set_dns01_solver(&mut self, solver: Box) { 107 | self.solvers.insert(ChallengeType::Dns01, solver); 108 | } 109 | 110 | /// Set the HTTP-01 solver 111 | pub fn set_http01_solver(&mut self, solver: Box) { 112 | self.solvers.insert(ChallengeType::Http01, solver); 113 | } 114 | 115 | /// Set the TLS-ALPN-01 solver 116 | pub fn set_tls_alpn01_solver(&mut self, solver: Box) { 117 | self.solvers.insert(ChallengeType::TlsAlpn01, solver); 118 | } 119 | 120 | /// Get the solver for the challenge type 121 | pub fn get(&self, type_: ChallengeType) -> Option<&dyn Solver> { 122 | self.solvers.get(&type_).map(AsRef::as_ref) 123 | } 124 | } 125 | 126 | impl Debug for SolverManager { 127 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 128 | let solvers = self.solvers.keys().collect::>(); 129 | 130 | // `Solver` doesn't implement debug, so we'll display what solvers are registered instead 131 | f.debug_struct("SolverManager") 132 | .field("registered", &solvers) 133 | .finish() 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/solver/common.rs: -------------------------------------------------------------------------------- 1 | use parking_lot::RwLock; 2 | use std::{collections::HashMap, sync::Arc}; 3 | use tokio::{sync::oneshot, task::JoinHandle}; 4 | 5 | pub(crate) type Challenges = Arc>>; 6 | 7 | /// A handle to stop the solver server once started. 8 | pub struct SolverHandle { 9 | pub(crate) handle: JoinHandle>, 10 | pub(crate) tx: oneshot::Sender<()>, 11 | } 12 | 13 | impl SolverHandle { 14 | /// Stop the server 15 | pub async fn stop(self) -> Result<(), E> { 16 | let _ = self.tx.send(()); 17 | self.handle.await.unwrap() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/solver/dns/cloudflare.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | error::{Error, Result}, 3 | solver::{boxed_err, Solver}, 4 | }; 5 | use parking_lot::Mutex; 6 | use reqwest::{ 7 | header::{self, HeaderMap, HeaderValue, IntoHeaderName}, 8 | Client, StatusCode, 9 | }; 10 | use serde::{Deserialize, Serialize}; 11 | use std::{ 12 | collections::HashMap, 13 | env, 14 | fmt::{Display, Formatter}, 15 | sync::Arc, 16 | time::Duration, 17 | }; 18 | use tracing::{instrument, Level}; 19 | 20 | /// Errors that could be generated by the [`CloudflareDns01Solver`] 21 | #[derive(Debug)] 22 | pub enum CloudflareError { 23 | /// Could not find one of the required environment variables 24 | /// (see [`CloudflareDns01Solver::from_env`]) 25 | MissingEnvironmentVariables, 26 | /// Failed to find the zone ID for the provided zone 27 | UnknownZone(String), 28 | } 29 | 30 | impl std::error::Error for CloudflareError {} 31 | impl Display for CloudflareError { 32 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 33 | match self { 34 | Self::MissingEnvironmentVariables => write!( 35 | f, 36 | "could not find one of the required environment variables" 37 | ), 38 | Self::UnknownZone(zone) => write!(f, "unknown zone {zone:?}"), 39 | } 40 | } 41 | } 42 | 43 | /// Uses the Cloudflare API to solve DNS-01 challenges. 44 | #[derive(Clone, Debug)] 45 | pub struct CloudflareDns01Solver { 46 | client: Client, 47 | // mapping from token to a zone id and record id pair 48 | tokens_to_records: Arc>>, 49 | } 50 | 51 | impl CloudflareDns01Solver { 52 | /// Creates a new [`CloudflareDns01Builder`] by pulling credentials from the environment. 53 | /// 54 | /// Credentials are pulled from the following environment variables, listed in order of 55 | /// precedence if multiple are defined: 56 | /// 1. `CLOUDFLARE_API_TOKEN` 57 | /// 2. `CLOUDFLARE_EMAIL` and `CLOUDFLARE_API_KEY` 58 | pub fn from_env() -> Result { 59 | if let Ok(token) = env::var("CLOUDFLARE_API_TOKEN") { 60 | Ok(Self::new_with_token(token)) 61 | } else if let (Ok(email), Ok(key)) = 62 | (env::var("CLOUDFLARE_EMAIL"), env::var("CLOUDFLARE_API_KEY")) 63 | { 64 | Ok(Self::new_with_auth_key(email, key)) 65 | } else { 66 | Err(Error::InvalidSolverConfiguration { 67 | name: "cloudflare dns-01", 68 | error: Box::new(CloudflareError::MissingEnvironmentVariables), 69 | }) 70 | } 71 | } 72 | 73 | /// Creates a new [`CloudflareDns01Builder`] using an authentication token. The token must have 74 | /// `Zone:Read` and `DNS:Edit` permissions. 75 | pub fn new_with_token>(token: S) -> CloudflareDns01Builder { 76 | let mut headers = HeaderMap::with_capacity(1); 77 | headers.insert( 78 | header::AUTHORIZATION, 79 | HeaderValue::try_from(format!("Bearer {}", token.as_ref())).unwrap(), 80 | ); 81 | 82 | CloudflareDns01Builder { headers } 83 | } 84 | 85 | /// Creates a new [`CloudflareDns01Builder`] using the Cloudflare global credentials. 86 | /// 87 | /// This should be avoided if at all possible, use [`CloudflareDns01Solver::new_with_token`] 88 | /// instead. 89 | pub fn new_with_auth_key(email: E, key: K) -> CloudflareDns01Builder 90 | where 91 | E: AsRef, 92 | K: AsRef, 93 | { 94 | let mut headers = HeaderMap::with_capacity(2); 95 | headers.insert( 96 | "X-Auth-Email", 97 | HeaderValue::from_str(email.as_ref()).unwrap(), 98 | ); 99 | headers.insert("X-Auth-Key", HeaderValue::from_str(key.as_ref()).unwrap()); 100 | 101 | CloudflareDns01Builder { headers } 102 | } 103 | 104 | /// Find a zone's ID by its name 105 | #[instrument( 106 | level = Level::DEBUG, 107 | name = "CloudflareDns01Solver::zone_id_by_name", 108 | err, 109 | skip(self), 110 | )] 111 | async fn zone_id_by_name(&self, name: &str) -> reqwest::Result> { 112 | let response: Response> = self 113 | .client 114 | .get("https://api.cloudflare.com/client/v4/zones") 115 | .query(&ListZoneOptions { name }) 116 | .send() 117 | .await? 118 | .error_for_status()? 119 | .json() 120 | .await?; 121 | 122 | debug_assert!(response.success); 123 | 124 | Ok(response.result.into_iter().next().map(|r| r.id)) 125 | } 126 | 127 | /// Set the TXT record with the provided content, returns the record's ID 128 | #[instrument( 129 | level = Level::DEBUG, 130 | name = "CloudflareDns01Solver::set_txt_record", 131 | err, 132 | skip(self, content), 133 | )] 134 | async fn set_txt_record( 135 | &self, 136 | zone_id: &str, 137 | name: &str, 138 | content: &str, 139 | ) -> reqwest::Result { 140 | let response: Response = self 141 | .client 142 | .post(format!( 143 | "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records" 144 | )) 145 | .json(&CreateRecordBody { 146 | type_: "TXT", 147 | ttl: 1, 148 | content, 149 | name, 150 | }) 151 | .send() 152 | .await? 153 | .error_for_status()? 154 | .json() 155 | .await?; 156 | 157 | Ok(response.result.id) 158 | } 159 | 160 | /// Remove a TXT record by ID 161 | #[instrument( 162 | level = Level::DEBUG, 163 | name = "CloudflareDns01Solver::remove_record", 164 | err, 165 | skip(self), 166 | )] 167 | async fn remove_record(&self, zone_id: &str, record_id: &str) -> reqwest::Result<()> { 168 | let response = self 169 | .client 170 | .delete(format!( 171 | "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}" 172 | )) 173 | .send() 174 | .await?; 175 | 176 | if response.status() != StatusCode::NOT_FOUND { 177 | response.error_for_status()?; 178 | } 179 | 180 | Ok(()) 181 | } 182 | } 183 | 184 | #[async_trait::async_trait] 185 | impl Solver for CloudflareDns01Solver { 186 | #[instrument( 187 | level = Level::INFO, 188 | name = "Solver::present", 189 | err, 190 | skip_all, 191 | fields(token, domain, solver = std::any::type_name::()), 192 | )] 193 | async fn present( 194 | &self, 195 | domain: String, 196 | token: String, 197 | key_authorization: String, 198 | ) -> Result<(), Box> { 199 | let zone = super::find_zone_by_fqdn(&domain).await.map_err(boxed_err)?; 200 | let zone_id = self 201 | .zone_id_by_name(&zone) 202 | .await 203 | .map_err(boxed_err)? 204 | .ok_or_else(|| boxed_err(CloudflareError::UnknownZone(zone)))?; 205 | 206 | let id = self 207 | .set_txt_record( 208 | &zone_id, 209 | &format!("_acme-challenge.{domain}"), 210 | &key_authorization, 211 | ) 212 | .await 213 | .map_err(boxed_err)?; 214 | 215 | let mut tokens_to_records = self.tokens_to_records.lock(); 216 | tokens_to_records.insert(token, (zone_id, id)); 217 | 218 | Ok(()) 219 | } 220 | 221 | #[instrument( 222 | level = Level::INFO, 223 | name = "Solver::cleanup", 224 | err, 225 | skip_all, 226 | fields(token, solver = std::any::type_name::()), 227 | )] 228 | async fn cleanup( 229 | &self, 230 | token: &str, 231 | ) -> Result<(), Box> { 232 | let (zone_id, record_id) = match { 233 | let mut tokens_to_records = self.tokens_to_records.lock(); 234 | tokens_to_records.remove(token) 235 | } { 236 | Some(v) => v, 237 | // already cleaned up, nothing to do 238 | None => return Ok(()), 239 | }; 240 | 241 | self.remove_record(&zone_id, &record_id) 242 | .await 243 | .map_err(boxed_err)?; 244 | 245 | Ok(()) 246 | } 247 | 248 | fn attempts(&self) -> usize { 249 | 60 250 | } 251 | 252 | fn interval(&self) -> Duration { 253 | Duration::from_secs(2) 254 | } 255 | } 256 | 257 | /// Used to configured a [`CloudflareDns01Solver`] 258 | #[derive(Debug)] 259 | pub struct CloudflareDns01Builder { 260 | headers: HeaderMap, 261 | } 262 | 263 | impl CloudflareDns01Builder { 264 | /// Adds a default header to the client 265 | pub fn add_header(mut self, key: K, value: V) -> Self 266 | where 267 | K: IntoHeaderName, 268 | V: Into, 269 | { 270 | self.headers.insert(key, value.into()); 271 | self 272 | } 273 | 274 | /// Build the DNS-01 solver 275 | pub fn build(self) -> Result { 276 | let client = Client::builder() 277 | .user_agent(crate::USER_AGENT) 278 | .default_headers(self.headers) 279 | .build()?; 280 | 281 | Ok(CloudflareDns01Solver { 282 | client, 283 | tokens_to_records: Arc::default(), 284 | }) 285 | } 286 | } 287 | 288 | #[derive(Debug, Serialize)] 289 | struct ListZoneOptions<'n> { 290 | name: &'n str, 291 | } 292 | 293 | #[derive(Debug, Serialize)] 294 | struct CreateRecordBody<'n> { 295 | content: &'n str, 296 | name: &'n str, 297 | #[serde(rename = "type")] 298 | type_: &'static str, 299 | ttl: usize, 300 | } 301 | 302 | #[derive(Debug, Deserialize)] 303 | struct Response { 304 | success: bool, 305 | result: T, 306 | } 307 | 308 | #[derive(Debug, Deserialize)] 309 | struct Zone { 310 | id: String, 311 | } 312 | 313 | #[derive(Debug, Deserialize)] 314 | struct Record { 315 | id: String, 316 | } 317 | 318 | #[cfg(all(test, feature = "integration"))] 319 | mod tests { 320 | use super::CloudflareDns01Solver; 321 | use crate::Solver; 322 | use std::{env, time::Duration}; 323 | use test_log::test; 324 | use tokio::time; 325 | 326 | const ZONE_NAME_ENV: &str = "DNS01_CF_ZONE"; 327 | const ZONE_ID_ENV: &str = "DNS01_CF_ZONE_ID"; 328 | 329 | fn solver() -> CloudflareDns01Solver { 330 | CloudflareDns01Solver::from_env().unwrap().build().unwrap() 331 | } 332 | 333 | #[test(tokio::test)] 334 | async fn zone_id_by_name_valid() -> reqwest::Result<()> { 335 | let test_zone = env::var(ZONE_NAME_ENV).unwrap(); 336 | let expected_id = env::var(ZONE_ID_ENV).ok(); 337 | 338 | let solver = solver(); 339 | let id = solver.zone_id_by_name(&test_zone).await?; 340 | assert_eq!(id, expected_id); 341 | 342 | Ok(()) 343 | } 344 | 345 | #[test(tokio::test)] 346 | async fn zone_id_by_name_invalid() -> reqwest::Result<()> { 347 | let solver = solver(); 348 | let id = solver.zone_id_by_name("lego.zz").await?; 349 | assert_eq!(id, None); 350 | 351 | Ok(()) 352 | } 353 | 354 | #[test(tokio::test)] 355 | async fn txt_record() -> reqwest::Result<()> { 356 | let zone = env::var(ZONE_NAME_ENV).unwrap(); 357 | let zone_id = env::var(ZONE_ID_ENV).unwrap(); 358 | 359 | let solver = solver(); 360 | 361 | let id = solver 362 | .set_txt_record(&zone_id, &format!("cf.lers.{zone}"), "lers-testing") 363 | .await?; 364 | 365 | time::sleep(Duration::from_secs(1)).await; 366 | 367 | solver.remove_record(&zone_id, &id).await?; 368 | 369 | Ok(()) 370 | } 371 | 372 | #[test(tokio::test)] 373 | async fn remove_non_existent_txt_record() { 374 | let zone_id = env::var(ZONE_ID_ENV).unwrap(); 375 | 376 | let solver = solver(); 377 | let result = solver 378 | .remove_record(&zone_id, "2ca364bf488e500ab98aa943f2d8973a") 379 | .await; 380 | assert!(result.is_ok()); 381 | } 382 | 383 | #[test(tokio::test)] 384 | async fn present_and_cleanup() { 385 | let zone = env::var(ZONE_NAME_ENV).unwrap(); 386 | let solver = solver(); 387 | 388 | solver 389 | .present( 390 | format!("cf.lers.{zone}"), 391 | String::from("present-and-cleanup-test"), 392 | String::from("present-and-cleanup-challenge"), 393 | ) 394 | .await 395 | .unwrap(); 396 | 397 | { 398 | let mapping = solver.tokens_to_records.lock(); 399 | assert_eq!(mapping.len(), 1); 400 | } 401 | 402 | time::sleep(Duration::from_secs(1)).await; 403 | 404 | solver.cleanup("present-and-cleanup-test").await.unwrap(); 405 | 406 | { 407 | let mapping = solver.tokens_to_records.lock(); 408 | assert_eq!(mapping.len(), 0); 409 | } 410 | } 411 | 412 | #[test(tokio::test)] 413 | async fn cleanup_empty() { 414 | let solver = solver(); 415 | solver.cleanup("this-does-not-exist").await.unwrap(); 416 | } 417 | 418 | #[test(tokio::test)] 419 | async fn cleanup_out_of_sync() { 420 | let solver = solver(); 421 | { 422 | let mut mapping = solver.tokens_to_records.lock(); 423 | mapping.insert( 424 | String::from("out-of-sync-test"), 425 | ( 426 | env::var(ZONE_ID_ENV).unwrap(), 427 | String::from("2ca364bf488e500ab98aa943f2d8973a"), 428 | ), 429 | ); 430 | } 431 | 432 | solver.cleanup("out-of-sync-test").await.unwrap(); 433 | } 434 | } 435 | -------------------------------------------------------------------------------- /src/solver/dns/mod.rs: -------------------------------------------------------------------------------- 1 | //! DNS-01 solvers for various providers 2 | //! 3 | //! Currently, the following providers are supported: 4 | //! - [Cloudflare](https://www.cloudflare.com): [`CloudflareDns01Solver`] 5 | //! 6 | //! If you would like a provider to be supported, 7 | //! [file an issue](https://github.com/akrantz01/lers/issues/new?assignees=&labels=dns-01+provider&template=dns-1-provider-request.md&title=) 8 | //! or [make a contribution](https://github.com/akrantz01/lers/compare). 9 | 10 | use once_cell::sync::OnceCell; 11 | use trust_dns_resolver::{ 12 | error::{ResolveError, ResolveErrorKind}, 13 | AsyncResolver, IntoName, TokioAsyncResolver, 14 | }; 15 | 16 | #[cfg(any(feature = "dns-01-cloudflare", feature = "integration"))] 17 | mod cloudflare; 18 | 19 | #[cfg(feature = "dns-01-cloudflare")] 20 | #[cfg_attr(docsrs, doc(cfg(feature = "dns-01-cloudflare")))] 21 | pub use cloudflare::{CloudflareDns01Builder, CloudflareDns01Solver, CloudflareError}; 22 | 23 | // TODO: don't use global resolver to allow for better configuration 24 | static RESOLVER: OnceCell = OnceCell::new(); 25 | 26 | /// Find the zone for a FQDN. 27 | /// 28 | /// This is intended for use by DNS-01 solvers to get the root zone for a FQDN. 29 | pub async fn find_zone_by_fqdn(fqdn: &str) -> Result { 30 | let resolver = RESOLVER.get_or_try_init(|| AsyncResolver::tokio_from_system_conf())?; 31 | 32 | let mut name = fqdn.into_name()?; 33 | loop { 34 | let lookup = resolver.soa_lookup(name.clone()).await; 35 | match lookup { 36 | Ok(lookup) => { 37 | let records = lookup.as_lookup().records(); 38 | debug_assert_ne!(records.len(), 0); 39 | let record = records.first().unwrap(); 40 | 41 | break Ok(record.name().to_utf8()); 42 | } 43 | Err(e) if matches!(e.kind(), ResolveErrorKind::NoRecordsFound { .. }) => { 44 | if name.num_labels() > 1 { 45 | name = name.base_name(); 46 | continue; 47 | } else { 48 | break Err(e); 49 | } 50 | } 51 | Err(e) => break Err(e), 52 | } 53 | } 54 | } 55 | 56 | #[cfg(test)] 57 | mod tests { 58 | use super::find_zone_by_fqdn; 59 | use trust_dns_resolver::error::{ResolveError, ResolveErrorKind}; 60 | 61 | #[tokio::test] 62 | async fn find_zone_by_fqdn_simple() -> Result<(), ResolveError> { 63 | let zone = find_zone_by_fqdn("gist.github.com").await?; 64 | assert_eq!(zone, "github.com."); 65 | 66 | Ok(()) 67 | } 68 | 69 | #[tokio::test] 70 | async fn find_zone_by_fqdn_cname() -> Result<(), ResolveError> { 71 | let zone = find_zone_by_fqdn("mail.google.com").await?; 72 | assert_eq!(zone, "google.com."); 73 | 74 | Ok(()) 75 | } 76 | 77 | #[tokio::test] 78 | async fn find_zone_by_fqdn_non_existent_subdomain() -> Result<(), ResolveError> { 79 | let zone = find_zone_by_fqdn("foo.google.com").await?; 80 | assert_eq!(zone, "google.com."); 81 | 82 | Ok(()) 83 | } 84 | 85 | #[tokio::test] 86 | async fn find_zone_by_fqdn_etld() -> Result<(), ResolveError> { 87 | let zone = find_zone_by_fqdn("example.com.ac").await?; 88 | assert_eq!(zone, "ac."); 89 | 90 | Ok(()) 91 | } 92 | 93 | #[tokio::test] 94 | async fn find_zone_by_fqdn_cross_zone_cname() -> Result<(), ResolveError> { 95 | let zone = find_zone_by_fqdn("cross-zone-example.assets.sh").await?; 96 | assert_eq!(zone, "assets.sh."); 97 | 98 | Ok(()) 99 | } 100 | 101 | #[tokio::test] 102 | async fn find_zone_by_fqdn_non_existent() { 103 | let error = find_zone_by_fqdn("test.lego.zz").await.unwrap_err(); 104 | assert!(matches!( 105 | error.kind(), 106 | ResolveErrorKind::NoRecordsFound { .. } 107 | )); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/solver/http.rs: -------------------------------------------------------------------------------- 1 | use super::{ 2 | common::{Challenges, SolverHandle}, 3 | Solver, 4 | }; 5 | use hyper::{ 6 | header, 7 | server::{conn::AddrIncoming, Builder, Server}, 8 | service::Service, 9 | Body, Method, Request, Response, StatusCode, 10 | }; 11 | use std::{ 12 | future::Future, 13 | net::{SocketAddr, TcpListener}, 14 | pin::Pin, 15 | task::{Context, Poll}, 16 | }; 17 | use tokio::sync::oneshot; 18 | use tracing::{instrument, Level, Span}; 19 | use uuid::Uuid; 20 | 21 | /// A bare-bones implementation of a solver for the HTTP-01 challenge. 22 | #[derive(Clone, Debug, Default)] 23 | pub struct Http01Solver { 24 | challenges: Challenges, 25 | } 26 | 27 | impl Http01Solver { 28 | /// Create a new solver 29 | pub fn new() -> Self { 30 | Self::default() 31 | } 32 | 33 | /// Start the solver in a separate task listening on the given address 34 | pub fn start(&self, address: &SocketAddr) -> hyper::Result> { 35 | let builder = Server::try_bind(address)?; 36 | Ok(self.launch(builder)) 37 | } 38 | 39 | /// Start the solver in a separate task using the given listener 40 | pub fn start_with_listener( 41 | &self, 42 | listener: TcpListener, 43 | ) -> hyper::Result> { 44 | let builder = Server::from_tcp(listener)?; 45 | Ok(self.launch(builder)) 46 | } 47 | 48 | fn launch(&self, builder: Builder) -> SolverHandle { 49 | let (tx, rx) = oneshot::channel(); 50 | 51 | let server = builder 52 | .serve(MakeSvc(self.challenges.clone())) 53 | .with_graceful_shutdown(async { rx.await.unwrap() }); 54 | 55 | SolverHandle { 56 | handle: tokio::spawn(server), 57 | tx, 58 | } 59 | } 60 | } 61 | 62 | #[async_trait::async_trait] 63 | impl Solver for Http01Solver { 64 | #[instrument( 65 | level = Level::INFO, 66 | name = "Solver::present", 67 | err, 68 | skip_all, 69 | fields(token, domain, solver = std::any::type_name::()), 70 | )] 71 | async fn present( 72 | &self, 73 | domain: String, 74 | token: String, 75 | key_authorization: String, 76 | ) -> Result<(), Box> { 77 | let mut challenges = self.challenges.write(); 78 | challenges.insert( 79 | token, 80 | Authorization { 81 | domain, 82 | key_authorization, 83 | }, 84 | ); 85 | 86 | Ok(()) 87 | } 88 | 89 | #[instrument( 90 | level = Level::INFO, 91 | name = "Solver::cleanup", 92 | err, 93 | skip_all, 94 | fields(token, solver = std::any::type_name::()), 95 | )] 96 | async fn cleanup( 97 | &self, 98 | token: &str, 99 | ) -> Result<(), Box> { 100 | let mut challenges = self.challenges.write(); 101 | challenges.remove(token); 102 | 103 | Ok(()) 104 | } 105 | } 106 | 107 | #[derive(Debug)] 108 | pub(crate) struct Authorization { 109 | pub domain: String, 110 | pub key_authorization: String, 111 | } 112 | 113 | struct SolverService(Challenges); 114 | 115 | impl Service> for SolverService { 116 | type Response = Response; 117 | type Error = hyper::Error; 118 | type Future = Pin> + Send>>; 119 | 120 | fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { 121 | Poll::Ready(Ok(())) 122 | } 123 | 124 | #[instrument( 125 | level = Level::INFO, 126 | name = "Http01Solver::request", 127 | skip_all, 128 | fields( 129 | method = %req.method(), 130 | uri = %req.uri(), 131 | version = ?req.version(), 132 | id = %Uuid::new_v4(), 133 | host, status, 134 | ), 135 | )] 136 | fn call(&mut self, req: Request) -> Self::Future { 137 | fn response(body: &'static str, status: StatusCode) -> Response { 138 | Span::current().record("status", status.as_u16()); 139 | Response::builder() 140 | .status(status) 141 | .body(Body::from(body)) 142 | .unwrap() 143 | } 144 | 145 | if req.method() != Method::GET { 146 | return Box::pin(async { 147 | Ok(response( 148 | "method not allowed", 149 | StatusCode::METHOD_NOT_ALLOWED, 150 | )) 151 | }); 152 | } 153 | 154 | let host = req 155 | .headers() 156 | .get(header::HOST) 157 | .map(|v| v.to_str().unwrap_or("")); 158 | 159 | let token = req 160 | .uri() 161 | .path() 162 | .strip_prefix("/.well-known/acme-challenge/"); 163 | 164 | if let (Some(token), Some(host)) = (token, host) { 165 | Span::current().record("host", host); 166 | 167 | let challenges = self.0.read(); 168 | 169 | if let Some(challenge) = challenges.get(token) { 170 | if challenge.domain == host { 171 | Span::current().record("status", 200); 172 | 173 | let response = Response::builder() 174 | .status(StatusCode::OK) 175 | .header(header::CONTENT_TYPE, "application/octet-stream") 176 | .body(challenge.key_authorization.clone().into()) 177 | .unwrap(); 178 | 179 | return Box::pin(async { Ok(response) }); 180 | } 181 | } 182 | } 183 | 184 | Box::pin(async { Ok(response("not found", StatusCode::NOT_FOUND)) }) 185 | } 186 | } 187 | 188 | struct MakeSvc(Challenges); 189 | 190 | impl Service for MakeSvc { 191 | type Response = SolverService; 192 | type Error = hyper::Error; 193 | type Future = Pin> + Send>>; 194 | 195 | fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { 196 | Poll::Ready(Ok(())) 197 | } 198 | 199 | fn call(&mut self, _req: T) -> Self::Future { 200 | let challenges = self.0.clone(); 201 | Box::pin(async move { Ok(SolverService(challenges)) }) 202 | } 203 | } 204 | 205 | #[cfg(test)] 206 | mod tests { 207 | use super::{Http01Solver, Solver, SolverHandle}; 208 | use reqwest::{header, Client, StatusCode}; 209 | use std::net::{SocketAddr, TcpListener}; 210 | use test_log::test; 211 | 212 | macro_rules! assert_challenges_size { 213 | ($solver:expr, $expected:expr) => {{ 214 | let challenges = $solver.challenges.read(); 215 | assert_eq!(challenges.len(), $expected); 216 | }}; 217 | } 218 | 219 | const DOMAIN: &str = "domain.com"; 220 | const TOKEN: &str = "testing-token"; 221 | const KEY_AUTHZ: &str = "testing-key-authorization"; 222 | 223 | fn solver() -> (Http01Solver, SolverHandle, SocketAddr) { 224 | let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); 225 | let addr = listener.local_addr().unwrap(); 226 | 227 | let solver = Http01Solver::new(); 228 | let handle = solver.start_with_listener(listener).unwrap(); 229 | 230 | (solver, handle, addr) 231 | } 232 | 233 | fn request_url(addr: &SocketAddr, token: &str) -> String { 234 | format!("http://{addr}/.well-known/acme-challenge/{token}") 235 | } 236 | 237 | #[test(tokio::test)] 238 | async fn valid() { 239 | let (solver, handle, addr) = solver(); 240 | 241 | solver 242 | .present(DOMAIN.into(), TOKEN.into(), KEY_AUTHZ.into()) 243 | .await 244 | .unwrap(); 245 | assert_challenges_size!(solver, 1); 246 | 247 | let client = Client::new(); 248 | let response = client 249 | .get(request_url(&addr, TOKEN)) 250 | .header(header::HOST, DOMAIN) 251 | .send() 252 | .await 253 | .unwrap(); 254 | 255 | assert_eq!(response.status(), StatusCode::OK); 256 | 257 | let key_authorization = response.text().await.unwrap(); 258 | assert_eq!(key_authorization, KEY_AUTHZ); 259 | 260 | solver.cleanup(TOKEN).await.unwrap(); 261 | assert_challenges_size!(solver, 0); 262 | 263 | handle.stop().await.unwrap(); 264 | } 265 | 266 | #[test(tokio::test)] 267 | async fn post() { 268 | let (_solver, handle, addr) = solver(); 269 | 270 | let client = Client::new(); 271 | let response = client.post(request_url(&addr, TOKEN)).send().await.unwrap(); 272 | 273 | assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); 274 | 275 | handle.stop().await.unwrap(); 276 | } 277 | 278 | #[test(tokio::test)] 279 | async fn missing_token() { 280 | let (solver, handle, addr) = solver(); 281 | 282 | solver 283 | .present(DOMAIN.into(), TOKEN.into(), KEY_AUTHZ.into()) 284 | .await 285 | .unwrap(); 286 | assert_challenges_size!(solver, 1); 287 | 288 | let client = Client::new(); 289 | let response = client 290 | .get(format!("http://{addr}/no/token")) 291 | .header(header::HOST, DOMAIN) 292 | .send() 293 | .await 294 | .unwrap(); 295 | 296 | assert_eq!(response.status(), StatusCode::NOT_FOUND); 297 | 298 | handle.stop().await.unwrap(); 299 | } 300 | 301 | #[test(tokio::test)] 302 | async fn wrong_token() { 303 | let (solver, handle, addr) = solver(); 304 | 305 | solver 306 | .present(DOMAIN.into(), TOKEN.into(), KEY_AUTHZ.into()) 307 | .await 308 | .unwrap(); 309 | assert_challenges_size!(solver, 1); 310 | 311 | let client = Client::new(); 312 | let response = client 313 | .get(request_url(&addr, "wrong-token")) 314 | .header(header::HOST, DOMAIN) 315 | .send() 316 | .await 317 | .unwrap(); 318 | 319 | assert_eq!(response.status(), StatusCode::NOT_FOUND); 320 | 321 | handle.stop().await.unwrap(); 322 | } 323 | 324 | #[test(tokio::test)] 325 | async fn missing_host_header() { 326 | let (solver, handle, addr) = solver(); 327 | 328 | solver 329 | .present(DOMAIN.into(), TOKEN.into(), KEY_AUTHZ.into()) 330 | .await 331 | .unwrap(); 332 | assert_challenges_size!(solver, 1); 333 | 334 | let client = Client::new(); 335 | let response = client.get(request_url(&addr, TOKEN)).send().await.unwrap(); 336 | 337 | assert_eq!(response.status(), StatusCode::NOT_FOUND); 338 | 339 | handle.stop().await.unwrap(); 340 | } 341 | 342 | #[test(tokio::test)] 343 | async fn wrong_host_header() { 344 | let (solver, handle, addr) = solver(); 345 | 346 | solver 347 | .present(DOMAIN.into(), TOKEN.into(), KEY_AUTHZ.into()) 348 | .await 349 | .unwrap(); 350 | assert_challenges_size!(solver, 1); 351 | 352 | let client = Client::new(); 353 | let response = client 354 | .get(request_url(&addr, TOKEN)) 355 | .header(header::HOST, "wrong.domain") 356 | .send() 357 | .await 358 | .unwrap(); 359 | 360 | assert_eq!(response.status(), StatusCode::NOT_FOUND); 361 | 362 | handle.stop().await.unwrap(); 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /src/solver/tls_alpn.rs: -------------------------------------------------------------------------------- 1 | use super::{ 2 | boxed_err, 3 | common::{Challenges, SolverHandle}, 4 | Solver, 5 | }; 6 | use futures::future::FutureExt; 7 | use openssl::{ 8 | error::ErrorStack, 9 | pkey::{PKey, Private}, 10 | ssl::{select_next_proto, AlpnError, NameType, SniError, SslAcceptor, SslContext, SslMethod}, 11 | x509::X509, 12 | }; 13 | use rcgen::{Certificate, CertificateParams, CustomExtension, RcgenError, SanType}; 14 | use std::{error::Error, io, net::SocketAddr, sync::Arc}; 15 | use tokio::{ 16 | io::AsyncWriteExt, 17 | net::{TcpListener, TcpStream}, 18 | sync::oneshot, 19 | }; 20 | use tracing::{error, field, instrument, span, Level, Span}; 21 | 22 | mod error; 23 | #[cfg(test)] 24 | mod smoke; 25 | mod stream; 26 | 27 | use stream::TlsAcceptor; 28 | 29 | static ALPN: &[u8; 11] = b"\x0aacme-tls/1"; 30 | 31 | /// A bare-bones implementation of a solver for the TLS-ALPN-01 challenge. 32 | #[derive(Clone, Debug, Default)] 33 | pub struct TlsAlpn01Solver { 34 | challenges: Challenges, 35 | } 36 | 37 | impl TlsAlpn01Solver { 38 | /// Create a new solver 39 | pub fn new() -> Self { 40 | Self::default() 41 | } 42 | 43 | /// Start the solver in a separate task listening on the given address 44 | pub async fn start(&self, address: SocketAddr) -> io::Result> { 45 | let listener = TcpListener::bind(address).await?; 46 | self.start_with_listener(listener) 47 | } 48 | 49 | /// Start the solver in a separate task using the given listener. 50 | pub fn start_with_listener( 51 | &self, 52 | listener: TcpListener, 53 | ) -> io::Result> { 54 | let acceptor = new_acceptor(self.challenges.clone())?; 55 | 56 | let (tx, rx) = oneshot::channel(); 57 | let handle = tokio::spawn(server(acceptor, listener.into(), rx)); 58 | 59 | Ok(SolverHandle { tx, handle }) 60 | } 61 | } 62 | 63 | #[async_trait::async_trait] 64 | impl Solver for TlsAlpn01Solver { 65 | #[instrument( 66 | level = Level::INFO, 67 | name = "Solver::present", 68 | err, 69 | skip_all, 70 | fields(token, domain, solver = std::any::type_name::()), 71 | )] 72 | async fn present( 73 | &self, 74 | domain: String, 75 | token: String, 76 | key_authorization: String, 77 | ) -> Result<(), Box> { 78 | let (certificate, private_key) = 79 | generate_certificate(&domain, &key_authorization).map_err(boxed_err)?; 80 | let (certificate, private_key) = 81 | load_openssl_tls_certificate(certificate, private_key).map_err(boxed_err)?; 82 | 83 | let mut context = SslContext::builder(SslMethod::tls()).map_err(boxed_err)?; 84 | context.set_private_key(&private_key).map_err(boxed_err)?; 85 | context.set_certificate(&certificate).map_err(boxed_err)?; 86 | 87 | context.set_alpn_protos(ALPN).map_err(boxed_err)?; 88 | context.set_alpn_select_callback(|_ssl, client| { 89 | select_next_proto(ALPN, client).ok_or(AlpnError::ALERT_FATAL) 90 | }); 91 | 92 | if cfg!(debug_assertions) { 93 | context.check_private_key().map_err(boxed_err)?; 94 | } 95 | 96 | let mut challenges = self.challenges.write(); 97 | challenges.insert( 98 | token, 99 | Authorization { 100 | domain, 101 | context: context.build(), 102 | }, 103 | ); 104 | 105 | Ok(()) 106 | } 107 | 108 | #[instrument( 109 | level = Level::INFO, 110 | name = "Solver::cleanup", 111 | err, 112 | skip_all, 113 | fields(token, solver = std::any::type_name::()), 114 | )] 115 | async fn cleanup(&self, token: &str) -> Result<(), Box> { 116 | let mut challenges = self.challenges.write(); 117 | challenges.remove(token); 118 | 119 | Ok(()) 120 | } 121 | } 122 | 123 | #[derive(Debug)] 124 | struct Authorization { 125 | domain: String, 126 | context: SslContext, 127 | } 128 | 129 | fn new_acceptor(challenges: Challenges) -> io::Result { 130 | let mut acceptor = SslAcceptor::mozilla_intermediate_v5(SslMethod::tls())?; 131 | 132 | acceptor.set_alpn_protos(ALPN)?; 133 | acceptor.set_alpn_select_callback(|_ssl, client| { 134 | select_next_proto(ALPN, client).ok_or(AlpnError::ALERT_FATAL) 135 | }); 136 | 137 | acceptor.set_servername_callback(move |ssl, _alert| { 138 | let span = span!( 139 | Level::DEBUG, 140 | "SslAcceptor::servername_callback", 141 | host = field::Empty 142 | ); 143 | let _enter = span.enter(); 144 | 145 | let servername = ssl.servername(NameType::HOST_NAME).ok_or(SniError::NOACK)?; 146 | span.record("host", &servername); 147 | 148 | let challenges = challenges.read(); 149 | let authorization = challenges 150 | .values() 151 | .find(|a| a.domain == servername) 152 | .ok_or(SniError::NOACK)?; 153 | 154 | ssl.set_ssl_context(&authorization.context) 155 | .map_err(|_| SniError::ALERT_FATAL)?; 156 | 157 | Ok(()) 158 | }); 159 | 160 | Ok(acceptor.build().into()) 161 | } 162 | 163 | async fn server( 164 | acceptor: TlsAcceptor, 165 | listener: TcpListener, 166 | stop: oneshot::Receiver<()>, 167 | ) -> io::Result<()> { 168 | let mut stop = stop.fuse(); 169 | let acceptor = Arc::new(acceptor); 170 | 171 | #[instrument( 172 | level = Level::INFO, 173 | name = "TlsAlpn01Solver::request", 174 | skip_all, 175 | fields(address), 176 | )] 177 | async fn handler(result: io::Result<(TcpStream, SocketAddr)>, acceptor: Arc) { 178 | let (socket, address) = match result { 179 | Ok(s) => s, 180 | Err(error) => { 181 | error!(%error, source = ?error.source(), "failed to accept connection"); 182 | return; 183 | } 184 | }; 185 | 186 | Span::current().record("address", field::display(address)); 187 | 188 | match acceptor.accept(socket).await { 189 | Ok(mut socket) => { 190 | debug_assert!(socket.get_ref().ssl().selected_alpn_protocol().is_some()); 191 | 192 | // Nothing to do once the handshake finishes 193 | let _ = socket.shutdown().await; 194 | } 195 | Err(error) => { 196 | error!(%error, source = ?error.source(), "failed to perform tls handshake"); 197 | } 198 | } 199 | } 200 | 201 | loop { 202 | futures::select_biased! { 203 | _ = stop => break, 204 | result = listener.accept().fuse() => { 205 | let acceptor = acceptor.clone(); 206 | tokio::spawn(handler(result, acceptor)); 207 | } 208 | } 209 | } 210 | 211 | Ok(()) 212 | } 213 | 214 | // Currently depends on rcgen pending support for custom x509 extensions in openssl 215 | // Relevant issues 216 | // - https://github.com/sfackler/rust-openssl/issues/1411 217 | // - https://github.com/sfackler/rust-openssl/issues/1601 218 | fn generate_certificate( 219 | domain: &str, 220 | key_authorization: &str, 221 | ) -> Result<(Vec, Vec), RcgenError> { 222 | debug_assert_eq!(key_authorization.as_bytes().len(), 32); 223 | 224 | let mut params = CertificateParams::default(); 225 | params 226 | .subject_alt_names 227 | .push(SanType::DnsName(domain.to_owned())); 228 | params 229 | .custom_extensions 230 | .push(CustomExtension::new_acme_identifier( 231 | key_authorization.as_bytes(), 232 | )); 233 | 234 | let certificate = Certificate::from_params(params)?; 235 | let certificate_der = certificate.serialize_der()?; 236 | let private_key_der = certificate.serialize_private_key_der(); 237 | 238 | Ok((certificate_der, private_key_der)) 239 | } 240 | 241 | fn load_openssl_tls_certificate( 242 | certificate: Vec, 243 | private_key: Vec, 244 | ) -> Result<(X509, PKey), ErrorStack> { 245 | let certificate = X509::from_der(&certificate)?; 246 | let private_key = PKey::private_key_from_der(&private_key)?; 247 | 248 | Ok((certificate, private_key)) 249 | } 250 | 251 | #[cfg(test)] 252 | mod tests { 253 | use super::{Solver, SolverHandle, TlsAlpn01Solver, ALPN}; 254 | 255 | use openssl::{ 256 | ssl::{HandshakeError, NameType, SslConnector, SslMethod, SslVerifyMode}, 257 | x509::{X509VerifyResult, X509}, 258 | }; 259 | use std::{ 260 | io, 261 | net::{SocketAddr, TcpStream}, 262 | }; 263 | use test_log::test; 264 | use tokio::net::TcpListener; 265 | use x509_parser::{ 266 | der_parser::parse_der, 267 | oid_registry::asn1_rs::{oid, Oid}, 268 | parse_x509_certificate, 269 | }; 270 | 271 | macro_rules! assert_challenges_size { 272 | ($solver:expr, $expected:expr) => {{ 273 | let challenges = $solver.challenges.read(); 274 | assert_eq!(challenges.len(), $expected); 275 | }}; 276 | } 277 | 278 | const ACME_IDENTIFIER_OID: Oid<'static> = oid!(1.3.6 .1 .5 .5 .7 .1 .31); 279 | 280 | const DOMAIN: &str = "domain.com"; 281 | const TOKEN: &str = "testing-token"; 282 | const KEY_AUTHZ: &str = "testing-key-authorization-abcdef"; 283 | 284 | async fn solver() -> (TlsAlpn01Solver, SolverHandle, SocketAddr) { 285 | let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); 286 | let addr = listener.local_addr().unwrap(); 287 | 288 | let solver = TlsAlpn01Solver::new(); 289 | let handle = solver.start_with_listener(listener).unwrap(); 290 | 291 | (solver, handle, addr) 292 | } 293 | 294 | fn check( 295 | address: SocketAddr, 296 | domain: &str, 297 | use_alpn: bool, 298 | use_sni: bool, 299 | ) -> Result<(Option, Option), HandshakeError> { 300 | let mut builder = SslConnector::builder(SslMethod::tls()).unwrap(); 301 | builder 302 | .set_alpn_protos(if use_alpn { ALPN } else { b"\x08http/1.1" }) 303 | .unwrap(); 304 | let connector = builder.build(); 305 | 306 | let mut ssl = connector 307 | .configure() 308 | .unwrap() 309 | .use_server_name_indication(use_sni) 310 | .verify_hostname(false); 311 | ssl.set_verify(SslVerifyMode::NONE); 312 | 313 | let socket = TcpStream::connect(&address).unwrap(); 314 | let mut stream = ssl.connect(domain, socket)?; 315 | 316 | let servername = stream 317 | .ssl() 318 | .servername(NameType::HOST_NAME) 319 | .map(ToOwned::to_owned); 320 | let certificate = stream.ssl().peer_certificate(); 321 | 322 | stream.shutdown().unwrap(); 323 | 324 | Ok((servername, certificate)) 325 | } 326 | 327 | // Need to use x509-parser since openssl doesn't support reading custom extensions 328 | // See: https://github.com/sfackler/rust-openssl/issues/373 329 | fn verify_key_authorization(certificate: &X509, expected: &str) { 330 | let der = certificate.to_der().unwrap(); 331 | let (_, certificate) = parse_x509_certificate(&der).unwrap(); 332 | 333 | let extension = certificate 334 | .get_extension_unique(&ACME_IDENTIFIER_OID) 335 | .unwrap() 336 | .unwrap(); 337 | assert!(extension.critical); 338 | 339 | let (_, parsed) = parse_der(extension.value).unwrap(); 340 | let bytes = parsed.as_slice().unwrap(); 341 | assert_eq!(String::from_utf8_lossy(bytes), expected); 342 | } 343 | 344 | #[test(tokio::test)] 345 | async fn valid() { 346 | let (solver, handle, addr) = solver().await; 347 | 348 | solver 349 | .present(DOMAIN.into(), TOKEN.into(), KEY_AUTHZ.into()) 350 | .await 351 | .unwrap(); 352 | assert_challenges_size!(solver, 1); 353 | 354 | let (servername, certificate) = 355 | tokio::task::spawn_blocking(move || check(addr, DOMAIN, true, true)) 356 | .await 357 | .unwrap() 358 | .unwrap(); 359 | 360 | assert_eq!(servername.unwrap(), "domain.com"); 361 | 362 | let certificate = certificate.unwrap(); 363 | assert_eq!( 364 | certificate 365 | .subject_alt_names() 366 | .unwrap() 367 | .iter() 368 | .next() 369 | .unwrap() 370 | .dnsname() 371 | .unwrap(), 372 | "domain.com" 373 | ); 374 | verify_key_authorization(&certificate, KEY_AUTHZ); 375 | 376 | solver.cleanup(TOKEN).await.unwrap(); 377 | assert_challenges_size!(solver, 0); 378 | 379 | handle.stop().await.unwrap(); 380 | } 381 | 382 | #[test(tokio::test)] 383 | async fn wrong_domain() { 384 | let (solver, handle, addr) = solver().await; 385 | 386 | solver 387 | .present(DOMAIN.into(), TOKEN.into(), KEY_AUTHZ.into()) 388 | .await 389 | .unwrap(); 390 | assert_challenges_size!(solver, 1); 391 | 392 | let error = tokio::task::spawn_blocking(move || check(addr, "wrong.domain", true, true)) 393 | .await 394 | .unwrap() 395 | .unwrap_err(); 396 | let HandshakeError::Failure(error) = error else { panic!("expected handshake failure") }; 397 | assert_eq!(error.ssl().verify_result(), X509VerifyResult::OK); 398 | assert_eq!(error.ssl().state_string(), "SSLERR"); 399 | 400 | solver.cleanup(TOKEN).await.unwrap(); 401 | assert_challenges_size!(solver, 0); 402 | 403 | handle.stop().await.unwrap(); 404 | } 405 | 406 | #[test(tokio::test)] 407 | async fn without_sni() { 408 | let (solver, handle, addr) = solver().await; 409 | 410 | solver 411 | .present(DOMAIN.into(), TOKEN.into(), KEY_AUTHZ.into()) 412 | .await 413 | .unwrap(); 414 | assert_challenges_size!(solver, 1); 415 | 416 | let error = tokio::task::spawn_blocking(move || check(addr, DOMAIN, true, false)) 417 | .await 418 | .unwrap() 419 | .unwrap_err(); 420 | let HandshakeError::Failure(error) = error else { panic!("expected handshake failure") }; 421 | assert_eq!(error.ssl().verify_result(), X509VerifyResult::OK); 422 | assert_eq!(error.ssl().state_string(), "SSLERR"); 423 | 424 | solver.cleanup(TOKEN).await.unwrap(); 425 | assert_challenges_size!(solver, 0); 426 | 427 | handle.stop().await.unwrap(); 428 | } 429 | 430 | #[test(tokio::test)] 431 | async fn without_alpn() { 432 | let (solver, handle, addr) = solver().await; 433 | 434 | solver 435 | .present(DOMAIN.into(), TOKEN.into(), KEY_AUTHZ.into()) 436 | .await 437 | .unwrap(); 438 | assert_challenges_size!(solver, 1); 439 | 440 | let error = tokio::task::spawn_blocking(move || check(addr, DOMAIN, false, true)) 441 | .await 442 | .unwrap() 443 | .unwrap_err(); 444 | let HandshakeError::Failure(error) = error else { panic!("expected handshake failure") }; 445 | assert_eq!(error.ssl().verify_result(), X509VerifyResult::OK); 446 | assert_eq!(error.ssl().state_string(), "SSLERR"); 447 | 448 | solver.cleanup(TOKEN).await.unwrap(); 449 | assert_challenges_size!(solver, 0); 450 | 451 | handle.stop().await.unwrap(); 452 | } 453 | } 454 | -------------------------------------------------------------------------------- /src/solver/tls_alpn/README.md: -------------------------------------------------------------------------------- 1 | # TLS-ALPN-01 2 | 3 | This module is a specialized implementation of [`tokio-native-tls`](https://crates.io/crates/tokio-native-tls) for 4 | OpenSSL so that we can get ALPN support on the server-side. 5 | 6 | The code and tests are from [`tokio-native-tls`](https://crates.io/crates/tokio-native-tls) and 7 | [`native-tls`](https://crates.io/crates/native-tls). 8 | -------------------------------------------------------------------------------- /src/solver/tls_alpn/error.rs: -------------------------------------------------------------------------------- 1 | use openssl::{error::ErrorStack, ssl, x509::X509VerifyResult}; 2 | use std::{ 3 | fmt::{Debug, Display, Formatter}, 4 | io::{self, ErrorKind}, 5 | }; 6 | 7 | /// From https://github.com/sfackler/rust-native-tls/blob/8fa929d6c3fb7c7adfca9e0fdd6446f5dfb34f92/src/imp/openssl.rs#L112-L150 8 | #[derive(Debug)] 9 | pub enum Error { 10 | Normal(ErrorStack), 11 | Ssl(ssl::Error, X509VerifyResult), 12 | } 13 | 14 | impl std::error::Error for Error { 15 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 16 | match *self { 17 | Error::Normal(ref e) => std::error::Error::source(e), 18 | Error::Ssl(ref e, _) => std::error::Error::source(e), 19 | } 20 | } 21 | } 22 | 23 | impl Display for Error { 24 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 25 | match *self { 26 | Error::Normal(ref e) => Display::fmt(e, f), 27 | Error::Ssl(ref e, X509VerifyResult::OK) => Display::fmt(e, f), 28 | Error::Ssl(ref e, v) => write!(f, "{} ({})", e, v), 29 | } 30 | } 31 | } 32 | 33 | impl From for Error { 34 | fn from(err: ErrorStack) -> Self { 35 | Error::Normal(err) 36 | } 37 | } 38 | 39 | impl From for io::Error { 40 | fn from(err: Error) -> io::Error { 41 | io::Error::new(ErrorKind::Other, err) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/solver/tls_alpn/smoke.rs: -------------------------------------------------------------------------------- 1 | use super::stream::{AllowStd, TlsAcceptor}; 2 | use futures::join; 3 | use native_tls::Certificate; 4 | use openssl::{ 5 | pkcs12::Pkcs12, 6 | ssl::{SslAcceptor, SslMethod, SslStream}, 7 | x509::X509VerifyResult, 8 | }; 9 | use std::{ 10 | fs, 11 | io::{Error, ErrorKind}, 12 | iter, 13 | }; 14 | use tokio::{ 15 | io::{AsyncReadExt, AsyncWrite, AsyncWriteExt}, 16 | net::{TcpListener, TcpStream}, 17 | }; 18 | use tokio_native_tls::TlsConnector; 19 | 20 | #[tokio::test] 21 | async fn client_to_server() { 22 | let srv = TcpListener::bind("127.0.0.1:0").await.unwrap(); 23 | let addr = srv.local_addr().unwrap(); 24 | 25 | let (server_tls, client_tls) = context(); 26 | 27 | let server = async move { 28 | let (socket, _) = srv.accept().await.unwrap(); 29 | let mut socket = server_tls.accept(socket).await.unwrap(); 30 | 31 | let openssl_stream: &SslStream<_> = socket.get_ref(); 32 | assert_eq!(openssl_stream.ssl().verify_result(), X509VerifyResult::OK); 33 | let allow_std_stream: &AllowStd<_> = openssl_stream.get_ref(); 34 | let _tokio_tcp_stream: &TcpStream = allow_std_stream.get_ref(); 35 | 36 | let mut data = Vec::new(); 37 | socket.read_to_end(&mut data).await.unwrap(); 38 | data 39 | }; 40 | 41 | let client = async move { 42 | let socket = TcpStream::connect(&addr).await.unwrap(); 43 | let socket = client_tls.connect("foobar.com", socket).await.unwrap(); 44 | copy(socket).await 45 | }; 46 | 47 | let (data, _) = join!(server, client); 48 | assert_eq!(data, vec![9; AMOUNT]); 49 | } 50 | 51 | #[tokio::test] 52 | async fn server_to_client() { 53 | let srv = TcpListener::bind("127.0.0.1:0").await.unwrap(); 54 | let addr = srv.local_addr().unwrap(); 55 | 56 | let (server_tls, client_tls) = context(); 57 | 58 | let server = async move { 59 | let (socket, _) = srv.accept().await.unwrap(); 60 | let socket = server_tls.accept(socket).await.unwrap(); 61 | copy(socket).await 62 | }; 63 | 64 | let client = async move { 65 | let socket = TcpStream::connect(&addr).await.unwrap(); 66 | let mut socket = client_tls.connect("foobar.com", socket).await.unwrap(); 67 | 68 | let mut data = Vec::new(); 69 | socket.read_to_end(&mut data).await.unwrap(); 70 | data 71 | }; 72 | 73 | let (_, data) = join!(server, client); 74 | assert_eq!(data, vec![9; AMOUNT]); 75 | } 76 | 77 | #[tokio::test] 78 | async fn one_byte_at_a_time() { 79 | const AMOUNT: usize = 1024; 80 | 81 | let srv = TcpListener::bind("127.0.0.1:0").await.unwrap(); 82 | let addr = srv.local_addr().unwrap(); 83 | 84 | let (server_tls, client_tls) = context(); 85 | 86 | let server = async move { 87 | let (socket, _) = srv.accept().await.unwrap(); 88 | let mut socket = server_tls.accept(socket).await.unwrap(); 89 | 90 | let mut sent = 0; 91 | for b in iter::repeat(9).take(AMOUNT) { 92 | let data = [b as u8]; 93 | socket.write_all(&data).await.unwrap(); 94 | sent += 1; 95 | } 96 | sent 97 | }; 98 | 99 | let client = async move { 100 | let socket = TcpStream::connect(&addr).await.unwrap(); 101 | let mut socket = client_tls.connect("foobar.com", socket).await.unwrap(); 102 | 103 | let mut data = Vec::new(); 104 | loop { 105 | let mut buf = [0; 1]; 106 | match socket.read_exact(&mut buf).await { 107 | Ok(_) => data.extend_from_slice(&buf), 108 | Err(ref err) if err.kind() == ErrorKind::UnexpectedEof => break, 109 | Err(err) => panic!("{}", err), 110 | } 111 | } 112 | data 113 | }; 114 | 115 | let (amount, data) = join!(server, client); 116 | assert_eq!(amount, AMOUNT); 117 | assert_eq!(data, vec![9; AMOUNT]); 118 | } 119 | 120 | fn context() -> (TlsAcceptor, TlsConnector) { 121 | let pkcs12 = fs::read("testdata/tls-alpn-01/identity.p12").unwrap(); 122 | let pkcs12 = Pkcs12::from_der(&pkcs12).unwrap(); 123 | let parsed = pkcs12.parse2("mypass").unwrap(); 124 | 125 | let mut acceptor = SslAcceptor::mozilla_intermediate(SslMethod::tls()).unwrap(); 126 | acceptor.set_private_key(&parsed.pkey.unwrap()).unwrap(); 127 | acceptor.set_certificate(&parsed.cert.unwrap()).unwrap(); 128 | parsed 129 | .ca 130 | .into_iter() 131 | .flatten() 132 | .rev() 133 | .for_each(|c| acceptor.add_extra_chain_cert(c).unwrap()); 134 | acceptor.set_min_proto_version(None).unwrap(); 135 | acceptor.set_max_proto_version(None).unwrap(); 136 | let acceptor = acceptor.build(); 137 | 138 | let der = fs::read("testdata/tls-alpn-01/root-ca.der").unwrap(); 139 | let cert = Certificate::from_der(&der).unwrap(); 140 | let connector = native_tls::TlsConnector::builder() 141 | .add_root_certificate(cert) 142 | .build() 143 | .unwrap(); 144 | 145 | (acceptor.into(), connector.into()) 146 | } 147 | 148 | const AMOUNT: usize = 128 * 1024; 149 | 150 | async fn copy(mut w: W) -> Result { 151 | let mut data = vec![9; AMOUNT]; 152 | let mut copied = 0; 153 | 154 | while !data.is_empty() { 155 | let written = w.write(&data).await?; 156 | if written <= data.len() { 157 | copied += written; 158 | data.resize(data.len() - written, 0); 159 | } else { 160 | w.write_all(&data).await?; 161 | copied += data.len(); 162 | break; 163 | } 164 | 165 | println!("remaining: {}", data.len()); 166 | } 167 | 168 | Ok(copied) 169 | } 170 | -------------------------------------------------------------------------------- /src/solver/tls_alpn/stream.rs: -------------------------------------------------------------------------------- 1 | use super::error::Error; 2 | use openssl::ssl::{ErrorCode, HandshakeError, MidHandshakeSslStream, SslAcceptor, SslStream}; 3 | use std::{ 4 | fmt::{Debug, Formatter}, 5 | future::Future, 6 | io::{self, ErrorKind, Read, Write}, 7 | pin::Pin, 8 | ptr::null_mut, 9 | task::{Context, Poll}, 10 | }; 11 | use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; 12 | 13 | #[derive(Clone)] 14 | pub(crate) struct TlsAcceptor(SslAcceptor); 15 | 16 | impl TlsAcceptor { 17 | pub async fn accept(&self, stream: S) -> Result, Error> 18 | where 19 | S: AsyncRead + AsyncWrite + Unpin, 20 | { 21 | handshake(move |s| self.0.accept(s), stream).await 22 | } 23 | } 24 | 25 | impl Debug for TlsAcceptor { 26 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 27 | f.debug_struct("TlsAcceptor").finish() 28 | } 29 | } 30 | 31 | impl From for TlsAcceptor { 32 | fn from(inner: SslAcceptor) -> Self { 33 | TlsAcceptor(inner) 34 | } 35 | } 36 | 37 | async fn handshake(f: F, stream: S) -> Result, Error> 38 | where 39 | F: FnOnce(AllowStd) -> Result>, HandshakeError>> + Unpin, 40 | S: AsyncRead + AsyncWrite + Unpin, 41 | { 42 | let start = StartedHandshakeFuture(Some(StartedHandshakeFutureInner { f, stream })); 43 | 44 | match start.await { 45 | Err(e) => Err(e), 46 | Ok(StartedHandshake::Done(s)) => Ok(s), 47 | Ok(StartedHandshake::Mid(s)) => MidHandshake(Some(s)).await, 48 | } 49 | } 50 | 51 | enum StartedHandshake { 52 | Done(TlsStream), 53 | Mid(MidHandshakeTlsStream), 54 | } 55 | 56 | struct StartedHandshakeFuture(Option>); 57 | struct StartedHandshakeFutureInner { 58 | f: F, 59 | stream: S, 60 | } 61 | 62 | impl Future for StartedHandshakeFuture 63 | where 64 | F: FnOnce(AllowStd) -> Result>, HandshakeError>> + Unpin, 65 | S: Unpin, 66 | AllowStd: Read + Write, 67 | { 68 | type Output = Result, Error>; 69 | 70 | fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { 71 | let inner = self.0.take().expect("future polled after completion"); 72 | let stream = AllowStd { 73 | inner: inner.stream, 74 | context: cx as *mut _ as *mut (), 75 | }; 76 | 77 | match (inner.f)(stream) { 78 | Ok(mut s) => { 79 | s.get_mut().context = null_mut(); 80 | Poll::Ready(Ok(StartedHandshake::Done(TlsStream(s)))) 81 | } 82 | Err(HandshakeError::SetupFailure(e)) => Poll::Ready(Err(e.into())), 83 | Err(HandshakeError::WouldBlock(mut s)) => { 84 | s.get_mut().context = null_mut(); 85 | Poll::Ready(Ok(StartedHandshake::Mid(MidHandshakeTlsStream(s)))) 86 | } 87 | Err(HandshakeError::Failure(e)) => { 88 | let v = e.ssl().verify_result(); 89 | Poll::Ready(Err(Error::Ssl(e.into_error(), v))) 90 | } 91 | } 92 | } 93 | } 94 | 95 | struct MidHandshake(Option>); 96 | 97 | impl Future for MidHandshake 98 | where 99 | S: AsyncRead + AsyncWrite + Unpin, 100 | { 101 | type Output = Result, Error>; 102 | 103 | fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { 104 | let mut_self = self.get_mut(); 105 | let mut s = mut_self.0.take().expect("future polled after completion"); 106 | 107 | s.get_mut().context = cx as *mut _ as *mut (); 108 | match s.handshake() { 109 | Ok(mut s) => { 110 | s.get_mut().get_mut().context = null_mut(); 111 | Poll::Ready(Ok(s)) 112 | } 113 | Err(HandshakeError::WouldBlock(mut s)) => { 114 | s.get_mut().context = null_mut(); 115 | mut_self.0 = Some(MidHandshakeTlsStream(s)); 116 | Poll::Pending 117 | } 118 | Err(HandshakeError::SetupFailure(e)) => Poll::Ready(Err(e.into())), 119 | Err(HandshakeError::Failure(e)) => { 120 | let v = e.ssl().verify_result(); 121 | Poll::Ready(Err(Error::Ssl(e.into_error(), v))) 122 | } 123 | } 124 | } 125 | } 126 | 127 | struct MidHandshakeTlsStream(MidHandshakeSslStream>); 128 | 129 | impl Debug for MidHandshakeTlsStream 130 | where 131 | S: Debug, 132 | { 133 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 134 | Debug::fmt(&self.0, f) 135 | } 136 | } 137 | 138 | impl MidHandshakeTlsStream { 139 | fn get_mut(&mut self) -> &mut AllowStd { 140 | self.0.get_mut() 141 | } 142 | } 143 | 144 | impl MidHandshakeTlsStream 145 | where 146 | S: AsyncRead + AsyncWrite + Unpin, 147 | AllowStd: Read + Write, 148 | { 149 | fn handshake(self) -> Result, HandshakeError>> { 150 | match self.0.handshake() { 151 | Ok(s) => Ok(TlsStream(s)), 152 | Err(e) => Err(e), 153 | } 154 | } 155 | } 156 | 157 | #[derive(Debug)] 158 | pub(crate) struct TlsStream(SslStream>); 159 | 160 | impl TlsStream { 161 | fn with_context(&mut self, ctx: &mut Context<'_>, f: F) -> Poll> 162 | where 163 | F: FnOnce(&mut SslStream>) -> io::Result, 164 | AllowStd: Read + Write, 165 | { 166 | self.0.get_mut().context = ctx as *mut _ as *mut (); 167 | let g = Guard(self); 168 | match f(&mut (g.0).0) { 169 | Ok(v) => Poll::Ready(Ok(v)), 170 | Err(ref e) if e.kind() == ErrorKind::WouldBlock => Poll::Pending, 171 | Err(e) => Poll::Ready(Err(e)), 172 | } 173 | } 174 | 175 | fn get_mut(&mut self) -> &mut SslStream> { 176 | &mut self.0 177 | } 178 | 179 | pub(crate) fn get_ref(&self) -> &SslStream> { 180 | &self.0 181 | } 182 | } 183 | 184 | impl AsyncRead for TlsStream 185 | where 186 | S: AsyncRead + AsyncWrite + Unpin, 187 | { 188 | fn poll_read( 189 | mut self: Pin<&mut Self>, 190 | ctx: &mut Context<'_>, 191 | buf: &mut ReadBuf<'_>, 192 | ) -> Poll> { 193 | self.with_context(ctx, |s| { 194 | let n = s.read(buf.initialize_unfilled())?; 195 | buf.advance(n); 196 | Ok(()) 197 | }) 198 | } 199 | } 200 | 201 | impl AsyncWrite for TlsStream 202 | where 203 | S: AsyncRead + AsyncWrite + Unpin, 204 | { 205 | fn poll_write( 206 | mut self: Pin<&mut Self>, 207 | ctx: &mut Context<'_>, 208 | buf: &[u8], 209 | ) -> Poll> { 210 | self.with_context(ctx, |s| s.write(buf)) 211 | } 212 | 213 | fn poll_flush(mut self: Pin<&mut Self>, ctx: &mut Context<'_>) -> Poll> { 214 | self.with_context(ctx, |s| s.flush()) 215 | } 216 | 217 | // From https://github.com/sfackler/rust-native-tls/blob/8fa929d6c3fb7c7adfca9e0fdd6446f5dfb34f92/src/imp/openssl.rs#L455-L464 218 | fn poll_shutdown(mut self: Pin<&mut Self>, ctx: &mut Context<'_>) -> Poll> { 219 | self.with_context(ctx, |s| match s.shutdown() { 220 | Ok(_) => Ok(()), 221 | Err(ref e) if e.code() == ErrorCode::ZERO_RETURN => Ok(()), 222 | Err(e) => Err(e 223 | .into_io_error() 224 | .unwrap_or_else(|e| io::Error::new(ErrorKind::Other, e))), 225 | }) 226 | } 227 | } 228 | 229 | #[derive(Debug)] 230 | pub(crate) struct AllowStd { 231 | pub(crate) inner: S, 232 | pub(crate) context: *mut (), 233 | } 234 | 235 | // *mut () context is neither Send nor Sync 236 | unsafe impl Send for AllowStd {} 237 | unsafe impl Sync for AllowStd {} 238 | 239 | impl AllowStd { 240 | pub(crate) fn with_context(&mut self, f: F) -> io::Result 241 | where 242 | F: FnOnce(&mut Context<'_>, Pin<&mut S>) -> Poll>, 243 | { 244 | unsafe { 245 | assert!(!self.context.is_null()); 246 | let waker = &mut *(self.context as *mut _); 247 | match f(waker, Pin::new(&mut self.inner)) { 248 | Poll::Ready(r) => r, 249 | Poll::Pending => Err(io::Error::from(ErrorKind::WouldBlock)), 250 | } 251 | } 252 | } 253 | 254 | #[allow(dead_code)] 255 | pub(crate) fn get_ref(&self) -> &S { 256 | &self.inner 257 | } 258 | } 259 | 260 | impl Read for AllowStd 261 | where 262 | S: AsyncRead + Unpin, 263 | { 264 | fn read(&mut self, buf: &mut [u8]) -> io::Result { 265 | let mut buf = ReadBuf::new(buf); 266 | self.with_context(|ctx, stream| stream.poll_read(ctx, &mut buf))?; 267 | Ok(buf.filled().len()) 268 | } 269 | } 270 | 271 | impl Write for AllowStd 272 | where 273 | S: AsyncWrite + Unpin, 274 | { 275 | fn write(&mut self, buf: &[u8]) -> io::Result { 276 | self.with_context(|ctx, stream| stream.poll_write(ctx, buf)) 277 | } 278 | 279 | fn flush(&mut self) -> io::Result<()> { 280 | self.with_context(|ctx, stream| stream.poll_flush(ctx)) 281 | } 282 | } 283 | 284 | struct Guard<'a, S>(&'a mut TlsStream) 285 | where 286 | AllowStd: Read + Write; 287 | 288 | impl Drop for Guard<'_, S> 289 | where 290 | AllowStd: Read + Write, 291 | { 292 | fn drop(&mut self) { 293 | (self.0).0.get_mut().context = null_mut() 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /src/test.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | solver::{boxed_err, Solver}, 3 | Account, Directory, 4 | }; 5 | use once_cell::sync::Lazy; 6 | use parking_lot::Mutex; 7 | use reqwest::Client; 8 | use serde::Serialize; 9 | use std::{collections::HashMap, error::Error, sync::Arc}; 10 | 11 | /// The pebble test server URL 12 | pub const TEST_URL: &str = "https://10.30.50.2:14000/dir"; 13 | 14 | /// Create a client allowing self-signed certificates 15 | pub fn client() -> Client { 16 | Client::builder() 17 | .danger_accept_invalid_hostnames(true) 18 | .danger_accept_invalid_certs(true) 19 | .user_agent("lers/testing") 20 | .build() 21 | .unwrap() 22 | } 23 | 24 | /// Create a new directory for the local Pebble instance 25 | pub async fn directory() -> Directory { 26 | Directory::builder(TEST_URL) 27 | .client(client()) 28 | .build() 29 | .await 30 | .unwrap() 31 | } 32 | 33 | pub async fn directory_with_http01_solver() -> Directory { 34 | Directory::builder(TEST_URL) 35 | .client(client()) 36 | .http01_solver(Box::new(EXTERNAL_HTTP01_SOLVER.clone())) 37 | .build() 38 | .await 39 | .unwrap() 40 | } 41 | 42 | pub async fn directory_with_dns01_solver() -> Directory { 43 | Directory::builder(TEST_URL) 44 | .client(client()) 45 | .dns01_solver(Box::new(EXTERNAL_DNS01_SOLVER.clone())) 46 | .build() 47 | .await 48 | .unwrap() 49 | } 50 | 51 | /// Create a new account on the server 52 | pub async fn account(directory: Directory) -> Account { 53 | directory 54 | .account() 55 | .contacts(vec!["mailto:test@user.com".into()]) 56 | .terms_of_service_agreed(true) 57 | .create_if_not_exists() 58 | .await 59 | .unwrap() 60 | } 61 | 62 | static EXTERNAL_HTTP01_SOLVER: Lazy = Lazy::new(|| ExternalHttp01Solver { 63 | domains: Arc::default(), 64 | client: Client::new(), 65 | }); 66 | 67 | static EXTERNAL_DNS01_SOLVER: Lazy = Lazy::new(|| ExternalDns01Solver { 68 | domains: Arc::default(), 69 | client: Client::new(), 70 | }); 71 | 72 | const ADD_A_RECORD_URL: &str = "http://10.30.50.3:8055/add-a"; 73 | const CLEAR_A_RECORD_URL: &str = "http://10.30.50.3:8055/clear-a"; 74 | const ADD_HTTP_01_URL: &str = "http://10.30.50.3:8055/add-http01"; 75 | const DELETE_HTTP_01_URL: &str = "http://10.30.50.3:8055/del-http-01"; 76 | const ADD_DNS_01_URL: &str = "http://10.30.50.3:8055/set-txt"; 77 | const CLEAR_DNS_01_URL: &str = "http://10.30.50.3:8055/clear-txt"; 78 | 79 | /// The external HTTP-01 solver delegates responsibility to the 80 | /// [Pebble Challenge Test Server](https://github.com/letsencrypt/pebble/tree/main/cmd/pebble-challtestsrv). 81 | #[derive(Debug, Clone)] 82 | struct ExternalHttp01Solver { 83 | // Maps from tokens to domains 84 | domains: Arc>>, 85 | client: Client, 86 | } 87 | 88 | #[async_trait::async_trait] 89 | impl Solver for ExternalHttp01Solver { 90 | async fn present( 91 | &self, 92 | domain: String, 93 | token: String, 94 | key_authorization: String, 95 | ) -> Result<(), Box> { 96 | request( 97 | &self.client, 98 | ADD_A_RECORD_URL, 99 | DnsRequest { 100 | host: &domain, 101 | addresses: Some(&["10.30.50.3"]), 102 | }, 103 | true, 104 | ) 105 | .await?; 106 | 107 | request( 108 | &self.client, 109 | ADD_HTTP_01_URL, 110 | Http01Request { 111 | token: &token, 112 | content: Some(&key_authorization), 113 | }, 114 | true, 115 | ) 116 | .await?; 117 | 118 | { 119 | let mut domains = self.domains.lock(); 120 | domains.insert(token, domain); 121 | } 122 | 123 | Ok(()) 124 | } 125 | 126 | async fn cleanup(&self, token: &str) -> Result<(), Box> { 127 | let domain = { 128 | let mut domains = self.domains.lock(); 129 | domains.remove(token) 130 | }; 131 | let Some(domain) = domain else { panic!("domain for token {token:?} does not exist") }; 132 | 133 | request( 134 | &self.client, 135 | CLEAR_A_RECORD_URL, 136 | DnsRequest { 137 | host: &domain, 138 | addresses: None, 139 | }, 140 | false, 141 | ) 142 | .await?; 143 | 144 | request( 145 | &self.client, 146 | DELETE_HTTP_01_URL, 147 | Http01Request { 148 | token, 149 | content: None, 150 | }, 151 | false, 152 | ) 153 | .await?; 154 | 155 | Ok(()) 156 | } 157 | } 158 | 159 | /// The external DNS-01 solver delegates responsibility to the 160 | /// [Pebble Challenge Test Server](https://github.com/letsencrypt/pebble/tree/main/cmd/pebble-challtestsrv). 161 | #[derive(Debug, Clone)] 162 | struct ExternalDns01Solver { 163 | // Maps from tokens to domains 164 | domains: Arc>>, 165 | client: Client, 166 | } 167 | 168 | #[async_trait::async_trait] 169 | impl Solver for ExternalDns01Solver { 170 | async fn present( 171 | &self, 172 | domain: String, 173 | token: String, 174 | key_authorization: String, 175 | ) -> Result<(), Box> { 176 | request( 177 | &self.client, 178 | ADD_DNS_01_URL, 179 | Dns01Request { 180 | host: format!("_acme-challenge.{domain}."), 181 | value: Some(&key_authorization), 182 | }, 183 | true, 184 | ) 185 | .await?; 186 | 187 | { 188 | let mut domains = self.domains.lock(); 189 | domains.insert(token, domain); 190 | } 191 | 192 | Ok(()) 193 | } 194 | 195 | async fn cleanup(&self, token: &str) -> Result<(), Box> { 196 | let domain = { 197 | let mut domains = self.domains.lock(); 198 | domains.remove(token) 199 | }; 200 | let Some(domain) = domain else { panic!("domain for token {token:?} does not exist") }; 201 | 202 | request( 203 | &self.client, 204 | CLEAR_DNS_01_URL, 205 | Dns01Request { 206 | host: format!("_acme-challenge.{domain}."), 207 | value: None, 208 | }, 209 | false, 210 | ) 211 | .await?; 212 | 213 | Ok(()) 214 | } 215 | } 216 | 217 | #[derive(Debug, Serialize)] 218 | struct DnsRequest<'s> { 219 | host: &'s str, 220 | #[serde(skip_serializing_if = "Option::is_none")] 221 | addresses: Option<&'s [&'s str]>, 222 | } 223 | 224 | #[derive(Debug, Serialize)] 225 | struct Http01Request<'s> { 226 | token: &'s str, 227 | #[serde(skip_serializing_if = "Option::is_none")] 228 | content: Option<&'s str>, 229 | } 230 | 231 | #[derive(Debug, Serialize)] 232 | struct Dns01Request<'s> { 233 | host: String, 234 | value: Option<&'s str>, 235 | } 236 | 237 | async fn request( 238 | client: &Client, 239 | url: &str, 240 | body: S, 241 | raise_for_status: bool, 242 | ) -> Result<(), Box> 243 | where 244 | S: Serialize, 245 | { 246 | let response = client 247 | .post(url) 248 | .json(&body) 249 | .send() 250 | .await 251 | .map_err(boxed_err)?; 252 | 253 | if raise_for_status { 254 | response.error_for_status().map_err(boxed_err)?; 255 | } 256 | 257 | Ok(()) 258 | } 259 | -------------------------------------------------------------------------------- /testdata/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by hack/seed.py 2 | account-ids.json 3 | -------------------------------------------------------------------------------- /testdata/accounts/1.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN EC PARAMETERS----- 2 | BggqhkjOPQMBBw== 3 | -----END EC PARAMETERS----- 4 | -----BEGIN EC PRIVATE KEY----- 5 | MHcCAQEEIPt8qna8aoYRt8M511/j9TFGnMJCdt9BPyxt8IZQSyploAoGCCqGSM49 6 | AwEHoUQDQgAE2ZnXNqj3aILz3gTYZlSGlhB7qeZ2fGTpRLtUIRI6rGp6oaxF4yIq 7 | Wi/LOetLNGbaeIvtb1CLpe+lptDOHFzDTw== 8 | -----END EC PRIVATE KEY----- 9 | -------------------------------------------------------------------------------- /testdata/accounts/2.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEA3uP9S/Fq+BeZTykGQ6k6IPJg8tiJZJ7J8N/KiXUCzLa3p8In 3 | Brd1AvBEGL8TsKwCKIDi49uYOvPOCqnriVCPD2RMmLS6bEBCAHnnNa1ZG9NPJUUf 4 | U5B1M7B/SbpjxQ+wjh8D26fOsZSnjlhHrbtV3MDjQvQCvmURVrSMEp6QXkxqCjuw 5 | W6405+dVwAAWoEpWvCsEnU1JZyc5xJKRS9Bd4/L1eN8+xu3jS/x4xK/fCGlb5Utv 6 | F19BBqiK7qEIfNOZynnrsMPT3LeoD+Kw3p1N9jgiexjWbqXnLnBIYjl7PrUIxdyW 7 | wuCRUGO7RUKBiPj10yIe4jSLU3Z9X/eGLIiBbwIDAQABAoIBAQCbr+KQtkvCiTFU 8 | AtLWVhE9TC/90NR/TQ6/SoI9a1cOSR6Vdl8uKNz5tXvLUURepndHdWeGQy/dFck4 9 | 16GnI3caCDQcgjipPmUb0gp3CuNwNTRWUybjhsbm2QTAKgpgbmoji3R1bIxKrAwx 10 | uGYw6ua5sJ3NeYWlGhF3X9trcghjZGy7+p001aSMCbqIUOtI1A3YOj77QnbERV7b 11 | s50ZrwQ3reopFSXB22j/E/IYueSl6hjjGJE8jRqOxDtvx7L/8wJD0xbMglul6fgJ 12 | fRXPazllNE73olq8XgQYa4HjmlS+kj7v+Z8JIrHTkf7y3a1e1oTzfIb4dVyR3TPj 13 | 6uLfb6MhAoGBAPjuuOjpy6XKPdNK43PeR04D9bCBfEFJ/kD+sDVLh5gQCZ2iKITv 14 | lyKYYiuU+LzShpRa3YlZrxYG9cUnLkbskUte7XAtE/XM6k3eyn9mp16YXRuaxvzd 15 | sqhnTNhmsVtTi4rt35ZdfwWV8DO5hzNkAx2VqshJbn5yeEqNtGs70avfAoGBAOU3 16 | /ZozTsS5yBiwhEIc1y5MZh7VVA3gH+Z5d89TDj/mGNWFTfXNVVD7HLav/3Io1EYp 17 | QSPh24CXEpiSFeQhtEhJSFeRtMEWG1q4cedYVfv7A0j6VsqaSm7AhPL6A9YSjISG 18 | ehuCTOvox3TLp3Cija7j8QZXYuMRwmPThByTuNxxAoGAG/WsPTTtU7zIfu/9ZilE 19 | NwYI1X9ltmuaLDCvF/1YyIKcoeDxziSfBBq7hAuieIro5Mbj9SZmnQHBHxjMgNjX 20 | ZPDPmHbntAcFFiP8+JxOFjjk1FHpIcPA6ltX7UJzjz9t//fB0kDEIJt7tEOVZPdJ 21 | xkvmN8LPr1IqIq2R4y1/2l8CgYAQU5igrx7hLEpwV8JT4zIAfjiX4aIHCvu6stQx 22 | 1DyjmIQUUVZoN6PoDLrS2F5dh0L3bGDTaXb1Bc2xSFZ+1Ve9/lpEwoAZcLWqFJEo 23 | ZUZamFp3jD06WRsMIHJXzC8RxGh12A5Cf1lzRDVQwGDAyRNGbb3xMbA9dDpgWeSD 24 | FJKKQQKBgAoEf/B0v2WWtNJyYFlMK1o9Hko+XcMewvbTvWZi8iRIHqWYJHrs8X/i 25 | gzO3BOt0iVK0OfGWp0w8M8oUbYyMobR9MZ67fGwOaLWvZNoeKI2+7TmiVbsGSeD4 26 | KPK+SKT03ij01Lmlfs+awBF3HR9+43rRrg9GOY5370E9NWDCXBnB 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /testdata/ecdsa_p-256.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgC9RVpFvy5h1bwgEr 3 | qUEusRJebI9uazmZDXOGYVq04dihRANCAARsUUkQqTQesDJNXPr2IKJXwqxfVs3B 4 | LHrSjpeVq32E+n7MZFsLKZuDQNpVFiH/Q8JazkIH9mOtdMpwMCT/xsdB 5 | -----END PRIVATE KEY----- -------------------------------------------------------------------------------- /testdata/ecdsa_p-384.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDC5lbcTFHw+Zn4G0Vht 3 | aZNILVgPnFwRYCfGLZF0dA5lCxC8ATQBKTwPKDg0XPQD1RWhZANiAAQwMPrxOuiy 4 | QFyeTj3B3tQjWcsjij2r0MkfQ/b2JuQBvoDMvGDSYi7VTc3BRMpR/+UeV2e0gDMX 5 | rs136N9MNqInFrRYxUdxnlq38BCTG1PpoK7mNDXNIpw+9WePVK/clPY= 6 | -----END PRIVATE KEY----- -------------------------------------------------------------------------------- /testdata/ecdsa_p-521.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIHuAgEAMBAGByqGSM49AgEGBSuBBAAjBIHWMIHTAgEBBEIBK9/0r8JdDm+ikwrb 3 | O60mhglpbN1Kqw+ZxGmmXntEZk8o+0U5u+KIqZxp/UwBxE+hJtWvfWOz3FxG4EKh 4 | IdhQITShgYkDgYYABAHduzIiYDqGwShTv2MgMupkPw89rhixdFA9/jJBeIS2uWf/ 5 | 0QqzZrYnY0ORVuuMGoX8y3kGphAPygE4dsxQTYUjNAFMoOF7pqGxKmjwo1BgvTHP 6 | EhNrGlK2FXfNuCLNXq3D46auVsdxxPiNh727S5ptvVF90+SJB5ikWzyGu6AfDqOj 7 | yw== 8 | -----END PRIVATE KEY----- -------------------------------------------------------------------------------- /testdata/rsa_2048.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDLYxzCsfs0zLjL 3 | 7SIwFM0tYgFy8uL7+Lz+xMlVohFuwCnetiEUXHa//v0ROneGqq5TbdusmM+BhX6F 4 | 1wHuXCriyxk8PbPO/MSct+6HQt2ye7L+hAgoa069vjyWpnhzT3iT5MUtWECkGOR0 5 | Fon6uFvuIzXGTdJctZvbEGvPWJdX6iR9dqj03mRXRrM5ndKl+vYc8tIQNRCuDhFb 6 | TUFl7fNm0q7tMlSeUgJ4sl3+t7EyLNbHyIjNhf41rKFdJKnXJ5c9F+TlmeWGsYK7 7 | rLCaGa7tADd2i7vtbE3YTMBQkCiIi7EukJJ5BXhfGcMR7ijzxT388jV/rEEp1x9H 8 | T8d4G63fAgMBAAECggEAYw6noE+tCJ841eFtyDspXxQfnoubb5tK+U6AvJtNoEIp 9 | YlYUMGWEVKhtOund8xHIC8wytJJMZknNQIRiZWQwYvsGFGf83jAP1kBjBS/U6CwF 10 | Fp7Zlk4FMLaprbnwakDneihuvFICUanqAnxDwX/vDkHJ3AZlEVBGU2BhEcCVHXSF 11 | kFic2fGBUIfH0SDN+4lfOiGSbjjvsQEiv3xH62dw+8/MCW9mV/lGUsQHOh98gNcv 12 | Xy/VQ85vMNQIkf+wv/zOzyeTn2iZzx8xmWz4ZkLmq5ece/nDMxwkHMdVlhiFfnVi 13 | MpP4k8JexK9JkWGTS/cQUqk34czKQJCLxkw8yqkwIQKBgQDLnISjEY1jc4YKO6Ab 14 | HdIy/f4jTIgxKVOEGjZj0AfXntFP84gs9p5gScO95N6+l4xCXWHmqmjrwKdEfIDd 15 | wc23bk1wfgYf29fkrwf1QB2W4sC3CdzTjPM18Fb6uSc6rxQ+rh+0LSMSEUzSxORJ 16 | V6E0NfyF6IweP1AfRxTETkHJbwKBgQD/t9LrLxTTOMZoa+Nmw8NlP02jrIaXyQ4X 17 | 3ThlK9fxCf+HDQOfhgT9NJVoWcN22y1Zsi5TxALguJsx42/T4bhMg+b17LA59sc6 18 | pAvRdB7Yrwfpjtb3vJuF0zEgjeOQE4bK+VZqiJcoLZ9lupQlY+ZHkVsO4y05oHQV 19 | 3ZERyQnKkQKBgQCZC3wTSoU5RMNy+6B74W13UL1u8P7J3SNef4l0exD5/PGeJBKu 20 | oW0oOSn9mYPoROdzlteY12xoEHZqHx+KEDu97hYdQUz/M3NS/FGCAgB7wtNSggJP 21 | rXm+iHoImZWoIaOY/a7s8qSS1xgksURa8JhGM3ItgT+ZGMPzzq0IZT5D1wKBgG3g 22 | jsB/enNH8fjsYsnFVDAtAy9Q8oRF38hhRdoy/JaVtTZSYTwqWfpyncA40cRAaTwh 23 | U8aqcpIcwJKvJ13jn01BX/xLt20wnGqWEn3tZ1Oz4bJ5reTFJg5asMFMNnux6DlO 24 | 6dLc3hZlhBgyE3X9dvVJf9blxoj8aOT8T1lVCOABAoGBAMnGgh5vqjD5GCvbnvcC 25 | 4jo+cQ9++myFNVJo6yolrmZJIjha/GdvWrq00Jrh98xY5O7TvPjHjmEoIYJbX4LR 26 | yHJKY5KTSIA7dWbA9VkwbBqjcjqmKZVHACY3+zy3ig4nok7hIRGIHII/PS0QHpVR 27 | jG39InX4dsVOfW9sHigdDyYU 28 | -----END PRIVATE KEY----- -------------------------------------------------------------------------------- /testdata/tls-alpn-01/identity.p12: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akrantz01/lers/991a2754a0aa0bacfb835e194a945a005ccb6fb7/testdata/tls-alpn-01/identity.p12 -------------------------------------------------------------------------------- /testdata/tls-alpn-01/root-ca.der: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akrantz01/lers/991a2754a0aa0bacfb835e194a945a005ccb6fb7/testdata/tls-alpn-01/root-ca.der --------------------------------------------------------------------------------