├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── docker-image.yml │ ├── lint.yml │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .vscode └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── app ├── acme │ ├── __init__.py │ ├── account │ │ └── router.py │ ├── authorization │ │ └── router.py │ ├── certificate │ │ ├── cronjob.py │ │ ├── router.py │ │ └── service.py │ ├── challenge │ │ ├── router.py │ │ └── service.py │ ├── directory │ │ └── router.py │ ├── exceptions.py │ ├── middleware.py │ ├── nonce │ │ ├── cronjob.py │ │ ├── router.py │ │ └── service.py │ └── order │ │ └── router.py ├── ca │ ├── __init__.py │ ├── cronjob.py │ ├── model.py │ └── service.py ├── config.py ├── db │ ├── __init__.py │ └── migrations │ │ ├── 001.sql │ │ └── __init__.py ├── logger.py ├── mail │ ├── __init__.py │ └── templates │ │ ├── cert-expired-info │ │ ├── body.html │ │ └── subject.txt │ │ ├── cert-expires-warning │ │ ├── body.html │ │ └── subject.txt │ │ └── new-account-info │ │ ├── body.html │ │ └── subject.txt ├── main.py └── web │ ├── __init__.py │ ├── middleware.py │ ├── router.py │ ├── templates │ ├── base.html │ ├── cert-log.html │ ├── domain-log.html │ └── index.html │ └── www │ └── favicon.png ├── pyproject.toml ├── requirements.txt └── tests ├── e2e ├── .gitignore ├── Caddyfile ├── Dockerfile.uacme ├── run.sh └── traefik.yaml └── pytest ├── .gitignore ├── Dockerfile ├── README.md ├── conftest.py ├── run.sh ├── test_acme_account.py ├── test_acme_certificate.py ├── test_acme_directory.py ├── test_acme_nonce.py └── utils.py /.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .github 3 | .mypy_cache 4 | .pytest_cache 5 | .ruff_cache 6 | .vscode 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "docker" 13 | directory: "/" 14 | schedule: 15 | interval: "weekly" 16 | - package-ecosystem: "github-actions" 17 | directory: "/" 18 | schedule: 19 | interval: "weekly" 20 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ main ] 9 | schedule: 10 | - cron: '18 3 * * 4' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | language: [ 'python' ] 25 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 26 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | 32 | # Initializes the CodeQL tools for scanning. 33 | - name: Initialize CodeQL 34 | uses: github/codeql-action/init@v3 35 | with: 36 | languages: ${{ matrix.language }} 37 | # If you wish to specify custom queries, you can do so here or in a config file. 38 | # By default, queries listed here will override any specified in a config file. 39 | # Prefix the list here with "+" to use these queries and those in the config file. 40 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 41 | 42 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 43 | # If this step fails, then you should remove it and run the build manually (see below) 44 | - name: Autobuild 45 | uses: github/codeql-action/autobuild@v3 46 | 47 | # ℹ️ Command-line programs to run using the OS shell. 48 | # 📚 https://git.io/JvXDl 49 | 50 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 51 | # and modify them (or add more) to build your code if your project 52 | # uses a compiled language 53 | 54 | #- run: | 55 | # make bootstrap 56 | # make release 57 | 58 | - name: Perform CodeQL Analysis 59 | uses: github/codeql-action/analyze@v3 60 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - 'v*.*.*' 9 | pull_request: 10 | # The branches below must be a subset of the branches above 11 | branches: [ main ] 12 | 13 | jobs: 14 | docker: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Check out the repo 18 | uses: actions/checkout@v4 19 | 20 | - name: apply version tag 21 | run: | 22 | if [ "${{ github.ref_type }}" == "tag" ]; then 23 | sed -i "s/'0.0.0'/'${{ github.ref_name }}'/" app/main.py 24 | else 25 | sed -i "s/'0.0.0'/'edge'/" app/main.py 26 | fi 27 | 28 | - name: Docker meta 29 | id: meta 30 | uses: docker/metadata-action@v5 31 | with: 32 | # list of Docker images to use as base name for tags 33 | images: | 34 | ${{ github.repository }} 35 | ghcr.io/${{ github.repository }} 36 | # generate Docker tags based on the following events/attributes 37 | tags: | 38 | type=semver,pattern={{major}}.{{minor}}.{{patch}} 39 | type=semver,pattern={{major}}.{{minor}} 40 | type=semver,pattern={{major}} 41 | type=edge,branch=main 42 | 43 | - name: Set up QEMU 44 | uses: docker/setup-qemu-action@v3 45 | 46 | - name: Set up Docker Buildx 47 | uses: docker/setup-buildx-action@v3 48 | 49 | - name: Log in to Docker Hub 50 | if: github.event_name != 'pull_request' 51 | uses: docker/login-action@v3 52 | with: 53 | username: ${{ secrets.DOCKER_USERNAME }} 54 | password: ${{ secrets.DOCKER_TOKEN }} 55 | 56 | - name: Login to GHCR 57 | if: github.event_name != 'pull_request' 58 | uses: docker/login-action@v3 59 | with: 60 | registry: ghcr.io 61 | username: ${{ github.repository_owner }} 62 | password: ${{ secrets.GITHUB_TOKEN }} 63 | 64 | - name: Build and push 65 | uses: docker/build-push-action@v6 66 | with: 67 | context: . 68 | # platforms: linux/amd64,linux/arm64 69 | push: ${{ github.event_name != 'pull_request' }} 70 | tags: ${{ steps.meta.outputs.tags }} 71 | labels: ${{ steps.meta.outputs.labels }} 72 | 73 | - name: Run Trivy vulnerability scanner 74 | if: github.event_name != 'pull_request' 75 | uses: aquasecurity/trivy-action@master 76 | with: 77 | image-ref: "ghcr.io/${{ github.repository }}" 78 | format: 'sarif' 79 | output: 'trivy-results.sarif' 80 | 81 | - name: Upload Trivy scan results to GitHub Security tab 82 | if: github.event_name != 'pull_request' 83 | uses: github/codeql-action/upload-sarif@v3 84 | with: 85 | sarif_file: 'trivy-results.sarif' 86 | 87 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Code Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | # The branches below must be a subset of the branches above 9 | branches: [ main ] 10 | 11 | jobs: 12 | hadolint: 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 15 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: hadolint/hadolint-action@v3.1.0 18 | with: 19 | dockerfile: Dockerfile 20 | 21 | python: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Check out the repo 25 | uses: actions/checkout@v4 26 | 27 | - name: Set up Python 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: '3.12' 31 | 32 | - name: Install dependencies 33 | run: | 34 | pip install flake8 flake8-bandit flake8-bugbear flake8-builtins flake8-comprehensions flake8-deprecated flake8-isort flake8-print flake8-quotes flake8-todo 35 | pip install pylint 36 | pip install ruff 37 | 38 | - name: Check linting 39 | run: | 40 | pip install -r requirements.txt 41 | echo -e "[settings]\nline_length=179\nsections=FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER" > .isort.cfg 42 | # ignore list: 43 | # F722: https://stackoverflow.com/questions/64909849/syntax-error-with-flake8-and-pydantic-constrained-types-constrregex 44 | # B008: FastAPI uses by design function calls in arguments for dependency injection 45 | python -m flake8 --max-line-length 179 --ignore=F722,B008,I001,I004,I005 app 46 | pylint app 47 | ruff check app 48 | ruff format --check app 49 | 50 | python-version-support: 51 | runs-on: ubuntu-latest 52 | strategy: 53 | matrix: 54 | python-version: ["3.10", "3.11", "3.12", "3.13"] 55 | 56 | steps: 57 | - uses: actions/checkout@v4 58 | - name: Set up Python ${{ matrix.python-version }} 59 | uses: actions/setup-python@v5 60 | with: 61 | python-version: ${{ matrix.python-version }} 62 | cache: "pip" 63 | - name: Check code 64 | run: | 65 | pip install --only-binary=:all: -r requirements.txt 66 | python -m compileall -q . 67 | 68 | spellcheck: 69 | name: Spell Check with Typos 70 | runs-on: ubuntu-latest 71 | steps: 72 | - name: Checkout Actions Repository 73 | uses: actions/checkout@v4 74 | 75 | - name: Check spelling 76 | uses: crate-ci/typos@master 77 | with: 78 | files: . -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Releases 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - id: imagetag 14 | run: | 15 | echo "DOCKER_IMAGE_TAG=$(echo ${{github.ref_name}} | cut -dv -f2)" >> $GITHUB_ENV 16 | - name: Create Release 17 | id: create_release 18 | if: github.event_name != 'pull_request' 19 | uses: actions/create-release@v1 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | with: 23 | tag_name: ${{ github.ref_name }} 24 | release_name: ${{ github.ref_name }} 25 | draft: false 26 | prerelease: false 27 | body: | 28 | Docker images: 29 | - ${{ github.repository }}:${{ env.DOCKER_IMAGE_TAG }} 30 | - ghcr.io/${{ github.repository }}:${{ env.DOCKER_IMAGE_TAG }} -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | # The branches below must be a subset of the branches above 9 | branches: [ main ] 10 | 11 | jobs: 12 | e2e: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Check out the repo 16 | uses: actions/checkout@v4 17 | 18 | - name: Set up Docker Buildx 19 | uses: docker/setup-buildx-action@v3 20 | 21 | - name: Run test suite 22 | run: | 23 | cd tests/e2e 24 | sudo ./run.sh 25 | 26 | pytest: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Check out the repo 30 | uses: actions/checkout@v4 31 | 32 | - name: Set up Docker Buildx 33 | uses: docker/setup-buildx-action@v3 34 | 35 | - name: Run test suite 36 | run: | 37 | cd tests/pytest 38 | sudo ./run.sh -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ---> Python 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/#use-with-ide 111 | .pdm.toml 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .env 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | #.idea/ 162 | 163 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.formatting.autopep8Args": [ 3 | "--max-line-length=179" 4 | ] 5 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/python:3.13.3-alpine 2 | 3 | RUN adduser --no-create-home --disabled-password appuser && \ 4 | apk update --no-cache 5 | 6 | WORKDIR /app 7 | EXPOSE 8080/tcp 8 | ENV PYTHONUNBUFFERED=True 9 | 10 | COPY requirements.txt . 11 | RUN pip install --no-cache-dir --upgrade -r requirements.txt 12 | 13 | ADD --chmod=0644 https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.1/milligram.min.css /app/web/www/libs/ 14 | ADD --chmod=0644 https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js /app/web/www/libs/ 15 | ADD --chmod=0644 https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css /app/web/www/libs/ 16 | 17 | USER appuser 18 | CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080", "--no-server-header"] 19 | 20 | HEALTHCHECK --start-period=10s --interval=3m --timeout=1s \ 21 | CMD wget --quiet --spider http://127.0.0.1:8080/acme/directory || exit 1 22 | 23 | COPY app /app -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 knrdl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ACME CA Server 2 | 3 | [![Build](https://github.com/knrdl/acme-ca-server/actions/workflows/docker-image.yml/badge.svg)](https://github.com/knrdl/acme-ca-server/actions/workflows/docker-image.yml) 4 | 5 | # Features 6 | 7 | * **ACME Server** implementation (http-01 challenge) 8 | * **Built-in CA** to sign/revoke certificates (can be replaced with an external CA), CA rollover is supported 9 | * Notification **Mails** (account created, certificate will expire soon, certificate is expired) with customizable templates 10 | * **Web UI** (certificate log) with customizable templates 11 | 12 | Tested with [Certbot](https://certbot.eff.org/), [Traefik](https://traefik.io/traefik/), [Caddy](https://caddyserver.com/), [uacme](https://github.com/ndilieto/uacme), [acme.sh](https://github.com/acmesh-official/acme.sh). 13 | 14 | # Setup 15 | 16 | ## 1. Generate a new CA root certificate (or use an existing cert) 17 | 18 | ``` 19 | $ openssl genrsa -out ca.key 4096 20 | $ openssl req -new -x509 -nodes -days 3650 -subj "/C=DE/O=Demo" -key ca.key -out ca.pem 21 | ``` 22 | 23 | ## 2. Deploy the container 24 | 25 | Docker Compose snippet: 26 | 27 | ```yaml 28 | version: '2.4' 29 | services: 30 | 31 | acme-ca-server: 32 | image: knrdl/acme-ca-server 33 | restart: always 34 | environment: 35 | EXTERNAL_URL: http://localhost:8080 36 | DB_DSN: postgresql://postgres:secret@db/postgres 37 | # ports: 38 | # - "8080:8080" 39 | networks: 40 | - net 41 | volumes: 42 | - ./ca.key:/import/ca.key:ro # needed once to import new ca 43 | - ./ca.pem:/import/ca.pem:ro # needed once to import new ca 44 | mem_limit: 250m 45 | 46 | db: 47 | image: postgres:16-alpine 48 | restart: always 49 | environment: 50 | POSTGRES_PASSWORD: secret 51 | networks: 52 | - net 53 | volumes: 54 | - ./db:/var/lib/postgresql/data 55 | mem_limit: 250m 56 | 57 | networks: 58 | net: 59 | ``` 60 | 61 | ## 3. Reverse proxy 62 | 63 | Serve the app behind a TLS terminating reverse proxy, e.g. as https://acme.mydomain.org 64 | 65 | The app listens on port 8080 for http traffic. 66 | 67 | ## 4. Test with certbot 68 | 69 | ```shell 70 | docker run -it --rm certbot/certbot certonly --server https://acme.mydomain.org/acme/directory --standalone --no-eff-email --email user1@mydomain.org -v --domains test1.mydomain.org 71 | ``` 72 | 73 | # Customizations 74 | 75 | ## Environment Variables 76 | 77 | | Env Var | Default | Description | 78 | |---------|---------|-------------| 79 | | EXTERNAL_URL | | The HTTPS address the server will be reachable from, e.g. https://acme.mydomain.org | 80 | | DB_DSN | | Postgres connection string, e.g. `postgresql://username:password@host/dbname` (database will be initialized on startup) | 81 | | ACME_TERMS_OF_SERVICE_URL | `None` | Optional URL which the ACME client can show when the user has to accept the terms of service, e.g. https://acme.mydomain.org/terms | 82 | | ACME_MAIL_TARGET_REGEX | any mail address | restrict the email address which must be provided to the ACME client by the user. E.g. `[^@]+@mydomain\.org` only allows mail addresses from mydomain.org | 83 | | ACME_TARGET_DOMAIN_REGEX | any non-wildcard domain name | restrict the domain names for which certificates can be requested via ACME. E.g. `[^\*]+\.mydomain\.org` only allows domain names from mydomain.org | 84 | | CA_ENABLED | `True` | whether the internal CA is enabled, set this to false when providing a custom CA implementation | 85 | | CA_CERT_LIFETIME | 60 days (`60d`) | how often certs must be replaced by the ACME client | 86 | | CA_CRL_LIFETIME | 7 days (`7d`) | how often the certificate revocation list will be rebuilt (despite rebuild on every certificate revocation) | 87 | | CA_ENCRYPTION_KEY | will be generated if not provided | the key to protect the CA private keys on rest (encrypted in the database) | 88 | | CA_IMPORT_DIR | `/import` | where the *ca.pem* and *ca.key* are initially imported from, see 2.
CA rollover is as simple as placing a new cert in this directory | 89 | | MAIL_ENABLED | `False` | if sending emails is enabled | 90 | | MAIL_HOST | `None` | smtp host | 91 | | MAIL_PORT | `None` | smtp port (default depends on encryption method) | 92 | | MAIL_USERNAME | `None` | smtp auth username | 93 | | MAIL_PASSWORD | `None` | smtp auth password | 94 | | MAIL_ENCRYPTION | `tls` | transport encryption method: `tls` (recommended), `starttls` or `plain` (unencrypted) | 95 | | MAIL_SENDER | `None` | the email address shown when sending mails, e.g. `acme@mydomain.org` | 96 | | MAIL_NOTIFY_ON_ACCOUNT_CREATION | `True` | whether to send a mail when the user runs ACME for the first time | 97 | | MAIL_WARN_BEFORE_CERT_EXPIRES | 20 days (`20d`) | when to warn the user via mail that a certificate has not been renewed in time (can be disabled by providing `false` as value) | 98 | | MAIL_NOTIFY_WHEN_CERT_EXPIRED | `True` | whether to inform the user that a certificate finally expired which has not been renewed in time | 99 | | WEB_ENABLED | `True` | whether to also provide UI endpoints or just the ACME functionality | 100 | | WEB_ENABLE_PUBLIC_LOG | `False` | whether to show a transparency log of all certificates generated via ACME | 101 | | WEB_APP_TITLE | `ACME CA Server` | title shown in web and mails | 102 | | WEB_APP_DESCRIPTION | `Self hosted ACME CA Server` | description shown in web and mails | 103 | 104 | ## Customize templates 105 | 106 | ### Mail 107 | 108 | Templates consist of `subject.txt` and `body.html` (see [here](./app/mail/templates)). Overwrite the following files: 109 | * /app/mail/templates/**cert-expired-info**/{subject.txt,body.html} 110 | * /app/mail/templates/**cert-expires-warning**/{subject.txt,body.html} 111 | * /app/mail/templates/**new-account-info**/{subject.txt,body.html} 112 | 113 | Template parameters: 114 | * `app_title`: `str` application title from `WEB_APP_TITLE` 115 | * `app_desc`: `str` application description from `WEB_APP_DESCRIPTION` 116 | * `web_url`: `str` web index url from `EXTERNAL_URL` 117 | * `acme_url`: `str` acme directory url 118 | * `domains`: `list[str]` list of expiring domains 119 | * `expires_at`: `datetime` domain expiration date 120 | * `expires_in_days`: `int` days until cert will expire 121 | * `serial_number`: `str` expiring certs serial number (hex) 122 | 123 | ### Web UI 124 | 125 | Custom files to be served by the http server can be placed in `/app/web/www`. 126 | 127 | Overwrite templates (see [here](./app/web/templates)): 128 | * /app/web/templates/cert-log.html (Certificate Listing) 129 | * /app/web/templates/domain-log.html (Domain Listing) 130 | * /app/web/templates/index.html (Startpage) 131 | 132 | Template parameters: 133 | * `app_title`: `str` application title from `WEB_APP_TITLE` 134 | * `app_desc`: `str` application description from `WEB_APP_DESCRIPTION` 135 | * `web_url`: `str` web index url from `EXTERNAL_URL` 136 | * `acme_url`: `str` acme directory url 137 | * `certs`: `list` list of certs for `cert-log.html` 138 | * `domains`: `list` list of domains for `domain-log.html` 139 | 140 | ## Provide a custom CA implementation 141 | 142 | First set env var `CA_ENABLED=False`. Then overwrite the file `/app/ca/service.py` (see [here](./app/ca/service.py)) in the docker image. It must provide two functions: 143 | 144 | ### 1. `sign_csr()` 145 | 146 | ```python 147 | async def sign_csr(csr: x509.CertificateSigningRequest, subject_domain: str, san_domains: list[str]) -> SignedCertInfo: 148 | ... 149 | ``` 150 | 151 | * `csr`: a [x509.CertificateSigningRequest](https://cryptography.io/en/latest/x509/reference/#cryptography.x509.CertificateSigningRequest) object 152 | * `subject_domain`: the main domain name for the certificate 153 | * `san_domains`: *subject alternative names*, all domain names (including `subject_domain`) for the certificate 154 | * *returns*: instance of `SignedCertInfo()` 155 | 156 | ```python 157 | class SignedCertInfo: 158 | cert: x509.Certificate 159 | cert_chain_pem: str 160 | ``` 161 | 162 | * `cert`: a [x509.Certificate](https://cryptography.io/en/latest/x509/reference/#cryptography.x509.Certificate) object 163 | * `cert_chain_pem`: a PEM-encoded text file containing the created cert as well as the root or also intermediate cert. This file will be used by the ACME client 164 | 165 | ### 2. `revoke_cert()` 166 | 167 | ```python 168 | async def revoke_cert(serial_number: str, revocations: set[tuple[str, datetime]]) -> None: 169 | ... 170 | ``` 171 | 172 | * `serial_number`: certificate serial number to revoke as hex value 173 | * `revocations`: all revoked certificates including the one specified by `serial_number`. It's a set of tuples containing `(serial_number, revocation_date)` 174 | * *returns*: no error on success, throw exception otherwise 175 | 176 | A custom CA backend must also handle the CRL (certificate revocation list) distribution. 177 | 178 | # Internals 179 | 180 | ## Entities 181 | 182 | ```mermaid 183 | flowchart LR 184 | accounts -->|1:0..n| orders 185 | orders -->|1:1..n| authorizations 186 | authorizations -->|1:1| challenges 187 | orders -->|1:0..1| certificates 188 | ``` 189 | -------------------------------------------------------------------------------- /app/acme/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Any 3 | 4 | from fastapi import APIRouter 5 | from fastapi.responses import JSONResponse 6 | 7 | from .account import router as account_router 8 | from .authorization import router as authorization_router 9 | from .certificate import cronjob as certificate_cronjob 10 | from .certificate import router as certificate_router 11 | from .challenge import router as challenge_router 12 | from .directory import router as directory_router 13 | from .nonce import cronjob as nonce_cronjob 14 | from .nonce import router as nonce_router 15 | from .order import router as order_router 16 | 17 | 18 | class ACMEResponse(JSONResponse): 19 | def render(self, content: dict[str, Any] | None) -> bytes: 20 | return super().render( # remove null fields from responses 21 | {k: v for k, v in content.items() if v is not None} if content is not None else None 22 | ) 23 | 24 | 25 | router = APIRouter(prefix='/acme', default_response_class=ACMEResponse) 26 | router.include_router(account_router.api) 27 | router.include_router(authorization_router.api) 28 | router.include_router(certificate_router.api) 29 | router.include_router(challenge_router.api) 30 | router.include_router(directory_router.api) 31 | router.include_router(nonce_router.api) 32 | router.include_router(order_router.api) 33 | 34 | 35 | async def start_cronjobs(): 36 | await asyncio.gather( 37 | certificate_cronjob.start(), 38 | nonce_cronjob.start(), 39 | ) 40 | -------------------------------------------------------------------------------- /app/acme/account/router.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | from typing import Annotated, Literal 3 | 4 | from fastapi import APIRouter, Depends, Response, status 5 | from pydantic import BaseModel, conlist, constr 6 | 7 | import db 8 | import mail 9 | from config import settings 10 | from logger import logger 11 | 12 | from ..exceptions import ACMEException 13 | from ..middleware import RequestData, SignedRequest 14 | 15 | tosAgreedType = Literal[True] if settings.acme.terms_of_service_url else (bool | None) 16 | contactType = conlist( 17 | constr(strip_whitespace=True, to_lower=True, pattern=f'^mailto:{settings.acme.mail_target_regex.pattern}$'), 18 | min_length=1, 19 | max_length=1, 20 | ) 21 | 22 | 23 | class NewOrViewAccountPayload(BaseModel): 24 | contact: list[str] | None = None 25 | termsOfServiceAgreed: bool | None = None # just to view the account no TOS agreement is required 26 | onlyReturnExisting: bool = False 27 | 28 | 29 | class NewAccountPayload(BaseModel): 30 | contact: contactType 31 | termsOfServiceAgreed: tosAgreedType = None 32 | onlyReturnExisting: Literal[False] = False 33 | 34 | @property 35 | def mail_addr(self) -> str: 36 | return self.contact[0].removeprefix('mailto:') 37 | 38 | 39 | class UpdateAccountPayload(BaseModel): 40 | status: Literal['deactivated'] | None = None 41 | contact: contactType | None = None 42 | 43 | @property 44 | def mail_addr(self) -> str | None: 45 | if self.contact: 46 | return self.contact[0].removeprefix('mailto:') 47 | 48 | 49 | api = APIRouter(tags=['acme:account']) 50 | 51 | 52 | @api.post('/new-account') 53 | async def create_or_view_account( 54 | response: Response, 55 | data: Annotated[RequestData[NewOrViewAccountPayload], Depends(SignedRequest(NewOrViewAccountPayload, allow_new_account=True))], 56 | ): 57 | """ 58 | https://www.rfc-editor.org/rfc/rfc8555.html#section-7.3 59 | """ 60 | jwk_json: dict = data.key.export(as_dict=True) 61 | 62 | async with db.transaction() as sql: 63 | result = await sql.record("""select id, mail, status from accounts where jwk=$1 and (id=$2 or $2::text is null)""", jwk_json, data.account_id) 64 | account_exists = bool(result) 65 | 66 | if account_exists: 67 | account_id, account_status, mail_addr = result['id'], result['status'], result['mail'] 68 | else: 69 | if data.payload.onlyReturnExisting: 70 | raise ACMEException(status_code=status.HTTP_400_BAD_REQUEST, exctype='accountDoesNotExist', detail='Account does not exist', new_nonce=data.new_nonce) 71 | else: # create new account 72 | # NewAccountPayload contains more checks than NewOrViewAccountPayload 73 | payload = NewAccountPayload(**data.payload.model_dump()) 74 | mail_addr = payload.mail_addr 75 | account_id = secrets.token_urlsafe(16) 76 | async with db.transaction() as sql: 77 | account_status = await sql.value( 78 | """insert into accounts (id, mail, jwk) values ($1, $2, $3) returning status""", 79 | account_id, 80 | mail_addr, 81 | jwk_json, 82 | ) 83 | try: 84 | await mail.send_new_account_info_mail(mail_addr) 85 | except Exception: 86 | logger.error('could not send new account mail to "%s"', mail_addr, exc_info=True) 87 | 88 | response.status_code = 200 if account_exists else 201 89 | response.headers['Location'] = f'{settings.external_url}acme/accounts/{account_id}' 90 | return { 91 | 'status': account_status, 92 | 'contact': ['mailto:' + mail_addr], 93 | 'orders': f'{settings.external_url}acme/accounts/{account_id}/orders', 94 | } 95 | 96 | 97 | @api.post('/key-change') 98 | async def change_key(data: Annotated[RequestData, Depends(SignedRequest())]): 99 | """not implemented""" 100 | raise ACMEException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, exctype='serverInternal', detail='not implemented', new_nonce=data.new_nonce) 101 | 102 | 103 | @api.post('/accounts/{acc_id}') 104 | async def view_or_update_account( 105 | acc_id: str, 106 | data: Annotated[RequestData[UpdateAccountPayload], Depends(SignedRequest(UpdateAccountPayload, allow_blocked_account=True))], 107 | ): 108 | if acc_id != data.account_id: 109 | raise ACMEException(status_code=status.HTTP_403_FORBIDDEN, exctype='unauthorized', detail='wrong kid', new_nonce=data.new_nonce) 110 | 111 | if data.payload.contact: 112 | async with db.transaction() as sql: 113 | await sql.exec("""update accounts set mail=$1 where id = $2 and status = 'valid'""", data.payload.mail_addr, acc_id) 114 | try: 115 | await mail.send_new_account_info_mail(data.payload.mail_addr) 116 | except Exception: 117 | logger.error('could not send new account mail to "%s"', data.payload.mail_addr, exc_info=True) 118 | 119 | if data.payload.status == 'deactivated': # https://www.rfc-editor.org/rfc/rfc8555#section-7.3.6 120 | async with db.transaction() as sql: 121 | await sql.exec("""update accounts set status='deactivated' where id = $1""", acc_id) 122 | await sql.exec( 123 | """ 124 | update orders set status='invalid', error=row('unauthorized','account deactivated') 125 | where account_id = $1 and status <> 'invalid' 126 | """, 127 | acc_id, 128 | ) 129 | 130 | async with db.transaction(readonly=True) as sql: 131 | account_status, mail_addr = await sql.record("""select status, mail from accounts where id = $1""", acc_id) 132 | 133 | return { 134 | 'status': account_status, 135 | 'contact': ['mailto:' + mail_addr], 136 | 'orders': f'{settings.external_url}acme/accounts/{acc_id}/orders', 137 | } 138 | 139 | 140 | @api.post('/accounts/{acc_id}/orders', tags=['acme:order']) 141 | async def view_orders(acc_id: str, data: Annotated[RequestData, Depends(SignedRequest())]): 142 | if acc_id != data.account_id: 143 | raise ACMEException(status_code=status.HTTP_403_FORBIDDEN, exctype='unauthorized', detail='wrong account id provided', new_nonce=data.new_nonce) 144 | async with db.transaction(readonly=True) as sql: 145 | orders = [order_id async for order_id, *_ in sql("""select id from orders where account_id = $1 and status <> 'invalid'""", acc_id)] 146 | return { 147 | 'orders': [f'{settings.external_url}acme/orders/{order_id}' for order_id in orders], 148 | } 149 | -------------------------------------------------------------------------------- /app/acme/authorization/router.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, Literal, Optional 2 | 3 | from fastapi import APIRouter, Depends, status 4 | from pydantic import BaseModel 5 | 6 | import db 7 | from config import settings 8 | 9 | from ..exceptions import ACMEException 10 | from ..middleware import RequestData, SignedRequest 11 | 12 | 13 | class UpdateAuthzPayload(BaseModel): 14 | status: Literal['deactivated'] | None = None 15 | 16 | 17 | api = APIRouter(tags=['acme:authorization']) 18 | 19 | 20 | @api.post('/authorizations/{authz_id}') 21 | async def view_or_update_authorization( 22 | authz_id: str, 23 | data: Annotated[RequestData[Optional[UpdateAuthzPayload]], Depends(SignedRequest(Optional[UpdateAuthzPayload]))], 24 | ): 25 | async with db.transaction(readonly=True) as sql: 26 | record = await sql.record( 27 | """ 28 | select authz.status, ord.status, ord.expires_at, authz.domain, chal.id, chal.token, chal.status, chal.validated_at 29 | from authorizations authz 30 | join challenges chal on chal.authz_id = authz.id 31 | join orders ord on authz.order_id = ord.id 32 | where authz.id = $1 and ord.account_id = $2 33 | """, 34 | authz_id, 35 | data.account_id, 36 | ) 37 | if record: 38 | authz_status, order_status, expires_at, domain, chal_id, chal_token, chal_status, chal_validated_at = record 39 | if data.payload and data.payload.status == 'deactivated': # deactivate authz 40 | if authz_status in ['pending', 'valid'] and order_status in ['pending', 'ready']: 41 | async with db.transaction() as sql: 42 | await sql.exec( 43 | """ 44 | update orders set status='invalid', error=row('unauthorized','authorization deactivated') 45 | where id = (select order_id from authorizations where id = $1) 46 | """, 47 | authz_id, 48 | ) 49 | authz_status = await sql.value("""update authorizations set status = 'deactivated' where id = $1 returning status""", authz_id) 50 | chal = { 51 | 'type': 'http-01', 52 | 'url': f'{settings.external_url}acme/challenges/{chal_id}', 53 | 'token': chal_token, 54 | 'status': chal_status, 55 | 'validated': chal_validated_at, 56 | } 57 | 58 | return { 59 | 'status': authz_status, 60 | 'expires': expires_at, 61 | 'identifier': {'type': 'dns', 'value': domain}, 62 | 'challenges': [{k: v for k, v in chal.items() if v is not None}], 63 | } 64 | else: 65 | raise ACMEException(status_code=status.HTTP_404_NOT_FOUND, exctype='malformed', detail='specified authorization not found for current account', new_nonce=data.new_nonce) 66 | 67 | 68 | @api.post('/new-authz') 69 | async def new_pre_authz(data: Annotated[RequestData, Depends(SignedRequest())]): 70 | raise ACMEException(status_code=status.HTTP_403_FORBIDDEN, exctype='unauthorized', detail='pre authorization is not supported', new_nonce=data.new_nonce) 71 | -------------------------------------------------------------------------------- /app/acme/certificate/cronjob.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import db 4 | import mail 5 | from config import settings 6 | from logger import logger 7 | 8 | 9 | async def start(): 10 | async def run(): 11 | while True: 12 | try: 13 | async with db.transaction(readonly=True) as sql: 14 | results = [ 15 | record 16 | async for record in sql( 17 | """ 18 | with 19 | expiring_domains as ( 20 | select authz.domain, acc.mail, cert.serial_number, cert.not_valid_after from certificates cert 21 | join orders ord on cert.order_id = ord.id 22 | join accounts acc on ord.account_id = acc.id 23 | join authorizations authz on authz.order_id = ord.id 24 | where acc.status = 'valid' and ord.status = 'valid' and cert.revoked_at is null and ( 25 | ($1::interval is not null and cert.not_valid_after > now() and cert.not_valid_after < now()+$1 and not cert.user_informed_cert_will_expire) 26 | or 27 | (cert.not_valid_after < now() and not cert.user_informed_cert_has_expired) 28 | ) 29 | order by authz.domain 30 | ), 31 | newest_domains as ( 32 | select authz.domain, max(cert.not_valid_after) as not_valid_after from orders ord 33 | join authorizations authz on authz.order_id = ord.id 34 | join certificates cert on cert.order_id = ord.id 35 | join expiring_domains exp on exp.domain = authz.domain 36 | group by authz.domain 37 | ) 38 | select expd.mail, expd.serial_number, expd.not_valid_after, expd.not_valid_after < now() as is_expired, array_agg(expd.domain) as domains 39 | from expiring_domains expd 40 | join newest_domains newd on expd.domain = newd.domain and expd.not_valid_after = newd.not_valid_after 41 | group by expd.mail, expd.serial_number, expd.not_valid_after 42 | having array_length(array_agg(expd.domain), 1) > 0 43 | """, 44 | settings.mail.warn_before_cert_expires, 45 | ) 46 | ] 47 | for mail_addr, serial_number, expires_at, is_expired, domains in results: 48 | if not is_expired and settings.mail.warn_before_cert_expires: 49 | try: 50 | await mail.send_certs_will_expire_warn_mail(receiver=mail_addr, domains=domains, expires_at=expires_at, serial_number=serial_number) 51 | ok = True 52 | except Exception: 53 | logger.error('could not send_certs_will_expire_warn_mail for "%s"', mail_addr, exc_info=True) 54 | ok = False 55 | if ok: 56 | async with db.transaction() as sql: 57 | await sql.exec("""update certificates set user_informed_cert_will_expire=true where serial_number=$1""", serial_number) 58 | if is_expired and settings.mail.notify_when_cert_expired: 59 | try: 60 | await mail.send_certs_expired_info_mail(receiver=mail_addr, domains=domains, expires_at=expires_at, serial_number=serial_number) 61 | ok = True 62 | except Exception: 63 | logger.error('could not send_certs_expired_info_mail for "%s"', mail_addr, exc_info=True) 64 | ok = False 65 | if ok: 66 | async with db.transaction() as sql: 67 | await sql.exec("""update certificates set user_informed_cert_has_expired=true where serial_number=$1""", serial_number) 68 | except Exception: 69 | logger.error('could not inform about expiring certificates', exc_info=True) 70 | finally: 71 | await asyncio.sleep(1 * 60 * 60) 72 | 73 | if settings.mail.notify_when_cert_expired or settings.mail.warn_before_cert_expires: 74 | asyncio.create_task(run()) 75 | -------------------------------------------------------------------------------- /app/acme/certificate/router.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from fastapi import APIRouter, Depends, Header, Response, status 4 | from jwcrypto.common import base64url_decode 5 | from pydantic import BaseModel, constr 6 | 7 | import db 8 | from ca import service as ca_service 9 | 10 | from ..exceptions import ACMEException 11 | from ..middleware import RequestData, SignedRequest 12 | from .service import SerialNumberConverter, parse_cert 13 | 14 | 15 | class RevokeCertPayload(BaseModel): 16 | certificate: constr(min_length=1, max_length=1 * 1024**2) 17 | reason: int | None = None # not evaluated 18 | 19 | 20 | api = APIRouter(tags=['acme:certificate']) 21 | 22 | 23 | @api.post('/certificates/{serial_number}', response_class=Response, responses={200: {'content': {'application/pem-certificate-chain': {}}}}) 24 | async def download_cert( 25 | response: Response, 26 | serial_number: constr(pattern='^[0-9A-F]+$'), 27 | data: Annotated[RequestData, Depends(SignedRequest())], 28 | accept: str = Header( 29 | default='*/*', pattern=r'(application/pem\-certificate\-chain|\*/\*)', description='Certificates are only supported as "application/pem-certificate-chain"' 30 | ), 31 | ): 32 | async with db.transaction(readonly=True) as sql: 33 | pem_chain = await sql.value( 34 | """ 35 | select cert.chain_pem from certificates cert 36 | join orders ord on cert.order_id = ord.id 37 | where cert.serial_number = $1 and ord.account_id = $2 38 | """, 39 | serial_number, 40 | data.account_id, 41 | ) 42 | if not pem_chain: 43 | raise ACMEException(status_code=status.HTTP_404_NOT_FOUND, exctype='malformed', detail='specified certificate not found for current account', new_nonce=data.new_nonce) 44 | return Response(content=pem_chain, headers=response.headers, media_type='application/pem-certificate-chain') 45 | 46 | 47 | @api.post('/revoke-cert', response_class=Response) 48 | async def revoke_cert(data: Annotated[RequestData[RevokeCertPayload], Depends(SignedRequest(RevokeCertPayload, allow_new_account=True))]): 49 | """ 50 | https://www.rfc-editor.org/rfc/rfc8555#section-7.6 51 | """ 52 | # this request might use account id or the account public key 53 | jwk_json: dict = data.key.export(as_dict=True) 54 | cert_bytes = base64url_decode(data.payload.certificate) 55 | cert = await parse_cert(cert_bytes) 56 | serial_number = SerialNumberConverter.int2hex(cert.serial_number) 57 | async with db.transaction(readonly=True) as sql: 58 | ok = await sql.value( 59 | """ 60 | select true from certificates c 61 | join orders o on o.id = c.order_id 62 | join accounts a on a.id = o.account_id 63 | where 64 | c.serial_number = $1 and c.revoked_at is null and 65 | ($2::text is null or (a.id = $2::text and a.status='valid')) and a.jwk=$3 66 | """, 67 | serial_number, 68 | data.account_id, 69 | jwk_json, 70 | ) 71 | if not ok: 72 | raise ACMEException(status_code=status.HTTP_400_BAD_REQUEST, exctype='alreadyRevoked', detail='cert already revoked or not accessible', new_nonce=data.new_nonce) 73 | async with db.transaction(readonly=True) as sql: 74 | revocations = [(sn, rev_at) async for sn, rev_at in sql("""select serial_number, revoked_at from certificates where revoked_at is not null""")] 75 | revoked_at = await sql.value("""select now()""") 76 | revocations = set(revocations) 77 | revocations.add((serial_number, revoked_at)) 78 | await ca_service.revoke_cert(serial_number=serial_number, revocations=revocations) 79 | async with db.transaction() as sql: 80 | await sql.exec( 81 | """update certificates set revoked_at = $2 where serial_number = $1 and revoked_at is null""", 82 | serial_number, 83 | revoked_at, 84 | ) 85 | -------------------------------------------------------------------------------- /app/acme/certificate/service.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from cryptography import x509 4 | from cryptography.hazmat.primitives import serialization 5 | from fastapi import status 6 | 7 | from ..exceptions import ACMEException 8 | 9 | 10 | class SerialNumberConverter: 11 | @staticmethod 12 | def int2hex(number: int): 13 | return hex(number)[2:].upper() 14 | 15 | @staticmethod 16 | def hex2int(number: str): 17 | return int(number, 16) 18 | 19 | 20 | async def check_csr(csr_der: bytes, ordered_domains: list[str], new_nonce: str | None = None): 21 | """ 22 | check csr and return contained values 23 | """ 24 | csr = await asyncio.to_thread(x509.load_der_x509_csr, csr_der) 25 | csr_pem_job = asyncio.to_thread(csr.public_bytes, serialization.Encoding.PEM) 26 | 27 | if not csr.is_signature_valid: 28 | raise ACMEException(status_code=status.HTTP_400_BAD_REQUEST, exctype='badCSR', detail='invalid signature', new_nonce=new_nonce) 29 | 30 | sans = csr.extensions.get_extension_for_oid(x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(x509.DNSName) 31 | csr_domains = set(sans) 32 | subject_candidates = csr.subject.get_attributes_for_oid(x509.oid.NameOID.COMMON_NAME) 33 | if subject_candidates: 34 | subject_domain = subject_candidates[0].value 35 | csr_domains.add(subject_domain) 36 | elif not sans: 37 | raise ACMEException(status_code=status.HTTP_400_BAD_REQUEST, exctype='badCSR', detail='subject and SANs cannot be both empty', new_nonce=new_nonce) 38 | else: 39 | subject_domain = sans[0] 40 | 41 | if csr_domains != set(ordered_domains): 42 | raise ACMEException(status_code=status.HTTP_400_BAD_REQUEST, exctype='badCSR', detail='domains in CSR does not match validated domains in ACME order', new_nonce=new_nonce) 43 | 44 | csr_pem: str = (await csr_pem_job).decode() 45 | return csr, csr_pem, subject_domain, csr_domains 46 | 47 | 48 | async def parse_cert(cert_der: bytes): 49 | cert = await asyncio.to_thread(x509.load_der_x509_certificate, cert_der) 50 | return cert 51 | -------------------------------------------------------------------------------- /app/acme/challenge/router.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from fastapi import APIRouter, Depends, Response, status 4 | 5 | import db 6 | from config import settings 7 | from logger import logger 8 | 9 | from ..exceptions import ACMEException 10 | from ..middleware import RequestData, SignedRequest 11 | from . import service 12 | 13 | api = APIRouter(tags=['acme:challenge']) 14 | 15 | 16 | @api.post('/challenges/{chal_id}') 17 | async def verify_challenge(response: Response, chal_id: str, data: Annotated[RequestData, Depends(SignedRequest())]): 18 | must_solve_challenge = False 19 | async with db.transaction() as sql: 20 | record = await sql.record( 21 | """ 22 | select chal.authz_id, chal.error, chal.status, authz.status, authz.domain, chal.validated_at, chal.token, ord.id, ord.status from challenges chal 23 | join authorizations authz on authz.id = chal.authz_id 24 | join orders ord on authz.order_id = ord.id 25 | where chal.id = $1 and ord.account_id = $2 and ord.expires_at > now() 26 | """, 27 | chal_id, 28 | data.account_id, 29 | ) 30 | if not record: 31 | raise ACMEException( 32 | status_code=status.HTTP_404_NOT_FOUND, 33 | exctype='malformed', 34 | detail='specified challenge not available for current account', 35 | new_nonce=data.new_nonce, 36 | ) 37 | authz_id, chal_err, chal_status, authz_status, domain, chal_validated_at, token, order_id, order_status = record 38 | if order_status == 'invalid': 39 | await sql.exec("""update authorizations set status = 'invalid' where id = $1""", authz_id) 40 | await sql.value( 41 | """ 42 | update challenges set status = 'invalid', error=row('unauthorized','order failed') 43 | where id = $1 and status <> 'invalid' 44 | """, 45 | chal_id, 46 | ) 47 | chal_status = 'invalid' 48 | if chal_status == 'pending' and order_status == 'pending': 49 | if authz_status == 'pending': 50 | must_solve_challenge = True 51 | chal_status = await sql.value("""update challenges set status = 'processing' where id = $1 returning status""", chal_id) 52 | else: 53 | await sql.value( 54 | """ 55 | update challenges set status='invalid', error=row('unauthorized','authorization failed') 56 | where id = $1 and status <> 'invalid' 57 | """, 58 | chal_id, 59 | ) 60 | chal_status = 'invalid' 61 | if chal_err: 62 | acme_error = ACMEException(exctype=chal_err.get('type'), detail=chal_err.get('detail'), new_nonce=data.new_nonce) 63 | else: 64 | acme_error = None 65 | 66 | # use append because there can be multiple Link-Headers with different rel targets 67 | response.headers.append('Link', f'<{settings.external_url}authorization/{authz_id}>;rel="up"') 68 | 69 | if must_solve_challenge: 70 | try: 71 | await service.check_challenge_is_fulfilled(domain=domain, token=token, jwk=data.key, new_nonce=data.new_nonce) 72 | err = False 73 | except ACMEException as e: 74 | err = e 75 | except Exception as e: 76 | err = ACMEException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, exctype='serverInternal', detail=str(e), new_nonce=data.new_nonce) 77 | logger.warning('challenge failed for %s (account: %s)', domain, data.account_id, exc_info=True) 78 | if err is False: 79 | async with db.transaction() as sql: 80 | chal_status, chal_validated_at = await sql.record( 81 | """ 82 | update challenges set validated_at=now(), status = 'valid' 83 | where id = $1 and status='processing' returning status, validated_at 84 | """, 85 | chal_id, 86 | ) 87 | await sql.exec( 88 | """update authorizations set status = 'valid' where id = $1 and status = 'pending'""", 89 | authz_id, 90 | ) 91 | await sql.exec( 92 | """ 93 | update orders set status='ready' where id = $1 and status='pending' and 94 | (select count(id) from authorizations where order_id = $1 and status <> 'valid') = 0 95 | """, 96 | order_id, 97 | ) # set order to ready if all authzs are valid 98 | else: 99 | acme_error = err 100 | async with db.transaction() as sql: 101 | chal_status = await sql.value( 102 | """update challenges set status = 'invalid', error=row($2,$3) where id = $1 returning status""", 103 | chal_id, 104 | err.exc_type, 105 | err.detail, 106 | ) 107 | await sql.exec("""update authorizations set status = 'invalid' where id = $1""", authz_id) 108 | await sql.exec( 109 | """update orders set status = 'invalid', error=row('unauthorized', 'challenge failed') where id = $1""", 110 | order_id, 111 | ) 112 | 113 | return { 114 | 'type': 'http-01', 115 | 'url': f'{settings.external_url}acme/challenges/{chal_id}', 116 | 'status': chal_status, 117 | 'validated': chal_validated_at, 118 | 'token': token, 119 | 'error': acme_error.value if acme_error else None, 120 | } 121 | -------------------------------------------------------------------------------- /app/acme/challenge/service.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import httpx 4 | import jwcrypto.jwk 5 | from fastapi import status 6 | 7 | from ..exceptions import ACMEException 8 | 9 | 10 | async def check_challenge_is_fulfilled(*, domain: str, token: str, jwk: jwcrypto.jwk.JWK, new_nonce: str = None): 11 | for _ in range(3): # 3x retry 12 | err: bool | ACMEException = True 13 | try: 14 | async with httpx.AsyncClient( 15 | timeout=10, 16 | # only http 1.0/1.1 is required, not https 17 | verify=False, # noqa: S501 (https is intentionally disabled) 18 | http1=True, 19 | http2=False, 20 | # todo: redirects are forbidden for now, but RFC states redirects should be supported 21 | follow_redirects=False, 22 | trust_env=False, # do not load proxy information from env vars 23 | ) as client: 24 | res = await client.get(f'http://{domain}:80/.well-known/acme-challenge/{token}') 25 | if res.status_code == 200 and res.text.rstrip() == f'{token}.{jwk.thumbprint()}': 26 | err = False 27 | else: 28 | err = ACMEException( 29 | status_code=status.HTTP_400_BAD_REQUEST, 30 | exctype='incorrectResponse', 31 | detail='presented token does not match challenge', 32 | new_nonce=new_nonce, 33 | ) 34 | except httpx.ConnectTimeout: 35 | err = ACMEException(status_code=status.HTTP_400_BAD_REQUEST, exctype='connection', detail='timeout', new_nonce=new_nonce) 36 | except httpx.ConnectError: 37 | err = ACMEException(status_code=status.HTTP_400_BAD_REQUEST, exctype='dns', detail='could not resolve address', new_nonce=new_nonce) 38 | except Exception: 39 | err = ACMEException(status_code=status.HTTP_400_BAD_REQUEST, exctype='serverInternal', detail='could not validate challenge', new_nonce=new_nonce) 40 | if err is False: 41 | return # check successful 42 | await asyncio.sleep(3) 43 | raise err 44 | -------------------------------------------------------------------------------- /app/acme/directory/router.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from config import settings 4 | 5 | api = APIRouter(tags=['acme:directory']) 6 | 7 | 8 | @api.get('/directory') 9 | async def get_directory(): 10 | """ 11 | See RFC 8555 7.1.1 "Directory" 12 | """ 13 | 14 | meta = {'website': settings.external_url} 15 | if settings.acme.terms_of_service_url: 16 | meta['termsOfService'] = settings.acme.terms_of_service_url 17 | return { 18 | 'newNonce': f'{settings.external_url}acme/new-nonce', 19 | 'newAccount': f'{settings.external_url}acme/new-account', 20 | 'newOrder': f'{settings.external_url}acme/new-order', 21 | 'revokeCert': f'{settings.external_url}acme/revoke-cert', 22 | 'keyChange': f'{settings.external_url}acme/key-change', 23 | # newAuthz: is not supported 24 | 'meta': meta, 25 | } 26 | -------------------------------------------------------------------------------- /app/acme/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from fastapi import status 4 | from fastapi.responses import JSONResponse 5 | 6 | from config import settings 7 | 8 | AcmeExceptionTypes = Literal[ 9 | 'accountDoesNotExist', 10 | 'alreadyRevoked', 11 | 'badCSR', 12 | 'badNonce', 13 | 'badPublicKey', 14 | 'badRevocationReason', 15 | 'badSignatureAlgorithm', 16 | 'caa', 17 | 'compound', 18 | 'connection', 19 | 'dns', 20 | 'externalAccountRequired', 21 | 'incorrectResponse', 22 | 'invalidContact', 23 | 'malformed', 24 | 'orderNotReady', 25 | 'rateLimited', 26 | 'rejectedIdentifier', 27 | 'serverInternal', 28 | 'tls', 29 | 'unauthorized', 30 | 'unsupportedContact', 31 | 'unsupportedIdentifier', 32 | 'userActionRequired', 33 | ] 34 | 35 | 36 | class ACMEException(Exception): 37 | exc_type: AcmeExceptionTypes 38 | detail: str 39 | headers: dict[str, str] 40 | status_code: int 41 | new_nonce: str | None 42 | 43 | def __init__( 44 | self, 45 | *, 46 | exctype: AcmeExceptionTypes, 47 | detail: str = '', 48 | status_code: int = status.HTTP_400_BAD_REQUEST, 49 | new_nonce: str | None = None, 50 | ) -> None: 51 | self.headers = {'Link': f'<{settings.external_url}acme/directory>;rel="index"'} 52 | # when a new nonce is already created it should also be used in the exception case 53 | # however if there is none yet, a new one gets generated in as_response() 54 | self.new_nonce = new_nonce 55 | self.exc_type = exctype 56 | self.detail = detail 57 | self.status_code = status_code 58 | 59 | @property 60 | def value(self): 61 | return {'type': 'urn:ietf:params:acme:error:' + self.exc_type, 'detail': self.detail} 62 | 63 | async def as_response(self): 64 | if not self.new_nonce: 65 | from .nonce.service import generate as generate_nonce # import here to prevent circular import # pylint: disable=import-outside-toplevel 66 | 67 | self.new_nonce = await generate_nonce() 68 | return JSONResponse( 69 | status_code=self.status_code, 70 | content=self.value, 71 | headers=dict(self.headers, **{'Replay-Nonce': self.new_nonce}), 72 | media_type='application/problem+json', 73 | ) 74 | 75 | def __repr__(self) -> str: 76 | return f'ACME-Exception({self.value})' 77 | -------------------------------------------------------------------------------- /app/acme/middleware.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Generic, Literal, TypeVar 3 | 4 | import jwcrypto.jwk 5 | import jwcrypto.jws 6 | from fastapi import Body, Header, Request, Response, status 7 | from jwcrypto.common import base64url_decode 8 | from pydantic import AnyHttpUrl, BaseModel, ConfigDict, constr, model_validator 9 | 10 | import db 11 | from config import settings 12 | 13 | from .exceptions import ACMEException 14 | from .nonce import service as nonce_service 15 | 16 | 17 | class RsaJwk(BaseModel): 18 | n: constr(min_length=1) 19 | e: constr(min_length=1) 20 | kty: Literal['RSA'] 21 | 22 | 23 | class EcJwk(BaseModel): 24 | crv: Literal['P-256'] 25 | x: constr(min_length=1) 26 | y: constr(min_length=1) 27 | kty: Literal['EC'] 28 | 29 | 30 | PayloadT = TypeVar('PayloadT') 31 | 32 | 33 | class RequestData(BaseModel, Generic[PayloadT]): 34 | payload: PayloadT 35 | key: jwcrypto.jwk.JWK 36 | account_id: str | None = None # None if account does not exist 37 | new_nonce: str 38 | 39 | model_config = ConfigDict(arbitrary_types_allowed=True) 40 | 41 | 42 | class Protected(BaseModel): 43 | # see https://www.rfc-editor.org/rfc/rfc8555#section-6.2 44 | alg: Literal['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512'] 45 | jwk: RsaJwk | EcJwk | None = None # new user 46 | kid: str | None = None # existing user 47 | nonce: constr(min_length=1) 48 | url: AnyHttpUrl 49 | 50 | @model_validator(mode='after') 51 | def valid_check(self) -> 'Protected': 52 | if not self.jwk and not self.kid: 53 | raise ACMEException(status_code=status.HTTP_400_BAD_REQUEST, exctype='malformed', detail='either jwk or kid must be set') 54 | if self.jwk and self.kid: 55 | raise ACMEException(status_code=status.HTTP_400_BAD_REQUEST, exctype='malformed', detail='the fields jwk and kid are mutually exclusive') 56 | return self 57 | 58 | 59 | class SignedRequest: # pylint: disable=too-few-public-methods 60 | def __init__( 61 | self, 62 | payload_model: BaseModel = None, 63 | *, 64 | allow_new_account: bool = False, 65 | allow_blocked_account: bool = False, 66 | ): 67 | self.allow_new_account = allow_new_account 68 | self.allow_blocked_account = allow_blocked_account 69 | self.payload_model = payload_model 70 | 71 | @staticmethod 72 | def _schemeless_url(url: str): 73 | if url.startswith('https://'): 74 | return url.removeprefix('https://') 75 | elif url.startswith('http://'): 76 | return url.removeprefix('http://') 77 | return url 78 | 79 | async def __call__( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals 80 | self, 81 | request: Request, 82 | response: Response, 83 | content_type: str = Header(..., pattern=r'^application/jose\+json$', description='Content Type must be "application/jose+json"'), 84 | protected: constr(min_length=1) = Body(...), 85 | signature: constr(min_length=1) = Body(...), 86 | payload: constr(min_length=0) = Body(...), 87 | ): 88 | protected_data = Protected(**json.loads(base64url_decode(protected))) 89 | 90 | # Scheme might be different because of reverse proxy forwarding 91 | if self._schemeless_url(str(protected_data.url)) != self._schemeless_url(str(request.url)): 92 | raise ACMEException(status_code=status.HTTP_400_BAD_REQUEST, exctype='unauthorized', detail='Requested URL does not match with actually called URL') 93 | 94 | if protected_data.kid: # account exists 95 | base_url = f'{settings.external_url}acme/accounts/' 96 | if not protected_data.kid.startswith(base_url): 97 | raise ACMEException(status_code=status.HTTP_400_BAD_REQUEST, exctype='malformed', detail=f'JWS invalid: kid must start with: "{base_url}"') 98 | 99 | account_id = protected_data.kid.split('/')[-1] 100 | if account_id: 101 | async with db.transaction(readonly=True) as sql: 102 | if self.allow_blocked_account: 103 | key_data = await sql.value("""select jwk from accounts where id = $1""", account_id) 104 | else: 105 | key_data = await sql.value("""select jwk from accounts where id = $1 and status = 'valid'""", account_id) 106 | else: 107 | key_data = None 108 | if not key_data: 109 | raise ACMEException(status_code=status.HTTP_400_BAD_REQUEST, exctype='accountDoesNotExist', detail='unknown, deactivated or revoked account') 110 | key = jwcrypto.jwk.JWK() 111 | key.import_key(**key_data) 112 | elif self.allow_new_account: 113 | account_id = None 114 | key = jwcrypto.jwk.JWK() 115 | key.import_key(**protected_data.jwk.model_dump()) 116 | else: 117 | raise ACMEException(status_code=status.HTTP_400_BAD_REQUEST, exctype='accountDoesNotExist', detail='unknown account. not accepting new accounts') 118 | 119 | jws = jwcrypto.jws.JWS() 120 | if 'none' in jws.allowed_algs: 121 | raise ValueError('"none" is a forbidden JWS algorithm!') 122 | try: 123 | # signature is checked here 124 | jws.deserialize(await request.body(), key) 125 | except jwcrypto.jws.InvalidJWSSignature as exc: 126 | raise ACMEException(status_code=status.HTTP_403_FORBIDDEN, exctype='unauthorized', detail='signature check failed') from exc 127 | 128 | if self.payload_model and payload: 129 | payload_data = self.payload_model(**json.loads(base64url_decode(payload))) 130 | else: 131 | payload_data = None 132 | 133 | new_nonce = await nonce_service.refresh(protected_data.nonce) 134 | 135 | response.headers['Replay-Nonce'] = new_nonce 136 | # use append because there can be multiple Link-Headers with different rel targets 137 | response.headers.append('Link', f'<{settings.external_url}acme/directory>;rel="index"') 138 | 139 | return RequestData[self.payload_model](payload=payload_data, key=key, account_id=account_id, new_nonce=new_nonce) 140 | -------------------------------------------------------------------------------- /app/acme/nonce/cronjob.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import db 4 | from logger import logger 5 | 6 | 7 | async def start(): 8 | async def run(): 9 | while True: 10 | try: 11 | async with db.transaction() as sql: 12 | await sql.exec("""delete from nonces where expires_at < now()""") 13 | except Exception: 14 | logger.error('could not purge old nonces', exc_info=True) 15 | finally: 16 | await asyncio.sleep(1 * 60 * 60) 17 | 18 | asyncio.create_task(run()) 19 | -------------------------------------------------------------------------------- /app/acme/nonce/router.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Response, status 2 | 3 | from config import settings 4 | 5 | from .service import generate 6 | 7 | api = APIRouter(tags=['acme:nonce']) 8 | 9 | 10 | @api.head('/new-nonce', status_code=status.HTTP_200_OK) 11 | @api.get('/new-nonce', status_code=status.HTTP_204_NO_CONTENT) 12 | async def get_nonce(response: Response): 13 | """ 14 | See RFC 8555 7.2 "Getting a Nonce" 15 | """ 16 | response.headers['Replay-Nonce'] = await generate() 17 | response.headers['Cache-Control'] = 'no-store' 18 | response.headers['Link'] = f'<{settings.external_url}acme/directory>;rel="index"' 19 | -------------------------------------------------------------------------------- /app/acme/nonce/service.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | 3 | from fastapi import status 4 | 5 | import db 6 | 7 | from ..exceptions import ACMEException 8 | 9 | 10 | async def generate() -> str: 11 | nonce = secrets.token_urlsafe(32) 12 | async with db.transaction() as sql: 13 | await sql.exec("""insert into nonces (id) values ($1)""", nonce) 14 | return nonce 15 | 16 | 17 | async def refresh(nonce: str) -> str: 18 | new_nonce = secrets.token_urlsafe(32) 19 | async with db.transaction() as sql: 20 | old_nonce_ok = await sql.exec("""delete from nonces where id = $1""", nonce) == 'DELETE 1' 21 | await sql.exec("""insert into nonces (id) values ($1)""", new_nonce) 22 | if not old_nonce_ok: 23 | raise ACMEException(status_code=status.HTTP_400_BAD_REQUEST, exctype='badNonce', detail='old nonce is wrong', new_nonce=new_nonce) 24 | return new_nonce 25 | -------------------------------------------------------------------------------- /app/acme/order/router.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import secrets 3 | from datetime import datetime 4 | from typing import Annotated, Literal, Optional 5 | 6 | from fastapi import APIRouter, Depends, Response, status 7 | from jwcrypto.common import base64url_decode 8 | from pydantic import BaseModel, conlist, constr 9 | 10 | import db 11 | from ca import service as ca_service 12 | from config import settings 13 | from logger import logger 14 | 15 | from ..certificate.service import SerialNumberConverter, check_csr 16 | from ..exceptions import ACMEException 17 | from ..middleware import RequestData, SignedRequest 18 | 19 | 20 | class NewOrderDomain(BaseModel): 21 | type: Literal['dns'] # noqa: A003 (allow shadowing builtin "type") 22 | value: constr(pattern=f'^{settings.acme.target_domain_regex.pattern}$') 23 | 24 | 25 | class NewOrderPayload(BaseModel): 26 | identifiers: conlist(NewOrderDomain, min_length=1) 27 | notBefore: Optional[datetime] = None 28 | notAfter: Optional[datetime] = None 29 | 30 | 31 | class FinalizeOrderPayload(BaseModel): 32 | csr: constr(min_length=1, max_length=1 * 1024**2) 33 | 34 | 35 | def order_response( 36 | *, 37 | status: str, 38 | expires_at: datetime, 39 | domains: list[str], 40 | authz_ids: list[str], 41 | order_id: str, 42 | error: Optional[ACMEException] = None, 43 | not_valid_before: Optional[datetime] = None, 44 | not_valid_after: Optional[datetime] = None, 45 | cert_serial_number: Optional[str] = None, 46 | ): 47 | return { 48 | 'status': status, 49 | 'expires': expires_at, 50 | 'identifiers': [{'type': 'dns', 'value': domain} for domain in domains], 51 | 'authorizations': [f'{settings.external_url}acme/authorizations/{authz_id}' for authz_id in authz_ids], 52 | 'finalize': f'{settings.external_url}acme/orders/{order_id}/finalize', 53 | 'error': error.value if error else None, 54 | 'notBefore': not_valid_before, 55 | 'notAfter': not_valid_after, 56 | 'certificate': f'{settings.external_url}acme/certificates/{cert_serial_number}' if cert_serial_number else None, 57 | } 58 | 59 | 60 | api = APIRouter(tags=['acme:order']) 61 | 62 | 63 | @api.post('/new-order', status_code=status.HTTP_201_CREATED) 64 | async def submit_order(response: Response, data: Annotated[RequestData[NewOrderPayload], Depends(SignedRequest(NewOrderPayload))]): 65 | if data.payload.notBefore is not None or data.payload.notAfter is not None: 66 | raise ACMEException( 67 | exctype='malformed', 68 | detail='Parameter notBefore and notAfter may not be specified as the constraints might cannot be enforced.', 69 | new_nonce=data.new_nonce, 70 | ) 71 | 72 | domains: list[str] = [identifier.value for identifier in data.payload.identifiers] 73 | 74 | def generate_tokens_sync(domains): 75 | order_id = secrets.token_urlsafe(16) 76 | authz_ids = {domain: secrets.token_urlsafe(16) for domain in domains} 77 | chal_ids = {domain: secrets.token_urlsafe(16) for domain in domains} 78 | chal_tkns = {domain: secrets.token_urlsafe(32) for domain in domains} 79 | return order_id, authz_ids, chal_ids, chal_tkns 80 | 81 | order_id, authz_ids, chal_ids, chal_tkns = await asyncio.to_thread(generate_tokens_sync, domains) 82 | 83 | async with db.transaction() as sql: 84 | order_status, expires_at = await sql.record( 85 | """insert into orders (id, account_id) values ($1, $2) returning status, expires_at""", 86 | order_id, 87 | data.account_id, 88 | ) 89 | await sql.execmany( 90 | """insert into authorizations (id, order_id, domain) values ($1, $2, $3)""", 91 | *[(authz_ids[domain], order_id, domain) for domain in domains], 92 | ) 93 | await sql.execmany( 94 | """insert into challenges (id, authz_id, token) values ($1, $2, $3)""", 95 | *[(chal_ids[domain], authz_ids[domain], chal_tkns[domain]) for domain in domains], 96 | ) 97 | 98 | response.headers['Location'] = f'{settings.external_url}acme/orders/{order_id}' 99 | return order_response( 100 | status=order_status, 101 | expires_at=expires_at, 102 | domains=domains, 103 | authz_ids=authz_ids.values(), 104 | order_id=order_id, 105 | ) 106 | 107 | 108 | @api.post('/orders/{order_id}') 109 | async def view_order(response: Response, order_id: str, data: Annotated[RequestData, Depends(SignedRequest())]): 110 | async with db.transaction(readonly=True) as sql: 111 | record = await sql.record( 112 | """select status, expires_at, error from orders where id = $1 and account_id = $2""", 113 | order_id, 114 | data.account_id, 115 | ) 116 | if not record: 117 | raise ACMEException(status_code=status.HTTP_404_NOT_FOUND, exctype='malformed', detail='specified order not found for current account', new_nonce=data.new_nonce) 118 | order_status, expires_at, err = record 119 | authzs = [row async for row in sql("""select id, domain from authorizations where order_id = $1""", order_id)] 120 | cert_record = await sql.record("""select serial_number, not_valid_before, not_valid_after from certificates where order_id = $1""", order_id) 121 | if cert_record: 122 | cert_sn, not_valid_before, not_valid_after = cert_record 123 | if err: 124 | acme_error = ACMEException(exctype=err.get('type'), detail=err.get('detail'), new_nonce=data.new_nonce) 125 | else: 126 | acme_error = None 127 | 128 | response.headers['Location'] = f'{settings.external_url}acme/orders/{order_id}' # see #139 129 | return order_response( 130 | status=order_status, 131 | expires_at=expires_at, 132 | domains=[domain for _, domain in authzs], 133 | authz_ids=[authz_id for authz_id, _ in authzs], 134 | order_id=order_id, 135 | not_valid_before=not_valid_before if cert_record else None, 136 | not_valid_after=not_valid_after if cert_record else None, 137 | cert_serial_number=cert_sn if cert_record else None, 138 | error=acme_error, 139 | ) 140 | 141 | 142 | @api.post('/orders/{order_id}/finalize') 143 | async def finalize_order(response: Response, order_id: str, data: Annotated[RequestData[FinalizeOrderPayload], Depends(SignedRequest(FinalizeOrderPayload))]): 144 | async with db.transaction(readonly=True) as sql: 145 | record = await sql.record( 146 | """ 147 | select status, expires_at, expires_at <= now() as is_expired from orders ord 148 | where ord.id = $1 and ord.account_id = $2 149 | """, 150 | order_id, 151 | data.account_id, 152 | ) 153 | if not record: 154 | raise ACMEException(status_code=status.HTTP_404_NOT_FOUND, exctype='malformed', detail='Unknown order for specified account.', new_nonce=data.new_nonce) 155 | order_status, expires_at, is_expired = record 156 | if order_status != 'ready': 157 | raise ACMEException(status_code=status.HTTP_403_FORBIDDEN, exctype='orderNotReady', detail=f'order status is: {order_status}', new_nonce=data.new_nonce) 158 | if is_expired: 159 | async with db.transaction() as sql: 160 | await sql.exec( 161 | """ 162 | update orders set status='invalid', error=row('unauthorized','order expired') 163 | where id = $1 and status <> 'invalid' 164 | """, 165 | order_id, 166 | ) 167 | await sql.exec("""update authorizations set status='expired' where order_id = $1""", order_id) 168 | raise ACMEException(status_code=status.HTTP_403_FORBIDDEN, exctype='orderNotReady', detail='order expired', new_nonce=data.new_nonce) 169 | else: 170 | async with db.transaction() as sql: 171 | await sql.exec("""update orders set status='processing' where id = $1 and status = 'ready'""", order_id) 172 | 173 | async with db.transaction(readonly=True) as sql: 174 | records = [(authz_id, domain) async for authz_id, domain, *_ in sql("""select id, domain from authorizations where order_id = $1 and status = 'valid'""", order_id)] 175 | domains = [domain for authz_id, domain in records] 176 | authz_ids = [authz_id for authz_id, domain in records] 177 | 178 | csr_bytes = base64url_decode(data.payload.csr) 179 | 180 | csr, csr_pem, subject_domain, san_domains = await check_csr(csr_bytes, ordered_domains=domains, new_nonce=data.new_nonce) 181 | 182 | try: 183 | signed_cert = await ca_service.sign_csr(csr, subject_domain, san_domains) 184 | err = False 185 | except ACMEException as e: 186 | err = e 187 | except Exception as e: 188 | err = ACMEException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, exctype='serverInternal', detail=str(e), new_nonce=data.new_nonce) 189 | logger.warning('sign csr failed (account: %s)', data.account_id, exc_info=True) 190 | 191 | if err is False: 192 | cert_sn = SerialNumberConverter.int2hex(signed_cert.cert.serial_number) 193 | 194 | async with db.transaction() as sql: 195 | not_valid_before, not_valid_after = await sql.record( 196 | """ 197 | insert into certificates (serial_number, csr_pem, chain_pem, order_id, not_valid_before, not_valid_after) 198 | values ($1, $2, $3, $4, $5, $6) returning not_valid_before, not_valid_after 199 | """, 200 | cert_sn, 201 | csr_pem, 202 | signed_cert.cert_chain_pem, 203 | order_id, 204 | signed_cert.cert.not_valid_before_utc, 205 | signed_cert.cert.not_valid_after_utc, 206 | ) 207 | order_status = await sql.value( 208 | """update orders set status='valid' where id = $1 and status='processing' returning status""", 209 | order_id, 210 | ) 211 | else: 212 | cert_sn = not_valid_before = not_valid_after = None 213 | async with db.transaction() as sql: 214 | order_status = await sql.value( 215 | """update orders set status='invalid', error=row($2,$3) where id = $1 returning status""", 216 | order_id, 217 | err.exc_type, 218 | err.detail, 219 | ) 220 | 221 | response.headers['Location'] = f'{settings.external_url}acme/orders/{order_id}' # see #139 222 | return order_response( 223 | status=order_status, 224 | expires_at=expires_at, 225 | domains=domains, 226 | authz_ids=authz_ids, 227 | order_id=order_id, 228 | not_valid_before=not_valid_before, 229 | not_valid_after=not_valid_after, 230 | cert_serial_number=cert_sn, 231 | error=err, 232 | ) 233 | -------------------------------------------------------------------------------- /app/ca/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from cryptography import x509 4 | from cryptography.hazmat.primitives import serialization 5 | from fastapi import APIRouter, Response 6 | from pydantic import constr 7 | 8 | import db 9 | from acme.certificate.service import SerialNumberConverter 10 | from config import settings 11 | from logger import logger 12 | 13 | router = APIRouter(prefix='/ca', tags=['ca']) 14 | 15 | if settings.ca.enabled: 16 | from cryptography.fernet import Fernet # pylint: disable=ungrouped-imports 17 | 18 | from . import cronjob 19 | from .service import build_crl_sync 20 | 21 | @router.get('/{serial_number}/crl', response_class=Response, responses={200: {'content': {'application/pkix-crl': {}}}}) 22 | async def download_crl(serial_number: constr(pattern='^[0-9A-F]+$')): 23 | async with db.transaction(readonly=True) as sql: 24 | crl_pem = await sql.value("""select crl_pem from cas where serial_number = $1""", serial_number) 25 | return Response(content=crl_pem, media_type='application/pkix-crl') 26 | 27 | async def init(): 28 | if (settings.ca.import_dir / 'ca.pem').is_file() and (settings.ca.import_dir / 'ca.key').is_file(): 29 | with open(settings.ca.import_dir / 'ca.key', 'rb') as f: 30 | ca_key_bytes = f.read() 31 | ca_key = serialization.load_pem_private_key(ca_key_bytes, None) 32 | f = Fernet(settings.ca.encryption_key.get_secret_value()) 33 | ca_key_enc = f.encrypt(ca_key_bytes) 34 | 35 | with open(settings.ca.import_dir / 'ca.pem', 'rb') as f: 36 | ca_cert_bytes = f.read() 37 | ca_cert = x509.load_pem_x509_certificate(ca_cert_bytes, None) 38 | serial_number = SerialNumberConverter.int2hex(ca_cert.serial_number) 39 | 40 | async with db.transaction(readonly=True) as sql: 41 | revocations = [record async for record in sql("""select serial_number, revoked_at from certificates where revoked_at is not null""")] 42 | _, crl_pem = await asyncio.to_thread(build_crl_sync, ca_key=ca_key, ca_cert=ca_cert, revocations=revocations) 43 | 44 | async with db.transaction() as sql: 45 | await sql.exec("""update cas set active = false""") 46 | await sql.exec( 47 | """ 48 | insert into cas (serial_number, cert_pem, key_pem_enc, active, crl_pem) 49 | values ($1, $2, $3, true, $4) 50 | on conflict (serial_number) do update set active = true, crl_pem = $4 51 | """, 52 | serial_number, 53 | ca_cert_bytes.decode(), 54 | ca_key_enc, 55 | crl_pem, 56 | ) 57 | logger.info('Successfully imported CA provided in "%s" folder', settings.ca.import_dir) 58 | else: 59 | async with db.transaction() as sql: 60 | ok = await sql.value("""select count(serial_number)=1 from cas where active=true""") 61 | if not ok: 62 | raise ValueError('internal ca is enabled but no CA certificate is registered and active. Please import one first.') 63 | 64 | await cronjob.start() 65 | else: 66 | 67 | async def init(): 68 | logger.info('Builtin CA is disabled, relying on custom CA implementation') 69 | -------------------------------------------------------------------------------- /app/ca/cronjob.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import db 4 | from logger import logger 5 | 6 | from .service import build_crl_sync, load_ca_sync 7 | 8 | 9 | async def start(): 10 | async def run(): 11 | while True: 12 | try: 13 | async with db.transaction(readonly=True) as sql: 14 | cas = [record async for record in sql("""select serial_number, cert_pem, key_pem_enc from cas""")] 15 | for sn, cert_pem, key_pem_enc in cas: 16 | ca_cert, ca_key = await asyncio.to_thread(load_ca_sync, cert_pem=cert_pem, key_pem_enc=key_pem_enc) 17 | # todo: maybe also include expired certs # pylint: disable=fixme 18 | async with db.transaction(readonly=True) as sql: 19 | revocations = [record async for record in sql("""select serial_number, revoked_at from certificates where revoked_at is not null""")] 20 | _, crl_pem = await asyncio.to_thread(build_crl_sync, ca_key=ca_key, ca_cert=ca_cert, revocations=revocations) 21 | async with db.transaction() as sql: 22 | await sql.exec("""update cas set crl_pem = $1 where serial_number = $2""", crl_pem, sn) 23 | except Exception: 24 | logger.error('could not rebuild crl', exc_info=True) 25 | finally: 26 | await asyncio.sleep(12 * 60 * 60) # rebuild crl every 12h 27 | 28 | asyncio.create_task(run()) 29 | -------------------------------------------------------------------------------- /app/ca/model.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from cryptography import x509 4 | 5 | 6 | @dataclass 7 | class SignedCertInfo: 8 | cert: x509.Certificate 9 | cert_chain_pem: str 10 | -------------------------------------------------------------------------------- /app/ca/service.py: -------------------------------------------------------------------------------- 1 | # this file can be overwritten to provide a custom ca implementation 2 | # the methods sign_csr() and revoke_cert() must be implemented with matching function signatures 3 | # set env var CA_ENABLED=False when providing a custom ca implementation 4 | 5 | import asyncio 6 | from datetime import datetime, timezone 7 | 8 | from cryptography import x509 9 | from cryptography.fernet import Fernet 10 | from cryptography.hazmat.primitives import hashes, serialization 11 | from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes 12 | 13 | import db 14 | from acme.certificate.service import SerialNumberConverter 15 | from config import settings 16 | 17 | from .model import SignedCertInfo 18 | 19 | 20 | async def sign_csr(csr: x509.CertificateSigningRequest, subject_domain: str, san_domains: list[str]) -> SignedCertInfo: 21 | """ 22 | csr: the parsed csr object 23 | subject_domain: the main requested domain name 24 | san_domains: the alternative (additional) requested domain names 25 | """ 26 | if not settings.ca.enabled: 27 | raise Exception('internal ca is not enabled (env var CA_ENABLED)! Please provide a custom ca implementation') # pylint: disable=broad-exception-raised 28 | 29 | ca_cert, ca_key = await load_active_ca() 30 | 31 | cert, cert_chain_pem = await asyncio.to_thread(generate_cert_sync, ca_key=ca_key, ca_cert=ca_cert, csr=csr, subject_domain=subject_domain, san_domains=san_domains) 32 | 33 | return SignedCertInfo(cert=cert, cert_chain_pem=cert_chain_pem) 34 | 35 | 36 | async def revoke_cert(serial_number: str, revocations: set[tuple[str, datetime]]) -> None: # pylint: disable=unused-argument 37 | if not settings.ca.enabled: 38 | raise Exception('internal ca is not enabled (env var CA_ENABLED)! Please provide a custom ca implementation') # pylint: disable=broad-exception-raised 39 | ca_cert, ca_key = await load_active_ca() 40 | _, crl_pem = await asyncio.to_thread(build_crl_sync, ca_key=ca_key, ca_cert=ca_cert, revocations=revocations) 41 | async with db.transaction() as sql: 42 | await sql.exec("""update cas set crl_pem = $1 where active = true""", crl_pem) 43 | 44 | 45 | async def load_active_ca(): 46 | async with db.transaction(readonly=True) as sql: 47 | cert_pem, key_pem_enc = await sql.record("""select cert_pem, key_pem_enc from cas where active = true""") 48 | return await asyncio.to_thread(load_ca_sync, cert_pem=cert_pem, key_pem_enc=key_pem_enc) 49 | 50 | 51 | def load_ca_sync(*, cert_pem, key_pem_enc): 52 | f = Fernet(settings.ca.encryption_key.get_secret_value()) 53 | key_pem = f.decrypt(key_pem_enc) 54 | ca_key = serialization.load_pem_private_key(key_pem, None) 55 | ca_cert = x509.load_pem_x509_certificate(cert_pem.encode(), None) 56 | return ca_cert, ca_key 57 | 58 | 59 | def generate_cert_sync(*, ca_key: PrivateKeyTypes, ca_cert: x509.Certificate, csr: x509.CertificateSigningRequest, subject_domain: str, san_domains: list[str]): 60 | ca_id = SerialNumberConverter.int2hex(ca_cert.serial_number) 61 | 62 | cert_builder = ( 63 | x509.CertificateBuilder( 64 | issuer_name=ca_cert.subject, 65 | subject_name=x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, subject_domain)]), 66 | serial_number=x509.random_serial_number(), 67 | not_valid_before=datetime.now(timezone.utc), 68 | not_valid_after=datetime.now(timezone.utc) + settings.ca.cert_lifetime, 69 | public_key=csr.public_key(), 70 | ) 71 | .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) 72 | .add_extension( 73 | x509.CRLDistributionPoints( 74 | distribution_points=[ 75 | x509.DistributionPoint( 76 | full_name=[x509.UniformResourceIdentifier(str(settings.external_url).removesuffix('/') + f'/ca/{ca_id}/crl')], 77 | relative_name=None, 78 | reasons=None, 79 | crl_issuer=None, 80 | ) 81 | ] 82 | ), 83 | critical=False, 84 | ) 85 | .add_extension(x509.SubjectAlternativeName(general_names=[x509.DNSName(domain) for domain in san_domains]), critical=False) 86 | .add_extension( 87 | x509.KeyUsage( 88 | digital_signature=True, 89 | content_commitment=False, 90 | key_encipherment=True, 91 | data_encipherment=False, 92 | key_agreement=False, 93 | key_cert_sign=False, 94 | crl_sign=False, 95 | encipher_only=False, 96 | decipher_only=False, 97 | ), 98 | critical=True, 99 | ) 100 | .add_extension(x509.ExtendedKeyUsage(usages=[x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH, x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]), critical=False) 101 | ) 102 | 103 | cert = cert_builder.sign(private_key=ca_key, algorithm=hashes.SHA512()) 104 | 105 | cert_pem = cert.public_bytes(serialization.Encoding.PEM) 106 | ca_cert_pem = ca_cert.public_bytes(serialization.Encoding.PEM) 107 | cert_chain_pem = (cert_pem + ca_cert_pem).decode() 108 | 109 | return cert, cert_chain_pem 110 | 111 | 112 | def build_crl_sync(*, ca_key: PrivateKeyTypes, ca_cert: x509.Certificate, revocations: set[tuple[str, datetime]]): 113 | now = datetime.now(timezone.utc) 114 | builder = x509.CertificateRevocationListBuilder( 115 | last_update=now, 116 | next_update=now + settings.ca.crl_lifetime, 117 | issuer_name=ca_cert.subject, 118 | ) 119 | for serial_number, revoked_at in revocations: 120 | revoked_cert = x509.RevokedCertificateBuilder().serial_number(SerialNumberConverter.hex2int(serial_number)).revocation_date(revoked_at).build() 121 | builder = builder.add_revoked_certificate(revoked_cert) 122 | crl = builder.sign(private_key=ca_key, algorithm=hashes.SHA512()) 123 | crl_pem = crl.public_bytes(encoding=serialization.Encoding.PEM).decode() 124 | return crl, crl_pem 125 | -------------------------------------------------------------------------------- /app/config.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | import sys 3 | from typing import Any, Literal, Optional, Pattern 4 | from pathlib import Path 5 | 6 | from pydantic import AnyHttpUrl, EmailStr, PostgresDsn, SecretStr, model_validator 7 | from pydantic_settings import BaseSettings, SettingsConfigDict 8 | 9 | from logger import logger 10 | 11 | 12 | class WebSettings(BaseSettings): 13 | enabled: bool = True 14 | enable_public_log: bool = False 15 | app_title: str = 'ACME CA Server' 16 | app_description: str = 'Self hosted ACME CA Server' 17 | model_config = SettingsConfigDict(env_prefix='web_') 18 | 19 | 20 | class CaSettings(BaseSettings): 21 | enabled: bool = True 22 | cert_lifetime: timedelta = timedelta(days=60) 23 | crl_lifetime: timedelta = timedelta(days=7) 24 | encryption_key: Optional[SecretStr] = None # encryption of private keys in database 25 | import_dir: Path = '/import' 26 | 27 | model_config = SettingsConfigDict(env_prefix='ca_') 28 | 29 | @model_validator(mode='after') 30 | def valid_check(self) -> 'CaSettings': 31 | if self.enabled: 32 | if not self.encryption_key: 33 | from cryptography.fernet import Fernet # pylint: disable=import-outside-toplevel 34 | 35 | logger.fatal('Env Var ca_encryption_key is missing, use this freshly generated key: %s', Fernet.generate_key().decode()) 36 | sys.exit(1) 37 | if self.cert_lifetime.days < 1: 38 | raise ValueError('Cert lifetime for internal CA must be at least one day, not: ' + str(self.cert_lifetime)) 39 | if self.crl_lifetime.days < 1: 40 | raise ValueError('CRL lifetime for internal CA must be at least one day, not: ' + str(self.crl_lifetime)) 41 | return self 42 | 43 | 44 | class MailSettings(BaseSettings): 45 | enabled: bool = False 46 | host: Optional[str] = None 47 | port: Optional[int] = None 48 | username: Optional[str] = None 49 | password: Optional[SecretStr] = None 50 | encryption: Literal['tls', 'starttls', 'plain'] = 'tls' 51 | sender: Optional[EmailStr] = None 52 | notify_on_account_creation: bool = True 53 | warn_before_cert_expires: timedelta | Literal[False] = timedelta(days=20) 54 | notify_when_cert_expired: bool = True 55 | 56 | model_config = SettingsConfigDict(env_prefix='mail_') 57 | 58 | @model_validator(mode='before') 59 | @classmethod 60 | def sanitize_values(cls, values: Any) -> Any: 61 | if 'warn_before_cert_expires' in values: # not in values if default value 62 | if (values['warn_before_cert_expires'] or '').lower().strip() in ('', 'false', '0', '-1'): 63 | values['warn_before_cert_expires'] = False 64 | return values 65 | 66 | @model_validator(mode='after') 67 | def valid_check(self) -> 'MailSettings': 68 | if self.enabled and (not self.host or not self.sender): 69 | raise ValueError('Mail parameters (mail_host, mail_sender) are missing as SMTP is enabled') 70 | if (self.username and not self.password) or (not self.username and self.password): 71 | raise ValueError('Either no mail auth must be specified or username and password must be provided') 72 | if self.enabled and not self.port: 73 | self.port = {'tls': 465, 'starttls': 587, 'plain': 25}[self.encryption] 74 | return self 75 | 76 | 77 | class AcmeSettings(BaseSettings): 78 | terms_of_service_url: AnyHttpUrl | None = None 79 | mail_target_regex: Pattern = r'[^@]+@[^@]+\.[^@]+' 80 | target_domain_regex: Pattern = r'[^\*]+\.[^\.]+' # disallow wildcard 81 | 82 | model_config = SettingsConfigDict(env_prefix='acme_') 83 | 84 | 85 | class Settings(BaseSettings): 86 | external_url: AnyHttpUrl 87 | db_dsn: PostgresDsn 88 | acme: AcmeSettings = AcmeSettings() 89 | ca: CaSettings = CaSettings() 90 | mail: MailSettings = MailSettings() 91 | web: WebSettings = WebSettings() 92 | 93 | @model_validator(mode='before') 94 | @classmethod 95 | def sanitize_values(cls, data: Any) -> Any: 96 | if 'external_url' in data and not data['external_url'].endswith('/'): 97 | data['external_url'] += '/' 98 | return data 99 | 100 | @model_validator(mode='after') 101 | def valid_check(self) -> 'Settings': 102 | if self.external_url.scheme != 'https': 103 | logger.warning('Env Var "external_url" is not HTTPS. This is insecure!') 104 | if self.mail.warn_before_cert_expires and self.ca.enabled and self.mail.enabled: 105 | if self.mail.warn_before_cert_expires >= self.ca.cert_lifetime: 106 | raise ValueError('Env var web_warn_before_cert_expires cannot be greater than ca_cert_lifetime') 107 | if self.mail.warn_before_cert_expires.days > self.ca.cert_lifetime.days / 2: 108 | logger.warning('Env var mail_warn_before_cert_expires should be more than half of the cert lifetime') 109 | return self 110 | 111 | 112 | settings = Settings() 113 | 114 | 115 | logger.info('Settings: %s', settings.model_dump()) 116 | -------------------------------------------------------------------------------- /app/db/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any 3 | 4 | import asyncpg 5 | from pydantic import BaseModel 6 | 7 | from config import settings 8 | from logger import logger 9 | 10 | _pool: asyncpg.pool.Pool = None 11 | 12 | 13 | async def connect(): 14 | global _pool # pylint: disable=global-statement 15 | _pool = await asyncpg.create_pool(min_size=0, max_size=20, dsn=str(settings.db_dsn), init=init_connection, server_settings={'application_name': settings.web.app_title}) 16 | 17 | 18 | async def disconnect(): 19 | await _pool.close() 20 | 21 | 22 | async def init_connection(conn: asyncpg.Connection): 23 | await conn.set_type_codec('jsonb', encoder=_encode_json, decoder=json.loads, schema='pg_catalog') 24 | 25 | 26 | def _encode_json(payload: Any) -> str: 27 | if isinstance(payload, BaseModel): 28 | return payload.json() 29 | else: 30 | return json.dumps(payload) 31 | 32 | 33 | class transaction: # pylint: disable=invalid-name 34 | readonly = False 35 | 36 | def __init__(self, readonly=False) -> None: 37 | self.readonly = readonly 38 | self.conn: asyncpg.Connection = None 39 | self.trans: asyncpg.connection.transaction = None 40 | 41 | async def __aenter__(self, *args, **kwargs): 42 | self.conn = await _pool.acquire() 43 | self.trans = self.conn.transaction(readonly=self.readonly) 44 | await self.trans.start() 45 | return self 46 | 47 | async def __call__(self, *args): 48 | """fetch response for query""" 49 | async for rec in self.conn.cursor(*args): 50 | yield rec 51 | 52 | async def record(self, *args): 53 | """fetch first response row for query""" 54 | return await self.conn.fetchrow(*args) 55 | 56 | async def value(self, *args): 57 | """fetch first value from first response row for query""" 58 | return await self.conn.fetchval(*args) 59 | 60 | async def exec(self, *args): # noqa: A003 (allow shadowing builtin "type") 61 | """execute command""" 62 | return await self.conn.execute(*args) 63 | 64 | async def execmany(self, command: str, *args): 65 | """execute command with many records""" 66 | return await self.conn.executemany(command, args) 67 | 68 | async def __aexit__(self, exc_type, exc_val, exc_tb): 69 | if exc_type: 70 | logger.debug('Transaction rollback. Reason: %s %s %s', exc_type, exc_val, exc_tb) 71 | await self.trans.rollback() 72 | else: 73 | await self.trans.commit() 74 | await _pool.release(self.conn) 75 | -------------------------------------------------------------------------------- /app/db/migrations/001.sql: -------------------------------------------------------------------------------- 1 | 2 | -- check mail contains "@" and a TLD 3 | CREATE DOMAIN mail_addr AS TEXT CHECK( VALUE ~ '^[^@]+@[^@]+\.[^@]+$'); 4 | 5 | -- FQDN should contain at least a dot and a TLD 6 | CREATE DOMAIN domain_name AS TEXT CHECK( VALUE ~ '^.+\.[^\.]+$'); 7 | 8 | -- random ids are not generated by postgres as 9 | -- 1. high entropy randomness is not guarantied by postgres 10 | -- 2. moves load from postgres to application server 11 | CREATE DOMAIN random_id AS TEXT CHECK( length(VALUE) > 20 ); 12 | 13 | -- hex representation of a certificate serial number 14 | CREATE DOMAIN serial_number AS TEXT CHECK( VALUE ~ '^[0-9A-F]+$'); 15 | 16 | -- these acme error types can be stored in db, list not exhausive 17 | CREATE TYPE acme_error_type AS ENUM ('connection', 'incorrectResponse', 'serverInternal', 'malformed', 'unauthorized', 'dns'); 18 | CREATE TYPE acme_error AS ( 19 | type acme_error_type, 20 | detail text 21 | ); 22 | 23 | -- unlogged table 24 | -- pro: less write overhead as WAL writes are not necessary => better performance 25 | -- contra: table is truncated on server crash, not part of WAL => streaming replication not possible 26 | create unlogged table nonces ( 27 | id random_id not null, 28 | expires_at timestamptz default now() + interval '30 minutes', 29 | PRIMARY KEY (id) 30 | ); 31 | 32 | CREATE TYPE account_status AS ENUM ('valid', 'deactivated', 'revoked'); 33 | create table accounts ( 34 | id random_id NOT NULL, 35 | mail mail_addr not null, 36 | jwk jsonb not null unique check (jsonb_typeof(jwk) = 'object'), 37 | status account_status not null default 'valid', 38 | created_at timestamptz default now(), 39 | PRIMARY KEY (id) 40 | ); 41 | -- index type "hash" is sufficient as only equality in jsonb is relevant 42 | create index accounts_jwk on accounts using hash (jwk); 43 | 44 | CREATE TYPE order_status AS ENUM ('pending', 'ready', 'processing', 'valid', 'invalid'); 45 | create table orders ( 46 | id random_id not null, 47 | account_id random_id not null references accounts(id), 48 | status order_status not null default 'pending', 49 | error acme_error default null check ((error is null and status <> 'invalid') or (error is not null and status = 'invalid')), 50 | expires_at timestamptz default now() + interval '60 minutes', 51 | PRIMARY KEY (id) 52 | ); 53 | 54 | CREATE TYPE authz_status AS ENUM ('pending', 'valid', 'invalid', 'deactivated', 'expired', 'revoked'); 55 | create table authorizations ( 56 | id random_id not null, 57 | order_id random_id not null references orders(id), 58 | status authz_status not null default 'pending', 59 | domain domain_name not null, 60 | PRIMARY KEY (id) 61 | ); 62 | 63 | CREATE TYPE challenge_status AS ENUM ('pending', 'processing', 'valid', 'invalid'); 64 | create table challenges ( 65 | id random_id not null, 66 | authz_id random_id not null unique references authorizations(id), 67 | status challenge_status not null default 'pending', 68 | token random_id not null, 69 | validated_at timestamptz default null check ((validated_at is null and status <> 'valid') or (validated_at is not null and status = 'valid')), 70 | error acme_error default null check ((error is null and status <> 'invalid') or (error is not null and status = 'invalid')), 71 | PRIMARY KEY (id) 72 | ); 73 | 74 | create table certificates ( 75 | serial_number serial_number not null, 76 | csr_pem text not null, 77 | chain_pem text not null, 78 | order_id random_id not null unique references orders(id), 79 | not_valid_before timestamptz not null, 80 | not_valid_after timestamptz not null check (not_valid_after > not_valid_before), 81 | revoked_at timestamptz default null, 82 | user_informed_cert_will_expire boolean not null default false, 83 | user_informed_cert_has_expired boolean not null default false, 84 | PRIMARY KEY (serial_number) 85 | ); 86 | 87 | -- only used if CA_ENABLED=True (builtin CA) 88 | create table cas ( 89 | serial_number serial_number not null, 90 | cert_pem text not null, 91 | key_pem_enc bytea not null, 92 | active boolean not null default false, 93 | crl_pem text not null, 94 | PRIMARY KEY (serial_number) 95 | ); 96 | CREATE UNIQUE INDEX cas_only_one_active ON cas (active) WHERE (active = true); -------------------------------------------------------------------------------- /app/db/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import db 4 | from logger import logger 5 | 6 | 7 | async def run(): 8 | async with db.transaction() as sql: 9 | await sql.exec(""" 10 | create table if not exists migrations ( 11 | dummy_id integer unique default 1 check (dummy_id = 1), -- there should only be one row 12 | migration int not null default 0, 13 | migrated_at timestamptz not null default now() 14 | ); 15 | insert into migrations (migration) values (default) on conflict do nothing; 16 | """) 17 | 18 | migrations_dir = Path(__file__).parent 19 | 20 | dirty = False 21 | while True: 22 | cur_level = await sql.value("""select migration from migrations""") 23 | next_level = cur_level + 1 24 | cur_file = migrations_dir / f'{cur_level:0>3}.sql' 25 | next_file = migrations_dir / f'{next_level:0>3}.sql' 26 | if not next_file.is_file(): 27 | break 28 | logger.info('Running migration: %s', next_file.name) 29 | with open(next_file, encoding='utf-8') as f: 30 | await sql.exec(f.read()) 31 | await sql.exec("""update migrations set migration=$1""", next_level) 32 | dirty = True 33 | if dirty: 34 | logger.info('Finished database migrations (current level: %s)', cur_file.name) 35 | else: 36 | logger.info('Database migrations are up to date (current level: %s)', cur_file.name) 37 | -------------------------------------------------------------------------------- /app/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logger = logging.getLogger('uvicorn') 4 | -------------------------------------------------------------------------------- /app/mail/__init__.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from email.mime.text import MIMEText 3 | from pathlib import Path 4 | from typing import Literal 5 | 6 | from aiosmtplib import SMTP 7 | from jinja2 import Environment, FileSystemLoader 8 | 9 | from config import settings 10 | from logger import logger 11 | 12 | template_engine = Environment(loader=FileSystemLoader(Path(__file__).parent / 'templates'), enable_async=True, autoescape=True) # pylint: disable=duplicate-code 13 | default_params = { # pylint: disable=duplicate-code 14 | 'app_title': settings.web.app_title, 15 | 'app_desc': settings.web.app_description, 16 | 'web_url': str(settings.external_url), 17 | 'acme_url': str(settings.external_url).removesuffix('/') + '/acme/directory', 18 | } 19 | 20 | Templates = Literal['cert-expired-info', 'cert-expires-warning', 'new-account-info'] 21 | 22 | 23 | async def send_mail(receiver: str, template: Templates, subject_vars: dict = None, body_vars: dict = None): 24 | subject_vars = subject_vars or {} 25 | subject_vars.update(**default_params) 26 | body_vars = body_vars or {} 27 | body_vars.update(**default_params) 28 | subject_job = template_engine.get_template(template + '/subject.txt').render_async(subject_vars) 29 | body_job = template_engine.get_template(template + '/body.html').render_async(body_vars) 30 | message = MIMEText(await body_job, 'html', 'utf-8') 31 | message['From'] = settings.mail.sender 32 | message['To'] = receiver 33 | message['Subject'] = await subject_job 34 | if settings.mail.enabled: 35 | auth = {} 36 | if settings.mail.username and settings.mail.password: 37 | auth = {'username': settings.mail.username, 'password': settings.mail.password.get_secret_value()} 38 | async with SMTP( 39 | hostname=settings.mail.host, 40 | port=settings.mail.port, 41 | **auth, 42 | use_tls=settings.mail.encryption == 'tls', 43 | start_tls=settings.mail.encryption == 'starttls', 44 | ) as client: 45 | await client.send_message(message) 46 | else: 47 | logger.debug('sending mails is disabled, not sending: %s', message) 48 | 49 | 50 | async def send_new_account_info_mail(receiver: str): 51 | await send_mail(receiver, 'new-account-info') 52 | 53 | 54 | async def send_certs_will_expire_warn_mail(*, receiver: str, domains: list[str], expires_at: datetime, serial_number: str): 55 | await send_mail( 56 | receiver, 57 | 'cert-expires-warning', 58 | body_vars={ 59 | 'domains': domains, 60 | 'expires_at': expires_at, 61 | 'serial_number': serial_number, 62 | 'expires_in_days': (expires_at - datetime.now(timezone.utc)).days, 63 | }, 64 | ) 65 | 66 | 67 | async def send_certs_expired_info_mail(*, receiver: str, domains: list[str], expires_at: datetime, serial_number: str): 68 | await send_mail( 69 | receiver, 70 | 'cert-expired-info', 71 | body_vars={ 72 | 'domains': domains, 73 | 'expires_at': expires_at, 74 | 'serial_number': serial_number, 75 | }, 76 | ) 77 | -------------------------------------------------------------------------------- /app/mail/templates/cert-expired-info/body.html: -------------------------------------------------------------------------------- 1 |

