Compare commits
225 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c93a261aad | ||
|
|
ee2bdafc56 | ||
|
|
680d998597 | ||
|
|
31599bad83 | ||
|
|
b682687449 | ||
|
|
2e827c5da6 | ||
|
|
8e9d867447 | ||
|
|
1e8c09c05f | ||
|
|
4a1a136fcb | ||
|
|
42789114b3 | ||
|
|
9a08102f43 | ||
|
|
6a72811a39 | ||
|
|
f417f24998 | ||
|
|
10b019b3b8 | ||
|
|
47b44a6751 | ||
|
|
4c5492fbec | ||
|
|
1d9fc8dccf | ||
|
|
a75057042f | ||
|
|
95a70e98c2 | ||
|
|
48f34938c6 | ||
|
|
3cbe1288c9 | ||
|
|
e873874752 | ||
|
|
dbd0c6fce8 | ||
|
|
7a27a67cdb | ||
|
|
5d03191493 | ||
|
|
723fe64d4d | ||
|
|
c5686e6653 | ||
|
|
fde359f4da | ||
|
|
10747555ae | ||
|
|
0fc755fe08 | ||
|
|
dcdfdacf75 | ||
|
|
0075104805 | ||
|
|
2cf6cda1fa | ||
|
|
6418ee32e5 | ||
|
|
cc08242abc | ||
|
|
62361dac44 | ||
|
|
5dcfd32a11 | ||
|
|
6ac951e146 | ||
|
|
508fba1da6 | ||
|
|
2da39317b2 | ||
|
|
16f858547f | ||
|
|
6de7570af0 | ||
|
|
9bc9e3412e | ||
|
|
f822602fff | ||
|
|
52ad3e80bd | ||
|
|
d9e3d7b2d2 | ||
|
|
d95a389c3f | ||
|
|
f7a0df3461 | ||
|
|
cb5d579a84 | ||
|
|
45626e88e2 | ||
|
|
15024aabd3 | ||
|
|
dd876a40ed | ||
|
|
7a90cdd231 | ||
|
|
7d461a8dfc | ||
|
|
83510c1da5 | ||
|
|
0b51653def | ||
|
|
aa005f20fe | ||
|
|
8a6138856f | ||
|
|
7322e56cc7 | ||
|
|
df9075e023 | ||
|
|
d91e552491 | ||
|
|
b3bd4304f4 | ||
|
|
37f6f8a12c | ||
|
|
be60ad5131 | ||
|
|
16cbd6c00a | ||
|
|
3b19e18641 | ||
|
|
259afd7458 | ||
|
|
b7e09dd652 | ||
|
|
8487bfeaa5 | ||
|
|
c323af7be9 | ||
|
|
2a92e22332 | ||
|
|
48ffe02ce3 | ||
|
|
a43fdedd12 | ||
|
|
4cffcbffaa | ||
|
|
65c33488dd | ||
|
|
487dd53103 | ||
|
|
d3d0b76f9f | ||
|
|
52ee2a5e8b | ||
|
|
a6bed18f0b | ||
|
|
792a76569d | ||
|
|
9105cd21ba | ||
|
|
6fd6a541d4 | ||
|
|
cda56361ad | ||
|
|
d3aceba188 | ||
|
|
8524255a2e | ||
|
|
a32fc2d6f0 | ||
|
|
a24f14d48f | ||
|
|
3ab9bf9f39 | ||
|
|
4708012b2d | ||
|
|
8a2ded2d0e | ||
|
|
77594d7300 | ||
|
|
fa9cd61066 | ||
|
|
a6d8c467f0 | ||
|
|
a0e8b49057 | ||
|
|
3272dcbebd | ||
|
|
3395dce319 | ||
|
|
ad29d5fbcf | ||
|
|
de48847af4 | ||
|
|
a57b29a276 | ||
|
|
9989e7b82f | ||
|
|
aafe7ba2f9 | ||
|
|
87e5dcbc83 | ||
|
|
2535a7bb29 | ||
|
|
e32f4fc5fb | ||
|
|
b5f7fe179e | ||
|
|
2ae7f83e2a | ||
|
|
935855b751 | ||
|
|
0dedef801a | ||
|
|
d5cb89ba13 | ||
|
|
04f3072399 | ||
|
|
392467609f | ||
|
|
7661dbaf82 | ||
|
|
014e554f70 | ||
|
|
f5bfe543ff | ||
|
|
e8cc2df316 | ||
|
|
f94c981dfd | ||
|
|
f0f3cdad9c | ||
|
|
6f46e1be15 | ||
|
|
ec3330ee0c | ||
|
|
e657cc3a8d | ||
|
|
a46db66371 | ||
|
|
d98edd97ad | ||
|
|
70ba4f2438 | ||
|
|
9d049723c2 | ||
|
|
60b88a3b83 | ||
|
|
e0e81a97f2 | ||
|
|
e050fe91a3 | ||
|
|
ed972a130f | ||
|
|
b411cddc8a | ||
|
|
40f0b91512 | ||
|
|
7e87acee3c | ||
|
|
680729655e | ||
|
|
94dcf25f6e | ||
|
|
96c4bcd9a8 | ||
|
|
a00e343459 | ||
|
|
86694397a6 | ||
|
|
b18c074088 | ||
|
|
f59a639ec4 | ||
|
|
5411e4c86a | ||
|
|
0425b87b78 | ||
|
|
1de966d637 | ||
|
|
ba2e4aecb7 | ||
|
|
7d2b1996d9 | ||
|
|
dcd52b0711 | ||
|
|
8074858620 | ||
|
|
d3d293299a | ||
|
|
9148acd332 | ||
|
|
9f9a1df85e | ||
|
|
985457e57b | ||
|
|
4004589cbf | ||
|
|
8f7c3756b3 | ||
|
|
6ea5da51e0 | ||
|
|
1ac05ae891 | ||
|
|
a441debdaa | ||
|
|
5dd898f56b | ||
|
|
a1fce6b398 | ||
|
|
635d9c3ec3 | ||
|
|
0f36d0c1ba | ||
|
|
619da0432a | ||
|
|
314838eb81 | ||
|
|
25a1933e01 | ||
|
|
0f500e8010 | ||
|
|
1afae838bb | ||
|
|
724be8848a | ||
|
|
06ea141ca9 | ||
|
|
23245c07b2 | ||
|
|
2d1d1cd534 | ||
|
|
5240e3cbf2 | ||
|
|
5fca4a14ab | ||
|
|
9be070414f | ||
|
|
761c268934 | ||
|
|
1fa110c9d7 | ||
|
|
9d1fccf53a | ||
|
|
b16c64a05b | ||
|
|
88932da859 | ||
|
|
8a69b2f1d9 | ||
|
|
57b5942fc3 | ||
|
|
0f0000298b | ||
|
|
c39fbe388c | ||
|
|
fc07f5f935 | ||
|
|
9c8cdd05da | ||
|
|
2c8609464c | ||
|
|
7a48c235a9 | ||
|
|
3f9387bd15 | ||
|
|
087cb4d1f4 | ||
|
|
bcbc3dd484 | ||
|
|
89737718c1 | ||
|
|
b0e389aad7 | ||
|
|
9f5451d16b | ||
|
|
5ada20cb74 | ||
|
|
ba256adcdb | ||
|
|
94adff7247 | ||
|
|
06d6231d6d | ||
|
|
61da18cc47 | ||
|
|
7ab421233e | ||
|
|
59f32c9d11 | ||
|
|
2b57c5f03c | ||
|
|
8f75af1e84 | ||
|
|
3c9b936168 | ||
|
|
93294fc989 | ||
|
|
58a07ddd79 | ||
|
|
933c1703b6 | ||
|
|
a44d739dd7 | ||
|
|
58374867c8 | ||
|
|
aa6ea3b513 | ||
|
|
a25ef72c4f | ||
|
|
396b6cce02 | ||
|
|
38fc7fcc48 | ||
|
|
0e225dcba2 | ||
|
|
4ff5719a65 | ||
|
|
798a61622c | ||
|
|
b20d01e032 | ||
|
|
990352e371 | ||
|
|
c5a5d6f9a1 | ||
|
|
d4850399c5 | ||
|
|
c4be440853 | ||
|
|
165c3e32b0 | ||
|
|
2660a2017b | ||
|
|
6a6544fd90 | ||
|
|
320cf92944 | ||
|
|
3078c2f3db | ||
|
|
c54f99e35b | ||
|
|
c81dbb2582 | ||
|
|
742f97e11a | ||
|
|
84c8dbc52a |
@@ -1,8 +1,8 @@
|
||||
# Configuring Azure Pipelines with Certbot
|
||||
|
||||
Let's begin. All pipelines are defined in `.azure-pipelines`. Currently there are two:
|
||||
* `.azure-pipelines/main.yml` is the main one, executed on PRs for master, and pushes to master,
|
||||
* `.azure-pipelines/advanced.yml` add installer testing on top of the main pipeline, and is executed for `test-*` branches, release branches, and nightly run for master.
|
||||
* `.azure-pipelines/main.yml` is the main one, executed on PRs for main, and pushes to main,
|
||||
* `.azure-pipelines/advanced.yml` add installer testing on top of the main pipeline, and is executed for `test-*` branches, release branches, and nightly run for main.
|
||||
|
||||
Several templates are defined in `.azure-pipelines/templates`. These YAML files aggregate common jobs configuration that can be reused in several pipelines.
|
||||
|
||||
@@ -64,7 +64,7 @@ Azure Pipeline needs RW on code, RO on metadata, RW on checks, commit statuses,
|
||||
RW access here is required to allow update of the pipelines YAML files from Azure DevOps interface, and to
|
||||
update the status of builds and PRs on GitHub side when Azure Pipelines are triggered.
|
||||
Note however that no admin access is defined here: this means that Azure Pipelines cannot do anything with
|
||||
protected branches, like master, and cannot modify the security context around this on GitHub.
|
||||
protected branches, like main, and cannot modify the security context around this on GitHub.
|
||||
Access can be defined for all or only selected repositories, which is nice.
|
||||
```
|
||||
|
||||
@@ -91,11 +91,11 @@ grant permissions from Azure Pipelines to GitHub in order to setup a GitHub OAut
|
||||
then are way too large (admin level on almost everything), while the classic approach does not add any more
|
||||
permissions, and works perfectly well.__
|
||||
|
||||
- Select GitHub in "Select your repository section", choose certbot/certbot in Repository, master in default branch.
|
||||
- Select GitHub in "Select your repository section", choose certbot/certbot in Repository, main in default branch.
|
||||
- Click on YAML option for "Select a template"
|
||||
- Choose a name for the pipeline (eg. test-pipeline), and browse to the actual pipeline YAML definition in the
|
||||
"YAML file path" input (eg. `.azure-pipelines/test-pipeline.yml`)
|
||||
- Click "Save & queue", choose the master branch to build the first pipeline, and click "Save and run" button.
|
||||
- Click "Save & queue", choose the main branch to build the first pipeline, and click "Save and run" button.
|
||||
|
||||
_Done. Pipeline is operational. Repeat to add more pipelines from existing YAML files in `.azure-pipelines`._
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# We run the test suite on commits to master so codecov gets coverage data
|
||||
# about the master branch and can use it to track coverage changes.
|
||||
# We run the test suite on commits to main so codecov gets coverage data
|
||||
# about the main branch and can use it to track coverage changes.
|
||||
trigger:
|
||||
- master
|
||||
- main
|
||||
pr:
|
||||
- master
|
||||
- main
|
||||
- '*.x'
|
||||
|
||||
variables:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Nightly pipeline running each day for master.
|
||||
# Nightly pipeline running each day for main.
|
||||
trigger: none
|
||||
pr: none
|
||||
schedules:
|
||||
@@ -6,7 +6,7 @@ schedules:
|
||||
displayName: Nightly build
|
||||
branches:
|
||||
include:
|
||||
- master
|
||||
- main
|
||||
always: true
|
||||
|
||||
variables:
|
||||
|
||||
@@ -4,7 +4,7 @@ jobs:
|
||||
- name: IMAGE_NAME
|
||||
value: ubuntu-22.04
|
||||
- name: PYTHON_VERSION
|
||||
value: 3.12
|
||||
value: 3.13
|
||||
- group: certbot-common
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -17,16 +17,17 @@ jobs:
|
||||
linux-py311:
|
||||
PYTHON_VERSION: 3.11
|
||||
TOXENV: py311
|
||||
linux-py312:
|
||||
PYTHON_VERSION: 3.12
|
||||
TOXENV: py312
|
||||
linux-isolated:
|
||||
TOXENV: 'isolated-acme,isolated-certbot,isolated-apache,isolated-cloudflare,isolated-digitalocean,isolated-dnsimple,isolated-dnsmadeeasy,isolated-gehirn,isolated-google,isolated-linode,isolated-luadns,isolated-nsone,isolated-ovh,isolated-rfc2136,isolated-route53,isolated-sakuracloud,isolated-nginx'
|
||||
linux-integration-certbot-oldest:
|
||||
PYTHON_VERSION: 3.8
|
||||
PYTHON_VERSION: 3.9
|
||||
TOXENV: integration-certbot-oldest
|
||||
linux-integration-nginx-oldest:
|
||||
PYTHON_VERSION: 3.8
|
||||
PYTHON_VERSION: 3.9
|
||||
TOXENV: integration-nginx-oldest
|
||||
# python 3.8 integration tests are not run here because they're run as
|
||||
# part of the standard test suite
|
||||
linux-py39-integration:
|
||||
PYTHON_VERSION: 3.9
|
||||
TOXENV: integration
|
||||
@@ -39,17 +40,19 @@ jobs:
|
||||
linux-py312-integration:
|
||||
PYTHON_VERSION: 3.12
|
||||
TOXENV: integration
|
||||
# python 3.13 integration tests are not run here because they're run as
|
||||
# part of the standard test suite
|
||||
nginx-compat:
|
||||
TOXENV: nginx_compat
|
||||
linux-integration-rfc2136:
|
||||
IMAGE_NAME: ubuntu-22.04
|
||||
PYTHON_VERSION: 3.8
|
||||
PYTHON_VERSION: 3.12
|
||||
TOXENV: integration-dns-rfc2136
|
||||
le-modification:
|
||||
IMAGE_NAME: ubuntu-22.04
|
||||
TOXENV: modification
|
||||
farmtest-apache2:
|
||||
PYTHON_VERSION: 3.8
|
||||
PYTHON_VERSION: 3.12
|
||||
TOXENV: test-farm-apache2
|
||||
pool:
|
||||
vmImage: $(IMAGE_NAME)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
jobs:
|
||||
- job: docker_build
|
||||
pool:
|
||||
vmImage: ubuntu-22.04
|
||||
vmImage: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
arm32v6:
|
||||
@@ -155,5 +155,5 @@ jobs:
|
||||
displayName: Prepare Certbot-CI
|
||||
- script: |
|
||||
set -e
|
||||
sudo -E venv/bin/pytest certbot-ci/snap_integration_tests/dns_tests --allow-persistent-changes --snap-folder $(Build.SourcesDirectory)/snap --snap-arch amd64
|
||||
sudo -E venv/bin/pytest certbot-ci/src/snap_integration_tests/dns_tests --allow-persistent-changes --snap-folder $(Build.SourcesDirectory)/snap --snap-arch amd64
|
||||
displayName: Test DNS plugins snaps
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
jobs:
|
||||
- job: test
|
||||
variables:
|
||||
PYTHON_VERSION: 3.12
|
||||
PYTHON_VERSION: 3.13
|
||||
strategy:
|
||||
matrix:
|
||||
macos-py38-cover:
|
||||
IMAGE_NAME: macOS-12
|
||||
PYTHON_VERSION: 3.8
|
||||
macos-cover:
|
||||
IMAGE_NAME: macOS-15
|
||||
TOXENV: cover
|
||||
# As of pip 23.1.0, builds started failing on macOS unless this flag was set.
|
||||
# See https://github.com/certbot/certbot/pull/9717#issuecomment-1610861794.
|
||||
PIP_USE_PEP517: "true"
|
||||
macos-cover:
|
||||
IMAGE_NAME: macOS-13
|
||||
TOXENV: cover
|
||||
# See explanation under macos-py38-cover.
|
||||
PIP_USE_PEP517: "true"
|
||||
linux-oldest:
|
||||
IMAGE_NAME: ubuntu-22.04
|
||||
PYTHON_VERSION: 3.8
|
||||
PYTHON_VERSION: 3.9
|
||||
TOXENV: oldest
|
||||
linux-py38:
|
||||
linux-py39:
|
||||
# linux unit tests with the oldest python we support
|
||||
IMAGE_NAME: ubuntu-22.04
|
||||
PYTHON_VERSION: 3.8
|
||||
TOXENV: py38
|
||||
PYTHON_VERSION: 3.9
|
||||
TOXENV: py39
|
||||
linux-cover:
|
||||
# linux unit+cover tests with the newest python we support
|
||||
IMAGE_NAME: ubuntu-22.04
|
||||
TOXENV: cover
|
||||
linux-lint:
|
||||
@@ -35,7 +31,6 @@ jobs:
|
||||
TOXENV: mypy
|
||||
linux-integration:
|
||||
IMAGE_NAME: ubuntu-22.04
|
||||
PYTHON_VERSION: 3.8
|
||||
TOXENV: integration
|
||||
apache-compat:
|
||||
IMAGE_NAME: ubuntu-22.04
|
||||
@@ -52,6 +47,6 @@ jobs:
|
||||
- template: ../steps/tox-steps.yml
|
||||
- job: test_sphinx_builds
|
||||
pool:
|
||||
vmImage: ubuntu-20.04
|
||||
vmImage: ubuntu-22.04
|
||||
steps:
|
||||
- template: ../steps/sphinx-steps.yml
|
||||
|
||||
@@ -8,7 +8,7 @@ stages:
|
||||
# If we change the output filename from `release_notes.md`, it should also be changed in tools/create_github_release.py
|
||||
- bash: |
|
||||
set -e
|
||||
CERTBOT_VERSION="$(cd certbot && python -c "import certbot; print(certbot.__version__)" && cd ~-)"
|
||||
CERTBOT_VERSION="$(cd certbot/src && python -c "import certbot; print(certbot.__version__)" && cd ~-)"
|
||||
"${BUILD_REPOSITORY_LOCALPATH}\tools\extract_changelog.py" "${CERTBOT_VERSION}" >> "${BUILD_ARTIFACTSTAGINGDIRECTORY}/release_notes.md"
|
||||
displayName: Prepare changelog
|
||||
- task: PublishPipelineArtifact@1
|
||||
|
||||
@@ -5,7 +5,7 @@ stages:
|
||||
variables:
|
||||
- group: certbot-common
|
||||
pool:
|
||||
vmImage: ubuntu-20.04
|
||||
vmImage: ubuntu-latest
|
||||
steps:
|
||||
- bash: |
|
||||
set -e
|
||||
|
||||
@@ -15,22 +15,11 @@ stages:
|
||||
- task: GitHubRelease@1
|
||||
inputs:
|
||||
# this "github-releases" credential is what azure pipelines calls a
|
||||
# "service connection". it was created using the instructions at
|
||||
# https://learn.microsoft.com/en-us/azure/devops/pipelines/library/service-endpoints?view=azure-devops&tabs=yaml#github-service-connection
|
||||
# with a fine-grained personal access token from github to limit
|
||||
# the permissions given to azure pipelines. the connection on azure
|
||||
# needs permissions for the "release" pipeline (and maybe the
|
||||
# "full-test-suite" pipeline to simplify testing it). information
|
||||
# on how to set up these permissions can be found at
|
||||
# https://learn.microsoft.com/en-us/azure/devops/pipelines/library/service-endpoints?view=azure-devops&tabs=yaml#secure-a-service-connection.
|
||||
# the github token that is used needs "contents:write" and
|
||||
# "workflows:write" permissions for the certbot repo
|
||||
# "service connection". it needs to be recreated annually. instructions
|
||||
# to do so and further information about the token are available at
|
||||
# https://github.com/EFForg/certbot-misc/wiki/Azure-Pipelines-setup#regenerating-github-release-credentials-for-use-on-azure
|
||||
#
|
||||
# as of writing this, the current token will expire on 3/15/2025.
|
||||
# when recreating it, you may also want to create it using the
|
||||
# shared "certbotbot" github account so the credentials aren't tied
|
||||
# to any one dev's github account and their access to the certbot
|
||||
# repo
|
||||
# as of writing this, the current token will expire on Wed, Feb 25 2026.
|
||||
gitHubConnection: github-releases
|
||||
title: ${{ format('Certbot {0}', replace(variables['Build.SourceBranchName'], 'v', '')) }}
|
||||
releaseNotesFilePath: '$(Pipeline.Workspace)/release_notes.md'
|
||||
|
||||
@@ -2,7 +2,7 @@ steps:
|
||||
- bash: |
|
||||
set -e
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends libaugeas0
|
||||
sudo apt-get install -y --no-install-recommends libaugeas-dev
|
||||
FINAL_STATUS=0
|
||||
declare -a FAILED_BUILDS
|
||||
tools/venv.py
|
||||
|
||||
@@ -20,7 +20,7 @@ steps:
|
||||
set -e
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libaugeas0 \
|
||||
libaugeas-dev \
|
||||
nginx-light
|
||||
sudo systemctl stop nginx
|
||||
sudo sysctl net.ipv4.ip_unprivileged_port_start=0
|
||||
|
||||
69
.github/ISSUE_TEMPLATE/bug.yaml
vendored
Normal file
69
.github/ISSUE_TEMPLATE/bug.yaml
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
name: Bug Report
|
||||
description: File a bug report.
|
||||
title: "[Bug]: "
|
||||
type: Bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report! If you're
|
||||
having trouble using Certbot and aren't sure you've found a bug,
|
||||
please first try asking for help at https://community.letsencrypt.org/.
|
||||
There is a much larger community there of people familiar with the
|
||||
project who will be able to more quickly answer your questions.
|
||||
- type: input
|
||||
attributes:
|
||||
label: OS
|
||||
description: |
|
||||
Describe your Operating System. Examples: Ubuntu 18.04, CentOS 8 Stream
|
||||
placeholder: Ubuntu 24.04
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Installation method
|
||||
description: |
|
||||
How did you install Certbot? Examples: snap, pip, apt, yum
|
||||
placeholder: snap
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Certbot Version
|
||||
description: |
|
||||
If you're not sure, you can find this by running `certbot --version`.
|
||||
placeholder: 1.0.0
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: |
|
||||
I ran this command and it produced this output. Example:
|
||||
```
|
||||
$ sudo certbot certonly -d adfsfasdf.asdfasdf --staging
|
||||
Saving debug log to /var/log/letsencrypt/letsencrypt.log
|
||||
Plugins selected: Authenticator nginx, Installer nginx
|
||||
Requesting a certificate for example.com
|
||||
An unexpected error occurred:
|
||||
Invalid identifiers requested :: Cannot issue for "adfsfasdf.asdfasdf": Domain name does not end with a valid public suffix (TLD)
|
||||
Ask for help or search for solutions at https://community.letsencrypt.org. See the logfile /var/log/letsencrypt/letsencrypt.log or re-run Certbot with -v for more details.
|
||||
```
|
||||
placeholder: Tell us what you see!
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: Certbot's behavior differed from what I expected because.
|
||||
placeholder: "What was expected?"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: Here is a Certbot log showing the issue (if available). Logs are stored in `/var/log/letsencrypt` by default. Feel free to redact domains, e-mail and IP addresses as you see fit.
|
||||
render: shell
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Let's Encrypt Community Support
|
||||
url: https://community.letsencrypt.org/
|
||||
about: If you're having trouble using Certbot and aren't sure you've found a bug or request for a new feature, please first try asking for help here. There is a much larger community there of people familiar with the project who will be able to more quickly answer your questions.
|
||||
- name: Certbot Security Policy
|
||||
url: https://github.com/certbot/certbot/security/advisories/new
|
||||
about: Please report security vulnerabilities here.
|
||||
27
.github/ISSUE_TEMPLATE/feature.yaml
vendored
Normal file
27
.github/ISSUE_TEMPLATE/feature.yaml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement to Certbot
|
||||
title: "[Feature Request]: "
|
||||
type: Feature
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What problem does this feature solve or what does it enhance?
|
||||
description: Explain what this feature addresses, or the benefit it provides.
|
||||
placeholder: For example, "Currently, users have to manually do X, which is time-consuming."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe the solution you'd like to see implemented.
|
||||
placeholder: For example, "Implement a new button that automatically does X."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Have you considered any alternative solutions?
|
||||
placeholder: For example, "We considered Y, but Z is a better approach because..."
|
||||
15
.github/ISSUE_TEMPLATE/task.yaml
vendored
Normal file
15
.github/ISSUE_TEMPLATE/task.yaml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
name: Task
|
||||
description: A codebase upkeep task such as managing deprecations and refactoring
|
||||
title: "[Task]: "
|
||||
type: Task
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Task description
|
||||
description: Describe the work that needs to happen, and why.
|
||||
placeholder: >
|
||||
For example, "In issue [link here], we noted that we cannot update [dependency] until
|
||||
[something happens]. That thing has happened, so now we should update [dependency]."
|
||||
validations:
|
||||
required: true
|
||||
22
.github/issue_template.md
vendored
22
.github/issue_template.md
vendored
@@ -1,22 +0,0 @@
|
||||
If you're having trouble using Certbot and aren't sure you've found a bug or
|
||||
request for a new feature, please first try asking for help at
|
||||
https://community.letsencrypt.org/. There is a much larger community there of
|
||||
people familiar with the project who will be able to more quickly answer your
|
||||
questions.
|
||||
|
||||
## My operating system is (include version):
|
||||
|
||||
|
||||
## I installed Certbot with (snap, OS package manager, pip, certbot-auto, etc):
|
||||
|
||||
|
||||
## I ran this command and it produced this output:
|
||||
|
||||
|
||||
## Certbot's behavior differed from what I expected because:
|
||||
|
||||
|
||||
## Here is a Certbot log showing the issue (if available):
|
||||
###### Logs are stored in `/var/log/letsencrypt` by default. Feel free to redact domains, e-mail and IP addresses as you see fit.
|
||||
|
||||
## Here is the relevant nginx server block or Apache virtualhost for the domain I am configuring:
|
||||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -1,6 +1,6 @@
|
||||
## Pull Request Checklist
|
||||
|
||||
- [ ] The Certbot team has recently expressed interest in reviewing a PR for this. If not, this PR may be closed due our limited resources and need to prioritize how we spend them.
|
||||
- [ ] If the change being made is to a [distributed component](https://certbot.eff.org/docs/contributing.html#code-components-and-layout), edit the `master` section of `certbot/CHANGELOG.md` to include a description of the change being made.
|
||||
- [ ] If the change being made is to a [distributed component](https://certbot.eff.org/docs/contributing.html#code-components-and-layout), edit the `main` section of `certbot/CHANGELOG.md` to include a description of the change being made.
|
||||
- [ ] Add or update any documentation as needed to support the changes in this PR.
|
||||
- [ ] Include your name in `AUTHORS.md` if you like.
|
||||
|
||||
17
.github/workflows/assigned.yaml
vendored
Normal file
17
.github/workflows/assigned.yaml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Issue Assigned
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [assigned]
|
||||
|
||||
pull_request_target:
|
||||
types: [assigned]
|
||||
jobs:
|
||||
send-mattermost-message:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: mattermost/action-mattermost-notify@master
|
||||
with:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_ASSIGN_WEBHOOK }}
|
||||
TEXT: >
|
||||
${{ github.event.issue.assignee.login || github.event.pull_request.assignee.login }} assigned to "${{ github.event.issue.title || github.event.pull_request.title }}": ${{ github.event.issue.html_url || github.event.pull_request.html_url }}
|
||||
7
.github/workflows/merged.yaml
vendored
7
.github/workflows/merged.yaml
vendored
@@ -1,14 +1,13 @@
|
||||
name: Merge Event
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types:
|
||||
- closed
|
||||
|
||||
jobs:
|
||||
if_merged:
|
||||
# Forked repos can not access Mattermost secret.
|
||||
if: github.event.pull_request.merged == true && !github.event.pull_request.head.repo.fork
|
||||
if: github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: mattermost/action-mattermost-notify@master
|
||||
@@ -18,4 +17,4 @@ jobs:
|
||||
[${{ github.repository }}] |
|
||||
[${{ github.event.pull_request.title }}
|
||||
#${{ github.event.number }}](https://github.com/${{ github.repository }}/pull/${{ github.event.number }})
|
||||
was merged into master by ${{ github.actor }}
|
||||
was merged into main by ${{ github.actor }}
|
||||
|
||||
16
.github/workflows/notify_weekly.yaml
vendored
16
.github/workflows/notify_weekly.yaml
vendored
@@ -2,8 +2,8 @@ name: Weekly Github Update
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every week on Thursday @ 13:00
|
||||
- cron: "0 13 * * 4"
|
||||
# Every week on Thursday @ 10:00
|
||||
- cron: "0 10 * * 4"
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
send-mattermost-message:
|
||||
@@ -13,15 +13,13 @@ jobs:
|
||||
- name: Create Mattermost Message
|
||||
run: |
|
||||
DATE=$(date --date="7 days ago" +"%Y-%m-%d")
|
||||
echo "MERGED_URL=https://github.com/pulls?q=merged%3A%3E${DATE}+org%3Acertbot" >> $GITHUB_ENV
|
||||
echo "UPDATED_URL=https://github.com/pulls?q=updated%3A%3E${DATE}+org%3Acertbot" >> $GITHUB_ENV
|
||||
echo "ASSIGNED_PRS=https://github.com/pulls?q=is%3Apr+is%3Aopen+updated%3A%3E%3D${DATE}+assignee%3A*+user%3Acertbot" >> $GITHUB_ENV
|
||||
echo "UPDATED_URL=https://github.com/issues?q=is%3Aissue+is%3Aopen+sort%3Acomments-desc+updated%3A%3E%3D${DATE}+user%3Acertbot" >> $GITHUB_ENV
|
||||
- uses: mattermost/action-mattermost-notify@master
|
||||
with:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK_URL }}
|
||||
MATTERMOST_CHANNEL: private-certbot
|
||||
TEXT: |
|
||||
## Updates Across Certbot Repos
|
||||
- Certbot team members SHOULD look at: [link](${{ env.MERGED_URL }})
|
||||
- Certbot team members MAY also want to look at: [link](${{ env.UPDATED_URL }})
|
||||
- Want to Discuss something today? Place it [here](https://docs.google.com/document/d/17YMUbtC1yg6MfiTMwT8zVm9LmO-cuGVBom0qFn8XJBM/edit?usp=sharing) and we can meet today on Zoom.
|
||||
- The key words SHOULD and MAY in this message are to be interpreted as described in [RFC 8147](https://www.rfc-editor.org/rfc/rfc8174).
|
||||
## Updates In the Past Week
|
||||
- Most commented in the last week: [link](${{ env.UPDATED_URL }})
|
||||
- Updated (assigned) PRs in the last week: [link](${{ env.ASSIGNED_PRS }})
|
||||
|
||||
5
.github/workflows/stale.yml
vendored
5
.github/workflows/stale.yml
vendored
@@ -28,7 +28,10 @@ jobs:
|
||||
exempt-all-issue-assignees: true
|
||||
|
||||
# Label to use when marking as stale
|
||||
stale-issue-label: needs-update
|
||||
stale-issue-label: stale-needs-update
|
||||
|
||||
# Label to use when issue is automatically closed
|
||||
close-issue-label: auto-closed
|
||||
|
||||
stale-issue-message: >
|
||||
We've made a lot of changes to Certbot since this issue was opened. If you
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -27,6 +27,8 @@ tags
|
||||
.idea
|
||||
.ropeproject
|
||||
.vscode
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# auth --cert-path --chain-path
|
||||
/*.pem
|
||||
|
||||
@@ -4,4 +4,4 @@ force_sort_within_sections=True
|
||||
force_single_line=True
|
||||
order_by_type=False
|
||||
line_length=400
|
||||
src_paths=acme/acme,acme/tests,certbot*/certbot*,certbot*/tests
|
||||
src_paths=acme/src,acme/tests,certbot*/tests,certbot/src,certbot*/src/certbot*
|
||||
|
||||
@@ -139,6 +139,7 @@ Authors
|
||||
* [John Reed](https://github.com/leerspace)
|
||||
* [Jonas Berlin](https://github.com/xkr47)
|
||||
* [Jonathan Herlin](https://github.com/Jonher937)
|
||||
* [Jonathan Vanasco](https://github.com/jvanasco)
|
||||
* [Jon Walsh](https://github.com/code-tree)
|
||||
* [Joona Hoikkala](https://github.com/joohoi)
|
||||
* [Josh McCullough](https://github.com/JoshMcCullough)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<!---
|
||||
|
||||
zoracon: (This is an old comment below, not sure how accurate this is anymore.
|
||||
Since Github seems to lean more towards Markdown these days, it's still probably accurate)
|
||||
|
||||
This file serves as an entry point for GitHub's Contributing
|
||||
Guidelines [1] only.
|
||||
|
||||
@@ -19,19 +22,15 @@ to the Sphinx generated docs is provided below.
|
||||
|
||||
Hi! Welcome to the Certbot project. We look forward to collaborating with you.
|
||||
|
||||
If you're reporting a bug in Certbot, please make sure to include:
|
||||
- The version of Certbot you're running.
|
||||
- The operating system you're running it on.
|
||||
- The commands you ran.
|
||||
- What you expected to happen, and
|
||||
- What actually happened.
|
||||
If you're reporting a bug in Certbot. Please open an issue: https://github.com/certbot/certbot/issues/new/choose.
|
||||
|
||||
If you're having trouble using Certbot and aren't sure you've found a bug, please first try asking for help at https://community.letsencrypt.org/. There is a much larger community there of people familiar with the project who will be able to more quickly answer your questions.
|
||||
|
||||
If you're a developer, we have some helpful information in our
|
||||
[Developer's Guide](https://certbot.eff.org/docs/contributing.html) to get you
|
||||
started. In particular, we recommend you read these sections
|
||||
started. In particular, we recommend you read these sections:
|
||||
|
||||
- [EFF's Public Projects Code of Conduct](https://www.eff.org/pages/eppcode)
|
||||
- [Finding issues to work on](https://certbot.eff.org/docs/contributing.html#find-issues-to-work-on)
|
||||
- [Coding style](https://certbot.eff.org/docs/contributing.html#coding-style)
|
||||
- [Submitting a pull request](https://certbot.eff.org/docs/contributing.html#submitting-a-pull-request)
|
||||
- [EFF's Public Projects Code of Conduct](https://www.eff.org/pages/eppcode)
|
||||
|
||||
|
||||
11
SECURITY.md
11
SECURITY.md
@@ -1,5 +1,16 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Explanation on supported versions [here](https://github.com/certbot/certbot/wiki/Architectural-Decision-Records#-update-to-certbots-version-policy-and-end-of-life-support-on-previous-major-versions)
|
||||
|
||||
| Major Version | Support Level |
|
||||
| ------- | ------------------ |
|
||||
| >= 4.0 | Full Support |
|
||||
| 3.x | Discretionary Backports |
|
||||
| <=2.x | None |
|
||||
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Security vulnerabilities can be reported using GitHub's [private vulnerability reporting tool](https://github.com/certbot/certbot/security/advisories/new).
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
include LICENSE.txt
|
||||
include README.rst
|
||||
include pytest.ini
|
||||
recursive-include docs *
|
||||
recursive-include examples *
|
||||
recursive-include acme/_internal/tests/testdata *
|
||||
include acme/py.typed
|
||||
recursive-include src/acme/_internal/tests/testdata *
|
||||
include src/acme/py.typed
|
||||
global-exclude __pycache__
|
||||
global-exclude *.py[cod]
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
"""Tests for acme.crypto_util."""
|
||||
import ipaddress
|
||||
import itertools
|
||||
import socket
|
||||
import socketserver
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import List
|
||||
import unittest
|
||||
|
||||
import josepy as jose
|
||||
import OpenSSL
|
||||
import pytest
|
||||
|
||||
from acme import errors
|
||||
from acme._internal.tests import test_util
|
||||
|
||||
|
||||
class SSLSocketAndProbeSNITest(unittest.TestCase):
|
||||
"""Tests for acme.crypto_util.SSLSocket/probe_sni."""
|
||||
|
||||
def setUp(self):
|
||||
self.cert = test_util.load_comparable_cert('rsa2048_cert.pem')
|
||||
key = test_util.load_pyopenssl_private_key('rsa2048_key.pem')
|
||||
# pylint: disable=protected-access
|
||||
certs = {b'foo': (key, self.cert.wrapped)}
|
||||
|
||||
from acme.crypto_util import SSLSocket
|
||||
|
||||
class _TestServer(socketserver.TCPServer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.socket = SSLSocket(self.socket, certs)
|
||||
|
||||
self.server = _TestServer(('', 0), socketserver.BaseRequestHandler)
|
||||
self.port = self.server.socket.getsockname()[1]
|
||||
self.server_thread = threading.Thread(
|
||||
target=self.server.handle_request)
|
||||
|
||||
def tearDown(self):
|
||||
if self.server_thread.is_alive():
|
||||
# The thread may have already terminated.
|
||||
self.server_thread.join() # pragma: no cover
|
||||
self.server.server_close()
|
||||
|
||||
def _probe(self, name):
|
||||
from acme.crypto_util import probe_sni
|
||||
return jose.ComparableX509(probe_sni(
|
||||
name, host='127.0.0.1', port=self.port))
|
||||
|
||||
def _start_server(self):
|
||||
self.server_thread.start()
|
||||
time.sleep(1) # TODO: avoid race conditions in other way
|
||||
|
||||
def test_probe_ok(self):
|
||||
self._start_server()
|
||||
assert self.cert == self._probe(b'foo')
|
||||
|
||||
def test_probe_not_recognized_name(self):
|
||||
self._start_server()
|
||||
with pytest.raises(errors.Error):
|
||||
self._probe(b'bar')
|
||||
|
||||
def test_probe_connection_error(self):
|
||||
self.server.server_close()
|
||||
original_timeout = socket.getdefaulttimeout()
|
||||
try:
|
||||
socket.setdefaulttimeout(1)
|
||||
with pytest.raises(errors.Error):
|
||||
self._probe(b'bar')
|
||||
finally:
|
||||
socket.setdefaulttimeout(original_timeout)
|
||||
|
||||
|
||||
class SSLSocketTest(unittest.TestCase):
|
||||
"""Tests for acme.crypto_util.SSLSocket."""
|
||||
|
||||
def test_ssl_socket_invalid_arguments(self):
|
||||
from acme.crypto_util import SSLSocket
|
||||
with pytest.raises(ValueError):
|
||||
_ = SSLSocket(None, {'sni': ('key', 'cert')},
|
||||
cert_selection=lambda _: None)
|
||||
with pytest.raises(ValueError):
|
||||
_ = SSLSocket(None)
|
||||
|
||||
|
||||
class PyOpenSSLCertOrReqAllNamesTest(unittest.TestCase):
|
||||
"""Test for acme.crypto_util._pyopenssl_cert_or_req_all_names."""
|
||||
|
||||
@classmethod
|
||||
def _call(cls, loader, name):
|
||||
# pylint: disable=protected-access
|
||||
from acme.crypto_util import _pyopenssl_cert_or_req_all_names
|
||||
return _pyopenssl_cert_or_req_all_names(loader(name))
|
||||
|
||||
def _call_cert(self, name):
|
||||
return self._call(test_util.load_cert, name)
|
||||
|
||||
def test_cert_one_san_no_common(self):
|
||||
assert self._call_cert('cert-nocn.der') == \
|
||||
['no-common-name.badssl.com']
|
||||
|
||||
def test_cert_no_sans_yes_common(self):
|
||||
assert self._call_cert('cert.pem') == ['example.com']
|
||||
|
||||
def test_cert_two_sans_yes_common(self):
|
||||
assert self._call_cert('cert-san.pem') == \
|
||||
['example.com', 'www.example.com']
|
||||
|
||||
|
||||
class PyOpenSSLCertOrReqSANTest(unittest.TestCase):
|
||||
"""Test for acme.crypto_util._pyopenssl_cert_or_req_san."""
|
||||
|
||||
@classmethod
|
||||
def _call(cls, loader, name):
|
||||
# pylint: disable=protected-access
|
||||
from acme.crypto_util import _pyopenssl_cert_or_req_san
|
||||
return _pyopenssl_cert_or_req_san(loader(name))
|
||||
|
||||
@classmethod
|
||||
def _get_idn_names(cls):
|
||||
"""Returns expected names from '{cert,csr}-idnsans.pem'."""
|
||||
chars = [chr(i) for i in itertools.chain(range(0x3c3, 0x400),
|
||||
range(0x641, 0x6fc),
|
||||
range(0x1820, 0x1877))]
|
||||
return [''.join(chars[i: i + 45]) + '.invalid'
|
||||
for i in range(0, len(chars), 45)]
|
||||
|
||||
def _call_cert(self, name):
|
||||
return self._call(test_util.load_cert, name)
|
||||
|
||||
def _call_csr(self, name):
|
||||
return self._call(test_util.load_csr, name)
|
||||
|
||||
def test_cert_no_sans(self):
|
||||
assert self._call_cert('cert.pem') == []
|
||||
|
||||
def test_cert_two_sans(self):
|
||||
assert self._call_cert('cert-san.pem') == \
|
||||
['example.com', 'www.example.com']
|
||||
|
||||
def test_cert_hundred_sans(self):
|
||||
assert self._call_cert('cert-100sans.pem') == \
|
||||
['example{0}.com'.format(i) for i in range(1, 101)]
|
||||
|
||||
def test_cert_idn_sans(self):
|
||||
assert self._call_cert('cert-idnsans.pem') == \
|
||||
self._get_idn_names()
|
||||
|
||||
def test_csr_no_sans(self):
|
||||
assert self._call_csr('csr-nosans.pem') == []
|
||||
|
||||
def test_csr_one_san(self):
|
||||
assert self._call_csr('csr.pem') == ['example.com']
|
||||
|
||||
def test_csr_two_sans(self):
|
||||
assert self._call_csr('csr-san.pem') == \
|
||||
['example.com', 'www.example.com']
|
||||
|
||||
def test_csr_six_sans(self):
|
||||
assert self._call_csr('csr-6sans.pem') == \
|
||||
['example.com', 'example.org', 'example.net',
|
||||
'example.info', 'subdomain.example.com',
|
||||
'other.subdomain.example.com']
|
||||
|
||||
def test_csr_hundred_sans(self):
|
||||
assert self._call_csr('csr-100sans.pem') == \
|
||||
['example{0}.com'.format(i) for i in range(1, 101)]
|
||||
|
||||
def test_csr_idn_sans(self):
|
||||
assert self._call_csr('csr-idnsans.pem') == \
|
||||
self._get_idn_names()
|
||||
|
||||
def test_critical_san(self):
|
||||
assert self._call_cert('critical-san.pem') == \
|
||||
['chicago-cubs.venafi.example', 'cubs.venafi.example']
|
||||
|
||||
|
||||
class PyOpenSSLCertOrReqSANIPTest(unittest.TestCase):
|
||||
"""Test for acme.crypto_util._pyopenssl_cert_or_req_san_ip."""
|
||||
|
||||
@classmethod
|
||||
def _call(cls, loader, name):
|
||||
# pylint: disable=protected-access
|
||||
from acme.crypto_util import _pyopenssl_cert_or_req_san_ip
|
||||
return _pyopenssl_cert_or_req_san_ip(loader(name))
|
||||
|
||||
def _call_cert(self, name):
|
||||
return self._call(test_util.load_cert, name)
|
||||
|
||||
def _call_csr(self, name):
|
||||
return self._call(test_util.load_csr, name)
|
||||
|
||||
def test_cert_no_sans(self):
|
||||
assert self._call_cert('cert.pem') == []
|
||||
|
||||
def test_csr_no_sans(self):
|
||||
assert self._call_csr('csr-nosans.pem') == []
|
||||
|
||||
def test_cert_domain_sans(self):
|
||||
assert self._call_cert('cert-san.pem') == []
|
||||
|
||||
def test_csr_domain_sans(self):
|
||||
assert self._call_csr('csr-san.pem') == []
|
||||
|
||||
def test_cert_ip_two_sans(self):
|
||||
assert self._call_cert('cert-ipsans.pem') == ['192.0.2.145', '203.0.113.1']
|
||||
|
||||
def test_csr_ip_two_sans(self):
|
||||
assert self._call_csr('csr-ipsans.pem') == ['192.0.2.145', '203.0.113.1']
|
||||
|
||||
def test_csr_ipv6_sans(self):
|
||||
assert self._call_csr('csr-ipv6sans.pem') == \
|
||||
['0:0:0:0:0:0:0:1', 'A3BE:32F3:206E:C75D:956:CEE:9858:5EC5']
|
||||
|
||||
def test_cert_ipv6_sans(self):
|
||||
assert self._call_cert('cert-ipv6sans.pem') == \
|
||||
['0:0:0:0:0:0:0:1', 'A3BE:32F3:206E:C75D:956:CEE:9858:5EC5']
|
||||
|
||||
|
||||
class GenSsCertTest(unittest.TestCase):
|
||||
"""Test for gen_ss_cert (generation of self-signed cert)."""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
self.cert_count = 5
|
||||
self.serial_num: List[int] = []
|
||||
self.key = OpenSSL.crypto.PKey()
|
||||
self.key.generate_key(OpenSSL.crypto.TYPE_RSA, 2048)
|
||||
|
||||
def test_sn_collisions(self):
|
||||
from acme.crypto_util import gen_ss_cert
|
||||
for _ in range(self.cert_count):
|
||||
cert = gen_ss_cert(self.key, ['dummy'], force_san=True,
|
||||
ips=[ipaddress.ip_address("10.10.10.10")])
|
||||
self.serial_num.append(cert.get_serial_number())
|
||||
assert len(set(self.serial_num)) >= self.cert_count
|
||||
|
||||
|
||||
def test_no_name(self):
|
||||
from acme.crypto_util import gen_ss_cert
|
||||
with pytest.raises(AssertionError):
|
||||
gen_ss_cert(self.key, ips=[ipaddress.ip_address("1.1.1.1")])
|
||||
gen_ss_cert(self.key)
|
||||
|
||||
|
||||
class MakeCSRTest(unittest.TestCase):
|
||||
"""Test for standalone functions."""
|
||||
|
||||
@classmethod
|
||||
def _call_with_key(cls, *args, **kwargs):
|
||||
privkey = OpenSSL.crypto.PKey()
|
||||
privkey.generate_key(OpenSSL.crypto.TYPE_RSA, 2048)
|
||||
privkey_pem = OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, privkey)
|
||||
from acme.crypto_util import make_csr
|
||||
return make_csr(privkey_pem, *args, **kwargs)
|
||||
|
||||
def test_make_csr(self):
|
||||
csr_pem = self._call_with_key(["a.example", "b.example"])
|
||||
assert b'--BEGIN CERTIFICATE REQUEST--' in csr_pem
|
||||
assert b'--END CERTIFICATE REQUEST--' in csr_pem
|
||||
csr = OpenSSL.crypto.load_certificate_request(
|
||||
OpenSSL.crypto.FILETYPE_PEM, csr_pem)
|
||||
# In pyopenssl 0.13 (used with TOXENV=py27-oldest), csr objects don't
|
||||
# have a get_extensions() method, so we skip this test if the method
|
||||
# isn't available.
|
||||
if hasattr(csr, 'get_extensions'):
|
||||
assert len(csr.get_extensions()) == 1
|
||||
assert csr.get_extensions()[0].get_data() == \
|
||||
OpenSSL.crypto.X509Extension(
|
||||
b'subjectAltName',
|
||||
critical=False,
|
||||
value=b'DNS:a.example, DNS:b.example',
|
||||
).get_data()
|
||||
|
||||
def test_make_csr_ip(self):
|
||||
csr_pem = self._call_with_key(["a.example"], False, [ipaddress.ip_address('127.0.0.1'), ipaddress.ip_address('::1')])
|
||||
assert b'--BEGIN CERTIFICATE REQUEST--' in csr_pem
|
||||
assert b'--END CERTIFICATE REQUEST--' in csr_pem
|
||||
csr = OpenSSL.crypto.load_certificate_request(
|
||||
OpenSSL.crypto.FILETYPE_PEM, csr_pem)
|
||||
# In pyopenssl 0.13 (used with TOXENV=py27-oldest), csr objects don't
|
||||
# have a get_extensions() method, so we skip this test if the method
|
||||
# isn't available.
|
||||
if hasattr(csr, 'get_extensions'):
|
||||
assert len(csr.get_extensions()) == 1
|
||||
assert csr.get_extensions()[0].get_data() == \
|
||||
OpenSSL.crypto.X509Extension(
|
||||
b'subjectAltName',
|
||||
critical=False,
|
||||
value=b'DNS:a.example, IP:127.0.0.1, IP:::1',
|
||||
).get_data()
|
||||
# for IP san it's actually need to be octet-string,
|
||||
# but somewhere downstream thankfully handle it for us
|
||||
|
||||
def test_make_csr_must_staple(self):
|
||||
csr_pem = self._call_with_key(["a.example"], must_staple=True)
|
||||
csr = OpenSSL.crypto.load_certificate_request(
|
||||
OpenSSL.crypto.FILETYPE_PEM, csr_pem)
|
||||
|
||||
# In pyopenssl 0.13 (used with TOXENV=py27-oldest), csr objects don't
|
||||
# have a get_extensions() method, so we skip this test if the method
|
||||
# isn't available.
|
||||
if hasattr(csr, 'get_extensions'):
|
||||
assert len(csr.get_extensions()) == 2
|
||||
# NOTE: Ideally we would filter by the TLS Feature OID, but
|
||||
# OpenSSL.crypto.X509Extension doesn't give us the extension's raw OID,
|
||||
# and the shortname field is just "UNDEF"
|
||||
must_staple_exts = [e for e in csr.get_extensions()
|
||||
if e.get_data() == b"0\x03\x02\x01\x05"]
|
||||
assert len(must_staple_exts) == 1, \
|
||||
"Expected exactly one Must Staple extension"
|
||||
|
||||
def test_make_csr_without_hostname(self):
|
||||
with pytest.raises(ValueError):
|
||||
self._call_with_key()
|
||||
|
||||
def test_make_csr_correct_version(self):
|
||||
csr_pem = self._call_with_key(["a.example"])
|
||||
csr = OpenSSL.crypto.load_certificate_request(
|
||||
OpenSSL.crypto.FILETYPE_PEM, csr_pem)
|
||||
|
||||
assert csr.get_version() == 0, \
|
||||
"Expected CSR version to be v1 (encoded as 0), per RFC 2986, section 4"
|
||||
|
||||
|
||||
class DumpPyopensslChainTest(unittest.TestCase):
|
||||
"""Test for dump_pyopenssl_chain."""
|
||||
|
||||
@classmethod
|
||||
def _call(cls, loaded):
|
||||
# pylint: disable=protected-access
|
||||
from acme.crypto_util import dump_pyopenssl_chain
|
||||
return dump_pyopenssl_chain(loaded)
|
||||
|
||||
def test_dump_pyopenssl_chain(self):
|
||||
names = ['cert.pem', 'cert-san.pem', 'cert-idnsans.pem']
|
||||
loaded = [test_util.load_cert(name) for name in names]
|
||||
length = sum(
|
||||
len(OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert))
|
||||
for cert in loaded)
|
||||
assert len(self._call(loaded)) == length
|
||||
|
||||
def test_dump_pyopenssl_chain_wrapped(self):
|
||||
names = ['cert.pem', 'cert-san.pem', 'cert-idnsans.pem']
|
||||
loaded = [test_util.load_cert(name) for name in names]
|
||||
wrap_func = jose.ComparableX509
|
||||
wrapped = [wrap_func(cert) for cert in loaded]
|
||||
dump_func = OpenSSL.crypto.dump_certificate
|
||||
length = sum(len(dump_func(OpenSSL.crypto.FILETYPE_PEM, cert)) for cert in loaded)
|
||||
assert len(self._call(wrapped)) == length
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover
|
||||
16
acme/acme/_internal/tests/testdata/csr-mixed.pem
vendored
16
acme/acme/_internal/tests/testdata/csr-mixed.pem
vendored
@@ -1,16 +0,0 @@
|
||||
-----BEGIN CERTIFICATE REQUEST-----
|
||||
MIICdjCCAV4CAQIwADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMXq
|
||||
v1y8EIcCbaUIzCtOcLkLS0MJ35oS+6DmV5WB1A0cIk6YrjsHIsY2lwMm13BWIvmw
|
||||
tY+Y6n0rr7eViNx5ZRGHpHEI/TL3Neb+VefTydL5CgvK3dd4ex2kSbTaed3fmpOx
|
||||
qMajEduwNcZPCcmoEXPkfrCP8w2vKQUkQ+JRPcdX1nTuzticeRP5B7YCmJsmxkEh
|
||||
Y0tzzZ+NIRDARoYNofefY86h3e5q66gtJxccNchmIM3YQahhg5n3Xoo8hGfM/TIc
|
||||
R7ncCBCLO6vtqo0QFva/NQODrgOmOsmgvqPkUWQFdZfWM8yIaU826dktx0CPB78t
|
||||
TudnJ1rBRvGsjHMsZikCAwEAAaAxMC8GCSqGSIb3DQEJDjEiMCAwHgYDVR0RBBcw
|
||||
FYINYS5leGVtcGxlLmNvbYcEwAACbzANBgkqhkiG9w0BAQsFAAOCAQEAdGMcRCxq
|
||||
1X09gn1TNdMt64XUv+wdJCKDaJ+AgyIJj7QvVw8H5k7dOnxS4I+a/yo4jE+LDl2/
|
||||
AuHcBLFEI4ddewdJSMrTNZjuRYuOdr3KP7fL7MffICSBi45vw5EOXg0tnjJCEiKu
|
||||
6gcJgbLSP5JMMd7Haf33Q/VWsmHofR3VwOMdrnakwAU3Ff5WTuXTNVhL1kT/uLFX
|
||||
yW1ru6BF4unwNqSR2UeulljpNfRBsiN4zJK11W6n9KT0NkBr9zY5WCM4sW7i8k9V
|
||||
TeypWGo3jBKzYAGeuxZsB97U77jZ2lrGdBLZKfbcjnTeRVqCvCRrui4El7UGYFmj
|
||||
7s6OJyWx5DSV8w==
|
||||
-----END CERTIFICATE REQUEST-----
|
||||
@@ -1,458 +0,0 @@
|
||||
"""Crypto utilities."""
|
||||
import binascii
|
||||
import contextlib
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
from typing import List
|
||||
from typing import Mapping
|
||||
from typing import Optional
|
||||
from typing import Sequence
|
||||
from typing import Set
|
||||
from typing import Tuple
|
||||
from typing import Union
|
||||
|
||||
import josepy as jose
|
||||
from OpenSSL import crypto
|
||||
from OpenSSL import SSL
|
||||
|
||||
from acme import errors
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default SSL method selected here is the most compatible, while secure
|
||||
# SSL method: TLSv1_METHOD is only compatible with
|
||||
# TLSv1_METHOD, while SSLv23_METHOD is compatible with all other
|
||||
# methods, including TLSv2_METHOD (read more at
|
||||
# https://www.openssl.org/docs/ssl/SSLv23_method.html). _serve_sni
|
||||
# should be changed to use "set_options" to disable SSLv2 and SSLv3,
|
||||
# in case it's used for things other than probing/serving!
|
||||
_DEFAULT_SSL_METHOD = SSL.SSLv23_METHOD
|
||||
|
||||
|
||||
class _DefaultCertSelection:
|
||||
def __init__(self, certs: Mapping[bytes, Tuple[crypto.PKey, crypto.X509]]):
|
||||
self.certs = certs
|
||||
|
||||
def __call__(self, connection: SSL.Connection) -> Optional[Tuple[crypto.PKey, crypto.X509]]:
|
||||
server_name = connection.get_servername()
|
||||
if server_name:
|
||||
return self.certs.get(server_name, None)
|
||||
return None # pragma: no cover
|
||||
|
||||
|
||||
class SSLSocket: # pylint: disable=too-few-public-methods
|
||||
"""SSL wrapper for sockets.
|
||||
|
||||
:ivar socket sock: Original wrapped socket.
|
||||
:ivar dict certs: Mapping from domain names (`bytes`) to
|
||||
`OpenSSL.crypto.X509`.
|
||||
:ivar method: See `OpenSSL.SSL.Context` for allowed values.
|
||||
:ivar alpn_selection: Hook to select negotiated ALPN protocol for
|
||||
connection.
|
||||
:ivar cert_selection: Hook to select certificate for connection. If given,
|
||||
`certs` parameter would be ignored, and therefore must be empty.
|
||||
|
||||
"""
|
||||
def __init__(self, sock: socket.socket,
|
||||
certs: Optional[Mapping[bytes, Tuple[crypto.PKey, crypto.X509]]] = None,
|
||||
method: int = _DEFAULT_SSL_METHOD,
|
||||
alpn_selection: Optional[Callable[[SSL.Connection, List[bytes]], bytes]] = None,
|
||||
cert_selection: Optional[Callable[[SSL.Connection],
|
||||
Optional[Tuple[crypto.PKey,
|
||||
crypto.X509]]]] = None
|
||||
) -> None:
|
||||
self.sock = sock
|
||||
self.alpn_selection = alpn_selection
|
||||
self.method = method
|
||||
if not cert_selection and not certs:
|
||||
raise ValueError("Neither cert_selection or certs specified.")
|
||||
if cert_selection and certs:
|
||||
raise ValueError("Both cert_selection and certs specified.")
|
||||
actual_cert_selection: Union[_DefaultCertSelection,
|
||||
Optional[Callable[[SSL.Connection],
|
||||
Optional[Tuple[crypto.PKey,
|
||||
crypto.X509]]]]] = cert_selection
|
||||
if actual_cert_selection is None:
|
||||
actual_cert_selection = _DefaultCertSelection(certs if certs else {})
|
||||
self.cert_selection = actual_cert_selection
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.sock, name)
|
||||
|
||||
def _pick_certificate_cb(self, connection: SSL.Connection) -> None:
|
||||
"""SNI certificate callback.
|
||||
|
||||
This method will set a new OpenSSL context object for this
|
||||
connection when an incoming connection provides an SNI name
|
||||
(in order to serve the appropriate certificate, if any).
|
||||
|
||||
:param connection: The TLS connection object on which the SNI
|
||||
extension was received.
|
||||
:type connection: :class:`OpenSSL.Connection`
|
||||
|
||||
"""
|
||||
pair = self.cert_selection(connection)
|
||||
if pair is None:
|
||||
logger.debug("Certificate selection for server name %s failed, dropping SSL",
|
||||
connection.get_servername())
|
||||
return
|
||||
key, cert = pair
|
||||
new_context = SSL.Context(self.method)
|
||||
new_context.set_options(SSL.OP_NO_SSLv2)
|
||||
new_context.set_options(SSL.OP_NO_SSLv3)
|
||||
new_context.use_privatekey(key)
|
||||
new_context.use_certificate(cert)
|
||||
if self.alpn_selection is not None:
|
||||
new_context.set_alpn_select_callback(self.alpn_selection)
|
||||
connection.set_context(new_context)
|
||||
|
||||
class FakeConnection:
|
||||
"""Fake OpenSSL.SSL.Connection."""
|
||||
|
||||
# pylint: disable=missing-function-docstring
|
||||
|
||||
def __init__(self, connection: SSL.Connection) -> None:
|
||||
self._wrapped = connection
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._wrapped, name)
|
||||
|
||||
def shutdown(self, *unused_args: Any) -> bool:
|
||||
# OpenSSL.SSL.Connection.shutdown doesn't accept any args
|
||||
try:
|
||||
return self._wrapped.shutdown()
|
||||
except SSL.Error as error:
|
||||
# We wrap the error so we raise the same error type as sockets
|
||||
# in the standard library. This is useful when this object is
|
||||
# used by code which expects a standard socket such as
|
||||
# socketserver in the standard library.
|
||||
raise socket.error(error)
|
||||
|
||||
def accept(self) -> Tuple[FakeConnection, Any]: # pylint: disable=missing-function-docstring
|
||||
sock, addr = self.sock.accept()
|
||||
|
||||
try:
|
||||
context = SSL.Context(self.method)
|
||||
context.set_options(SSL.OP_NO_SSLv2)
|
||||
context.set_options(SSL.OP_NO_SSLv3)
|
||||
context.set_tlsext_servername_callback(self._pick_certificate_cb)
|
||||
if self.alpn_selection is not None:
|
||||
context.set_alpn_select_callback(self.alpn_selection)
|
||||
|
||||
ssl_sock = self.FakeConnection(SSL.Connection(context, sock))
|
||||
ssl_sock.set_accept_state()
|
||||
|
||||
# This log line is especially desirable because without it requests to
|
||||
# our standalone TLSALPN server would not be logged.
|
||||
logger.debug("Performing handshake with %s", addr)
|
||||
try:
|
||||
ssl_sock.do_handshake()
|
||||
except SSL.Error as error:
|
||||
# _pick_certificate_cb might have returned without
|
||||
# creating SSL context (wrong server name)
|
||||
raise socket.error(error)
|
||||
|
||||
return ssl_sock, addr
|
||||
except:
|
||||
# If we encounter any error, close the new socket before reraising
|
||||
# the exception.
|
||||
sock.close()
|
||||
raise
|
||||
|
||||
|
||||
def probe_sni(name: bytes, host: bytes, port: int = 443, timeout: int = 300, # pylint: disable=too-many-arguments
|
||||
method: int = _DEFAULT_SSL_METHOD, source_address: Tuple[str, int] = ('', 0),
|
||||
alpn_protocols: Optional[Sequence[bytes]] = None) -> crypto.X509:
|
||||
"""Probe SNI server for SSL certificate.
|
||||
|
||||
:param bytes name: Byte string to send as the server name in the
|
||||
client hello message.
|
||||
:param bytes host: Host to connect to.
|
||||
:param int port: Port to connect to.
|
||||
:param int timeout: Timeout in seconds.
|
||||
:param method: See `OpenSSL.SSL.Context` for allowed values.
|
||||
:param tuple source_address: Enables multi-path probing (selection
|
||||
of source interface). See `socket.creation_connection` for more
|
||||
info. Available only in Python 2.7+.
|
||||
:param alpn_protocols: Protocols to request using ALPN.
|
||||
:type alpn_protocols: `Sequence` of `bytes`
|
||||
|
||||
:raises acme.errors.Error: In case of any problems.
|
||||
|
||||
:returns: SSL certificate presented by the server.
|
||||
:rtype: OpenSSL.crypto.X509
|
||||
|
||||
"""
|
||||
context = SSL.Context(method)
|
||||
context.set_timeout(timeout)
|
||||
|
||||
socket_kwargs = {'source_address': source_address}
|
||||
|
||||
try:
|
||||
logger.debug(
|
||||
"Attempting to connect to %s:%d%s.", host, port,
|
||||
" from {0}:{1}".format(
|
||||
source_address[0],
|
||||
source_address[1]
|
||||
) if any(source_address) else ""
|
||||
)
|
||||
socket_tuple: Tuple[bytes, int] = (host, port)
|
||||
sock = socket.create_connection(socket_tuple, **socket_kwargs) # type: ignore[arg-type]
|
||||
except socket.error as error:
|
||||
raise errors.Error(error)
|
||||
|
||||
with contextlib.closing(sock) as client:
|
||||
client_ssl = SSL.Connection(context, client)
|
||||
client_ssl.set_connect_state()
|
||||
client_ssl.set_tlsext_host_name(name) # pyOpenSSL>=0.13
|
||||
if alpn_protocols is not None:
|
||||
client_ssl.set_alpn_protos(alpn_protocols)
|
||||
try:
|
||||
client_ssl.do_handshake()
|
||||
client_ssl.shutdown()
|
||||
except SSL.Error as error:
|
||||
raise errors.Error(error)
|
||||
cert = client_ssl.get_peer_certificate()
|
||||
assert cert # Appease mypy. We would have crashed out by now if there was no certificate.
|
||||
return cert
|
||||
|
||||
|
||||
def make_csr(private_key_pem: bytes, domains: Optional[Union[Set[str], List[str]]] = None,
|
||||
must_staple: bool = False,
|
||||
ipaddrs: Optional[List[Union[ipaddress.IPv4Address, ipaddress.IPv6Address]]] = None
|
||||
) -> bytes:
|
||||
"""Generate a CSR containing domains or IPs as subjectAltNames.
|
||||
|
||||
:param buffer private_key_pem: Private key, in PEM PKCS#8 format.
|
||||
:param list domains: List of DNS names to include in subjectAltNames of CSR.
|
||||
:param bool must_staple: Whether to include the TLS Feature extension (aka
|
||||
OCSP Must Staple: https://tools.ietf.org/html/rfc7633).
|
||||
:param list ipaddrs: List of IPaddress(type ipaddress.IPv4Address or ipaddress.IPv6Address)
|
||||
names to include in subbjectAltNames of CSR.
|
||||
params ordered this way for backward competablity when called by positional argument.
|
||||
:returns: buffer PEM-encoded Certificate Signing Request.
|
||||
"""
|
||||
private_key = crypto.load_privatekey(
|
||||
crypto.FILETYPE_PEM, private_key_pem)
|
||||
csr = crypto.X509Req()
|
||||
sanlist = []
|
||||
# if domain or ip list not supplied make it empty list so it's easier to iterate
|
||||
if domains is None:
|
||||
domains = []
|
||||
if ipaddrs is None:
|
||||
ipaddrs = []
|
||||
if len(domains)+len(ipaddrs) == 0:
|
||||
raise ValueError("At least one of domains or ipaddrs parameter need to be not empty")
|
||||
for address in domains:
|
||||
sanlist.append('DNS:' + address)
|
||||
for ips in ipaddrs:
|
||||
sanlist.append('IP:' + ips.exploded)
|
||||
# make sure its ascii encoded
|
||||
san_string = ', '.join(sanlist).encode('ascii')
|
||||
# for IP san it's actually need to be octet-string,
|
||||
# but somewhere downsteam thankfully handle it for us
|
||||
extensions = [
|
||||
crypto.X509Extension(
|
||||
b'subjectAltName',
|
||||
critical=False,
|
||||
value=san_string
|
||||
),
|
||||
]
|
||||
if must_staple:
|
||||
extensions.append(crypto.X509Extension(
|
||||
b"1.3.6.1.5.5.7.1.24",
|
||||
critical=False,
|
||||
value=b"DER:30:03:02:01:05"))
|
||||
csr.add_extensions(extensions)
|
||||
csr.set_pubkey(private_key)
|
||||
# RFC 2986 Section 4.1 only defines version 0
|
||||
csr.set_version(0)
|
||||
csr.sign(private_key, 'sha256')
|
||||
return crypto.dump_certificate_request(
|
||||
crypto.FILETYPE_PEM, csr)
|
||||
|
||||
|
||||
def _pyopenssl_cert_or_req_all_names(loaded_cert_or_req: Union[crypto.X509, crypto.X509Req]
|
||||
) -> List[str]:
|
||||
# unlike its name this only outputs DNS names, other type of idents will ignored
|
||||
common_name = loaded_cert_or_req.get_subject().CN
|
||||
sans = _pyopenssl_cert_or_req_san(loaded_cert_or_req)
|
||||
|
||||
if common_name is None:
|
||||
return sans
|
||||
return [common_name] + [d for d in sans if d != common_name]
|
||||
|
||||
|
||||
def _pyopenssl_cert_or_req_san(cert_or_req: Union[crypto.X509, crypto.X509Req]) -> List[str]:
|
||||
"""Get Subject Alternative Names from certificate or CSR using pyOpenSSL.
|
||||
|
||||
.. todo:: Implement directly in PyOpenSSL!
|
||||
|
||||
.. note:: Although this is `acme` internal API, it is used by
|
||||
`letsencrypt`.
|
||||
|
||||
:param cert_or_req: Certificate or CSR.
|
||||
:type cert_or_req: `OpenSSL.crypto.X509` or `OpenSSL.crypto.X509Req`.
|
||||
|
||||
:returns: A list of Subject Alternative Names that is DNS.
|
||||
:rtype: `list` of `str`
|
||||
|
||||
"""
|
||||
# This function finds SANs with dns name
|
||||
|
||||
# constants based on PyOpenSSL certificate/CSR text dump
|
||||
part_separator = ":"
|
||||
prefix = "DNS" + part_separator
|
||||
|
||||
sans_parts = _pyopenssl_extract_san_list_raw(cert_or_req)
|
||||
|
||||
return [part.split(part_separator)[1]
|
||||
for part in sans_parts if part.startswith(prefix)]
|
||||
|
||||
|
||||
def _pyopenssl_cert_or_req_san_ip(cert_or_req: Union[crypto.X509, crypto.X509Req]) -> List[str]:
|
||||
"""Get Subject Alternative Names IPs from certificate or CSR using pyOpenSSL.
|
||||
|
||||
:param cert_or_req: Certificate or CSR.
|
||||
:type cert_or_req: `OpenSSL.crypto.X509` or `OpenSSL.crypto.X509Req`.
|
||||
|
||||
:returns: A list of Subject Alternative Names that are IP Addresses.
|
||||
:rtype: `list` of `str`. note that this returns as string, not IPaddress object
|
||||
|
||||
"""
|
||||
|
||||
# constants based on PyOpenSSL certificate/CSR text dump
|
||||
part_separator = ":"
|
||||
prefix = "IP Address" + part_separator
|
||||
|
||||
sans_parts = _pyopenssl_extract_san_list_raw(cert_or_req)
|
||||
|
||||
return [part[len(prefix):] for part in sans_parts if part.startswith(prefix)]
|
||||
|
||||
|
||||
def _pyopenssl_extract_san_list_raw(cert_or_req: Union[crypto.X509, crypto.X509Req]) -> List[str]:
|
||||
"""Get raw SAN string from cert or csr, parse it as UTF-8 and return.
|
||||
|
||||
:param cert_or_req: Certificate or CSR.
|
||||
:type cert_or_req: `OpenSSL.crypto.X509` or `OpenSSL.crypto.X509Req`.
|
||||
|
||||
:returns: raw san strings, parsed byte as utf-8
|
||||
:rtype: `list` of `str`
|
||||
|
||||
"""
|
||||
# This function finds SANs by dumping the certificate/CSR to text and
|
||||
# searching for "X509v3 Subject Alternative Name" in the text. This method
|
||||
# is used to because in PyOpenSSL version <0.17 `_subjectAltNameString` methods are
|
||||
# not able to Parse IP Addresses in subjectAltName string.
|
||||
|
||||
if isinstance(cert_or_req, crypto.X509):
|
||||
# pylint: disable=line-too-long
|
||||
text = crypto.dump_certificate(crypto.FILETYPE_TEXT, cert_or_req).decode('utf-8')
|
||||
else:
|
||||
text = crypto.dump_certificate_request(crypto.FILETYPE_TEXT, cert_or_req).decode('utf-8')
|
||||
# WARNING: this function does not support multiple SANs extensions.
|
||||
# Multiple X509v3 extensions of the same type is disallowed by RFC 5280.
|
||||
raw_san = re.search(r"X509v3 Subject Alternative Name:(?: critical)?\s*(.*)", text)
|
||||
|
||||
parts_separator = ", "
|
||||
# WARNING: this function assumes that no SAN can include
|
||||
# parts_separator, hence the split!
|
||||
sans_parts = [] if raw_san is None else raw_san.group(1).split(parts_separator)
|
||||
return sans_parts
|
||||
|
||||
|
||||
def gen_ss_cert(key: crypto.PKey, domains: Optional[List[str]] = None,
|
||||
not_before: Optional[int] = None,
|
||||
validity: int = (7 * 24 * 60 * 60), force_san: bool = True,
|
||||
extensions: Optional[List[crypto.X509Extension]] = None,
|
||||
ips: Optional[List[Union[ipaddress.IPv4Address, ipaddress.IPv6Address]]] = None
|
||||
) -> crypto.X509:
|
||||
"""Generate new self-signed certificate.
|
||||
|
||||
:type domains: `list` of `str`
|
||||
:param OpenSSL.crypto.PKey key:
|
||||
:param bool force_san:
|
||||
:param extensions: List of additional extensions to include in the cert.
|
||||
:type extensions: `list` of `OpenSSL.crypto.X509Extension`
|
||||
:type ips: `list` of (`ipaddress.IPv4Address` or `ipaddress.IPv6Address`)
|
||||
|
||||
If more than one domain is provided, all of the domains are put into
|
||||
``subjectAltName`` X.509 extension and first domain is set as the
|
||||
subject CN. If only one domain is provided no ``subjectAltName``
|
||||
extension is used, unless `force_san` is ``True``.
|
||||
|
||||
"""
|
||||
assert domains or ips, "Must provide one or more hostnames or IPs for the cert."
|
||||
|
||||
cert = crypto.X509()
|
||||
cert.set_serial_number(int(binascii.hexlify(os.urandom(16)), 16))
|
||||
cert.set_version(2)
|
||||
|
||||
if extensions is None:
|
||||
extensions = []
|
||||
if domains is None:
|
||||
domains = []
|
||||
if ips is None:
|
||||
ips = []
|
||||
extensions.append(
|
||||
crypto.X509Extension(
|
||||
b"basicConstraints", True, b"CA:TRUE, pathlen:0"),
|
||||
)
|
||||
|
||||
if len(domains) > 0:
|
||||
cert.get_subject().CN = domains[0]
|
||||
# TODO: what to put into cert.get_subject()?
|
||||
cert.set_issuer(cert.get_subject())
|
||||
|
||||
sanlist = []
|
||||
for address in domains:
|
||||
sanlist.append('DNS:' + address)
|
||||
for ip in ips:
|
||||
sanlist.append('IP:' + ip.exploded)
|
||||
san_string = ', '.join(sanlist).encode('ascii')
|
||||
if force_san or len(domains) > 1 or len(ips) > 0:
|
||||
extensions.append(crypto.X509Extension(
|
||||
b"subjectAltName",
|
||||
critical=False,
|
||||
value=san_string
|
||||
))
|
||||
|
||||
cert.add_extensions(extensions)
|
||||
|
||||
cert.gmtime_adj_notBefore(0 if not_before is None else not_before)
|
||||
cert.gmtime_adj_notAfter(validity)
|
||||
|
||||
cert.set_pubkey(key)
|
||||
cert.sign(key, "sha256")
|
||||
return cert
|
||||
|
||||
|
||||
def dump_pyopenssl_chain(chain: Union[List[jose.ComparableX509], List[crypto.X509]],
|
||||
filetype: int = crypto.FILETYPE_PEM) -> bytes:
|
||||
"""Dump certificate chain into a bundle.
|
||||
|
||||
:param list chain: List of `OpenSSL.crypto.X509` (or wrapped in
|
||||
:class:`josepy.util.ComparableX509`).
|
||||
|
||||
:returns: certificate chain bundle
|
||||
:rtype: bytes
|
||||
|
||||
"""
|
||||
# XXX: returns empty string when no chain is available, which
|
||||
# shuts up RenewableCert, but might not be the best solution...
|
||||
|
||||
def _dump_cert(cert: Union[jose.ComparableX509, crypto.X509]) -> bytes:
|
||||
if isinstance(cert, jose.ComparableX509):
|
||||
if isinstance(cert.wrapped, crypto.X509Req):
|
||||
raise errors.Error("Unexpected CSR provided.") # pragma: no cover
|
||||
cert = cert.wrapped
|
||||
return crypto.dump_certificate(filetype, cert)
|
||||
|
||||
# assumes that OpenSSL.crypto.dump_certificate includes ending
|
||||
# newline character
|
||||
return b"".join(_dump_cert(cert) for cert in chain)
|
||||
5
acme/docs/api/crypto_util.rst
Normal file
5
acme/docs/api/crypto_util.rst
Normal file
@@ -0,0 +1,5 @@
|
||||
Crypto_util
|
||||
-----------
|
||||
|
||||
.. automodule:: acme.crypto_util
|
||||
:members:
|
||||
5
acme/docs/api/jws.rst
Normal file
5
acme/docs/api/jws.rst
Normal file
@@ -0,0 +1,5 @@
|
||||
JWS
|
||||
---
|
||||
|
||||
.. automodule:: acme.jws
|
||||
:members:
|
||||
5
acme/docs/api/util.rst
Normal file
5
acme/docs/api/util.rst
Normal file
@@ -0,0 +1,5 @@
|
||||
Util
|
||||
----
|
||||
|
||||
.. automodule:: acme.util
|
||||
:members:
|
||||
@@ -27,10 +27,11 @@ Workflow:
|
||||
"""
|
||||
from contextlib import contextmanager
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
import josepy as jose
|
||||
import OpenSSL
|
||||
|
||||
from acme import challenges
|
||||
from acme import client
|
||||
@@ -68,10 +69,11 @@ def new_csr_comp(domain_name, pkey_pem=None):
|
||||
"""Create certificate signing request."""
|
||||
if pkey_pem is None:
|
||||
# Create private key.
|
||||
pkey = OpenSSL.crypto.PKey()
|
||||
pkey.generate_key(OpenSSL.crypto.TYPE_RSA, CERT_PKEY_BITS)
|
||||
pkey_pem = OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM,
|
||||
pkey)
|
||||
pkey = rsa.generate_private_key(public_exponent=65537, key_size=CERT_PKEY_BITS)
|
||||
pkey_pem = pkey.private_bytes(encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption())
|
||||
|
||||
csr_pem = crypto_util.make_csr(pkey_pem, [domain_name])
|
||||
return pkey_pem, csr_pem
|
||||
|
||||
@@ -168,11 +170,8 @@ def example_http():
|
||||
|
||||
# Terms of Service URL is in client_acme.directory.meta.terms_of_service
|
||||
# Registration Resource: regr
|
||||
# Creates account with contact information.
|
||||
email = ('fake@example.com')
|
||||
regr = client_acme.new_account(
|
||||
messages.NewRegistration.from_data(
|
||||
email=email, terms_of_service_agreed=True))
|
||||
messages.NewRegistration.from_data(terms_of_service_agreed=True))
|
||||
|
||||
# Create domain private key and CSR
|
||||
pkey_pem, csr_pem = new_csr_comp(DOMAIN)
|
||||
@@ -200,9 +199,7 @@ def example_http():
|
||||
|
||||
# Revoke certificate
|
||||
|
||||
fullchain_com = jose.ComparableX509(
|
||||
OpenSSL.crypto.load_certificate(
|
||||
OpenSSL.crypto.FILETYPE_PEM, fullchain_pem))
|
||||
fullchain_com = x509.load_pem_x509_certificate(fullchain_pem.encode())
|
||||
|
||||
try:
|
||||
client_acme.revoke(fullchain_com, 0) # revocation reason = 0
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[pytest]
|
||||
norecursedirs = .* build dist CVS _darcs {arch} *.egg
|
||||
@@ -1,21 +1,17 @@
|
||||
import sys
|
||||
|
||||
from setuptools import find_packages
|
||||
from setuptools import setup
|
||||
|
||||
version = '2.12.0.dev0'
|
||||
version = '4.1.1'
|
||||
|
||||
install_requires = [
|
||||
'cryptography>=3.2.1',
|
||||
# Josepy 2+ may introduce backward incompatible changes by droping usage of
|
||||
# deprecated PyOpenSSL APIs.
|
||||
'josepy>=1.13.0, <2',
|
||||
# pyOpenSSL 23.1.0 is a bad release: https://github.com/pyca/pyopenssl/issues/1199
|
||||
'PyOpenSSL>=17.5.0,!=23.1.0',
|
||||
'cryptography>=43.0.0',
|
||||
'josepy>=2.0.0',
|
||||
# PyOpenSSL>=25.0.0 is just needed to satisfy mypy right now so this dependency can probably be
|
||||
# relaxed to >=24.0.0 if needed.
|
||||
'PyOpenSSL>=25.0.0',
|
||||
'pyrfc3339',
|
||||
'pytz>=2019.3',
|
||||
'requests>=2.20.0',
|
||||
'setuptools>=41.6.0',
|
||||
]
|
||||
|
||||
docs_extras = [
|
||||
@@ -24,15 +20,6 @@ docs_extras = [
|
||||
]
|
||||
|
||||
test_extras = [
|
||||
# In theory we could scope importlib_resources to env marker 'python_version<"3.9"'. But this
|
||||
# makes the pinning mechanism emit warnings when running `poetry lock` because in the corner
|
||||
# case of an extra dependency with env marker coming from a setup.py file, it generate the
|
||||
# invalid requirement 'importlib_resource>=1.3.1;python<=3.9;extra=="test"'.
|
||||
# To fix the issue, we do not pass the env marker. This is fine because:
|
||||
# - importlib_resources can be applied to any Python version,
|
||||
# - this is a "test" extra dependency for limited audience,
|
||||
# - it does not change anything at the end for the generated requirement files.
|
||||
'importlib_resources>=1.3.1',
|
||||
'pytest',
|
||||
'pytest-xdist',
|
||||
'typing-extensions',
|
||||
@@ -46,23 +33,24 @@ setup(
|
||||
author="Certbot Project",
|
||||
author_email='certbot-dev@eff.org',
|
||||
license='Apache License 2.0',
|
||||
python_requires='>=3.8',
|
||||
python_requires='>=3.9.2',
|
||||
classifiers=[
|
||||
'Development Status :: 5 - Production/Stable',
|
||||
'Intended Audience :: Developers',
|
||||
'License :: OSI Approved :: Apache Software License',
|
||||
'Programming Language :: Python',
|
||||
'Programming Language :: Python :: 3',
|
||||
'Programming Language :: Python :: 3.8',
|
||||
'Programming Language :: Python :: 3.9',
|
||||
'Programming Language :: Python :: 3.10',
|
||||
'Programming Language :: Python :: 3.11',
|
||||
'Programming Language :: Python :: 3.12',
|
||||
'Programming Language :: Python :: 3.13',
|
||||
'Topic :: Internet :: WWW/HTTP',
|
||||
'Topic :: Security',
|
||||
],
|
||||
|
||||
packages=find_packages(),
|
||||
packages=find_packages(where='src'),
|
||||
package_dir={'': 'src'},
|
||||
include_package_data=True,
|
||||
install_requires=install_requires,
|
||||
extras_require={
|
||||
|
||||
@@ -13,7 +13,7 @@ import requests
|
||||
from acme import errors
|
||||
from acme._internal.tests import test_util
|
||||
|
||||
CERT = test_util.load_comparable_cert('cert.pem')
|
||||
CERT = test_util.load_cert('cert.pem')
|
||||
KEY = jose.JWKRSA(key=test_util.load_rsa_private_key('rsa512_key.pem'))
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ from typing import Dict
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from cryptography import x509
|
||||
|
||||
import josepy as jose
|
||||
import pytest
|
||||
import requests
|
||||
@@ -24,6 +26,7 @@ from acme.client import ClientV2
|
||||
|
||||
CERT_SAN_PEM = test_util.load_vector('cert-san.pem')
|
||||
CSR_MIXED_PEM = test_util.load_vector('csr-mixed.pem')
|
||||
CSR_NO_SANS_PEM = test_util.load_vector('csr-nosans.pem')
|
||||
KEY = jose.JWKRSA.load(test_util.load_vector('rsa512_key.pem'))
|
||||
|
||||
DIRECTORY_V2 = messages.Directory({
|
||||
@@ -97,6 +100,10 @@ class ClientV2Test(unittest.TestCase):
|
||||
body=self.order,
|
||||
uri='https://www.letsencrypt-demo.org/acme/acct/1/order/1',
|
||||
authorizations=[self.authzr, self.authzr2], csr_pem=CSR_MIXED_PEM)
|
||||
self.orderr2 = messages.OrderResource(
|
||||
body=self.order,
|
||||
uri='https://www.letsencrypt-demo.org/acme/acct/1/order/1',
|
||||
authorizations=[self.authzr, self.authzr2], csr_pem=CSR_NO_SANS_PEM)
|
||||
|
||||
def test_new_account(self):
|
||||
self.response.status_code = http_client.CREATED
|
||||
@@ -158,6 +165,10 @@ class ClientV2Test(unittest.TestCase):
|
||||
mock_post_as_get.side_effect = (authz_response, authz_response2)
|
||||
assert self.client.new_order(CSR_MIXED_PEM) == self.orderr
|
||||
|
||||
with mock.patch('acme.client.ClientV2._post_as_get') as mock_post_as_get:
|
||||
mock_post_as_get.side_effect = (authz_response, authz_response2)
|
||||
assert self.client.new_order(CSR_NO_SANS_PEM) == self.orderr2
|
||||
|
||||
def test_answer_challege(self):
|
||||
self.response.links['up'] = {'url': self.challr.authzr_uri}
|
||||
self.response.json.return_value = self.challr.body.to_json()
|
||||
@@ -245,6 +256,26 @@ class ClientV2Test(unittest.TestCase):
|
||||
with pytest.raises(errors.IssuanceError):
|
||||
self.client.finalize_order(self.orderr, deadline)
|
||||
|
||||
@mock.patch('acme.client.ClientV2.begin_finalization')
|
||||
def test_finalize_order_ready(self, mock_begin):
|
||||
# https://github.com/certbot/certbot/issues/9766
|
||||
updated_order_ready = self.order.update(status=messages.STATUS_READY)
|
||||
|
||||
updated_order_valid = self.order.update(
|
||||
certificate='https://www.letsencrypt-demo.org/acme/cert/',
|
||||
status=messages.STATUS_VALID)
|
||||
updated_orderr = self.orderr.update(body=updated_order_valid, fullchain_pem=CERT_SAN_PEM)
|
||||
|
||||
self.response.text = CERT_SAN_PEM
|
||||
|
||||
self.response.json.side_effect = [updated_order_ready.to_json(),
|
||||
updated_order_valid.to_json()]
|
||||
|
||||
deadline = datetime.datetime(9999, 9, 9)
|
||||
assert self.client.finalize_order(self.orderr, deadline) == updated_orderr
|
||||
assert self.response.json.call_count == 2
|
||||
assert mock_begin.call_count == 2
|
||||
|
||||
def test_finalize_order_invalid_status(self):
|
||||
# https://github.com/certbot/certbot/issues/9296
|
||||
order = self.order.update(error=None, status=messages.STATUS_INVALID)
|
||||
@@ -252,6 +283,53 @@ class ClientV2Test(unittest.TestCase):
|
||||
with pytest.raises(errors.Error, match="The certificate order failed"):
|
||||
self.client.finalize_order(self.orderr, datetime.datetime(9999, 9, 9))
|
||||
|
||||
@mock.patch('acme.client.time.sleep')
|
||||
@mock.patch('acme.client.datetime')
|
||||
def test_finalize_order_orderNotReady(self, dt_mock, mock_sleep):
|
||||
# https://github.com/certbot/certbot/issues/9766
|
||||
updated_order_processing = self.order.update(status=messages.STATUS_PROCESSING)
|
||||
updated_order_ready = self.order.update(status=messages.STATUS_READY)
|
||||
|
||||
updated_order_valid = self.order.update(
|
||||
certificate='https://www.letsencrypt-demo.org/acme/cert/',
|
||||
status=messages.STATUS_VALID)
|
||||
updated_orderr = self.orderr.update(body=updated_order_valid, fullchain_pem=CERT_SAN_PEM)
|
||||
|
||||
self.response.text = CERT_SAN_PEM
|
||||
|
||||
self.response.json.side_effect = [updated_order_processing.to_json(),
|
||||
updated_order_ready.to_json(),
|
||||
updated_order_valid.to_json()]
|
||||
|
||||
dt_mock.datetime.now.return_value = datetime.datetime(2015, 3, 27)
|
||||
dt_mock.timedelta = datetime.timedelta
|
||||
self.response.headers['Retry-After'] = '50'
|
||||
|
||||
post = mock.MagicMock()
|
||||
post.side_effect = [messages.Error.with_code('orderNotReady'), # first begin_finalization
|
||||
# sleep 1
|
||||
self.response, # first poll_finalization poll --> returns processing
|
||||
# retry-after sleep here
|
||||
self.response, # second poll_finalization poll --> returns ready
|
||||
mock.MagicMock(), # second begin_finalization
|
||||
# sleep 1
|
||||
self.response, # third poll_finalization poll --> returns valid
|
||||
self.response # fetch cert
|
||||
]
|
||||
self.net.post = post
|
||||
|
||||
self.client.finalize_order(self.orderr, datetime.datetime(9999, 9, 9))
|
||||
assert self.net.post.call_count == 6
|
||||
assert mock_sleep.call_args_list == [((1,),), ((50,),), ((1,),)]
|
||||
|
||||
def test_finalize_order_otherErrorCode(self):
|
||||
post = mock.MagicMock()
|
||||
post.side_effect = [messages.Error.with_code('serverInternal')]
|
||||
self.net.post = post
|
||||
|
||||
with pytest.raises(messages.Error):
|
||||
self.client.finalize_order(self.orderr, datetime.datetime(9999, 9, 9))
|
||||
|
||||
def test_finalize_order_timeout(self):
|
||||
deadline = datetime.datetime.now() - datetime.timedelta(seconds=60)
|
||||
with pytest.raises(errors.TimeoutError):
|
||||
@@ -333,7 +411,7 @@ class ClientV2Test(unittest.TestCase):
|
||||
with mock.patch('acme.client.ClientV2._authzr_from_response') as mock_client:
|
||||
mock_client.return_value = self.authzr2
|
||||
|
||||
self.client.poll(self.authzr2) # pylint: disable=protected-access
|
||||
self.client.poll(self.authzr2)
|
||||
|
||||
self.client.net.post.assert_called_once_with(
|
||||
self.authzr2.uri, None,
|
||||
@@ -386,6 +464,200 @@ class ClientV2Test(unittest.TestCase):
|
||||
assert DIRECTORY_V2.to_partial_json() == \
|
||||
ClientV2.get_directory('https://example.com/dir', self.net).to_partial_json()
|
||||
|
||||
@mock.patch('acme.client.datetime')
|
||||
def test_renewal_time_expired_cert(self, dt_mock):
|
||||
utc_now = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
|
||||
dt_mock.datetime.now.return_value = utc_now
|
||||
|
||||
cert_pem = make_cert_for_renewal(
|
||||
not_before=datetime.datetime(2025, 3, 12, 00, 00, 00),
|
||||
not_after=datetime.datetime(2025, 3, 20, 00, 00, 00),
|
||||
)
|
||||
cert = x509.load_pem_x509_certificate(cert_pem)
|
||||
|
||||
t, _ = self.client.renewal_time(cert_pem)
|
||||
assert t == cert.not_valid_after_utc
|
||||
|
||||
@mock.patch('acme.client.datetime')
|
||||
def test_renewal_time_no_renewal_info(self, dt_mock):
|
||||
utc_now = datetime.datetime(2025, 3, 15, tzinfo=datetime.timezone.utc)
|
||||
dt_mock.datetime.now.return_value = utc_now
|
||||
# A directory with no 'renewalInfo' should result in None.
|
||||
self.client.directory = messages.Directory({})
|
||||
cert_pem = make_cert_for_renewal(
|
||||
not_before=datetime.datetime(2025, 3, 12, 00, 00, 00),
|
||||
not_after=datetime.datetime(2025, 3, 20, 00, 00, 00),
|
||||
)
|
||||
t, _ = self.client.renewal_time(cert_pem)
|
||||
assert t == None
|
||||
|
||||
cert_pem = make_cert_for_renewal(
|
||||
not_before=datetime.datetime(2025, 3, 12, 00, 00, 00),
|
||||
not_after=datetime.datetime(2025, 3, 30, 00, 00, 00),
|
||||
)
|
||||
t, _ = self.client.renewal_time(cert_pem)
|
||||
assert t == None
|
||||
|
||||
@mock.patch('acme.client.datetime')
|
||||
def test_renewal_time_with_renewal_info(self, dt_mock):
|
||||
from cryptography import x509
|
||||
from acme.client import _renewal_info_path_component
|
||||
utc_now = datetime.datetime(2025, 3, 15, tzinfo=datetime.timezone.utc)
|
||||
dt_mock.datetime.now.return_value = utc_now
|
||||
dt_mock.timedelta = datetime.timedelta
|
||||
|
||||
cert_pem = make_cert_for_renewal(
|
||||
not_before=datetime.datetime(2025, 3, 12, 00, 00, 00),
|
||||
not_after=datetime.datetime(2025, 3, 20, 00, 00, 00),
|
||||
)
|
||||
|
||||
self.client.directory = messages.Directory({
|
||||
'renewalInfo': 'https://www.letsencrypt-demo.org/acme/renewal-info',
|
||||
})
|
||||
|
||||
self.response.json.return_value = {
|
||||
"suggestedWindow": {
|
||||
"start": "2025-03-14T01:01:01Z",
|
||||
"end": "2025-03-14T01:01:01Z",
|
||||
},
|
||||
"message": "Keep those certs fresh"
|
||||
}
|
||||
t, _ = self.client.renewal_time(cert_pem)
|
||||
cert_parsed = x509.load_pem_x509_certificate(cert_pem)
|
||||
ari_path_component = _renewal_info_path_component(cert_parsed)
|
||||
self.net.get.assert_called_once_with("https://www.letsencrypt-demo.org/acme/renewal-info/" +
|
||||
ari_path_component,
|
||||
content_type='application/json')
|
||||
assert t == datetime.datetime(2025, 3, 14, 1, 1, 1, tzinfo=datetime.timezone.utc)
|
||||
|
||||
self.net.reset_mock()
|
||||
|
||||
self.response.json.return_value = {
|
||||
"suggestedWindow": {
|
||||
"start": "2025-03-16T01:01:01Z",
|
||||
"end": "2025-03-17T01:01:01Z",
|
||||
},
|
||||
"message": "Keep those certs fresh"
|
||||
}
|
||||
t, _ = self.client.renewal_time(cert_pem)
|
||||
self.net.get.assert_called_once_with("https://www.letsencrypt-demo.org/acme/renewal-info/" +
|
||||
ari_path_component,
|
||||
content_type='application/json')
|
||||
assert t >= datetime.datetime(2025, 3, 16, 1, 1, 1, tzinfo=datetime.timezone.utc)
|
||||
assert t <= datetime.datetime(2025, 3, 17, 1, 1, 1, tzinfo=datetime.timezone.utc)
|
||||
|
||||
@mock.patch('acme.client.datetime')
|
||||
def test_renewal_time_renewal_info_errors(self, dt_mock):
|
||||
utc_now = datetime.datetime(2025, 3, 15, tzinfo=datetime.timezone.utc)
|
||||
dt_mock.datetime.now.return_value = utc_now
|
||||
self.client.directory = messages.Directory({
|
||||
'renewalInfo': 'https://www.letsencrypt-demo.org/acme/renewal-info',
|
||||
})
|
||||
# Failure to fetch the 'renewalInfo' URL should return None
|
||||
self.net.get.side_effect = requests.exceptions.RequestException
|
||||
|
||||
cert_pem = make_cert_for_renewal(
|
||||
not_before=datetime.datetime(2025, 3, 12, 00, 00, 00),
|
||||
not_after=datetime.datetime(2025, 3, 20, 00, 00, 00),
|
||||
)
|
||||
t, _ = self.client.renewal_time(cert_pem)
|
||||
assert t == None
|
||||
|
||||
cert_pem = make_cert_for_renewal(
|
||||
not_before=datetime.datetime(2025, 3, 12, 00, 00, 00),
|
||||
not_after=datetime.datetime(2025, 3, 30, 00, 00, 00),
|
||||
)
|
||||
t, _ = self.client.renewal_time(cert_pem)
|
||||
assert t == None
|
||||
|
||||
@mock.patch('acme.client.datetime')
|
||||
def test_renewal_time_returns_retry_after(self, dt_mock):
|
||||
def now(tzinfo=None):
|
||||
return datetime.datetime(2025, 3, 15, tzinfo=tzinfo)
|
||||
dt_mock.datetime.now.side_effect = now
|
||||
dt_mock.timedelta = datetime.timedelta
|
||||
dt_mock.timezone = datetime.timezone
|
||||
|
||||
self.client.directory = messages.Directory({
|
||||
'renewalInfo': 'https://www.letsencrypt-demo.org/acme/renewal-info',
|
||||
})
|
||||
cert_pem = make_cert_for_renewal(
|
||||
not_before=datetime.datetime(2025, 3, 12, 00, 00, 00),
|
||||
not_after=datetime.datetime(2025, 3, 20, 00, 00, 00),
|
||||
)
|
||||
self.response.json.return_value = {
|
||||
"suggestedWindow": {
|
||||
"start": "2025-03-14T01:01:01Z",
|
||||
"end": "2025-03-14T01:01:01Z",
|
||||
},
|
||||
"message": "Keep those certs fresh"
|
||||
}
|
||||
|
||||
# With no explicit Retry-After in header, default to six hours
|
||||
_, retry_after = self.client.renewal_time(cert_pem)
|
||||
assert retry_after == datetime.datetime(2025, 3, 15, 6, 0, 0)
|
||||
|
||||
# With an explicit Retry-After in header, use that
|
||||
self.response.headers['Retry-After'] = '100'
|
||||
_, retry_after = self.client.renewal_time(cert_pem)
|
||||
assert retry_after == datetime.datetime(2025, 3, 15, 00, 1, 40)
|
||||
|
||||
def test_renewal_info_path_component():
|
||||
from cryptography import x509
|
||||
from acme.client import _renewal_info_path_component
|
||||
|
||||
cert = x509.load_pem_x509_certificate(test_util.load_vector('rsa2048_cert.pem'))
|
||||
|
||||
assert _renewal_info_path_component(cert) == "fL5sRirC8VS5AtOQh9DfoAzYNCI.ALVG_VbBb5U7"
|
||||
|
||||
# From https://www.ietf.org/archive/id/draft-ietf-acme-ari-08.html appendix A.
|
||||
ARI_TEST_CERT = b"""
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBQzCB66ADAgECAgUAh2VDITAKBggqhkjOPQQDAjAVMRMwEQYDVQQDEwpFeGFt
|
||||
cGxlIENBMCIYDzAwMDEwMTAxMDAwMDAwWhgPMDAwMTAxMDEwMDAwMDBaMBYxFDAS
|
||||
BgNVBAMTC2V4YW1wbGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEeBZu
|
||||
7cbpAYNXZLbbh8rNIzuOoqOOtmxA1v7cRm//AwyMwWxyHz4zfwmBhcSrf47NUAFf
|
||||
qzLQ2PPQxdTXREYEnKMjMCEwHwYDVR0jBBgwFoAUaYhba4dGQEHhs3uEe6CuLN4B
|
||||
yNQwCgYIKoZIzj0EAwIDRwAwRAIge09+S5TZAlw5tgtiVvuERV6cT4mfutXIlwTb
|
||||
+FYN/8oCIClDsqBklhB9KAelFiYt9+6FDj3z4KGVelYM5MdsO3pK
|
||||
-----END CERTIFICATE-----
|
||||
"""
|
||||
|
||||
cert = x509.load_pem_x509_certificate(ARI_TEST_CERT)
|
||||
assert _renewal_info_path_component(cert) == "aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE"
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover
|
||||
|
||||
def make_cert_for_renewal(not_before, not_after) -> bytes:
|
||||
"""
|
||||
Return a PEM-encoded, self-signed certificate with the given dates.
|
||||
"""
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives import serialization, hashes
|
||||
# AKID and serial are the inputs to constructing the renewalInfo URL
|
||||
akid = x509.AuthorityKeyIdentifier(b"1234", None, None)
|
||||
serial = 56789
|
||||
key = ec.generate_private_key(ec.SECP256R1())
|
||||
cert = x509.CertificateBuilder(
|
||||
issuer_name=x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, "Some Issuer")]),
|
||||
subject_name=x509.Name([]),
|
||||
public_key=key.public_key(),
|
||||
serial_number=serial,
|
||||
not_valid_before=not_before,
|
||||
not_valid_after=not_after,
|
||||
).add_extension(
|
||||
x509.SubjectAlternativeName([x509.DNSName('example.com')]),
|
||||
critical=False,
|
||||
).add_extension(
|
||||
akid,
|
||||
critical=False,
|
||||
).sign(
|
||||
private_key=key,
|
||||
algorithm=hashes.SHA256(),
|
||||
)
|
||||
return cert.public_bytes(serialization.Encoding.PEM)
|
||||
|
||||
class MockJSONDeSerializable(jose.JSONDeSerializable):
|
||||
# pylint: disable=missing-docstring
|
||||
@@ -627,12 +899,11 @@ class ClientNetworkTest(unittest.TestCase):
|
||||
except requests.exceptions.ConnectionError as z: #pragma: no cover
|
||||
assert "'Connection aborted.'" in str(z) or "[WinError 10061]" in str(z)
|
||||
|
||||
|
||||
class ClientNetworkWithMockedResponseTest(unittest.TestCase):
|
||||
"""Tests for acme.client.ClientNetwork which mock out response."""
|
||||
|
||||
def setUp(self):
|
||||
self.net = ClientNetwork(key=None, alg=None)
|
||||
self.net = ClientNetwork(key='fake', alg=None)
|
||||
|
||||
self.response = mock.MagicMock(ok=True, status_code=http_client.OK)
|
||||
self.response.headers = {}
|
||||
@@ -707,7 +978,6 @@ class ClientNetworkWithMockedResponseTest(unittest.TestCase):
|
||||
assert self.response.checked
|
||||
|
||||
def test_post(self):
|
||||
# pylint: disable=protected-access
|
||||
assert self.response == self.net.post(
|
||||
'uri', self.obj, content_type=self.content_type)
|
||||
assert self.response.checked
|
||||
@@ -736,7 +1006,6 @@ class ClientNetworkWithMockedResponseTest(unittest.TestCase):
|
||||
check_response = mock.MagicMock()
|
||||
check_response.side_effect = messages.Error.with_code('badNonce')
|
||||
|
||||
# pylint: disable=protected-access
|
||||
self.net._check_response = check_response
|
||||
with pytest.raises(messages.Error):
|
||||
self.net.post('uri',
|
||||
@@ -747,7 +1016,6 @@ class ClientNetworkWithMockedResponseTest(unittest.TestCase):
|
||||
check_response.side_effect = [messages.Error.with_code('malformed'),
|
||||
self.response]
|
||||
|
||||
# pylint: disable=protected-access
|
||||
self.net._check_response = check_response
|
||||
with pytest.raises(messages.Error):
|
||||
self.net.post('uri',
|
||||
@@ -758,7 +1026,6 @@ class ClientNetworkWithMockedResponseTest(unittest.TestCase):
|
||||
post_once.side_effect = [messages.Error.with_code('badNonce'),
|
||||
self.response]
|
||||
|
||||
# pylint: disable=protected-access
|
||||
assert self.response == self.net.post(
|
||||
'uri', self.obj, content_type=self.content_type)
|
||||
|
||||
@@ -789,6 +1056,16 @@ class ClientNetworkWithMockedResponseTest(unittest.TestCase):
|
||||
self.content_type = None
|
||||
self.net.post('uri', self.obj, content_type=None, new_nonce_url='new_nonce_uri')
|
||||
|
||||
def test_no_key_error(self):
|
||||
"A ClientNetwork with no key should error on POST but succeed on GET"
|
||||
self.net = ClientNetwork()
|
||||
self.net._send_request = mock.MagicMock()
|
||||
self.net._send_request.return_value = self.response
|
||||
with pytest.raises(errors.Error):
|
||||
self.net.post('uri', "body")
|
||||
assert self.response == self.net.get(
|
||||
'uri', content_type=self.content_type, bar='baz')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover
|
||||
342
acme/src/acme/_internal/tests/crypto_util_test.py
Normal file
342
acme/src/acme/_internal/tests/crypto_util_test.py
Normal file
@@ -0,0 +1,342 @@
|
||||
"""Tests for acme.crypto_util."""
|
||||
import ipaddress
|
||||
import itertools
|
||||
import socket
|
||||
import socketserver
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import List
|
||||
import unittest
|
||||
from unittest import mock
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa, x25519
|
||||
|
||||
from acme import errors
|
||||
from acme._internal.tests import test_util
|
||||
|
||||
|
||||
class FormatTest(unittest.TestCase):
|
||||
def test_to_cryptography_encoding(self):
|
||||
from acme.crypto_util import Format
|
||||
assert Format.DER.to_cryptography_encoding() == serialization.Encoding.DER
|
||||
assert Format.PEM.to_cryptography_encoding() == serialization.Encoding.PEM
|
||||
|
||||
|
||||
class SSLSocketAndProbeSNITest(unittest.TestCase):
|
||||
"""Tests for acme.crypto_util.SSLSocket/probe_sni."""
|
||||
|
||||
def setUp(self):
|
||||
self.cert = test_util.load_cert('rsa2048_cert.pem')
|
||||
key = test_util.load_pyopenssl_private_key('rsa2048_key.pem')
|
||||
# pylint: disable=protected-access
|
||||
certs = {b'foo': (key, self.cert)}
|
||||
|
||||
from acme.crypto_util import SSLSocket
|
||||
|
||||
class _TestServer(socketserver.TCPServer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.socket = SSLSocket(self.socket, certs)
|
||||
|
||||
self.server = _TestServer(('', 0), socketserver.BaseRequestHandler)
|
||||
self.port = self.server.socket.getsockname()[1]
|
||||
self.server_thread = threading.Thread(
|
||||
target=self.server.handle_request)
|
||||
|
||||
def tearDown(self):
|
||||
if self.server_thread.is_alive():
|
||||
# The thread may have already terminated.
|
||||
self.server_thread.join() # pragma: no cover
|
||||
self.server.server_close()
|
||||
|
||||
def _probe(self, name):
|
||||
from acme.crypto_util import probe_sni
|
||||
return probe_sni(name, host='127.0.0.1', port=self.port)
|
||||
|
||||
def _start_server(self):
|
||||
self.server_thread.start()
|
||||
time.sleep(1) # TODO: avoid race conditions in other way
|
||||
|
||||
def test_probe_ok(self):
|
||||
self._start_server()
|
||||
assert self.cert == self._probe(b'foo')
|
||||
|
||||
def test_probe_not_recognized_name(self):
|
||||
self._start_server()
|
||||
with pytest.raises(errors.Error):
|
||||
self._probe(b'bar')
|
||||
|
||||
def test_probe_connection_error(self):
|
||||
self.server.server_close()
|
||||
original_timeout = socket.getdefaulttimeout()
|
||||
try:
|
||||
socket.setdefaulttimeout(1)
|
||||
with pytest.raises(errors.Error):
|
||||
self._probe(b'bar')
|
||||
finally:
|
||||
socket.setdefaulttimeout(original_timeout)
|
||||
|
||||
|
||||
class SSLSocketTest(unittest.TestCase):
|
||||
"""Tests for acme.crypto_util.SSLSocket."""
|
||||
|
||||
def test_ssl_socket_invalid_arguments(self):
|
||||
from acme.crypto_util import SSLSocket
|
||||
with pytest.raises(ValueError):
|
||||
_ = SSLSocket(None, {'sni': ('key', 'cert')},
|
||||
cert_selection=lambda _: None)
|
||||
with pytest.raises(ValueError):
|
||||
_ = SSLSocket(None)
|
||||
|
||||
|
||||
class MiscTests(unittest.TestCase):
|
||||
|
||||
def test_dump_cryptography_chain(self):
|
||||
from acme.crypto_util import dump_cryptography_chain
|
||||
|
||||
cert1 = test_util.load_cert('rsa2048_cert.pem')
|
||||
cert2 = test_util.load_cert('rsa4096_cert.pem')
|
||||
|
||||
chain = [cert1, cert2]
|
||||
dumped = dump_cryptography_chain(chain)
|
||||
|
||||
# default is PEM encoding Encoding.PEM
|
||||
assert isinstance(dumped, bytes)
|
||||
|
||||
|
||||
class CryptographyCertOrReqSANTest(unittest.TestCase):
|
||||
"""Test for acme.crypto_util._cryptography_cert_or_req_san."""
|
||||
|
||||
@classmethod
|
||||
def _call(cls, loader, name):
|
||||
# pylint: disable=protected-access
|
||||
from acme.crypto_util import _cryptography_cert_or_req_san
|
||||
return _cryptography_cert_or_req_san(loader(name))
|
||||
|
||||
@classmethod
|
||||
def _get_idn_names(cls):
|
||||
"""Returns expected names from '{cert,csr}-idnsans.pem'."""
|
||||
chars = [chr(i) for i in itertools.chain(range(0x3c3, 0x400),
|
||||
range(0x641, 0x6fc),
|
||||
range(0x1820, 0x1877))]
|
||||
return [''.join(chars[i: i + 45]) + '.invalid'
|
||||
for i in range(0, len(chars), 45)]
|
||||
|
||||
def _call_cert(self, name):
|
||||
return self._call(test_util.load_cert, name)
|
||||
|
||||
def _call_csr(self, name):
|
||||
return self._call(test_util.load_csr, name)
|
||||
|
||||
def test_cert_no_sans(self):
|
||||
assert self._call_cert('cert.pem') == []
|
||||
|
||||
def test_cert_two_sans(self):
|
||||
assert self._call_cert('cert-san.pem') == \
|
||||
['example.com', 'www.example.com']
|
||||
|
||||
def test_cert_hundred_sans(self):
|
||||
assert self._call_cert('cert-100sans.pem') == \
|
||||
['example{0}.com'.format(i) for i in range(1, 101)]
|
||||
|
||||
def test_cert_idn_sans(self):
|
||||
assert self._call_cert('cert-idnsans.pem') == \
|
||||
self._get_idn_names()
|
||||
|
||||
def test_csr_no_sans(self):
|
||||
assert self._call_csr('csr-nosans.pem') == []
|
||||
|
||||
def test_csr_one_san(self):
|
||||
assert self._call_csr('csr.pem') == ['example.com']
|
||||
|
||||
def test_csr_two_sans(self):
|
||||
assert self._call_csr('csr-san.pem') == \
|
||||
['example.com', 'www.example.com']
|
||||
|
||||
def test_csr_six_sans(self):
|
||||
assert self._call_csr('csr-6sans.pem') == \
|
||||
['example.com', 'example.org', 'example.net',
|
||||
'example.info', 'subdomain.example.com',
|
||||
'other.subdomain.example.com']
|
||||
|
||||
def test_csr_hundred_sans(self):
|
||||
assert self._call_csr('csr-100sans.pem') == \
|
||||
['example{0}.com'.format(i) for i in range(1, 101)]
|
||||
|
||||
def test_csr_idn_sans(self):
|
||||
assert self._call_csr('csr-idnsans.pem') == \
|
||||
self._get_idn_names()
|
||||
|
||||
def test_critical_san(self):
|
||||
assert self._call_cert('critical-san.pem') == \
|
||||
['chicago-cubs.venafi.example', 'cubs.venafi.example']
|
||||
|
||||
|
||||
class GenMakeSelfSignedCertTest(unittest.TestCase):
|
||||
"""Test for make_self_signed_cert."""
|
||||
|
||||
def setUp(self):
|
||||
self.cert_count = 5
|
||||
self.serial_num: List[int] = []
|
||||
self.privkey = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
def test_sn_collisions(self):
|
||||
from acme.crypto_util import make_self_signed_cert
|
||||
for _ in range(self.cert_count):
|
||||
cert = make_self_signed_cert(self.privkey, ['dummy'], force_san=True,
|
||||
ips=[ipaddress.ip_address("10.10.10.10")])
|
||||
self.serial_num.append(cert.serial_number)
|
||||
assert len(set(self.serial_num)) >= self.cert_count
|
||||
|
||||
def test_no_ips(self):
|
||||
from acme.crypto_util import make_self_signed_cert
|
||||
cert = make_self_signed_cert(self.privkey, ['dummy'])
|
||||
|
||||
@mock.patch("acme.crypto_util._now")
|
||||
def test_expiry_times(self, mock_now):
|
||||
from acme.crypto_util import make_self_signed_cert
|
||||
from datetime import datetime, timedelta, timezone
|
||||
not_before = 1736200830
|
||||
validity = 100
|
||||
|
||||
not_before_dt = datetime.fromtimestamp(not_before)
|
||||
validity_td = timedelta(validity)
|
||||
not_after_dt = not_before_dt + validity_td
|
||||
cert = make_self_signed_cert(
|
||||
self.privkey,
|
||||
['dummy'],
|
||||
not_before=not_before_dt,
|
||||
validity=validity_td,
|
||||
)
|
||||
# TODO: This should be `not_valid_before_utc` once we raise the minimum
|
||||
# cryptography version.
|
||||
# https://github.com/certbot/certbot/issues/10105
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings(
|
||||
'ignore',
|
||||
message='Properties that return.*datetime object'
|
||||
)
|
||||
self.assertEqual(cert.not_valid_before, not_before_dt)
|
||||
self.assertEqual(cert.not_valid_after, not_after_dt)
|
||||
|
||||
now = not_before + 1
|
||||
now_dt = datetime.fromtimestamp(now)
|
||||
mock_now.return_value = now_dt.replace(tzinfo=timezone.utc)
|
||||
valid_after_now_dt = now_dt + validity_td
|
||||
cert = make_self_signed_cert(
|
||||
self.privkey,
|
||||
['dummy'],
|
||||
validity=validity_td,
|
||||
)
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings(
|
||||
'ignore',
|
||||
message='Properties that return.*datetime object'
|
||||
)
|
||||
self.assertEqual(cert.not_valid_before, now_dt)
|
||||
self.assertEqual(cert.not_valid_after, valid_after_now_dt)
|
||||
|
||||
def test_no_name(self):
|
||||
from acme.crypto_util import make_self_signed_cert
|
||||
with pytest.raises(AssertionError):
|
||||
make_self_signed_cert(self.privkey, ips=[ipaddress.ip_address("1.1.1.1")])
|
||||
make_self_signed_cert(self.privkey)
|
||||
|
||||
def test_extensions(self):
|
||||
from acme.crypto_util import make_self_signed_cert
|
||||
extension_type = x509.TLSFeature([x509.TLSFeatureType.status_request])
|
||||
extension = x509.Extension(
|
||||
x509.TLSFeature.oid,
|
||||
False,
|
||||
extension_type
|
||||
)
|
||||
cert = make_self_signed_cert(
|
||||
self.privkey,
|
||||
ips=[ipaddress.ip_address("1.1.1.1")],
|
||||
extensions=[extension]
|
||||
)
|
||||
self.assertIn(extension, cert.extensions)
|
||||
|
||||
|
||||
class MakeCSRTest(unittest.TestCase):
|
||||
"""Test for standalone functions."""
|
||||
|
||||
@classmethod
|
||||
def _call_with_key(cls, *args, **kwargs):
|
||||
privkey = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
privkey_pem = privkey.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
from acme.crypto_util import make_csr
|
||||
|
||||
return make_csr(privkey_pem, *args, **kwargs)
|
||||
|
||||
def test_make_csr(self):
|
||||
csr_pem = self._call_with_key(["a.example", "b.example"])
|
||||
assert b"--BEGIN CERTIFICATE REQUEST--" in csr_pem
|
||||
assert b"--END CERTIFICATE REQUEST--" in csr_pem
|
||||
csr = x509.load_pem_x509_csr(csr_pem)
|
||||
|
||||
assert len(csr.extensions) == 1
|
||||
assert list(
|
||||
csr.extensions.get_extension_for_class(x509.SubjectAlternativeName).value
|
||||
) == [
|
||||
x509.DNSName("a.example"),
|
||||
x509.DNSName("b.example"),
|
||||
]
|
||||
|
||||
def test_make_csr_ip(self):
|
||||
csr_pem = self._call_with_key(
|
||||
["a.example"],
|
||||
False,
|
||||
[ipaddress.ip_address("127.0.0.1"), ipaddress.ip_address("::1")],
|
||||
)
|
||||
assert b"--BEGIN CERTIFICATE REQUEST--" in csr_pem
|
||||
assert b"--END CERTIFICATE REQUEST--" in csr_pem
|
||||
|
||||
csr = x509.load_pem_x509_csr(csr_pem)
|
||||
|
||||
assert len(csr.extensions) == 1
|
||||
assert list(
|
||||
csr.extensions.get_extension_for_class(x509.SubjectAlternativeName).value
|
||||
) == [
|
||||
x509.DNSName("a.example"),
|
||||
x509.IPAddress(ipaddress.ip_address("127.0.0.1")),
|
||||
x509.IPAddress(ipaddress.ip_address("::1")),
|
||||
]
|
||||
|
||||
def test_make_csr_must_staple(self):
|
||||
csr_pem = self._call_with_key(["a.example"], must_staple=True)
|
||||
csr = x509.load_pem_x509_csr(csr_pem)
|
||||
|
||||
assert len(csr.extensions) == 2
|
||||
assert list(csr.extensions.get_extension_for_class(x509.TLSFeature).value) == [
|
||||
x509.TLSFeatureType.status_request
|
||||
]
|
||||
|
||||
def test_make_csr_without_hostname(self):
|
||||
with pytest.raises(ValueError):
|
||||
self._call_with_key()
|
||||
|
||||
def test_make_csr_invalid_key_type(self):
|
||||
privkey = x25519.X25519PrivateKey.generate()
|
||||
privkey_pem = privkey.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
from acme.crypto_util import make_csr
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
make_csr(privkey_pem, ["a.example"])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover
|
||||
@@ -51,5 +51,36 @@ class PollErrorTest(unittest.TestCase):
|
||||
'sentinel.AR2})' % repr(set()) == repr(self.invalid)
|
||||
|
||||
|
||||
class ValidationErrorTest(unittest.TestCase):
|
||||
"""Tests for acme.errors.ValidationError"""
|
||||
|
||||
def setUp(self):
|
||||
from acme.errors import ValidationError
|
||||
from acme.challenges import DNS01
|
||||
from acme.messages import Error
|
||||
from acme.messages import Authorization
|
||||
from acme.messages import AuthorizationResource
|
||||
from acme.messages import IDENTIFIER_FQDN
|
||||
from acme.messages import ChallengeBody
|
||||
from acme.messages import Identifier
|
||||
self.challenge_error = Error(typ='custom', detail='bar')
|
||||
failed_authzr = AuthorizationResource(
|
||||
body=Authorization(
|
||||
identifier=Identifier(typ=IDENTIFIER_FQDN, value="example.com"),
|
||||
challenges=[ChallengeBody(
|
||||
chall=DNS01(),
|
||||
error=self.challenge_error,
|
||||
)]
|
||||
)
|
||||
)
|
||||
self.error = ValidationError([failed_authzr])
|
||||
|
||||
def test_repr(self):
|
||||
err_message = str(self.error)
|
||||
assert 'Authorization for example.com failed' in err_message
|
||||
assert 'Challenge dns-01 failed' in err_message
|
||||
assert str(self.challenge_error) in err_message
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Tests for acme.jose shim."""
|
||||
import importlib
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -21,7 +21,7 @@ class HeaderTest(unittest.TestCase):
|
||||
except (ValueError, TypeError):
|
||||
assert True
|
||||
else:
|
||||
assert False # pragma: no cover
|
||||
pytest.fail("Exception from jose.b64decode wasn't raised") # pragma: no cover
|
||||
|
||||
def test_nonce_decoder(self):
|
||||
from acme.jws import Header
|
||||
@@ -1,10 +1,8 @@
|
||||
"""Tests for acme.messages."""
|
||||
import contextlib
|
||||
import sys
|
||||
from typing import Dict
|
||||
import unittest
|
||||
from unittest import mock
|
||||
import warnings
|
||||
|
||||
import josepy as jose
|
||||
import pytest
|
||||
@@ -12,8 +10,8 @@ import pytest
|
||||
from acme import challenges
|
||||
from acme._internal.tests import test_util
|
||||
|
||||
CERT = test_util.load_comparable_cert('cert.der')
|
||||
CSR = test_util.load_comparable_csr('csr.der')
|
||||
CERT = test_util.load_cert('cert.der')
|
||||
CSR = test_util.load_csr('csr.der')
|
||||
KEY = test_util.load_rsa_private_key('rsa512_key.pem')
|
||||
|
||||
|
||||
@@ -162,6 +160,10 @@ class DirectoryTest(unittest.TestCase):
|
||||
terms_of_service='https://example.com/acme/terms',
|
||||
website='https://www.example.com/',
|
||||
caa_identities=['example.com'],
|
||||
profiles={
|
||||
"example": "some profile",
|
||||
"other example": "a different profile"
|
||||
}
|
||||
),
|
||||
})
|
||||
|
||||
@@ -191,6 +193,10 @@ class DirectoryTest(unittest.TestCase):
|
||||
'termsOfService': 'https://example.com/acme/terms',
|
||||
'website': 'https://www.example.com/',
|
||||
'caaIdentities': ['example.com'],
|
||||
'profiles': {
|
||||
'example': 'some profile',
|
||||
'other example': 'a different profile'
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -528,14 +534,25 @@ class NewOrderTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
from acme.messages import NewOrder
|
||||
self.reg = NewOrder(
|
||||
self.order = NewOrder(
|
||||
identifiers=mock.sentinel.identifiers)
|
||||
|
||||
def test_to_partial_json(self):
|
||||
assert self.reg.to_json() == {
|
||||
assert self.order.to_json() == {
|
||||
'identifiers': mock.sentinel.identifiers,
|
||||
}
|
||||
|
||||
def test_default_profile_empty(self):
|
||||
assert self.order.profile is None
|
||||
|
||||
def test_non_empty_profile(self):
|
||||
from acme.messages import NewOrder
|
||||
order = NewOrder(identifiers=mock.sentinel.identifiers, profile='example')
|
||||
assert order.to_json() == {
|
||||
'identifiers': mock.sentinel.identifiers,
|
||||
'profile': 'example',
|
||||
}
|
||||
|
||||
|
||||
class JWSPayloadRFC8555Compliant(unittest.TestCase):
|
||||
"""Test for RFC8555 compliance of JWS generated from resources/challenges"""
|
||||
@@ -11,6 +11,8 @@ from unittest import mock
|
||||
import josepy as jose
|
||||
import pytest
|
||||
import requests
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
from acme import challenges
|
||||
from acme import crypto_util
|
||||
@@ -116,13 +118,13 @@ class TLSALPN01ServerTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.certs = {b'localhost': (
|
||||
test_util.load_pyopenssl_private_key('rsa2048_key.pem'),
|
||||
test_util.load_cert('rsa2048_cert.pem'),
|
||||
serialization.load_pem_private_key(test_util.load_vector('rsa2048_key.pem'), password=None),
|
||||
x509.load_pem_x509_certificate(test_util.load_vector('rsa2048_cert.pem')),
|
||||
)}
|
||||
# Use different certificate for challenge.
|
||||
self.challenge_certs = {b'localhost': (
|
||||
test_util.load_pyopenssl_private_key('rsa4096_key.pem'),
|
||||
test_util.load_cert('rsa4096_cert.pem'),
|
||||
serialization.load_pem_private_key(test_util.load_vector('rsa4096_key.pem'), password=None),
|
||||
x509.load_pem_x509_certificate(test_util.load_vector('rsa4096_cert.pem')),
|
||||
)}
|
||||
from acme.standalone import TLSALPN01Server
|
||||
self.server = TLSALPN01Server(("localhost", 0), certs=self.certs,
|
||||
@@ -142,8 +144,8 @@ class TLSALPN01ServerTest(unittest.TestCase):
|
||||
# cert = crypto_util.probe_sni(
|
||||
# b'localhost', host=host, port=port, timeout=1)
|
||||
# # Expect normal cert when connecting without ALPN.
|
||||
# self.assertEqual(jose.ComparableX509(cert),
|
||||
# jose.ComparableX509(self.certs[b'localhost'][1]))
|
||||
# self.assertEqual(cert,
|
||||
# self.certs[b'localhost'][1])
|
||||
|
||||
def test_challenge_certs(self):
|
||||
host, port = self.server.socket.getsockname()[:2]
|
||||
@@ -151,8 +153,7 @@ class TLSALPN01ServerTest(unittest.TestCase):
|
||||
b'localhost', host=host, port=port, timeout=1,
|
||||
alpn_protocols=[b"acme-tls/1"])
|
||||
# Expect challenge cert when connecting with ALPN.
|
||||
assert jose.ComparableX509(cert) == \
|
||||
jose.ComparableX509(self.challenge_certs[b'localhost'][1])
|
||||
assert cert == self.challenge_certs[b'localhost'][1]
|
||||
|
||||
def test_bad_alpn(self):
|
||||
host, port = self.server.socket.getsockname()[:2]
|
||||
@@ -193,7 +194,7 @@ class BaseDualNetworkedServersTest(unittest.TestCase):
|
||||
|
||||
from acme.standalone import BaseDualNetworkedServers
|
||||
|
||||
mock_bind.side_effect = socket.error(EADDRINUSE, "Fake addr in use error")
|
||||
mock_bind.side_effect = OSError(EADDRINUSE, "Fake addr in use error")
|
||||
|
||||
with pytest.raises(socket.error) as exc_info:
|
||||
BaseDualNetworkedServers(
|
||||
@@ -3,59 +3,55 @@
|
||||
.. warning:: This module is not part of the public API.
|
||||
|
||||
"""
|
||||
import importlib.resources
|
||||
import os
|
||||
import sys
|
||||
from typing import Callable
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
import josepy as jose
|
||||
from josepy.util import ComparableECKey
|
||||
from OpenSSL import crypto
|
||||
|
||||
if sys.version_info >= (3, 9): # pragma: no cover
|
||||
import importlib.resources as importlib_resources
|
||||
else: # pragma: no cover
|
||||
import importlib_resources
|
||||
|
||||
|
||||
def load_vector(*names):
|
||||
"""Load contents of a test vector."""
|
||||
# luckily, resource_string opens file in binary mode
|
||||
vector_ref = importlib_resources.files(__package__).joinpath('testdata', *names)
|
||||
vector_ref = importlib.resources.files(__package__).joinpath('testdata', *names)
|
||||
return vector_ref.read_bytes()
|
||||
|
||||
|
||||
def _guess_loader(filename, loader_pem, loader_der):
|
||||
def _guess_loader(filename: str, loader_pem: Callable, loader_der: Callable) -> Callable:
|
||||
_, ext = os.path.splitext(filename)
|
||||
if ext.lower() == '.pem':
|
||||
if ext.lower() == ".pem":
|
||||
return loader_pem
|
||||
elif ext.lower() == '.der':
|
||||
elif ext.lower() == ".der":
|
||||
return loader_der
|
||||
raise ValueError("Loader could not be recognized based on extension") # pragma: no cover
|
||||
else: # pragma: no cover
|
||||
raise ValueError("Loader could not be recognized based on extension")
|
||||
|
||||
|
||||
def load_cert(*names):
|
||||
def _guess_pyopenssl_loader(filename: str, loader_pem: int, loader_der: int) -> int:
|
||||
_, ext = os.path.splitext(filename)
|
||||
if ext.lower() == ".pem":
|
||||
return loader_pem
|
||||
else: # pragma: no cover
|
||||
raise ValueError("Loader could not be recognized based on extension")
|
||||
|
||||
|
||||
def load_cert(*names: str) -> x509.Certificate:
|
||||
"""Load certificate."""
|
||||
loader = _guess_loader(
|
||||
names[-1], crypto.FILETYPE_PEM, crypto.FILETYPE_ASN1)
|
||||
return crypto.load_certificate(loader, load_vector(*names))
|
||||
names[-1], x509.load_pem_x509_certificate, x509.load_der_x509_certificate
|
||||
)
|
||||
return loader(load_vector(*names))
|
||||
|
||||
|
||||
def load_comparable_cert(*names):
|
||||
"""Load ComparableX509 cert."""
|
||||
return jose.ComparableX509(load_cert(*names))
|
||||
|
||||
|
||||
def load_csr(*names):
|
||||
def load_csr(*names: str) -> x509.CertificateSigningRequest:
|
||||
"""Load certificate request."""
|
||||
loader = _guess_loader(
|
||||
names[-1], crypto.FILETYPE_PEM, crypto.FILETYPE_ASN1)
|
||||
return crypto.load_certificate_request(loader, load_vector(*names))
|
||||
|
||||
|
||||
def load_comparable_csr(*names):
|
||||
"""Load ComparableX509 certificate request."""
|
||||
return jose.ComparableX509(load_csr(*names))
|
||||
loader = _guess_loader(names[-1], x509.load_pem_x509_csr, x509.load_der_x509_csr)
|
||||
return loader(load_vector(*names))
|
||||
|
||||
|
||||
def load_rsa_private_key(*names):
|
||||
@@ -76,6 +72,6 @@ def load_ecdsa_private_key(*names):
|
||||
|
||||
def load_pyopenssl_private_key(*names):
|
||||
"""Load pyOpenSSL private key."""
|
||||
loader = _guess_loader(
|
||||
loader = _guess_pyopenssl_loader(
|
||||
names[-1], crypto.FILETYPE_PEM, crypto.FILETYPE_ASN1)
|
||||
return crypto.load_privatekey(loader, load_vector(*names))
|
||||
16
acme/src/acme/_internal/tests/testdata/csr-mixed.pem
vendored
Normal file
16
acme/src/acme/_internal/tests/testdata/csr-mixed.pem
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
-----BEGIN CERTIFICATE REQUEST-----
|
||||
MIICdjCCAV4CAQAwADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANoV
|
||||
T1pdvRUUBOqvm7M2ebLEHV7higUH7qAGUZEkfP6W4YriYVY+IHrH1svNPSa+oPTK
|
||||
7weDNmT11ehWnGyECIM9z2r2Hi9yVV0ycxh4hWQ4Nt8BAKZwCwaXpyWm7Gj6m2Ez
|
||||
pSN5Dd67g5YAQBrUUh1+RRbFi9c0Ls/6ZOExMvfg8kqt4c2sXCgH1IFnxvvOjBYo
|
||||
p7xh0x3L1Akyax0tw8qgQp/z5mkupmVDNJYPFmbzFPMNyDR61ed6QUTDg7P4UAuF
|
||||
kejLLzFvz5YaO7vC+huaTuPhInAhpzqpr4yU97KIjos2/83Itu/Cv8U1RAeEeRTk
|
||||
h0WjUfltoem/5f8bIdsCAwEAAaAxMC8GCSqGSIb3DQEJDjEiMCAwHgYDVR0RBBcw
|
||||
FYINYS5leGVtcGxlLmNvbYcEwAACbzANBgkqhkiG9w0BAQsFAAOCAQEAQ7n/hYen
|
||||
5INHlcslHPYCQ/BAbX6Ou+Y8hUu8puWNVpE2OM95L2C87jbWwTmCRnkFBwtyoNqo
|
||||
j3DXVW2RYv8y/exq7V6Y5LtpHTgwfugINJ3XlcVzA4Vnf1xqOxv3kwejkq74RuXn
|
||||
xd5N28srgiFqb0e4tOAWVI8Tw27bgBqjoXl0QDFPZpctqUia5bcDJ9WzNSM7VaO1
|
||||
CBNGHBRz+zL8sqoqJA4HV58tjcgzl+1RtGM+iUHxXpnH+aCNKWIUINrAzIm4Sm00
|
||||
93RJjhb1kdNR0BC7ikWVbAWaVviHdvATK/RfpmhWDqfEaNgBpvT91GnkhpzctSFD
|
||||
ro0yCUUXXrIr0w==
|
||||
-----END CERTIFICATE REQUEST-----
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for acme.util."""
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""ACME Identifier Validation Challenges."""
|
||||
import abc
|
||||
import codecs
|
||||
import functools
|
||||
import hashlib
|
||||
import logging
|
||||
@@ -14,7 +13,9 @@ from typing import Tuple
|
||||
from typing import Type
|
||||
from typing import TypeVar
|
||||
from typing import Union
|
||||
import warnings
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
import josepy as jose
|
||||
from OpenSSL import crypto
|
||||
@@ -89,7 +90,7 @@ class _TokenChallenge(Challenge):
|
||||
:ivar bytes token:
|
||||
|
||||
"""
|
||||
TOKEN_SIZE = 128 / 8 # Based on the entropy value from the spec
|
||||
TOKEN_SIZE = 128 // 8 # Based on the entropy value from the spec
|
||||
"""Minimum size of the :attr:`token` in bytes."""
|
||||
|
||||
# TODO: acme-spec doesn't specify token as base64-encoded value
|
||||
@@ -399,7 +400,11 @@ class HTTP01(KeyAuthorizationChallenge):
|
||||
|
||||
@ChallengeResponse.register
|
||||
class TLSALPN01Response(KeyAuthorizationChallengeResponse):
|
||||
"""ACME tls-alpn-01 challenge response."""
|
||||
"""ACME tls-alpn-01 challenge response.
|
||||
|
||||
.. deprecated:: 4.1.0
|
||||
|
||||
"""
|
||||
typ = "tls-alpn-01"
|
||||
|
||||
PORT = 443
|
||||
@@ -413,13 +418,18 @@ class TLSALPN01Response(KeyAuthorizationChallengeResponse):
|
||||
ID_PE_ACME_IDENTIFIER_V1 = b"1.3.6.1.5.5.7.1.30.1"
|
||||
ACME_TLS_1_PROTOCOL = b"acme-tls/1"
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
warnings.warn("TLSALPN01Response is deprecated and will be removed in an "
|
||||
"upcoming certbot major version update", DeprecationWarning)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def h(self) -> bytes:
|
||||
"""Hash value stored in challenge certificate"""
|
||||
return hashlib.sha256(self.key_authorization.encode('utf-8')).digest()
|
||||
|
||||
def gen_cert(self, domain: str, key: Optional[crypto.PKey] = None, bits: int = 2048
|
||||
) -> Tuple[crypto.X509, crypto.PKey]:
|
||||
) -> Tuple[x509.Certificate, crypto.PKey]:
|
||||
"""Generate tls-alpn-01 certificate.
|
||||
|
||||
:param str domain: Domain verified by the challenge.
|
||||
@@ -428,22 +438,32 @@ class TLSALPN01Response(KeyAuthorizationChallengeResponse):
|
||||
fresh key will be generated.
|
||||
:param int bits: Number of bits for newly generated key.
|
||||
|
||||
:rtype: `tuple` of `OpenSSL.crypto.X509` and `OpenSSL.crypto.PKey`
|
||||
:rtype: `tuple` of `x509.Certificate` and `OpenSSL.crypto.PKey`
|
||||
|
||||
"""
|
||||
if key is None:
|
||||
key = crypto.PKey()
|
||||
key.generate_key(crypto.TYPE_RSA, bits)
|
||||
|
||||
der_value = b"DER:" + codecs.encode(self.h, 'hex')
|
||||
acme_extension = crypto.X509Extension(self.ID_PE_ACME_IDENTIFIER_V1,
|
||||
critical=True, value=der_value)
|
||||
oid = x509.ObjectIdentifier(self.ID_PE_ACME_IDENTIFIER_V1.decode())
|
||||
acme_extension = x509.Extension(
|
||||
oid,
|
||||
critical=True,
|
||||
value=x509.UnrecognizedExtension(oid, self.h)
|
||||
)
|
||||
|
||||
return crypto_util.gen_ss_cert(key, [domain], force_san=True,
|
||||
extensions=[acme_extension]), key
|
||||
cryptography_key = key.to_cryptography_key()
|
||||
assert isinstance(cryptography_key, crypto_util.CertificateIssuerPrivateKeyTypesTpl)
|
||||
cert = crypto_util.make_self_signed_cert(
|
||||
cryptography_key,
|
||||
[domain],
|
||||
force_san=True,
|
||||
extensions=[acme_extension]
|
||||
)
|
||||
return cert, key
|
||||
|
||||
def probe_cert(self, domain: str, host: Optional[str] = None,
|
||||
port: Optional[int] = None) -> crypto.X509:
|
||||
port: Optional[int] = None) -> x509.Certificate:
|
||||
"""Probe tls-alpn-01 challenge certificate.
|
||||
|
||||
:param str domain: domain being validated, required.
|
||||
@@ -457,41 +477,48 @@ class TLSALPN01Response(KeyAuthorizationChallengeResponse):
|
||||
if port is None:
|
||||
port = self.PORT
|
||||
|
||||
return crypto_util.probe_sni(host=host.encode(), port=port, name=domain.encode(),
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings(
|
||||
'ignore',
|
||||
message='alpn_protocols parameter is deprecated'
|
||||
)
|
||||
return crypto_util.probe_sni(host=host.encode(), port=port, name=domain.encode(),
|
||||
alpn_protocols=[self.ACME_TLS_1_PROTOCOL])
|
||||
|
||||
def verify_cert(self, domain: str, cert: crypto.X509) -> bool:
|
||||
def verify_cert(self, domain: str, cert: x509.Certificate, ) -> bool:
|
||||
"""Verify tls-alpn-01 challenge certificate.
|
||||
|
||||
:param str domain: Domain name being validated.
|
||||
:param OpensSSL.crypto.X509 cert: Challenge certificate.
|
||||
:param cert: Challenge certificate.
|
||||
:type cert: `cryptography.x509.Certificate`
|
||||
|
||||
:returns: Whether the certificate was successfully verified.
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
names = crypto_util._pyopenssl_cert_or_req_all_names(cert)
|
||||
# Type ignore needed due to
|
||||
# https://github.com/pyca/pyopenssl/issues/730.
|
||||
logger.debug('Certificate %s. SANs: %s',
|
||||
cert.digest('sha256'), names)
|
||||
names = crypto_util.get_names_from_subject_and_extensions(
|
||||
cert.subject, cert.extensions
|
||||
)
|
||||
logger.debug(
|
||||
"Certificate %s. SANs: %s", cert.fingerprint(hashes.SHA256()), names
|
||||
)
|
||||
if len(names) != 1 or names[0].lower() != domain.lower():
|
||||
return False
|
||||
|
||||
for i in range(cert.get_extension_count()):
|
||||
ext = cert.get_extension(i)
|
||||
# FIXME: assume this is the ACME extension. Currently there is no
|
||||
# way to get full OID of an unknown extension from pyopenssl.
|
||||
if ext.get_short_name() == b'UNDEF':
|
||||
data = ext.get_data()
|
||||
return data == self.h
|
||||
try:
|
||||
ext = cert.extensions.get_extension_for_oid(
|
||||
x509.ObjectIdentifier(self.ID_PE_ACME_IDENTIFIER_V1.decode())
|
||||
)
|
||||
except x509.ExtensionNotFound:
|
||||
return False
|
||||
|
||||
return False
|
||||
# This is for the type checker.
|
||||
assert isinstance(ext.value, x509.UnrecognizedExtension)
|
||||
return ext.value.value == self.h
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def simple_verify(self, chall: 'TLSALPN01', domain: str, account_public_key: jose.JWK,
|
||||
cert: Optional[crypto.X509] = None, host: Optional[str] = None,
|
||||
cert: Optional[x509.Certificate] = None, host: Optional[str] = None,
|
||||
port: Optional[int] = None) -> bool:
|
||||
"""Simple verify.
|
||||
|
||||
@@ -501,7 +528,7 @@ class TLSALPN01Response(KeyAuthorizationChallengeResponse):
|
||||
:param .challenges.TLSALPN01 chall: Corresponding challenge.
|
||||
:param str domain: Domain name being validated.
|
||||
:param JWK account_public_key:
|
||||
:param OpenSSL.crypto.X509 cert: Optional certificate. If not
|
||||
:param x509.Certificate cert: Optional certificate. If not
|
||||
provided (``None``) certificate will be retrieved using
|
||||
`probe_cert`.
|
||||
:param string host: IP address used to probe the certificate.
|
||||
@@ -528,11 +555,21 @@ class TLSALPN01Response(KeyAuthorizationChallengeResponse):
|
||||
|
||||
@Challenge.register # pylint: disable=too-many-ancestors
|
||||
class TLSALPN01(KeyAuthorizationChallenge):
|
||||
"""ACME tls-alpn-01 challenge."""
|
||||
"""ACME tls-alpn-01 challenge.
|
||||
|
||||
.. deprecated:: 4.1.0
|
||||
|
||||
"""
|
||||
response_cls = TLSALPN01Response
|
||||
typ = response_cls.typ
|
||||
|
||||
def validation(self, account_key: jose.JWK, **kwargs: Any) -> Tuple[crypto.X509, crypto.PKey]:
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
warnings.warn("TLSALPN01 is deprecated and will be removed in an "
|
||||
"upcoming certbot major version update", DeprecationWarning)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def validation(self, account_key: jose.JWK,
|
||||
**kwargs: Any) -> Tuple[x509.Certificate, crypto.PKey]:
|
||||
"""Generate validation.
|
||||
|
||||
:param JWK account_key:
|
||||
@@ -541,7 +578,7 @@ class TLSALPN01(KeyAuthorizationChallenge):
|
||||
in certificate generation. If not provided (``None``), then
|
||||
fresh key will be generated.
|
||||
|
||||
:rtype: `tuple` of `OpenSSL.crypto.X509` and `OpenSSL.crypto.PKey`
|
||||
:rtype: `tuple` of `x509.Certificate` and `OpenSSL.crypto.PKey`
|
||||
|
||||
"""
|
||||
# TODO: Remove cast when response() is generic.
|
||||
@@ -560,6 +597,8 @@ class TLSALPN01(KeyAuthorizationChallenge):
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
warnings.warn("TLSALPN01 is deprecated and will be removed in an "
|
||||
"upcoming certbot major version update", DeprecationWarning)
|
||||
return (hasattr(SSL.Connection, "set_alpn_protos")
|
||||
and hasattr(SSL.Context, "set_alpn_select_callback"))
|
||||
|
||||
@@ -4,6 +4,8 @@ import datetime
|
||||
from email.utils import parsedate_tz
|
||||
import http.client as http_client
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
@@ -15,8 +17,9 @@ from typing import Set
|
||||
from typing import Tuple
|
||||
from typing import Union
|
||||
|
||||
from cryptography import x509
|
||||
|
||||
import josepy as jose
|
||||
import OpenSSL
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from requests.utils import parse_header_links
|
||||
@@ -113,7 +116,7 @@ class ClientV2:
|
||||
self.net.account = new_regr
|
||||
return new_regr
|
||||
|
||||
def new_order(self, csr_pem: bytes) -> messages.OrderResource:
|
||||
def new_order(self, csr_pem: bytes, profile: Optional[str] = None) -> messages.OrderResource:
|
||||
"""Request a new Order object from the server.
|
||||
|
||||
:param bytes csr_pem: A CSR in PEM format.
|
||||
@@ -121,19 +124,24 @@ class ClientV2:
|
||||
:returns: The newly created order.
|
||||
:rtype: OrderResource
|
||||
"""
|
||||
csr = OpenSSL.crypto.load_certificate_request(OpenSSL.crypto.FILETYPE_PEM, csr_pem)
|
||||
# pylint: disable=protected-access
|
||||
dnsNames = crypto_util._pyopenssl_cert_or_req_all_names(csr)
|
||||
ipNames = crypto_util._pyopenssl_cert_or_req_san_ip(csr)
|
||||
# ipNames is now []string
|
||||
csr = x509.load_pem_x509_csr(csr_pem)
|
||||
dnsNames = crypto_util.get_names_from_subject_and_extensions(csr.subject, csr.extensions)
|
||||
try:
|
||||
san_ext = csr.extensions.get_extension_for_class(x509.SubjectAlternativeName)
|
||||
except x509.ExtensionNotFound:
|
||||
ipNames = []
|
||||
else:
|
||||
ipNames = san_ext.value.get_values_for_type(x509.IPAddress)
|
||||
identifiers = []
|
||||
for name in dnsNames:
|
||||
identifiers.append(messages.Identifier(typ=messages.IDENTIFIER_FQDN,
|
||||
value=name))
|
||||
for ips in ipNames:
|
||||
for ip in ipNames:
|
||||
identifiers.append(messages.Identifier(typ=messages.IDENTIFIER_IP,
|
||||
value=ips))
|
||||
order = messages.NewOrder(identifiers=identifiers)
|
||||
value=str(ip)))
|
||||
if profile is None:
|
||||
profile = ""
|
||||
order = messages.NewOrder(identifiers=identifiers, profile=profile)
|
||||
response = self._post(self.directory['newOrder'], order)
|
||||
body = messages.Order.from_json(response.json())
|
||||
authorizations = []
|
||||
@@ -218,10 +226,14 @@ class ClientV2:
|
||||
|
||||
:returns: updated order
|
||||
:rtype: messages.OrderResource
|
||||
|
||||
:raises .messages.Error: If server indicates order is not yet in ready state,
|
||||
it will return a 403 (Forbidden) error with a problem document/error code of type
|
||||
"orderNotReady"
|
||||
|
||||
"""
|
||||
csr = OpenSSL.crypto.load_certificate_request(
|
||||
OpenSSL.crypto.FILETYPE_PEM, orderr.csr_pem)
|
||||
wrapped_csr = messages.CertificateRequest(csr=jose.ComparableX509(csr))
|
||||
csr = x509.load_pem_x509_csr(orderr.csr_pem)
|
||||
wrapped_csr = messages.CertificateRequest(csr=csr)
|
||||
res = self._post(orderr.body.finalize, wrapped_csr)
|
||||
orderr = orderr.update(body=messages.Order.from_json(res.json()))
|
||||
return orderr
|
||||
@@ -234,21 +246,37 @@ class ClientV2:
|
||||
Poll an order that has been finalized for its status.
|
||||
If it becomes valid, obtain the certificate.
|
||||
|
||||
If a finalization request previously returned `orderNotReady`,
|
||||
poll until ready, send a new finalization request, and continue
|
||||
polling until valid as above.
|
||||
|
||||
:returns: finalized order (with certificate)
|
||||
:rtype: messages.OrderResource
|
||||
"""
|
||||
|
||||
sleep_seconds: float = 1
|
||||
while datetime.datetime.now() < deadline:
|
||||
time.sleep(1)
|
||||
if sleep_seconds > 0:
|
||||
time.sleep(sleep_seconds)
|
||||
response = self._post_as_get(orderr.uri)
|
||||
body = messages.Order.from_json(response.json())
|
||||
if body.status == messages.STATUS_INVALID:
|
||||
# "invalid": The certificate will not be issued. Consider this
|
||||
# order process abandoned.
|
||||
if body.error is not None:
|
||||
raise errors.IssuanceError(body.error)
|
||||
raise errors.Error(
|
||||
"The certificate order failed. No further information was provided "
|
||||
"by the server.")
|
||||
elif body.status == messages.STATUS_READY:
|
||||
# "ready": The server agrees that the requirements have been
|
||||
# fulfilled, and is awaiting finalization. Submit a finalization
|
||||
# request.
|
||||
self.begin_finalization(orderr)
|
||||
sleep_seconds = 1
|
||||
elif body.status == messages.STATUS_VALID and body.certificate is not None:
|
||||
# "valid": The server has issued the certificate and provisioned its
|
||||
# URL to the "certificate" field of the order. Download the
|
||||
# certificate.
|
||||
certificate_response = self._post_as_get(body.certificate)
|
||||
orderr = orderr.update(body=body, fullchain_pem=certificate_response.text)
|
||||
if fetch_alternative_chains:
|
||||
@@ -256,6 +284,14 @@ class ClientV2:
|
||||
alt_chains = [self._post_as_get(url).text for url in alt_chains_urls]
|
||||
orderr = orderr.update(alternative_fullchains_pem=alt_chains)
|
||||
return orderr
|
||||
elif body.status == messages.STATUS_PROCESSING:
|
||||
# "processing": The certificate is being issued. Send a POST-as-GET request after
|
||||
# the time given in the Retry-After header field of the response, if any.
|
||||
retry_after = self.retry_after(response, 1)
|
||||
# Whatever Retry-After the ACME server requests, the polling must not take
|
||||
# longer than the overall deadline
|
||||
retry_after = min(retry_after, deadline)
|
||||
sleep_seconds = (retry_after - datetime.datetime.now()).total_seconds()
|
||||
raise errors.TimeoutError()
|
||||
|
||||
def finalize_order(self, orderr: messages.OrderResource, deadline: datetime.datetime,
|
||||
@@ -271,14 +307,79 @@ class ClientV2:
|
||||
:rtype: messages.OrderResource
|
||||
|
||||
"""
|
||||
self.begin_finalization(orderr)
|
||||
try:
|
||||
self.begin_finalization(orderr)
|
||||
except messages.Error as e:
|
||||
if e.code != 'orderNotReady':
|
||||
raise e
|
||||
return self.poll_finalization(orderr, deadline, fetch_alternative_chains)
|
||||
|
||||
def revoke(self, cert: jose.ComparableX509, rsn: int) -> None:
|
||||
def renewal_time(self, cert_pem: bytes
|
||||
) -> Tuple[Optional[datetime.datetime], datetime.datetime]:
|
||||
"""Return an appropriate time to attempt renewal of the certificate,
|
||||
and the next time to ask the ACME server for renewal info.
|
||||
|
||||
If the certificate has already expired, renewal info isn't checked.
|
||||
Instead, the certificate's notAfter time is returned and the certificate
|
||||
should be immediately renewed.
|
||||
|
||||
If the ACME directory has a "renewalInfo" field, the response will be
|
||||
based on a fetch of the renewal info resource for the certificate
|
||||
(https://www.ietf.org/archive/id/draft-ietf-acme-ari-08.html).
|
||||
|
||||
If there is no "renewalInfo" field, this function will return a tuple of
|
||||
None, and the next time to ask the ACME server for renewal info.
|
||||
|
||||
This function may make other network calls in the future (e.g., OCSP
|
||||
or CRL).
|
||||
|
||||
:param bytes cert_pem: cert as pem file
|
||||
|
||||
:returns: Tuple of time to attempt renewal, next time to ask for renewal info
|
||||
"""
|
||||
now = datetime.datetime.now()
|
||||
# https://www.ietf.org/archive/id/draft-ietf-acme-ari-08.html#section-4.3.3
|
||||
default_retry_after = datetime.timedelta(seconds=6 * 60 * 60)
|
||||
|
||||
cert = x509.load_pem_x509_certificate(cert_pem)
|
||||
|
||||
# from https://www.ietf.org/archive/id/draft-ietf-acme-ari-08.html#section-4.3, "Clients
|
||||
# MUST NOT check a certificate's RenewalInfo after the certificate has expired."
|
||||
#
|
||||
# we call datetime.datetime.now here with the UTC argument to create a timezone aware
|
||||
# datetime object that can be compared with the UTC notAfter from cryptography
|
||||
if cert.not_valid_after_utc < datetime.datetime.now(datetime.timezone.utc):
|
||||
return cert.not_valid_after_utc, now + default_retry_after
|
||||
|
||||
try:
|
||||
renewal_info_base_url = self.directory['renewalInfo']
|
||||
except KeyError:
|
||||
return None, now + default_retry_after
|
||||
|
||||
ari_url = renewal_info_base_url + '/' + _renewal_info_path_component(cert)
|
||||
try:
|
||||
resp = self.net.get(ari_url, content_type='application/json')
|
||||
except (requests.exceptions.RequestException, messages.Error) as error:
|
||||
logger.info("failed to fetch renewal_info URL (%s): %s", ari_url, error)
|
||||
return None, now + default_retry_after
|
||||
|
||||
renewal_info: messages.RenewalInfo = messages.RenewalInfo.from_json(resp.json())
|
||||
|
||||
start = renewal_info.suggested_window.start # pylint: disable=no-member
|
||||
end = renewal_info.suggested_window.end # pylint: disable=no-member
|
||||
|
||||
delta_seconds = (end - start).total_seconds()
|
||||
random_seconds = random.uniform(0, delta_seconds)
|
||||
random_time = start + datetime.timedelta(seconds=random_seconds)
|
||||
|
||||
retry_after = self.retry_after(resp, default_retry_after.seconds)
|
||||
return random_time, retry_after
|
||||
|
||||
|
||||
def revoke(self, cert: x509.Certificate, rsn: int) -> None:
|
||||
"""Revoke certificate.
|
||||
|
||||
:param .ComparableX509 cert: `OpenSSL.crypto.X509` wrapped in
|
||||
`.ComparableX509`
|
||||
:param x509.Certificate cert: `x509.Certificate`
|
||||
|
||||
:param int rsn: Reason code for certificate revocation.
|
||||
|
||||
@@ -466,11 +567,10 @@ class ClientV2:
|
||||
|
||||
return datetime.datetime.now() + datetime.timedelta(seconds=seconds)
|
||||
|
||||
def _revoke(self, cert: jose.ComparableX509, rsn: int, url: str) -> None:
|
||||
def _revoke(self, cert: x509.Certificate, rsn: int, url: str) -> None:
|
||||
"""Revoke certificate.
|
||||
|
||||
:param .ComparableX509 cert: `OpenSSL.crypto.X509` wrapped in
|
||||
`.ComparableX509`
|
||||
:param .x509.Certificate cert: `x509.Certificate`
|
||||
|
||||
:param int rsn: Reason code for certificate revocation.
|
||||
|
||||
@@ -500,7 +600,7 @@ class ClientNetwork:
|
||||
|
||||
"""Initialize.
|
||||
|
||||
:param josepy.JWK key: Account private key
|
||||
:param josepy.JWK key: Account private key. Required to use .post().
|
||||
:param messages.RegistrationResource account: Account object. Required if you are
|
||||
planning to use .post() for anything other than creating a new account;
|
||||
may be set later after registering.
|
||||
@@ -509,7 +609,8 @@ class ClientNetwork:
|
||||
:param str user_agent: String to send as User-Agent header.
|
||||
:param int timeout: Timeout for requests.
|
||||
"""
|
||||
def __init__(self, key: jose.JWK, account: Optional[messages.RegistrationResource] = None,
|
||||
def __init__(self, key: Optional[jose.JWK] = None,
|
||||
account: Optional[messages.RegistrationResource] = None,
|
||||
alg: jose.JWASignature = jose.RS256, verify_ssl: bool = True,
|
||||
user_agent: str = 'acme-python', timeout: int = DEFAULT_NETWORK_TIMEOUT) -> None:
|
||||
self.key = key
|
||||
@@ -546,16 +647,17 @@ class ClientNetwork:
|
||||
"""
|
||||
jobj = obj.json_dumps(indent=2).encode() if obj else b''
|
||||
logger.debug('JWS payload:\n%s', jobj)
|
||||
assert self.key
|
||||
kwargs = {
|
||||
"alg": self.alg,
|
||||
"nonce": nonce,
|
||||
"url": url
|
||||
"url": url,
|
||||
"key": self.key
|
||||
}
|
||||
# newAccount and revokeCert work without the kid
|
||||
# newAccount must not have kid
|
||||
if self.account is not None:
|
||||
kwargs["kid"] = self.account["uri"]
|
||||
kwargs["key"] = self.key
|
||||
return jws.JWS.sign(jobj, **cast(Mapping[str, Any], kwargs)).json_dumps(indent=2)
|
||||
|
||||
@classmethod
|
||||
@@ -745,9 +847,30 @@ class ClientNetwork:
|
||||
def _post_once(self, url: str, obj: jose.JSONDeSerializable,
|
||||
content_type: str = JOSE_CONTENT_TYPE, **kwargs: Any) -> requests.Response:
|
||||
new_nonce_url = kwargs.pop('new_nonce_url', None)
|
||||
if not self.key:
|
||||
raise errors.Error("acme.ClientNetwork with no private key can't POST.")
|
||||
data = self._wrap_in_jws(obj, self._get_nonce(url, new_nonce_url), url)
|
||||
kwargs.setdefault('headers', {'Content-Type': content_type})
|
||||
response = self._send_request('POST', url, data=data, **kwargs)
|
||||
response = self._check_response(response, content_type=content_type)
|
||||
self._add_nonce(response)
|
||||
return response
|
||||
|
||||
def _renewal_info_path_component(cert: x509.Certificate) -> str:
|
||||
akid_ext = cert.extensions.get_extension_for_oid(x509.ExtensionOID.AUTHORITY_KEY_IDENTIFIER)
|
||||
key_identifier = akid_ext.value.key_identifier # type: ignore[attr-defined]
|
||||
|
||||
akid_encoded = base64.urlsafe_b64encode(key_identifier).decode('ascii').replace("=", "")
|
||||
|
||||
# We add one to the reported bit_length so there is room for the sign bit.
|
||||
# https://docs.python.org/3/library/stdtypes.html#int.bit_length
|
||||
# "Return the number of bits necessary to represent an integer in binary, excluding
|
||||
# the sign and leading zeros"
|
||||
serial = cert.serial_number
|
||||
encoded_serial_len = math.ceil((serial.bit_length()+1)/8)
|
||||
# Serials are encoded as ASN.1 INTEGERS, which means big endian and signed (two's complement).
|
||||
# https://letsencrypt.org/docs/a-warm-welcome-to-asn1-and-der/#integer-encoding
|
||||
serial_bytes = serial.to_bytes(encoded_serial_len, byteorder='big', signed=True)
|
||||
serial_encoded = base64.urlsafe_b64encode(serial_bytes).decode('ascii').replace("=", "")
|
||||
|
||||
return f"{akid_encoded}.{serial_encoded}"
|
||||
500
acme/src/acme/crypto_util.py
Normal file
500
acme/src/acme/crypto_util.py
Normal file
@@ -0,0 +1,500 @@
|
||||
"""Crypto utilities."""
|
||||
import contextlib
|
||||
import enum
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
import typing
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
from typing import List
|
||||
from typing import Literal
|
||||
from typing import Mapping
|
||||
from typing import Optional
|
||||
from typing import Sequence
|
||||
from typing import Set
|
||||
from typing import Tuple
|
||||
from typing import Union
|
||||
import warnings
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import dsa, rsa, ec, ed25519, ed448, types
|
||||
from cryptography.hazmat.primitives.serialization import Encoding
|
||||
from OpenSSL import crypto
|
||||
from OpenSSL import SSL
|
||||
|
||||
from acme import errors
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default SSL method selected here is the most compatible, while secure
|
||||
# SSL method: TLSv1_METHOD is only compatible with
|
||||
# TLSv1_METHOD, while TLS_method is compatible with all other
|
||||
# methods, including TLSv2_METHOD (read more at
|
||||
# https://docs.openssl.org/master/man3/SSL_CTX_new/#notes). _serve_sni
|
||||
# should be changed to use "set_options" to disable SSLv2 and SSLv3,
|
||||
# in case it's used for things other than probing/serving!
|
||||
_DEFAULT_SSL_METHOD = SSL.TLS_METHOD
|
||||
|
||||
|
||||
class Format(enum.IntEnum):
|
||||
"""File format to be used when parsing or serializing X.509 structures.
|
||||
|
||||
Backwards compatible with the `FILETYPE_ASN1` and `FILETYPE_PEM` constants
|
||||
from pyOpenSSL.
|
||||
"""
|
||||
DER = crypto.FILETYPE_ASN1
|
||||
PEM = crypto.FILETYPE_PEM
|
||||
|
||||
def to_cryptography_encoding(self) -> Encoding:
|
||||
"""Converts the Format to the corresponding cryptography `Encoding`.
|
||||
"""
|
||||
if self == Format.DER:
|
||||
return Encoding.DER
|
||||
else:
|
||||
return Encoding.PEM
|
||||
|
||||
|
||||
_KeyAndCert = Union[
|
||||
Tuple[crypto.PKey, crypto.X509],
|
||||
Tuple[types.CertificateIssuerPrivateKeyTypes, x509.Certificate],
|
||||
]
|
||||
|
||||
|
||||
class _DefaultCertSelection:
|
||||
def __init__(self, certs: Mapping[bytes, _KeyAndCert]):
|
||||
self.certs = certs
|
||||
|
||||
def __call__(self, connection: SSL.Connection) -> Optional[_KeyAndCert]:
|
||||
server_name = connection.get_servername()
|
||||
if server_name:
|
||||
return self.certs.get(server_name, None)
|
||||
return None # pragma: no cover
|
||||
|
||||
|
||||
class SSLSocket: # pylint: disable=too-few-public-methods
|
||||
"""SSL wrapper for sockets.
|
||||
|
||||
:ivar socket sock: Original wrapped socket.
|
||||
:ivar dict certs: Mapping from domain names (`bytes`) to
|
||||
`OpenSSL.crypto.X509`.
|
||||
:ivar method: See `OpenSSL.SSL.Context` for allowed values.
|
||||
:ivar alpn_selection: Hook to select negotiated ALPN protocol for
|
||||
connection.
|
||||
:ivar cert_selection: Hook to select certificate for connection. If given,
|
||||
`certs` parameter would be ignored, and therefore must be empty.
|
||||
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
sock: socket.socket,
|
||||
certs: Optional[Mapping[bytes, _KeyAndCert]] = None,
|
||||
method: int = _DEFAULT_SSL_METHOD,
|
||||
alpn_selection: Optional[Callable[[SSL.Connection, List[bytes]], bytes]] = None,
|
||||
cert_selection: Optional[
|
||||
Callable[
|
||||
[SSL.Connection],
|
||||
Optional[_KeyAndCert],
|
||||
]
|
||||
] = None,
|
||||
) -> None:
|
||||
warnings.warn("SSLSocket is deprecated and will be removed in an upcoming release",
|
||||
DeprecationWarning)
|
||||
self.sock = sock
|
||||
self.alpn_selection = alpn_selection
|
||||
self.method = method
|
||||
if not cert_selection and not certs:
|
||||
raise ValueError("Neither cert_selection or certs specified.")
|
||||
if cert_selection and certs:
|
||||
raise ValueError("Both cert_selection and certs specified.")
|
||||
if cert_selection is None:
|
||||
cert_selection = _DefaultCertSelection(certs if certs else {})
|
||||
self.cert_selection = cert_selection
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.sock, name)
|
||||
|
||||
def _pick_certificate_cb(self, connection: SSL.Connection) -> None:
|
||||
"""SNI certificate callback.
|
||||
|
||||
This method will set a new OpenSSL context object for this
|
||||
connection when an incoming connection provides an SNI name
|
||||
(in order to serve the appropriate certificate, if any).
|
||||
|
||||
:param connection: The TLS connection object on which the SNI
|
||||
extension was received.
|
||||
:type connection: :class:`OpenSSL.Connection`
|
||||
|
||||
"""
|
||||
pair = self.cert_selection(connection)
|
||||
if pair is None:
|
||||
logger.debug("Certificate selection for server name %s failed, dropping SSL",
|
||||
connection.get_servername())
|
||||
return
|
||||
key, cert = pair
|
||||
new_context = SSL.Context(self.method)
|
||||
new_context.set_min_proto_version(SSL.TLS1_2_VERSION)
|
||||
new_context.use_privatekey(key)
|
||||
if isinstance(cert, x509.Certificate):
|
||||
cert = crypto.X509.from_cryptography(cert)
|
||||
new_context.use_certificate(cert)
|
||||
if self.alpn_selection is not None:
|
||||
new_context.set_alpn_select_callback(self.alpn_selection)
|
||||
connection.set_context(new_context)
|
||||
|
||||
class FakeConnection:
|
||||
"""Fake OpenSSL.SSL.Connection."""
|
||||
|
||||
# pylint: disable=missing-function-docstring
|
||||
|
||||
def __init__(self, connection: SSL.Connection) -> None:
|
||||
self._wrapped = connection
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._wrapped, name)
|
||||
|
||||
def shutdown(self, *unused_args: Any) -> bool:
|
||||
# OpenSSL.SSL.Connection.shutdown doesn't accept any args
|
||||
try:
|
||||
return self._wrapped.shutdown()
|
||||
except SSL.Error as error: # pragma: no cover
|
||||
# We wrap the error so we raise the same error type as sockets
|
||||
# in the standard library. This is useful when this object is
|
||||
# used by code which expects a standard socket such as
|
||||
# socketserver in the standard library.
|
||||
#
|
||||
# We don't track code coverage in this "except" branch to avoid spurious CI failures
|
||||
# caused by missing test coverage. These aren't worth fixing because this entire
|
||||
# class has been deprecated. See https://github.com/certbot/certbot/issues/10284.
|
||||
raise OSError(error)
|
||||
|
||||
def accept(self) -> Tuple[FakeConnection, Any]: # pylint: disable=missing-function-docstring
|
||||
sock, addr = self.sock.accept()
|
||||
|
||||
try:
|
||||
context = SSL.Context(self.method)
|
||||
context.set_options(SSL.OP_NO_SSLv2)
|
||||
context.set_options(SSL.OP_NO_SSLv3)
|
||||
context.set_tlsext_servername_callback(self._pick_certificate_cb)
|
||||
if self.alpn_selection is not None:
|
||||
context.set_alpn_select_callback(self.alpn_selection)
|
||||
|
||||
ssl_sock = self.FakeConnection(SSL.Connection(context, sock))
|
||||
ssl_sock.set_accept_state()
|
||||
|
||||
# This log line is especially desirable because without it requests to
|
||||
# our standalone TLSALPN server would not be logged.
|
||||
logger.debug("Performing handshake with %s", addr)
|
||||
try:
|
||||
ssl_sock.do_handshake()
|
||||
except SSL.Error as error:
|
||||
# _pick_certificate_cb might have returned without
|
||||
# creating SSL context (wrong server name)
|
||||
raise OSError(error)
|
||||
|
||||
return ssl_sock, addr
|
||||
except:
|
||||
# If we encounter any error, close the new socket before reraising
|
||||
# the exception.
|
||||
sock.close()
|
||||
raise
|
||||
|
||||
|
||||
def probe_sni(name: bytes, host: bytes, port: int = 443, timeout: int = 300, # pylint: disable=too-many-arguments
|
||||
method: int = _DEFAULT_SSL_METHOD, source_address: Tuple[str, int] = ('', 0),
|
||||
alpn_protocols: Optional[Sequence[bytes]] = None) -> x509.Certificate:
|
||||
"""Probe SNI server for SSL certificate.
|
||||
|
||||
:param bytes name: Byte string to send as the server name in the
|
||||
client hello message.
|
||||
:param bytes host: Host to connect to.
|
||||
:param int port: Port to connect to.
|
||||
:param int timeout: Timeout in seconds.
|
||||
:param method: See `OpenSSL.SSL.Context` for allowed values.
|
||||
:param tuple source_address: Enables multi-path probing (selection
|
||||
of source interface). See `socket.creation_connection` for more
|
||||
info. Available only in Python 2.7+.
|
||||
:param alpn_protocols: Protocols to request using ALPN.
|
||||
:type alpn_protocols: `Sequence` of `bytes`
|
||||
|
||||
:raises acme.errors.Error: In case of any problems.
|
||||
|
||||
:returns: SSL certificate presented by the server.
|
||||
:rtype: cryptography.x509.Certificate
|
||||
|
||||
"""
|
||||
context = SSL.Context(method)
|
||||
context.set_timeout(timeout)
|
||||
|
||||
socket_kwargs = {'source_address': source_address}
|
||||
|
||||
try:
|
||||
logger.debug(
|
||||
"Attempting to connect to %s:%d%s.", host, port,
|
||||
" from {0}:{1}".format(
|
||||
source_address[0],
|
||||
source_address[1]
|
||||
) if any(source_address) else ""
|
||||
)
|
||||
socket_tuple: Tuple[bytes, int] = (host, port)
|
||||
sock = socket.create_connection(socket_tuple, **socket_kwargs) # type: ignore[arg-type]
|
||||
except OSError as error:
|
||||
raise errors.Error(error)
|
||||
|
||||
with contextlib.closing(sock) as client:
|
||||
client_ssl = SSL.Connection(context, client)
|
||||
client_ssl.set_connect_state()
|
||||
client_ssl.set_tlsext_host_name(name) # pyOpenSSL>=0.13
|
||||
if alpn_protocols is not None:
|
||||
client_ssl.set_alpn_protos(list(alpn_protocols))
|
||||
warnings.warn("alpn_protocols parameter is deprecated and will be removed in an "
|
||||
"upcoming certbot major version update", DeprecationWarning)
|
||||
try:
|
||||
client_ssl.do_handshake()
|
||||
client_ssl.shutdown()
|
||||
except SSL.Error as error:
|
||||
raise errors.Error(error)
|
||||
cert = client_ssl.get_peer_certificate()
|
||||
assert cert # Appease mypy. We would have crashed out by now if there was no certificate.
|
||||
return cert.to_cryptography()
|
||||
|
||||
|
||||
# Even *more* annoyingly, due to a mypy bug, we can't use Union[] types in
|
||||
# isinstance expressions without causing false mypy errors. So we have to
|
||||
# recreate the type collection as a tuple here. And no, typing.get_args doesn't
|
||||
# work due to another mypy bug.
|
||||
#
|
||||
# mypy issues:
|
||||
# * https://github.com/python/mypy/issues/17680
|
||||
# * https://github.com/python/mypy/issues/15106
|
||||
CertificateIssuerPrivateKeyTypesTpl = (
|
||||
dsa.DSAPrivateKey,
|
||||
rsa.RSAPrivateKey,
|
||||
ec.EllipticCurvePrivateKey,
|
||||
ed25519.Ed25519PrivateKey,
|
||||
ed448.Ed448PrivateKey,
|
||||
)
|
||||
|
||||
|
||||
def make_csr(
|
||||
private_key_pem: bytes,
|
||||
domains: Optional[Union[Set[str], List[str]]] = None,
|
||||
must_staple: bool = False,
|
||||
ipaddrs: Optional[List[Union[ipaddress.IPv4Address, ipaddress.IPv6Address]]] = None,
|
||||
) -> bytes:
|
||||
"""Generate a CSR containing domains or IPs as subjectAltNames.
|
||||
|
||||
Parameters are ordered this way for backwards compatibility when called using positional
|
||||
arguments.
|
||||
|
||||
:param buffer private_key_pem: Private key, in PEM PKCS#8 format.
|
||||
:param list domains: List of DNS names to include in subjectAltNames of CSR.
|
||||
:param bool must_staple: Whether to include the TLS Feature extension (aka
|
||||
OCSP Must Staple: https://tools.ietf.org/html/rfc7633).
|
||||
:param list ipaddrs: List of IPaddress(type ipaddress.IPv4Address or ipaddress.IPv6Address)
|
||||
names to include in subbjectAltNames of CSR.
|
||||
|
||||
:returns: buffer PEM-encoded Certificate Signing Request.
|
||||
|
||||
"""
|
||||
private_key = serialization.load_pem_private_key(private_key_pem, password=None)
|
||||
if not isinstance(private_key, CertificateIssuerPrivateKeyTypesTpl):
|
||||
raise ValueError(f"Invalid private key type: {type(private_key)}")
|
||||
if domains is None:
|
||||
domains = []
|
||||
if ipaddrs is None:
|
||||
ipaddrs = []
|
||||
if len(domains) + len(ipaddrs) == 0:
|
||||
raise ValueError(
|
||||
"At least one of domains or ipaddrs parameter need to be not empty"
|
||||
)
|
||||
|
||||
builder = (
|
||||
x509.CertificateSigningRequestBuilder()
|
||||
.subject_name(x509.Name([]))
|
||||
.add_extension(
|
||||
x509.SubjectAlternativeName(
|
||||
[x509.DNSName(d) for d in domains]
|
||||
+ [x509.IPAddress(i) for i in ipaddrs]
|
||||
),
|
||||
critical=False,
|
||||
)
|
||||
)
|
||||
if must_staple:
|
||||
builder = builder.add_extension(
|
||||
# "status_request" is the feature commonly known as OCSP
|
||||
# Must-Staple
|
||||
x509.TLSFeature([x509.TLSFeatureType.status_request]),
|
||||
critical=False,
|
||||
)
|
||||
|
||||
csr = builder.sign(private_key, hashes.SHA256())
|
||||
return csr.public_bytes(Encoding.PEM)
|
||||
|
||||
|
||||
def get_names_from_subject_and_extensions(
|
||||
subject: x509.Name, exts: x509.Extensions
|
||||
) -> List[str]:
|
||||
"""Gets all DNS SAN names as well as the first Common Name from subject.
|
||||
|
||||
:param subject: Name of the x509 object, which may include Common Name
|
||||
:type subject: `cryptography.x509.Name`
|
||||
:param exts: Extensions of the x509 object, which may include SANs
|
||||
:type exts: `cryptography.x509.Extensions`
|
||||
|
||||
:returns: List of DNS Subject Alternative Names and first Common Name
|
||||
:rtype: `list` of `str`
|
||||
"""
|
||||
# We know these are always `str` because `bytes` is only possible for
|
||||
# other OIDs.
|
||||
cns = [
|
||||
typing.cast(str, c.value)
|
||||
for c in subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME)
|
||||
]
|
||||
try:
|
||||
san_ext = exts.get_extension_for_class(x509.SubjectAlternativeName)
|
||||
except x509.ExtensionNotFound:
|
||||
dns_names = []
|
||||
else:
|
||||
dns_names = san_ext.value.get_values_for_type(x509.DNSName)
|
||||
|
||||
if not cns:
|
||||
return dns_names
|
||||
else:
|
||||
# We only include the first CN, if there are multiple. This matches
|
||||
# the behavior of the previously implementation using pyOpenSSL.
|
||||
return [cns[0]] + [d for d in dns_names if d != cns[0]]
|
||||
|
||||
|
||||
def _cryptography_cert_or_req_san(
|
||||
cert_or_req: Union[x509.Certificate, x509.CertificateSigningRequest],
|
||||
) -> List[str]:
|
||||
"""Get Subject Alternative Names from certificate or CSR using pyOpenSSL.
|
||||
|
||||
.. note:: Although this is `acme` internal API, it is used by
|
||||
`letsencrypt`.
|
||||
|
||||
:param cert_or_req: Certificate or CSR.
|
||||
:type cert_or_req: `x509.Certificate` or `x509.CertificateSigningRequest`.
|
||||
|
||||
:returns: A list of Subject Alternative Names that is DNS.
|
||||
:rtype: `list` of `str`
|
||||
|
||||
Deprecated
|
||||
.. deprecated: 3.2.1
|
||||
"""
|
||||
# ???: is this translation needed?
|
||||
exts = cert_or_req.extensions
|
||||
try:
|
||||
san_ext = exts.get_extension_for_class(x509.SubjectAlternativeName)
|
||||
except x509.ExtensionNotFound:
|
||||
return []
|
||||
|
||||
return san_ext.value.get_values_for_type(x509.DNSName)
|
||||
|
||||
|
||||
# Helper function that can be mocked in unit tests
|
||||
def _now() -> datetime:
|
||||
return datetime.now(tz=timezone.utc)
|
||||
|
||||
|
||||
def make_self_signed_cert(private_key: types.CertificateIssuerPrivateKeyTypes,
|
||||
domains: Optional[List[str]] = None,
|
||||
not_before: Optional[datetime] = None,
|
||||
validity: Optional[timedelta] = None, force_san: bool = True,
|
||||
extensions: Optional[List[x509.Extension]] = None,
|
||||
ips: Optional[List[Union[ipaddress.IPv4Address,
|
||||
ipaddress.IPv6Address]]] = None
|
||||
) -> x509.Certificate:
|
||||
"""Generate new self-signed certificate.
|
||||
:param buffer private_key_pem: Private key, in PEM PKCS#8 format.
|
||||
:type domains: `list` of `str`
|
||||
:param int not_before: A datetime after which the cert is valid. If no
|
||||
timezone is specified, UTC is assumed
|
||||
:type not_before: `datetime.datetime`
|
||||
:param validity: Duration for which the cert will be valid. Defaults to 1
|
||||
week
|
||||
:type validity: `datetime.timedelta`
|
||||
:param buffer private_key_pem: One of
|
||||
`cryptography.hazmat.primitives.asymmetric.types.CertificateIssuerPrivateKeyTypes`
|
||||
:param bool force_san:
|
||||
:param extensions: List of additional extensions to include in the cert.
|
||||
:type extensions: `list` of `x509.Extension[x509.ExtensionType]`
|
||||
:type ips: `list` of (`ipaddress.IPv4Address` or `ipaddress.IPv6Address`)
|
||||
If more than one domain is provided, all of the domains are put into
|
||||
``subjectAltName`` X.509 extension and first domain is set as the
|
||||
subject CN. If only one domain is provided no ``subjectAltName``
|
||||
extension is used, unless `force_san` is ``True``.
|
||||
"""
|
||||
assert domains or ips, "Must provide one or more hostnames or IPs for the cert."
|
||||
|
||||
builder = x509.CertificateBuilder()
|
||||
builder = builder.serial_number(x509.random_serial_number())
|
||||
|
||||
if extensions is not None:
|
||||
for ext in extensions:
|
||||
builder = builder.add_extension(ext.value, ext.critical)
|
||||
if domains is None:
|
||||
domains = []
|
||||
if ips is None:
|
||||
ips = []
|
||||
builder = builder.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
|
||||
|
||||
name_attrs = []
|
||||
if len(domains) > 0:
|
||||
name_attrs.append(x509.NameAttribute(
|
||||
x509.OID_COMMON_NAME,
|
||||
domains[0]
|
||||
))
|
||||
|
||||
builder = builder.subject_name(x509.Name(name_attrs))
|
||||
builder = builder.issuer_name(x509.Name(name_attrs))
|
||||
|
||||
sanlist: List[x509.GeneralName] = []
|
||||
for address in domains:
|
||||
sanlist.append(x509.DNSName(address))
|
||||
for ip in ips:
|
||||
sanlist.append(x509.IPAddress(ip))
|
||||
if force_san or len(domains) > 1 or len(ips) > 0:
|
||||
builder = builder.add_extension(
|
||||
x509.SubjectAlternativeName(sanlist),
|
||||
critical=False
|
||||
)
|
||||
|
||||
if not_before is None:
|
||||
not_before = _now()
|
||||
if validity is None:
|
||||
validity = timedelta(seconds=7 * 24 * 60 * 60)
|
||||
builder = builder.not_valid_before(not_before)
|
||||
builder = builder.not_valid_after(not_before + validity)
|
||||
|
||||
public_key = private_key.public_key()
|
||||
builder = builder.public_key(public_key)
|
||||
return builder.sign(private_key, hashes.SHA256())
|
||||
|
||||
|
||||
def dump_cryptography_chain(
|
||||
chain: List[x509.Certificate],
|
||||
encoding: Literal[Encoding.PEM, Encoding.DER] = Encoding.PEM,
|
||||
) -> bytes:
|
||||
"""Dump certificate chain into a bundle.
|
||||
|
||||
:param list chain: List of `cryptography.x509.Certificate`.
|
||||
|
||||
:returns: certificate chain bundle
|
||||
:rtype: bytes
|
||||
|
||||
Deprecated
|
||||
.. deprecated: 3.2.1
|
||||
"""
|
||||
# XXX: returns empty string when no chain is available, which
|
||||
# shuts up RenewableCert, but might not be the best solution...
|
||||
|
||||
def _dump_cert(cert: x509.Certificate) -> bytes:
|
||||
return cert.public_bytes(encoding)
|
||||
|
||||
# assumes that x509.Certificate.public_bytes includes ending
|
||||
# newline character
|
||||
return b"".join(_dump_cert(cert) for cert in chain)
|
||||
@@ -107,6 +107,16 @@ class ValidationError(Error):
|
||||
self.failed_authzrs = failed_authzrs
|
||||
super().__init__()
|
||||
|
||||
def __str__(self) -> str:
|
||||
msg = []
|
||||
for authzr in self.failed_authzrs:
|
||||
msg.append(f'Authorization for {authzr.body.identifier.value} ' \
|
||||
'failed due to one or more failed challenges:')
|
||||
for challenge in authzr.body.challenges:
|
||||
msg.append(f' Challenge {challenge.chall.typ} failed ' \
|
||||
f'with error {str(challenge.error)}')
|
||||
return '\n'.join(msg)
|
||||
|
||||
|
||||
class TimeoutError(Error): # pylint: disable=redefined-builtin
|
||||
"""Error for when polling an authorization or an order times out."""
|
||||
@@ -13,6 +13,8 @@ from typing import Tuple
|
||||
from typing import Type
|
||||
from typing import TypeVar
|
||||
|
||||
from cryptography import x509
|
||||
|
||||
import josepy as jose
|
||||
|
||||
from acme import challenges
|
||||
@@ -231,6 +233,7 @@ class Directory(jose.JSONDeSerializable):
|
||||
website: str = jose.field('website', omitempty=True)
|
||||
caa_identities: List[str] = jose.field('caaIdentities', omitempty=True)
|
||||
external_account_required: bool = jose.field('externalAccountRequired', omitempty=True)
|
||||
profiles: Dict[str, str] = jose.field('profiles', omitempty=True)
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
kwargs = {self._internal_name(k): v for k, v in kwargs.items()}
|
||||
@@ -578,18 +581,17 @@ class AuthorizationResource(ResourceWithURI):
|
||||
class CertificateRequest(jose.JSONObjectWithFields):
|
||||
"""ACME newOrder request.
|
||||
|
||||
:ivar jose.ComparableX509 csr:
|
||||
`OpenSSL.crypto.X509Req` wrapped in `.ComparableX509`
|
||||
:ivar x509.CertificateSigningRequest csr: `x509.CertificateSigningRequest`
|
||||
|
||||
"""
|
||||
csr: jose.ComparableX509 = jose.field('csr', decoder=jose.decode_csr, encoder=jose.encode_csr)
|
||||
csr: x509.CertificateSigningRequest = jose.field(
|
||||
'csr', decoder=jose.decode_csr, encoder=jose.encode_csr)
|
||||
|
||||
|
||||
class CertificateResource(ResourceWithURI):
|
||||
"""Certificate Resource.
|
||||
|
||||
:ivar josepy.util.ComparableX509 body:
|
||||
`OpenSSL.crypto.X509` wrapped in `.ComparableX509`
|
||||
:ivar x509.Certificate body: `x509.Certificate`
|
||||
:ivar str cert_chain_uri: URI found in the 'up' ``Link`` header
|
||||
:ivar tuple authzrs: `tuple` of `AuthorizationResource`.
|
||||
|
||||
@@ -601,11 +603,10 @@ class CertificateResource(ResourceWithURI):
|
||||
class Revocation(jose.JSONObjectWithFields):
|
||||
"""Revocation message.
|
||||
|
||||
:ivar jose.ComparableX509 certificate: `OpenSSL.crypto.X509` wrapped in
|
||||
`jose.ComparableX509`
|
||||
:ivar x509.Certificate certificate: `x509.Certificate`
|
||||
|
||||
"""
|
||||
certificate: jose.ComparableX509 = jose.field(
|
||||
certificate: x509.Certificate = jose.field(
|
||||
'certificate', decoder=jose.decode_cert, encoder=jose.encode_cert)
|
||||
reason: int = jose.field('reason')
|
||||
|
||||
@@ -613,6 +614,8 @@ class Revocation(jose.JSONObjectWithFields):
|
||||
class Order(ResourceBody):
|
||||
"""Order Resource Body.
|
||||
|
||||
:ivar profile: The profile to request.
|
||||
:vartype profile: str
|
||||
:ivar identifiers: List of identifiers for the certificate.
|
||||
:vartype identifiers: `list` of `.Identifier`
|
||||
:ivar acme.messages.Status status:
|
||||
@@ -624,6 +627,8 @@ class Order(ResourceBody):
|
||||
:ivar datetime.datetime expires: When the order expires.
|
||||
:ivar ~.Error error: Any error that occurred during finalization, if applicable.
|
||||
"""
|
||||
# https://datatracker.ietf.org/doc/draft-aaron-acme-profiles/
|
||||
profile: str = jose.field('profile', omitempty=True)
|
||||
identifiers: List[Identifier] = jose.field('identifiers', omitempty=True)
|
||||
status: Status = jose.field('status', decoder=Status.from_json, omitempty=True)
|
||||
authorizations: List[str] = jose.field('authorizations', omitempty=True)
|
||||
@@ -679,3 +684,19 @@ class OrderResource(ResourceWithURI):
|
||||
|
||||
class NewOrder(Order):
|
||||
"""New order."""
|
||||
|
||||
|
||||
class RenewalInfo(ResourceBody):
|
||||
"""Renewal Info Resource Body.
|
||||
:ivar acme.messages.SuggestedWindow window: The suggested renewal window.
|
||||
"""
|
||||
class SuggestedWindow(jose.JSONObjectWithFields):
|
||||
"""Suggested Renewal Window, sub-resource of Renewal Info Resource.
|
||||
:ivar datetime.datetime start: Beginning of suggested renewal window
|
||||
:ivar datetime.datetime end: End of suggested renewal window (inclusive)
|
||||
"""
|
||||
start: datetime.datetime = fields.rfc3339('start', omitempty=True)
|
||||
end: datetime.datetime = fields.rfc3339('end', omitempty=True)
|
||||
|
||||
suggested_window: SuggestedWindow = jose.field('suggestedWindow',
|
||||
decoder=SuggestedWindow.from_json)
|
||||
@@ -15,8 +15,8 @@ from typing import Optional
|
||||
from typing import Set
|
||||
from typing import Tuple
|
||||
from typing import Type
|
||||
import warnings
|
||||
|
||||
from OpenSSL import crypto
|
||||
from OpenSSL import SSL
|
||||
|
||||
from acme import challenges
|
||||
@@ -26,9 +26,15 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TLSServer(socketserver.TCPServer):
|
||||
"""Generic TLS Server."""
|
||||
"""Generic TLS Server
|
||||
|
||||
.. deprecated:: 4.1.0
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
warnings.warn("TLSServer is deprecated and will be removed in an upcoming release",
|
||||
DeprecationWarning)
|
||||
self.ipv6 = kwargs.pop("ipv6", False)
|
||||
if self.ipv6:
|
||||
self.address_family = socket.AF_INET6
|
||||
@@ -40,13 +46,15 @@ class TLSServer(socketserver.TCPServer):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _wrap_sock(self) -> None:
|
||||
self.socket = cast(socket.socket, crypto_util.SSLSocket(
|
||||
self.socket, cert_selection=self._cert_selection,
|
||||
alpn_selection=getattr(self, '_alpn_selection', None),
|
||||
method=self.method))
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings('ignore', 'SSLSocket is deprecated')
|
||||
self.socket = cast(socket.socket, crypto_util.SSLSocket(
|
||||
self.socket, cert_selection=self._cert_selection,
|
||||
alpn_selection=getattr(self, '_alpn_selection', None),
|
||||
method=self.method))
|
||||
|
||||
def _cert_selection(self, connection: SSL.Connection
|
||||
) -> Optional[Tuple[crypto.PKey, crypto.X509]]: # pragma: no cover
|
||||
) -> Optional[crypto_util._KeyAndCert]: # pragma: no cover
|
||||
"""Callback selecting certificate for connection."""
|
||||
server_name = connection.get_servername()
|
||||
if server_name:
|
||||
@@ -98,7 +106,7 @@ class BaseDualNetworkedServers:
|
||||
logger.debug(
|
||||
"Successfully bound to %s:%s using %s", new_address[0],
|
||||
new_address[1], "IPv6" if ip_version else "IPv4")
|
||||
except socket.error as e:
|
||||
except OSError as e:
|
||||
last_socket_err = e
|
||||
if self.servers:
|
||||
# Already bound using IPv6.
|
||||
@@ -121,7 +129,7 @@ class BaseDualNetworkedServers:
|
||||
if last_socket_err:
|
||||
raise last_socket_err
|
||||
else: # pragma: no cover
|
||||
raise socket.error("Could not bind to IPv4 or IPv6.")
|
||||
raise OSError("Could not bind to IPv4 or IPv6.")
|
||||
|
||||
def serve_forever(self) -> None:
|
||||
"""Wraps socketserver.TCPServer.serve_forever"""
|
||||
@@ -147,24 +155,31 @@ class BaseDualNetworkedServers:
|
||||
|
||||
|
||||
class TLSALPN01Server(TLSServer, ACMEServerMixin):
|
||||
"""TLSALPN01 Server."""
|
||||
"""TLSALPN01 Server.
|
||||
|
||||
.. deprecated:: 4.1.0
|
||||
|
||||
"""
|
||||
|
||||
ACME_TLS_1_PROTOCOL = b"acme-tls/1"
|
||||
|
||||
def __init__(self, server_address: Tuple[str, int],
|
||||
certs: List[Tuple[crypto.PKey, crypto.X509]],
|
||||
challenge_certs: Mapping[bytes, Tuple[crypto.PKey, crypto.X509]],
|
||||
certs: List[crypto_util._KeyAndCert],
|
||||
challenge_certs: Mapping[bytes, crypto_util._KeyAndCert],
|
||||
ipv6: bool = False) -> None:
|
||||
warnings.warn("TLSALPN01Server is deprecated and will be removed in an "
|
||||
"upcoming certbot major version update", DeprecationWarning)
|
||||
# We don't need to implement a request handler here because the work
|
||||
# (including logging) is being done by wrapped socket set up in the
|
||||
# parent TLSServer class.
|
||||
TLSServer.__init__(
|
||||
self, server_address, socketserver.BaseRequestHandler, certs=certs,
|
||||
ipv6=ipv6)
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings("ignore", "TLSServer is deprecated")
|
||||
TLSServer.__init__(
|
||||
self, server_address, socketserver.BaseRequestHandler, certs=certs,
|
||||
ipv6=ipv6)
|
||||
self.challenge_certs = challenge_certs
|
||||
|
||||
def _cert_selection(self, connection: SSL.Connection) -> Optional[Tuple[crypto.PKey,
|
||||
crypto.X509]]:
|
||||
def _cert_selection(self, connection: SSL.Connection) -> Optional[crypto_util._KeyAndCert]:
|
||||
# TODO: We would like to serve challenge cert only if asked for it via
|
||||
# ALPN. To do this, we need to retrieve the list of protos from client
|
||||
# hello, but this is currently impossible with openssl [0], and ALPN
|
||||
@@ -1,8 +1,8 @@
|
||||
include LICENSE.txt
|
||||
include README.rst
|
||||
recursive-include certbot_apache/_internal/augeas_lens *.aug
|
||||
recursive-include certbot_apache/_internal/tls_configs *.conf
|
||||
recursive-include certbot_apache/_internal/tests/testdata *
|
||||
include certbot_apache/py.typed
|
||||
recursive-include src/certbot_apache/_internal/augeas_lens *.aug
|
||||
recursive-include src/certbot_apache/_internal/tls_configs *.conf
|
||||
recursive-include src/certbot_apache/_internal/tests/testdata *
|
||||
include src/certbot_apache/py.typed
|
||||
global-exclude __pycache__
|
||||
global-exclude *.py[cod]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from setuptools import find_packages
|
||||
from setuptools import setup
|
||||
|
||||
version = '2.12.0.dev0'
|
||||
version = '4.1.1'
|
||||
|
||||
install_requires = [
|
||||
# We specify the minimum acme and certbot version as the current plugin
|
||||
@@ -9,9 +9,7 @@ install_requires = [
|
||||
# https://github.com/certbot/certbot/issues/8761 for more info.
|
||||
f'acme>={version}',
|
||||
f'certbot>={version}',
|
||||
'importlib_resources>=1.3.1; python_version < "3.9"',
|
||||
'python-augeas',
|
||||
'setuptools>=41.6.0',
|
||||
]
|
||||
|
||||
dev_extras = [
|
||||
@@ -30,7 +28,7 @@ setup(
|
||||
author="Certbot Project",
|
||||
author_email='certbot-dev@eff.org',
|
||||
license='Apache License 2.0',
|
||||
python_requires='>=3.8',
|
||||
python_requires='>=3.9.2',
|
||||
classifiers=[
|
||||
'Development Status :: 5 - Production/Stable',
|
||||
'Environment :: Plugins',
|
||||
@@ -39,11 +37,11 @@ setup(
|
||||
'Operating System :: POSIX :: Linux',
|
||||
'Programming Language :: Python',
|
||||
'Programming Language :: Python :: 3',
|
||||
'Programming Language :: Python :: 3.8',
|
||||
'Programming Language :: Python :: 3.9',
|
||||
'Programming Language :: Python :: 3.10',
|
||||
'Programming Language :: Python :: 3.11',
|
||||
'Programming Language :: Python :: 3.12',
|
||||
'Programming Language :: Python :: 3.13',
|
||||
'Topic :: Internet :: WWW/HTTP',
|
||||
'Topic :: Security',
|
||||
'Topic :: System :: Installation/Setup',
|
||||
@@ -52,7 +50,8 @@ setup(
|
||||
'Topic :: Utilities',
|
||||
],
|
||||
|
||||
packages=find_packages(),
|
||||
packages=find_packages(where='src'),
|
||||
package_dir={'': 'src'},
|
||||
include_package_data=True,
|
||||
install_requires=install_requires,
|
||||
extras_require={
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import atexit
|
||||
import binascii
|
||||
import fnmatch
|
||||
import importlib.resources
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import ExitStack
|
||||
from typing import Dict
|
||||
from typing import Iterable
|
||||
@@ -17,12 +17,6 @@ from certbot import errors
|
||||
from certbot import util
|
||||
from certbot.compat import os
|
||||
|
||||
if sys.version_info >= (3, 9): # pragma: no cover
|
||||
import importlib.resources as importlib_resources
|
||||
else: # pragma: no cover
|
||||
import importlib_resources
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -257,6 +251,6 @@ def find_ssl_apache_conf(prefix: str) -> str:
|
||||
"""
|
||||
file_manager = ExitStack()
|
||||
atexit.register(file_manager.close)
|
||||
ref = (importlib_resources.files("certbot_apache").joinpath("_internal")
|
||||
ref = (importlib.resources.files("certbot_apache").joinpath("_internal")
|
||||
.joinpath("tls_configs").joinpath("{0}-options-ssl-apache.conf".format(prefix)))
|
||||
return str(file_manager.enter_context(importlib_resources.as_file(ref)))
|
||||
return str(file_manager.enter_context(importlib.resources.as_file(ref)))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user