{{app_title}}

2 |

Certificate Expiration

3 | 4 | Your server certificate has expired. 5 | If this is not intended please check and fix your certificate renewal automation. 6 | 7 |

Expiration date: {{expires_at.isoformat()}}

8 | 9 | Affected domains: 10 | 11 | 18 | 19 | If the certificate expiration is intended no action needs to be taken. -------------------------------------------------------------------------------- /app/mail/templates/cert-expired-info/subject.txt: -------------------------------------------------------------------------------- 1 | [{{app_title}}] Certificate Expiration -------------------------------------------------------------------------------- /app/mail/templates/cert-expires-warning/body.html: -------------------------------------------------------------------------------- 1 |

{{app_title}}

2 |

Certificate Warning

3 | 4 | Your server certificate will expire soon. 5 | If this is not intended please check and fix your certificate renewal automation. 6 | Otherwise, your websites may become unusable via HTTPS for visitors. 7 | 8 |

Expiration date: {{expires_at.isoformat()}} (in {{expires_in_days}} days)

9 | 10 | Affected domains: 11 | 12 | 19 | 20 | If the certificate expiration is intended you can ignore this warning. -------------------------------------------------------------------------------- /app/mail/templates/cert-expires-warning/subject.txt: -------------------------------------------------------------------------------- 1 | [{{app_title}}] Certificate Expiration -------------------------------------------------------------------------------- /app/mail/templates/new-account-info/body.html: -------------------------------------------------------------------------------- 1 |

{{app_title}}

2 | 3 | Congratulations, you successfully created an ACME account to automate server certificate updates. -------------------------------------------------------------------------------- /app/mail/templates/new-account-info/subject.txt: -------------------------------------------------------------------------------- 1 | [{{app_title}}] New ACME account created -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.0.0' # replaced during build, do not change 2 | 3 | from contextlib import asynccontextmanager 4 | from pathlib import Path 5 | 6 | from fastapi import FastAPI, HTTPException, Request, status 7 | from fastapi.exception_handlers import http_exception_handler 8 | from fastapi.exceptions import RequestValidationError 9 | from fastapi.openapi.docs import get_swagger_ui_html 10 | from fastapi.responses import JSONResponse 11 | from fastapi.staticfiles import StaticFiles 12 | from pydantic import ValidationError 13 | 14 | import acme 15 | import ca 16 | import db 17 | import db.migrations 18 | import web 19 | from acme.exceptions import ACMEException 20 | from config import settings 21 | 22 | 23 | @asynccontextmanager 24 | async def lifespan(_: FastAPI): 25 | await db.connect() 26 | await db.migrations.run() 27 | await ca.init() 28 | await acme.start_cronjobs() 29 | yield 30 | await db.disconnect() 31 | 32 | 33 | app = FastAPI( 34 | lifespan=lifespan, 35 | version=__version__, 36 | redoc_url=None, 37 | docs_url=None, 38 | title=settings.web.app_title, 39 | description=settings.web.app_description, 40 | ) 41 | app.add_middleware( 42 | web.middleware.SecurityHeadersMiddleware, 43 | content_security_policy={ 44 | '/acme/': "base-uri 'self'; default-src 'none';", 45 | '/endpoints': "base-uri 'self'; default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; frame-src 'none'; img-src 'self' data:;", 46 | '/': "base-uri 'self'; default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; frame-src 'none'; img-src 'self' data:;", 47 | }, 48 | ) 49 | 50 | if settings.web.enabled: 51 | 52 | @app.get('/endpoints', tags=['web']) 53 | async def swagger_ui_html(): 54 | return get_swagger_ui_html( 55 | openapi_url='/openapi.json', 56 | title=app.title, 57 | swagger_favicon_url='favicon.png', 58 | swagger_css_url='libs/swagger-ui.css', 59 | swagger_js_url='libs/swagger-ui-bundle.js', 60 | ) 61 | 62 | 63 | @app.exception_handler(RequestValidationError) 64 | @app.exception_handler(HTTPException) 65 | @app.exception_handler(ACMEException) 66 | @app.exception_handler(Exception) 67 | async def acme_exception_handler(request: Request, exc: Exception): 68 | # custom exception handler for acme specific response format 69 | if request.url.path.startswith('/acme/') or isinstance(exc, ACMEException): 70 | if isinstance(exc, ACMEException): 71 | return await exc.as_response() 72 | elif isinstance(exc, ValidationError): 73 | return await ACMEException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, exctype='malformed', detail=exc.json()).as_response() 74 | elif isinstance(exc, HTTPException): 75 | return await ACMEException(status_code=exc.status_code, exctype='serverInternal', detail=str(exc.detail)).as_response() 76 | else: 77 | return await ACMEException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, exctype='serverInternal', detail=str(exc)).as_response() 78 | else: 79 | if isinstance(exc, HTTPException): 80 | return await http_exception_handler(request, exc) 81 | else: 82 | return JSONResponse({'detail': str(exc)}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) 83 | 84 | 85 | app.include_router(acme.router) 86 | app.include_router(acme.directory_router.api) # serve acme directory under /acme/directory and /directory 87 | app.include_router(ca.router) 88 | 89 | if settings.web.enabled: 90 | app.include_router(web.router) 91 | 92 | if Path('/app/web/www').exists(): 93 | app.mount('/', StaticFiles(directory='/app/web/www'), name='static') 94 | -------------------------------------------------------------------------------- /app/web/__init__.py: -------------------------------------------------------------------------------- 1 | from . import middleware # noqa: F401 (import required as module export) 2 | from . import router as router_module 3 | 4 | router = router_module.api 5 | -------------------------------------------------------------------------------- /app/web/middleware.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Request, Response 2 | from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint 3 | 4 | 5 | class SecurityHeadersMiddleware(BaseHTTPMiddleware): # pylint: disable=too-few-public-methods 6 | """Add security headers to all responses.""" 7 | 8 | def __init__( 9 | self, 10 | app: FastAPI, 11 | *, 12 | content_security_policy: dict[str, str] | None = None, 13 | permissions_policy: dict[str, str] | None = None, 14 | ) -> None: 15 | super().__init__(app) 16 | self.csp = content_security_policy 17 | self.pp = permissions_policy 18 | 19 | async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: 20 | """Dispatch of the middleware. 21 | 22 | :param request: Incoming request 23 | :param call_next: Function to process the request 24 | :return: Return response coming from processed request 25 | """ 26 | headers = { 27 | 'Cross-Origin-Opener-Policy': 'same-origin', 28 | 'Referrer-Policy': 'strict-origin-when-cross-origin', 29 | 'X-Content-Type-Options': 'nosniff', 30 | 'X-Frame-Options': 'DENY', 31 | 'X-XSS-Protection': '1; mode=block', 32 | 'Strict-Transport-Security': 'max-age=31536000', 33 | } 34 | if self.csp: 35 | matches = [path for path in self.csp.keys() if request.url.path.startswith(path)] 36 | if matches: 37 | best_match = sorted(matches, key=len, reverse=True)[0] 38 | headers['Content-Security-Policy'] = self.csp[best_match] 39 | if self.pp: 40 | matches = [path for path in self.pp.keys() if request.url.path.startswith(path)] 41 | if matches: 42 | best_match = sorted(matches, key=len, reverse=True)[0] 43 | headers['Permissions-Policy'] = self.pp[best_match] 44 | response = await call_next(request) 45 | response.headers.update(headers) 46 | 47 | return response 48 | -------------------------------------------------------------------------------- /app/web/router.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Literal 3 | 4 | from fastapi import APIRouter, HTTPException, Response, status 5 | from fastapi.responses import HTMLResponse 6 | from jinja2 import Environment, FileSystemLoader 7 | from pydantic import constr 8 | 9 | import db 10 | from config import settings 11 | 12 | template_engine = Environment(loader=FileSystemLoader(Path(__file__).parent / 'templates'), enable_async=True, autoescape=True) 13 | 14 | default_params = { # pylint: disable=duplicate-code 15 | 'app_title': settings.web.app_title, 16 | 'app_desc': settings.web.app_description, 17 | 'web_url': str(settings.external_url), 18 | 'acme_url': str(settings.external_url).removesuffix('/') + '/acme/directory', 19 | } 20 | 21 | 22 | api = APIRouter(tags=['web']) 23 | 24 | 25 | @api.get('/', response_class=HTMLResponse) 26 | async def index(): 27 | return await template_engine.get_template('index.html').render_async(**default_params) 28 | 29 | 30 | if settings.web.enable_public_log: 31 | 32 | @api.get('/certificates', response_class=HTMLResponse) 33 | async def certificate_log(): 34 | async with db.transaction(readonly=True) as sql: 35 | certs = [ 36 | record 37 | async for record in sql(""" 38 | select 39 | serial_number, not_valid_before, not_valid_after, revoked_at, 40 | (not_valid_after > now() and revoked_at is null) as is_valid, 41 | (not_valid_after - not_valid_before) as lifetime, 42 | (now() - not_valid_before) as age, 43 | array((select domain from authorizations authz where authz.order_id = cert.order_id order by domain)) as domains 44 | from certificates cert 45 | group by serial_number 46 | order by not_valid_after desc 47 | """) 48 | ] 49 | return await template_engine.get_template('cert-log.html').render_async(**default_params, certs=certs) 50 | 51 | @api.get('/certificates/{serial_number}', response_class=Response, responses={200: {'content': {'application/pem-certificate-chain': {}}}}) 52 | async def download_certificate(serial_number: constr(pattern='^[0-9A-F]+$')): 53 | async with db.transaction(readonly=True) as sql: 54 | pem_chain = await sql.value("""select chain_pem from certificates where serial_number = $1""", serial_number) 55 | if not pem_chain: 56 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='unknown certificate') 57 | return Response(content=pem_chain, media_type='application/pem-certificate-chain') 58 | 59 | @api.get('/domains', response_class=HTMLResponse) 60 | async def domain_log(domainfilter: str = '', domainstatus: Literal['all', 'valid', 'invalid'] = 'all'): 61 | async with db.transaction(readonly=True) as sql: 62 | domains = [ 63 | record 64 | async for record in sql( 65 | """ 66 | with data as ( 67 | select 68 | authz.domain as domain_name, 69 | min(cert.not_valid_before) as first_requested_at, 70 | max(cert.not_valid_after) as expires_at, 71 | (max(cert.not_valid_after) FILTER (WHERE revoked_at is null)) > now() AS is_valid 72 | from orders ord 73 | join authorizations authz on authz.order_id = ord.id 74 | join certificates cert on cert.order_id = ord.id 75 | where ($1::text = '' or authz.domain ilike '%' || $1::text || '%') 76 | group by authz.domain 77 | ) 78 | select * from data 79 | where ($2 = 'all' or ($2 = 'valid' and is_valid) or ($2 = 'invalid' and not is_valid)) 80 | order by domain_name 81 | """, 82 | domainfilter.replace('*', '%'), 83 | domainstatus, 84 | ) 85 | ] 86 | return await template_engine.get_template('domain-log.html').render_async(**default_params, domains=domains, domainstatus=domainstatus, domainfilter=domainfilter) 87 | else: 88 | 89 | @api.get('/certificates') 90 | async def certificate_log(): 91 | raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='This page is disabled') 92 | 93 | @api.get('/domains') 94 | async def domain_log(): 95 | raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='This page is disabled') 96 | -------------------------------------------------------------------------------- /app/web/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block head %} 6 | 9 | 10 | {% block title %}{% endblock %} 11 | 20 | {% endblock %} 21 | 22 | 23 | 24 | 25 |

{{app_title}}

26 |
27 |
{% block content %}{% endblock %}
28 | 29 | 30 | -------------------------------------------------------------------------------- /app/web/templates/cert-log.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Certificates{% endblock %} 3 | {% block head %} 4 | {{ super() }} 5 | 36 | {% endblock %} 37 | {% block content %} 38 |

Certificates

39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | {% for cert in certs %} 53 | 54 | 55 | 56 | 57 | 58 | 65 | 72 | 81 | 82 | {% endfor %} 83 | 84 |
Serial NumberCreated AtValid UntilAge (Days)Revoked AtDomains
 {{cert.serial_number}}{{cert.not_valid_before.strftime('%Y-%m-%d')}}{{cert.not_valid_after.strftime('%Y-%m-%d')}} 59 | {% if cert.is_valid %} 60 | {{cert.age.days}} / {{cert.lifetime.days}} 61 | {% else %} 62 | - 63 | {% endif %} 64 | 66 | {% if cert.revoked_at %} 67 | {{cert.revoked_at.strftime('%Y-%m-%d')}} 68 | {% else %} 69 | - 70 | {% endif %} 71 | 73 |
    74 | {% for domain in cert.domains %} 75 |
  • 76 | {{domain}} 77 |
  • 78 | {% endfor %} 79 |
80 |
85 | {% endblock %} -------------------------------------------------------------------------------- /app/web/templates/domain-log.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Domains{% endblock %} 3 | {% block head %} 4 | {{ super() }} 5 | 41 | {% endblock %} 42 | {% block content %} 43 |

Domains

44 | 45 |
46 |
47 | 48 |

filter domain status

49 |
50 |
51 | 54 | 57 | 60 |
61 |
62 | 66 | 67 |
68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | {% for domain in domains %} 80 | 81 | 82 | 84 | 85 | 86 | 87 | {% endfor %} 88 | 89 |
DomainFirst requested atNewest certificate valid until
 {{domain.domain_name}}{{domain.first_requested_at.strftime('%Y-%m-%d')}}{{domain.expires_at.strftime('%Y-%m-%d')}}
90 | {% endblock %} -------------------------------------------------------------------------------- /app/web/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Index{% endblock %} 3 | {% block head %} 4 | {{ super() }} 5 | 7 | {% endblock %} 8 | {% block content %} 9 | 10 | 21 | 22 |

ACME Entrypoint: {{acme_url}}

23 | 24 | {% endblock %} -------------------------------------------------------------------------------- /app/web/www/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knrdl/acme-ca-server/4371f3d0931c77863d15d7623d4c876b58eeab6d/app/web/www/favicon.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "acme-ca-server" 3 | description = "ACME CA Server" 4 | readme = "README.md" 5 | license = {file = "LICENSE"} 6 | dynamic = ["dependencies"] 7 | requires-python = ">=3.10" 8 | classifiers = [ 9 | "License :: OSI Approved :: MIT License", 10 | "Programming Language :: Python :: 3", 11 | ] 12 | 13 | [project.urls] 14 | Homepage = "https://github.com/knrdl/acme-ca-server" 15 | Documentation = "https://github.com/knrdl/acme-ca-server" 16 | Repository = "https://github.com/knrdl/acme-ca-server.git" 17 | Issues = "https://github.com/knrdl/acme-ca-server/issues" 18 | 19 | [tool.setuptools.dynamic] 20 | dependencies = {file = ["requirements.txt"]} 21 | 22 | 23 | [tool.pylint] 24 | max-line-length = 179 25 | recursive = 'yes' 26 | disable = 'too-many-branches,no-else-return,broad-exception-caught,missing-module-docstring,missing-class-docstring,missing-function-docstring' 27 | 28 | [tool.pytest.ini_options] 29 | pythonpath = "app" 30 | testpaths = [ 31 | "tests/pytest/", 32 | ] 33 | addopts = [ 34 | "--import-mode=importlib", 35 | ] 36 | 37 | [tool.ruff] 38 | line-length = 179 39 | 40 | [tool.ruff.format] 41 | quote-style = "single" -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiosmtplib==4.0.1 2 | asyncpg==0.30.0 3 | cryptography==45.0.3 4 | fastapi==0.115.12 5 | httpx==0.28.1 6 | jinja2==3.1.6 7 | jwcrypto==1.5.6 8 | pydantic[email]==2.11.5 9 | pydantic-settings==2.9.1 10 | uvicorn[standard]==0.34.3 11 | -------------------------------------------------------------------------------- /tests/e2e/.gitignore: -------------------------------------------------------------------------------- 1 | certbot/ 2 | traefikdata/ 3 | caddydata/ 4 | uacmedata/ 5 | acmeshdata/ 6 | 7 | ca.key 8 | ca.pem 9 | *.xdb -------------------------------------------------------------------------------- /tests/e2e/Caddyfile: -------------------------------------------------------------------------------- 1 | { 2 | acme_ca http://localhost:8080/acme/directory 3 | email caddy@example.org 4 | ocsp_stapling off 5 | } 6 | 7 | http://localhost:8080 { 8 | reverse_proxy acme.example.org:8080 9 | 10 | } 11 | 12 | host10.example.org { 13 | header Content-Type text/html 14 | respond "

test

" 15 | } -------------------------------------------------------------------------------- /tests/e2e/Dockerfile.uacme: -------------------------------------------------------------------------------- 1 | FROM debian:latest 2 | 3 | RUN apt-get update && apt-get install -y uacme nginx && \ 4 | mkdir -p /var/www/html/.well-known/acme-challenge -------------------------------------------------------------------------------- /tests/e2e/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # also works: alias docker='podman' 4 | 5 | # cleanup 6 | docker rm -f -v test_db 7 | docker rm -f -v test_server 8 | docker rm -f -v test_mail 9 | 10 | docker network rm -f test_net 11 | 12 | # run tests 13 | set -e 14 | 15 | docker build --pull -t acmeserver ../.. 16 | 17 | docker network create test_net 18 | 19 | docker run -dit -e POSTGRES_PASSWORD=secret --name test_db --net test_net docker.io/postgres:16-alpine 20 | 21 | docker run -dit --name test_mail --net test_net -p "3000:80" docker.io/rnwood/smtp4dev 22 | echo See sent emails at http://localhost:3000 23 | 24 | # generate ca key+cert 25 | openssl genrsa -out ca.key 4096 26 | openssl req -new -x509 -nodes -days 3650 -subj "/C=XX/O=Test" -key ca.key -out ca.pem -set_serial "0xDEADBEAF" 27 | chmod a+r {ca.key,ca.pem} 28 | 29 | function run_server() { 30 | docker run -dit --name test_server --net test_net -p8080:8080 --network-alias acme.example.org \ 31 | -v "$PWD/ca.key:/import/ca.key:ro" -v "$PWD/ca.pem:/import/ca.pem:ro" \ 32 | -e DB_DSN="postgresql://postgres:secret@test_db/postgres" \ 33 | -e MAIL_ENABLED=true -e MAIL_HOST=test_mail -e MAIL_ENCRYPTION=plain -e MAIL_SENDER=acme@example.org \ 34 | -e web_enable_public_log=true \ 35 | -e EXTERNAL_URL="http://acme.example.org:8080" \ 36 | -e CA_ENCRYPTION_KEY="DaxNj1bTiCsk6aQiY43hz2jDqBZAU5kta1uNBzp_yqo=" \ 37 | acmeserver 38 | } 39 | run_server 40 | 41 | sleep 5 42 | curl --fail --silent localhost:8080 > /dev/null 43 | curl --fail --silent localhost:8080/certificates > /dev/null 44 | curl --fail --silent localhost:8080/endpoints > /dev/null 45 | 46 | # certbot 47 | 48 | rm -rf certbot 49 | mkdir certbot 50 | 51 | echo "Certbot 1a" 52 | docker run --rm --pull always --name test_certbot1a --net test_net -v "$PWD/certbot:/etc/letsencrypt" \ 53 | --network-alias host1.example.org --network-alias host2.example.org --network-alias host3.example.org docker.io/certbot/certbot certonly \ 54 | --server http://acme.example.org:8080/acme/directory --standalone --no-eff-email \ 55 | --email certbot@example.org -vvv \ 56 | --domains host1.example.org --domains host2.example.org 57 | 58 | echo "Certbot 1b" 59 | docker run --rm --name test_certbot1b --net test_net -v "$PWD/certbot:/etc/letsencrypt" \ 60 | --network-alias host1.example.org --network-alias host2.example.org --network-alias host3.example.org docker.io/certbot/certbot update_account --no-eff-email -m certbot2@example.org \ 61 | --server http://acme.example.org:8080/acme/directory 62 | 63 | echo "Certbot 2" 64 | docker run --rm --name test_certbot2 --net test_net -v "$PWD/certbot:/etc/letsencrypt" \ 65 | --network-alias host1.example.org --network-alias host2.example.org --network-alias host3.example.org docker.io/certbot/certbot certonly \ 66 | --server http://acme.example.org:8080/acme/directory --standalone --no-eff-email --force-renewal \ 67 | --email certbot@example.org -vvv \ 68 | --domains host1.example.org --domains host2.example.org --domains host3.example.org 69 | 70 | echo "Certbot 3a" 71 | docker run --rm --name test_certbot3a --net test_net -v "$PWD/certbot:/etc/letsencrypt" \ 72 | --network-alias host1.example.org --network-alias host2.example.org --network-alias host3.example.org docker.io/certbot/certbot certificates \ 73 | --server http://acme.example.org:8080/acme/directory 74 | 75 | echo "Certbot 3b" 76 | docker run --rm --name test_certbot3b --net test_net -v "$PWD/certbot:/etc/letsencrypt" \ 77 | --network-alias host1.example.org --network-alias host2.example.org --network-alias host3.example.org docker.io/certbot/certbot reconfigure \ 78 | --server http://acme.example.org:8080/acme/directory --cert-name host1.example.org -vvv 79 | 80 | echo "Certbot 3c" 81 | docker run --rm --name test_certbot3c --net test_net -v "$PWD/certbot:/etc/letsencrypt" \ 82 | --network-alias host1.example.org --network-alias host2.example.org --network-alias host3.example.org docker.io/certbot/certbot revoke \ 83 | --server http://acme.example.org:8080/acme/directory --cert-name host1.example.org -vvv --non-interactive 84 | 85 | echo "Certbot 3d" 86 | docker run --rm --name test_certbot3d --net test_net -v "$PWD/certbot:/etc/letsencrypt" \ 87 | --network-alias host1.example.org --network-alias host2.example.org --network-alias host3.example.org docker.io/certbot/certbot certificates \ 88 | --server http://acme.example.org:8080/acme/directory 89 | 90 | echo "Certbot 4a" 91 | docker run --rm --name test_certbot4a --net test_net -v "$PWD/certbot:/etc/letsencrypt" \ 92 | --network-alias host1.example.org --network-alias host2.example.org --network-alias host3.example.org docker.io/certbot/certbot show_account \ 93 | --server http://acme.example.org:8080/acme/directory 94 | 95 | echo "Certbot 4b" 96 | docker run --rm --name test_certbot4b --net test_net -v "$PWD/certbot:/etc/letsencrypt" \ 97 | --network-alias host1.example.org --network-alias host2.example.org --network-alias host3.example.org docker.io/certbot/certbot unregister \ 98 | --server http://acme.example.org:8080/acme/directory --non-interactive 99 | 100 | echo "Certbot 4c" 101 | docker run --rm --name test_certbot4c --net test_net -v "$PWD/certbot:/etc/letsencrypt" \ 102 | --network-alias host1.example.org --network-alias host2.example.org --network-alias host3.example.org docker.io/certbot/certbot show_account \ 103 | --server http://acme.example.org:8080/acme/directory || true 104 | 105 | # Traefik 106 | 107 | rm -rf traefikdata 108 | mkdir traefikdata 109 | 110 | echo "Traefik 1" 111 | docker run -dit --rm --pull always --name test_traefik --net test_net -v "$PWD/traefik.yaml:/file.yaml:ro" -v "$PWD/traefikdata:/acme" -p8082:80 -p8083:8080 \ 112 | --network-alias host20.example.org docker.io/traefik:latest --log.level=DEBUG --providers.file.filename=/file.yaml --api.insecure=true --api.dashboard=true \ 113 | --entrypoints.web.address=:80 --entrypoints.web-secure.address=:443 \ 114 | --certificatesresolvers.myresolver.acme.email=traefik@example.org \ 115 | --certificatesresolvers.myresolver.acme.storage=/acme/acme.json \ 116 | --certificatesresolvers.myresolver.acme.httpChallenge.entryPoint=web \ 117 | --certificatesresolvers.myresolver.acme.caServer=http://acme.example.org:8080/acme/directory 118 | 119 | while true; do 120 | cat traefikdata/acme.json | jq '.myresolver.Certificates[0].domain.main == "host20.example.org"' | grep 'true' && break 121 | sleep 1 122 | done 123 | 124 | docker kill test_traefik 125 | 126 | # Caddy 127 | 128 | rm -rf caddydata 129 | mkdir caddydata 130 | 131 | echo "Caddy 1" 132 | docker run -dit --rm --pull always --name test_caddy --net test_net -v "$PWD/Caddyfile:/etc/caddy/Caddyfile:ro" -v "$PWD/caddydata:/data" \ 133 | --network-alias host10.example.org docker.io/caddy:alpine 134 | 135 | while [ ! -f ./caddydata/caddy/certificates/localhost-8080-acme-directory/host10.example.org/host10.example.org.crt ]; do 136 | sleep 1 137 | done 138 | 139 | docker kill test_caddy 140 | 141 | # uacme 142 | 143 | rm -rf uacmedata 144 | mkdir uacmedata 145 | 146 | docker build --pull -t uacme -f Dockerfile.uacme . 147 | 148 | echo "uacme 1" 149 | docker run --rm --name test_uacme1 --net test_net -v "$PWD/uacmedata:/uacme" \ 150 | uacme uacme -v -c /uacme \ 151 | --acme-url http://acme.example.org:8080/acme/directory new uacme@example.org 152 | 153 | echo "uacme 2" 154 | docker run --rm --name test_uacme2 --net test_net -v "$PWD/uacmedata:/uacme" \ 155 | --network-alias host30.example.org -e UACME_CHALLENGE_PATH=/var/www/html/.well-known/acme-challenge uacme \ 156 | bash -c "nginx -g 'daemon on;' && uacme -vvv -c /uacme -h /usr/share/uacme/uacme.sh \ 157 | --acme-url http://acme.example.org:8080/acme/directory issue host30.example.org" 158 | 159 | echo "uacme 3 (missing email addr on account registration)" 160 | docker run --rm --name test_uacme1 --net test_net \ 161 | uacme uacme -v -y -c /tmp \ 162 | --acme-url http://acme.example.org:8080/acme/directory new 2>&1 | grep urn:ietf:params:acme:error:malformed 163 | 164 | # acme.sh 165 | 166 | rm -rf acmeshdata 167 | mkdir acmeshdata 168 | 169 | echo "acme.sh 1" 170 | docker run --rm --name test_acmesh1 --net test_net -v "$PWD/acmeshdata:/acme.sh" --network-alias host40.example.org \ 171 | docker.io/neilpang/acme.sh --issue -d host40.example.org --standalone \ 172 | --accountemail acmesh@example.org --server http://acme.example.org:8080/acme/directory 173 | 174 | echo "acme.sh 2" 175 | docker run --rm --name test_acmesh2 --net test_net -v "$PWD/acmeshdata:/acme.sh" --network-alias host40.example.org \ 176 | docker.io/neilpang/acme.sh --revoke -d host40.example.org --server http://acme.example.org:8080/acme/directory 177 | 178 | 179 | 180 | docker kill test_server 181 | docker logs test_server 182 | docker rm test_server 183 | 184 | docker exec test_db psql -U postgres -c "update certificates set not_valid_before=now() - interval '50 day', not_valid_after=now() - interval '10 days' where order_id = (select id from orders where status = 'valid' order by id asc limit 1);" 185 | docker exec test_db psql -U postgres -c "update certificates set not_valid_before=now() - interval '50 day', not_valid_after=now() + interval '10 days' where order_id = (select id from orders where status = 'valid' order by id desc limit 1);" 186 | 187 | run_server 188 | 189 | sleep 5 190 | 191 | docker kill test_server 192 | 193 | docker logs test_server -------------------------------------------------------------------------------- /tests/e2e/traefik.yaml: -------------------------------------------------------------------------------- 1 | http: 2 | routers: 3 | router0: 4 | entryPoints: 5 | - web 6 | - web-secure 7 | service: service-foo 8 | rule: Host(`host20.example.org`) 9 | tls: 10 | certResolver: myresolver 11 | 12 | services: 13 | service-foo: 14 | loadBalancer: 15 | servers: 16 | - url: https://example.org 17 | passHostHeader: false 18 | -------------------------------------------------------------------------------- /tests/pytest/.gitignore: -------------------------------------------------------------------------------- 1 | import-ca/ -------------------------------------------------------------------------------- /tests/pytest/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12.7-alpine 2 | 3 | RUN apk add openssl 4 | 5 | RUN pip install pytest coverage 6 | 7 | WORKDIR /runner 8 | 9 | COPY pyproject.toml . 10 | 11 | COPY requirements.txt ./ 12 | 13 | RUN pip install -r ./requirements.txt 14 | 15 | COPY app ./app/ 16 | 17 | COPY tests/pytest ./tests/pytest/ 18 | 19 | CMD coverage run --branch -m pytest && coverage html --omit="./tests/*" -------------------------------------------------------------------------------- /tests/pytest/README.md: -------------------------------------------------------------------------------- 1 | 2 | # PyTest 3 | 4 | ## Run without docker 5 | 6 | 1. Start a new (pristine) postgres db instance 7 | 2. Install project dependencies: `pip install -r requirements.txt` 8 | 3. Install pytest: `pip install pytest` 9 | 4. In project root directory, run tests like this: `db_dsn=postgresql://postgres:postgres@localhost/postgres pytest .` 10 | 11 | ## Run with docker 12 | 13 | execute `./run.sh` -------------------------------------------------------------------------------- /tests/pytest/conftest.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | from typing import Generator 3 | import os 4 | import shutil 5 | import pytest 6 | from pathlib import Path 7 | import subprocess 8 | import asyncio 9 | 10 | import jwcrypto.jwk 11 | import json 12 | 13 | 14 | @pytest.fixture(scope='session') 15 | def testclient() -> Generator[TestClient, None, None]: 16 | os.environ['ca_encryption_key'] = 'M8L6RSYPiHHr6GogXmkQIs7gVia_K5fDDJiNK7zUt0k=' 17 | os.environ['external_url'] = 'http://localhost:8000/' 18 | 19 | ca_dir = Path(__file__).parent / 'import-ca' 20 | os.environ['ca_import_dir'] = str(ca_dir) 21 | shutil.rmtree(ca_dir, ignore_errors=True) 22 | ca_dir.mkdir() 23 | subprocess.call(['openssl', 'genrsa', '-out', ca_dir / 'ca.key', '4096']) 24 | subprocess.call(['openssl', 'req', '-new', '-x509', '-nodes', '-days', '3650', '-subj', '/C=DE/O=Demo', '-key', ca_dir / 'ca.key', '-out', ca_dir / 'ca.pem']) 25 | 26 | async def noop(): 27 | pass 28 | 29 | import main 30 | 31 | # cronjobs are disabled because they would keep the test run going even if all tests are done 32 | main.ca.cronjob.start = noop 33 | main.acme.start_cronjobs = noop 34 | 35 | with TestClient(main.app) as tc: 36 | yield tc 37 | 38 | 39 | @pytest.fixture(scope='session') 40 | def directory(testclient: TestClient) -> dict[str, str]: 41 | return testclient.get('/acme/directory').json() 42 | 43 | 44 | @pytest.fixture 45 | def account_jwk() -> jwcrypto.jwk.JWK: 46 | jwk_key = jwcrypto.jwk.JWK.generate(kty='EC', crv='P-256') 47 | 48 | return jwk_key 49 | 50 | 51 | @pytest.fixture 52 | def signed_request(testclient: TestClient, account_jwk: jwcrypto.jwk.JWK, directory): 53 | class SignedRequest: 54 | @property 55 | def account_jwk(self): 56 | return account_jwk 57 | 58 | @property 59 | def nonce(self): 60 | return testclient.head(directory['newNonce']).headers['Replay-Nonce'] 61 | 62 | def __call__(self, url: str, nonce: str, payload: dict | str, account_url: str | None = None): 63 | jws = jwcrypto.jws.JWS('' if payload == '' else json.dumps(payload)) 64 | protected = {'alg': 'ES256', 'nonce': nonce, 'url': url} 65 | if account_url is None: 66 | protected['jwk'] = account_jwk.export_public(as_dict=True) 67 | else: 68 | protected['kid'] = account_url 69 | 70 | jws.add_signature(account_jwk, protected=protected) 71 | 72 | return testclient.post(url, content=jws.serialize(), headers={'Content-Type': 'application/jose+json'}) 73 | 74 | return SignedRequest() 75 | 76 | 77 | @pytest.fixture(scope='session') 78 | def db(): 79 | import asyncpg 80 | 81 | class DbConnector: 82 | @staticmethod 83 | def fetch_row(*args): 84 | import config 85 | 86 | async def do(): 87 | connection = await asyncpg.connect(str(config.settings.db_dsn)) 88 | stored_row = await connection.fetchrow(*args) 89 | await connection.close() 90 | return stored_row 91 | 92 | return asyncio.run(do()) 93 | 94 | return DbConnector() 95 | -------------------------------------------------------------------------------- /tests/pytest/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # also works: alias docker='podman' 4 | 5 | docker network rm -f test_net 6 | 7 | docker container rm -f test_db 8 | 9 | set -e 10 | 11 | docker build --pull -t testrunner -f ./Dockerfile ../.. 12 | 13 | docker network create test_net 14 | 15 | docker run -dt -e POSTGRES_PASSWORD=secret --name test_db --net test_net docker.io/postgres:16-alpine 16 | 17 | sleep 5 18 | 19 | docker run -t --rm --name test_runner --net test_net -e db_dsn=postgresql://postgres:secret@test_db/postgres testrunner 20 | -------------------------------------------------------------------------------- /tests/pytest/test_acme_account.py: -------------------------------------------------------------------------------- 1 | _mail_address = 'mailto:dummy@example.com' 2 | 3 | 4 | def test_should_return_error_for_non_existing_accounts(signed_request, directory): 5 | response = signed_request(directory['newAccount'], signed_request.nonce, {'onlyReturnExisting': True}) 6 | 7 | assert response.status_code == 400 8 | assert response.headers['Content-Type'] == 'application/problem+json' 9 | assert response.json()['type'] == 'urn:ietf:params:acme:error:accountDoesNotExist' 10 | 11 | 12 | def test_should_not_reflect_illegal_fields(signed_request, directory): 13 | response = signed_request(directory['newAccount'], signed_request.nonce, {'contact': [_mail_address], 'unknown': 'dummy', 'onlyReturnExisting': False}) 14 | assert response.status_code == 201 15 | assert 'unknown' not in response.json() 16 | assert 'onlyReturnExisting' not in response.json() 17 | 18 | 19 | def test_should_ignore_orders(signed_request, directory): 20 | response = signed_request(directory['newAccount'], signed_request.nonce, {'contact': [_mail_address], 'orders': 'http://localhost/dummy'}) 21 | assert response.status_code == 201 22 | assert response.json()['orders'] != 'http://localhost/dummy' 23 | 24 | 25 | def test_should_return_created_new_account_if_not_exist(signed_request, directory): 26 | response = signed_request(directory['newAccount'], signed_request.nonce, {'contact': [_mail_address]}) 27 | assert response.status_code == 201 28 | assert response.headers['Location'].startswith('http://localhost:8000/acme/accounts/') 29 | assert len(response.headers['Location']) == len('http://localhost:8000/acme/accounts/') + 22 30 | 31 | assert response.json()['status'] == 'valid' 32 | assert response.json()['contact'] == [_mail_address] 33 | assert response.json()['orders'] == response.headers['Location'] + '/orders' 34 | 35 | 36 | def test_should_return_existing_account(signed_request, directory): 37 | # signed_request uses both times the same jwk 38 | response1 = signed_request(directory['newAccount'], signed_request.nonce, {'contact': [_mail_address]}) 39 | response2 = signed_request(directory['newAccount'], signed_request.nonce, {'contact': [_mail_address]}) 40 | 41 | assert response1.status_code == 201 42 | assert response2.status_code == 200 43 | assert response1.headers['Location'] == response2.headers['Location'] 44 | assert response1.json() == response2.json() 45 | 46 | 47 | def test_should_update_account(signed_request, directory): 48 | response = signed_request(directory['newAccount'], signed_request.nonce, {'contact': [_mail_address]}) 49 | assert response.status_code == 201 50 | account_url = response.headers['Location'] 51 | 52 | response = signed_request(account_url, signed_request.nonce, {'contact': ['mailto:test@example.com'], 'status': 'deactivated'}, account_url) 53 | assert response.status_code == 200, response.json() 54 | 55 | response = signed_request(account_url, signed_request.nonce, {}, account_url) 56 | assert response.status_code == 200 57 | assert response.json()['status'] == 'deactivated' 58 | assert response.json()['contact'] == ['mailto:test@example.com'] 59 | 60 | 61 | def test_should_handle_account_mismatch(signed_request, directory): 62 | response = signed_request(directory['newAccount'], signed_request.nonce, {'contact': [_mail_address]}) 63 | assert response.status_code == 201 64 | account_url = response.headers['Location'] 65 | 66 | response = signed_request('http://localhost:8000/acme/accounts/hello123', signed_request.nonce, {}, account_url) 67 | assert response.status_code == 403 68 | assert response.headers['Content-Type'] == 'application/problem+json' 69 | assert response.json()['type'] == 'urn:ietf:params:acme:error:unauthorized' 70 | 71 | response = signed_request(account_url, signed_request.nonce, {}, 'http://localhost:8000/acme/accounts/hello123') 72 | assert response.status_code == 400 73 | assert response.headers['Content-Type'] == 'application/problem+json' 74 | assert response.json()['type'] == 'urn:ietf:params:acme:error:accountDoesNotExist' 75 | 76 | 77 | def test_should_show_account_orders(signed_request, directory): 78 | response = signed_request(directory['newAccount'], signed_request.nonce, {'contact': [_mail_address]}) 79 | assert response.status_code == 201 80 | account_url = response.headers['Location'] 81 | orders_url = response.json()['orders'] 82 | 83 | response = signed_request(directory['newOrder'], response.headers['Replay-Nonce'], {'identifiers': [{'type': 'dns', 'value': 'test.example.org'}]}, account_url) 84 | 85 | response = signed_request(orders_url, response.headers['Replay-Nonce'], '', account_url) 86 | assert response.status_code == 200 87 | assert len(response.json()['orders']) == 1 88 | assert response.json()['orders'][0].startswith('http://localhost:8000/acme/orders/'), response.json() 89 | -------------------------------------------------------------------------------- /tests/pytest/test_acme_certificate.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | import httpx 3 | from cryptography import x509 4 | from cryptography.hazmat.primitives.serialization import Encoding 5 | import jwcrypto 6 | 7 | from .utils import build_csr 8 | 9 | 10 | _mail_address = 'mailto:dummy@example.com' 11 | _host = 'example.com' 12 | 13 | 14 | def test_should_revoke_certificate(signed_request, directory): 15 | response = signed_request(directory['newAccount'], signed_request.nonce, {'contact': [_mail_address]}) 16 | account_id = response.headers['Location'] 17 | 18 | response = signed_request(directory['newOrder'], response.headers['Replay-Nonce'], {'identifiers': [{'type': 'dns', 'value': _host}]}, account_id) 19 | authz_url = response.json()['authorizations'][0] 20 | finalize_order_url = response.json()['finalize'] 21 | 22 | response = signed_request(authz_url, response.headers['Replay-Nonce'], '', account_id) 23 | challenge_token = response.json()['challenges'][0]['token'] 24 | challenge_url = response.json()['challenges'][0]['url'] 25 | 26 | mock_challenge_file_contents = f'{challenge_token}.{signed_request.account_jwk.thumbprint()}'.rstrip() 27 | 28 | with mock.patch( 29 | 'app.acme.challenge.service.httpx.AsyncClient.get', 30 | return_value=httpx.Response(200, text=mock_challenge_file_contents), 31 | ) as mock_get: 32 | response = signed_request(challenge_url, response.headers['Replay-Nonce'], '', account_id) 33 | 34 | mock_get.assert_called_once_with(f'http://{_host}:80/.well-known/acme-challenge/{challenge_token}') 35 | 36 | csr = build_csr([_host]) 37 | 38 | response = signed_request(finalize_order_url, response.headers['Replay-Nonce'], {'csr': jwcrypto.common.base64url_encode(csr.public_bytes(Encoding.DER))}, account_id) 39 | cert_url = response.json()['certificate'] 40 | 41 | response = signed_request(cert_url, response.headers['Replay-Nonce'], {}, account_id) 42 | signed_cert = x509.load_pem_x509_certificate(response.content) 43 | 44 | assert signed_cert.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME)[0].value == _host 45 | assert signed_cert.public_key() == csr.public_key() 46 | 47 | response = signed_request( 48 | directory['revokeCert'], response.headers['Replay-Nonce'], {'certificate': jwcrypto.common.base64url_encode(signed_cert.public_bytes(Encoding.DER))}, account_id 49 | ) 50 | assert response.status_code == 200 51 | assert response.headers['Content-Length'] == '0' 52 | 53 | response = signed_request( 54 | directory['revokeCert'], response.headers['Replay-Nonce'], {'certificate': jwcrypto.common.base64url_encode(signed_cert.public_bytes(Encoding.DER))}, account_id 55 | ) 56 | assert response.status_code == 400 57 | assert response.headers['Content-Type'] == 'application/problem+json' 58 | assert response.json()['type'] == 'urn:ietf:params:acme:error:alreadyRevoked' 59 | -------------------------------------------------------------------------------- /tests/pytest/test_acme_directory.py: -------------------------------------------------------------------------------- 1 | from .conftest import TestClient 2 | 3 | 4 | def test_show_directory(testclient: TestClient): 5 | response = testclient.get('/acme/directory') 6 | assert response.status_code == 200 7 | assert response.headers['Content-Type'] == 'application/json' 8 | assert response.json() == { 9 | 'newNonce': 'http://localhost:8000/acme/new-nonce', 10 | 'newAccount': 'http://localhost:8000/acme/new-account', 11 | 'newOrder': 'http://localhost:8000/acme/new-order', 12 | 'revokeCert': 'http://localhost:8000/acme/revoke-cert', 13 | 'keyChange': 'http://localhost:8000/acme/key-change', 14 | 'meta': {'website': 'http://localhost:8000/'}, 15 | } 16 | 17 | 18 | def test_directory_shows_terms(testclient: TestClient, monkeypatch): 19 | import config 20 | 21 | monkeypatch.setattr(config.settings.acme, 'terms_of_service_url', 'https://example.com/terms.html') 22 | response = testclient.get('/acme/directory') 23 | assert response.status_code == 200 24 | assert response.json() == { 25 | 'newNonce': 'http://localhost:8000/acme/new-nonce', 26 | 'newAccount': 'http://localhost:8000/acme/new-account', 27 | 'newOrder': 'http://localhost:8000/acme/new-order', 28 | 'revokeCert': 'http://localhost:8000/acme/revoke-cert', 29 | 'keyChange': 'http://localhost:8000/acme/key-change', 30 | 'meta': {'termsOfService': 'https://example.com/terms.html', 'website': 'http://localhost:8000/'}, 31 | } 32 | -------------------------------------------------------------------------------- /tests/pytest/test_acme_nonce.py: -------------------------------------------------------------------------------- 1 | from .conftest import TestClient 2 | 3 | import re 4 | import jwcrypto 5 | import datetime 6 | 7 | 8 | def test_generate_nonce(testclient: TestClient, directory): 9 | response = testclient.get(directory['newNonce']) 10 | assert response.status_code == 204 11 | nonce = response.headers['Replay-Nonce'] 12 | assert len(nonce) == 43 13 | assert re.match('^[A-Za-z0-9_-]+$', nonce) 14 | assert len(jwcrypto.common.base64url_decode(nonce)) >= 128 // 8, 'minimum 128bit entropy' 15 | response2 = testclient.head(directory['newNonce']) 16 | nonce2 = response2.headers['Replay-Nonce'] 17 | assert nonce != nonce2 18 | 19 | 20 | def test_should_fail_on_bad_nonce(signed_request, directory): 21 | response = signed_request(directory['newAccount'], 'not-a-correct-nonce', {'contact': ['mailto:dummy@example.com']}) 22 | 23 | assert response.status_code == 400 24 | assert response.headers['Content-Type'] == 'application/problem+json' 25 | assert response.json()['type'] == 'urn:ietf:params:acme:error:badNonce' 26 | assert response.json()['detail'] == 'old nonce is wrong' 27 | 28 | 29 | def test_should_persist_new_nonce_with_expiration(testclient: TestClient, directory, db) -> None: 30 | nonce = testclient.get(directory['newNonce']).headers['Replay-Nonce'] 31 | 32 | age, *_ = db.fetch_row('select expires_at - now() from nonces where id=$1', nonce) 33 | assert datetime.timedelta(minutes=29, seconds=59) < age < datetime.timedelta(minutes=30, milliseconds=50) 34 | -------------------------------------------------------------------------------- /tests/pytest/utils.py: -------------------------------------------------------------------------------- 1 | from cryptography import x509 2 | from cryptography.hazmat.primitives import hashes 3 | from cryptography.hazmat.primitives.asymmetric import rsa 4 | 5 | 6 | def build_csr(names: list[str]) -> x509.CertificateSigningRequest: 7 | """Returns a certificate signing request containing the given names. 8 | 9 | The first given name is used as the common name. Any further names are used as subject alternative names. 10 | The signing request is signed using a freshly generated RSA private key with 2048 bit length and SHA256 hashing. 11 | """ 12 | return ( 13 | x509.CertificateSigningRequestBuilder() 14 | .subject_name(x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, names[0])])) 15 | .add_extension(x509.SubjectAlternativeName([x509.DNSName(name) for name in names[1:]]), critical=False) 16 | .sign(rsa.generate_private_key(65537, 2048), hashes.SHA256()) 17 | ) 18 | --------------------------------------------------------------------------------