Compare commits
1 Commits
test-upgra
...
v0.13.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
074774810d |
@@ -1,119 +0,0 @@
|
||||
# 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.
|
||||
|
||||
Several templates are defined in `.azure-pipelines/templates`. These YAML files aggregate common jobs configuration that can be reused in several pipelines.
|
||||
|
||||
Unlike Travis, where CodeCov is working without any action required, CodeCov supports Azure Pipelines
|
||||
using the coverage-bash utility (not python-coverage for now) only if you provide the Codecov repo token
|
||||
using the `CODECOV_TOKEN` environment variable. So `CODECOV_TOKEN` needs to be set as a secured
|
||||
environment variable to allow the main pipeline to publish coverage reports to CodeCov.
|
||||
|
||||
This INSTALL.md file explains how to configure Azure Pipelines with Certbot in order to execute the CI/CD logic defined in `.azure-pipelines` folder with it.
|
||||
During this installation step, warnings describing user access and legal comitments will be displayed like this:
|
||||
```
|
||||
!!! ACCESS REQUIRED !!!
|
||||
```
|
||||
|
||||
This document suppose that the Azure DevOps organization is named _certbot_, and the Azure DevOps project is also _certbot_.
|
||||
|
||||
## Useful links
|
||||
|
||||
* https://docs.microsoft.com/en-us/azure/devops/pipelines/yaml-schema?view=azure-devops&tabs=schema
|
||||
* https://www.azuredevopslabs.com/labs/azuredevops/github-integration/
|
||||
* https://docs.microsoft.com/en-us/azure/devops/pipelines/ecosystems/python?view=azure-devops
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Having a GitHub account
|
||||
|
||||
Use your GitHub user for a normal GitHub account, or a user that has administrative rights to the GitHub organization if relevant.
|
||||
|
||||
### Having an Azure DevOps account
|
||||
- Go to https://dev.azure.com/, click "Start free with GitHub"
|
||||
- Login to GitHub
|
||||
|
||||
```
|
||||
!!! ACCESS REQUIRED !!!
|
||||
Personal user data (email + profile info, in read-only)
|
||||
```
|
||||
|
||||
- Microsoft will create a Live account using the email referenced for the GitHub account. This account is also linked to GitHub account (meaning you can log it using GitHub authentication)
|
||||
- Proceed with account registration (birth date, country), add details about name and email contact
|
||||
|
||||
```
|
||||
!!! ACCESS REQUIRED !!!
|
||||
Microsoft proposes to send commercial links to this mail
|
||||
Azure DevOps terms of service need to be accepted
|
||||
```
|
||||
|
||||
_Logged to Azure DevOps, account is ready._
|
||||
|
||||
### Installing Azure Pipelines to GitHub
|
||||
|
||||
- On GitHub, go to Marketplace
|
||||
- Select Azure Pipeline, and "Set up a plan"
|
||||
- Select Free, then "Install it for free"
|
||||
- Click "Complete order and begin installation"
|
||||
|
||||
```
|
||||
!!! ACCESS !!!
|
||||
Azure Pipeline needs RW on code, RO on metadata, RW on checks, commit statuses, deployments, issues, pull requests.
|
||||
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.
|
||||
Access can be defined for all or only selected repositories, which is nice.
|
||||
```
|
||||
|
||||
- Redirected to Azure DevOps, select the account created in _Having an Azure DevOps account_ section.
|
||||
- Select the organization, and click "Create a new project" (let's name it the same than the targeted github repo)
|
||||
- The Visibility is public, to profit from 10 parallel jobs
|
||||
|
||||
```
|
||||
!!! ACCESS !!!
|
||||
Azure Pipelines needs access to the GitHub account (in term of being able to check it is valid), and the Resources shared between the GitHub account and Azure Pipelines.
|
||||
```
|
||||
|
||||
_Done. We can move to pipelines configuration._
|
||||
|
||||
## Import an existing pipelines from `.azure-pipelines` folder
|
||||
|
||||
- On Azure DevOps, go to your organization (eg. _certbot_) then your project (eg. _certbot_)
|
||||
- Click "Pipelines" tab
|
||||
- Click "New pipeline"
|
||||
- Where is your code?: select "__Use the classic editor__"
|
||||
|
||||
__Warning: Do not choose the GitHub option in Where is your code? section. Indeed, this option will trigger an OAuth
|
||||
grant permissions from Azure Pipelines to GitHub in order to setup a GitHub OAuth Application. The permissions asked
|
||||
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.
|
||||
- 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.
|
||||
|
||||
_Done. Pipeline is operational. Repeat to add more pipelines from existing YAML files in `.azure-pipelines`._
|
||||
|
||||
## Add a secret variable to a pipeline (like `CODECOV_TOKEN`)
|
||||
|
||||
__NB: Following steps suppose that you already setup the YAML pipeline file to
|
||||
consume the secret variable that these steps will create as an environment variable.
|
||||
For a variable named `CODECOV_TOKEN` consuming the variable `codecov_token`,
|
||||
in the YAML file this setup would take the form of the following:
|
||||
```
|
||||
steps:
|
||||
- script: ./do_something_that_consumes_CODECOV_TOKEN # Eg. `codecov -F windows`
|
||||
env:
|
||||
CODECOV_TOKEN: $(codecov_token)
|
||||
```
|
||||
|
||||
To set up a variable that is shared between pipelines, follow the instructions
|
||||
at
|
||||
https://docs.microsoft.com/en-us/azure/devops/pipelines/library/variable-groups.
|
||||
When adding variables to a group, don't forget to tick "Keep this value secret"
|
||||
if it shouldn't be shared publcily.
|
||||
@@ -1,14 +0,0 @@
|
||||
# Advanced pipeline for running our full test suite on demand.
|
||||
trigger:
|
||||
# When changing these triggers, please ensure the documentation under
|
||||
# "Running tests in CI" is still correct.
|
||||
- test-*
|
||||
pr: none
|
||||
|
||||
variables:
|
||||
# We don't publish our Docker images in this pipeline, but when building them
|
||||
# for testing, let's use the nightly tag.
|
||||
dockerTag: nightly
|
||||
|
||||
stages:
|
||||
- template: templates/stages/test-and-package-stage.yml
|
||||
@@ -1,8 +0,0 @@
|
||||
trigger: none
|
||||
pr:
|
||||
- master
|
||||
- '*.x'
|
||||
|
||||
jobs:
|
||||
- template: templates/jobs/standard-tests-jobs.yml
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Nightly pipeline running each day for master.
|
||||
trigger: none
|
||||
pr: none
|
||||
schedules:
|
||||
- cron: "30 4 * * *"
|
||||
displayName: Nightly build
|
||||
branches:
|
||||
include:
|
||||
- master
|
||||
always: true
|
||||
|
||||
variables:
|
||||
dockerTag: nightly
|
||||
|
||||
stages:
|
||||
- template: templates/stages/test-and-package-stage.yml
|
||||
- template: templates/stages/deploy-stage.yml
|
||||
- template: templates/stages/notify-failure-stage.yml
|
||||
@@ -1,18 +0,0 @@
|
||||
# Release pipeline to run our full test suite, build artifacts, and deploy them
|
||||
# for GitHub release tags.
|
||||
trigger:
|
||||
tags:
|
||||
include:
|
||||
- v*
|
||||
pr: none
|
||||
|
||||
variables:
|
||||
dockerTag: ${{variables['Build.SourceBranchName']}}
|
||||
|
||||
stages:
|
||||
- template: templates/stages/test-and-package-stage.yml
|
||||
- template: templates/stages/changelog-stage.yml
|
||||
- template: templates/stages/deploy-stage.yml
|
||||
parameters:
|
||||
snapReleaseChannel: beta
|
||||
- template: templates/stages/notify-failure-stage.yml
|
||||
@@ -1,100 +0,0 @@
|
||||
jobs:
|
||||
- job: extended_test
|
||||
variables:
|
||||
- name: IMAGE_NAME
|
||||
value: ubuntu-18.04
|
||||
- name: PYTHON_VERSION
|
||||
value: 3.9
|
||||
- group: certbot-common
|
||||
strategy:
|
||||
matrix:
|
||||
linux-py36:
|
||||
PYTHON_VERSION: 3.6
|
||||
TOXENV: py36
|
||||
linux-py37:
|
||||
PYTHON_VERSION: 3.7
|
||||
TOXENV: py37
|
||||
linux-py38:
|
||||
PYTHON_VERSION: 3.8
|
||||
TOXENV: py38
|
||||
linux-py37-nopin:
|
||||
PYTHON_VERSION: 3.7
|
||||
TOXENV: py37
|
||||
CERTBOT_NO_PIN: 1
|
||||
linux-external-mock:
|
||||
TOXENV: external-mock
|
||||
linux-boulder-v1-integration-certbot-oldest:
|
||||
PYTHON_VERSION: 3.6
|
||||
TOXENV: integration-certbot-oldest
|
||||
ACME_SERVER: boulder-v1
|
||||
linux-boulder-v2-integration-certbot-oldest:
|
||||
PYTHON_VERSION: 3.6
|
||||
TOXENV: integration-certbot-oldest
|
||||
ACME_SERVER: boulder-v2
|
||||
linux-boulder-v1-integration-nginx-oldest:
|
||||
PYTHON_VERSION: 3.6
|
||||
TOXENV: integration-nginx-oldest
|
||||
ACME_SERVER: boulder-v1
|
||||
linux-boulder-v2-integration-nginx-oldest:
|
||||
PYTHON_VERSION: 3.6
|
||||
TOXENV: integration-nginx-oldest
|
||||
ACME_SERVER: boulder-v2
|
||||
linux-boulder-v1-py36-integration:
|
||||
PYTHON_VERSION: 3.6
|
||||
TOXENV: integration
|
||||
ACME_SERVER: boulder-v1
|
||||
linux-boulder-v2-py36-integration:
|
||||
PYTHON_VERSION: 3.6
|
||||
TOXENV: integration
|
||||
ACME_SERVER: boulder-v2
|
||||
linux-boulder-v1-py37-integration:
|
||||
PYTHON_VERSION: 3.7
|
||||
TOXENV: integration
|
||||
ACME_SERVER: boulder-v1
|
||||
linux-boulder-v2-py37-integration:
|
||||
PYTHON_VERSION: 3.7
|
||||
TOXENV: integration
|
||||
ACME_SERVER: boulder-v2
|
||||
linux-boulder-v1-py38-integration:
|
||||
PYTHON_VERSION: 3.8
|
||||
TOXENV: integration
|
||||
ACME_SERVER: boulder-v1
|
||||
linux-boulder-v2-py38-integration:
|
||||
PYTHON_VERSION: 3.8
|
||||
TOXENV: integration
|
||||
ACME_SERVER: boulder-v2
|
||||
linux-boulder-v1-py39-integration:
|
||||
PYTHON_VERSION: 3.9
|
||||
TOXENV: integration
|
||||
ACME_SERVER: boulder-v1
|
||||
linux-boulder-v2-py39-integration:
|
||||
PYTHON_VERSION: 3.9
|
||||
TOXENV: integration
|
||||
ACME_SERVER: boulder-v2
|
||||
nginx-compat:
|
||||
TOXENV: nginx_compat
|
||||
linux-integration-rfc2136:
|
||||
IMAGE_NAME: ubuntu-18.04
|
||||
PYTHON_VERSION: 3.8
|
||||
TOXENV: integration-dns-rfc2136
|
||||
docker-dev:
|
||||
TOXENV: docker_dev
|
||||
macos-farmtest-apache2:
|
||||
# We run one of these test farm tests on macOS to help ensure the
|
||||
# tests continue to work on the platform.
|
||||
IMAGE_NAME: macOS-10.15
|
||||
PYTHON_VERSION: 3.8
|
||||
TOXENV: test-farm-apache2
|
||||
farmtest-leauto-upgrades:
|
||||
PYTHON_VERSION: 3.7
|
||||
TOXENV: test-farm-leauto-upgrades
|
||||
farmtest-certonly-standalone:
|
||||
PYTHON_VERSION: 3.7
|
||||
TOXENV: test-farm-certonly-standalone
|
||||
farmtest-sdists:
|
||||
PYTHON_VERSION: 3.7
|
||||
TOXENV: test-farm-sdists
|
||||
pool:
|
||||
vmImage: $(IMAGE_NAME)
|
||||
steps:
|
||||
- template: ../steps/tox-steps.yml
|
||||
@@ -1,70 +0,0 @@
|
||||
jobs:
|
||||
- job: installer_build
|
||||
pool:
|
||||
vmImage: vs2017-win2016
|
||||
steps:
|
||||
- task: UsePythonVersion@0
|
||||
inputs:
|
||||
versionSpec: 3.8
|
||||
architecture: x86
|
||||
addToPath: true
|
||||
- script: |
|
||||
python -m venv venv
|
||||
venv\Scripts\python tools\pipstrap.py
|
||||
venv\Scripts\python tools\pip_install.py -e windows-installer
|
||||
displayName: Prepare Windows installer build environment
|
||||
- script: |
|
||||
venv\Scripts\construct-windows-installer
|
||||
displayName: Build Certbot installer
|
||||
- task: CopyFiles@2
|
||||
inputs:
|
||||
sourceFolder: $(System.DefaultWorkingDirectory)/windows-installer/build/nsis
|
||||
contents: '*.exe'
|
||||
targetFolder: $(Build.ArtifactStagingDirectory)
|
||||
- task: PublishPipelineArtifact@1
|
||||
inputs:
|
||||
path: $(Build.ArtifactStagingDirectory)
|
||||
# If we change the artifact's name, it should also be changed in tools/create_github_release.py
|
||||
artifact: windows-installer
|
||||
displayName: Publish Windows installer
|
||||
- job: installer_run
|
||||
dependsOn: installer_build
|
||||
strategy:
|
||||
matrix:
|
||||
win2019:
|
||||
imageName: windows-2019
|
||||
win2016:
|
||||
imageName: vs2017-win2016
|
||||
pool:
|
||||
vmImage: $(imageName)
|
||||
steps:
|
||||
- powershell: |
|
||||
if ($PSVersionTable.PSVersion.Major -ne 5) {
|
||||
throw "Powershell version is not 5.x"
|
||||
}
|
||||
condition: eq(variables['imageName'], 'vs2017-win2016')
|
||||
displayName: Check Powershell 5.x is used in vs2017-win2016
|
||||
- task: UsePythonVersion@0
|
||||
inputs:
|
||||
versionSpec: 3.8
|
||||
addToPath: true
|
||||
- task: DownloadPipelineArtifact@2
|
||||
inputs:
|
||||
artifact: windows-installer
|
||||
path: $(Build.SourcesDirectory)/bin
|
||||
displayName: Retrieve Windows installer
|
||||
- script: |
|
||||
python -m venv venv
|
||||
venv\Scripts\python tools\pipstrap.py
|
||||
venv\Scripts\python tools\pip_install.py -e certbot-ci
|
||||
env:
|
||||
PIP_NO_BUILD_ISOLATION: no
|
||||
displayName: Prepare Certbot-CI
|
||||
- script: |
|
||||
set PATH=%ProgramFiles(x86)%\Certbot\bin;%PATH%
|
||||
venv\Scripts\python -m pytest certbot-ci\windows_installer_integration_tests --allow-persistent-changes --installer-path $(Build.SourcesDirectory)\bin\certbot-beta-installer-win32.exe
|
||||
displayName: Run windows installer integration tests
|
||||
- script: |
|
||||
set PATH=%ProgramFiles(x86)%\Certbot\bin;%PATH%
|
||||
venv\Scripts\python -m pytest certbot-ci\certbot_integration_tests\certbot_tests -n 4
|
||||
displayName: Run certbot integration tests
|
||||
@@ -1,78 +0,0 @@
|
||||
jobs:
|
||||
- job: test
|
||||
variables:
|
||||
PYTHON_VERSION: 3.9
|
||||
strategy:
|
||||
matrix:
|
||||
macos-py36:
|
||||
IMAGE_NAME: macOS-10.15
|
||||
PYTHON_VERSION: 3.6
|
||||
TOXENV: py36
|
||||
macos-py39:
|
||||
IMAGE_NAME: macOS-10.15
|
||||
PYTHON_VERSION: 3.9
|
||||
TOXENV: py39
|
||||
windows-py36:
|
||||
IMAGE_NAME: vs2017-win2016
|
||||
PYTHON_VERSION: 3.6
|
||||
TOXENV: py36
|
||||
windows-py38-cover:
|
||||
IMAGE_NAME: vs2017-win2016
|
||||
PYTHON_VERSION: 3.8
|
||||
TOXENV: py38-cover
|
||||
windows-integration-certbot:
|
||||
IMAGE_NAME: vs2017-win2016
|
||||
PYTHON_VERSION: 3.8
|
||||
TOXENV: integration-certbot
|
||||
linux-oldest-tests-1:
|
||||
IMAGE_NAME: ubuntu-18.04
|
||||
PYTHON_VERSION: 3.6
|
||||
TOXENV: '{acme,apache,apache-v2,certbot}-oldest'
|
||||
linux-oldest-tests-2:
|
||||
IMAGE_NAME: ubuntu-18.04
|
||||
PYTHON_VERSION: 3.6
|
||||
TOXENV: '{dns,nginx}-oldest'
|
||||
linux-py36:
|
||||
IMAGE_NAME: ubuntu-18.04
|
||||
PYTHON_VERSION: 3.6
|
||||
TOXENV: py36
|
||||
linux-py39-cover:
|
||||
IMAGE_NAME: ubuntu-18.04
|
||||
PYTHON_VERSION: 3.9
|
||||
TOXENV: py39-cover
|
||||
linux-py39-lint:
|
||||
IMAGE_NAME: ubuntu-18.04
|
||||
PYTHON_VERSION: 3.9
|
||||
TOXENV: lint
|
||||
linux-py39-mypy:
|
||||
IMAGE_NAME: ubuntu-18.04
|
||||
PYTHON_VERSION: 3.9
|
||||
TOXENV: mypy
|
||||
linux-integration:
|
||||
IMAGE_NAME: ubuntu-18.04
|
||||
PYTHON_VERSION: 3.8
|
||||
TOXENV: integration
|
||||
ACME_SERVER: pebble
|
||||
apache-compat:
|
||||
IMAGE_NAME: ubuntu-18.04
|
||||
TOXENV: apache_compat
|
||||
le-modification:
|
||||
IMAGE_NAME: ubuntu-18.04
|
||||
TOXENV: modification
|
||||
apacheconftest:
|
||||
IMAGE_NAME: ubuntu-18.04
|
||||
PYTHON_VERSION: 3.6
|
||||
TOXENV: apacheconftest-with-pebble
|
||||
nginxroundtrip:
|
||||
IMAGE_NAME: ubuntu-18.04
|
||||
PYTHON_VERSION: 3.6
|
||||
TOXENV: nginxroundtrip
|
||||
pool:
|
||||
vmImage: $(IMAGE_NAME)
|
||||
steps:
|
||||
- template: ../steps/tox-steps.yml
|
||||
- job: test_sphinx_builds
|
||||
pool:
|
||||
vmImage: ubuntu-20.04
|
||||
steps:
|
||||
- template: ../steps/sphinx-steps.yml
|
||||
@@ -1,19 +0,0 @@
|
||||
stages:
|
||||
- stage: Changelog
|
||||
jobs:
|
||||
- job: prepare
|
||||
pool:
|
||||
vmImage: vs2017-win2016
|
||||
steps:
|
||||
# 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 ~-)"
|
||||
"${BUILD_REPOSITORY_LOCALPATH}\tools\extract_changelog.py" "${CERTBOT_VERSION}" >> "${BUILD_ARTIFACTSTAGINGDIRECTORY}/release_notes.md"
|
||||
displayName: Prepare changelog
|
||||
- task: PublishPipelineArtifact@1
|
||||
inputs:
|
||||
path: $(Build.ArtifactStagingDirectory)
|
||||
# If we change the artifact's name, it should also be changed in tools/create_github_release.py
|
||||
artifact: changelog
|
||||
displayName: Publish changelog
|
||||
@@ -1,106 +0,0 @@
|
||||
parameters:
|
||||
- name: snapReleaseChannel
|
||||
type: string
|
||||
default: edge
|
||||
values:
|
||||
- edge
|
||||
- beta
|
||||
|
||||
stages:
|
||||
- stage: Deploy
|
||||
jobs:
|
||||
# This job relies on credentials used to publish the Certbot snaps. This
|
||||
# credential file was created by running:
|
||||
#
|
||||
# snapcraft logout
|
||||
# snapcraft login (provide the shared snapcraft credentials when prompted)
|
||||
# snapcraft export-login --channels=beta,edge snapcraft.cfg
|
||||
#
|
||||
# Then the file was added as a secure file in Azure pipelines
|
||||
# with the name snapcraft.cfg by following the instructions at
|
||||
# https://docs.microsoft.com/en-us/azure/devops/pipelines/library/secure-files?view=azure-devops
|
||||
# including authorizing the file in all pipelines as described at
|
||||
# https://docs.microsoft.com/en-us/azure/devops/pipelines/library/secure-files?view=azure-devops#how-do-i-authorize-a-secure-file-for-use-in-all-pipelines.
|
||||
#
|
||||
# This file has a maximum lifetime of one year and the current
|
||||
# file will expire on 2021-07-28 which is also tracked by
|
||||
# https://github.com/certbot/certbot/issues/7931. The file will
|
||||
# need to be updated before then to prevent automated deploys
|
||||
# from breaking.
|
||||
#
|
||||
# Revoking these credentials can be done by changing the password of the
|
||||
# account used to generate the credentials. See
|
||||
# https://forum.snapcraft.io/t/revoking-exported-credentials/19031 for
|
||||
# more info.
|
||||
- job: publish_snap
|
||||
pool:
|
||||
vmImage: ubuntu-18.04
|
||||
variables:
|
||||
- group: certbot-common
|
||||
strategy:
|
||||
matrix:
|
||||
amd64:
|
||||
SNAP_ARCH: amd64
|
||||
arm32v6:
|
||||
SNAP_ARCH: armhf
|
||||
arm64v8:
|
||||
SNAP_ARCH: arm64
|
||||
steps:
|
||||
- bash: |
|
||||
set -e
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends snapd
|
||||
sudo snap install --classic snapcraft
|
||||
displayName: Install dependencies
|
||||
- task: DownloadPipelineArtifact@2
|
||||
inputs:
|
||||
artifact: snaps_$(SNAP_ARCH)
|
||||
path: $(Build.SourcesDirectory)/snap
|
||||
displayName: Retrieve Certbot snaps
|
||||
- task: DownloadSecureFile@1
|
||||
name: snapcraftCfg
|
||||
inputs:
|
||||
secureFile: snapcraft.cfg
|
||||
- bash: |
|
||||
set -e
|
||||
snapcraft login --with $(snapcraftCfg.secureFilePath)
|
||||
for SNAP_FILE in snap/*.snap; do
|
||||
tools/retry.sh eval snapcraft upload --release=${{ parameters.snapReleaseChannel }} "${SNAP_FILE}"
|
||||
done
|
||||
displayName: Publish to Snap store
|
||||
- job: publish_docker
|
||||
pool:
|
||||
vmImage: ubuntu-18.04
|
||||
strategy:
|
||||
matrix:
|
||||
amd64:
|
||||
DOCKER_ARCH: amd64
|
||||
arm32v6:
|
||||
DOCKER_ARCH: arm32v6
|
||||
arm64v8:
|
||||
DOCKER_ARCH: arm64v8
|
||||
steps:
|
||||
- task: DownloadPipelineArtifact@2
|
||||
inputs:
|
||||
artifact: docker_$(DOCKER_ARCH)
|
||||
path: $(Build.SourcesDirectory)
|
||||
displayName: Retrieve Docker images
|
||||
- bash: set -e && docker load --input $(Build.SourcesDirectory)/images.tar
|
||||
displayName: Load Docker images
|
||||
- task: Docker@2
|
||||
inputs:
|
||||
command: login
|
||||
# The credentials used here are for the shared certbotbot account
|
||||
# on Docker Hub. The credentials are stored in a service account
|
||||
# which was created by following the instructions at
|
||||
# https://docs.microsoft.com/en-us/azure/devops/pipelines/library/service-endpoints?view=azure-devops&tabs=yaml#sep-docreg.
|
||||
# The name given to this service account must match the value
|
||||
# given to containerRegistry below. "Grant access to all
|
||||
# pipelines" should also be checked. To revoke these
|
||||
# credentials, we can change the password on the certbotbot
|
||||
# Docker Hub account or remove the account from the
|
||||
# Certbot organization on Docker Hub.
|
||||
containerRegistry: docker-hub
|
||||
displayName: Login to Docker Hub
|
||||
- bash: set -e && tools/docker/deploy.sh $(dockerTag) $DOCKER_ARCH
|
||||
displayName: Deploy the Docker images
|
||||
@@ -1,19 +0,0 @@
|
||||
stages:
|
||||
- stage: On_Failure
|
||||
jobs:
|
||||
- job: notify_mattermost
|
||||
variables:
|
||||
- group: certbot-common
|
||||
pool:
|
||||
vmImage: ubuntu-20.04
|
||||
steps:
|
||||
- bash: |
|
||||
set -e
|
||||
MESSAGE="\
|
||||
---\n\
|
||||
##### Azure Pipeline
|
||||
*Repo* $(Build.Repository.ID) - *Pipeline* $(Build.DefinitionName) #$(Build.BuildNumber) - *Branch/PR* $(Build.SourceBranchName)\n\
|
||||
:warning: __Pipeline has failed__: [Link to the build](https://dev.azure.com/$(Build.Repository.ID)/_build/results?buildId=$(Build.BuildId)&view=results)\n\n\
|
||||
---"
|
||||
curl -i -X POST --data-urlencode "payload={\"text\":\"${MESSAGE}\"}" "$(MATTERMOST_URL)"
|
||||
condition: failed()
|
||||
@@ -1,4 +0,0 @@
|
||||
stages:
|
||||
- stage: TestAndPackage
|
||||
jobs:
|
||||
- template: ../jobs/packaging-jobs.yml
|
||||
@@ -1,23 +0,0 @@
|
||||
steps:
|
||||
- bash: |
|
||||
FINAL_STATUS=0
|
||||
declare -a FAILED_BUILDS
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python tools/pipstrap.py
|
||||
for doc_path in */docs
|
||||
do
|
||||
echo ""
|
||||
echo "##[group]Building $doc_path"
|
||||
pip install -q -e $doc_path/..[docs]
|
||||
if ! sphinx-build -W --keep-going -b html $doc_path $doc_path/_build/html; then
|
||||
FINAL_STATUS=1
|
||||
FAILED_BUILDS[${#FAILED_BUILDS[@]}]="${doc_path%/docs}"
|
||||
fi
|
||||
echo "##[endgroup]"
|
||||
done
|
||||
if [[ $FINAL_STATUS -ne 0 ]]; then
|
||||
echo "##[error]The following builds failed: ${FAILED_BUILDS[*]}"
|
||||
exit 1
|
||||
fi
|
||||
displayName: Build Sphinx Documentation
|
||||
@@ -1,53 +0,0 @@
|
||||
steps:
|
||||
- bash: |
|
||||
set -e
|
||||
brew install augeas
|
||||
condition: startswith(variables['IMAGE_NAME'], 'macOS')
|
||||
displayName: Install MacOS dependencies
|
||||
- bash: |
|
||||
set -e
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
python-dev \
|
||||
gcc \
|
||||
libaugeas0 \
|
||||
libssl-dev \
|
||||
libffi-dev \
|
||||
ca-certificates \
|
||||
nginx-light \
|
||||
openssl
|
||||
sudo systemctl stop nginx
|
||||
condition: startswith(variables['IMAGE_NAME'], 'ubuntu')
|
||||
displayName: Install Linux dependencies
|
||||
- task: UsePythonVersion@0
|
||||
inputs:
|
||||
versionSpec: $(PYTHON_VERSION)
|
||||
addToPath: true
|
||||
# tools/pip_install.py is used to pin packages to a known working version
|
||||
# except in tests where the environment variable CERTBOT_NO_PIN is set.
|
||||
# virtualenv is listed here explicitly to make sure it is upgraded when
|
||||
# CERTBOT_NO_PIN is set to work around failures we've seen when using an older
|
||||
# version of virtualenv. The option "-I" is set so when CERTBOT_NO_PIN is also
|
||||
# set, pip updates dependencies it thinks are already satisfied to avoid some
|
||||
# problems with its lack of real dependency resolution.
|
||||
- bash: |
|
||||
set -e
|
||||
python tools/pipstrap.py
|
||||
python tools/pip_install.py -I tox virtualenv
|
||||
displayName: Install runtime dependencies
|
||||
- task: DownloadSecureFile@1
|
||||
name: testFarmPem
|
||||
inputs:
|
||||
secureFile: azure-test-farm.pem
|
||||
condition: contains(variables['TOXENV'], 'test-farm')
|
||||
- bash: |
|
||||
set -e
|
||||
export TARGET_BRANCH="`echo "${BUILD_SOURCEBRANCH}" | sed -E 's!refs/(heads|tags)/!!g'`"
|
||||
[ -z "${SYSTEM_PULLREQUEST_TARGETBRANCH}" ] || export TARGET_BRANCH="${SYSTEM_PULLREQUEST_TARGETBRANCH}"
|
||||
env
|
||||
python -m tox
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID)
|
||||
AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY)
|
||||
AWS_EC2_PEM_FILE: $(testFarmPem.secureFilePath)
|
||||
displayName: Run tox
|
||||
@@ -1,5 +1,3 @@
|
||||
[run]
|
||||
omit = */setup.py
|
||||
|
||||
[report]
|
||||
omit = */setup.py
|
||||
# show lines missing coverage in output
|
||||
show_missing = True
|
||||
|
||||
@@ -8,4 +8,5 @@
|
||||
.git
|
||||
.tox
|
||||
venv
|
||||
venv3
|
||||
docs
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# https://editorconfig.org/
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
|
||||
[*.py]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
charset = utf-8
|
||||
max_line_length = 100
|
||||
|
||||
[*.yaml]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
12
.envrc
12
.envrc
@@ -1,12 +0,0 @@
|
||||
# This file is just a nicety for developers who use direnv. When you cd under
|
||||
# the Certbot repo, Certbot's virtual environment will be automatically
|
||||
# activated and then deactivated when you cd elsewhere. Developers have to have
|
||||
# direnv set up and run `direnv allow` to allow this file to execute on their
|
||||
# system. You can find more information at https://direnv.net/.
|
||||
. venv/bin/activate
|
||||
# direnv doesn't support modifying PS1 so we unset it to squelch the error
|
||||
# it'll otherwise print about this being done in the activate script. See
|
||||
# https://github.com/direnv/direnv/wiki/PS1. If you would like your shell
|
||||
# prompt to change like it normally does, see
|
||||
# https://github.com/direnv/direnv/wiki/Python#restoring-the-ps1.
|
||||
unset PS1
|
||||
35
.github/stale.yml
vendored
35
.github/stale.yml
vendored
@@ -1,35 +0,0 @@
|
||||
# Configuration for https://github.com/marketplace/stale
|
||||
|
||||
# Number of days of inactivity before an Issue or Pull Request becomes stale
|
||||
daysUntilStale: 365
|
||||
|
||||
# Number of days of inactivity before an Issue or Pull Request with the stale label is closed.
|
||||
# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
|
||||
# When changing this value, be sure to also update markComment below.
|
||||
daysUntilClose: 30
|
||||
|
||||
# Ignore issues with an assignee (defaults to false)
|
||||
exemptAssignees: true
|
||||
|
||||
# Label to use when marking as stale
|
||||
staleLabel: needs-update
|
||||
|
||||
# Comment to post when marking as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
We've made a lot of changes to Certbot since this issue was opened. If you
|
||||
still have this issue with an up-to-date version of Certbot, can you please
|
||||
add a comment letting us know? This helps us to better see what issues are
|
||||
still affecting our users. If there is no activity in the next 30 days, this
|
||||
issue will be automatically closed.
|
||||
|
||||
# Comment to post when closing a stale Issue or Pull Request.
|
||||
closeComment: >
|
||||
This issue has been closed due to lack of activity, but if you think it
|
||||
should be reopened, please open a new issue with a link to this one and we'll
|
||||
take a look.
|
||||
|
||||
# Limit the number of actions per hour, from 1-30. Default is 30
|
||||
limitPerRun: 1
|
||||
|
||||
# Don't mark pull requests as stale.
|
||||
only: issues
|
||||
33
.gitignore
vendored
33
.gitignore
vendored
@@ -6,12 +6,10 @@ dist*/
|
||||
/venv*/
|
||||
/kgs/
|
||||
/.tox/
|
||||
/releases*/
|
||||
/log*
|
||||
/releases/
|
||||
letsencrypt.log
|
||||
certbot.log
|
||||
letsencrypt-auto-source/letsencrypt-auto.sig.lzma.base64
|
||||
poetry.lock
|
||||
|
||||
# coverage
|
||||
.coverage
|
||||
@@ -27,7 +25,6 @@ tags
|
||||
\#*#
|
||||
.idea
|
||||
.ropeproject
|
||||
.vscode
|
||||
|
||||
# auth --cert-path --chain-path
|
||||
/*.pem
|
||||
@@ -36,33 +33,5 @@ tags
|
||||
tests/letstest/letest-*/
|
||||
tests/letstest/*.pem
|
||||
tests/letstest/venv/
|
||||
tests/letstest/venv3/
|
||||
|
||||
.venv
|
||||
|
||||
# pytest cache
|
||||
.cache
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
|
||||
# docker files
|
||||
.docker
|
||||
|
||||
# certbot tests
|
||||
.certbot_test_workspace
|
||||
**/assets/pebble*
|
||||
**/assets/challtestsrv*
|
||||
|
||||
# snap files
|
||||
.snapcraft
|
||||
parts
|
||||
prime
|
||||
stage
|
||||
*.snap
|
||||
snap-constraints.txt
|
||||
qemu-*
|
||||
certbot-dns*/certbot-dns*_amd64*.txt
|
||||
certbot-dns*/certbot-dns*_arm*.txt
|
||||
/certbot_amd64*.txt
|
||||
/certbot_arm*.txt
|
||||
certbot-dns*/snap
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
[settings]
|
||||
skip_glob=venv*
|
||||
skip=letsencrypt-auto-source
|
||||
force_sort_within_sections=True
|
||||
force_single_line=True
|
||||
order_by_type=False
|
||||
line_length=400
|
||||
60
.pylintrc
60
.pylintrc
@@ -8,10 +8,7 @@ jobs=0
|
||||
|
||||
# Python code to execute, usually for sys.path manipulation such as
|
||||
# pygtk.require().
|
||||
# CERTBOT COMMENT
|
||||
# This is needed for pylint to import linter_plugin.py since
|
||||
# https://github.com/PyCQA/pylint/pull/3396.
|
||||
init-hook="import pylint.config, os, sys; sys.path.append(os.path.dirname(pylint.config.PYLINTRC))"
|
||||
#init-hook=
|
||||
|
||||
# Profiled execution.
|
||||
profile=no
|
||||
@@ -27,11 +24,6 @@ persistent=yes
|
||||
# usually to register additional checkers.
|
||||
load-plugins=linter_plugin
|
||||
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
# run arbitrary code.
|
||||
extension-pkg-whitelist=pywintypes,win32api,win32file,win32security
|
||||
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
|
||||
@@ -49,14 +41,10 @@ extension-pkg-whitelist=pywintypes,win32api,win32file,win32security
|
||||
# --enable=similarities". If you want to run only the classes checker, but have
|
||||
# no Warning level messages displayed, use"--disable=all --enable=classes
|
||||
# --disable=W"
|
||||
# CERTBOT COMMENT
|
||||
# 1) Once certbot codebase is claimed to be compatible exclusively with Python 3,
|
||||
# the useless-object-inheritance check can be enabled again, and code fixed accordingly.
|
||||
# 2) Check unsubscriptable-object tends to create a lot of false positives. Let's disable it.
|
||||
# See https://github.com/PyCQA/pylint/issues/1498.
|
||||
# 3) Same as point 2 for no-value-for-parameter.
|
||||
# See https://github.com/PyCQA/pylint/issues/2820.
|
||||
disable=fixme,locally-disabled,locally-enabled,bad-continuation,no-self-use,invalid-name,cyclic-import,duplicate-code,design,import-outside-toplevel,useless-object-inheritance,unsubscriptable-object,no-value-for-parameter,no-else-return,no-else-raise,no-else-break,no-else-continue
|
||||
disable=fixme,locally-disabled,abstract-class-not-used,abstract-class-little-used,bad-continuation,too-few-public-methods,no-self-use,invalid-name,too-many-instance-attributes,cyclic-import
|
||||
# abstract-class-not-used cannot be disabled locally (at least in
|
||||
# pylint 1.4.1), same for abstract-class-little-used
|
||||
|
||||
|
||||
[REPORTS]
|
||||
|
||||
@@ -257,13 +245,13 @@ ignore-mixin-members=yes
|
||||
# List of module names for which member attributes should not be checked
|
||||
# (useful for modules/projects where namespaces are manipulated during runtime
|
||||
# and thus existing member attributes cannot be deduced by static analysis
|
||||
ignored-modules=pkg_resources,confargparse,argparse
|
||||
ignored-modules=pkg_resources,confargparse,argparse,six.moves,six.moves.urllib
|
||||
# import errors ignored only in 1.4.4
|
||||
# https://bitbucket.org/logilab/pylint/commits/cd000904c9e2
|
||||
|
||||
# List of classes names for which member attributes should not be checked
|
||||
# (useful for classes with attributes dynamically set).
|
||||
ignored-classes=Field,Header,JWS,closing
|
||||
ignored-classes=SQLObject
|
||||
|
||||
# When zope mode is activated, add a predefined set of Zope acquired attributes
|
||||
# to generated-members.
|
||||
@@ -309,6 +297,40 @@ valid-classmethod-first-arg=cls
|
||||
valid-metaclass-classmethod-first-arg=mcs
|
||||
|
||||
|
||||
[DESIGN]
|
||||
|
||||
# Maximum number of arguments for function / method
|
||||
max-args=6
|
||||
|
||||
# Argument names that match this expression will be ignored. Default to name
|
||||
# with leading underscore
|
||||
ignored-argument-names=_.*
|
||||
|
||||
# Maximum number of locals for function / method body
|
||||
max-locals=15
|
||||
|
||||
# Maximum number of return / yield for function / method body
|
||||
max-returns=6
|
||||
|
||||
# Maximum number of branch for function / method body
|
||||
max-branches=12
|
||||
|
||||
# Maximum number of statements in function / method body
|
||||
max-statements=50
|
||||
|
||||
# Maximum number of parents for a class (see R0901).
|
||||
max-parents=12
|
||||
|
||||
# Maximum number of attributes for a class (see R0902).
|
||||
max-attributes=7
|
||||
|
||||
# Minimum number of public methods for a class (see R0903).
|
||||
min-public-methods=2
|
||||
|
||||
# Maximum number of public methods for a class (see R0904).
|
||||
max-public-methods=20
|
||||
|
||||
|
||||
[EXCEPTIONS]
|
||||
|
||||
# Exceptions that will emit a warning when being caught. Defaults to
|
||||
|
||||
149
.travis.yml
Normal file
149
.travis.yml
Normal file
@@ -0,0 +1,149 @@
|
||||
language: python
|
||||
|
||||
cache:
|
||||
directories:
|
||||
- $HOME/.cache/pip
|
||||
|
||||
before_install:
|
||||
- 'dpkg -s libaugeas0'
|
||||
|
||||
# using separate envs with different TOXENVs creates 4x1 Travis build
|
||||
# matrix, which allows us to clearly distinguish which component under
|
||||
# test has failed
|
||||
matrix:
|
||||
include:
|
||||
- python: "2.7"
|
||||
env: TOXENV=cover
|
||||
- python: "2.7"
|
||||
env: TOXENV=lint
|
||||
- python: "2.7"
|
||||
env: TOXENV=py27-oldest BOULDER_INTEGRATION=1
|
||||
sudo: required
|
||||
after_failure:
|
||||
- sudo cat /var/log/mysql/error.log
|
||||
- ps aux | grep mysql
|
||||
services: docker
|
||||
- python: "2.6"
|
||||
env: TOXENV=py26 BOULDER_INTEGRATION=1
|
||||
sudo: required
|
||||
after_failure:
|
||||
- sudo cat /var/log/mysql/error.log
|
||||
- ps aux | grep mysql
|
||||
services: docker
|
||||
- python: "2.6"
|
||||
env: TOXENV=py26-oldest BOULDER_INTEGRATION=1
|
||||
sudo: required
|
||||
after_failure:
|
||||
- sudo cat /var/log/mysql/error.log
|
||||
- ps aux | grep mysql
|
||||
services: docker
|
||||
- python: "2.7"
|
||||
env: TOXENV=py27_install BOULDER_INTEGRATION=1
|
||||
sudo: required
|
||||
after_failure:
|
||||
- sudo cat /var/log/mysql/error.log
|
||||
- ps aux | grep mysql
|
||||
services: docker
|
||||
- sudo: required
|
||||
env: TOXENV=apache_compat
|
||||
services: docker
|
||||
before_install:
|
||||
addons:
|
||||
- sudo: required
|
||||
env: TOXENV=nginx_compat
|
||||
services: docker
|
||||
before_install:
|
||||
addons:
|
||||
- sudo: required
|
||||
env: TOXENV=le_auto_precise
|
||||
services: docker
|
||||
before_install:
|
||||
addons:
|
||||
- sudo: required
|
||||
env: TOXENV=le_auto_trusty
|
||||
services: docker
|
||||
before_install:
|
||||
addons:
|
||||
- sudo: required
|
||||
env: TOXENV=le_auto_wheezy
|
||||
services: docker
|
||||
before_install:
|
||||
addons:
|
||||
- sudo: required
|
||||
env: TOXENV=le_auto_centos6
|
||||
services: docker
|
||||
before_install:
|
||||
addons:
|
||||
- python: "2.7"
|
||||
env: TOXENV=apacheconftest
|
||||
sudo: required
|
||||
- python: "3.3"
|
||||
env: TOXENV=py33
|
||||
- python: "3.4"
|
||||
env: TOXENV=py34
|
||||
- python: "3.5"
|
||||
env: TOXENV=py35
|
||||
- python: "3.6"
|
||||
env: TOXENV=py36
|
||||
- python: "2.7"
|
||||
env: TOXENV=nginxroundtrip
|
||||
|
||||
|
||||
# Only build pushes to the master branch, PRs, and branches beginning with
|
||||
# `test-` or of the form `digit(s).digit(s).x`. This reduces the number of
|
||||
# simultaneous Travis runs, which speeds turnaround time on review since there
|
||||
# is a cap of on the number of simultaneous runs.
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
- /^\d+\.\d+\.x$/
|
||||
- /^test-.*$/
|
||||
|
||||
# container-based infrastructure
|
||||
sudo: false
|
||||
|
||||
addons:
|
||||
# Custom /etc/hosts required for simple verification of http-01
|
||||
# and tls-sni-01, and for certbot_test_nginx
|
||||
hosts:
|
||||
- le.wtf
|
||||
- le1.wtf
|
||||
- le2.wtf
|
||||
- le3.wtf
|
||||
- nginx.wtf
|
||||
- boulder
|
||||
- boulder-mysql
|
||||
- boulder-rabbitmq
|
||||
apt:
|
||||
sources:
|
||||
- augeas
|
||||
packages: # Keep in sync with letsencrypt-auto-source/pieces/bootstrappers/deb_common.sh and Boulder.
|
||||
- python-dev
|
||||
- python-virtualenv
|
||||
- gcc
|
||||
- libaugeas0
|
||||
- libssl-dev
|
||||
- libffi-dev
|
||||
- ca-certificates
|
||||
# For certbot-nginx integration testing
|
||||
- nginx-light
|
||||
- openssl
|
||||
# for apacheconftest
|
||||
- apache2
|
||||
- libapache2-mod-wsgi
|
||||
- libapache2-mod-macro
|
||||
|
||||
install: "travis_retry pip install tox coveralls"
|
||||
script: 'travis_retry tox && ([ "xxx$BOULDER_INTEGRATION" = "xxx" ] || ./tests/travis-integration.sh)'
|
||||
|
||||
after_success: '[ "$TOXENV" == "cover" ] && coveralls'
|
||||
|
||||
notifications:
|
||||
email: false
|
||||
irc:
|
||||
channels:
|
||||
- "chat.freenode.net#letsencrypt"
|
||||
on_success: never
|
||||
on_failure: always
|
||||
use_notice: true
|
||||
skip_join: true
|
||||
284
AUTHORS.md
284
AUTHORS.md
@@ -1,284 +0,0 @@
|
||||
Authors
|
||||
=======
|
||||
|
||||
* [Aaron Gable](https://github.com/aarongable)
|
||||
* [Aaron Zirbes](https://github.com/aaronzirbes)
|
||||
* Aaron Zuehlke
|
||||
* Ada Lovelace
|
||||
* [Adam Woodbeck](https://github.com/awoodbeck)
|
||||
* [Adrien Ferrand](https://github.com/adferrand)
|
||||
* [Aidin Gharibnavaz](https://github.com/aidin36)
|
||||
* [AJ ONeal](https://github.com/coolaj86)
|
||||
* [Alcaro](https://github.com/Alcaro)
|
||||
* [Alexander Mankuta](https://github.com/pointlessone)
|
||||
* [Alex Bowers](https://github.com/alexbowers)
|
||||
* [Alex Conlin](https://github.com/alexconlin)
|
||||
* [Alex Gaynor](https://github.com/alex)
|
||||
* [Alex Halderman](https://github.com/jhalderm)
|
||||
* [Alex Jordan](https://github.com/strugee)
|
||||
* [Alex Zorin](https://github.com/alexzorin)
|
||||
* [Amjad Mashaal](https://github.com/TheNavigat)
|
||||
* [Andrew Murray](https://github.com/radarhere)
|
||||
* [Andrzej Górski](https://github.com/andrzej3393)
|
||||
* [Anselm Levskaya](https://github.com/levskaya)
|
||||
* [Antoine Jacoutot](https://github.com/ajacoutot)
|
||||
* [April King](https://github.com/april)
|
||||
* [asaph](https://github.com/asaph)
|
||||
* [Axel Beckert](https://github.com/xtaran)
|
||||
* [Bas](https://github.com/Mechazawa)
|
||||
* [benbankes](https://github.com/benbankes)
|
||||
* [Ben Irving](https://github.com/benileo)
|
||||
* [Benjamin Kerensa](https://github.com/bkerensa)
|
||||
* [Benjamin Neff](https://github.com/SuperTux88)
|
||||
* [Benjamin Piouffle](https://github.com/Betree)
|
||||
* [Ben Ubois](https://github.com/benubois)
|
||||
* [Ben Wolfe](https://github.com/bwolfe)
|
||||
* [Bigfish](https://github.com/bwolfe)
|
||||
* [Blake Griffith](https://github.com/cowlicks)
|
||||
* [Brad Warren](https://github.com/bmw)
|
||||
* [Brandon Kraft](https://github.com/kraftbj)
|
||||
* [Brandon Kreisel](https://github.com/BKreisel)
|
||||
* [Brian Heim](https://github.com/brianlheim)
|
||||
* [Cameron Steel](https://github.com/Tugzrida)
|
||||
* [Ceesjan Luiten](https://github.com/quinox)
|
||||
* [Chad Whitacre](https://github.com/whit537)
|
||||
* [Chhatoi Pritam Baral](https://github.com/pritambaral)
|
||||
* [Chris Johns](https://github.com/ter0)
|
||||
* [Chris Lamb](https://github.com/lamby)
|
||||
* [chrismarget](https://github.com/chrismarget)
|
||||
* [Christian Gärtner](https://github.com/ChristianGaertner)
|
||||
* [Christian Rosentreter](https://github.com/the-real-tokai)
|
||||
* [Christopher Brown](https://github.com/chbrown)
|
||||
* [Christopher Manning](https://github.com/christophermanning)
|
||||
* [Christoph Kisfeld](https://github.com/chk1)
|
||||
* [Clif Houck](https://github.com/ClifHouck)
|
||||
* [Cooper Quintin](https://github.com/cooperq)
|
||||
* [Corey Farwell](https://github.com/frewsxcv)
|
||||
* [Craig Smith](https://github.com/dashaxiong)
|
||||
* [Damian Poddebniak](https://github.com/duesee)
|
||||
* [Damien Nozay](https://github.com/dnozay)
|
||||
* [Damien Tournoud](https://github.com/damz)
|
||||
* [DanCld](https://github.com/DanCld)
|
||||
* [Daniel Albers](https://github.com/AID)
|
||||
* [Daniel Aleksandersen](https://github.com/da2x)
|
||||
* [Daniel Almasi](https://github.com/almasen)
|
||||
* [Daniel Convissor](https://github.com/convissor)
|
||||
* [Daniel "Drex" Drexler](https://github.com/aeturnum)
|
||||
* [Daniel Huang](https://github.com/dhuang)
|
||||
* [Dave Guarino](https://github.com/daguar)
|
||||
* [David cz](https://github.com/dave-cz)
|
||||
* [David Dworken](https://github.com/ddworken)
|
||||
* [David Kreitschmann](https://kreitschmann.de)
|
||||
* [David Xia](https://github.com/davidxia)
|
||||
* [Devin Howard](https://github.com/devvmh)
|
||||
* [dokazaki](https://github.com/dokazaki)
|
||||
* [Dominic Cleal](https://github.com/domcleal)
|
||||
* [Dominic Lüchinger](https://github.com/dol)
|
||||
* [Douglas José](https://github.com/douglasjose)
|
||||
* [Erica Portnoy](https://github.com/ohemorange)
|
||||
* [Eric Engestrom](https://github.com/1ace)
|
||||
* [Eric Rescorla](https://github.com/ekr)
|
||||
* [Eric Wustrow](https://github.com/ewust)
|
||||
* [Erik Rose](https://github.com/erikrose)
|
||||
* [Eugene Kazakov](https://github.com/xgin)
|
||||
* [Fabian](https://github.com/faerbit)
|
||||
* [Faidon Liambotis](https://github.com/paravoid)
|
||||
* [Fan Jiang](https://github.com/tcz001)
|
||||
* [Felix Lechner](https://github.com/lechner)
|
||||
* [Felix Schwarz](https://github.com/FelixSchwarz)
|
||||
* [Felix Yan](https://github.com/felixonmars)
|
||||
* [Filip Ochnik](https://github.com/filipochnik)
|
||||
* [Florian Klink](https://github.com/flokli)
|
||||
* [Francois Marier](https://github.com/fmarier)
|
||||
* [Frank](https://github.com/Frankkkkk)
|
||||
* [Frederic BLANC](https://github.com/fblanc)
|
||||
* [Garrett Robinson](https://github.com/garrettr)
|
||||
* [Gene Wood](https://github.com/gene1wood)
|
||||
* [Geoffroy Doucet](https://www.geoffroydoucet.com)
|
||||
* [Gian Carlo Pace](https://github.com/gicappa)
|
||||
* [Gilles Pietri](https://github.com/gilou)
|
||||
* [Giovanni Pellerano](https://github.com/evilaliv3)
|
||||
* [Giovanni Toraldo](https://github.com/gionn)
|
||||
* [Gordin](https://github.com/Gordin)
|
||||
* [Gregor Dschung](https://github.com/chkpnt)
|
||||
* [Gregory L. Dietsche](https://github.com/farmergreg)
|
||||
* [Greg Osuri](https://github.com/gosuri)
|
||||
* [Guillaume Boudreau](https://github.com/gboudreau)
|
||||
* [Harlan Lieberman-Berg](https://github.com/hlieberman)
|
||||
* [Henri Salo](https://github.com/fgeek)
|
||||
* [Henry Chen](https://github.com/henrychen95)
|
||||
* [Hugo van Kemenade](https://github.com/hugovk)
|
||||
* [Ingolf Becker](https://github.com/watercrossing)
|
||||
* [Ivan Nejgebauer](https://github.com/inejge)
|
||||
* [Jaap Eldering](https://github.com/eldering)
|
||||
* [Jacob Hoffman-Andrews](https://github.com/jsha)
|
||||
* [Jacob Sachs](https://github.com/jsachs)
|
||||
* [Jairo Llopis](https://github.com/Yajo)
|
||||
* [Jakub Warmuz](https://github.com/kuba)
|
||||
* [James Kasten](https://github.com/jdkasten)
|
||||
* [Jason Grinblat](https://github.com/ptychomancer)
|
||||
* [Jay Faulkner](https://github.com/jayofdoom)
|
||||
* [J.C. Jones](https://github.com/jcjones)
|
||||
* [Jeff Hodges](https://github.com/jmhodges)
|
||||
* [Jeremy Gillula](https://github.com/jgillula)
|
||||
* [Jeroen Ketelaar](https://github.com/JKetelaar)
|
||||
* [Jeroen Pluimers](https://github.com/jpluimers)
|
||||
* [j](https://github.com/bit)
|
||||
* [Jim Tittsler](https://github.com/jimt)
|
||||
* [Joe Ranweiler](https://github.com/ranweiler)
|
||||
* [Joerg Sonnenberger](https://github.com/jsonn)
|
||||
* [John Leach](https://github.com/johnl)
|
||||
* [John Reed](https://github.com/leerspace)
|
||||
* [Jonas Berlin](https://github.com/xkr47)
|
||||
* [Jonathan Herlin](https://github.com/Jonher937)
|
||||
* [Jon Walsh](https://github.com/code-tree)
|
||||
* [Joona Hoikkala](https://github.com/joohoi)
|
||||
* [Josh McCullough](https://github.com/JoshMcCullough)
|
||||
* [Josh Soref](https://github.com/jsoref)
|
||||
* [Joubin Jabbari](https://github.com/joubin)
|
||||
* [Juho Juopperi](https://github.com/jkjuopperi)
|
||||
* [Kane York](https://github.com/riking)
|
||||
* [Kenichi Maehashi](https://github.com/kmaehashi)
|
||||
* [Kenneth Skovhede](https://github.com/kenkendk)
|
||||
* [Kevin Burke](https://github.com/kevinburke)
|
||||
* [Kevin London](https://github.com/kevinlondon)
|
||||
* [Kubilay Kocak](https://github.com/koobs)
|
||||
* [LeCoyote](https://github.com/LeCoyote)
|
||||
* [Lee Watson](https://github.com/TheReverend403)
|
||||
* [Leo Famulari](https://github.com/lfam)
|
||||
* [lf](https://github.com/lf-)
|
||||
* [Liam Marshall](https://github.com/liamim)
|
||||
* [Lior Sabag](https://github.com/liorsbg)
|
||||
* [Lipis](https://github.com/lipis)
|
||||
* [lord63](https://github.com/lord63)
|
||||
* [Lorenzo Fundaró](https://github.com/lfundaro)
|
||||
* [Luca Beltrame](https://github.com/lbeltrame)
|
||||
* [Luca Ebach](https://github.com/lucebac)
|
||||
* [Luca Olivetti](https://github.com/olivluca)
|
||||
* [Luke Rogers](https://github.com/lukeroge)
|
||||
* [Maarten](https://github.com/mrtndwrd)
|
||||
* [Mads Jensen](https://github.com/atombrella)
|
||||
* [Maikel Martens](https://github.com/krukas)
|
||||
* [Malte Janduda](https://github.com/MalteJ)
|
||||
* [Mantas Mikulėnas](https://github.com/grawity)
|
||||
* [Marcel Krüger](https://github.com/zauguin)
|
||||
* [Marcos Caceres](https://github.com/marcoscaceres)
|
||||
* [Marek Viger](https://github.com/freezy-sk)
|
||||
* [Mario Villaplana](https://github.com/supermari0)
|
||||
* [Marius Gedminas](https://github.com/mgedmin)
|
||||
* [Martey Dodoo](https://github.com/martey)
|
||||
* [Martijn Bastiaan](https://github.com/martijnbastiaan)
|
||||
* [Martijn Braam](https://github.com/MartijnBraam)
|
||||
* [Martin Brugger](https://github.com/mbrugger)
|
||||
* [Mathieu Leduc-Hamel](https://github.com/mlhamel)
|
||||
* [Matt Bostock](https://github.com/mattbostock)
|
||||
* [Matthew Ames](https://github.com/SuperMatt)
|
||||
* [Michael Schumacher](https://github.com/schumaml)
|
||||
* [Michael Strache](https://github.com/Jarodiv)
|
||||
* [Michael Sverdlin](https://github.com/sveder)
|
||||
* [Michael Watters](https://github.com/blackknight36)
|
||||
* [Michal Moravec](https://github.com/https://github.com/Majkl578)
|
||||
* [Michal Papis](https://github.com/mpapis)
|
||||
* [Mickaël Schoentgen](https://github.com/BoboTiG)
|
||||
* [Minn Soe](https://github.com/MinnSoe)
|
||||
* [Min RK](https://github.com/minrk)
|
||||
* [Miquel Ruiz](https://github.com/miquelruiz)
|
||||
* [Môshe van der Sterre](https://github.com/moshevds)
|
||||
* [mrstanwell](https://github.com/mrstanwell)
|
||||
* [Nav Aulakh](https://github.com/Nav)
|
||||
* [Nelson Elhage](https://github.com/nelhage)
|
||||
* [Nick Fong](https://github.com/nickfong)
|
||||
* [Nick Le Mouton](https://github.com/NoodlesNZ)
|
||||
* [Nikos Roussos](https://github.com/comzeradd)
|
||||
* [Noah Swartz](https://github.com/swartzcr)
|
||||
* [Ola Bini](https://github.com/olabini)
|
||||
* [Ondřej Súkup](https://github.com/mimi1vx)
|
||||
* [Ondřej Surý](https://github.com/oerdnj)
|
||||
* [osirisinferi](https://github.com/osirisinferi)
|
||||
* Patrick Figel
|
||||
* [Patrick Heppler](https://github.com/PatrickHeppler)
|
||||
* [Paul Feitzinger](https://github.com/pfeyz)
|
||||
* [Pavan Gupta](https://github.com/pavgup)
|
||||
* [Pavel Pavlov](https://github.com/ghost355)
|
||||
* [Peter Conrad](https://github.com/pconrad-fb)
|
||||
* [Peter Eckersley](https://github.com/pde)
|
||||
* [Peter Mosmans](https://github.com/PeterMosmans)
|
||||
* [Philippe Langlois](https://github.com/langloisjp)
|
||||
* [Philipp Spitzer](https://github.com/spitza)
|
||||
* [Piero Steinger](https://github.com/Jadaw1n)
|
||||
* [Pierre Jaury](https://github.com/kaiyou)
|
||||
* [Piotr Kasprzyk](https://github.com/kwadrat)
|
||||
* [Prayag Verma](https://github.com/pra85)
|
||||
* [Rasesh Patel](https://github.com/raspat1)
|
||||
* [Reinaldo de Souza Jr](https://github.com/juniorz)
|
||||
* [Remi Rampin](https://github.com/remram44)
|
||||
* [Rémy HUBSCHER](https://github.com/Natim)
|
||||
* [Rémy Léone](https://github.com/sieben)
|
||||
* [Richard Barnes](https://github.com/r-barnes)
|
||||
* [Richard Panek](https://github.com/kernelpanek)
|
||||
* [Robert Buchholz](https://github.com/rbu)
|
||||
* [Robert Dailey](https://github.com/pahrohfit)
|
||||
* [Robert Habermann](https://github.com/frennkie)
|
||||
* [Robert Xiao](https://github.com/nneonneo)
|
||||
* [Roland Shoemaker](https://github.com/rolandshoemaker)
|
||||
* [Roy Wellington Ⅳ](https://github.com/thanatos)
|
||||
* [rugk](https://github.com/rugk)
|
||||
* [Sachi King](https://github.com/nakato)
|
||||
* [Sagi Kedmi](https://github.com/sagi)
|
||||
* [Sam Lanning](https://github.com/samlanning)
|
||||
* [sapics](https://github.com/sapics)
|
||||
* [Scott Barr](https://github.com/scottjbarr)
|
||||
* [Scott Merrill](https://github.com/skpy)
|
||||
* [Sebastian Bögl](https://github.com/TheBoegl)
|
||||
* [Sebastian Wagner](https://github.com/sebix)
|
||||
* [sedrubal](https://github.com/sedrubal)
|
||||
* [Seppe Stas](https://github.com/seppestas)
|
||||
* [Sergey Nuzdhin](https://github.com/lwolf)
|
||||
* [Seth Schoen](https://github.com/schoen)
|
||||
* [Sharif Nassar](https://github.com/mrwacky42)
|
||||
* [Shaun Cummiskey](https://github.com/ampersign)
|
||||
* [Shiloh Heurich](https://github.com/sheurich)
|
||||
* [silverwind](https://github.com/silverwind)
|
||||
* [Sorvani](https://github.com/sorvani)
|
||||
* [Spencer Bliven](https://github.com/sbliven)
|
||||
* [Stacey Sheldon](https://github.com/solidgoldbomb)
|
||||
* [Stavros Korokithakis](https://github.com/skorokithakis)
|
||||
* [Ștefan Talpalaru](https://github.com/stefantalpalaru)
|
||||
* [Stefan Weil](https://github.com/stweil)
|
||||
* [Steve Desmond](https://github.com/stevedesmond-ca)
|
||||
* [sydneyli](https://github.com/sydneyli)
|
||||
* [taixx046](https://github.com/taixx046)
|
||||
* [Tan Jay Jun](https://github.com/jayjun)
|
||||
* [Tapple Gao](https://github.com/tapple)
|
||||
* [Telepenin Nikolay](https://github.com/telepenin)
|
||||
* [Thomas Cottier](https://github.com/tcottier-enalean)
|
||||
* [Thomas Mayer](https://github.com/thomaszbz)
|
||||
* [Thomas Waldmann](https://github.com/ThomasWaldmann)
|
||||
* [Thom Wiggers](https://github.com/thomwiggers)
|
||||
* [Till Maas](https://github.com/tyll)
|
||||
* [Timothy Guan-tin Chien](https://github.com/timdream)
|
||||
* [Torsten Bögershausen](https://github.com/tboegi)
|
||||
* [Travis Raines](https://github.com/rainest)
|
||||
* [Trung Ngo](https://github.com/Ngo-The-Trung)
|
||||
* [Valentin](https://github.com/e00E)
|
||||
* [venyii](https://github.com/venyii)
|
||||
* [Viktor Szakats](https://github.com/vszakats)
|
||||
* [Ville Skyttä](https://github.com/scop)
|
||||
* [Vinney Cavallo](https://github.com/vcavallo)
|
||||
* [Vladimir Rutsky](https://github.com/rutsky)
|
||||
* [Wang Yu](https://github.com/wyhitcs)
|
||||
* [Ward Vandewege](https://github.com/cure)
|
||||
* [Whyfoo](https://github.com/whyfoo)
|
||||
* [Wilfried Teiken](https://github.com/wteiken)
|
||||
* [Willem Fibbe](https://github.com/fibbers)
|
||||
* [William Budington](https://github.com/Hainish)
|
||||
* [Will Newby](https://github.com/willnewby)
|
||||
* [Will Oller](https://github.com/willoller)
|
||||
* [Yan](https://github.com/diracdeltas)
|
||||
* [Yen Chi Hsuan](https://github.com/yan12125)
|
||||
* [Yomna](https://github.com/ynasser)
|
||||
* [Yoni Jah](https://github.com/yonjah)
|
||||
* [YourDaddyIsHere](https://github.com/YourDaddyIsHere)
|
||||
* [Yuseong Cho](https://github.com/g6123)
|
||||
* [Zach Shepherd](https://github.com/zjs)
|
||||
* [陈三](https://github.com/chenxsan)
|
||||
@@ -1 +0,0 @@
|
||||
certbot/CHANGELOG.md
|
||||
408
CHANGELOG.md
Normal file
408
CHANGELOG.md
Normal file
@@ -0,0 +1,408 @@
|
||||
# Certbot change log
|
||||
|
||||
Certbot adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## 0.12.0 - 2017-02-03
|
||||
|
||||
### Added
|
||||
|
||||
* Allow non-camelcase Apache VirtualHost names
|
||||
* Allow more log messages to be silenced
|
||||
|
||||
### Fixed
|
||||
|
||||
* Fix a regression around using `--cert-name` when getting new certificates
|
||||
|
||||
More information about these changes can be found on our GitHub repo:
|
||||
https://github.com/certbot/certbot/issues?q=is%3Aissue%20milestone%3A0.12.0
|
||||
|
||||
## 0.11.1 - 2017-02-01
|
||||
|
||||
### Fixed
|
||||
|
||||
* Resolve a problem where Certbot would crash while parsing command line
|
||||
arguments in some cases.
|
||||
* Fix a typo.
|
||||
|
||||
More details about these changes can be found on our GitHub repo:
|
||||
https://github.com/certbot/certbot/pulls?q=is%3Apr%20milestone%3A0.11.1%20is%3Aclosed
|
||||
|
||||
## 0.11.0 - 2017-02-01
|
||||
|
||||
### Added
|
||||
|
||||
* The UI has been improved in the standalone plugin. When using the
|
||||
plugin while running Certbot interactively and a required port is bound
|
||||
by another process, Certbot will give you the option to retry to grab
|
||||
the port rather than immediately exiting.
|
||||
* You are now able to deactivate your account with the Let's Encrypt
|
||||
server using the `unregister` subcommand.
|
||||
* When revoking a certificate using the `revoke` subcommand, you now
|
||||
have the option to provide the reason the certificate is being revoked
|
||||
to Let's Encrypt with `--reason`.
|
||||
|
||||
### Changed
|
||||
|
||||
* Providing `--quiet` to `certbot-auto` now silences package manager output.
|
||||
|
||||
### Removed
|
||||
|
||||
* Removal of the optional `dnspython` dependency in our `acme` package.
|
||||
Now the library does not support client side verification of the DNS
|
||||
challenge.
|
||||
|
||||
More details about these changes can be found on our GitHub repo:
|
||||
https://github.com/certbot/certbot/issues?q=is%3Aissue+milestone%3A0.11.0+is%3Aclosed
|
||||
|
||||
## 0.10.2 - 2017-01-25
|
||||
|
||||
### Added
|
||||
|
||||
* If Certbot receives a request with a `badNonce` error, we
|
||||
automatically retry the request. Since nonces from Let's Encrypt expire,
|
||||
this helps people performing the DNS challenge with the `manual` plugin
|
||||
who may have to wait an extended period of time for their DNS changes to
|
||||
propagate.
|
||||
|
||||
### Fixed
|
||||
|
||||
* We now save `--preferred-challenges` values for renewal. Previously
|
||||
these values were discarded causing a different challenge type to be
|
||||
used when renewing certs in some cases.
|
||||
|
||||
More details about these changes can be found on our GitHub repo:
|
||||
https://github.com/certbot/certbot/issues?q=is%3Aissue+milestone%3A0.10.2+is%3Aclosed
|
||||
|
||||
## 0.10.1 - 2017-01-13
|
||||
|
||||
### Fixed
|
||||
|
||||
* Resolve problems where when asking Certbot to update a certificate at
|
||||
an existing path to include different domain names, the old names would
|
||||
continue to be used.
|
||||
* Fix issues successfully running our unit test suite on some systems.
|
||||
|
||||
More details about these changes can be found on our GitHub repo:
|
||||
https://github.com/certbot/certbot/issues?q=is%3Aissue+milestone%3A0.10.1+is%3Aclosed
|
||||
|
||||
## 0.10.0 - 2017-01-11
|
||||
|
||||
## Added
|
||||
|
||||
* Added the ability to customize and automatically complete DNS and HTTP
|
||||
domain validation challenges with the manual plugin. The flags
|
||||
`--manual-auth-hook` and `--manual-cleanup-hook` can now be provided
|
||||
when using the manual plugin to execute commands provided by the user to
|
||||
perform and clean up challenges provided by the CA. This is best used in
|
||||
complicated setups where the DNS challenge must be used or Certbot's
|
||||
existing plugins cannot be used to perform HTTP challenges. For more
|
||||
information on how this works, see `certbot --help manual`.
|
||||
* Added a `--cert-name` flag for specifying the name to use for the
|
||||
certificate in Certbot's configuration directory. Using this flag in
|
||||
combination with `-d/--domains`, a user can easily request a new
|
||||
certificate with different domains and save it with the name provided by
|
||||
`--cert-name`. Additionally, `--cert-name` can be used to select a
|
||||
certificate with the `certonly` and `run` subcommands so a full list of
|
||||
domains in the certificate does not have to be provided.
|
||||
* Added subcommand `certificates` for listing the certificates managed by
|
||||
Certbot and their properties.
|
||||
* Added `delete` subcommand for removing certificates managed by Certbot
|
||||
from the configuration directory.
|
||||
* Support requesting internationalized domain names (IDNs).
|
||||
* Hooks provided to Certbot are now saved to be reused during renewal.
|
||||
If you run Certbot with `--pre-hook`, `--renew-hook`, or `--post-hook`
|
||||
flags when obtaining a certificate, the provided commands will
|
||||
automatically be saved and executed again when renewing the certificate.
|
||||
A pre-hook and/or post-hook can also be given to the `certbot renew`
|
||||
command either on the command line or in a [configuration
|
||||
file](https://certbot.eff.org/docs/using.html#configuration-file) to run
|
||||
an additional command before/after any certificate is renewed. Hooks
|
||||
will only be run if a certificate is renewed.
|
||||
* Support Busybox in certbot-auto.
|
||||
|
||||
### Changed
|
||||
|
||||
* Recategorized `-h/--help` output to improve documentation and
|
||||
discoverability.
|
||||
|
||||
### Removed
|
||||
|
||||
* Removed the ncurses interface. This change solves problems people
|
||||
were having on many systems, reduces the number of Certbot
|
||||
dependencies, and simplifies our code. Certbot's only interface now is
|
||||
the text interface which was available by providing `-t/--text` to
|
||||
earlier versions of Certbot.
|
||||
|
||||
### Fixed
|
||||
|
||||
* Many small bug fixes.
|
||||
|
||||
More details about these changes can be found on our GitHub repo:
|
||||
https://github.com/certbot/certbot/issues?q=is%3Aissue+milestone%3A0.10.0is%3Aclosed
|
||||
|
||||
## 0.9.3 - 2016-10-13
|
||||
|
||||
### Added
|
||||
|
||||
* The Apache plugin uses information about your OS to help determine the
|
||||
layout of your Apache configuration directory. We added a patch to
|
||||
ensure this code behaves the same way when testing on different systems
|
||||
as the tests were failing in some cases.
|
||||
|
||||
### Changed
|
||||
|
||||
* Adopt more conservative behavior about reporting a needed port as
|
||||
unavailable when using the standalone plugin.
|
||||
|
||||
More details about these changes can be found on our GitHub repo:
|
||||
https://github.com/certbot/certbot/milestone/27?closed=1
|
||||
|
||||
## 0.9.2 - 2016-10-12
|
||||
|
||||
### Added
|
||||
|
||||
* Stop requiring that all possibly required ports are available when
|
||||
using the standalone plugin. Only verify the ports are available when
|
||||
you know they are necessary.
|
||||
|
||||
### Fixed
|
||||
|
||||
* Verify that our optional dependencies version matches what is
|
||||
required by Certbot.
|
||||
* Ensure we properly copy `ssl on;` directives as necessary when
|
||||
performing domain validation in the Nginx plugin.
|
||||
* Fix problems where symlinks were becoming files when they were
|
||||
packaged, causing errors during testing and OS packaging.
|
||||
|
||||
More details about these changes can be found on our GitHub repo:
|
||||
https://github.com/certbot/certbot/milestone/26?closed=1
|
||||
|
||||
## 0.9.1 - 2016-10-06
|
||||
|
||||
### Fixed
|
||||
|
||||
* Fix a bug that was introduced in version 0.9.0 where the command
|
||||
line flag -q/--quiet wasn't respected in some cases.
|
||||
|
||||
More details about these changes can be found on our GitHub repo:
|
||||
https://github.com/certbot/certbot/milestone/25?closed=1
|
||||
|
||||
## 0.9.0 - 2016-10-05
|
||||
|
||||
### Added
|
||||
|
||||
* Add an alpha version of the Nginx plugin. This plugin fully automates the
|
||||
process of obtaining and installing certificates with Nginx.
|
||||
Additionally, it is able to automatically configure security
|
||||
enhancements such as an HTTP to HTTPS redirect and OCSP stapling. To use
|
||||
this plugin, you must have the `certbot-nginx` package installed (which
|
||||
is installed automatically when using `certbot-auto`) and provide
|
||||
`--nginx` on the command line. This plugin is still in its early stages
|
||||
so we recommend you use it with some caution and make sure you have a
|
||||
backup of your Nginx configuration.
|
||||
* Support the `DNS` challenge in the `acme` library and `DNS` in
|
||||
Certbot's `manual` plugin. This allows you to create DNS records to
|
||||
prove to Let's Encrypt you control the requested domain name. To use
|
||||
this feature, include `--manual --preferred-challenges dns` on the
|
||||
command line.
|
||||
* Help with enabling Extra Packages for Enterprise Linux (EPEL) on
|
||||
CentOS 6 when using `certbot-auto`. To use `certbot-auto` on CentOS 6,
|
||||
the EPEL repository has to be enabled. `certbot-auto` will now prompt
|
||||
users asking them if they would like the script to enable this for them
|
||||
automatically. This is done without prompting users when using
|
||||
`letsencrypt-auto` or if `-n/--non-interactive/--noninteractive` is
|
||||
included on the command line.
|
||||
|
||||
More details about these changes can be found on our GitHub repo:
|
||||
https://github.com/certbot/certbot/issues?q=is%3Aissue+milestone%3A0.9.0+is%3Aclosed
|
||||
|
||||
## 0.8.1 - 2016-06-14
|
||||
|
||||
### Added
|
||||
|
||||
* Preserve a certificate's common name when using `renew`
|
||||
* Save webroot values for renewal when they are entered interactively
|
||||
* Gracefully report the Apache plugin isn't usable when Augeas is not installed
|
||||
* Experimental support for Mageia has been added to `certbot-auto`
|
||||
|
||||
### Fixed
|
||||
|
||||
* Fix problems with an invalid user-agent string on OS X
|
||||
|
||||
More details about these changes can be found on our GitHub repo:
|
||||
https://github.com/certbot/certbot/issues?q=is%3Aissue+milestone%3A0.8.1+
|
||||
|
||||
## 0.8.0 - 2016-06-02
|
||||
|
||||
### Added
|
||||
|
||||
* The main new feature in this release is the `register` subcommand which
|
||||
can be used to register an account with the Let's Encrypt CA.
|
||||
* Additionally, you can run `certbot register --update-registration` to
|
||||
change the e-mail address associated with your registration.
|
||||
|
||||
More details about these changes can be found on our GitHub repo:
|
||||
https://github.com/certbot/certbot/issues?q=is%3Aissue+milestone%3A0.8.0+
|
||||
|
||||
## 0.7.0 - 2016-05-27
|
||||
|
||||
### Added
|
||||
|
||||
* Added `--must-staple` to request certificates from Let's Encrypt
|
||||
with the OCSP must staple extension
|
||||
* Automatically configure OSCP stapling for Apache
|
||||
* Allow requesting certificates for domains found in the common name
|
||||
of a custom CSR
|
||||
|
||||
### Fixed
|
||||
|
||||
* Miscellaneous bug fixes
|
||||
|
||||
More details about these changes can be found on our GitHub repo:
|
||||
https://github.com/certbot/certbot/issues?q=milestone%3A0.7.0+is%3Aissue
|
||||
|
||||
## 0.6.0 - 2016-05-12
|
||||
|
||||
### Added
|
||||
|
||||
* Versioned the datetime dependency in setup.py
|
||||
|
||||
### Changed
|
||||
|
||||
* Renamed the client from `letsencrypt` to `certbot`
|
||||
|
||||
### Fixed
|
||||
|
||||
* Fixed a small json deserialization error
|
||||
* Preserve domain order in generated CSRs
|
||||
* Some minor bug fixes
|
||||
|
||||
More details about these changes can be found on our GitHub repo:
|
||||
https://github.com/certbot/certbot/issues?q=is%3Aissue%20milestone%3A0.6.0%20is%3Aclosed%20
|
||||
|
||||
## 0.5.0 - 2016-04-05
|
||||
|
||||
### Added
|
||||
|
||||
* Add the ability to use the webroot plugin interactively.
|
||||
* The flags --pre-hook, --post-hook, and --renew-hook can be used with
|
||||
the renew subcommand to register shell commands to run in response to
|
||||
renewal events. Pre-hook commands will be run before any certs are
|
||||
renewed, post-hook commands will be run after any certs are renewed,
|
||||
and renew-hook commands will be run after each cert is renewed. If no
|
||||
certs are due for renewal, no command is run.
|
||||
* A -q/--quiet flag which silences all output except errors.
|
||||
* An --allow-subset-of-domains flag which can be used with the renew
|
||||
command to prevent renewal failures for a subset of the requested
|
||||
domains from causing the client to exit.
|
||||
|
||||
### Changed
|
||||
|
||||
* Use cleaner renewal configuration files. In /etc/letsencrypt/renewal
|
||||
by default, these files can be used to control what parameters are
|
||||
used when renewing a specific certificate.
|
||||
|
||||
More details about these changes can be found on our GitHub repo:
|
||||
https://github.com/letsencrypt/letsencrypt/issues?q=milestone%3A0.5.0+is%3Aissue
|
||||
|
||||
## 0.4.2 - 2016-03-03
|
||||
|
||||
### Fixed
|
||||
|
||||
* Resolves problems encountered when compiling letsencrypt
|
||||
against the new OpenSSL release.
|
||||
* A patch fixing problems of using `letsencrypt renew` with configuration files
|
||||
from private beta has been added.
|
||||
|
||||
More details about these changes can be found on our GitHub repo:
|
||||
https://github.com/letsencrypt/letsencrypt/issues?q=is%3Aissue+milestone%3A0.4.2
|
||||
|
||||
## 0.4.1 - 2016-02-29
|
||||
|
||||
### Fixed
|
||||
|
||||
* Fix Apache parsing errors with some configurations
|
||||
* Fix Werkzeug dependency problems on some Red Hat systems
|
||||
* Fix bootstrapping failures when using letsencrypt-auto with --no-self-upgrade
|
||||
* Fix problems with parsing renewal config files from private beta
|
||||
|
||||
More details about these changes can be found on our GitHub repo:
|
||||
https://github.com/letsencrypt/letsencrypt/issues?q=is:issue+milestone:0.4.1
|
||||
|
||||
## 0.4.0 - 2016-02-10
|
||||
|
||||
### Added
|
||||
|
||||
* The new verb/subcommand `renew` can be used to renew your existing
|
||||
certificates as they approach expiration. Running `letsencrypt renew`
|
||||
will examine all existing certificate lineages and determine if any are
|
||||
less than 30 days from expiration. If so, the client will use the
|
||||
settings provided when you previously obtained the certificate to renew
|
||||
it. The subcommand finishes by printing a summary of which renewals were
|
||||
successful, failed, or not yet due.
|
||||
* A `--dry-run` flag has been added to help with testing configuration
|
||||
without affecting production rate limits. Currently supported by the
|
||||
`renew` and `certonly` subcommands, providing `--dry-run` on the command
|
||||
line will obtain certificates from the staging server without saving the
|
||||
resulting certificates to disk.
|
||||
* Major improvements have been added to letsencrypt-auto. This script
|
||||
has been rewritten to include full support for Python 2.6, the ability
|
||||
for letsencrypt-auto to update itself, and improvements to the
|
||||
stability, security, and performance of the script.
|
||||
* Support for Apache 2.2 has been added to the Apache plugin.
|
||||
|
||||
More details about these changes can be found on our GitHub repo:
|
||||
https://github.com/letsencrypt/letsencrypt/issues?q=is%3Aissue+milestone%3A0.4.0
|
||||
|
||||
## 0.3.0 - 2016-01-27
|
||||
|
||||
### Added
|
||||
|
||||
* Add a non-interactive mode which can be enabled by including `-n` or
|
||||
`--non-interactive` on the command line. This can be used to guarantee
|
||||
the client will not prompt when run automatically using cron/systemd.
|
||||
* Preparation for the new letsencrypt-auto script. Over the past
|
||||
couple months, we've been working on increasing the reliability and
|
||||
security of letsencrypt-auto. A number of changes landed in this
|
||||
release to prepare for the new version of this script.
|
||||
|
||||
More details about these changes can be found on our GitHub repo:
|
||||
https://github.com/letsencrypt/letsencrypt/issues?q=is%3Aissue+milestone%3A0.3.0
|
||||
|
||||
## 0.2.0 - 2016-01-14
|
||||
|
||||
### Added
|
||||
|
||||
* Apache plugin support for non-Debian based systems. Support has been
|
||||
added for modern Red Hat based systems such as Fedora 23, Red Hat 7,
|
||||
and CentOS 7 running Apache 2.4. In theory, this plugin should be
|
||||
able to be configured to run on any Unix-like OS running Apache 2.4.
|
||||
* Relaxed PyOpenSSL version requirements. This adds support for systems
|
||||
with PyOpenSSL versions 0.13 or 0.14.
|
||||
* Improved error messages from the client.
|
||||
|
||||
### Fixed
|
||||
|
||||
* Resolves issues with the Apache plugin enabling an HTTP to HTTPS
|
||||
redirect on some systems.
|
||||
|
||||
More details about these changes can be found on our GitHub repo:
|
||||
https://github.com/letsencrypt/letsencrypt/issues?q=is%3Aissue+milestone%3A0.2.0
|
||||
|
||||
## 0.1.1 - 2015-12-15
|
||||
|
||||
### Added
|
||||
|
||||
* Avoids attempting to issue for unqualified domain names like
|
||||
"localhost"
|
||||
|
||||
### Fixed
|
||||
|
||||
* Fix a confusing UI path that caused some users to repeatedly renew
|
||||
their certs while experimenting with the client, in some cases hitting
|
||||
issuance rate limits
|
||||
* Fix numerous Apache configuration parser problems
|
||||
* Fix --webroot permission handling for non-root users
|
||||
|
||||
More details about these changes can be found on our GitHub repo:
|
||||
https://github.com/letsencrypt/letsencrypt/issues?q=milestone%3A0.1.1
|
||||
8
CHANGES.rst
Normal file
8
CHANGES.rst
Normal file
@@ -0,0 +1,8 @@
|
||||
ChangeLog
|
||||
=========
|
||||
|
||||
To see the changes in a given release, view the issues closed in a given
|
||||
release's GitHub milestone:
|
||||
|
||||
- `Past releases <https://github.com/certbot/certbot/milestones?state=closed>`_
|
||||
- `Upcoming releases <https://github.com/certbot/certbot/milestones>`_
|
||||
@@ -1 +0,0 @@
|
||||
This project is governed by [EFF's Public Projects Code of Conduct](https://www.eff.org/pages/eppcode).
|
||||
@@ -11,7 +11,7 @@ to the Sphinx generated docs is provided below.
|
||||
|
||||
|
||||
[1] https://github.com/blog/1184-contributing-guidelines
|
||||
[2] https://docutils.sourceforge.io/docs/user/rst/quickref.html#hyperlink-targets
|
||||
[2] http://docutils.sourceforge.net/docs/user/rst/quickref.html#hyperlink-targets
|
||||
|
||||
-->
|
||||
|
||||
@@ -33,5 +33,3 @@ started. In particular, we recommend you read these sections
|
||||
- [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)
|
||||
|
||||
|
||||
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
FROM python:2-alpine
|
||||
|
||||
ENTRYPOINT [ "certbot" ]
|
||||
EXPOSE 80 443
|
||||
VOLUME /etc/letsencrypt /var/lib/letsencrypt
|
||||
WORKDIR /opt/certbot
|
||||
|
||||
COPY CHANGES.rst README.rst setup.py src/
|
||||
COPY acme src/acme
|
||||
COPY certbot src/certbot
|
||||
|
||||
RUN apk add --no-cache --virtual .certbot-deps \
|
||||
libffi \
|
||||
libssl1.0 \
|
||||
ca-certificates \
|
||||
binutils
|
||||
RUN apk add --no-cache --virtual .build-deps \
|
||||
gcc \
|
||||
linux-headers \
|
||||
openssl-dev \
|
||||
musl-dev \
|
||||
libffi-dev \
|
||||
&& pip install --no-cache-dir \
|
||||
--editable /opt/certbot/src/acme \
|
||||
--editable /opt/certbot/src \
|
||||
&& apk del .build-deps
|
||||
@@ -1,20 +1,70 @@
|
||||
# This Dockerfile builds an image for development.
|
||||
FROM debian:buster
|
||||
FROM ubuntu:trusty
|
||||
MAINTAINER Jakub Warmuz <jakub@warmuz.org>
|
||||
MAINTAINER William Budington <bill@eff.org>
|
||||
MAINTAINER Yan <yan@eff.org>
|
||||
|
||||
# Note: this only exposes the port to other docker containers.
|
||||
EXPOSE 80 443
|
||||
# Note: this only exposes the port to other docker containers. You
|
||||
# still have to bind to 443@host at runtime, as per the ACME spec.
|
||||
EXPOSE 443
|
||||
|
||||
# TODO: make sure --config-dir and --work-dir cannot be changed
|
||||
# through the CLI (certbot-docker wrapper that uses standalone
|
||||
# authenticator and text mode only?)
|
||||
VOLUME /etc/letsencrypt /var/lib/letsencrypt
|
||||
|
||||
WORKDIR /opt/certbot/src
|
||||
|
||||
COPY . .
|
||||
RUN apt-get update && \
|
||||
apt-get install apache2 git python3-dev python3-venv gcc libaugeas0 \
|
||||
libssl-dev libffi-dev ca-certificates openssl nginx-light -y && \
|
||||
# no need to mkdir anything:
|
||||
# https://docs.docker.com/reference/builder/#copy
|
||||
# If <dest> doesn't exist, it is created along with all missing
|
||||
# directories in its path.
|
||||
|
||||
# TODO: Install Apache/Nginx for plugin development.
|
||||
COPY letsencrypt-auto-source/letsencrypt-auto /opt/certbot/src/letsencrypt-auto-source/letsencrypt-auto
|
||||
RUN /opt/certbot/src/letsencrypt-auto-source/letsencrypt-auto --os-packages-only && \
|
||||
apt-get install python3-dev -y && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* \
|
||||
/tmp/* \
|
||||
/var/tmp/*
|
||||
|
||||
RUN VENV_NAME="../venv" python3 tools/venv.py
|
||||
# the above is not likely to change, so by putting it further up the
|
||||
# Dockerfile we make sure we cache as much as possible
|
||||
|
||||
COPY setup.py README.rst CHANGES.rst MANIFEST.in linter_plugin.py tox.cover.sh tox.ini .pylintrc /opt/certbot/src/
|
||||
|
||||
# all above files are necessary for setup.py, however, package source
|
||||
# code directory has to be copied separately to a subdirectory...
|
||||
# https://docs.docker.com/reference/builder/#copy: "If <src> is a
|
||||
# directory, the entire contents of the directory are copied,
|
||||
# including filesystem metadata. Note: The directory itself is not
|
||||
# copied, just its contents." Order again matters, three files are far
|
||||
# more likely to be cached than the whole project directory
|
||||
|
||||
COPY certbot /opt/certbot/src/certbot/
|
||||
COPY acme /opt/certbot/src/acme/
|
||||
COPY certbot-apache /opt/certbot/src/certbot-apache/
|
||||
COPY certbot-nginx /opt/certbot/src/certbot-nginx/
|
||||
COPY letshelp-certbot /opt/certbot/src/letshelp-certbot/
|
||||
COPY certbot-compatibility-test /opt/certbot/src/certbot-compatibility-test/
|
||||
COPY tests /opt/certbot/src/tests/
|
||||
|
||||
RUN virtualenv --no-site-packages -p python2 /opt/certbot/venv && \
|
||||
/opt/certbot/venv/bin/pip install -U pip && \
|
||||
/opt/certbot/venv/bin/pip install -U setuptools && \
|
||||
/opt/certbot/venv/bin/pip install \
|
||||
-e /opt/certbot/src/acme \
|
||||
-e /opt/certbot/src \
|
||||
-e /opt/certbot/src/certbot-apache \
|
||||
-e /opt/certbot/src/certbot-nginx \
|
||||
-e /opt/certbot/src/letshelp-certbot \
|
||||
-e /opt/certbot/src/certbot-compatibility-test \
|
||||
-e /opt/certbot/src[dev,docs]
|
||||
|
||||
# install in editable mode (-e) to save space: it's not possible to
|
||||
# "rm -rf /opt/certbot/src" (it's stays in the underlaying image);
|
||||
# this might also help in debugging: you can "docker run --entrypoint
|
||||
# bash" and investigate, apply patches, etc.
|
||||
|
||||
ENV PATH /opt/certbot/venv/bin:$PATH
|
||||
|
||||
75
Dockerfile-old
Normal file
75
Dockerfile-old
Normal file
@@ -0,0 +1,75 @@
|
||||
# https://github.com/letsencrypt/letsencrypt/pull/431#issuecomment-103659297
|
||||
# it is more likely developers will already have ubuntu:trusty rather
|
||||
# than e.g. debian:jessie and image size differences are negligible
|
||||
FROM ubuntu:trusty
|
||||
MAINTAINER Jakub Warmuz <jakub@warmuz.org>
|
||||
MAINTAINER William Budington <bill@eff.org>
|
||||
|
||||
# Note: this only exposes the port to other docker containers. You
|
||||
# still have to bind to 443@host at runtime, as per the ACME spec.
|
||||
EXPOSE 443
|
||||
|
||||
# TODO: make sure --config-dir and --work-dir cannot be changed
|
||||
# through the CLI (certbot-docker wrapper that uses standalone
|
||||
# authenticator and text mode only?)
|
||||
VOLUME /etc/letsencrypt /var/lib/letsencrypt
|
||||
|
||||
WORKDIR /opt/certbot
|
||||
|
||||
# no need to mkdir anything:
|
||||
# https://docs.docker.com/reference/builder/#copy
|
||||
# If <dest> doesn't exist, it is created along with all missing
|
||||
# directories in its path.
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
COPY letsencrypt-auto-source/letsencrypt-auto /opt/certbot/src/letsencrypt-auto-source/letsencrypt-auto
|
||||
RUN /opt/certbot/src/letsencrypt-auto-source/letsencrypt-auto --os-packages-only && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* \
|
||||
/tmp/* \
|
||||
/var/tmp/*
|
||||
|
||||
# the above is not likely to change, so by putting it further up the
|
||||
# Dockerfile we make sure we cache as much as possible
|
||||
|
||||
|
||||
COPY setup.py README.rst CHANGES.rst MANIFEST.in letsencrypt-auto-source/pieces/pipstrap.py /opt/certbot/src/
|
||||
|
||||
# all above files are necessary for setup.py and venv setup, however,
|
||||
# package source code directory has to be copied separately to a
|
||||
# subdirectory...
|
||||
# https://docs.docker.com/reference/builder/#copy: "If <src> is a
|
||||
# directory, the entire contents of the directory are copied,
|
||||
# including filesystem metadata. Note: The directory itself is not
|
||||
# copied, just its contents." Order again matters, three files are far
|
||||
# more likely to be cached than the whole project directory
|
||||
|
||||
COPY certbot /opt/certbot/src/certbot/
|
||||
COPY acme /opt/certbot/src/acme/
|
||||
COPY certbot-apache /opt/certbot/src/certbot-apache/
|
||||
COPY certbot-nginx /opt/certbot/src/certbot-nginx/
|
||||
|
||||
|
||||
RUN virtualenv --no-site-packages -p python2 /opt/certbot/venv
|
||||
|
||||
# PATH is set now so pipstrap upgrades the correct (v)env
|
||||
ENV PATH /opt/certbot/venv/bin:$PATH
|
||||
RUN /opt/certbot/venv/bin/python /opt/certbot/src/pipstrap.py && \
|
||||
/opt/certbot/venv/bin/pip install \
|
||||
-e /opt/certbot/src/acme \
|
||||
-e /opt/certbot/src \
|
||||
-e /opt/certbot/src/certbot-apache \
|
||||
-e /opt/certbot/src/certbot-nginx
|
||||
|
||||
# install in editable mode (-e) to save space: it's not possible to
|
||||
# "rm -rf /opt/certbot/src" (it's stays in the underlaying image);
|
||||
# this might also help in debugging: you can "docker run --entrypoint
|
||||
# bash" and investigate, apply patches, etc.
|
||||
|
||||
# set up certbot/letsencrypt wrapper to warn people about Dockerfile changes
|
||||
COPY tools/docker-warning.sh /opt/certbot/bin/certbot
|
||||
RUN ln -s /opt/certbot/bin/certbot /opt/certbot/bin/letsencrypt
|
||||
ENV PATH /opt/certbot/bin:$PATH
|
||||
|
||||
ENTRYPOINT [ "certbot" ]
|
||||
@@ -1,13 +1,7 @@
|
||||
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 installed Certbot with (certbot-auto, OS package manager, pip, etc):
|
||||
|
||||
|
||||
## I ran this command and it produced this output:
|
||||
@@ -18,5 +12,3 @@ questions.
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
include README.rst
|
||||
include CHANGELOG.md
|
||||
include CHANGES.rst
|
||||
include CONTRIBUTING.md
|
||||
include LICENSE.txt
|
||||
include linter_plugin.py
|
||||
recursive-include docs *
|
||||
recursive-include examples *
|
||||
recursive-include certbot/tests/testdata *
|
||||
recursive-include tests *.py
|
||||
include certbot/ssl-dhparams.pem
|
||||
global-exclude __pycache__
|
||||
global-exclude *.py[cod]
|
||||
@@ -1 +0,0 @@
|
||||
certbot/README.rst
|
||||
166
README.rst
Normal file
166
README.rst
Normal file
@@ -0,0 +1,166 @@
|
||||
.. This file contains a series of comments that are used to include sections of this README in other files. Do not modify these comments unless you know what you are doing. tag:intro-begin
|
||||
|
||||
Certbot is part of EFF’s effort to encrypt the entire Internet. Secure communication over the Web relies on HTTPS, which requires the use of a digital certificate that lets browsers verify the identify of web servers (e.g., is that really google.com?). Web servers obtain their certificates from trusted third parties called certificate authorities (CAs). Certbot is an easy-to-use client that fetches a certificate from Let’s Encrypt—an open certificate authority launched by the EFF, Mozilla, and others—and deploys it to a web server.
|
||||
|
||||
Anyone who has gone through the trouble of setting up a secure website knows what a hassle getting and maintaining a certificate is. Certbot and Let’s Encrypt can automate away the pain and let you turn on and manage HTTPS with simple commands. Using Certbot and Let's Encrypt is free, so there’s no need to arrange payment.
|
||||
|
||||
How you use Certbot depends on the configuration of your web server. The best way to get started is to use our `interactive guide <https://certbot.eff.org>`_. It generates instructions based on your configuration settings. In most cases, you’ll need `root or administrator access <https://certbot.eff.org/faq/#does-certbot-require-root-privileges>`_ to your web server to run Certbot.
|
||||
|
||||
If you’re using a hosted service and don’t have direct access to your web server, you might not be able to use Certbot. Check with your hosting provider for documentation about uploading certificates or using certificates issues by Let’s Encrypt.
|
||||
|
||||
Certbot is a fully-featured, extensible client for the Let's
|
||||
Encrypt CA (or any other CA that speaks the `ACME
|
||||
<https://github.com/ietf-wg-acme/acme/blob/master/draft-ietf-acme-acme.md>`_
|
||||
protocol) that can automate the tasks of obtaining certificates and
|
||||
configuring webservers to use them. This client runs on Unix-based operating
|
||||
systems.
|
||||
|
||||
Until May 2016, Certbot was named simply ``letsencrypt`` or ``letsencrypt-auto``,
|
||||
depending on install method. Instructions on the Internet, and some pieces of the
|
||||
software, may still refer to this older name.
|
||||
|
||||
Contributing
|
||||
------------
|
||||
|
||||
If you'd like to contribute to this project please read `Developer Guide
|
||||
<https://certbot.eff.org/docs/contributing.html>`_.
|
||||
|
||||
.. _installation:
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
The easiest way to install Certbot is by visiting `certbot.eff.org`_, where you can
|
||||
find the correct installation instructions for many web server and OS combinations.
|
||||
For more information, see `Get Certbot <https://certbot.eff.org/docs/install.html>`_.
|
||||
|
||||
.. _certbot.eff.org: https://certbot.eff.org/
|
||||
|
||||
How to run the client
|
||||
---------------------
|
||||
|
||||
In many cases, you can just run ``certbot-auto`` or ``certbot``, and the
|
||||
client will guide you through the process of obtaining and installing certs
|
||||
interactively.
|
||||
|
||||
For full command line help, you can type::
|
||||
|
||||
./certbot-auto --help all
|
||||
|
||||
|
||||
You can also tell it exactly what you want it to do from the command line.
|
||||
For instance, if you want to obtain a cert for ``example.com``,
|
||||
``www.example.com``, and ``other.example.net``, using the Apache plugin to both
|
||||
obtain and install the certs, you could do this::
|
||||
|
||||
./certbot-auto --apache -d example.com -d www.example.com -d other.example.net
|
||||
|
||||
(The first time you run the command, it will make an account, and ask for an
|
||||
email and agreement to the Let's Encrypt Subscriber Agreement; you can
|
||||
automate those with ``--email`` and ``--agree-tos``)
|
||||
|
||||
If you want to use a webserver that doesn't have full plugin support yet, you
|
||||
can still use "standalone" or "webroot" plugins to obtain a certificate::
|
||||
|
||||
./certbot-auto certonly --standalone --email admin@example.com -d example.com -d www.example.com -d other.example.net
|
||||
|
||||
|
||||
Understanding the client in more depth
|
||||
--------------------------------------
|
||||
|
||||
To understand what the client is doing in detail, it's important to
|
||||
understand the way it uses plugins. Please see the `explanation of
|
||||
plugins <https://certbot.eff.org/docs/using.html#plugins>`_ in
|
||||
the User Guide.
|
||||
|
||||
Links
|
||||
=====
|
||||
|
||||
.. Do not modify this comment unless you know what you're doing. tag:links-begin
|
||||
|
||||
Documentation: https://certbot.eff.org/docs
|
||||
|
||||
Software project: https://github.com/certbot/certbot
|
||||
|
||||
Notes for developers: https://certbot.eff.org/docs/contributing.html
|
||||
|
||||
Main Website: https://certbot.eff.org
|
||||
|
||||
Let's Encrypt Website: https://letsencrypt.org
|
||||
|
||||
IRC Channel: #letsencrypt on `Freenode`_
|
||||
|
||||
Community: https://community.letsencrypt.org
|
||||
|
||||
ACME spec: http://ietf-wg-acme.github.io/acme/
|
||||
|
||||
ACME working area in github: https://github.com/ietf-wg-acme/acme
|
||||
|
||||
|
||||
Mailing list: `client-dev`_ (to subscribe without a Google account, send an
|
||||
email to client-dev+subscribe@letsencrypt.org)
|
||||
|
||||
|build-status| |coverage| |docs| |container|
|
||||
|
||||
.. _Freenode: https://webchat.freenode.net?channels=%23letsencrypt
|
||||
|
||||
.. _OFTC: https://webchat.oftc.net?channels=%23certbot
|
||||
|
||||
.. _client-dev: https://groups.google.com/a/letsencrypt.org/forum/#!forum/client-dev
|
||||
|
||||
.. |build-status| image:: https://travis-ci.org/certbot/certbot.svg?branch=master
|
||||
:target: https://travis-ci.org/certbot/certbot
|
||||
:alt: Travis CI status
|
||||
|
||||
.. |coverage| image:: https://coveralls.io/repos/certbot/certbot/badge.svg?branch=master
|
||||
:target: https://coveralls.io/r/certbot/certbot
|
||||
:alt: Coverage status
|
||||
|
||||
.. |docs| image:: https://readthedocs.org/projects/letsencrypt/badge/
|
||||
:target: https://readthedocs.org/projects/letsencrypt/
|
||||
:alt: Documentation status
|
||||
|
||||
.. |container| image:: https://quay.io/repository/letsencrypt/letsencrypt/status
|
||||
:target: https://quay.io/repository/letsencrypt/letsencrypt
|
||||
:alt: Docker Repository on Quay.io
|
||||
|
||||
.. Do not modify this comment unless you know what you're doing. tag:links-end
|
||||
|
||||
System Requirements
|
||||
===================
|
||||
|
||||
See https://certbot.eff.org/docs/install.html#system-requirements.
|
||||
|
||||
.. Do not modify this comment unless you know what you're doing. tag:intro-end
|
||||
|
||||
.. Do not modify this comment unless you know what you're doing. tag:features-begin
|
||||
|
||||
Current Features
|
||||
=====================
|
||||
|
||||
* Supports multiple web servers:
|
||||
|
||||
- apache/2.x (beta support for auto-configuration)
|
||||
- nginx/0.8.48+ (alpha support for auto-configuration)
|
||||
- webroot (adds files to webroot directories in order to prove control of
|
||||
domains and obtain certs)
|
||||
- standalone (runs its own simple webserver to prove you control a domain)
|
||||
- other server software via `third party plugins <https://certbot.eff.org/docs/using.html#third-party-plugins>`_
|
||||
|
||||
* The private key is generated locally on your system.
|
||||
* Can talk to the Let's Encrypt CA or optionally to other ACME
|
||||
compliant services.
|
||||
* Can get domain-validated (DV) certificates.
|
||||
* Can revoke certificates.
|
||||
* Adjustable RSA key bit-length (2048 (default), 4096, ...).
|
||||
* Can optionally install a http -> https redirect, so your site effectively
|
||||
runs https only (Apache only)
|
||||
* Fully automated.
|
||||
* Configuration changes are logged and can be reverted.
|
||||
* Supports ncurses and text (-t) UI, or can be driven entirely from the
|
||||
command line.
|
||||
* Free and Open Source Software, made with Python.
|
||||
|
||||
.. Do not modify this comment unless you know what you're doing. tag:features-end
|
||||
|
||||
For extensive documentation on using and contributing to Certbot, go to https://certbot.eff.org/docs. If you would like to contribute to the project or run the latest code from git, you should read our `developer guide <https://certbot.eff.org/docs/contributing.html>`_.
|
||||
@@ -1,8 +1,5 @@
|
||||
include LICENSE.txt
|
||||
include README.rst
|
||||
include pytest.ini
|
||||
recursive-include docs *
|
||||
recursive-include examples *
|
||||
recursive-include tests *
|
||||
global-exclude __pycache__
|
||||
global-exclude *.py[cod]
|
||||
recursive-include acme/testdata *
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
"""ACME protocol implementation.
|
||||
|
||||
This module is an implementation of the `ACME protocol`_.
|
||||
This module is an implementation of the `ACME protocol`_. Latest
|
||||
supported version: `draft-ietf-acme-01`_.
|
||||
|
||||
|
||||
.. _`ACME protocol`: https://ietf-wg-acme.github.io/acme
|
||||
|
||||
.. _`draft-ietf-acme-01`:
|
||||
https://github.com/ietf-wg-acme/acme/tree/draft-ietf-acme-acme-01
|
||||
|
||||
"""
|
||||
import sys
|
||||
|
||||
# This code exists to keep backwards compatibility with people using acme.jose
|
||||
# before it became the standalone josepy package.
|
||||
#
|
||||
# It is based on
|
||||
# https://github.com/requests/requests/blob/1278ecdf71a312dc2268f3bfc0aabfab3c006dcf/requests/packages.py
|
||||
import josepy as jose
|
||||
|
||||
for mod in list(sys.modules):
|
||||
# This traversal is apparently necessary such that the identities are
|
||||
# preserved (acme.jose.* is josepy.*)
|
||||
if mod == 'josepy' or mod.startswith('josepy.'):
|
||||
sys.modules['acme.' + mod.replace('josepy', 'jose', 1)] = sys.modules[mod]
|
||||
|
||||
@@ -1,31 +1,29 @@
|
||||
"""ACME Identifier Validation Challenges."""
|
||||
import abc
|
||||
import codecs
|
||||
import functools
|
||||
import hashlib
|
||||
import logging
|
||||
import socket
|
||||
from typing import Type
|
||||
|
||||
from cryptography.hazmat.primitives import hashes # type: ignore
|
||||
import josepy as jose
|
||||
from OpenSSL import crypto
|
||||
from OpenSSL import SSL # type: ignore # https://github.com/python/typeshed/issues/2052
|
||||
import OpenSSL
|
||||
import requests
|
||||
|
||||
from acme import crypto_util
|
||||
from acme import errors
|
||||
from acme import crypto_util
|
||||
from acme import fields
|
||||
from acme.mixins import ResourceMixin
|
||||
from acme.mixins import TypeMixin
|
||||
from acme import jose
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
|
||||
|
||||
class Challenge(jose.TypedJSONObjectWithFields):
|
||||
# _fields_to_partial_json
|
||||
# _fields_to_partial_json | pylint: disable=abstract-method
|
||||
"""ACME challenge."""
|
||||
TYPES: dict = {}
|
||||
TYPES = {} # type: dict
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
@@ -36,10 +34,10 @@ class Challenge(jose.TypedJSONObjectWithFields):
|
||||
return UnrecognizedChallenge.from_json(jobj)
|
||||
|
||||
|
||||
class ChallengeResponse(ResourceMixin, TypeMixin, jose.TypedJSONObjectWithFields):
|
||||
# _fields_to_partial_json
|
||||
class ChallengeResponse(jose.TypedJSONObjectWithFields):
|
||||
# _fields_to_partial_json | pylint: disable=abstract-method
|
||||
"""ACME challenge response."""
|
||||
TYPES: dict = {}
|
||||
TYPES = {} # type: dict
|
||||
resource_type = 'challenge'
|
||||
resource = fields.Resource(resource_type)
|
||||
|
||||
@@ -62,7 +60,8 @@ class UnrecognizedChallenge(Challenge):
|
||||
object.__setattr__(self, "jobj", jobj)
|
||||
|
||||
def to_partial_json(self):
|
||||
return self.jobj # pylint: disable=no-member
|
||||
# pylint: disable=no-member
|
||||
return self.jobj
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
@@ -95,7 +94,6 @@ class _TokenChallenge(Challenge):
|
||||
"""
|
||||
# TODO: check that path combined with uri does not go above
|
||||
# URI_ROOT_PATH!
|
||||
# pylint: disable=unsupported-membership-test
|
||||
return b'..' not in self.token and b'/' not in self.token
|
||||
|
||||
|
||||
@@ -120,7 +118,7 @@ class KeyAuthorizationChallengeResponse(ChallengeResponse):
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
parts = self.key_authorization.split('.')
|
||||
parts = self.key_authorization.split('.') # pylint: disable=no-member
|
||||
if len(parts) != 2:
|
||||
logger.debug("Key authorization (%r) is not well formed",
|
||||
self.key_authorization)
|
||||
@@ -140,21 +138,18 @@ class KeyAuthorizationChallengeResponse(ChallengeResponse):
|
||||
|
||||
return True
|
||||
|
||||
def to_partial_json(self):
|
||||
jobj = super(KeyAuthorizationChallengeResponse, self).to_partial_json()
|
||||
jobj.pop('keyAuthorization', None)
|
||||
return jobj
|
||||
|
||||
|
||||
class KeyAuthorizationChallenge(_TokenChallenge, metaclass=abc.ABCMeta):
|
||||
class KeyAuthorizationChallenge(_TokenChallenge):
|
||||
# pylint: disable=abstract-class-little-used,too-many-ancestors
|
||||
"""Challenge based on Key Authorization.
|
||||
|
||||
:param response_cls: Subclass of `KeyAuthorizationChallengeResponse`
|
||||
that will be used to generate ``response``.
|
||||
:param str typ: type of the challenge
|
||||
that will be used to generate `response`.
|
||||
|
||||
"""
|
||||
typ: str = NotImplemented
|
||||
response_cls: Type[KeyAuthorizationChallengeResponse] = NotImplemented
|
||||
__metaclass__ = abc.ABCMeta
|
||||
|
||||
response_cls = NotImplemented
|
||||
thumbprint_hash_function = (
|
||||
KeyAuthorizationChallengeResponse.thumbprint_hash_function)
|
||||
|
||||
@@ -178,7 +173,7 @@ class KeyAuthorizationChallenge(_TokenChallenge, metaclass=abc.ABCMeta):
|
||||
:rtype: KeyAuthorizationChallengeResponse
|
||||
|
||||
"""
|
||||
return self.response_cls( # pylint: disable=not-callable
|
||||
return self.response_cls(
|
||||
key_authorization=self.key_authorization(account_key))
|
||||
|
||||
@abc.abstractmethod
|
||||
@@ -215,7 +210,7 @@ class DNS01Response(KeyAuthorizationChallengeResponse):
|
||||
"""ACME dns-01 challenge response."""
|
||||
typ = "dns-01"
|
||||
|
||||
def simple_verify(self, chall, domain, account_public_key): # pylint: disable=unused-argument
|
||||
def simple_verify(self, chall, domain, account_public_key):
|
||||
"""Simple verify.
|
||||
|
||||
This method no longer checks DNS records and is a simple wrapper
|
||||
@@ -231,13 +226,14 @@ class DNS01Response(KeyAuthorizationChallengeResponse):
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
# pylint: disable=unused-argument
|
||||
verified = self.verify(chall, account_public_key)
|
||||
if not verified:
|
||||
logger.debug("Verification of key authorization in response failed")
|
||||
return verified
|
||||
|
||||
|
||||
@Challenge.register
|
||||
@Challenge.register # pylint: disable=too-many-ancestors
|
||||
class DNS01(KeyAuthorizationChallenge):
|
||||
"""ACME dns-01 challenge."""
|
||||
response_cls = DNS01Response
|
||||
@@ -310,7 +306,7 @@ class HTTP01Response(KeyAuthorizationChallengeResponse):
|
||||
uri = chall.uri(domain)
|
||||
logger.debug("Verifying %s at %s...", chall.typ, uri)
|
||||
try:
|
||||
http_response = requests.get(uri, verify=False)
|
||||
http_response = requests.get(uri)
|
||||
except requests.exceptions.RequestException as error:
|
||||
logger.error("Unable to reach %s: %s", uri, error)
|
||||
return False
|
||||
@@ -327,7 +323,7 @@ class HTTP01Response(KeyAuthorizationChallengeResponse):
|
||||
return True
|
||||
|
||||
|
||||
@Challenge.register
|
||||
@Challenge.register # pylint: disable=too-many-ancestors
|
||||
class HTTP01(KeyAuthorizationChallenge):
|
||||
"""ACME http-01 challenge."""
|
||||
response_cls = HTTP01Response
|
||||
@@ -368,9 +364,12 @@ class HTTP01(KeyAuthorizationChallenge):
|
||||
|
||||
|
||||
@ChallengeResponse.register
|
||||
class TLSALPN01Response(KeyAuthorizationChallengeResponse):
|
||||
"""ACME tls-alpn-01 challenge response."""
|
||||
typ = "tls-alpn-01"
|
||||
class TLSSNI01Response(KeyAuthorizationChallengeResponse):
|
||||
"""ACME tls-sni-01 challenge response."""
|
||||
typ = "tls-sni-01"
|
||||
|
||||
DOMAIN_SUFFIX = b".acme.invalid"
|
||||
"""Domain name suffix."""
|
||||
|
||||
PORT = 443
|
||||
"""Verification port as defined by the protocol.
|
||||
@@ -380,18 +379,28 @@ class TLSALPN01Response(KeyAuthorizationChallengeResponse):
|
||||
|
||||
"""
|
||||
|
||||
ID_PE_ACME_IDENTIFIER_V1 = b"1.3.6.1.5.5.7.1.30.1"
|
||||
ACME_TLS_1_PROTOCOL = "acme-tls/1"
|
||||
@property
|
||||
def z(self): # pylint: disable=invalid-name
|
||||
"""``z`` value used for verification.
|
||||
|
||||
:rtype bytes:
|
||||
|
||||
"""
|
||||
return hashlib.sha256(
|
||||
self.key_authorization.encode("utf-8")).hexdigest().lower().encode()
|
||||
|
||||
@property
|
||||
def h(self):
|
||||
"""Hash value stored in challenge certificate"""
|
||||
return hashlib.sha256(self.key_authorization.encode('utf-8')).digest()
|
||||
def z_domain(self):
|
||||
"""Domain name used for verification, generated from `z`.
|
||||
|
||||
def gen_cert(self, domain, key=None, bits=2048):
|
||||
"""Generate tls-alpn-01 certificate.
|
||||
:rtype bytes:
|
||||
|
||||
"""
|
||||
return self.z[:32] + b'.' + self.z[32:] + self.DOMAIN_SUFFIX
|
||||
|
||||
def gen_cert(self, key=None, bits=2048):
|
||||
"""Generate tls-sni-01 certificate.
|
||||
|
||||
:param unicode domain: Domain verified by the challenge.
|
||||
:param OpenSSL.crypto.PKey key: Optional private key used in
|
||||
certificate generation. If not provided (``None``), then
|
||||
fresh key will be generated.
|
||||
@@ -401,38 +410,33 @@ class TLSALPN01Response(KeyAuthorizationChallengeResponse):
|
||||
|
||||
"""
|
||||
if key is None:
|
||||
key = crypto.PKey()
|
||||
key.generate_key(crypto.TYPE_RSA, bits)
|
||||
key = OpenSSL.crypto.PKey()
|
||||
key.generate_key(OpenSSL.crypto.TYPE_RSA, bits)
|
||||
return crypto_util.gen_ss_cert(key, [
|
||||
# z_domain is too big to fit into CN, hence first dummy domain
|
||||
'dummy', self.z_domain.decode()], force_san=True), key
|
||||
|
||||
def probe_cert(self, domain, **kwargs):
|
||||
"""Probe tls-sni-01 challenge certificate.
|
||||
|
||||
der_value = b"DER:" + codecs.encode(self.h, 'hex')
|
||||
acme_extension = crypto.X509Extension(self.ID_PE_ACME_IDENTIFIER_V1,
|
||||
critical=True, value=der_value)
|
||||
|
||||
return crypto_util.gen_ss_cert(key, [domain], force_san=True,
|
||||
extensions=[acme_extension]), key
|
||||
|
||||
def probe_cert(self, domain, host=None, port=None):
|
||||
"""Probe tls-alpn-01 challenge certificate.
|
||||
|
||||
:param unicode domain: domain being validated, required.
|
||||
:param string host: IP address used to probe the certificate.
|
||||
:param int port: Port used to probe the certificate.
|
||||
:param unicode domain:
|
||||
|
||||
"""
|
||||
if host is None:
|
||||
# TODO: domain is not necessary if host is provided
|
||||
if "host" not in kwargs:
|
||||
host = socket.gethostbyname(domain)
|
||||
logger.debug('%s resolved to %s', domain, host)
|
||||
if port is None:
|
||||
port = self.PORT
|
||||
kwargs["host"] = host
|
||||
|
||||
return crypto_util.probe_sni(host=host, port=port, name=domain,
|
||||
alpn_protocols=[self.ACME_TLS_1_PROTOCOL])
|
||||
kwargs.setdefault("port", self.PORT)
|
||||
kwargs["name"] = self.z_domain
|
||||
# TODO: try different methods?
|
||||
# pylint: disable=protected-access
|
||||
return crypto_util.probe_sni(**kwargs)
|
||||
|
||||
def verify_cert(self, domain, cert):
|
||||
"""Verify tls-alpn-01 challenge certificate.
|
||||
def verify_cert(self, cert):
|
||||
"""Verify tls-sni-01 challenge certificate.
|
||||
|
||||
:param unicode domain: Domain name being validated.
|
||||
:param OpensSSL.crypto.X509 cert: Challenge certificate.
|
||||
|
||||
:returns: Whether the certificate was successfully verified.
|
||||
@@ -440,40 +444,28 @@ class TLSALPN01Response(KeyAuthorizationChallengeResponse):
|
||||
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
names = crypto_util._pyopenssl_cert_or_req_all_names(cert)
|
||||
logger.debug('Certificate %s. SANs: %s', cert.digest('sha256'), names)
|
||||
if len(names) != 1 or names[0].lower() != domain.lower():
|
||||
return False
|
||||
sans = crypto_util._pyopenssl_cert_or_req_san(cert)
|
||||
logger.debug('Certificate %s. SANs: %s', cert.digest('sha256'), sans)
|
||||
return self.z_domain.decode() in sans
|
||||
|
||||
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
|
||||
|
||||
return False
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def simple_verify(self, chall, domain, account_public_key,
|
||||
cert=None, host=None, port=None):
|
||||
cert=None, **kwargs):
|
||||
"""Simple verify.
|
||||
|
||||
Verify ``validation`` using ``account_public_key``, optionally
|
||||
probe tls-alpn-01 certificate and check using `verify_cert`.
|
||||
probe tls-sni-01 certificate and check using `verify_cert`.
|
||||
|
||||
:param .challenges.TLSALPN01 chall: Corresponding challenge.
|
||||
:param .challenges.TLSSNI01 chall: Corresponding challenge.
|
||||
:param str domain: Domain name being validated.
|
||||
:param JWK account_public_key:
|
||||
:param OpenSSL.crypto.X509 cert: Optional certificate. If not
|
||||
provided (``None``) certificate will be retrieved using
|
||||
`probe_cert`.
|
||||
:param string host: IP address used to probe the certificate.
|
||||
:param int port: Port used to probe the certificate.
|
||||
|
||||
|
||||
:returns: ``True`` if and only if client's control of the domain has been verified.
|
||||
:returns: ``True`` iff client's control of the domain has been
|
||||
verified.
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
@@ -483,25 +475,27 @@ class TLSALPN01Response(KeyAuthorizationChallengeResponse):
|
||||
|
||||
if cert is None:
|
||||
try:
|
||||
cert = self.probe_cert(domain=domain, host=host, port=port)
|
||||
cert = self.probe_cert(domain=domain, **kwargs)
|
||||
except errors.Error as error:
|
||||
logger.debug(str(error), exc_info=True)
|
||||
logger.debug(error, exc_info=True)
|
||||
return False
|
||||
|
||||
return self.verify_cert(domain, cert)
|
||||
return self.verify_cert(cert)
|
||||
|
||||
|
||||
@Challenge.register # pylint: disable=too-many-ancestors
|
||||
class TLSALPN01(KeyAuthorizationChallenge):
|
||||
"""ACME tls-alpn-01 challenge."""
|
||||
response_cls = TLSALPN01Response
|
||||
class TLSSNI01(KeyAuthorizationChallenge):
|
||||
"""ACME tls-sni-01 challenge."""
|
||||
response_cls = TLSSNI01Response
|
||||
typ = response_cls.typ
|
||||
|
||||
# boulder#962, ietf-wg-acme#22
|
||||
#n = jose.Field("n", encoder=int, decoder=int)
|
||||
|
||||
def validation(self, account_key, **kwargs):
|
||||
"""Generate validation.
|
||||
|
||||
:param JWK account_key:
|
||||
:param unicode domain: Domain verified by the challenge.
|
||||
:param OpenSSL.crypto.PKey cert_key: Optional private key used
|
||||
in certificate generation. If not provided (``None``), then
|
||||
fresh key will be generated.
|
||||
@@ -509,26 +503,10 @@ class TLSALPN01(KeyAuthorizationChallenge):
|
||||
:rtype: `tuple` of `OpenSSL.crypto.X509` and `OpenSSL.crypto.PKey`
|
||||
|
||||
"""
|
||||
return self.response(account_key).gen_cert(
|
||||
key=kwargs.get('cert_key'),
|
||||
domain=kwargs.get('domain'))
|
||||
|
||||
@staticmethod
|
||||
def is_supported():
|
||||
"""
|
||||
Check if TLS-ALPN-01 challenge is supported on this machine.
|
||||
This implies that a recent version of OpenSSL is installed (>= 1.0.2),
|
||||
or a recent cryptography version shipped with the OpenSSL library is installed.
|
||||
|
||||
:returns: ``True`` if TLS-ALPN-01 is supported on this machine, ``False`` otherwise.
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
return (hasattr(SSL.Connection, "set_alpn_protos")
|
||||
and hasattr(SSL.Context, "set_alpn_select_callback"))
|
||||
return self.response(account_key).gen_cert(key=kwargs.get('cert_key'))
|
||||
|
||||
|
||||
@Challenge.register
|
||||
@Challenge.register # pylint: disable=too-many-ancestors
|
||||
class DNS(_TokenChallenge):
|
||||
"""ACME "dns" challenge."""
|
||||
typ = "dns"
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"""Tests for acme.challenges."""
|
||||
import urllib.parse as urllib_parse
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import josepy as jose
|
||||
import mock
|
||||
import OpenSSL
|
||||
import requests
|
||||
|
||||
from acme import errors
|
||||
from six.moves.urllib import parse as urllib_parse # pylint: disable=import-error
|
||||
|
||||
import test_util
|
||||
from acme import errors
|
||||
from acme import jose
|
||||
from acme import test_util
|
||||
|
||||
CERT = test_util.load_comparable_cert('cert.pem')
|
||||
KEY = jose.JWKRSA(key=test_util.load_rsa_private_key('rsa512_key.pem'))
|
||||
@@ -21,6 +21,7 @@ class ChallengeTest(unittest.TestCase):
|
||||
from acme.challenges import Challenge
|
||||
from acme.challenges import UnrecognizedChallenge
|
||||
chall = UnrecognizedChallenge({"type": "foo"})
|
||||
# pylint: disable=no-member
|
||||
self.assertEqual(chall, Challenge.from_json(chall.jobj))
|
||||
|
||||
|
||||
@@ -76,6 +77,7 @@ class KeyAuthorizationChallengeResponseTest(unittest.TestCase):
|
||||
|
||||
|
||||
class DNS01ResponseTest(unittest.TestCase):
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
|
||||
def setUp(self):
|
||||
from acme.challenges import DNS01Response
|
||||
@@ -91,8 +93,7 @@ class DNS01ResponseTest(unittest.TestCase):
|
||||
self.response = self.chall.response(KEY)
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual({k: v for k, v in self.jmsg.items() if k != 'keyAuthorization'},
|
||||
self.msg.to_partial_json())
|
||||
self.assertEqual(self.jmsg, self.msg.to_partial_json())
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.challenges import DNS01Response
|
||||
@@ -147,6 +148,7 @@ class DNS01Test(unittest.TestCase):
|
||||
|
||||
|
||||
class HTTP01ResponseTest(unittest.TestCase):
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
|
||||
def setUp(self):
|
||||
from acme.challenges import HTTP01Response
|
||||
@@ -162,8 +164,7 @@ class HTTP01ResponseTest(unittest.TestCase):
|
||||
self.response = self.chall.response(KEY)
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual({k: v for k, v in self.jmsg.items() if k != 'keyAuthorization'},
|
||||
self.msg.to_partial_json())
|
||||
self.assertEqual(self.jmsg, self.msg.to_partial_json())
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.challenges import HTTP01Response
|
||||
@@ -184,7 +185,7 @@ class HTTP01ResponseTest(unittest.TestCase):
|
||||
mock_get.return_value = mock.MagicMock(text=validation)
|
||||
self.assertTrue(self.response.simple_verify(
|
||||
self.chall, "local", KEY.public_key()))
|
||||
mock_get.assert_called_once_with(self.chall.uri("local"), verify=False)
|
||||
mock_get.assert_called_once_with(self.chall.uri("local"))
|
||||
|
||||
@mock.patch("acme.challenges.requests.get")
|
||||
def test_simple_verify_bad_validation(self, mock_get):
|
||||
@@ -200,7 +201,7 @@ class HTTP01ResponseTest(unittest.TestCase):
|
||||
HTTP01Response.WHITESPACE_CUTSET))
|
||||
self.assertTrue(self.response.simple_verify(
|
||||
self.chall, "local", KEY.public_key()))
|
||||
mock_get.assert_called_once_with(self.chall.uri("local"), verify=False)
|
||||
mock_get.assert_called_once_with(self.chall.uri("local"))
|
||||
|
||||
@mock.patch("acme.challenges.requests.get")
|
||||
def test_simple_verify_connection_error(self, mock_get):
|
||||
@@ -256,68 +257,42 @@ class HTTP01Test(unittest.TestCase):
|
||||
self.msg.update(token=b'..').good_token)
|
||||
|
||||
|
||||
class TLSALPN01ResponseTest(unittest.TestCase):
|
||||
class TLSSNI01ResponseTest(unittest.TestCase):
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
|
||||
def setUp(self):
|
||||
from acme.challenges import TLSALPN01
|
||||
self.chall = TLSALPN01(
|
||||
from acme.challenges import TLSSNI01
|
||||
self.chall = TLSSNI01(
|
||||
token=jose.b64decode(b'a82d5ff8ef740d12881f6d3c2277ab2e'))
|
||||
self.domain = u'example.com'
|
||||
self.domain2 = u'example2.com'
|
||||
|
||||
self.response = self.chall.response(KEY)
|
||||
self.jmsg = {
|
||||
'resource': 'challenge',
|
||||
'type': 'tls-alpn-01',
|
||||
'type': 'tls-sni-01',
|
||||
'keyAuthorization': self.response.key_authorization,
|
||||
}
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
label1 = b'dc38d9c3fa1a4fdcc3a5501f2d38583f'
|
||||
label2 = b'b7793728f084394f2a1afd459556bb5c'
|
||||
self.z = label1 + label2
|
||||
self.z_domain = label1 + b'.' + label2 + b'.acme.invalid'
|
||||
self.domain = 'foo.com'
|
||||
|
||||
def test_z_and_domain(self):
|
||||
self.assertEqual(self.z, self.response.z)
|
||||
self.assertEqual(self.z_domain, self.response.z_domain)
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual({k: v for k, v in self.jmsg.items() if k != 'keyAuthorization'},
|
||||
self.response.to_partial_json())
|
||||
self.assertEqual(self.jmsg, self.response.to_partial_json())
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.challenges import TLSALPN01Response
|
||||
self.assertEqual(self.response, TLSALPN01Response.from_json(self.jmsg))
|
||||
from acme.challenges import TLSSNI01Response
|
||||
self.assertEqual(self.response, TLSSNI01Response.from_json(self.jmsg))
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.challenges import TLSALPN01Response
|
||||
hash(TLSALPN01Response.from_json(self.jmsg))
|
||||
|
||||
def test_gen_verify_cert(self):
|
||||
key1 = test_util.load_pyopenssl_private_key('rsa512_key.pem')
|
||||
cert, key2 = self.response.gen_cert(self.domain, key1)
|
||||
self.assertEqual(key1, key2)
|
||||
self.assertTrue(self.response.verify_cert(self.domain, cert))
|
||||
|
||||
def test_gen_verify_cert_gen_key(self):
|
||||
cert, key = self.response.gen_cert(self.domain)
|
||||
self.assertTrue(isinstance(key, OpenSSL.crypto.PKey))
|
||||
self.assertTrue(self.response.verify_cert(self.domain, cert))
|
||||
|
||||
def test_verify_bad_cert(self):
|
||||
self.assertFalse(self.response.verify_cert(self.domain,
|
||||
test_util.load_cert('cert.pem')))
|
||||
|
||||
def test_verify_bad_domain(self):
|
||||
key1 = test_util.load_pyopenssl_private_key('rsa512_key.pem')
|
||||
cert, key2 = self.response.gen_cert(self.domain, key1)
|
||||
self.assertEqual(key1, key2)
|
||||
self.assertFalse(self.response.verify_cert(self.domain2, cert))
|
||||
|
||||
def test_simple_verify_bad_key_authorization(self):
|
||||
key2 = jose.JWKRSA.load(test_util.load_vector('rsa256_key.pem'))
|
||||
self.response.simple_verify(self.chall, "local", key2.public_key())
|
||||
|
||||
@mock.patch('acme.challenges.TLSALPN01Response.verify_cert', autospec=True)
|
||||
def test_simple_verify(self, mock_verify_cert):
|
||||
mock_verify_cert.return_value = mock.sentinel.verification
|
||||
self.assertEqual(
|
||||
mock.sentinel.verification, self.response.simple_verify(
|
||||
self.chall, self.domain, KEY.public_key(),
|
||||
cert=mock.sentinel.cert))
|
||||
mock_verify_cert.assert_called_once_with(
|
||||
self.response, self.domain, mock.sentinel.cert)
|
||||
from acme.challenges import TLSSNI01Response
|
||||
hash(TLSSNI01Response.from_json(self.jmsg))
|
||||
|
||||
@mock.patch('acme.challenges.socket.gethostbyname')
|
||||
@mock.patch('acme.challenges.crypto_util.probe_sni')
|
||||
@@ -326,29 +301,70 @@ class TLSALPN01ResponseTest(unittest.TestCase):
|
||||
self.response.probe_cert('foo.com')
|
||||
mock_gethostbyname.assert_called_once_with('foo.com')
|
||||
mock_probe_sni.assert_called_once_with(
|
||||
host='127.0.0.1', port=self.response.PORT, name='foo.com',
|
||||
alpn_protocols=['acme-tls/1'])
|
||||
host='127.0.0.1', port=self.response.PORT,
|
||||
name=self.z_domain)
|
||||
|
||||
self.response.probe_cert('foo.com', host='8.8.8.8')
|
||||
mock_probe_sni.assert_called_with(
|
||||
host='8.8.8.8', port=mock.ANY, name='foo.com',
|
||||
alpn_protocols=['acme-tls/1'])
|
||||
host='8.8.8.8', port=mock.ANY, name=mock.ANY)
|
||||
|
||||
@mock.patch('acme.challenges.TLSALPN01Response.probe_cert')
|
||||
self.response.probe_cert('foo.com', port=1234)
|
||||
mock_probe_sni.assert_called_with(
|
||||
host=mock.ANY, port=1234, name=mock.ANY)
|
||||
|
||||
self.response.probe_cert('foo.com', bar='baz')
|
||||
mock_probe_sni.assert_called_with(
|
||||
host=mock.ANY, port=mock.ANY, name=mock.ANY, bar='baz')
|
||||
|
||||
self.response.probe_cert('foo.com', name=b'xxx')
|
||||
mock_probe_sni.assert_called_with(
|
||||
host=mock.ANY, port=mock.ANY,
|
||||
name=self.z_domain)
|
||||
|
||||
def test_gen_verify_cert(self):
|
||||
key1 = test_util.load_pyopenssl_private_key('rsa512_key.pem')
|
||||
cert, key2 = self.response.gen_cert(key1)
|
||||
self.assertEqual(key1, key2)
|
||||
self.assertTrue(self.response.verify_cert(cert))
|
||||
|
||||
def test_gen_verify_cert_gen_key(self):
|
||||
cert, key = self.response.gen_cert()
|
||||
self.assertTrue(isinstance(key, OpenSSL.crypto.PKey))
|
||||
self.assertTrue(self.response.verify_cert(cert))
|
||||
|
||||
def test_verify_bad_cert(self):
|
||||
self.assertFalse(self.response.verify_cert(
|
||||
test_util.load_cert('cert.pem')))
|
||||
|
||||
def test_simple_verify_bad_key_authorization(self):
|
||||
key2 = jose.JWKRSA.load(test_util.load_vector('rsa256_key.pem'))
|
||||
self.response.simple_verify(self.chall, "local", key2.public_key())
|
||||
|
||||
@mock.patch('acme.challenges.TLSSNI01Response.verify_cert', autospec=True)
|
||||
def test_simple_verify(self, mock_verify_cert):
|
||||
mock_verify_cert.return_value = mock.sentinel.verification
|
||||
self.assertEqual(
|
||||
mock.sentinel.verification, self.response.simple_verify(
|
||||
self.chall, self.domain, KEY.public_key(),
|
||||
cert=mock.sentinel.cert))
|
||||
mock_verify_cert.assert_called_once_with(
|
||||
self.response, mock.sentinel.cert)
|
||||
|
||||
@mock.patch('acme.challenges.TLSSNI01Response.probe_cert')
|
||||
def test_simple_verify_false_on_probe_error(self, mock_probe_cert):
|
||||
mock_probe_cert.side_effect = errors.Error
|
||||
self.assertFalse(self.response.simple_verify(
|
||||
self.chall, self.domain, KEY.public_key()))
|
||||
|
||||
|
||||
class TLSALPN01Test(unittest.TestCase):
|
||||
class TLSSNI01Test(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
from acme.challenges import TLSALPN01
|
||||
self.msg = TLSALPN01(
|
||||
from acme.challenges import TLSSNI01
|
||||
self.msg = TLSSNI01(
|
||||
token=jose.b64decode('a82d5ff8ef740d12881f6d3c2277ab2e'))
|
||||
self.jmsg = {
|
||||
'type': 'tls-alpn-01',
|
||||
'type': 'tls-sni-01',
|
||||
'token': 'a82d5ff8ef740d12881f6d3c2277ab2e',
|
||||
}
|
||||
|
||||
@@ -356,26 +372,25 @@ class TLSALPN01Test(unittest.TestCase):
|
||||
self.assertEqual(self.jmsg, self.msg.to_partial_json())
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.challenges import TLSALPN01
|
||||
self.assertEqual(self.msg, TLSALPN01.from_json(self.jmsg))
|
||||
from acme.challenges import TLSSNI01
|
||||
self.assertEqual(self.msg, TLSSNI01.from_json(self.jmsg))
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.challenges import TLSALPN01
|
||||
hash(TLSALPN01.from_json(self.jmsg))
|
||||
from acme.challenges import TLSSNI01
|
||||
hash(TLSSNI01.from_json(self.jmsg))
|
||||
|
||||
def test_from_json_invalid_token_length(self):
|
||||
from acme.challenges import TLSALPN01
|
||||
from acme.challenges import TLSSNI01
|
||||
self.jmsg['token'] = jose.encode_b64jose(b'abcd')
|
||||
self.assertRaises(
|
||||
jose.DeserializationError, TLSALPN01.from_json, self.jmsg)
|
||||
jose.DeserializationError, TLSSNI01.from_json, self.jmsg)
|
||||
|
||||
@mock.patch('acme.challenges.TLSALPN01Response.gen_cert')
|
||||
@mock.patch('acme.challenges.TLSSNI01Response.gen_cert')
|
||||
def test_validation(self, mock_gen_cert):
|
||||
mock_gen_cert.return_value = ('cert', 'key')
|
||||
self.assertEqual(('cert', 'key'), self.msg.validation(
|
||||
KEY, cert_key=mock.sentinel.cert_key, domain=mock.sentinel.domain))
|
||||
mock_gen_cert.assert_called_once_with(key=mock.sentinel.cert_key,
|
||||
domain=mock.sentinel.domain)
|
||||
KEY, cert_key=mock.sentinel.cert_key))
|
||||
mock_gen_cert.assert_called_once_with(key=mock.sentinel.cert_key)
|
||||
|
||||
|
||||
class DNSTest(unittest.TestCase):
|
||||
@@ -478,18 +493,5 @@ class DNSResponseTest(unittest.TestCase):
|
||||
self.msg.check_validation(self.chall, KEY.public_key()))
|
||||
|
||||
|
||||
class JWSPayloadRFC8555Compliant(unittest.TestCase):
|
||||
"""Test for RFC8555 compliance of JWS generated from resources/challenges"""
|
||||
def test_challenge_payload(self):
|
||||
from acme.challenges import HTTP01Response
|
||||
|
||||
challenge_body = HTTP01Response()
|
||||
challenge_body.le_acme_version = 2
|
||||
|
||||
jobj = challenge_body.json_dumps(indent=2).encode()
|
||||
# RFC8555 states that challenge responses must have an empty payload.
|
||||
self.assertEqual(jobj, b'{}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
File diff suppressed because it is too large
Load Diff
733
acme/acme/client_test.py
Normal file
733
acme/acme/client_test.py
Normal file
@@ -0,0 +1,733 @@
|
||||
"""Tests for acme.client."""
|
||||
import datetime
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from six.moves import http_client # pylint: disable=import-error
|
||||
|
||||
import mock
|
||||
import requests
|
||||
|
||||
from acme import challenges
|
||||
from acme import errors
|
||||
from acme import jose
|
||||
from acme import jws as acme_jws
|
||||
from acme import messages
|
||||
from acme import messages_test
|
||||
from acme import test_util
|
||||
|
||||
|
||||
CERT_DER = test_util.load_vector('cert.der')
|
||||
KEY = jose.JWKRSA.load(test_util.load_vector('rsa512_key.pem'))
|
||||
KEY2 = jose.JWKRSA.load(test_util.load_vector('rsa256_key.pem'))
|
||||
|
||||
|
||||
class ClientTest(unittest.TestCase):
|
||||
"""Tests for acme.client.Client."""
|
||||
# pylint: disable=too-many-instance-attributes,too-many-public-methods
|
||||
|
||||
def setUp(self):
|
||||
self.response = mock.MagicMock(
|
||||
ok=True, status_code=http_client.OK, headers={}, links={})
|
||||
self.net = mock.MagicMock()
|
||||
self.net.post.return_value = self.response
|
||||
self.net.get.return_value = self.response
|
||||
|
||||
self.directory = messages.Directory({
|
||||
messages.NewRegistration:
|
||||
'https://www.letsencrypt-demo.org/acme/new-reg',
|
||||
messages.Revocation:
|
||||
'https://www.letsencrypt-demo.org/acme/revoke-cert',
|
||||
messages.NewAuthorization:
|
||||
'https://www.letsencrypt-demo.org/acme/new-authz',
|
||||
messages.CertificateRequest:
|
||||
'https://www.letsencrypt-demo.org/acme/new-cert',
|
||||
})
|
||||
|
||||
from acme.client import Client
|
||||
self.client = Client(
|
||||
directory=self.directory, key=KEY, alg=jose.RS256, net=self.net)
|
||||
|
||||
self.identifier = messages.Identifier(
|
||||
typ=messages.IDENTIFIER_FQDN, value='example.com')
|
||||
|
||||
# Registration
|
||||
self.contact = ('mailto:cert-admin@example.com', 'tel:+12025551212')
|
||||
reg = messages.Registration(
|
||||
contact=self.contact, key=KEY.public_key())
|
||||
self.new_reg = messages.NewRegistration(**dict(reg))
|
||||
self.regr = messages.RegistrationResource(
|
||||
body=reg, uri='https://www.letsencrypt-demo.org/acme/reg/1',
|
||||
terms_of_service='https://www.letsencrypt-demo.org/tos')
|
||||
|
||||
# Authorization
|
||||
authzr_uri = 'https://www.letsencrypt-demo.org/acme/authz/1'
|
||||
challb = messages.ChallengeBody(
|
||||
uri=(authzr_uri + '/1'), status=messages.STATUS_VALID,
|
||||
chall=challenges.DNS(token=jose.b64decode(
|
||||
'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA')))
|
||||
self.challr = messages.ChallengeResource(
|
||||
body=challb, authzr_uri=authzr_uri)
|
||||
self.authz = messages.Authorization(
|
||||
identifier=messages.Identifier(
|
||||
typ=messages.IDENTIFIER_FQDN, value='example.com'),
|
||||
challenges=(challb,), combinations=None)
|
||||
self.authzr = messages.AuthorizationResource(
|
||||
body=self.authz, uri=authzr_uri)
|
||||
|
||||
# Request issuance
|
||||
self.certr = messages.CertificateResource(
|
||||
body=messages_test.CERT, authzrs=(self.authzr,),
|
||||
uri='https://www.letsencrypt-demo.org/acme/cert/1',
|
||||
cert_chain_uri='https://www.letsencrypt-demo.org/ca')
|
||||
|
||||
# Reason code for revocation
|
||||
self.rsn = 1
|
||||
|
||||
def test_init_downloads_directory(self):
|
||||
uri = 'http://www.letsencrypt-demo.org/directory'
|
||||
from acme.client import Client
|
||||
self.client = Client(
|
||||
directory=uri, key=KEY, alg=jose.RS256, net=self.net)
|
||||
self.net.get.assert_called_once_with(uri)
|
||||
|
||||
def test_register(self):
|
||||
# "Instance of 'Field' has no to_json/update member" bug:
|
||||
# pylint: disable=no-member
|
||||
self.response.status_code = http_client.CREATED
|
||||
self.response.json.return_value = self.regr.body.to_json()
|
||||
self.response.headers['Location'] = self.regr.uri
|
||||
self.response.links.update({
|
||||
'terms-of-service': {'url': self.regr.terms_of_service},
|
||||
})
|
||||
|
||||
self.assertEqual(self.regr, self.client.register(self.new_reg))
|
||||
# TODO: test POST call arguments
|
||||
|
||||
def test_update_registration(self):
|
||||
# "Instance of 'Field' has no to_json/update member" bug:
|
||||
# pylint: disable=no-member
|
||||
self.response.headers['Location'] = self.regr.uri
|
||||
self.response.json.return_value = self.regr.body.to_json()
|
||||
self.assertEqual(self.regr, self.client.update_registration(self.regr))
|
||||
# TODO: test POST call arguments
|
||||
|
||||
# TODO: split here and separate test
|
||||
self.response.json.return_value = self.regr.body.update(
|
||||
contact=()).to_json()
|
||||
|
||||
def test_deactivate_account(self):
|
||||
self.response.headers['Location'] = self.regr.uri
|
||||
self.response.json.return_value = self.regr.body.to_json()
|
||||
self.assertEqual(self.regr,
|
||||
self.client.deactivate_registration(self.regr))
|
||||
|
||||
def test_query_registration(self):
|
||||
self.response.json.return_value = self.regr.body.to_json()
|
||||
self.assertEqual(self.regr, self.client.query_registration(self.regr))
|
||||
|
||||
def test_agree_to_tos(self):
|
||||
self.client.update_registration = mock.Mock()
|
||||
self.client.agree_to_tos(self.regr)
|
||||
regr = self.client.update_registration.call_args[0][0]
|
||||
self.assertEqual(self.regr.terms_of_service, regr.body.agreement)
|
||||
|
||||
def _prepare_response_for_request_challenges(self):
|
||||
self.response.status_code = http_client.CREATED
|
||||
self.response.headers['Location'] = self.authzr.uri
|
||||
self.response.json.return_value = self.authz.to_json()
|
||||
|
||||
def test_request_challenges(self):
|
||||
self._prepare_response_for_request_challenges()
|
||||
self.client.request_challenges(self.identifier)
|
||||
self.net.post.assert_called_once_with(
|
||||
self.directory.new_authz,
|
||||
messages.NewAuthorization(identifier=self.identifier))
|
||||
|
||||
def test_request_challenges_deprecated_arg(self):
|
||||
self._prepare_response_for_request_challenges()
|
||||
self.client.request_challenges(self.identifier, new_authzr_uri="hi")
|
||||
self.net.post.assert_called_once_with(
|
||||
self.directory.new_authz,
|
||||
messages.NewAuthorization(identifier=self.identifier))
|
||||
|
||||
def test_request_challenges_custom_uri(self):
|
||||
self._prepare_response_for_request_challenges()
|
||||
self.client.request_challenges(self.identifier)
|
||||
self.net.post.assert_called_once_with(
|
||||
'https://www.letsencrypt-demo.org/acme/new-authz', mock.ANY)
|
||||
|
||||
def test_request_challenges_unexpected_update(self):
|
||||
self._prepare_response_for_request_challenges()
|
||||
self.response.json.return_value = self.authz.update(
|
||||
identifier=self.identifier.update(value='foo')).to_json()
|
||||
self.assertRaises(
|
||||
errors.UnexpectedUpdate, self.client.request_challenges,
|
||||
self.identifier)
|
||||
|
||||
def test_request_domain_challenges(self):
|
||||
self.client.request_challenges = mock.MagicMock()
|
||||
self.assertEqual(
|
||||
self.client.request_challenges(self.identifier),
|
||||
self.client.request_domain_challenges('example.com'))
|
||||
|
||||
def test_answer_challenge(self):
|
||||
self.response.links['up'] = {'url': self.challr.authzr_uri}
|
||||
self.response.json.return_value = self.challr.body.to_json()
|
||||
|
||||
chall_response = challenges.DNSResponse(validation=None)
|
||||
|
||||
self.client.answer_challenge(self.challr.body, chall_response)
|
||||
|
||||
# TODO: split here and separate test
|
||||
self.assertRaises(errors.UnexpectedUpdate, self.client.answer_challenge,
|
||||
self.challr.body.update(uri='foo'), chall_response)
|
||||
|
||||
def test_answer_challenge_missing_next(self):
|
||||
self.assertRaises(
|
||||
errors.ClientError, self.client.answer_challenge,
|
||||
self.challr.body, challenges.DNSResponse(validation=None))
|
||||
|
||||
def test_retry_after_date(self):
|
||||
self.response.headers['Retry-After'] = 'Fri, 31 Dec 1999 23:59:59 GMT'
|
||||
self.assertEqual(
|
||||
datetime.datetime(1999, 12, 31, 23, 59, 59),
|
||||
self.client.retry_after(response=self.response, default=10))
|
||||
|
||||
@mock.patch('acme.client.datetime')
|
||||
def test_retry_after_invalid(self, dt_mock):
|
||||
dt_mock.datetime.now.return_value = datetime.datetime(2015, 3, 27)
|
||||
dt_mock.timedelta = datetime.timedelta
|
||||
|
||||
self.response.headers['Retry-After'] = 'foooo'
|
||||
self.assertEqual(
|
||||
datetime.datetime(2015, 3, 27, 0, 0, 10),
|
||||
self.client.retry_after(response=self.response, default=10))
|
||||
|
||||
@mock.patch('acme.client.datetime')
|
||||
def test_retry_after_overflow(self, dt_mock):
|
||||
dt_mock.datetime.now.return_value = datetime.datetime(2015, 3, 27)
|
||||
dt_mock.timedelta = datetime.timedelta
|
||||
dt_mock.datetime.side_effect = datetime.datetime
|
||||
|
||||
self.response.headers['Retry-After'] = "Tue, 116 Feb 2016 11:50:00 MST"
|
||||
self.assertEqual(
|
||||
datetime.datetime(2015, 3, 27, 0, 0, 10),
|
||||
self.client.retry_after(response=self.response, default=10))
|
||||
|
||||
@mock.patch('acme.client.datetime')
|
||||
def test_retry_after_seconds(self, dt_mock):
|
||||
dt_mock.datetime.now.return_value = datetime.datetime(2015, 3, 27)
|
||||
dt_mock.timedelta = datetime.timedelta
|
||||
|
||||
self.response.headers['Retry-After'] = '50'
|
||||
self.assertEqual(
|
||||
datetime.datetime(2015, 3, 27, 0, 0, 50),
|
||||
self.client.retry_after(response=self.response, default=10))
|
||||
|
||||
@mock.patch('acme.client.datetime')
|
||||
def test_retry_after_missing(self, dt_mock):
|
||||
dt_mock.datetime.now.return_value = datetime.datetime(2015, 3, 27)
|
||||
dt_mock.timedelta = datetime.timedelta
|
||||
|
||||
self.assertEqual(
|
||||
datetime.datetime(2015, 3, 27, 0, 0, 10),
|
||||
self.client.retry_after(response=self.response, default=10))
|
||||
|
||||
def test_poll(self):
|
||||
self.response.json.return_value = self.authzr.body.to_json()
|
||||
self.assertEqual((self.authzr, self.response),
|
||||
self.client.poll(self.authzr))
|
||||
|
||||
# TODO: split here and separate test
|
||||
self.response.json.return_value = self.authz.update(
|
||||
identifier=self.identifier.update(value='foo')).to_json()
|
||||
self.assertRaises(
|
||||
errors.UnexpectedUpdate, self.client.poll, self.authzr)
|
||||
|
||||
def test_request_issuance(self):
|
||||
self.response.content = CERT_DER
|
||||
self.response.headers['Location'] = self.certr.uri
|
||||
self.response.links['up'] = {'url': self.certr.cert_chain_uri}
|
||||
self.assertEqual(self.certr, self.client.request_issuance(
|
||||
messages_test.CSR, (self.authzr,)))
|
||||
# TODO: check POST args
|
||||
|
||||
def test_request_issuance_missing_up(self):
|
||||
self.response.content = CERT_DER
|
||||
self.response.headers['Location'] = self.certr.uri
|
||||
self.assertEqual(
|
||||
self.certr.update(cert_chain_uri=None),
|
||||
self.client.request_issuance(messages_test.CSR, (self.authzr,)))
|
||||
|
||||
def test_request_issuance_missing_location(self):
|
||||
self.assertRaises(
|
||||
errors.ClientError, self.client.request_issuance,
|
||||
messages_test.CSR, (self.authzr,))
|
||||
|
||||
@mock.patch('acme.client.datetime')
|
||||
@mock.patch('acme.client.time')
|
||||
def test_poll_and_request_issuance(self, time_mock, dt_mock):
|
||||
# clock.dt | pylint: disable=no-member
|
||||
clock = mock.MagicMock(dt=datetime.datetime(2015, 3, 27))
|
||||
|
||||
def sleep(seconds):
|
||||
"""increment clock"""
|
||||
clock.dt += datetime.timedelta(seconds=seconds)
|
||||
time_mock.sleep.side_effect = sleep
|
||||
|
||||
def now():
|
||||
"""return current clock value"""
|
||||
return clock.dt
|
||||
dt_mock.datetime.now.side_effect = now
|
||||
dt_mock.timedelta = datetime.timedelta
|
||||
|
||||
def poll(authzr): # pylint: disable=missing-docstring
|
||||
# record poll start time based on the current clock value
|
||||
authzr.times.append(clock.dt)
|
||||
|
||||
# suppose it takes 2 seconds for server to produce the
|
||||
# result, increment clock
|
||||
clock.dt += datetime.timedelta(seconds=2)
|
||||
|
||||
if len(authzr.retries) == 1: # no more retries
|
||||
done = mock.MagicMock(uri=authzr.uri, times=authzr.times)
|
||||
done.body.status = authzr.retries[0]
|
||||
return done, []
|
||||
|
||||
# response (2nd result tuple element) is reduced to only
|
||||
# Retry-After header contents represented as integer
|
||||
# seconds; authzr.retries is a list of Retry-After
|
||||
# headers, head(retries) is peeled of as a current
|
||||
# Retry-After header, and tail(retries) is persisted for
|
||||
# later poll() calls
|
||||
return (mock.MagicMock(retries=authzr.retries[1:],
|
||||
uri=authzr.uri + '.', times=authzr.times),
|
||||
authzr.retries[0])
|
||||
self.client.poll = mock.MagicMock(side_effect=poll)
|
||||
|
||||
mintime = 7
|
||||
|
||||
def retry_after(response, default):
|
||||
# pylint: disable=missing-docstring
|
||||
# check that poll_and_request_issuance correctly passes mintime
|
||||
self.assertEqual(default, mintime)
|
||||
return clock.dt + datetime.timedelta(seconds=response)
|
||||
self.client.retry_after = mock.MagicMock(side_effect=retry_after)
|
||||
|
||||
def request_issuance(csr, authzrs): # pylint: disable=missing-docstring
|
||||
return csr, authzrs
|
||||
self.client.request_issuance = mock.MagicMock(
|
||||
side_effect=request_issuance)
|
||||
|
||||
csr = mock.MagicMock()
|
||||
authzrs = (
|
||||
mock.MagicMock(uri='a', times=[], retries=(
|
||||
8, 20, 30, messages.STATUS_VALID)),
|
||||
mock.MagicMock(uri='b', times=[], retries=(
|
||||
5, messages.STATUS_VALID)),
|
||||
)
|
||||
|
||||
cert, updated_authzrs = self.client.poll_and_request_issuance(
|
||||
csr, authzrs, mintime=mintime,
|
||||
# make sure that max_attempts is per-authorization, rather
|
||||
# than global
|
||||
max_attempts=max(len(authzrs[0].retries), len(authzrs[1].retries)))
|
||||
self.assertTrue(cert[0] is csr)
|
||||
self.assertTrue(cert[1] is updated_authzrs)
|
||||
self.assertEqual(updated_authzrs[0].uri, 'a...')
|
||||
self.assertEqual(updated_authzrs[1].uri, 'b.')
|
||||
self.assertEqual(updated_authzrs[0].times, [
|
||||
datetime.datetime(2015, 3, 27),
|
||||
# a is scheduled for 10, but b is polling [9..11), so it
|
||||
# will be picked up as soon as b is finished, without
|
||||
# additional sleeping
|
||||
datetime.datetime(2015, 3, 27, 0, 0, 11),
|
||||
datetime.datetime(2015, 3, 27, 0, 0, 33),
|
||||
datetime.datetime(2015, 3, 27, 0, 1, 5),
|
||||
])
|
||||
self.assertEqual(updated_authzrs[1].times, [
|
||||
datetime.datetime(2015, 3, 27, 0, 0, 2),
|
||||
datetime.datetime(2015, 3, 27, 0, 0, 9),
|
||||
])
|
||||
self.assertEqual(clock.dt, datetime.datetime(2015, 3, 27, 0, 1, 7))
|
||||
|
||||
# CA sets invalid | TODO: move to a separate test
|
||||
invalid_authzr = mock.MagicMock(
|
||||
times=[], retries=[messages.STATUS_INVALID])
|
||||
self.assertRaises(
|
||||
errors.PollError, self.client.poll_and_request_issuance,
|
||||
csr, authzrs=(invalid_authzr,), mintime=mintime)
|
||||
|
||||
# exceeded max_attempts | TODO: move to a separate test
|
||||
self.assertRaises(
|
||||
errors.PollError, self.client.poll_and_request_issuance,
|
||||
csr, authzrs, mintime=mintime, max_attempts=2)
|
||||
|
||||
def test_check_cert(self):
|
||||
self.response.headers['Location'] = self.certr.uri
|
||||
self.response.content = CERT_DER
|
||||
self.assertEqual(self.certr.update(body=messages_test.CERT),
|
||||
self.client.check_cert(self.certr))
|
||||
|
||||
# TODO: split here and separate test
|
||||
self.response.headers['Location'] = 'foo'
|
||||
self.assertRaises(
|
||||
errors.UnexpectedUpdate, self.client.check_cert, self.certr)
|
||||
|
||||
def test_check_cert_missing_location(self):
|
||||
self.response.content = CERT_DER
|
||||
self.assertRaises(
|
||||
errors.ClientError, self.client.check_cert, self.certr)
|
||||
|
||||
def test_refresh(self):
|
||||
self.client.check_cert = mock.MagicMock()
|
||||
self.assertEqual(
|
||||
self.client.check_cert(self.certr), self.client.refresh(self.certr))
|
||||
|
||||
def test_fetch_chain_no_up_link(self):
|
||||
self.assertEqual([], self.client.fetch_chain(self.certr.update(
|
||||
cert_chain_uri=None)))
|
||||
|
||||
def test_fetch_chain_single(self):
|
||||
# pylint: disable=protected-access
|
||||
self.client._get_cert = mock.MagicMock()
|
||||
self.client._get_cert.return_value = (
|
||||
mock.MagicMock(links={}), "certificate")
|
||||
self.assertEqual([self.client._get_cert(self.certr.cert_chain_uri)[1]],
|
||||
self.client.fetch_chain(self.certr))
|
||||
|
||||
def test_fetch_chain_max(self):
|
||||
# pylint: disable=protected-access
|
||||
up_response = mock.MagicMock(links={'up': {'url': 'http://cert'}})
|
||||
noup_response = mock.MagicMock(links={})
|
||||
self.client._get_cert = mock.MagicMock()
|
||||
self.client._get_cert.side_effect = [
|
||||
(up_response, "cert")] * 9 + [(noup_response, "last_cert")]
|
||||
chain = self.client.fetch_chain(self.certr, max_length=10)
|
||||
self.assertEqual(chain, ["cert"] * 9 + ["last_cert"])
|
||||
|
||||
def test_fetch_chain_too_many(self): # recursive
|
||||
# pylint: disable=protected-access
|
||||
response = mock.MagicMock(links={'up': {'url': 'http://cert'}})
|
||||
self.client._get_cert = mock.MagicMock()
|
||||
self.client._get_cert.return_value = (response, "certificate")
|
||||
self.assertRaises(errors.Error, self.client.fetch_chain, self.certr)
|
||||
|
||||
def test_revoke(self):
|
||||
self.client.revoke(self.certr.body, self.rsn)
|
||||
self.net.post.assert_called_once_with(
|
||||
self.directory[messages.Revocation], mock.ANY, content_type=None)
|
||||
|
||||
def test_revocation_payload(self):
|
||||
obj = messages.Revocation(certificate=self.certr.body, reason=self.rsn)
|
||||
self.assertTrue('reason' in obj.to_partial_json().keys())
|
||||
self.assertEquals(self.rsn, obj.to_partial_json()['reason'])
|
||||
|
||||
def test_revoke_bad_status_raises_error(self):
|
||||
self.response.status_code = http_client.METHOD_NOT_ALLOWED
|
||||
self.assertRaises(
|
||||
errors.ClientError,
|
||||
self.client.revoke,
|
||||
self.certr,
|
||||
self.rsn)
|
||||
|
||||
|
||||
class ClientNetworkTest(unittest.TestCase):
|
||||
"""Tests for acme.client.ClientNetwork."""
|
||||
|
||||
def setUp(self):
|
||||
self.verify_ssl = mock.MagicMock()
|
||||
self.wrap_in_jws = mock.MagicMock(return_value=mock.sentinel.wrapped)
|
||||
|
||||
from acme.client import ClientNetwork
|
||||
self.net = ClientNetwork(
|
||||
key=KEY, alg=jose.RS256, verify_ssl=self.verify_ssl,
|
||||
user_agent='acme-python-test')
|
||||
|
||||
self.response = mock.MagicMock(ok=True, status_code=http_client.OK)
|
||||
self.response.headers = {}
|
||||
self.response.links = {}
|
||||
|
||||
def test_init(self):
|
||||
self.assertTrue(self.net.verify_ssl is self.verify_ssl)
|
||||
|
||||
def test_wrap_in_jws(self):
|
||||
class MockJSONDeSerializable(jose.JSONDeSerializable):
|
||||
# pylint: disable=missing-docstring
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def to_partial_json(self):
|
||||
return {'foo': self.value}
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, value):
|
||||
pass # pragma: no cover
|
||||
|
||||
# pylint: disable=protected-access
|
||||
jws_dump = self.net._wrap_in_jws(
|
||||
MockJSONDeSerializable('foo'), nonce=b'Tg')
|
||||
jws = acme_jws.JWS.json_loads(jws_dump)
|
||||
self.assertEqual(json.loads(jws.payload.decode()), {'foo': 'foo'})
|
||||
self.assertEqual(jws.signature.combined.nonce, b'Tg')
|
||||
|
||||
def test_check_response_not_ok_jobj_no_error(self):
|
||||
self.response.ok = False
|
||||
self.response.json.return_value = {}
|
||||
with mock.patch('acme.client.messages.Error.from_json') as from_json:
|
||||
from_json.side_effect = jose.DeserializationError
|
||||
# pylint: disable=protected-access
|
||||
self.assertRaises(
|
||||
errors.ClientError, self.net._check_response, self.response)
|
||||
|
||||
def test_check_response_not_ok_jobj_error(self):
|
||||
self.response.ok = False
|
||||
self.response.json.return_value = messages.Error(
|
||||
detail='foo', typ='serverInternal', title='some title').to_json()
|
||||
# pylint: disable=protected-access
|
||||
self.assertRaises(
|
||||
messages.Error, self.net._check_response, self.response)
|
||||
|
||||
def test_check_response_not_ok_no_jobj(self):
|
||||
self.response.ok = False
|
||||
self.response.json.side_effect = ValueError
|
||||
# pylint: disable=protected-access
|
||||
self.assertRaises(
|
||||
errors.ClientError, self.net._check_response, self.response)
|
||||
|
||||
def test_check_response_ok_no_jobj_ct_required(self):
|
||||
self.response.json.side_effect = ValueError
|
||||
for response_ct in [self.net.JSON_CONTENT_TYPE, 'foo']:
|
||||
self.response.headers['Content-Type'] = response_ct
|
||||
# pylint: disable=protected-access
|
||||
self.assertRaises(
|
||||
errors.ClientError, self.net._check_response, self.response,
|
||||
content_type=self.net.JSON_CONTENT_TYPE)
|
||||
|
||||
def test_check_response_ok_no_jobj_no_ct(self):
|
||||
self.response.json.side_effect = ValueError
|
||||
for response_ct in [self.net.JSON_CONTENT_TYPE, 'foo']:
|
||||
self.response.headers['Content-Type'] = response_ct
|
||||
# pylint: disable=protected-access,no-value-for-parameter
|
||||
self.assertEqual(
|
||||
self.response, self.net._check_response(self.response))
|
||||
|
||||
def test_check_response_jobj(self):
|
||||
self.response.json.return_value = {}
|
||||
for response_ct in [self.net.JSON_CONTENT_TYPE, 'foo']:
|
||||
self.response.headers['Content-Type'] = response_ct
|
||||
# pylint: disable=protected-access,no-value-for-parameter
|
||||
self.assertEqual(
|
||||
self.response, self.net._check_response(self.response))
|
||||
|
||||
def test_send_request(self):
|
||||
self.net.session = mock.MagicMock()
|
||||
self.net.session.request.return_value = self.response
|
||||
# pylint: disable=protected-access
|
||||
self.assertEqual(self.response, self.net._send_request(
|
||||
'HEAD', 'http://example.com/', 'foo', bar='baz'))
|
||||
self.net.session.request.assert_called_once_with(
|
||||
'HEAD', 'http://example.com/', 'foo',
|
||||
headers=mock.ANY, verify=mock.ANY, timeout=mock.ANY, bar='baz')
|
||||
|
||||
@mock.patch('acme.client.logger')
|
||||
def test_send_request_get_der(self, mock_logger):
|
||||
self.net.session = mock.MagicMock()
|
||||
self.net.session.request.return_value = mock.MagicMock(
|
||||
ok=True, status_code=http_client.OK,
|
||||
headers={"Content-Type": "application/pkix-cert"},
|
||||
content=b"hi")
|
||||
# pylint: disable=protected-access
|
||||
self.net._send_request('HEAD', 'http://example.com/', 'foo',
|
||||
timeout=mock.ANY, bar='baz')
|
||||
mock_logger.debug.assert_called_with(
|
||||
'Received response:\nHTTP %d\n%s\n\n%s', 200,
|
||||
'Content-Type: application/pkix-cert', b'aGk=')
|
||||
|
||||
def test_send_request_post(self):
|
||||
self.net.session = mock.MagicMock()
|
||||
self.net.session.request.return_value = self.response
|
||||
# pylint: disable=protected-access
|
||||
self.assertEqual(self.response, self.net._send_request(
|
||||
'POST', 'http://example.com/', 'foo', data='qux', bar='baz'))
|
||||
self.net.session.request.assert_called_once_with(
|
||||
'POST', 'http://example.com/', 'foo',
|
||||
headers=mock.ANY, verify=mock.ANY, timeout=mock.ANY, data='qux', bar='baz')
|
||||
|
||||
def test_send_request_verify_ssl(self):
|
||||
# pylint: disable=protected-access
|
||||
for verify in True, False:
|
||||
self.net.session = mock.MagicMock()
|
||||
self.net.session.request.return_value = self.response
|
||||
self.net.verify_ssl = verify
|
||||
# pylint: disable=protected-access
|
||||
self.assertEqual(
|
||||
self.response,
|
||||
self.net._send_request('GET', 'http://example.com/'))
|
||||
self.net.session.request.assert_called_once_with(
|
||||
'GET', 'http://example.com/', verify=verify,
|
||||
timeout=mock.ANY, headers=mock.ANY)
|
||||
|
||||
def test_send_request_user_agent(self):
|
||||
self.net.session = mock.MagicMock()
|
||||
# pylint: disable=protected-access
|
||||
self.net._send_request('GET', 'http://example.com/',
|
||||
headers={'bar': 'baz'})
|
||||
self.net.session.request.assert_called_once_with(
|
||||
'GET', 'http://example.com/', verify=mock.ANY,
|
||||
timeout=mock.ANY,
|
||||
headers={'User-Agent': 'acme-python-test', 'bar': 'baz'})
|
||||
|
||||
self.net._send_request('GET', 'http://example.com/',
|
||||
headers={'User-Agent': 'foo2'})
|
||||
self.net.session.request.assert_called_with(
|
||||
'GET', 'http://example.com/',
|
||||
verify=mock.ANY, timeout=mock.ANY, headers={'User-Agent': 'foo2'})
|
||||
|
||||
def test_send_request_timeout(self):
|
||||
self.net.session = mock.MagicMock()
|
||||
# pylint: disable=protected-access
|
||||
self.net._send_request('GET', 'http://example.com/',
|
||||
headers={'bar': 'baz'})
|
||||
self.net.session.request.assert_called_once_with(
|
||||
mock.ANY, mock.ANY, verify=mock.ANY, headers=mock.ANY,
|
||||
timeout=45)
|
||||
|
||||
def test_del(self):
|
||||
sess = mock.MagicMock()
|
||||
self.net.session = sess
|
||||
del self.net
|
||||
sess.close.assert_called_once_with()
|
||||
|
||||
@mock.patch('acme.client.requests')
|
||||
def test_requests_error_passthrough(self, mock_requests):
|
||||
mock_requests.exceptions = requests.exceptions
|
||||
mock_requests.request.side_effect = requests.exceptions.RequestException
|
||||
# pylint: disable=protected-access
|
||||
self.assertRaises(requests.exceptions.RequestException,
|
||||
self.net._send_request, 'GET', 'uri')
|
||||
|
||||
|
||||
class ClientNetworkWithMockedResponseTest(unittest.TestCase):
|
||||
"""Tests for acme.client.ClientNetwork which mock out response."""
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
|
||||
def setUp(self):
|
||||
from acme.client import ClientNetwork
|
||||
self.net = ClientNetwork(key=None, alg=None)
|
||||
|
||||
self.response = mock.MagicMock(ok=True, status_code=http_client.OK)
|
||||
self.response.headers = {}
|
||||
self.response.links = {}
|
||||
self.checked_response = mock.MagicMock()
|
||||
self.obj = mock.MagicMock()
|
||||
self.wrapped_obj = mock.MagicMock()
|
||||
self.content_type = mock.sentinel.content_type
|
||||
|
||||
self.all_nonces = [
|
||||
jose.b64encode(b'Nonce'),
|
||||
jose.b64encode(b'Nonce2'), jose.b64encode(b'Nonce3')]
|
||||
self.available_nonces = self.all_nonces[:]
|
||||
|
||||
def send_request(*args, **kwargs):
|
||||
# pylint: disable=unused-argument,missing-docstring
|
||||
if self.available_nonces:
|
||||
self.response.headers = {
|
||||
self.net.REPLAY_NONCE_HEADER:
|
||||
self.available_nonces.pop().decode()}
|
||||
else:
|
||||
self.response.headers = {}
|
||||
return self.response
|
||||
|
||||
# pylint: disable=protected-access
|
||||
self.net._send_request = self.send_request = mock.MagicMock(
|
||||
side_effect=send_request)
|
||||
self.net._check_response = self.check_response
|
||||
self.net._wrap_in_jws = mock.MagicMock(return_value=self.wrapped_obj)
|
||||
|
||||
def check_response(self, response, content_type):
|
||||
# pylint: disable=missing-docstring
|
||||
self.assertEqual(self.response, response)
|
||||
self.assertEqual(self.content_type, content_type)
|
||||
return self.checked_response
|
||||
|
||||
def test_head(self):
|
||||
self.assertEqual(self.response, self.net.head(
|
||||
'http://example.com/', 'foo', bar='baz'))
|
||||
self.send_request.assert_called_once_with(
|
||||
'HEAD', 'http://example.com/', 'foo', bar='baz')
|
||||
|
||||
def test_get(self):
|
||||
self.assertEqual(self.checked_response, self.net.get(
|
||||
'http://example.com/', content_type=self.content_type, bar='baz'))
|
||||
self.send_request.assert_called_once_with(
|
||||
'GET', 'http://example.com/', bar='baz')
|
||||
|
||||
def test_post_no_content_type(self):
|
||||
self.content_type = self.net.JOSE_CONTENT_TYPE
|
||||
self.assertEqual(self.checked_response, self.net.post('uri', self.obj))
|
||||
|
||||
def test_post(self):
|
||||
# pylint: disable=protected-access
|
||||
self.assertEqual(self.checked_response, self.net.post(
|
||||
'uri', self.obj, content_type=self.content_type))
|
||||
self.net._wrap_in_jws.assert_called_once_with(
|
||||
self.obj, jose.b64decode(self.all_nonces.pop()))
|
||||
|
||||
self.available_nonces = []
|
||||
self.assertRaises(errors.MissingNonce, self.net.post,
|
||||
'uri', self.obj, content_type=self.content_type)
|
||||
self.net._wrap_in_jws.assert_called_with(
|
||||
self.obj, jose.b64decode(self.all_nonces.pop()))
|
||||
|
||||
def test_post_wrong_initial_nonce(self): # HEAD
|
||||
self.available_nonces = [b'f', jose.b64encode(b'good')]
|
||||
self.assertRaises(errors.BadNonce, self.net.post, 'uri',
|
||||
self.obj, content_type=self.content_type)
|
||||
|
||||
def test_post_wrong_post_response_nonce(self):
|
||||
self.available_nonces = [jose.b64encode(b'good'), b'f']
|
||||
self.assertRaises(errors.BadNonce, self.net.post, 'uri',
|
||||
self.obj, content_type=self.content_type)
|
||||
|
||||
def test_post_failed_retry(self):
|
||||
check_response = mock.MagicMock()
|
||||
check_response.side_effect = messages.Error.with_code('badNonce')
|
||||
|
||||
# pylint: disable=protected-access
|
||||
self.net._check_response = check_response
|
||||
self.assertRaises(messages.Error, self.net.post, 'uri',
|
||||
self.obj, content_type=self.content_type)
|
||||
|
||||
def test_post_not_retried(self):
|
||||
check_response = mock.MagicMock()
|
||||
check_response.side_effect = [messages.Error.with_code('malformed'),
|
||||
self.checked_response]
|
||||
|
||||
# pylint: disable=protected-access
|
||||
self.net._check_response = check_response
|
||||
self.assertRaises(messages.Error, self.net.post, 'uri',
|
||||
self.obj, content_type=self.content_type)
|
||||
|
||||
def test_post_successful_retry(self):
|
||||
check_response = mock.MagicMock()
|
||||
check_response.side_effect = [messages.Error.with_code('badNonce'),
|
||||
self.checked_response]
|
||||
|
||||
# pylint: disable=protected-access
|
||||
self.net._check_response = check_response
|
||||
self.assertEqual(self.checked_response, self.net.post(
|
||||
'uri', self.obj, content_type=self.content_type))
|
||||
|
||||
def test_head_get_post_error_passthrough(self):
|
||||
self.send_request.side_effect = requests.exceptions.RequestException
|
||||
for method in self.net.head, self.net.get:
|
||||
self.assertRaises(
|
||||
requests.exceptions.RequestException, method, 'GET', 'uri')
|
||||
self.assertRaises(requests.exceptions.RequestException,
|
||||
self.net.post, 'uri', obj=self.obj)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
@@ -2,66 +2,43 @@
|
||||
import binascii
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
from typing import Callable
|
||||
from typing import Tuple
|
||||
from typing import Union
|
||||
import sys
|
||||
|
||||
import josepy as jose
|
||||
from OpenSSL import crypto
|
||||
from OpenSSL import SSL # type: ignore # https://github.com/python/typeshed/issues/2052
|
||||
import OpenSSL
|
||||
|
||||
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
|
||||
# TLSSNI01 certificate serving and probing is not affected by SSL
|
||||
# vulnerabilities: prober needs to check certificate for expected
|
||||
# contents anyway. Working SNI is the only thing that's necessary for
|
||||
# the challenge and thus scoping down SSL/TLS method (version) would
|
||||
# cause interoperability issues: 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 # type: ignore
|
||||
_DEFAULT_TLSSNI01_SSL_METHOD = OpenSSL.SSL.SSLv23_METHOD # type: ignore
|
||||
|
||||
|
||||
class _DefaultCertSelection:
|
||||
def __init__(self, certs):
|
||||
self.certs = certs
|
||||
|
||||
def __call__(self, connection):
|
||||
server_name = connection.get_servername()
|
||||
return self.certs.get(server_name, None)
|
||||
|
||||
|
||||
class SSLSocket: # pylint: disable=too-few-public-methods
|
||||
class SSLSocket(object): # 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, certs=None,
|
||||
method=_DEFAULT_SSL_METHOD, alpn_selection=None,
|
||||
cert_selection=None):
|
||||
def __init__(self, sock, certs, method=_DEFAULT_TLSSNI01_SSL_METHOD):
|
||||
self.sock = sock
|
||||
self.alpn_selection = alpn_selection
|
||||
self.certs = certs
|
||||
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)
|
||||
self.cert_selection = cert_selection
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.sock, name)
|
||||
@@ -78,25 +55,24 @@ class SSLSocket: # pylint: disable=too-few-public-methods
|
||||
: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())
|
||||
server_name = connection.get_servername()
|
||||
try:
|
||||
key, cert = self.certs[server_name]
|
||||
except KeyError:
|
||||
logger.debug("Server name (%s) not recognized, dropping SSL",
|
||||
server_name)
|
||||
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 = OpenSSL.SSL.Context(self.method)
|
||||
new_context.set_options(OpenSSL.SSL.OP_NO_SSLv2)
|
||||
new_context.set_options(OpenSSL.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:
|
||||
class FakeConnection(object):
|
||||
"""Fake OpenSSL.SSL.Connection."""
|
||||
|
||||
# pylint: disable=missing-function-docstring
|
||||
# pylint: disable=too-few-public-methods,missing-docstring
|
||||
|
||||
def __init__(self, connection):
|
||||
self._wrapped = connection
|
||||
@@ -108,23 +84,21 @@ class SSLSocket: # pylint: disable=too-few-public-methods
|
||||
# OpenSSL.SSL.Connection.shutdown doesn't accept any args
|
||||
return self._wrapped.shutdown()
|
||||
|
||||
def accept(self): # pylint: disable=missing-function-docstring
|
||||
def accept(self): # pylint: disable=missing-docstring
|
||||
sock, addr = self.sock.accept()
|
||||
|
||||
context = SSL.Context(self.method)
|
||||
context.set_options(SSL.OP_NO_SSLv2)
|
||||
context.set_options(SSL.OP_NO_SSLv3)
|
||||
context = OpenSSL.SSL.Context(self.method)
|
||||
context.set_options(OpenSSL.SSL.OP_NO_SSLv2)
|
||||
context.set_options(OpenSSL.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 = self.FakeConnection(OpenSSL.SSL.Connection(context, sock))
|
||||
ssl_sock.set_accept_state()
|
||||
|
||||
logger.debug("Performing handshake with %s", addr)
|
||||
try:
|
||||
ssl_sock.do_handshake()
|
||||
except SSL.Error as error:
|
||||
except OpenSSL.SSL.Error as error:
|
||||
# _pick_certificate_cb might have returned without
|
||||
# creating SSL context (wrong server name)
|
||||
raise socket.error(error)
|
||||
@@ -132,9 +106,8 @@ class SSLSocket: # pylint: disable=too-few-public-methods
|
||||
return ssl_sock, addr
|
||||
|
||||
|
||||
def probe_sni(name, host, port=443, timeout=300, # pylint: disable=too-many-arguments
|
||||
method=_DEFAULT_SSL_METHOD, source_address=('', 0),
|
||||
alpn_protocols=None):
|
||||
def probe_sni(name, host, port=443, timeout=300,
|
||||
method=_DEFAULT_TLSSNI01_SSL_METHOD, source_address=('0', 0)):
|
||||
"""Probe SNI server for SSL certificate.
|
||||
|
||||
:param bytes name: Byte string to send as the server name in the
|
||||
@@ -146,8 +119,6 @@ def probe_sni(name, host, port=443, timeout=300, # pylint: disable=too-many-argu
|
||||
: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: `list` of `bytes`
|
||||
|
||||
:raises acme.errors.Error: In case of any problems.
|
||||
|
||||
@@ -155,79 +126,30 @@ def probe_sni(name, host, port=443, timeout=300, # pylint: disable=too-many-argu
|
||||
:rtype: OpenSSL.crypto.X509
|
||||
|
||||
"""
|
||||
context = SSL.Context(method)
|
||||
context = OpenSSL.SSL.Context(method)
|
||||
context.set_timeout(timeout)
|
||||
|
||||
socket_kwargs = {'source_address': source_address}
|
||||
socket_kwargs = {} if sys.version_info < (2, 7) else {
|
||||
'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[str, int] = (host, port)
|
||||
sock = socket.create_connection(socket_tuple, **socket_kwargs) # type: ignore
|
||||
# pylint: disable=star-args
|
||||
sock = socket.create_connection((host, port), **socket_kwargs)
|
||||
except socket.error as error:
|
||||
raise errors.Error(error)
|
||||
|
||||
with contextlib.closing(sock) as client:
|
||||
client_ssl = SSL.Connection(context, client)
|
||||
client_ssl = OpenSSL.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:
|
||||
except OpenSSL.SSL.Error as error:
|
||||
raise errors.Error(error)
|
||||
return client_ssl.get_peer_certificate()
|
||||
|
||||
|
||||
def make_csr(private_key_pem, domains, must_staple=False):
|
||||
"""Generate a CSR containing a list of domains 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).
|
||||
:returns: buffer PEM-encoded Certificate Signing Request.
|
||||
"""
|
||||
private_key = crypto.load_privatekey(
|
||||
crypto.FILETYPE_PEM, private_key_pem)
|
||||
csr = crypto.X509Req()
|
||||
extensions = [
|
||||
crypto.X509Extension(
|
||||
b'subjectAltName',
|
||||
critical=False,
|
||||
value=', '.join('DNS:' + d for d in domains).encode('ascii')
|
||||
),
|
||||
]
|
||||
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)
|
||||
csr.set_version(2)
|
||||
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):
|
||||
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):
|
||||
"""Get Subject Alternative Names from certificate or CSR using pyOpenSSL.
|
||||
|
||||
@@ -254,15 +176,14 @@ def _pyopenssl_cert_or_req_san(cert_or_req):
|
||||
parts_separator = ", "
|
||||
prefix = "DNS" + part_separator
|
||||
|
||||
if isinstance(cert_or_req, crypto.X509):
|
||||
# pylint: disable=line-too-long
|
||||
func: Union[Callable[[int, crypto.X509Req], bytes], Callable[[int, crypto.X509], bytes]] = crypto.dump_certificate
|
||||
if isinstance(cert_or_req, OpenSSL.crypto.X509):
|
||||
func = OpenSSL.crypto.dump_certificate
|
||||
else:
|
||||
func = crypto.dump_certificate_request
|
||||
text = func(crypto.FILETYPE_TEXT, cert_or_req).decode("utf-8")
|
||||
func = OpenSSL.crypto.dump_certificate_request
|
||||
text = func(OpenSSL.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.
|
||||
match = re.search(r"X509v3 Subject Alternative Name:(?: critical)?\s*(.*)", text)
|
||||
match = re.search(r"X509v3 Subject Alternative Name:\s*(.*)", text)
|
||||
# WARNING: this function assumes that no SAN can include
|
||||
# parts_separator, hence the split!
|
||||
sans_parts = [] if match is None else match.group(1).split(parts_separator)
|
||||
@@ -272,14 +193,12 @@ def _pyopenssl_cert_or_req_san(cert_or_req):
|
||||
|
||||
|
||||
def gen_ss_cert(key, domains, not_before=None,
|
||||
validity=(7 * 24 * 60 * 60), force_san=True, extensions=None):
|
||||
validity=(7 * 24 * 60 * 60), force_san=True):
|
||||
"""Generate new self-signed certificate.
|
||||
|
||||
:type domains: `list` of `unicode`
|
||||
: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`
|
||||
|
||||
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
|
||||
@@ -288,24 +207,21 @@ def gen_ss_cert(key, domains, not_before=None,
|
||||
|
||||
"""
|
||||
assert domains, "Must provide one or more hostnames for the cert."
|
||||
cert = crypto.X509()
|
||||
cert.set_serial_number(int(binascii.hexlify(os.urandom(16)), 16))
|
||||
cert = OpenSSL.crypto.X509()
|
||||
cert.set_serial_number(int(binascii.hexlify(OpenSSL.rand.bytes(16)), 16))
|
||||
cert.set_version(2)
|
||||
|
||||
if extensions is None:
|
||||
extensions = []
|
||||
|
||||
extensions.append(
|
||||
crypto.X509Extension(
|
||||
extensions = [
|
||||
OpenSSL.crypto.X509Extension(
|
||||
b"basicConstraints", True, b"CA:TRUE, pathlen:0"),
|
||||
)
|
||||
]
|
||||
|
||||
cert.get_subject().CN = domains[0]
|
||||
# TODO: what to put into cert.get_subject()?
|
||||
cert.set_issuer(cert.get_subject())
|
||||
|
||||
if force_san or len(domains) > 1:
|
||||
extensions.append(crypto.X509Extension(
|
||||
extensions.append(OpenSSL.crypto.X509Extension(
|
||||
b"subjectAltName",
|
||||
critical=False,
|
||||
value=b", ".join(b"DNS:" + d.encode() for d in domains)
|
||||
@@ -319,26 +235,3 @@ def gen_ss_cert(key, domains, not_before=None,
|
||||
cert.set_pubkey(key)
|
||||
cert.sign(key, "sha256")
|
||||
return cert
|
||||
|
||||
|
||||
def dump_pyopenssl_chain(chain, filetype=crypto.FILETYPE_PEM):
|
||||
"""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):
|
||||
if isinstance(cert, jose.ComparableX509):
|
||||
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)
|
||||
|
||||
156
acme/acme/crypto_util_test.py
Normal file
156
acme/acme/crypto_util_test.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Tests for acme.crypto_util."""
|
||||
import itertools
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import six
|
||||
from six.moves import socketserver #type: ignore # pylint: disable=import-error
|
||||
|
||||
import OpenSSL
|
||||
|
||||
from acme import errors
|
||||
from acme import jose
|
||||
from acme import test_util
|
||||
|
||||
|
||||
class SSLSocketAndProbeSNITest(unittest.TestCase):
|
||||
"""Tests for acme.crypto_util.SSLSocket/probe_sni."""
|
||||
|
||||
_multiprocess_can_split_ = True
|
||||
|
||||
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):
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
# six.moves.* | pylint: disable=attribute-defined-outside-init,no-init
|
||||
|
||||
def server_bind(self): # pylint: disable=missing-docstring
|
||||
self.socket = SSLSocket(socket.socket(), certs=certs)
|
||||
socketserver.TCPServer.server_bind(self)
|
||||
|
||||
self.server = _TestServer(('', 0), socketserver.BaseRequestHandler)
|
||||
self.port = self.server.socket.getsockname()[1]
|
||||
self.server_thread = threading.Thread(
|
||||
# pylint: disable=no-member
|
||||
target=self.server.handle_request)
|
||||
self.server_thread.start()
|
||||
time.sleep(1) # TODO: avoid race conditions in other way
|
||||
|
||||
def tearDown(self):
|
||||
self.server_thread.join()
|
||||
|
||||
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 test_probe_ok(self):
|
||||
self.assertEqual(self.cert, self._probe(b'foo'))
|
||||
|
||||
def test_probe_not_recognized_name(self):
|
||||
self.assertRaises(errors.Error, self._probe, b'bar')
|
||||
|
||||
# TODO: py33/py34 tox hangs forever on do_handshake in second probe
|
||||
#def probe_connection_error(self):
|
||||
# self._probe(b'foo')
|
||||
# #time.sleep(1) # TODO: avoid race conditions in other way
|
||||
# self.assertRaises(errors.Error, self._probe, b'bar')
|
||||
|
||||
|
||||
class PyOpenSSLCertOrReqSANTest(unittest.TestCase):
|
||||
"""Test for acme.crypto_util._pyopenssl_cert_or_req_san."""
|
||||
|
||||
_multiprocess_can_split_ = True
|
||||
|
||||
@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 = [six.unichr(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):
|
||||
self.assertEqual(self._call_cert('cert.pem'), [])
|
||||
|
||||
def test_cert_two_sans(self):
|
||||
self.assertEqual(self._call_cert('cert-san.pem'),
|
||||
['example.com', 'www.example.com'])
|
||||
|
||||
def test_cert_hundred_sans(self):
|
||||
self.assertEqual(self._call_cert('cert-100sans.pem'),
|
||||
['example{0}.com'.format(i) for i in range(1, 101)])
|
||||
|
||||
def test_cert_idn_sans(self):
|
||||
self.assertEqual(self._call_cert('cert-idnsans.pem'),
|
||||
self._get_idn_names())
|
||||
|
||||
def test_csr_no_sans(self):
|
||||
self.assertEqual(self._call_csr('csr-nosans.pem'), [])
|
||||
|
||||
def test_csr_one_san(self):
|
||||
self.assertEqual(self._call_csr('csr.pem'), ['example.com'])
|
||||
|
||||
def test_csr_two_sans(self):
|
||||
self.assertEqual(self._call_csr('csr-san.pem'),
|
||||
['example.com', 'www.example.com'])
|
||||
|
||||
def test_csr_six_sans(self):
|
||||
self.assertEqual(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):
|
||||
self.assertEqual(self._call_csr('csr-100sans.pem'),
|
||||
['example{0}.com'.format(i) for i in range(1, 101)])
|
||||
|
||||
def test_csr_idn_sans(self):
|
||||
self.assertEqual(self._call_csr('csr-idnsans.pem'),
|
||||
self._get_idn_names())
|
||||
|
||||
|
||||
class RandomSnTest(unittest.TestCase):
|
||||
"""Test for random certificate serial numbers."""
|
||||
|
||||
_multiprocess_can_split_ = True
|
||||
|
||||
def setUp(self):
|
||||
self.cert_count = 5
|
||||
self.serial_num = []
|
||||
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)
|
||||
self.serial_num.append(cert.get_serial_number())
|
||||
self.assertTrue(len(set(self.serial_num)) > 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
@@ -1,5 +1,5 @@
|
||||
"""ACME errors."""
|
||||
from josepy import errors as jose_errors
|
||||
from acme.jose import errors as jose_errors
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
@@ -28,8 +28,8 @@ class NonceError(ClientError):
|
||||
|
||||
class BadNonce(NonceError):
|
||||
"""Bad nonce error."""
|
||||
def __init__(self, nonce, error, *args):
|
||||
super(BadNonce, self).__init__(*args)
|
||||
def __init__(self, nonce, error, *args, **kwargs):
|
||||
super(BadNonce, self).__init__(*args, **kwargs)
|
||||
self.nonce = nonce
|
||||
self.error = error
|
||||
|
||||
@@ -44,11 +44,11 @@ class MissingNonce(NonceError):
|
||||
Replay-Nonce header field in each successful response to a POST it
|
||||
provides to a client (...)".
|
||||
|
||||
:ivar requests.Response ~.response: HTTP Response
|
||||
:ivar requests.Response response: HTTP Response
|
||||
|
||||
"""
|
||||
def __init__(self, response, *args):
|
||||
super(MissingNonce, self).__init__(*args)
|
||||
def __init__(self, response, *args, **kwargs):
|
||||
super(MissingNonce, self).__init__(*args, **kwargs)
|
||||
self.response = response
|
||||
|
||||
def __str__(self):
|
||||
@@ -82,45 +82,3 @@ class PollError(ClientError):
|
||||
def __repr__(self):
|
||||
return '{0}(exhausted={1!r}, updated={2!r})'.format(
|
||||
self.__class__.__name__, self.exhausted, self.updated)
|
||||
|
||||
|
||||
class ValidationError(Error):
|
||||
"""Error for authorization failures. Contains a list of authorization
|
||||
resources, each of which is invalid and should have an error field.
|
||||
"""
|
||||
def __init__(self, failed_authzrs):
|
||||
self.failed_authzrs = failed_authzrs
|
||||
super(ValidationError, self).__init__()
|
||||
|
||||
|
||||
class TimeoutError(Error): # pylint: disable=redefined-builtin
|
||||
"""Error for when polling an authorization or an order times out."""
|
||||
|
||||
|
||||
class IssuanceError(Error):
|
||||
"""Error sent by the server after requesting issuance of a certificate."""
|
||||
|
||||
def __init__(self, error):
|
||||
"""Initialize.
|
||||
|
||||
:param messages.Error error: The error provided by the server.
|
||||
"""
|
||||
self.error = error
|
||||
super(IssuanceError, self).__init__()
|
||||
|
||||
|
||||
class ConflictError(ClientError):
|
||||
"""Error for when the server returns a 409 (Conflict) HTTP status.
|
||||
|
||||
In the version of ACME implemented by Boulder, this is used to find an
|
||||
account if you only have the private key, but don't know the account URL.
|
||||
|
||||
Also used in V2 of the ACME client for the same purpose.
|
||||
"""
|
||||
def __init__(self, location):
|
||||
self.location = location
|
||||
super(ConflictError, self).__init__()
|
||||
|
||||
|
||||
class WildcardUnsupportedError(Error):
|
||||
"""Error for when a wildcard is requested but is unsupported by ACME CA."""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for acme.errors."""
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import mock
|
||||
|
||||
|
||||
class BadNonceTest(unittest.TestCase):
|
||||
@@ -34,7 +35,7 @@ class PollErrorTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from acme.errors import PollError
|
||||
self.timeout = PollError(
|
||||
exhausted={mock.sentinel.AR},
|
||||
exhausted=set([mock.sentinel.AR]),
|
||||
updated={})
|
||||
self.invalid = PollError(exhausted=set(), updated={
|
||||
mock.sentinel.AR: mock.sentinel.AR2})
|
||||
@@ -1,9 +1,11 @@
|
||||
"""ACME JSON fields."""
|
||||
import logging
|
||||
|
||||
import josepy as jose
|
||||
import pyrfc3339
|
||||
|
||||
from acme import jose
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import datetime
|
||||
import unittest
|
||||
|
||||
import josepy as jose
|
||||
import pytz
|
||||
|
||||
from acme import jose
|
||||
|
||||
|
||||
class FixedTest(unittest.TestCase):
|
||||
"""Tests for acme.fields.Fixed."""
|
||||
82
acme/acme/jose/__init__.py
Normal file
82
acme/acme/jose/__init__.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Javascript Object Signing and Encryption (jose).
|
||||
|
||||
This package is a Python implementation of the standards developed by
|
||||
IETF `Javascript Object Signing and Encryption (Active WG)`_, in
|
||||
particular the following RFCs:
|
||||
|
||||
- `JSON Web Algorithms (JWA)`_
|
||||
- `JSON Web Key (JWK)`_
|
||||
- `JSON Web Signature (JWS)`_
|
||||
|
||||
|
||||
.. _`Javascript Object Signing and Encryption (Active WG)`:
|
||||
https://tools.ietf.org/wg/jose/
|
||||
|
||||
.. _`JSON Web Algorithms (JWA)`:
|
||||
https://datatracker.ietf.org/doc/draft-ietf-jose-json-web-algorithms/
|
||||
|
||||
.. _`JSON Web Key (JWK)`:
|
||||
https://datatracker.ietf.org/doc/draft-ietf-jose-json-web-key/
|
||||
|
||||
.. _`JSON Web Signature (JWS)`:
|
||||
https://datatracker.ietf.org/doc/draft-ietf-jose-json-web-signature/
|
||||
|
||||
"""
|
||||
from acme.jose.b64 import (
|
||||
b64decode,
|
||||
b64encode,
|
||||
)
|
||||
|
||||
from acme.jose.errors import (
|
||||
DeserializationError,
|
||||
SerializationError,
|
||||
Error,
|
||||
UnrecognizedTypeError,
|
||||
)
|
||||
|
||||
from acme.jose.interfaces import JSONDeSerializable
|
||||
|
||||
from acme.jose.json_util import (
|
||||
Field,
|
||||
JSONObjectWithFields,
|
||||
TypedJSONObjectWithFields,
|
||||
decode_b64jose,
|
||||
decode_cert,
|
||||
decode_csr,
|
||||
decode_hex16,
|
||||
encode_b64jose,
|
||||
encode_cert,
|
||||
encode_csr,
|
||||
encode_hex16,
|
||||
)
|
||||
|
||||
from acme.jose.jwa import (
|
||||
HS256,
|
||||
HS384,
|
||||
HS512,
|
||||
JWASignature,
|
||||
PS256,
|
||||
PS384,
|
||||
PS512,
|
||||
RS256,
|
||||
RS384,
|
||||
RS512,
|
||||
)
|
||||
|
||||
from acme.jose.jwk import (
|
||||
JWK,
|
||||
JWKRSA,
|
||||
)
|
||||
|
||||
from acme.jose.jws import (
|
||||
Header,
|
||||
JWS,
|
||||
Signature,
|
||||
)
|
||||
|
||||
from acme.jose.util import (
|
||||
ComparableX509,
|
||||
ComparableKey,
|
||||
ComparableRSAKey,
|
||||
ImmutableMap,
|
||||
)
|
||||
61
acme/acme/jose/b64.py
Normal file
61
acme/acme/jose/b64.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""JOSE Base64.
|
||||
|
||||
`JOSE Base64`_ is defined as:
|
||||
|
||||
- URL-safe Base64
|
||||
- padding stripped
|
||||
|
||||
|
||||
.. _`JOSE Base64`:
|
||||
https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-37#appendix-C
|
||||
|
||||
.. Do NOT try to call this module "base64", as it will "shadow" the
|
||||
standard library.
|
||||
|
||||
"""
|
||||
import base64
|
||||
|
||||
import six
|
||||
|
||||
|
||||
def b64encode(data):
|
||||
"""JOSE Base64 encode.
|
||||
|
||||
:param data: Data to be encoded.
|
||||
:type data: `bytes`
|
||||
|
||||
:returns: JOSE Base64 string.
|
||||
:rtype: bytes
|
||||
|
||||
:raises TypeError: if `data` is of incorrect type
|
||||
|
||||
"""
|
||||
if not isinstance(data, six.binary_type):
|
||||
raise TypeError('argument should be {0}'.format(six.binary_type))
|
||||
return base64.urlsafe_b64encode(data).rstrip(b'=')
|
||||
|
||||
|
||||
def b64decode(data):
|
||||
"""JOSE Base64 decode.
|
||||
|
||||
:param data: Base64 string to be decoded. If it's unicode, then
|
||||
only ASCII characters are allowed.
|
||||
:type data: `bytes` or `unicode`
|
||||
|
||||
:returns: Decoded data.
|
||||
:rtype: bytes
|
||||
|
||||
:raises TypeError: if input is of incorrect type
|
||||
:raises ValueError: if input is unicode with non-ASCII characters
|
||||
|
||||
"""
|
||||
if isinstance(data, six.string_types):
|
||||
try:
|
||||
data = data.encode('ascii')
|
||||
except UnicodeEncodeError:
|
||||
raise ValueError(
|
||||
'unicode argument should contain only ASCII characters')
|
||||
elif not isinstance(data, six.binary_type):
|
||||
raise TypeError('argument should be a str or unicode')
|
||||
|
||||
return base64.urlsafe_b64decode(data + b'=' * (4 - (len(data) % 4)))
|
||||
77
acme/acme/jose/b64_test.py
Normal file
77
acme/acme/jose/b64_test.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Tests for acme.jose.b64."""
|
||||
import unittest
|
||||
|
||||
import six
|
||||
|
||||
|
||||
# https://en.wikipedia.org/wiki/Base64#Examples
|
||||
B64_PADDING_EXAMPLES = {
|
||||
b'any carnal pleasure.': (b'YW55IGNhcm5hbCBwbGVhc3VyZS4', b'='),
|
||||
b'any carnal pleasure': (b'YW55IGNhcm5hbCBwbGVhc3VyZQ', b'=='),
|
||||
b'any carnal pleasur': (b'YW55IGNhcm5hbCBwbGVhc3Vy', b''),
|
||||
b'any carnal pleasu': (b'YW55IGNhcm5hbCBwbGVhc3U', b'='),
|
||||
b'any carnal pleas': (b'YW55IGNhcm5hbCBwbGVhcw', b'=='),
|
||||
}
|
||||
|
||||
|
||||
B64_URL_UNSAFE_EXAMPLES = {
|
||||
six.int2byte(251) + six.int2byte(239): b'--8',
|
||||
six.int2byte(255) * 2: b'__8',
|
||||
}
|
||||
|
||||
|
||||
class B64EncodeTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.b64.b64encode."""
|
||||
|
||||
@classmethod
|
||||
def _call(cls, data):
|
||||
from acme.jose.b64 import b64encode
|
||||
return b64encode(data)
|
||||
|
||||
def test_empty(self):
|
||||
self.assertEqual(self._call(b''), b'')
|
||||
|
||||
def test_unsafe_url(self):
|
||||
for text, b64 in six.iteritems(B64_URL_UNSAFE_EXAMPLES):
|
||||
self.assertEqual(self._call(text), b64)
|
||||
|
||||
def test_different_paddings(self):
|
||||
for text, (b64, _) in six.iteritems(B64_PADDING_EXAMPLES):
|
||||
self.assertEqual(self._call(text), b64)
|
||||
|
||||
def test_unicode_fails_with_type_error(self):
|
||||
self.assertRaises(TypeError, self._call, u'some unicode')
|
||||
|
||||
|
||||
class B64DecodeTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.b64.b64decode."""
|
||||
|
||||
@classmethod
|
||||
def _call(cls, data):
|
||||
from acme.jose.b64 import b64decode
|
||||
return b64decode(data)
|
||||
|
||||
def test_unsafe_url(self):
|
||||
for text, b64 in six.iteritems(B64_URL_UNSAFE_EXAMPLES):
|
||||
self.assertEqual(self._call(b64), text)
|
||||
|
||||
def test_input_without_padding(self):
|
||||
for text, (b64, _) in six.iteritems(B64_PADDING_EXAMPLES):
|
||||
self.assertEqual(self._call(b64), text)
|
||||
|
||||
def test_input_with_padding(self):
|
||||
for text, (b64, pad) in six.iteritems(B64_PADDING_EXAMPLES):
|
||||
self.assertEqual(self._call(b64 + pad), text)
|
||||
|
||||
def test_unicode_with_ascii(self):
|
||||
self.assertEqual(self._call(u'YQ'), b'a')
|
||||
|
||||
def test_non_ascii_unicode_fails(self):
|
||||
self.assertRaises(ValueError, self._call, u'\u0105')
|
||||
|
||||
def test_type_error_no_unicode_or_bytes(self):
|
||||
self.assertRaises(TypeError, self._call, object())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
35
acme/acme/jose/errors.py
Normal file
35
acme/acme/jose/errors.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""JOSE errors."""
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
"""Generic JOSE Error."""
|
||||
|
||||
|
||||
class DeserializationError(Error):
|
||||
"""JSON deserialization error."""
|
||||
|
||||
def __str__(self):
|
||||
return "Deserialization error: {0}".format(
|
||||
super(DeserializationError, self).__str__())
|
||||
|
||||
|
||||
class SerializationError(Error):
|
||||
"""JSON serialization error."""
|
||||
|
||||
|
||||
class UnrecognizedTypeError(DeserializationError):
|
||||
"""Unrecognized type error.
|
||||
|
||||
:ivar str typ: The unrecognized type of the JSON object.
|
||||
:ivar jobj: Full JSON object.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, typ, jobj):
|
||||
self.typ = typ
|
||||
self.jobj = jobj
|
||||
super(UnrecognizedTypeError, self).__init__(str(self))
|
||||
|
||||
def __str__(self):
|
||||
return '{0} was not recognized, full message: {1}'.format(
|
||||
self.typ, self.jobj)
|
||||
17
acme/acme/jose/errors_test.py
Normal file
17
acme/acme/jose/errors_test.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Tests for acme.jose.errors."""
|
||||
import unittest
|
||||
|
||||
|
||||
class UnrecognizedTypeErrorTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from acme.jose.errors import UnrecognizedTypeError
|
||||
self.error = UnrecognizedTypeError('foo', {'type': 'foo'})
|
||||
|
||||
def test_str(self):
|
||||
self.assertEqual(
|
||||
"foo was not recognized, full message: {'type': 'foo'}",
|
||||
str(self.error))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
216
acme/acme/jose/interfaces.py
Normal file
216
acme/acme/jose/interfaces.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""JOSE interfaces."""
|
||||
import abc
|
||||
import collections
|
||||
import json
|
||||
|
||||
import six
|
||||
|
||||
from acme.jose import errors
|
||||
from acme.jose import util
|
||||
|
||||
# pylint: disable=no-self-argument,no-method-argument,no-init,inherit-non-class
|
||||
# pylint: disable=too-few-public-methods
|
||||
|
||||
|
||||
@six.add_metaclass(abc.ABCMeta)
|
||||
class JSONDeSerializable(object):
|
||||
# pylint: disable=too-few-public-methods
|
||||
"""Interface for (de)serializable JSON objects.
|
||||
|
||||
Please recall, that standard Python library implements
|
||||
:class:`json.JSONEncoder` and :class:`json.JSONDecoder` that perform
|
||||
translations based on respective :ref:`conversion tables
|
||||
<conversion-table>` that look pretty much like the one below (for
|
||||
complete tables see relevant Python documentation):
|
||||
|
||||
.. _conversion-table:
|
||||
|
||||
====== ======
|
||||
JSON Python
|
||||
====== ======
|
||||
object dict
|
||||
... ...
|
||||
====== ======
|
||||
|
||||
While the above **conversion table** is about translation of JSON
|
||||
documents to/from the basic Python types only,
|
||||
:class:`JSONDeSerializable` introduces the following two concepts:
|
||||
|
||||
serialization
|
||||
Turning an arbitrary Python object into Python object that can
|
||||
be encoded into a JSON document. **Full serialization** produces
|
||||
a Python object composed of only basic types as required by the
|
||||
:ref:`conversion table <conversion-table>`. **Partial
|
||||
serialization** (accomplished by :meth:`to_partial_json`)
|
||||
produces a Python object that might also be built from other
|
||||
:class:`JSONDeSerializable` objects.
|
||||
|
||||
deserialization
|
||||
Turning a decoded Python object (necessarily one of the basic
|
||||
types as required by the :ref:`conversion table
|
||||
<conversion-table>`) into an arbitrary Python object.
|
||||
|
||||
Serialization produces **serialized object** ("partially serialized
|
||||
object" or "fully serialized object" for partial and full
|
||||
serialization respectively) and deserialization produces
|
||||
**deserialized object**, both usually denoted in the source code as
|
||||
``jobj``.
|
||||
|
||||
Wording in the official Python documentation might be confusing
|
||||
after reading the above, but in the light of those definitions, one
|
||||
can view :meth:`json.JSONDecoder.decode` as decoder and
|
||||
deserializer of basic types, :meth:`json.JSONEncoder.default` as
|
||||
serializer of basic types, :meth:`json.JSONEncoder.encode` as
|
||||
serializer and encoder of basic types.
|
||||
|
||||
One could extend :mod:`json` to support arbitrary object
|
||||
(de)serialization either by:
|
||||
|
||||
- overriding :meth:`json.JSONDecoder.decode` and
|
||||
:meth:`json.JSONEncoder.default` in subclasses
|
||||
|
||||
- or passing ``object_hook`` argument (or ``object_hook_pairs``)
|
||||
to :func:`json.load`/:func:`json.loads` or ``default`` argument
|
||||
for :func:`json.dump`/:func:`json.dumps`.
|
||||
|
||||
Interestingly, ``default`` is required to perform only partial
|
||||
serialization, as :func:`json.dumps` applies ``default``
|
||||
recursively. This is the idea behind making :meth:`to_partial_json`
|
||||
produce only partial serialization, while providing custom
|
||||
:meth:`json_dumps` that dumps with ``default`` set to
|
||||
:meth:`json_dump_default`.
|
||||
|
||||
To make further documentation a bit more concrete, please, consider
|
||||
the following imaginatory implementation example::
|
||||
|
||||
class Foo(JSONDeSerializable):
|
||||
def to_partial_json(self):
|
||||
return 'foo'
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
return Foo()
|
||||
|
||||
class Bar(JSONDeSerializable):
|
||||
def to_partial_json(self):
|
||||
return [Foo(), Foo()]
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
return Bar()
|
||||
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def to_partial_json(self): # pragma: no cover
|
||||
"""Partially serialize.
|
||||
|
||||
Following the example, **partial serialization** means the following::
|
||||
|
||||
assert isinstance(Bar().to_partial_json()[0], Foo)
|
||||
assert isinstance(Bar().to_partial_json()[1], Foo)
|
||||
|
||||
# in particular...
|
||||
assert Bar().to_partial_json() != ['foo', 'foo']
|
||||
|
||||
:raises acme.jose.errors.SerializationError:
|
||||
in case of any serialization error.
|
||||
:returns: Partially serializable object.
|
||||
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def to_json(self):
|
||||
"""Fully serialize.
|
||||
|
||||
Again, following the example from before, **full serialization**
|
||||
means the following::
|
||||
|
||||
assert Bar().to_json() == ['foo', 'foo']
|
||||
|
||||
:raises acme.jose.errors.SerializationError:
|
||||
in case of any serialization error.
|
||||
:returns: Fully serialized object.
|
||||
|
||||
"""
|
||||
def _serialize(obj):
|
||||
if isinstance(obj, JSONDeSerializable):
|
||||
return _serialize(obj.to_partial_json())
|
||||
if isinstance(obj, six.string_types): # strings are Sequence
|
||||
return obj
|
||||
elif isinstance(obj, list):
|
||||
return [_serialize(subobj) for subobj in obj]
|
||||
elif isinstance(obj, collections.Sequence):
|
||||
# default to tuple, otherwise Mapping could get
|
||||
# unhashable list
|
||||
return tuple(_serialize(subobj) for subobj in obj)
|
||||
elif isinstance(obj, collections.Mapping):
|
||||
return dict((_serialize(key), _serialize(value))
|
||||
for key, value in six.iteritems(obj))
|
||||
else:
|
||||
return obj
|
||||
|
||||
return _serialize(self)
|
||||
|
||||
@util.abstractclassmethod
|
||||
def from_json(cls, jobj): # pylint: disable=unused-argument
|
||||
"""Deserialize a decoded JSON document.
|
||||
|
||||
:param jobj: Python object, composed of only other basic data
|
||||
types, as decoded from JSON document. Not necessarily
|
||||
:class:`dict` (as decoded from "JSON object" document).
|
||||
|
||||
:raises acme.jose.errors.DeserializationError:
|
||||
if decoding was unsuccessful, e.g. in case of unparseable
|
||||
X509 certificate, or wrong padding in JOSE base64 encoded
|
||||
string, etc.
|
||||
|
||||
"""
|
||||
# TypeError: Can't instantiate abstract class <cls> with
|
||||
# abstract methods from_json, to_partial_json
|
||||
return cls() # pylint: disable=abstract-class-instantiated
|
||||
|
||||
@classmethod
|
||||
def json_loads(cls, json_string):
|
||||
"""Deserialize from JSON document string."""
|
||||
try:
|
||||
loads = json.loads(json_string)
|
||||
except ValueError as error:
|
||||
raise errors.DeserializationError(error)
|
||||
return cls.from_json(loads)
|
||||
|
||||
def json_dumps(self, **kwargs):
|
||||
"""Dump to JSON string using proper serializer.
|
||||
|
||||
:returns: JSON document string.
|
||||
:rtype: str
|
||||
|
||||
"""
|
||||
return json.dumps(self, default=self.json_dump_default, **kwargs)
|
||||
|
||||
def json_dumps_pretty(self):
|
||||
"""Dump the object to pretty JSON document string.
|
||||
|
||||
:rtype: str
|
||||
|
||||
"""
|
||||
return self.json_dumps(sort_keys=True, indent=4, separators=(',', ': '))
|
||||
|
||||
@classmethod
|
||||
def json_dump_default(cls, python_object):
|
||||
"""Serialize Python object.
|
||||
|
||||
This function is meant to be passed as ``default`` to
|
||||
:func:`json.dump` or :func:`json.dumps`. They call
|
||||
``default(python_object)`` only for non-basic Python types, so
|
||||
this function necessarily raises :class:`TypeError` if
|
||||
``python_object`` is not an instance of
|
||||
:class:`IJSONSerializable`.
|
||||
|
||||
Please read the class docstring for more information.
|
||||
|
||||
"""
|
||||
if isinstance(python_object, JSONDeSerializable):
|
||||
return python_object.to_partial_json()
|
||||
else: # this branch is necessary, cannot just "return"
|
||||
raise TypeError(repr(python_object) + ' is not JSON serializable')
|
||||
114
acme/acme/jose/interfaces_test.py
Normal file
114
acme/acme/jose/interfaces_test.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Tests for acme.jose.interfaces."""
|
||||
import unittest
|
||||
|
||||
|
||||
class JSONDeSerializableTest(unittest.TestCase):
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
|
||||
def setUp(self):
|
||||
from acme.jose.interfaces import JSONDeSerializable
|
||||
|
||||
# pylint: disable=missing-docstring,invalid-name
|
||||
|
||||
class Basic(JSONDeSerializable):
|
||||
def __init__(self, v):
|
||||
self.v = v
|
||||
|
||||
def to_partial_json(self):
|
||||
return self.v
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
return cls(jobj)
|
||||
|
||||
class Sequence(JSONDeSerializable):
|
||||
def __init__(self, x, y):
|
||||
self.x = x
|
||||
self.y = y
|
||||
|
||||
def to_partial_json(self):
|
||||
return [self.x, self.y]
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
return cls(
|
||||
Basic.from_json(jobj[0]), Basic.from_json(jobj[1]))
|
||||
|
||||
class Mapping(JSONDeSerializable):
|
||||
def __init__(self, x, y):
|
||||
self.x = x
|
||||
self.y = y
|
||||
|
||||
def to_partial_json(self):
|
||||
return {self.x: self.y}
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
pass # pragma: no cover
|
||||
|
||||
self.basic1 = Basic('foo1')
|
||||
self.basic2 = Basic('foo2')
|
||||
self.seq = Sequence(self.basic1, self.basic2)
|
||||
self.mapping = Mapping(self.basic1, self.basic2)
|
||||
self.nested = Basic([[self.basic1]])
|
||||
self.tuple = Basic(('foo',))
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
self.Basic = Basic
|
||||
self.Sequence = Sequence
|
||||
self.Mapping = Mapping
|
||||
|
||||
def test_to_json_sequence(self):
|
||||
self.assertEqual(self.seq.to_json(), ['foo1', 'foo2'])
|
||||
|
||||
def test_to_json_mapping(self):
|
||||
self.assertEqual(self.mapping.to_json(), {'foo1': 'foo2'})
|
||||
|
||||
def test_to_json_other(self):
|
||||
mock_value = object()
|
||||
self.assertTrue(self.Basic(mock_value).to_json() is mock_value)
|
||||
|
||||
def test_to_json_nested(self):
|
||||
self.assertEqual(self.nested.to_json(), [['foo1']])
|
||||
|
||||
def test_to_json(self):
|
||||
self.assertEqual(self.tuple.to_json(), (('foo', )))
|
||||
|
||||
def test_from_json_not_implemented(self):
|
||||
from acme.jose.interfaces import JSONDeSerializable
|
||||
self.assertRaises(TypeError, JSONDeSerializable.from_json, 'xxx')
|
||||
|
||||
def test_json_loads(self):
|
||||
seq = self.Sequence.json_loads('["foo1", "foo2"]')
|
||||
self.assertTrue(isinstance(seq, self.Sequence))
|
||||
self.assertTrue(isinstance(seq.x, self.Basic))
|
||||
self.assertTrue(isinstance(seq.y, self.Basic))
|
||||
self.assertEqual(seq.x.v, 'foo1')
|
||||
self.assertEqual(seq.y.v, 'foo2')
|
||||
|
||||
def test_json_dumps(self):
|
||||
self.assertEqual('["foo1", "foo2"]', self.seq.json_dumps())
|
||||
|
||||
def test_json_dumps_pretty(self):
|
||||
self.assertEqual(self.seq.json_dumps_pretty(),
|
||||
'[\n "foo1",\n "foo2"\n]')
|
||||
|
||||
def test_json_dump_default(self):
|
||||
from acme.jose.interfaces import JSONDeSerializable
|
||||
|
||||
self.assertEqual(
|
||||
'foo1', JSONDeSerializable.json_dump_default(self.basic1))
|
||||
|
||||
jobj = JSONDeSerializable.json_dump_default(self.seq)
|
||||
self.assertEqual(len(jobj), 2)
|
||||
self.assertTrue(jobj[0] is self.basic1)
|
||||
self.assertTrue(jobj[1] is self.basic2)
|
||||
|
||||
def test_json_dump_default_type_error(self):
|
||||
from acme.jose.interfaces import JSONDeSerializable
|
||||
self.assertRaises(
|
||||
TypeError, JSONDeSerializable.json_dump_default, object())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
485
acme/acme/jose/json_util.py
Normal file
485
acme/acme/jose/json_util.py
Normal file
@@ -0,0 +1,485 @@
|
||||
"""JSON (de)serialization framework.
|
||||
|
||||
The framework presented here is somewhat based on `Go's "json" package`_
|
||||
(especially the ``omitempty`` functionality).
|
||||
|
||||
.. _`Go's "json" package`: http://golang.org/pkg/encoding/json/
|
||||
|
||||
"""
|
||||
import abc
|
||||
import binascii
|
||||
import logging
|
||||
|
||||
import OpenSSL
|
||||
import six
|
||||
|
||||
from acme.jose import b64
|
||||
from acme.jose import errors
|
||||
from acme.jose import interfaces
|
||||
from acme.jose import util
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Field(object):
|
||||
"""JSON object field.
|
||||
|
||||
:class:`Field` is meant to be used together with
|
||||
:class:`JSONObjectWithFields`.
|
||||
|
||||
``encoder`` (``decoder``) is a callable that accepts a single
|
||||
parameter, i.e. a value to be encoded (decoded), and returns the
|
||||
serialized (deserialized) value. In case of errors it should raise
|
||||
:class:`~acme.jose.errors.SerializationError`
|
||||
(:class:`~acme.jose.errors.DeserializationError`).
|
||||
|
||||
Note, that ``decoder`` should perform partial serialization only.
|
||||
|
||||
:ivar str json_name: Name of the field when encoded to JSON.
|
||||
:ivar default: Default value (used when not present in JSON object).
|
||||
:ivar bool omitempty: If ``True`` and the field value is empty, then
|
||||
it will not be included in the serialized JSON object, and
|
||||
``default`` will be used for deserialization. Otherwise, if ``False``,
|
||||
field is considered as required, value will always be included in the
|
||||
serialized JSON objected, and it must also be present when
|
||||
deserializing.
|
||||
|
||||
"""
|
||||
__slots__ = ('json_name', 'default', 'omitempty', 'fdec', 'fenc')
|
||||
|
||||
def __init__(self, json_name, default=None, omitempty=False,
|
||||
decoder=None, encoder=None):
|
||||
# pylint: disable=too-many-arguments
|
||||
self.json_name = json_name
|
||||
self.default = default
|
||||
self.omitempty = omitempty
|
||||
|
||||
self.fdec = self.default_decoder if decoder is None else decoder
|
||||
self.fenc = self.default_encoder if encoder is None else encoder
|
||||
|
||||
@classmethod
|
||||
def _empty(cls, value):
|
||||
"""Is the provided value considered "empty" for this field?
|
||||
|
||||
This is useful for subclasses that might want to override the
|
||||
definition of being empty, e.g. for some more exotic data types.
|
||||
|
||||
"""
|
||||
return not isinstance(value, bool) and not value
|
||||
|
||||
def omit(self, value):
|
||||
"""Omit the value in output?"""
|
||||
return self._empty(value) and self.omitempty
|
||||
|
||||
def _update_params(self, **kwargs):
|
||||
current = dict(json_name=self.json_name, default=self.default,
|
||||
omitempty=self.omitempty,
|
||||
decoder=self.fdec, encoder=self.fenc)
|
||||
current.update(kwargs)
|
||||
return type(self)(**current) # pylint: disable=star-args
|
||||
|
||||
def decoder(self, fdec):
|
||||
"""Descriptor to change the decoder on JSON object field."""
|
||||
return self._update_params(decoder=fdec)
|
||||
|
||||
def encoder(self, fenc):
|
||||
"""Descriptor to change the encoder on JSON object field."""
|
||||
return self._update_params(encoder=fenc)
|
||||
|
||||
def decode(self, value):
|
||||
"""Decode a value, optionally with context JSON object."""
|
||||
return self.fdec(value)
|
||||
|
||||
def encode(self, value):
|
||||
"""Encode a value, optionally with context JSON object."""
|
||||
return self.fenc(value)
|
||||
|
||||
@classmethod
|
||||
def default_decoder(cls, value):
|
||||
"""Default decoder.
|
||||
|
||||
Recursively deserialize into immutable types (
|
||||
:class:`acme.jose.util.frozendict` instead of
|
||||
:func:`dict`, :func:`tuple` instead of :func:`list`).
|
||||
|
||||
"""
|
||||
# bases cases for different types returned by json.loads
|
||||
if isinstance(value, list):
|
||||
return tuple(cls.default_decoder(subvalue) for subvalue in value)
|
||||
elif isinstance(value, dict):
|
||||
return util.frozendict(
|
||||
dict((cls.default_decoder(key), cls.default_decoder(value))
|
||||
for key, value in six.iteritems(value)))
|
||||
else: # integer or string
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def default_encoder(cls, value):
|
||||
"""Default (passthrough) encoder."""
|
||||
# field.to_partial_json() is no good as encoder has to do partial
|
||||
# serialization only
|
||||
return value
|
||||
|
||||
|
||||
class JSONObjectWithFieldsMeta(abc.ABCMeta):
|
||||
"""Metaclass for :class:`JSONObjectWithFields` and its subclasses.
|
||||
|
||||
It makes sure that, for any class ``cls`` with ``__metaclass__``
|
||||
set to ``JSONObjectWithFieldsMeta``:
|
||||
|
||||
1. All fields (attributes of type :class:`Field`) in the class
|
||||
definition are moved to the ``cls._fields`` dictionary, where
|
||||
keys are field attribute names and values are fields themselves.
|
||||
|
||||
2. ``cls.__slots__`` is extended by all field attribute names
|
||||
(i.e. not :attr:`Field.json_name`). Original ``cls.__slots__``
|
||||
are stored in ``cls._orig_slots``.
|
||||
|
||||
In a consequence, for a field attribute name ``some_field``,
|
||||
``cls.some_field`` will be a slot descriptor and not an instance
|
||||
of :class:`Field`. For example::
|
||||
|
||||
some_field = Field('someField', default=())
|
||||
|
||||
class Foo(object):
|
||||
__metaclass__ = JSONObjectWithFieldsMeta
|
||||
__slots__ = ('baz',)
|
||||
some_field = some_field
|
||||
|
||||
assert Foo.__slots__ == ('some_field', 'baz')
|
||||
assert Foo._orig_slots == ()
|
||||
assert Foo.some_field is not Field
|
||||
|
||||
assert Foo._fields.keys() == ['some_field']
|
||||
assert Foo._fields['some_field'] is some_field
|
||||
|
||||
As an implementation note, this metaclass inherits from
|
||||
:class:`abc.ABCMeta` (and not the usual :class:`type`) to mitigate
|
||||
the metaclass conflict (:class:`ImmutableMap` and
|
||||
:class:`JSONDeSerializable`, parents of :class:`JSONObjectWithFields`,
|
||||
use :class:`abc.ABCMeta` as its metaclass).
|
||||
|
||||
"""
|
||||
|
||||
def __new__(mcs, name, bases, dikt):
|
||||
fields = {}
|
||||
|
||||
for base in bases:
|
||||
fields.update(getattr(base, '_fields', {}))
|
||||
# Do not reorder, this class might override fields from base classes!
|
||||
for key, value in tuple(six.iteritems(dikt)):
|
||||
# not six.iterkeys() (in-place edit!)
|
||||
if isinstance(value, Field):
|
||||
fields[key] = dikt.pop(key)
|
||||
|
||||
dikt['_orig_slots'] = dikt.get('__slots__', ())
|
||||
dikt['__slots__'] = tuple(
|
||||
list(dikt['_orig_slots']) + list(six.iterkeys(fields)))
|
||||
dikt['_fields'] = fields
|
||||
|
||||
return abc.ABCMeta.__new__(mcs, name, bases, dikt)
|
||||
|
||||
|
||||
@six.add_metaclass(JSONObjectWithFieldsMeta)
|
||||
class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable):
|
||||
# pylint: disable=too-few-public-methods
|
||||
"""JSON object with fields.
|
||||
|
||||
Example::
|
||||
|
||||
class Foo(JSONObjectWithFields):
|
||||
bar = Field('Bar')
|
||||
empty = Field('Empty', omitempty=True)
|
||||
|
||||
@bar.encoder
|
||||
def bar(value):
|
||||
return value + 'bar'
|
||||
|
||||
@bar.decoder
|
||||
def bar(value):
|
||||
if not value.endswith('bar'):
|
||||
raise errors.DeserializationError('No bar suffix!')
|
||||
return value[:-3]
|
||||
|
||||
assert Foo(bar='baz').to_partial_json() == {'Bar': 'bazbar'}
|
||||
assert Foo.from_json({'Bar': 'bazbar'}) == Foo(bar='baz')
|
||||
assert (Foo.from_json({'Bar': 'bazbar', 'Empty': '!'})
|
||||
== Foo(bar='baz', empty='!'))
|
||||
assert Foo(bar='baz').bar == 'baz'
|
||||
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def _defaults(cls):
|
||||
"""Get default fields values."""
|
||||
return dict([(slot, field.default) for slot, field
|
||||
in six.iteritems(cls._fields)])
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
# pylint: disable=star-args
|
||||
super(JSONObjectWithFields, self).__init__(
|
||||
**(dict(self._defaults(), **kwargs)))
|
||||
|
||||
def encode(self, name):
|
||||
"""Encode a single field.
|
||||
|
||||
:param str name: Name of the field to be encoded.
|
||||
|
||||
:raises errors.SerializationError: if field cannot be serialized
|
||||
:raises errors.Error: if field could not be found
|
||||
|
||||
"""
|
||||
try:
|
||||
field = self._fields[name]
|
||||
except KeyError:
|
||||
raise errors.Error("Field not found: {0}".format(name))
|
||||
|
||||
return field.encode(getattr(self, name))
|
||||
|
||||
def fields_to_partial_json(self):
|
||||
"""Serialize fields to JSON."""
|
||||
jobj = {}
|
||||
omitted = set()
|
||||
for slot, field in six.iteritems(self._fields):
|
||||
value = getattr(self, slot)
|
||||
|
||||
if field.omit(value):
|
||||
omitted.add((slot, value))
|
||||
else:
|
||||
try:
|
||||
jobj[field.json_name] = field.encode(value)
|
||||
except errors.SerializationError as error:
|
||||
raise errors.SerializationError(
|
||||
'Could not encode {0} ({1}): {2}'.format(
|
||||
slot, value, error))
|
||||
return jobj
|
||||
|
||||
def to_partial_json(self):
|
||||
return self.fields_to_partial_json()
|
||||
|
||||
@classmethod
|
||||
def _check_required(cls, jobj):
|
||||
missing = set()
|
||||
for _, field in six.iteritems(cls._fields):
|
||||
if not field.omitempty and field.json_name not in jobj:
|
||||
missing.add(field.json_name)
|
||||
|
||||
if missing:
|
||||
raise errors.DeserializationError(
|
||||
'The following fields are required: {0}'.format(
|
||||
','.join(missing)))
|
||||
|
||||
@classmethod
|
||||
def fields_from_json(cls, jobj):
|
||||
"""Deserialize fields from JSON."""
|
||||
cls._check_required(jobj)
|
||||
fields = {}
|
||||
for slot, field in six.iteritems(cls._fields):
|
||||
if field.json_name not in jobj and field.omitempty:
|
||||
fields[slot] = field.default
|
||||
else:
|
||||
value = jobj[field.json_name]
|
||||
try:
|
||||
fields[slot] = field.decode(value)
|
||||
except errors.DeserializationError as error:
|
||||
raise errors.DeserializationError(
|
||||
'Could not decode {0!r} ({1!r}): {2}'.format(
|
||||
slot, value, error))
|
||||
return fields
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
return cls(**cls.fields_from_json(jobj))
|
||||
|
||||
|
||||
def encode_b64jose(data):
|
||||
"""Encode JOSE Base-64 field.
|
||||
|
||||
:param bytes data:
|
||||
:rtype: `unicode`
|
||||
|
||||
"""
|
||||
# b64encode produces ASCII characters only
|
||||
return b64.b64encode(data).decode('ascii')
|
||||
|
||||
|
||||
def decode_b64jose(data, size=None, minimum=False):
|
||||
"""Decode JOSE Base-64 field.
|
||||
|
||||
:param unicode data:
|
||||
:param int size: Required length (after decoding).
|
||||
:param bool minimum: If ``True``, then `size` will be treated as
|
||||
minimum required length, as opposed to exact equality.
|
||||
|
||||
:rtype: bytes
|
||||
|
||||
"""
|
||||
error_cls = TypeError if six.PY2 else binascii.Error
|
||||
try:
|
||||
decoded = b64.b64decode(data.encode())
|
||||
except error_cls as error:
|
||||
raise errors.DeserializationError(error)
|
||||
|
||||
if size is not None and ((not minimum and len(decoded) != size) or
|
||||
(minimum and len(decoded) < size)):
|
||||
raise errors.DeserializationError(
|
||||
"Expected at least or exactly {0} bytes".format(size))
|
||||
|
||||
return decoded
|
||||
|
||||
|
||||
def encode_hex16(value):
|
||||
"""Hexlify.
|
||||
|
||||
:param bytes value:
|
||||
:rtype: unicode
|
||||
|
||||
"""
|
||||
return binascii.hexlify(value).decode()
|
||||
|
||||
|
||||
def decode_hex16(value, size=None, minimum=False):
|
||||
"""Decode hexlified field.
|
||||
|
||||
:param unicode value:
|
||||
:param int size: Required length (after decoding).
|
||||
:param bool minimum: If ``True``, then `size` will be treated as
|
||||
minimum required length, as opposed to exact equality.
|
||||
|
||||
:rtype: bytes
|
||||
|
||||
"""
|
||||
value = value.encode()
|
||||
if size is not None and ((not minimum and len(value) != size * 2) or
|
||||
(minimum and len(value) < size * 2)):
|
||||
raise errors.DeserializationError()
|
||||
error_cls = TypeError if six.PY2 else binascii.Error
|
||||
try:
|
||||
return binascii.unhexlify(value)
|
||||
except error_cls as error:
|
||||
raise errors.DeserializationError(error)
|
||||
|
||||
|
||||
def encode_cert(cert):
|
||||
"""Encode certificate as JOSE Base-64 DER.
|
||||
|
||||
:type cert: `OpenSSL.crypto.X509` wrapped in `.ComparableX509`
|
||||
:rtype: unicode
|
||||
|
||||
"""
|
||||
return encode_b64jose(OpenSSL.crypto.dump_certificate(
|
||||
OpenSSL.crypto.FILETYPE_ASN1, cert.wrapped))
|
||||
|
||||
|
||||
def decode_cert(b64der):
|
||||
"""Decode JOSE Base-64 DER-encoded certificate.
|
||||
|
||||
:param unicode b64der:
|
||||
:rtype: `OpenSSL.crypto.X509` wrapped in `.ComparableX509`
|
||||
|
||||
"""
|
||||
try:
|
||||
return util.ComparableX509(OpenSSL.crypto.load_certificate(
|
||||
OpenSSL.crypto.FILETYPE_ASN1, decode_b64jose(b64der)))
|
||||
except OpenSSL.crypto.Error as error:
|
||||
raise errors.DeserializationError(error)
|
||||
|
||||
|
||||
def encode_csr(csr):
|
||||
"""Encode CSR as JOSE Base-64 DER.
|
||||
|
||||
:type csr: `OpenSSL.crypto.X509Req` wrapped in `.ComparableX509`
|
||||
:rtype: unicode
|
||||
|
||||
"""
|
||||
return encode_b64jose(OpenSSL.crypto.dump_certificate_request(
|
||||
OpenSSL.crypto.FILETYPE_ASN1, csr.wrapped))
|
||||
|
||||
|
||||
def decode_csr(b64der):
|
||||
"""Decode JOSE Base-64 DER-encoded CSR.
|
||||
|
||||
:param unicode b64der:
|
||||
:rtype: `OpenSSL.crypto.X509Req` wrapped in `.ComparableX509`
|
||||
|
||||
"""
|
||||
try:
|
||||
return util.ComparableX509(OpenSSL.crypto.load_certificate_request(
|
||||
OpenSSL.crypto.FILETYPE_ASN1, decode_b64jose(b64der)))
|
||||
except OpenSSL.crypto.Error as error:
|
||||
raise errors.DeserializationError(error)
|
||||
|
||||
|
||||
class TypedJSONObjectWithFields(JSONObjectWithFields):
|
||||
"""JSON object with type."""
|
||||
|
||||
typ = NotImplemented
|
||||
"""Type of the object. Subclasses must override."""
|
||||
|
||||
type_field_name = "type"
|
||||
"""Field name used to distinguish different object types.
|
||||
|
||||
Subclasses will probably have to override this.
|
||||
|
||||
"""
|
||||
|
||||
TYPES = NotImplemented
|
||||
"""Types registered for JSON deserialization"""
|
||||
|
||||
@classmethod
|
||||
def register(cls, type_cls, typ=None):
|
||||
"""Register class for JSON deserialization."""
|
||||
typ = type_cls.typ if typ is None else typ
|
||||
cls.TYPES[typ] = type_cls
|
||||
return type_cls
|
||||
|
||||
@classmethod
|
||||
def get_type_cls(cls, jobj):
|
||||
"""Get the registered class for ``jobj``."""
|
||||
if cls in six.itervalues(cls.TYPES):
|
||||
if cls.type_field_name not in jobj:
|
||||
raise errors.DeserializationError(
|
||||
"Missing type field ({0})".format(cls.type_field_name))
|
||||
# cls is already registered type_cls, force to use it
|
||||
# so that, e.g Revocation.from_json(jobj) fails if
|
||||
# jobj["type"] != "revocation".
|
||||
return cls
|
||||
|
||||
if not isinstance(jobj, dict):
|
||||
raise errors.DeserializationError(
|
||||
"{0} is not a dictionary object".format(jobj))
|
||||
try:
|
||||
typ = jobj[cls.type_field_name]
|
||||
except KeyError:
|
||||
raise errors.DeserializationError("missing type field")
|
||||
|
||||
try:
|
||||
return cls.TYPES[typ]
|
||||
except KeyError:
|
||||
raise errors.UnrecognizedTypeError(typ, jobj)
|
||||
|
||||
def to_partial_json(self):
|
||||
"""Get JSON serializable object.
|
||||
|
||||
:returns: Serializable JSON object representing ACME typed object.
|
||||
:meth:`validate` will almost certainly not work, due to reasons
|
||||
explained in :class:`acme.interfaces.IJSONSerializable`.
|
||||
:rtype: dict
|
||||
|
||||
"""
|
||||
jobj = self.fields_to_partial_json()
|
||||
jobj[self.type_field_name] = self.typ
|
||||
return jobj
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
"""Deserialize ACME object from valid JSON object.
|
||||
|
||||
:raises acme.errors.UnrecognizedTypeError: if type
|
||||
of the ACME object has not been registered.
|
||||
|
||||
"""
|
||||
# make sure subclasses don't cause infinite recursive from_json calls
|
||||
type_cls = cls.get_type_cls(jobj)
|
||||
return type_cls(**type_cls.fields_from_json(jobj))
|
||||
381
acme/acme/jose/json_util_test.py
Normal file
381
acme/acme/jose/json_util_test.py
Normal file
@@ -0,0 +1,381 @@
|
||||
"""Tests for acme.jose.json_util."""
|
||||
import itertools
|
||||
import unittest
|
||||
|
||||
import mock
|
||||
import six
|
||||
|
||||
from acme import test_util
|
||||
|
||||
from acme.jose import errors
|
||||
from acme.jose import interfaces
|
||||
from acme.jose import util
|
||||
|
||||
|
||||
CERT = test_util.load_comparable_cert('cert.pem')
|
||||
CSR = test_util.load_comparable_csr('csr.pem')
|
||||
|
||||
|
||||
class FieldTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.json_util.Field."""
|
||||
|
||||
def test_no_omit_boolean(self):
|
||||
from acme.jose.json_util import Field
|
||||
for default, omitempty, value in itertools.product(
|
||||
[True, False], [True, False], [True, False]):
|
||||
self.assertFalse(
|
||||
Field("foo", default=default, omitempty=omitempty).omit(value))
|
||||
|
||||
def test_descriptors(self):
|
||||
mock_value = mock.MagicMock()
|
||||
|
||||
# pylint: disable=missing-docstring
|
||||
|
||||
def decoder(unused_value):
|
||||
return 'd'
|
||||
|
||||
def encoder(unused_value):
|
||||
return 'e'
|
||||
|
||||
from acme.jose.json_util import Field
|
||||
field = Field('foo')
|
||||
|
||||
field = field.encoder(encoder)
|
||||
self.assertEqual('e', field.encode(mock_value))
|
||||
|
||||
field = field.decoder(decoder)
|
||||
self.assertEqual('e', field.encode(mock_value))
|
||||
self.assertEqual('d', field.decode(mock_value))
|
||||
|
||||
def test_default_encoder_is_partial(self):
|
||||
class MockField(interfaces.JSONDeSerializable):
|
||||
# pylint: disable=missing-docstring
|
||||
def to_partial_json(self):
|
||||
return 'foo' # pragma: no cover
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
pass # pragma: no cover
|
||||
mock_field = MockField()
|
||||
|
||||
from acme.jose.json_util import Field
|
||||
self.assertTrue(Field.default_encoder(mock_field) is mock_field)
|
||||
# in particular...
|
||||
self.assertNotEqual('foo', Field.default_encoder(mock_field))
|
||||
|
||||
def test_default_encoder_passthrough(self):
|
||||
mock_value = mock.MagicMock()
|
||||
from acme.jose.json_util import Field
|
||||
self.assertTrue(Field.default_encoder(mock_value) is mock_value)
|
||||
|
||||
def test_default_decoder_list_to_tuple(self):
|
||||
from acme.jose.json_util import Field
|
||||
self.assertEqual((1, 2, 3), Field.default_decoder([1, 2, 3]))
|
||||
|
||||
def test_default_decoder_dict_to_frozendict(self):
|
||||
from acme.jose.json_util import Field
|
||||
obj = Field.default_decoder({'x': 2})
|
||||
self.assertTrue(isinstance(obj, util.frozendict))
|
||||
self.assertEqual(obj, util.frozendict(x=2))
|
||||
|
||||
def test_default_decoder_passthrough(self):
|
||||
mock_value = mock.MagicMock()
|
||||
from acme.jose.json_util import Field
|
||||
self.assertTrue(Field.default_decoder(mock_value) is mock_value)
|
||||
|
||||
|
||||
class JSONObjectWithFieldsMetaTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.json_util.JSONObjectWithFieldsMeta."""
|
||||
|
||||
def setUp(self):
|
||||
from acme.jose.json_util import Field
|
||||
from acme.jose.json_util import JSONObjectWithFieldsMeta
|
||||
self.field = Field('Baz')
|
||||
self.field2 = Field('Baz2')
|
||||
# pylint: disable=invalid-name,missing-docstring,too-few-public-methods
|
||||
# pylint: disable=blacklisted-name
|
||||
|
||||
@six.add_metaclass(JSONObjectWithFieldsMeta)
|
||||
class A(object):
|
||||
__slots__ = ('bar',)
|
||||
baz = self.field
|
||||
|
||||
class B(A):
|
||||
pass
|
||||
|
||||
class C(A):
|
||||
baz = self.field2
|
||||
|
||||
self.a_cls = A
|
||||
self.b_cls = B
|
||||
self.c_cls = C
|
||||
|
||||
def test_fields(self):
|
||||
# pylint: disable=protected-access,no-member
|
||||
self.assertEqual({'baz': self.field}, self.a_cls._fields)
|
||||
self.assertEqual({'baz': self.field}, self.b_cls._fields)
|
||||
|
||||
def test_fields_inheritance(self):
|
||||
# pylint: disable=protected-access,no-member
|
||||
self.assertEqual({'baz': self.field2}, self.c_cls._fields)
|
||||
|
||||
def test_slots(self):
|
||||
self.assertEqual(('bar', 'baz'), self.a_cls.__slots__)
|
||||
self.assertEqual(('baz',), self.b_cls.__slots__)
|
||||
|
||||
def test_orig_slots(self):
|
||||
# pylint: disable=protected-access,no-member
|
||||
self.assertEqual(('bar',), self.a_cls._orig_slots)
|
||||
self.assertEqual((), self.b_cls._orig_slots)
|
||||
|
||||
|
||||
class JSONObjectWithFieldsTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.json_util.JSONObjectWithFields."""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
def setUp(self):
|
||||
from acme.jose.json_util import JSONObjectWithFields
|
||||
from acme.jose.json_util import Field
|
||||
|
||||
class MockJSONObjectWithFields(JSONObjectWithFields):
|
||||
# pylint: disable=invalid-name,missing-docstring,no-self-argument
|
||||
# pylint: disable=too-few-public-methods
|
||||
x = Field('x', omitempty=True,
|
||||
encoder=(lambda x: x * 2),
|
||||
decoder=(lambda x: x / 2))
|
||||
y = Field('y')
|
||||
z = Field('Z') # on purpose uppercase
|
||||
|
||||
@y.encoder
|
||||
def y(value):
|
||||
if value == 500:
|
||||
raise errors.SerializationError()
|
||||
return value
|
||||
|
||||
@y.decoder
|
||||
def y(value):
|
||||
if value == 500:
|
||||
raise errors.DeserializationError()
|
||||
return value
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
self.MockJSONObjectWithFields = MockJSONObjectWithFields
|
||||
self.mock = MockJSONObjectWithFields(x=None, y=2, z=3)
|
||||
|
||||
def test_init_defaults(self):
|
||||
self.assertEqual(self.mock, self.MockJSONObjectWithFields(y=2, z=3))
|
||||
|
||||
def test_encode(self):
|
||||
self.assertEqual(10, self.MockJSONObjectWithFields(
|
||||
x=5, y=0, z=0).encode("x"))
|
||||
|
||||
def test_encode_wrong_field(self):
|
||||
self.assertRaises(errors.Error, self.mock.encode, 'foo')
|
||||
|
||||
def test_encode_serialization_error_passthrough(self):
|
||||
self.assertRaises(
|
||||
errors.SerializationError,
|
||||
self.MockJSONObjectWithFields(y=500, z=None).encode, "y")
|
||||
|
||||
def test_fields_to_partial_json_omits_empty(self):
|
||||
self.assertEqual(self.mock.fields_to_partial_json(), {'y': 2, 'Z': 3})
|
||||
|
||||
def test_fields_from_json_fills_default_for_empty(self):
|
||||
self.assertEqual(
|
||||
{'x': None, 'y': 2, 'z': 3},
|
||||
self.MockJSONObjectWithFields.fields_from_json({'y': 2, 'Z': 3}))
|
||||
|
||||
def test_fields_from_json_fails_on_missing(self):
|
||||
self.assertRaises(
|
||||
errors.DeserializationError,
|
||||
self.MockJSONObjectWithFields.fields_from_json, {'y': 0})
|
||||
self.assertRaises(
|
||||
errors.DeserializationError,
|
||||
self.MockJSONObjectWithFields.fields_from_json, {'Z': 0})
|
||||
self.assertRaises(
|
||||
errors.DeserializationError,
|
||||
self.MockJSONObjectWithFields.fields_from_json, {'x': 0, 'y': 0})
|
||||
self.assertRaises(
|
||||
errors.DeserializationError,
|
||||
self.MockJSONObjectWithFields.fields_from_json, {'x': 0, 'Z': 0})
|
||||
|
||||
def test_fields_to_partial_json_encoder(self):
|
||||
self.assertEqual(
|
||||
self.MockJSONObjectWithFields(x=1, y=2, z=3).to_partial_json(),
|
||||
{'x': 2, 'y': 2, 'Z': 3})
|
||||
|
||||
def test_fields_from_json_decoder(self):
|
||||
self.assertEqual(
|
||||
{'x': 2, 'y': 2, 'z': 3},
|
||||
self.MockJSONObjectWithFields.fields_from_json(
|
||||
{'x': 4, 'y': 2, 'Z': 3}))
|
||||
|
||||
def test_fields_to_partial_json_error_passthrough(self):
|
||||
self.assertRaises(
|
||||
errors.SerializationError, self.MockJSONObjectWithFields(
|
||||
x=1, y=500, z=3).to_partial_json)
|
||||
|
||||
def test_fields_from_json_error_passthrough(self):
|
||||
self.assertRaises(
|
||||
errors.DeserializationError,
|
||||
self.MockJSONObjectWithFields.from_json,
|
||||
{'x': 4, 'y': 500, 'Z': 3})
|
||||
|
||||
|
||||
class DeEncodersTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.b64_cert = (
|
||||
u'MIIB3jCCAYigAwIBAgICBTkwDQYJKoZIhvcNAQELBQAwdzELMAkGA1UEBhM'
|
||||
u'CVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIwEAYDVQQHDAlBbm4gQXJib3IxKz'
|
||||
u'ApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTWljaGlnYW4gYW5kIHRoZSBFRkYxF'
|
||||
u'DASBgNVBAMMC2V4YW1wbGUuY29tMB4XDTE0MTIxMTIyMzQ0NVoXDTE0MTIx'
|
||||
u'ODIyMzQ0NVowdzELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRI'
|
||||
u'wEAYDVQQHDAlBbm4gQXJib3IxKzApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTW'
|
||||
u'ljaGlnYW4gYW5kIHRoZSBFRkYxFDASBgNVBAMMC2V4YW1wbGUuY29tMFwwD'
|
||||
u'QYJKoZIhvcNAQEBBQADSwAwSAJBAKx1c7RR7R_drnBSQ_zfx1vQLHUbFLh1'
|
||||
u'AQQQ5R8DZUXd36efNK79vukFhN9HFoHZiUvOjm0c-pVE6K-EdE_twuUCAwE'
|
||||
u'AATANBgkqhkiG9w0BAQsFAANBAC24z0IdwIVKSlntksllvr6zJepBH5fMnd'
|
||||
u'fk3XJp10jT6VE-14KNtjh02a56GoraAvJAT5_H67E8GvJ_ocNnB_o'
|
||||
)
|
||||
self.b64_csr = (
|
||||
u'MIIBXTCCAQcCAQAweTELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2F'
|
||||
u'uMRIwEAYDVQQHDAlBbm4gQXJib3IxDDAKBgNVBAoMA0VGRjEfMB0GA1UECw'
|
||||
u'wWVW5pdmVyc2l0eSBvZiBNaWNoaWdhbjEUMBIGA1UEAwwLZXhhbXBsZS5jb'
|
||||
u'20wXDANBgkqhkiG9w0BAQEFAANLADBIAkEArHVztFHtH92ucFJD_N_HW9As'
|
||||
u'dRsUuHUBBBDlHwNlRd3fp580rv2-6QWE30cWgdmJS86ObRz6lUTor4R0T-3'
|
||||
u'C5QIDAQABoCkwJwYJKoZIhvcNAQkOMRowGDAWBgNVHREEDzANggtleGFtcG'
|
||||
u'xlLmNvbTANBgkqhkiG9w0BAQsFAANBAHJH_O6BtC9aGzEVCMGOZ7z9iIRHW'
|
||||
u'Szr9x_bOzn7hLwsbXPAgO1QxEwL-X-4g20Gn9XBE1N9W6HCIEut2d8wACg'
|
||||
)
|
||||
|
||||
def test_encode_b64jose(self):
|
||||
from acme.jose.json_util import encode_b64jose
|
||||
encoded = encode_b64jose(b'x')
|
||||
self.assertTrue(isinstance(encoded, six.string_types))
|
||||
self.assertEqual(u'eA', encoded)
|
||||
|
||||
def test_decode_b64jose(self):
|
||||
from acme.jose.json_util import decode_b64jose
|
||||
decoded = decode_b64jose(u'eA')
|
||||
self.assertTrue(isinstance(decoded, six.binary_type))
|
||||
self.assertEqual(b'x', decoded)
|
||||
|
||||
def test_decode_b64jose_padding_error(self):
|
||||
from acme.jose.json_util import decode_b64jose
|
||||
self.assertRaises(errors.DeserializationError, decode_b64jose, u'x')
|
||||
|
||||
def test_decode_b64jose_size(self):
|
||||
from acme.jose.json_util import decode_b64jose
|
||||
self.assertEqual(b'foo', decode_b64jose(u'Zm9v', size=3))
|
||||
self.assertRaises(
|
||||
errors.DeserializationError, decode_b64jose, u'Zm9v', size=2)
|
||||
self.assertRaises(
|
||||
errors.DeserializationError, decode_b64jose, u'Zm9v', size=4)
|
||||
|
||||
def test_decode_b64jose_minimum_size(self):
|
||||
from acme.jose.json_util import decode_b64jose
|
||||
self.assertEqual(b'foo', decode_b64jose(u'Zm9v', size=3, minimum=True))
|
||||
self.assertEqual(b'foo', decode_b64jose(u'Zm9v', size=2, minimum=True))
|
||||
self.assertRaises(errors.DeserializationError, decode_b64jose,
|
||||
u'Zm9v', size=4, minimum=True)
|
||||
|
||||
def test_encode_hex16(self):
|
||||
from acme.jose.json_util import encode_hex16
|
||||
encoded = encode_hex16(b'foo')
|
||||
self.assertEqual(u'666f6f', encoded)
|
||||
self.assertTrue(isinstance(encoded, six.string_types))
|
||||
|
||||
def test_decode_hex16(self):
|
||||
from acme.jose.json_util import decode_hex16
|
||||
decoded = decode_hex16(u'666f6f')
|
||||
self.assertEqual(b'foo', decoded)
|
||||
self.assertTrue(isinstance(decoded, six.binary_type))
|
||||
|
||||
def test_decode_hex16_minimum_size(self):
|
||||
from acme.jose.json_util import decode_hex16
|
||||
self.assertEqual(b'foo', decode_hex16(u'666f6f', size=3, minimum=True))
|
||||
self.assertEqual(b'foo', decode_hex16(u'666f6f', size=2, minimum=True))
|
||||
self.assertRaises(errors.DeserializationError, decode_hex16,
|
||||
u'666f6f', size=4, minimum=True)
|
||||
|
||||
def test_decode_hex16_odd_length(self):
|
||||
from acme.jose.json_util import decode_hex16
|
||||
self.assertRaises(errors.DeserializationError, decode_hex16, u'x')
|
||||
|
||||
def test_encode_cert(self):
|
||||
from acme.jose.json_util import encode_cert
|
||||
self.assertEqual(self.b64_cert, encode_cert(CERT))
|
||||
|
||||
def test_decode_cert(self):
|
||||
from acme.jose.json_util import decode_cert
|
||||
cert = decode_cert(self.b64_cert)
|
||||
self.assertTrue(isinstance(cert, util.ComparableX509))
|
||||
self.assertEqual(cert, CERT)
|
||||
self.assertRaises(errors.DeserializationError, decode_cert, u'')
|
||||
|
||||
def test_encode_csr(self):
|
||||
from acme.jose.json_util import encode_csr
|
||||
self.assertEqual(self.b64_csr, encode_csr(CSR))
|
||||
|
||||
def test_decode_csr(self):
|
||||
from acme.jose.json_util import decode_csr
|
||||
csr = decode_csr(self.b64_csr)
|
||||
self.assertTrue(isinstance(csr, util.ComparableX509))
|
||||
self.assertEqual(csr, CSR)
|
||||
self.assertRaises(errors.DeserializationError, decode_csr, u'')
|
||||
|
||||
|
||||
class TypedJSONObjectWithFieldsTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
from acme.jose.json_util import TypedJSONObjectWithFields
|
||||
|
||||
# pylint: disable=missing-docstring,abstract-method
|
||||
# pylint: disable=too-few-public-methods
|
||||
|
||||
class MockParentTypedJSONObjectWithFields(TypedJSONObjectWithFields):
|
||||
TYPES = {}
|
||||
type_field_name = 'type'
|
||||
|
||||
@MockParentTypedJSONObjectWithFields.register
|
||||
class MockTypedJSONObjectWithFields(
|
||||
MockParentTypedJSONObjectWithFields):
|
||||
typ = 'test'
|
||||
__slots__ = ('foo',)
|
||||
|
||||
@classmethod
|
||||
def fields_from_json(cls, jobj):
|
||||
return {'foo': jobj['foo']}
|
||||
|
||||
def fields_to_partial_json(self):
|
||||
return {'foo': self.foo}
|
||||
|
||||
self.parent_cls = MockParentTypedJSONObjectWithFields
|
||||
self.msg = MockTypedJSONObjectWithFields(foo='bar')
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.msg.to_partial_json(), {
|
||||
'type': 'test',
|
||||
'foo': 'bar',
|
||||
})
|
||||
|
||||
def test_from_json_non_dict_fails(self):
|
||||
for value in [[], (), 5, "asd"]: # all possible input types
|
||||
self.assertRaises(
|
||||
errors.DeserializationError, self.parent_cls.from_json, value)
|
||||
|
||||
def test_from_json_dict_no_type_fails(self):
|
||||
self.assertRaises(
|
||||
errors.DeserializationError, self.parent_cls.from_json, {})
|
||||
|
||||
def test_from_json_unknown_type_fails(self):
|
||||
self.assertRaises(errors.UnrecognizedTypeError,
|
||||
self.parent_cls.from_json, {'type': 'bar'})
|
||||
|
||||
def test_from_json_returns_obj(self):
|
||||
self.assertEqual({'foo': 'bar'}, self.parent_cls.from_json(
|
||||
{'type': 'test', 'foo': 'bar'}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
180
acme/acme/jose/jwa.py
Normal file
180
acme/acme/jose/jwa.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""JSON Web Algorithm.
|
||||
|
||||
https://tools.ietf.org/html/draft-ietf-jose-json-web-algorithms-40
|
||||
|
||||
"""
|
||||
import abc
|
||||
import collections
|
||||
import logging
|
||||
|
||||
import cryptography.exceptions
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes # type: ignore
|
||||
from cryptography.hazmat.primitives import hmac # type: ignore
|
||||
from cryptography.hazmat.primitives.asymmetric import padding # type: ignore
|
||||
|
||||
from acme.jose import errors
|
||||
from acme.jose import interfaces
|
||||
from acme.jose import jwk
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JWA(interfaces.JSONDeSerializable): # pylint: disable=abstract-method
|
||||
# pylint: disable=too-few-public-methods
|
||||
# for some reason disable=abstract-method has to be on the line
|
||||
# above...
|
||||
"""JSON Web Algorithm."""
|
||||
|
||||
|
||||
class JWASignature(JWA, collections.Hashable): # type: ignore
|
||||
"""JSON Web Signature Algorithm."""
|
||||
SIGNATURES = {} # type: dict
|
||||
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, JWASignature):
|
||||
return NotImplemented
|
||||
return self.name == other.name
|
||||
|
||||
def __hash__(self):
|
||||
return hash((self.__class__, self.name))
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self == other
|
||||
|
||||
@classmethod
|
||||
def register(cls, signature_cls):
|
||||
"""Register class for JSON deserialization."""
|
||||
cls.SIGNATURES[signature_cls.name] = signature_cls
|
||||
return signature_cls
|
||||
|
||||
def to_partial_json(self):
|
||||
return self.name
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
return cls.SIGNATURES[jobj]
|
||||
|
||||
@abc.abstractmethod
|
||||
def sign(self, key, msg): # pragma: no cover
|
||||
"""Sign the ``msg`` using ``key``."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@abc.abstractmethod
|
||||
def verify(self, key, msg, sig): # pragma: no cover
|
||||
"""Verify the ``msg` and ``sig`` using ``key``."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def __repr__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class _JWAHS(JWASignature):
|
||||
|
||||
kty = jwk.JWKOct
|
||||
|
||||
def __init__(self, name, hash_):
|
||||
super(_JWAHS, self).__init__(name)
|
||||
self.hash = hash_()
|
||||
|
||||
def sign(self, key, msg):
|
||||
signer = hmac.HMAC(key, self.hash, backend=default_backend())
|
||||
signer.update(msg)
|
||||
return signer.finalize()
|
||||
|
||||
def verify(self, key, msg, sig):
|
||||
verifier = hmac.HMAC(key, self.hash, backend=default_backend())
|
||||
verifier.update(msg)
|
||||
try:
|
||||
verifier.verify(sig)
|
||||
except cryptography.exceptions.InvalidSignature as error:
|
||||
logger.debug(error, exc_info=True)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
class _JWARSA(object):
|
||||
|
||||
kty = jwk.JWKRSA
|
||||
padding = NotImplemented
|
||||
hash = NotImplemented
|
||||
|
||||
def sign(self, key, msg):
|
||||
"""Sign the ``msg`` using ``key``."""
|
||||
try:
|
||||
signer = key.signer(self.padding, self.hash)
|
||||
except AttributeError as error:
|
||||
logger.debug(error, exc_info=True)
|
||||
raise errors.Error("Public key cannot be used for signing")
|
||||
except ValueError as error: # digest too large
|
||||
logger.debug(error, exc_info=True)
|
||||
raise errors.Error(str(error))
|
||||
signer.update(msg)
|
||||
try:
|
||||
return signer.finalize()
|
||||
except ValueError as error:
|
||||
logger.debug(error, exc_info=True)
|
||||
raise errors.Error(str(error))
|
||||
|
||||
def verify(self, key, msg, sig):
|
||||
"""Verify the ``msg` and ``sig`` using ``key``."""
|
||||
verifier = key.verifier(sig, self.padding, self.hash)
|
||||
verifier.update(msg)
|
||||
try:
|
||||
verifier.verify()
|
||||
except cryptography.exceptions.InvalidSignature as error:
|
||||
logger.debug(error, exc_info=True)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
class _JWARS(_JWARSA, JWASignature):
|
||||
|
||||
def __init__(self, name, hash_):
|
||||
super(_JWARS, self).__init__(name)
|
||||
self.padding = padding.PKCS1v15()
|
||||
self.hash = hash_()
|
||||
|
||||
|
||||
class _JWAPS(_JWARSA, JWASignature):
|
||||
|
||||
def __init__(self, name, hash_):
|
||||
super(_JWAPS, self).__init__(name)
|
||||
self.padding = padding.PSS(
|
||||
mgf=padding.MGF1(hash_()),
|
||||
salt_length=padding.PSS.MAX_LENGTH)
|
||||
self.hash = hash_()
|
||||
|
||||
|
||||
class _JWAES(JWASignature): # pylint: disable=abstract-class-not-used
|
||||
|
||||
# TODO: implement ES signatures
|
||||
|
||||
def sign(self, key, msg): # pragma: no cover
|
||||
raise NotImplementedError()
|
||||
|
||||
def verify(self, key, msg, sig): # pragma: no cover
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
HS256 = JWASignature.register(_JWAHS('HS256', hashes.SHA256))
|
||||
HS384 = JWASignature.register(_JWAHS('HS384', hashes.SHA384))
|
||||
HS512 = JWASignature.register(_JWAHS('HS512', hashes.SHA512))
|
||||
|
||||
RS256 = JWASignature.register(_JWARS('RS256', hashes.SHA256))
|
||||
RS384 = JWASignature.register(_JWARS('RS384', hashes.SHA384))
|
||||
RS512 = JWASignature.register(_JWARS('RS512', hashes.SHA512))
|
||||
|
||||
PS256 = JWASignature.register(_JWAPS('PS256', hashes.SHA256))
|
||||
PS384 = JWASignature.register(_JWAPS('PS384', hashes.SHA384))
|
||||
PS512 = JWASignature.register(_JWAPS('PS512', hashes.SHA512))
|
||||
|
||||
ES256 = JWASignature.register(_JWAES('ES256'))
|
||||
ES384 = JWASignature.register(_JWAES('ES384'))
|
||||
ES512 = JWASignature.register(_JWAES('ES512'))
|
||||
104
acme/acme/jose/jwa_test.py
Normal file
104
acme/acme/jose/jwa_test.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Tests for acme.jose.jwa."""
|
||||
import unittest
|
||||
|
||||
from acme import test_util
|
||||
|
||||
from acme.jose import errors
|
||||
|
||||
|
||||
RSA256_KEY = test_util.load_rsa_private_key('rsa256_key.pem')
|
||||
RSA512_KEY = test_util.load_rsa_private_key('rsa512_key.pem')
|
||||
RSA1024_KEY = test_util.load_rsa_private_key('rsa1024_key.pem')
|
||||
|
||||
|
||||
class JWASignatureTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.jwa.JWASignature."""
|
||||
|
||||
def setUp(self):
|
||||
from acme.jose.jwa import JWASignature
|
||||
|
||||
class MockSig(JWASignature):
|
||||
# pylint: disable=missing-docstring,too-few-public-methods
|
||||
# pylint: disable=abstract-class-not-used
|
||||
def sign(self, key, msg):
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
def verify(self, key, msg, sig):
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
self.Sig1 = MockSig('Sig1')
|
||||
self.Sig2 = MockSig('Sig2')
|
||||
|
||||
def test_eq(self):
|
||||
self.assertEqual(self.Sig1, self.Sig1)
|
||||
|
||||
def test_ne(self):
|
||||
self.assertNotEqual(self.Sig1, self.Sig2)
|
||||
|
||||
def test_ne_other_type(self):
|
||||
self.assertNotEqual(self.Sig1, 5)
|
||||
|
||||
def test_repr(self):
|
||||
self.assertEqual('Sig1', repr(self.Sig1))
|
||||
self.assertEqual('Sig2', repr(self.Sig2))
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.Sig1.to_partial_json(), 'Sig1')
|
||||
self.assertEqual(self.Sig2.to_partial_json(), 'Sig2')
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.jose.jwa import JWASignature
|
||||
from acme.jose.jwa import RS256
|
||||
self.assertTrue(JWASignature.from_json('RS256') is RS256)
|
||||
|
||||
|
||||
class JWAHSTest(unittest.TestCase): # pylint: disable=too-few-public-methods
|
||||
|
||||
def test_it(self):
|
||||
from acme.jose.jwa import HS256
|
||||
sig = (
|
||||
b"\xceR\xea\xcd\x94\xab\xcf\xfb\xe0\xacA.:\x1a'\x08i\xe2\xc4"
|
||||
b"\r\x85+\x0e\x85\xaeUZ\xd4\xb3\x97zO"
|
||||
)
|
||||
self.assertEqual(HS256.sign(b'some key', b'foo'), sig)
|
||||
self.assertTrue(HS256.verify(b'some key', b'foo', sig) is True)
|
||||
self.assertTrue(HS256.verify(b'some key', b'foo', sig + b'!') is False)
|
||||
|
||||
|
||||
class JWARSTest(unittest.TestCase):
|
||||
|
||||
def test_sign_no_private_part(self):
|
||||
from acme.jose.jwa import RS256
|
||||
self.assertRaises(
|
||||
errors.Error, RS256.sign, RSA512_KEY.public_key(), b'foo')
|
||||
|
||||
def test_sign_key_too_small(self):
|
||||
from acme.jose.jwa import RS256
|
||||
from acme.jose.jwa import PS256
|
||||
self.assertRaises(errors.Error, RS256.sign, RSA256_KEY, b'foo')
|
||||
self.assertRaises(errors.Error, PS256.sign, RSA256_KEY, b'foo')
|
||||
|
||||
def test_rs(self):
|
||||
from acme.jose.jwa import RS256
|
||||
sig = (
|
||||
b'|\xc6\xb2\xa4\xab(\x87\x99\xfa*:\xea\xf8\xa0N&}\x9f\x0f\xc0O'
|
||||
b'\xc6t\xa3\xe6\xfa\xbb"\x15Y\x80Y\xe0\x81\xb8\x88)\xba\x0c\x9c'
|
||||
b'\xa4\x99\x1e\x19&\xd8\xc7\x99S\x97\xfc\x85\x0cOV\xe6\x07\x99'
|
||||
b'\xd2\xb9.>}\xfd'
|
||||
)
|
||||
self.assertEqual(RS256.sign(RSA512_KEY, b'foo'), sig)
|
||||
self.assertTrue(RS256.verify(RSA512_KEY.public_key(), b'foo', sig))
|
||||
self.assertFalse(RS256.verify(
|
||||
RSA512_KEY.public_key(), b'foo', sig + b'!'))
|
||||
|
||||
def test_ps(self):
|
||||
from acme.jose.jwa import PS256
|
||||
sig = PS256.sign(RSA1024_KEY, b'foo')
|
||||
self.assertTrue(PS256.verify(RSA1024_KEY.public_key(), b'foo', sig))
|
||||
self.assertFalse(PS256.verify(
|
||||
RSA1024_KEY.public_key(), b'foo', sig + b'!'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
281
acme/acme/jose/jwk.py
Normal file
281
acme/acme/jose/jwk.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""JSON Web Key."""
|
||||
import abc
|
||||
import binascii
|
||||
import json
|
||||
import logging
|
||||
|
||||
import cryptography.exceptions
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes # type: ignore
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec # type: ignore
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
import six
|
||||
|
||||
from acme.jose import errors
|
||||
from acme.jose import json_util
|
||||
from acme.jose import util
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JWK(json_util.TypedJSONObjectWithFields):
|
||||
# pylint: disable=too-few-public-methods
|
||||
"""JSON Web Key."""
|
||||
type_field_name = 'kty'
|
||||
TYPES = {} # type: dict
|
||||
cryptography_key_types = () # type: tuple
|
||||
"""Subclasses should override."""
|
||||
|
||||
required = NotImplemented
|
||||
"""Required members of public key's representation as defined by JWK/JWA."""
|
||||
|
||||
_thumbprint_json_dumps_params = {
|
||||
# "no whitespace or line breaks before or after any syntactic
|
||||
# elements"
|
||||
'indent': None,
|
||||
'separators': (',', ':'),
|
||||
# "members ordered lexicographically by the Unicode [UNICODE]
|
||||
# code points of the member names"
|
||||
'sort_keys': True,
|
||||
}
|
||||
|
||||
def thumbprint(self, hash_function=hashes.SHA256):
|
||||
"""Compute JWK Thumbprint.
|
||||
|
||||
https://tools.ietf.org/html/rfc7638
|
||||
|
||||
:returns bytes:
|
||||
|
||||
"""
|
||||
digest = hashes.Hash(hash_function(), backend=default_backend())
|
||||
digest.update(json.dumps(
|
||||
dict((k, v) for k, v in six.iteritems(self.to_json())
|
||||
if k in self.required),
|
||||
**self._thumbprint_json_dumps_params).encode())
|
||||
return digest.finalize()
|
||||
|
||||
@abc.abstractmethod
|
||||
def public_key(self): # pragma: no cover
|
||||
"""Generate JWK with public key.
|
||||
|
||||
For symmetric cryptosystems, this would return ``self``.
|
||||
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def _load_cryptography_key(cls, data, password=None, backend=None):
|
||||
backend = default_backend() if backend is None else backend
|
||||
exceptions = {}
|
||||
|
||||
# private key?
|
||||
for loader in (serialization.load_pem_private_key,
|
||||
serialization.load_der_private_key):
|
||||
try:
|
||||
return loader(data, password, backend)
|
||||
except (ValueError, TypeError,
|
||||
cryptography.exceptions.UnsupportedAlgorithm) as error:
|
||||
exceptions[loader] = error
|
||||
|
||||
# public key?
|
||||
for loader in (serialization.load_pem_public_key,
|
||||
serialization.load_der_public_key):
|
||||
try:
|
||||
return loader(data, backend)
|
||||
except (ValueError,
|
||||
cryptography.exceptions.UnsupportedAlgorithm) as error:
|
||||
exceptions[loader] = error
|
||||
|
||||
# no luck
|
||||
raise errors.Error('Unable to deserialize key: {0}'.format(exceptions))
|
||||
|
||||
@classmethod
|
||||
def load(cls, data, password=None, backend=None):
|
||||
"""Load serialized key as JWK.
|
||||
|
||||
:param str data: Public or private key serialized as PEM or DER.
|
||||
:param str password: Optional password.
|
||||
:param backend: A `.PEMSerializationBackend` and
|
||||
`.DERSerializationBackend` provider.
|
||||
|
||||
:raises errors.Error: if unable to deserialize, or unsupported
|
||||
JWK algorithm
|
||||
|
||||
:returns: JWK of an appropriate type.
|
||||
:rtype: `JWK`
|
||||
|
||||
"""
|
||||
try:
|
||||
key = cls._load_cryptography_key(data, password, backend)
|
||||
except errors.Error as error:
|
||||
logger.debug('Loading symmetric key, asymmetric failed: %s', error)
|
||||
return JWKOct(key=data)
|
||||
|
||||
if cls.typ is not NotImplemented and not isinstance(
|
||||
key, cls.cryptography_key_types):
|
||||
raise errors.Error('Unable to deserialize {0} into {1}'.format(
|
||||
key.__class__, cls.__class__))
|
||||
for jwk_cls in six.itervalues(cls.TYPES):
|
||||
if isinstance(key, jwk_cls.cryptography_key_types):
|
||||
return jwk_cls(key=key)
|
||||
raise errors.Error('Unsupported algorithm: {0}'.format(key.__class__))
|
||||
|
||||
|
||||
@JWK.register
|
||||
class JWKES(JWK): # pragma: no cover
|
||||
# pylint: disable=abstract-class-not-used
|
||||
"""ES JWK.
|
||||
|
||||
.. warning:: This is not yet implemented!
|
||||
|
||||
"""
|
||||
typ = 'ES'
|
||||
cryptography_key_types = (
|
||||
ec.EllipticCurvePublicKey, ec.EllipticCurvePrivateKey)
|
||||
required = ('crv', JWK.type_field_name, 'x', 'y')
|
||||
|
||||
def fields_to_partial_json(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def fields_from_json(cls, jobj):
|
||||
raise NotImplementedError()
|
||||
|
||||
def public_key(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
@JWK.register
|
||||
class JWKOct(JWK):
|
||||
"""Symmetric JWK."""
|
||||
typ = 'oct'
|
||||
__slots__ = ('key',)
|
||||
required = ('k', JWK.type_field_name)
|
||||
|
||||
def fields_to_partial_json(self):
|
||||
# TODO: An "alg" member SHOULD also be present to identify the
|
||||
# algorithm intended to be used with the key, unless the
|
||||
# application uses another means or convention to determine
|
||||
# the algorithm used.
|
||||
return {'k': json_util.encode_b64jose(self.key)}
|
||||
|
||||
@classmethod
|
||||
def fields_from_json(cls, jobj):
|
||||
return cls(key=json_util.decode_b64jose(jobj['k']))
|
||||
|
||||
def public_key(self):
|
||||
return self
|
||||
|
||||
|
||||
@JWK.register
|
||||
class JWKRSA(JWK):
|
||||
"""RSA JWK.
|
||||
|
||||
:ivar key: `cryptography.hazmat.primitives.rsa.RSAPrivateKey`
|
||||
or `cryptography.hazmat.primitives.rsa.RSAPublicKey` wrapped
|
||||
in `.ComparableRSAKey`
|
||||
|
||||
"""
|
||||
typ = 'RSA'
|
||||
cryptography_key_types = (rsa.RSAPublicKey, rsa.RSAPrivateKey)
|
||||
__slots__ = ('key',)
|
||||
required = ('e', JWK.type_field_name, 'n')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if 'key' in kwargs and not isinstance(
|
||||
kwargs['key'], util.ComparableRSAKey):
|
||||
kwargs['key'] = util.ComparableRSAKey(kwargs['key'])
|
||||
super(JWKRSA, self).__init__(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def _encode_param(cls, data):
|
||||
"""Encode Base64urlUInt.
|
||||
|
||||
:type data: long
|
||||
:rtype: unicode
|
||||
|
||||
"""
|
||||
def _leading_zeros(arg):
|
||||
if len(arg) % 2:
|
||||
return '0' + arg
|
||||
return arg
|
||||
|
||||
return json_util.encode_b64jose(binascii.unhexlify(
|
||||
_leading_zeros(hex(data)[2:].rstrip('L'))))
|
||||
|
||||
@classmethod
|
||||
def _decode_param(cls, data):
|
||||
"""Decode Base64urlUInt."""
|
||||
try:
|
||||
return int(binascii.hexlify(json_util.decode_b64jose(data)), 16)
|
||||
except ValueError: # invalid literal for long() with base 16
|
||||
raise errors.DeserializationError()
|
||||
|
||||
def public_key(self):
|
||||
return type(self)(key=self.key.public_key())
|
||||
|
||||
@classmethod
|
||||
def fields_from_json(cls, jobj):
|
||||
# pylint: disable=invalid-name
|
||||
n, e = (cls._decode_param(jobj[x]) for x in ('n', 'e'))
|
||||
public_numbers = rsa.RSAPublicNumbers(e=e, n=n)
|
||||
if 'd' not in jobj: # public key
|
||||
key = public_numbers.public_key(default_backend())
|
||||
else: # private key
|
||||
d = cls._decode_param(jobj['d'])
|
||||
if ('p' in jobj or 'q' in jobj or 'dp' in jobj or
|
||||
'dq' in jobj or 'qi' in jobj or 'oth' in jobj):
|
||||
# "If the producer includes any of the other private
|
||||
# key parameters, then all of the others MUST be
|
||||
# present, with the exception of "oth", which MUST
|
||||
# only be present when more than two prime factors
|
||||
# were used."
|
||||
p, q, dp, dq, qi, = all_params = tuple(
|
||||
jobj.get(x) for x in ('p', 'q', 'dp', 'dq', 'qi'))
|
||||
if tuple(param for param in all_params if param is None):
|
||||
raise errors.Error(
|
||||
'Some private parameters are missing: {0}'.format(
|
||||
all_params))
|
||||
p, q, dp, dq, qi = tuple(
|
||||
cls._decode_param(x) for x in all_params)
|
||||
|
||||
# TODO: check for oth
|
||||
else:
|
||||
# cryptography>=0.8
|
||||
p, q = rsa.rsa_recover_prime_factors(n, e, d)
|
||||
dp = rsa.rsa_crt_dmp1(d, p)
|
||||
dq = rsa.rsa_crt_dmq1(d, q)
|
||||
qi = rsa.rsa_crt_iqmp(p, q)
|
||||
|
||||
key = rsa.RSAPrivateNumbers(
|
||||
p, q, d, dp, dq, qi, public_numbers).private_key(
|
||||
default_backend())
|
||||
|
||||
return cls(key=key)
|
||||
|
||||
def fields_to_partial_json(self):
|
||||
# pylint: disable=protected-access
|
||||
if isinstance(self.key._wrapped, rsa.RSAPublicKey):
|
||||
numbers = self.key.public_numbers()
|
||||
params = {
|
||||
'n': numbers.n,
|
||||
'e': numbers.e,
|
||||
}
|
||||
else: # rsa.RSAPrivateKey
|
||||
private = self.key.private_numbers()
|
||||
public = self.key.public_key().public_numbers()
|
||||
params = {
|
||||
'n': public.n,
|
||||
'e': public.e,
|
||||
'd': private.d,
|
||||
'p': private.p,
|
||||
'q': private.q,
|
||||
'dp': private.dmp1,
|
||||
'dq': private.dmq1,
|
||||
'qi': private.iqmp,
|
||||
}
|
||||
return dict((key, self._encode_param(value))
|
||||
for key, value in six.iteritems(params))
|
||||
191
acme/acme/jose/jwk_test.py
Normal file
191
acme/acme/jose/jwk_test.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""Tests for acme.jose.jwk."""
|
||||
import binascii
|
||||
import unittest
|
||||
|
||||
from acme import test_util
|
||||
|
||||
from acme.jose import errors
|
||||
from acme.jose import json_util
|
||||
from acme.jose import util
|
||||
|
||||
|
||||
DSA_PEM = test_util.load_vector('dsa512_key.pem')
|
||||
RSA256_KEY = test_util.load_rsa_private_key('rsa256_key.pem')
|
||||
RSA512_KEY = test_util.load_rsa_private_key('rsa512_key.pem')
|
||||
|
||||
|
||||
class JWKTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.jwk.JWK."""
|
||||
|
||||
def test_load(self):
|
||||
from acme.jose.jwk import JWK
|
||||
self.assertRaises(errors.Error, JWK.load, DSA_PEM)
|
||||
|
||||
def test_load_subclass_wrong_type(self):
|
||||
from acme.jose.jwk import JWKRSA
|
||||
self.assertRaises(errors.Error, JWKRSA.load, DSA_PEM)
|
||||
|
||||
|
||||
class JWKTestBaseMixin(object):
|
||||
"""Mixin test for JWK subclass tests."""
|
||||
|
||||
thumbprint = NotImplemented
|
||||
|
||||
def test_thumbprint_private(self):
|
||||
self.assertEqual(self.thumbprint, self.jwk.thumbprint())
|
||||
|
||||
def test_thumbprint_public(self):
|
||||
self.assertEqual(self.thumbprint, self.jwk.public_key().thumbprint())
|
||||
|
||||
|
||||
class JWKOctTest(unittest.TestCase, JWKTestBaseMixin):
|
||||
"""Tests for acme.jose.jwk.JWKOct."""
|
||||
|
||||
thumbprint = (b"\xf3\xe7\xbe\xa8`\xd2\xdap\xe9}\x9c\xce>"
|
||||
b"\xd0\xfcI\xbe\xcd\x92'\xd4o\x0e\xf41\xea"
|
||||
b"\x8e(\x8a\xb2i\x1c")
|
||||
|
||||
def setUp(self):
|
||||
from acme.jose.jwk import JWKOct
|
||||
self.jwk = JWKOct(key=b'foo')
|
||||
self.jobj = {'kty': 'oct', 'k': json_util.encode_b64jose(b'foo')}
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.jwk.to_partial_json(), self.jobj)
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.jose.jwk import JWKOct
|
||||
self.assertEqual(self.jwk, JWKOct.from_json(self.jobj))
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.jose.jwk import JWKOct
|
||||
hash(JWKOct.from_json(self.jobj))
|
||||
|
||||
def test_load(self):
|
||||
from acme.jose.jwk import JWKOct
|
||||
self.assertEqual(self.jwk, JWKOct.load(b'foo'))
|
||||
|
||||
def test_public_key(self):
|
||||
self.assertTrue(self.jwk.public_key() is self.jwk)
|
||||
|
||||
|
||||
class JWKRSATest(unittest.TestCase, JWKTestBaseMixin):
|
||||
"""Tests for acme.jose.jwk.JWKRSA."""
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
|
||||
thumbprint = (b'\x83K\xdc#3\x98\xca\x98\xed\xcb\x80\x80<\x0c'
|
||||
b'\xf0\x95\xb9H\xb2*l\xbd$\xe5&|O\x91\xd4 \xb0Y')
|
||||
|
||||
def setUp(self):
|
||||
from acme.jose.jwk import JWKRSA
|
||||
self.jwk256 = JWKRSA(key=RSA256_KEY.public_key())
|
||||
self.jwk256json = {
|
||||
'kty': 'RSA',
|
||||
'e': 'AQAB',
|
||||
'n': 'm2Fylv-Uz7trgTW8EBHP3FQSMeZs2GNQ6VRo1sIVJEk',
|
||||
}
|
||||
# pylint: disable=protected-access
|
||||
self.jwk256_not_comparable = JWKRSA(
|
||||
key=RSA256_KEY.public_key()._wrapped)
|
||||
self.jwk512 = JWKRSA(key=RSA512_KEY.public_key())
|
||||
self.jwk512json = {
|
||||
'kty': 'RSA',
|
||||
'e': 'AQAB',
|
||||
'n': 'rHVztFHtH92ucFJD_N_HW9AsdRsUuHUBBBDlHwNlRd3fp5'
|
||||
'80rv2-6QWE30cWgdmJS86ObRz6lUTor4R0T-3C5Q',
|
||||
}
|
||||
self.private = JWKRSA(key=RSA256_KEY)
|
||||
self.private_json_small = self.jwk256json.copy()
|
||||
self.private_json_small['d'] = (
|
||||
'lPQED_EPTV0UIBfNI3KP2d9Jlrc2mrMllmf946bu-CE')
|
||||
self.private_json = self.jwk256json.copy()
|
||||
self.private_json.update({
|
||||
'd': 'lPQED_EPTV0UIBfNI3KP2d9Jlrc2mrMllmf946bu-CE',
|
||||
'p': 'zUVNZn4lLLBD1R6NE8TKNQ',
|
||||
'q': 'wcfKfc7kl5jfqXArCRSURQ',
|
||||
'dp': 'CWJFq43QvT5Bm5iN8n1okQ',
|
||||
'dq': 'bHh2u7etM8LKKCF2pY2UdQ',
|
||||
'qi': 'oi45cEkbVoJjAbnQpFY87Q',
|
||||
})
|
||||
self.jwk = self.private
|
||||
|
||||
def test_init_auto_comparable(self):
|
||||
self.assertTrue(isinstance(
|
||||
self.jwk256_not_comparable.key, util.ComparableRSAKey))
|
||||
self.assertEqual(self.jwk256, self.jwk256_not_comparable)
|
||||
|
||||
def test_encode_param_zero(self):
|
||||
from acme.jose.jwk import JWKRSA
|
||||
# pylint: disable=protected-access
|
||||
# TODO: move encode/decode _param to separate class
|
||||
self.assertEqual('AA', JWKRSA._encode_param(0))
|
||||
|
||||
def test_equals(self):
|
||||
self.assertEqual(self.jwk256, self.jwk256)
|
||||
self.assertEqual(self.jwk512, self.jwk512)
|
||||
|
||||
def test_not_equals(self):
|
||||
self.assertNotEqual(self.jwk256, self.jwk512)
|
||||
self.assertNotEqual(self.jwk512, self.jwk256)
|
||||
|
||||
def test_load(self):
|
||||
from acme.jose.jwk import JWKRSA
|
||||
self.assertEqual(self.private, JWKRSA.load(
|
||||
test_util.load_vector('rsa256_key.pem')))
|
||||
|
||||
def test_public_key(self):
|
||||
self.assertEqual(self.jwk256, self.private.public_key())
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.jwk256.to_partial_json(), self.jwk256json)
|
||||
self.assertEqual(self.jwk512.to_partial_json(), self.jwk512json)
|
||||
self.assertEqual(self.private.to_partial_json(), self.private_json)
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.jose.jwk import JWK
|
||||
self.assertEqual(
|
||||
self.jwk256, JWK.from_json(self.jwk256json))
|
||||
self.assertEqual(
|
||||
self.jwk512, JWK.from_json(self.jwk512json))
|
||||
self.assertEqual(self.private, JWK.from_json(self.private_json))
|
||||
|
||||
def test_from_json_private_small(self):
|
||||
from acme.jose.jwk import JWK
|
||||
self.assertEqual(self.private, JWK.from_json(self.private_json_small))
|
||||
|
||||
def test_from_json_missing_one_additional(self):
|
||||
from acme.jose.jwk import JWK
|
||||
del self.private_json['q']
|
||||
self.assertRaises(errors.Error, JWK.from_json, self.private_json)
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.jose.jwk import JWK
|
||||
hash(JWK.from_json(self.jwk256json))
|
||||
|
||||
def test_from_json_non_schema_errors(self):
|
||||
# valid against schema, but still failing
|
||||
from acme.jose.jwk import JWK
|
||||
self.assertRaises(errors.DeserializationError, JWK.from_json,
|
||||
{'kty': 'RSA', 'e': 'AQAB', 'n': ''})
|
||||
self.assertRaises(errors.DeserializationError, JWK.from_json,
|
||||
{'kty': 'RSA', 'e': 'AQAB', 'n': '1'})
|
||||
|
||||
def test_thumbprint_go_jose(self):
|
||||
# https://github.com/square/go-jose/blob/4ddd71883fa547d37fbf598071f04512d8bafee3/jwk.go#L155
|
||||
# https://github.com/square/go-jose/blob/4ddd71883fa547d37fbf598071f04512d8bafee3/jwk_test.go#L331-L344
|
||||
# https://github.com/square/go-jose/blob/4ddd71883fa547d37fbf598071f04512d8bafee3/jwk_test.go#L384
|
||||
from acme.jose.jwk import JWKRSA
|
||||
key = JWKRSA.json_loads("""{
|
||||
"kty": "RSA",
|
||||
"kid": "bilbo.baggins@hobbiton.example",
|
||||
"use": "sig",
|
||||
"n": "n4EPtAOCc9AlkeQHPzHStgAbgs7bTZLwUBZdR8_KuKPEHLd4rHVTeT-O-XV2jRojdNhxJWTDvNd7nqQ0VEiZQHz_AJmSCpMaJMRBSFKrKb2wqVwGU_NsYOYL-QtiWN2lbzcEe6XC0dApr5ydQLrHqkHHig3RBordaZ6Aj-oBHqFEHYpPe7Tpe-OfVfHd1E6cS6M1FZcD1NNLYD5lFHpPI9bTwJlsde3uhGqC0ZCuEHg8lhzwOHrtIQbS0FVbb9k3-tVTU4fg_3L_vniUFAKwuCLqKnS2BYwdq_mzSnbLY7h_qixoR7jig3__kRhuaxwUkRz5iaiQkqgc5gHdrNP5zw",
|
||||
"e": "AQAB"
|
||||
}""")
|
||||
self.assertEqual(
|
||||
binascii.hexlify(key.thumbprint()),
|
||||
b"f63838e96077ad1fc01c3f8405774dedc0641f558ebb4b40dccf5f9b6d66a932")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
432
acme/acme/jose/jws.py
Normal file
432
acme/acme/jose/jws.py
Normal file
@@ -0,0 +1,432 @@
|
||||
"""JOSE Web Signature."""
|
||||
import argparse
|
||||
import base64
|
||||
import sys
|
||||
|
||||
import OpenSSL
|
||||
import six
|
||||
|
||||
from acme.jose import b64
|
||||
from acme.jose import errors
|
||||
from acme.jose import json_util
|
||||
from acme.jose import jwa
|
||||
from acme.jose import jwk
|
||||
from acme.jose import util
|
||||
|
||||
|
||||
class MediaType(object):
|
||||
"""MediaType field encoder/decoder."""
|
||||
|
||||
PREFIX = 'application/'
|
||||
"""MIME Media Type and Content Type prefix."""
|
||||
|
||||
@classmethod
|
||||
def decode(cls, value):
|
||||
"""Decoder."""
|
||||
# 4.1.10
|
||||
if '/' not in value:
|
||||
if ';' in value:
|
||||
raise errors.DeserializationError('Unexpected semi-colon')
|
||||
return cls.PREFIX + value
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def encode(cls, value):
|
||||
"""Encoder."""
|
||||
# 4.1.10
|
||||
if ';' not in value:
|
||||
assert value.startswith(cls.PREFIX)
|
||||
return value[len(cls.PREFIX):]
|
||||
return value
|
||||
|
||||
|
||||
class Header(json_util.JSONObjectWithFields):
|
||||
"""JOSE Header.
|
||||
|
||||
.. warning:: This class supports **only** Registered Header
|
||||
Parameter Names (as defined in section 4.1 of the
|
||||
protocol). If you need Public Header Parameter Names (4.2)
|
||||
or Private Header Parameter Names (4.3), you must subclass
|
||||
and override :meth:`from_json` and :meth:`to_partial_json`
|
||||
appropriately.
|
||||
|
||||
.. warning:: This class does not support any extensions through
|
||||
the "crit" (Critical) Header Parameter (4.1.11) and as a
|
||||
conforming implementation, :meth:`from_json` treats its
|
||||
occurrence as an error. Please subclass if you seek for
|
||||
a different behaviour.
|
||||
|
||||
:ivar x5tS256: "x5t#S256"
|
||||
:ivar str typ: MIME Media Type, inc. :const:`MediaType.PREFIX`.
|
||||
:ivar str cty: Content-Type, inc. :const:`MediaType.PREFIX`.
|
||||
|
||||
"""
|
||||
alg = json_util.Field(
|
||||
'alg', decoder=jwa.JWASignature.from_json, omitempty=True)
|
||||
jku = json_util.Field('jku', omitempty=True)
|
||||
jwk = json_util.Field('jwk', decoder=jwk.JWK.from_json, omitempty=True)
|
||||
kid = json_util.Field('kid', omitempty=True)
|
||||
x5u = json_util.Field('x5u', omitempty=True)
|
||||
x5c = json_util.Field('x5c', omitempty=True, default=())
|
||||
x5t = json_util.Field(
|
||||
'x5t', decoder=json_util.decode_b64jose, omitempty=True)
|
||||
x5tS256 = json_util.Field(
|
||||
'x5t#S256', decoder=json_util.decode_b64jose, omitempty=True)
|
||||
typ = json_util.Field('typ', encoder=MediaType.encode,
|
||||
decoder=MediaType.decode, omitempty=True)
|
||||
cty = json_util.Field('cty', encoder=MediaType.encode,
|
||||
decoder=MediaType.decode, omitempty=True)
|
||||
crit = json_util.Field('crit', omitempty=True, default=())
|
||||
|
||||
def not_omitted(self):
|
||||
"""Fields that would not be omitted in the JSON object."""
|
||||
return dict((name, getattr(self, name))
|
||||
for name, field in six.iteritems(self._fields)
|
||||
if not field.omit(getattr(self, name)))
|
||||
|
||||
def __add__(self, other):
|
||||
if not isinstance(other, type(self)):
|
||||
raise TypeError('Header cannot be added to: {0}'.format(
|
||||
type(other)))
|
||||
|
||||
not_omitted_self = self.not_omitted()
|
||||
not_omitted_other = other.not_omitted()
|
||||
|
||||
if set(not_omitted_self).intersection(not_omitted_other):
|
||||
raise TypeError('Addition of overlapping headers not defined')
|
||||
|
||||
not_omitted_self.update(not_omitted_other)
|
||||
return type(self)(**not_omitted_self) # pylint: disable=star-args
|
||||
|
||||
def find_key(self):
|
||||
"""Find key based on header.
|
||||
|
||||
.. todo:: Supports only "jwk" header parameter lookup.
|
||||
|
||||
:returns: (Public) key found in the header.
|
||||
:rtype: .JWK
|
||||
|
||||
:raises acme.jose.errors.Error: if key could not be found
|
||||
|
||||
"""
|
||||
if self.jwk is None:
|
||||
raise errors.Error('No key found')
|
||||
return self.jwk
|
||||
|
||||
@crit.decoder
|
||||
def crit(unused_value):
|
||||
# pylint: disable=missing-docstring,no-self-argument,no-self-use
|
||||
raise errors.DeserializationError(
|
||||
'"crit" is not supported, please subclass')
|
||||
|
||||
# x5c does NOT use JOSE Base64 (4.1.6)
|
||||
|
||||
@x5c.encoder # type: ignore
|
||||
def x5c(value): # pylint: disable=missing-docstring,no-self-argument
|
||||
return [base64.b64encode(OpenSSL.crypto.dump_certificate(
|
||||
OpenSSL.crypto.FILETYPE_ASN1, cert.wrapped)) for cert in value]
|
||||
|
||||
@x5c.decoder # type: ignore
|
||||
def x5c(value): # pylint: disable=missing-docstring,no-self-argument
|
||||
try:
|
||||
return tuple(util.ComparableX509(OpenSSL.crypto.load_certificate(
|
||||
OpenSSL.crypto.FILETYPE_ASN1,
|
||||
base64.b64decode(cert))) for cert in value)
|
||||
except OpenSSL.crypto.Error as error:
|
||||
raise errors.DeserializationError(error)
|
||||
|
||||
|
||||
class Signature(json_util.JSONObjectWithFields):
|
||||
"""JWS Signature.
|
||||
|
||||
:ivar combined: Combined Header (protected and unprotected,
|
||||
:class:`Header`).
|
||||
:ivar unicode protected: JWS protected header (Jose Base-64 decoded).
|
||||
:ivar header: JWS Unprotected Header (:class:`Header`).
|
||||
:ivar str signature: The signature.
|
||||
|
||||
"""
|
||||
header_cls = Header
|
||||
|
||||
__slots__ = ('combined',)
|
||||
protected = json_util.Field('protected', omitempty=True, default='')
|
||||
header = json_util.Field(
|
||||
'header', omitempty=True, default=header_cls(),
|
||||
decoder=header_cls.from_json)
|
||||
signature = json_util.Field(
|
||||
'signature', decoder=json_util.decode_b64jose,
|
||||
encoder=json_util.encode_b64jose)
|
||||
|
||||
@protected.encoder # type: ignore
|
||||
def protected(value): # pylint: disable=missing-docstring,no-self-argument
|
||||
# wrong type guess (Signature, not bytes) | pylint: disable=no-member
|
||||
return json_util.encode_b64jose(value.encode('utf-8'))
|
||||
|
||||
@protected.decoder # type: ignore
|
||||
def protected(value): # pylint: disable=missing-docstring,no-self-argument
|
||||
return json_util.decode_b64jose(value).decode('utf-8')
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
if 'combined' not in kwargs:
|
||||
kwargs = self._with_combined(kwargs)
|
||||
super(Signature, self).__init__(**kwargs)
|
||||
assert self.combined.alg is not None
|
||||
|
||||
@classmethod
|
||||
def _with_combined(cls, kwargs):
|
||||
assert 'combined' not in kwargs
|
||||
header = kwargs.get('header', cls._fields['header'].default)
|
||||
protected = kwargs.get('protected', cls._fields['protected'].default)
|
||||
|
||||
if protected:
|
||||
combined = header + cls.header_cls.json_loads(protected)
|
||||
else:
|
||||
combined = header
|
||||
|
||||
kwargs['combined'] = combined
|
||||
return kwargs
|
||||
|
||||
@classmethod
|
||||
def _msg(cls, protected, payload):
|
||||
return (b64.b64encode(protected.encode('utf-8')) + b'.' +
|
||||
b64.b64encode(payload))
|
||||
|
||||
def verify(self, payload, key=None):
|
||||
"""Verify.
|
||||
|
||||
:param JWK key: Key used for verification.
|
||||
|
||||
"""
|
||||
key = self.combined.find_key() if key is None else key
|
||||
return self.combined.alg.verify(
|
||||
key=key.key, sig=self.signature,
|
||||
msg=self._msg(self.protected, payload))
|
||||
|
||||
@classmethod
|
||||
def sign(cls, payload, key, alg, include_jwk=True,
|
||||
protect=frozenset(), **kwargs):
|
||||
"""Sign.
|
||||
|
||||
:param JWK key: Key for signature.
|
||||
|
||||
"""
|
||||
assert isinstance(key, alg.kty)
|
||||
|
||||
header_params = kwargs
|
||||
header_params['alg'] = alg
|
||||
if include_jwk:
|
||||
header_params['jwk'] = key.public_key()
|
||||
|
||||
assert set(header_params).issubset(cls.header_cls._fields)
|
||||
assert protect.issubset(cls.header_cls._fields)
|
||||
|
||||
protected_params = {}
|
||||
for header in protect:
|
||||
protected_params[header] = header_params.pop(header)
|
||||
if protected_params:
|
||||
# pylint: disable=star-args
|
||||
protected = cls.header_cls(**protected_params).json_dumps()
|
||||
else:
|
||||
protected = ''
|
||||
|
||||
header = cls.header_cls(**header_params) # pylint: disable=star-args
|
||||
signature = alg.sign(key.key, cls._msg(protected, payload))
|
||||
|
||||
return cls(protected=protected, header=header, signature=signature)
|
||||
|
||||
def fields_to_partial_json(self):
|
||||
fields = super(Signature, self).fields_to_partial_json()
|
||||
if not fields['header'].not_omitted():
|
||||
del fields['header']
|
||||
return fields
|
||||
|
||||
@classmethod
|
||||
def fields_from_json(cls, jobj):
|
||||
fields = super(Signature, cls).fields_from_json(jobj)
|
||||
fields_with_combined = cls._with_combined(fields)
|
||||
if 'alg' not in fields_with_combined['combined'].not_omitted():
|
||||
raise errors.DeserializationError('alg not present')
|
||||
return fields_with_combined
|
||||
|
||||
|
||||
class JWS(json_util.JSONObjectWithFields):
|
||||
"""JSON Web Signature.
|
||||
|
||||
:ivar str payload: JWS Payload.
|
||||
:ivar str signature: JWS Signatures.
|
||||
|
||||
"""
|
||||
__slots__ = ('payload', 'signatures')
|
||||
|
||||
signature_cls = Signature
|
||||
|
||||
def verify(self, key=None):
|
||||
"""Verify."""
|
||||
return all(sig.verify(self.payload, key) for sig in self.signatures)
|
||||
|
||||
@classmethod
|
||||
def sign(cls, payload, **kwargs):
|
||||
"""Sign."""
|
||||
return cls(payload=payload, signatures=(
|
||||
cls.signature_cls.sign(payload=payload, **kwargs),))
|
||||
|
||||
@property
|
||||
def signature(self):
|
||||
"""Get a singleton signature.
|
||||
|
||||
:rtype: `signature_cls`
|
||||
|
||||
"""
|
||||
assert len(self.signatures) == 1
|
||||
return self.signatures[0]
|
||||
|
||||
def to_compact(self):
|
||||
"""Compact serialization.
|
||||
|
||||
:rtype: bytes
|
||||
|
||||
"""
|
||||
assert len(self.signatures) == 1
|
||||
|
||||
assert 'alg' not in self.signature.header.not_omitted()
|
||||
# ... it must be in protected
|
||||
|
||||
return (
|
||||
b64.b64encode(self.signature.protected.encode('utf-8')) +
|
||||
b'.' +
|
||||
b64.b64encode(self.payload) +
|
||||
b'.' +
|
||||
b64.b64encode(self.signature.signature))
|
||||
|
||||
@classmethod
|
||||
def from_compact(cls, compact):
|
||||
"""Compact deserialization.
|
||||
|
||||
:param bytes compact:
|
||||
|
||||
"""
|
||||
try:
|
||||
protected, payload, signature = compact.split(b'.')
|
||||
except ValueError:
|
||||
raise errors.DeserializationError(
|
||||
'Compact JWS serialization should comprise of exactly'
|
||||
' 3 dot-separated components')
|
||||
|
||||
sig = cls.signature_cls(
|
||||
protected=b64.b64decode(protected).decode('utf-8'),
|
||||
signature=b64.b64decode(signature))
|
||||
return cls(payload=b64.b64decode(payload), signatures=(sig,))
|
||||
|
||||
def to_partial_json(self, flat=True): # pylint: disable=arguments-differ
|
||||
assert self.signatures
|
||||
payload = json_util.encode_b64jose(self.payload)
|
||||
|
||||
if flat and len(self.signatures) == 1:
|
||||
ret = self.signatures[0].to_partial_json()
|
||||
ret['payload'] = payload
|
||||
return ret
|
||||
else:
|
||||
return {
|
||||
'payload': payload,
|
||||
'signatures': self.signatures,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
if 'signature' in jobj and 'signatures' in jobj:
|
||||
raise errors.DeserializationError('Flat mixed with non-flat')
|
||||
elif 'signature' in jobj: # flat
|
||||
return cls(payload=json_util.decode_b64jose(jobj.pop('payload')),
|
||||
signatures=(cls.signature_cls.from_json(jobj),))
|
||||
else:
|
||||
return cls(payload=json_util.decode_b64jose(jobj['payload']),
|
||||
signatures=tuple(cls.signature_cls.from_json(sig)
|
||||
for sig in jobj['signatures']))
|
||||
|
||||
|
||||
class CLI(object):
|
||||
"""JWS CLI."""
|
||||
|
||||
@classmethod
|
||||
def sign(cls, args):
|
||||
"""Sign."""
|
||||
key = args.alg.kty.load(args.key.read())
|
||||
args.key.close()
|
||||
if args.protect is None:
|
||||
args.protect = []
|
||||
if args.compact:
|
||||
args.protect.append('alg')
|
||||
|
||||
sig = JWS.sign(payload=sys.stdin.read().encode(), key=key, alg=args.alg,
|
||||
protect=set(args.protect))
|
||||
|
||||
if args.compact:
|
||||
six.print_(sig.to_compact().decode('utf-8'))
|
||||
else: # JSON
|
||||
six.print_(sig.json_dumps_pretty())
|
||||
|
||||
@classmethod
|
||||
def verify(cls, args):
|
||||
"""Verify."""
|
||||
if args.compact:
|
||||
sig = JWS.from_compact(sys.stdin.read().encode())
|
||||
else: # JSON
|
||||
try:
|
||||
sig = JWS.json_loads(sys.stdin.read())
|
||||
except errors.Error as error:
|
||||
six.print_(error)
|
||||
return -1
|
||||
|
||||
if args.key is not None:
|
||||
assert args.kty is not None
|
||||
key = args.kty.load(args.key.read()).public_key()
|
||||
args.key.close()
|
||||
else:
|
||||
key = None
|
||||
|
||||
sys.stdout.write(sig.payload)
|
||||
return not sig.verify(key=key)
|
||||
|
||||
@classmethod
|
||||
def _alg_type(cls, arg):
|
||||
return jwa.JWASignature.from_json(arg)
|
||||
|
||||
@classmethod
|
||||
def _header_type(cls, arg):
|
||||
assert arg in Signature.header_cls._fields
|
||||
return arg
|
||||
|
||||
@classmethod
|
||||
def _kty_type(cls, arg):
|
||||
assert arg in jwk.JWK.TYPES
|
||||
return jwk.JWK.TYPES[arg]
|
||||
|
||||
@classmethod
|
||||
def run(cls, args=sys.argv[1:]):
|
||||
"""Parse arguments and sign/verify."""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--compact', action='store_true')
|
||||
|
||||
subparsers = parser.add_subparsers()
|
||||
parser_sign = subparsers.add_parser('sign')
|
||||
parser_sign.set_defaults(func=cls.sign)
|
||||
parser_sign.add_argument(
|
||||
'-k', '--key', type=argparse.FileType('rb'), required=True)
|
||||
parser_sign.add_argument(
|
||||
'-a', '--alg', type=cls._alg_type, default=jwa.RS256)
|
||||
parser_sign.add_argument(
|
||||
'-p', '--protect', action='append', type=cls._header_type)
|
||||
|
||||
parser_verify = subparsers.add_parser('verify')
|
||||
parser_verify.set_defaults(func=cls.verify)
|
||||
parser_verify.add_argument(
|
||||
'-k', '--key', type=argparse.FileType('rb'), required=False)
|
||||
parser_verify.add_argument(
|
||||
'--kty', type=cls._kty_type, required=False)
|
||||
|
||||
parsed = parser.parse_args(args)
|
||||
return parsed.func(parsed)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(CLI.run()) # pragma: no cover
|
||||
239
acme/acme/jose/jws_test.py
Normal file
239
acme/acme/jose/jws_test.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""Tests for acme.jose.jws."""
|
||||
import base64
|
||||
import unittest
|
||||
|
||||
import mock
|
||||
import OpenSSL
|
||||
|
||||
from acme import test_util
|
||||
|
||||
from acme.jose import errors
|
||||
from acme.jose import json_util
|
||||
from acme.jose import jwa
|
||||
from acme.jose import jwk
|
||||
|
||||
|
||||
CERT = test_util.load_comparable_cert('cert.pem')
|
||||
KEY = jwk.JWKRSA.load(test_util.load_vector('rsa512_key.pem'))
|
||||
|
||||
|
||||
class MediaTypeTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.jws.MediaType."""
|
||||
|
||||
def test_decode(self):
|
||||
from acme.jose.jws import MediaType
|
||||
self.assertEqual('application/app', MediaType.decode('application/app'))
|
||||
self.assertEqual('application/app', MediaType.decode('app'))
|
||||
self.assertRaises(
|
||||
errors.DeserializationError, MediaType.decode, 'app;foo')
|
||||
|
||||
def test_encode(self):
|
||||
from acme.jose.jws import MediaType
|
||||
self.assertEqual('app', MediaType.encode('application/app'))
|
||||
self.assertEqual('application/app;foo',
|
||||
MediaType.encode('application/app;foo'))
|
||||
|
||||
|
||||
class HeaderTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.jws.Header."""
|
||||
|
||||
def setUp(self):
|
||||
from acme.jose.jws import Header
|
||||
self.header1 = Header(jwk='foo')
|
||||
self.header2 = Header(jwk='bar')
|
||||
self.crit = Header(crit=('a', 'b'))
|
||||
self.empty = Header()
|
||||
|
||||
def test_add_non_empty(self):
|
||||
from acme.jose.jws import Header
|
||||
self.assertEqual(Header(jwk='foo', crit=('a', 'b')),
|
||||
self.header1 + self.crit)
|
||||
|
||||
def test_add_empty(self):
|
||||
self.assertEqual(self.header1, self.header1 + self.empty)
|
||||
self.assertEqual(self.header1, self.empty + self.header1)
|
||||
|
||||
def test_add_overlapping_error(self):
|
||||
self.assertRaises(TypeError, self.header1.__add__, self.header2)
|
||||
|
||||
def test_add_wrong_type_error(self):
|
||||
self.assertRaises(TypeError, self.header1.__add__, 'xxx')
|
||||
|
||||
def test_crit_decode_always_errors(self):
|
||||
from acme.jose.jws import Header
|
||||
self.assertRaises(errors.DeserializationError, Header.from_json,
|
||||
{'crit': ['a', 'b']})
|
||||
|
||||
def test_x5c_decoding(self):
|
||||
from acme.jose.jws import Header
|
||||
header = Header(x5c=(CERT, CERT))
|
||||
jobj = header.to_partial_json()
|
||||
cert_asn1 = OpenSSL.crypto.dump_certificate(
|
||||
OpenSSL.crypto.FILETYPE_ASN1, CERT.wrapped)
|
||||
cert_b64 = base64.b64encode(cert_asn1)
|
||||
self.assertEqual(jobj, {'x5c': [cert_b64, cert_b64]})
|
||||
self.assertEqual(header, Header.from_json(jobj))
|
||||
jobj['x5c'][0] = base64.b64encode(b'xxx' + cert_asn1)
|
||||
self.assertRaises(errors.DeserializationError, Header.from_json, jobj)
|
||||
|
||||
def test_find_key(self):
|
||||
self.assertEqual('foo', self.header1.find_key())
|
||||
self.assertEqual('bar', self.header2.find_key())
|
||||
self.assertRaises(errors.Error, self.crit.find_key)
|
||||
|
||||
|
||||
class SignatureTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.jws.Signature."""
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.jose.jws import Header
|
||||
from acme.jose.jws import Signature
|
||||
self.assertEqual(
|
||||
Signature(signature=b'foo', header=Header(alg=jwa.RS256)),
|
||||
Signature.from_json(
|
||||
{'signature': 'Zm9v', 'header': {'alg': 'RS256'}}))
|
||||
|
||||
def test_from_json_no_alg_error(self):
|
||||
from acme.jose.jws import Signature
|
||||
self.assertRaises(errors.DeserializationError,
|
||||
Signature.from_json, {'signature': 'foo'})
|
||||
|
||||
|
||||
class JWSTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.jws.JWS."""
|
||||
|
||||
def setUp(self):
|
||||
self.privkey = KEY
|
||||
self.pubkey = self.privkey.public_key()
|
||||
|
||||
from acme.jose.jws import JWS
|
||||
self.unprotected = JWS.sign(
|
||||
payload=b'foo', key=self.privkey, alg=jwa.RS256)
|
||||
self.protected = JWS.sign(
|
||||
payload=b'foo', key=self.privkey, alg=jwa.RS256,
|
||||
protect=frozenset(['jwk', 'alg']))
|
||||
self.mixed = JWS.sign(
|
||||
payload=b'foo', key=self.privkey, alg=jwa.RS256,
|
||||
protect=frozenset(['alg']))
|
||||
|
||||
def test_pubkey_jwk(self):
|
||||
self.assertEqual(self.unprotected.signature.combined.jwk, self.pubkey)
|
||||
self.assertEqual(self.protected.signature.combined.jwk, self.pubkey)
|
||||
self.assertEqual(self.mixed.signature.combined.jwk, self.pubkey)
|
||||
|
||||
def test_sign_unprotected(self):
|
||||
self.assertTrue(self.unprotected.verify())
|
||||
|
||||
def test_sign_protected(self):
|
||||
self.assertTrue(self.protected.verify())
|
||||
|
||||
def test_sign_mixed(self):
|
||||
self.assertTrue(self.mixed.verify())
|
||||
|
||||
def test_compact_lost_unprotected(self):
|
||||
compact = self.mixed.to_compact()
|
||||
self.assertEqual(
|
||||
b'eyJhbGciOiAiUlMyNTYifQ.Zm9v.OHdxFVj73l5LpxbFp1AmYX4yJM0Pyb'
|
||||
b'_893n1zQjpim_eLS5J1F61lkvrCrCDErTEJnBGOGesJ72M7b6Ve1cAJA',
|
||||
compact)
|
||||
|
||||
from acme.jose.jws import JWS
|
||||
mixed = JWS.from_compact(compact)
|
||||
|
||||
self.assertNotEqual(self.mixed, mixed)
|
||||
self.assertEqual(
|
||||
set(['alg']), set(mixed.signature.combined.not_omitted()))
|
||||
|
||||
def test_from_compact_missing_components(self):
|
||||
from acme.jose.jws import JWS
|
||||
self.assertRaises(errors.DeserializationError, JWS.from_compact, b'.')
|
||||
|
||||
def test_json_omitempty(self):
|
||||
protected_jobj = self.protected.to_partial_json(flat=True)
|
||||
unprotected_jobj = self.unprotected.to_partial_json(flat=True)
|
||||
|
||||
self.assertTrue('protected' not in unprotected_jobj)
|
||||
self.assertTrue('header' not in protected_jobj)
|
||||
|
||||
unprotected_jobj['header'] = unprotected_jobj['header'].to_json()
|
||||
|
||||
from acme.jose.jws import JWS
|
||||
self.assertEqual(JWS.from_json(protected_jobj), self.protected)
|
||||
self.assertEqual(JWS.from_json(unprotected_jobj), self.unprotected)
|
||||
|
||||
def test_json_flat(self):
|
||||
jobj_to = {
|
||||
'signature': json_util.encode_b64jose(
|
||||
self.mixed.signature.signature),
|
||||
'payload': json_util.encode_b64jose(b'foo'),
|
||||
'header': self.mixed.signature.header,
|
||||
'protected': json_util.encode_b64jose(
|
||||
self.mixed.signature.protected.encode('utf-8')),
|
||||
}
|
||||
jobj_from = jobj_to.copy()
|
||||
jobj_from['header'] = jobj_from['header'].to_json()
|
||||
|
||||
self.assertEqual(self.mixed.to_partial_json(flat=True), jobj_to)
|
||||
from acme.jose.jws import JWS
|
||||
self.assertEqual(self.mixed, JWS.from_json(jobj_from))
|
||||
|
||||
def test_json_not_flat(self):
|
||||
jobj_to = {
|
||||
'signatures': (self.mixed.signature,),
|
||||
'payload': json_util.encode_b64jose(b'foo'),
|
||||
}
|
||||
jobj_from = jobj_to.copy()
|
||||
jobj_from['signatures'] = [jobj_to['signatures'][0].to_json()]
|
||||
|
||||
self.assertEqual(self.mixed.to_partial_json(flat=False), jobj_to)
|
||||
from acme.jose.jws import JWS
|
||||
self.assertEqual(self.mixed, JWS.from_json(jobj_from))
|
||||
|
||||
def test_from_json_mixed_flat(self):
|
||||
from acme.jose.jws import JWS
|
||||
self.assertRaises(errors.DeserializationError, JWS.from_json,
|
||||
{'signatures': (), 'signature': 'foo'})
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.jose.jws import JWS
|
||||
hash(JWS.from_json(self.mixed.to_json()))
|
||||
|
||||
|
||||
class CLITest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.key_path = test_util.vector_path('rsa512_key.pem')
|
||||
|
||||
def test_unverified(self):
|
||||
from acme.jose.jws import CLI
|
||||
with mock.patch('sys.stdin') as sin:
|
||||
sin.read.return_value = '{"payload": "foo", "signature": "xxx"}'
|
||||
with mock.patch('sys.stdout'):
|
||||
self.assertEqual(-1, CLI.run(['verify']))
|
||||
|
||||
def test_json(self):
|
||||
from acme.jose.jws import CLI
|
||||
|
||||
with mock.patch('sys.stdin') as sin:
|
||||
sin.read.return_value = 'foo'
|
||||
with mock.patch('sys.stdout') as sout:
|
||||
CLI.run(['sign', '-k', self.key_path, '-a', 'RS256',
|
||||
'-p', 'jwk'])
|
||||
sin.read.return_value = sout.write.mock_calls[0][1][0]
|
||||
self.assertEqual(0, CLI.run(['verify']))
|
||||
|
||||
def test_compact(self):
|
||||
from acme.jose.jws import CLI
|
||||
|
||||
with mock.patch('sys.stdin') as sin:
|
||||
sin.read.return_value = 'foo'
|
||||
with mock.patch('sys.stdout') as sout:
|
||||
CLI.run(['--compact', 'sign', '-k', self.key_path])
|
||||
sin.read.return_value = sout.write.mock_calls[0][1][0]
|
||||
self.assertEqual(0, CLI.run([
|
||||
'--compact', 'verify', '--kty', 'RSA',
|
||||
'-k', self.key_path]))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
226
acme/acme/jose/util.py
Normal file
226
acme/acme/jose/util.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""JOSE utilities."""
|
||||
import collections
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
import OpenSSL
|
||||
import six
|
||||
|
||||
|
||||
class abstractclassmethod(classmethod):
|
||||
# pylint: disable=invalid-name,too-few-public-methods
|
||||
"""Descriptor for an abstract classmethod.
|
||||
|
||||
It augments the :mod:`abc` framework with an abstract
|
||||
classmethod. This is implemented as :class:`abc.abstractclassmethod`
|
||||
in the standard Python library starting with version 3.2.
|
||||
|
||||
This particular implementation, allegedly based on Python 3.3 source
|
||||
code, is stolen from
|
||||
http://stackoverflow.com/questions/11217878/python-2-7-combine-abc-abstractmethod-and-classmethod.
|
||||
|
||||
"""
|
||||
__isabstractmethod__ = True
|
||||
|
||||
def __init__(self, target):
|
||||
target.__isabstractmethod__ = True
|
||||
super(abstractclassmethod, self).__init__(target)
|
||||
|
||||
|
||||
class ComparableX509(object): # pylint: disable=too-few-public-methods
|
||||
"""Wrapper for OpenSSL.crypto.X509** objects that supports __eq__.
|
||||
|
||||
:ivar wrapped: Wrapped certificate or certificate request.
|
||||
:type wrapped: `OpenSSL.crypto.X509` or `OpenSSL.crypto.X509Req`.
|
||||
|
||||
"""
|
||||
def __init__(self, wrapped):
|
||||
assert isinstance(wrapped, OpenSSL.crypto.X509) or isinstance(
|
||||
wrapped, OpenSSL.crypto.X509Req)
|
||||
self.wrapped = wrapped
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.wrapped, name)
|
||||
|
||||
def _dump(self, filetype=OpenSSL.crypto.FILETYPE_ASN1):
|
||||
"""Dumps the object into a buffer with the specified encoding.
|
||||
|
||||
:param int filetype: The desired encoding. Should be one of
|
||||
`OpenSSL.crypto.FILETYPE_ASN1`,
|
||||
`OpenSSL.crypto.FILETYPE_PEM`, or
|
||||
`OpenSSL.crypto.FILETYPE_TEXT`.
|
||||
|
||||
:returns: Encoded X509 object.
|
||||
:rtype: str
|
||||
|
||||
"""
|
||||
if isinstance(self.wrapped, OpenSSL.crypto.X509):
|
||||
func = OpenSSL.crypto.dump_certificate
|
||||
else: # assert in __init__ makes sure this is X509Req
|
||||
func = OpenSSL.crypto.dump_certificate_request
|
||||
return func(filetype, self.wrapped)
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, self.__class__):
|
||||
return NotImplemented
|
||||
# pylint: disable=protected-access
|
||||
return self._dump() == other._dump()
|
||||
|
||||
def __hash__(self):
|
||||
return hash((self.__class__, self._dump()))
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self == other
|
||||
|
||||
def __repr__(self):
|
||||
return '<{0}({1!r})>'.format(self.__class__.__name__, self.wrapped)
|
||||
|
||||
|
||||
class ComparableKey(object): # pylint: disable=too-few-public-methods
|
||||
"""Comparable wrapper for `cryptography` keys.
|
||||
|
||||
See https://github.com/pyca/cryptography/issues/2122.
|
||||
|
||||
"""
|
||||
__hash__ = NotImplemented
|
||||
|
||||
def __init__(self, wrapped):
|
||||
self._wrapped = wrapped
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._wrapped, name)
|
||||
|
||||
def __eq__(self, other):
|
||||
# pylint: disable=protected-access
|
||||
if (not isinstance(other, self.__class__) or
|
||||
self._wrapped.__class__ is not other._wrapped.__class__):
|
||||
return NotImplemented
|
||||
elif hasattr(self._wrapped, 'private_numbers'):
|
||||
return self.private_numbers() == other.private_numbers()
|
||||
elif hasattr(self._wrapped, 'public_numbers'):
|
||||
return self.public_numbers() == other.public_numbers()
|
||||
else:
|
||||
return NotImplemented
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self == other
|
||||
|
||||
def __repr__(self):
|
||||
return '<{0}({1!r})>'.format(self.__class__.__name__, self._wrapped)
|
||||
|
||||
def public_key(self):
|
||||
"""Get wrapped public key."""
|
||||
return self.__class__(self._wrapped.public_key())
|
||||
|
||||
|
||||
class ComparableRSAKey(ComparableKey): # pylint: disable=too-few-public-methods
|
||||
"""Wrapper for `cryptography` RSA keys.
|
||||
|
||||
Wraps around:
|
||||
- `cryptography.hazmat.primitives.asymmetric.RSAPrivateKey`
|
||||
- `cryptography.hazmat.primitives.asymmetric.RSAPublicKey`
|
||||
|
||||
"""
|
||||
|
||||
def __hash__(self):
|
||||
# public_numbers() hasn't got stable hash!
|
||||
# https://github.com/pyca/cryptography/issues/2143
|
||||
if isinstance(self._wrapped, rsa.RSAPrivateKeyWithSerialization):
|
||||
priv = self.private_numbers()
|
||||
pub = priv.public_numbers
|
||||
return hash((self.__class__, priv.p, priv.q, priv.dmp1,
|
||||
priv.dmq1, priv.iqmp, pub.n, pub.e))
|
||||
elif isinstance(self._wrapped, rsa.RSAPublicKeyWithSerialization):
|
||||
pub = self.public_numbers()
|
||||
return hash((self.__class__, pub.n, pub.e))
|
||||
|
||||
|
||||
class ImmutableMap(collections.Mapping, collections.Hashable): # type: ignore
|
||||
# pylint: disable=too-few-public-methods
|
||||
"""Immutable key to value mapping with attribute access."""
|
||||
|
||||
__slots__ = ()
|
||||
"""Must be overridden in subclasses."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
if set(kwargs) != set(self.__slots__):
|
||||
raise TypeError(
|
||||
'__init__() takes exactly the following arguments: {0} '
|
||||
'({1} given)'.format(', '.join(self.__slots__),
|
||||
', '.join(kwargs) if kwargs else 'none'))
|
||||
for slot in self.__slots__:
|
||||
object.__setattr__(self, slot, kwargs.pop(slot))
|
||||
|
||||
def update(self, **kwargs):
|
||||
"""Return updated map."""
|
||||
items = dict(self)
|
||||
items.update(kwargs)
|
||||
return type(self)(**items) # pylint: disable=star-args
|
||||
|
||||
def __getitem__(self, key):
|
||||
try:
|
||||
return getattr(self, key)
|
||||
except AttributeError:
|
||||
raise KeyError(key)
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.__slots__)
|
||||
|
||||
def __len__(self):
|
||||
return len(self.__slots__)
|
||||
|
||||
def __hash__(self):
|
||||
return hash(tuple(getattr(self, slot) for slot in self.__slots__))
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
raise AttributeError("can't set attribute")
|
||||
|
||||
def __repr__(self):
|
||||
return '{0}({1})'.format(self.__class__.__name__, ', '.join(
|
||||
'{0}={1!r}'.format(key, value)
|
||||
for key, value in six.iteritems(self)))
|
||||
|
||||
|
||||
class frozendict(collections.Mapping, collections.Hashable): # type: ignore
|
||||
# pylint: disable=invalid-name,too-few-public-methods
|
||||
"""Frozen dictionary."""
|
||||
__slots__ = ('_items', '_keys')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if kwargs and not args:
|
||||
items = dict(kwargs)
|
||||
elif len(args) == 1 and isinstance(args[0], collections.Mapping):
|
||||
items = args[0]
|
||||
else:
|
||||
raise TypeError()
|
||||
# TODO: support generators/iterators
|
||||
|
||||
object.__setattr__(self, '_items', items)
|
||||
object.__setattr__(self, '_keys', tuple(sorted(six.iterkeys(items))))
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._items[key]
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._keys)
|
||||
|
||||
def __len__(self):
|
||||
return len(self._items)
|
||||
|
||||
def _sorted_items(self):
|
||||
return tuple((key, self[key]) for key in self._keys)
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self._sorted_items())
|
||||
|
||||
def __getattr__(self, name):
|
||||
try:
|
||||
return self._items[name]
|
||||
except KeyError:
|
||||
raise AttributeError(name)
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
raise AttributeError("can't set attribute")
|
||||
|
||||
def __repr__(self):
|
||||
return 'frozendict({0})'.format(', '.join('{0}={1!r}'.format(
|
||||
key, value) for key, value in self._sorted_items()))
|
||||
199
acme/acme/jose/util_test.py
Normal file
199
acme/acme/jose/util_test.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""Tests for acme.jose.util."""
|
||||
import functools
|
||||
import unittest
|
||||
|
||||
import six
|
||||
|
||||
from acme import test_util
|
||||
|
||||
|
||||
class ComparableX509Test(unittest.TestCase):
|
||||
"""Tests for acme.jose.util.ComparableX509."""
|
||||
|
||||
def setUp(self):
|
||||
# test_util.load_comparable_{csr,cert} return ComparableX509
|
||||
self.req1 = test_util.load_comparable_csr('csr.pem')
|
||||
self.req2 = test_util.load_comparable_csr('csr.pem')
|
||||
self.req_other = test_util.load_comparable_csr('csr-san.pem')
|
||||
|
||||
self.cert1 = test_util.load_comparable_cert('cert.pem')
|
||||
self.cert2 = test_util.load_comparable_cert('cert.pem')
|
||||
self.cert_other = test_util.load_comparable_cert('cert-san.pem')
|
||||
|
||||
def test_getattr_proxy(self):
|
||||
self.assertTrue(self.cert1.has_expired())
|
||||
|
||||
def test_eq(self):
|
||||
self.assertEqual(self.req1, self.req2)
|
||||
self.assertEqual(self.cert1, self.cert2)
|
||||
|
||||
def test_ne(self):
|
||||
self.assertNotEqual(self.req1, self.req_other)
|
||||
self.assertNotEqual(self.cert1, self.cert_other)
|
||||
|
||||
def test_ne_wrong_types(self):
|
||||
self.assertNotEqual(self.req1, 5)
|
||||
self.assertNotEqual(self.cert1, 5)
|
||||
|
||||
def test_hash(self):
|
||||
self.assertEqual(hash(self.req1), hash(self.req2))
|
||||
self.assertNotEqual(hash(self.req1), hash(self.req_other))
|
||||
|
||||
self.assertEqual(hash(self.cert1), hash(self.cert2))
|
||||
self.assertNotEqual(hash(self.cert1), hash(self.cert_other))
|
||||
|
||||
def test_repr(self):
|
||||
for x509 in self.req1, self.cert1:
|
||||
self.assertEqual(repr(x509),
|
||||
'<ComparableX509({0!r})>'.format(x509.wrapped))
|
||||
|
||||
|
||||
class ComparableRSAKeyTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.util.ComparableRSAKey."""
|
||||
|
||||
def setUp(self):
|
||||
# test_utl.load_rsa_private_key return ComparableRSAKey
|
||||
self.key = test_util.load_rsa_private_key('rsa256_key.pem')
|
||||
self.key_same = test_util.load_rsa_private_key('rsa256_key.pem')
|
||||
self.key2 = test_util.load_rsa_private_key('rsa512_key.pem')
|
||||
|
||||
def test_getattr_proxy(self):
|
||||
self.assertEqual(256, self.key.key_size)
|
||||
|
||||
def test_eq(self):
|
||||
self.assertEqual(self.key, self.key_same)
|
||||
|
||||
def test_ne(self):
|
||||
self.assertNotEqual(self.key, self.key2)
|
||||
|
||||
def test_ne_different_types(self):
|
||||
self.assertNotEqual(self.key, 5)
|
||||
|
||||
def test_ne_not_wrapped(self):
|
||||
# pylint: disable=protected-access
|
||||
self.assertNotEqual(self.key, self.key_same._wrapped)
|
||||
|
||||
def test_ne_no_serialization(self):
|
||||
from acme.jose.util import ComparableRSAKey
|
||||
self.assertNotEqual(ComparableRSAKey(5), ComparableRSAKey(5))
|
||||
|
||||
def test_hash(self):
|
||||
self.assertTrue(isinstance(hash(self.key), int))
|
||||
self.assertEqual(hash(self.key), hash(self.key_same))
|
||||
self.assertNotEqual(hash(self.key), hash(self.key2))
|
||||
|
||||
def test_repr(self):
|
||||
self.assertTrue(repr(self.key).startswith(
|
||||
'<ComparableRSAKey(<cryptography.hazmat.'))
|
||||
|
||||
def test_public_key(self):
|
||||
from acme.jose.util import ComparableRSAKey
|
||||
self.assertTrue(isinstance(self.key.public_key(), ComparableRSAKey))
|
||||
|
||||
|
||||
class ImmutableMapTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.util.ImmutableMap."""
|
||||
|
||||
def setUp(self):
|
||||
# pylint: disable=invalid-name,too-few-public-methods
|
||||
# pylint: disable=missing-docstring
|
||||
from acme.jose.util import ImmutableMap
|
||||
|
||||
class A(ImmutableMap):
|
||||
__slots__ = ('x', 'y')
|
||||
|
||||
class B(ImmutableMap):
|
||||
__slots__ = ('x', 'y')
|
||||
|
||||
self.A = A
|
||||
self.B = B
|
||||
|
||||
self.a1 = self.A(x=1, y=2)
|
||||
self.a1_swap = self.A(y=2, x=1)
|
||||
self.a2 = self.A(x=3, y=4)
|
||||
self.b = self.B(x=1, y=2)
|
||||
|
||||
def test_update(self):
|
||||
self.assertEqual(self.A(x=2, y=2), self.a1.update(x=2))
|
||||
self.assertEqual(self.a2, self.a1.update(x=3, y=4))
|
||||
|
||||
def test_get_missing_item_raises_key_error(self):
|
||||
self.assertRaises(KeyError, self.a1.__getitem__, 'z')
|
||||
|
||||
def test_order_of_args_does_not_matter(self):
|
||||
self.assertEqual(self.a1, self.a1_swap)
|
||||
|
||||
def test_type_error_on_missing(self):
|
||||
self.assertRaises(TypeError, self.A, x=1)
|
||||
self.assertRaises(TypeError, self.A, y=2)
|
||||
|
||||
def test_type_error_on_unrecognized(self):
|
||||
self.assertRaises(TypeError, self.A, x=1, z=2)
|
||||
self.assertRaises(TypeError, self.A, x=1, y=2, z=3)
|
||||
|
||||
def test_get_attr(self):
|
||||
self.assertEqual(1, self.a1.x)
|
||||
self.assertEqual(2, self.a1.y)
|
||||
self.assertEqual(1, self.a1_swap.x)
|
||||
self.assertEqual(2, self.a1_swap.y)
|
||||
|
||||
def test_set_attr_raises_attribute_error(self):
|
||||
self.assertRaises(
|
||||
AttributeError, functools.partial(self.a1.__setattr__, 'x'), 10)
|
||||
|
||||
def test_equal(self):
|
||||
self.assertEqual(self.a1, self.a1)
|
||||
self.assertEqual(self.a2, self.a2)
|
||||
self.assertNotEqual(self.a1, self.a2)
|
||||
|
||||
def test_hash(self):
|
||||
self.assertEqual(hash((1, 2)), hash(self.a1))
|
||||
|
||||
def test_unhashable(self):
|
||||
self.assertRaises(TypeError, self.A(x=1, y={}).__hash__)
|
||||
|
||||
def test_repr(self):
|
||||
self.assertEqual('A(x=1, y=2)', repr(self.a1))
|
||||
self.assertEqual('A(x=1, y=2)', repr(self.a1_swap))
|
||||
self.assertEqual('B(x=1, y=2)', repr(self.b))
|
||||
self.assertEqual("B(x='foo', y='bar')", repr(self.B(x='foo', y='bar')))
|
||||
|
||||
|
||||
class frozendictTest(unittest.TestCase): # pylint: disable=invalid-name
|
||||
"""Tests for acme.jose.util.frozendict."""
|
||||
|
||||
def setUp(self):
|
||||
from acme.jose.util import frozendict
|
||||
self.fdict = frozendict(x=1, y='2')
|
||||
|
||||
def test_init_dict(self):
|
||||
from acme.jose.util import frozendict
|
||||
self.assertEqual(self.fdict, frozendict({'x': 1, 'y': '2'}))
|
||||
|
||||
def test_init_other_raises_type_error(self):
|
||||
from acme.jose.util import frozendict
|
||||
# specifically fail for generators...
|
||||
self.assertRaises(TypeError, frozendict, six.iteritems({'a': 'b'}))
|
||||
|
||||
def test_len(self):
|
||||
self.assertEqual(2, len(self.fdict))
|
||||
|
||||
def test_hash(self):
|
||||
self.assertTrue(isinstance(hash(self.fdict), int))
|
||||
|
||||
def test_getattr_proxy(self):
|
||||
self.assertEqual(1, self.fdict.x)
|
||||
self.assertEqual('2', self.fdict.y)
|
||||
|
||||
def test_getattr_raises_attribute_error(self):
|
||||
self.assertRaises(AttributeError, self.fdict.__getattr__, 'z')
|
||||
|
||||
def test_setattr_immutable(self):
|
||||
self.assertRaises(AttributeError, self.fdict.__setattr__, 'z', 3)
|
||||
|
||||
def test_repr(self):
|
||||
self.assertEqual("frozendict(x=1, y='2')", repr(self.fdict))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
@@ -1,23 +1,17 @@
|
||||
"""ACME-specific JWS.
|
||||
|
||||
The JWS implementation in josepy only implements the base JOSE standard. In
|
||||
order to support the new header fields defined in ACME, this module defines some
|
||||
ACME-specific classes that layer on top of josepy.
|
||||
"""
|
||||
import josepy as jose
|
||||
"""ACME JOSE JWS."""
|
||||
from acme import jose
|
||||
|
||||
|
||||
class Header(jose.Header):
|
||||
"""ACME-specific JOSE Header. Implements nonce, kid, and url.
|
||||
"""ACME JOSE Header.
|
||||
|
||||
.. todo:: Implement ``acmePath``.
|
||||
|
||||
"""
|
||||
nonce = jose.Field('nonce', omitempty=True, encoder=jose.encode_b64jose)
|
||||
kid = jose.Field('kid', omitempty=True)
|
||||
url = jose.Field('url', omitempty=True)
|
||||
|
||||
# Mypy does not understand the josepy magic happening here, and falsely claims
|
||||
# that nonce is redefined. Let's ignore the type check here.
|
||||
@nonce.decoder # type: ignore
|
||||
def nonce(value): # pylint: disable=no-self-argument,missing-function-docstring
|
||||
@nonce.decoder
|
||||
def nonce(value): # pylint: disable=missing-docstring,no-self-argument
|
||||
try:
|
||||
return jose.decode_b64jose(value)
|
||||
except jose.DeserializationError as error:
|
||||
@@ -26,7 +20,7 @@ class Header(jose.Header):
|
||||
|
||||
|
||||
class Signature(jose.Signature):
|
||||
"""ACME-specific Signature. Uses ACME-specific Header for customer fields."""
|
||||
"""ACME Signature."""
|
||||
__slots__ = jose.Signature._orig_slots # pylint: disable=no-member
|
||||
|
||||
# TODO: decoder/encoder should accept cls? Otherwise, subclassing
|
||||
@@ -40,17 +34,11 @@ class Signature(jose.Signature):
|
||||
|
||||
|
||||
class JWS(jose.JWS):
|
||||
"""ACME-specific JWS. Includes none, url, and kid in protected header."""
|
||||
"""ACME JWS."""
|
||||
signature_cls = Signature
|
||||
__slots__ = jose.JWS._orig_slots
|
||||
__slots__ = jose.JWS._orig_slots # pylint: disable=no-member
|
||||
|
||||
@classmethod
|
||||
# pylint: disable=arguments-differ
|
||||
def sign(cls, payload, key, alg, nonce, url=None, kid=None):
|
||||
# Per ACME spec, jwk and kid are mutually exclusive, so only include a
|
||||
# jwk field if kid is not provided.
|
||||
include_jwk = kid is None
|
||||
def sign(cls, payload, key, alg, nonce): # pylint: disable=arguments-differ
|
||||
return super(JWS, cls).sign(payload, key=key, alg=alg,
|
||||
protect=frozenset(['nonce', 'url', 'kid', 'jwk', 'alg']),
|
||||
nonce=nonce, url=url, kid=kid,
|
||||
include_jwk=include_jwk)
|
||||
protect=frozenset(['nonce']), nonce=nonce)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Tests for acme.jws."""
|
||||
import unittest
|
||||
|
||||
import josepy as jose
|
||||
from acme import jose
|
||||
from acme import test_util
|
||||
|
||||
import test_util
|
||||
|
||||
KEY = jose.JWKRSA.load(test_util.load_vector('rsa512_key.pem'))
|
||||
|
||||
@@ -37,30 +37,16 @@ class JWSTest(unittest.TestCase):
|
||||
self.privkey = KEY
|
||||
self.pubkey = self.privkey.public_key()
|
||||
self.nonce = jose.b64encode(b'Nonce')
|
||||
self.url = 'hi'
|
||||
self.kid = 'baaaaa'
|
||||
|
||||
def test_kid_serialize(self):
|
||||
def test_it(self):
|
||||
from acme.jws import JWS
|
||||
jws = JWS.sign(payload=b'foo', key=self.privkey,
|
||||
alg=jose.RS256, nonce=self.nonce,
|
||||
url=self.url, kid=self.kid)
|
||||
alg=jose.RS256, nonce=self.nonce)
|
||||
self.assertEqual(jws.signature.combined.nonce, self.nonce)
|
||||
self.assertEqual(jws.signature.combined.url, self.url)
|
||||
self.assertEqual(jws.signature.combined.kid, self.kid)
|
||||
self.assertEqual(jws.signature.combined.jwk, None)
|
||||
# TODO: check that nonce is in protected header
|
||||
|
||||
self.assertEqual(jws, JWS.from_json(jws.to_json()))
|
||||
|
||||
def test_jwk_serialize(self):
|
||||
from acme.jws import JWS
|
||||
jws = JWS.sign(payload=b'foo', key=self.privkey,
|
||||
alg=jose.RS256, nonce=self.nonce,
|
||||
url=self.url)
|
||||
self.assertEqual(jws.signature.combined.kid, None)
|
||||
self.assertEqual(jws.signature.combined.jwk, self.pubkey)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
@@ -1,17 +0,0 @@
|
||||
"""Simple shim around the typing module.
|
||||
|
||||
This was useful when this code supported Python 2 and typing wasn't always
|
||||
available. This code is being kept for now for backwards compatibility.
|
||||
|
||||
"""
|
||||
import warnings
|
||||
from typing import * # pylint: disable=wildcard-import, unused-wildcard-import
|
||||
from typing import Collection, IO # type: ignore
|
||||
|
||||
warnings.warn("acme.magic_typing is deprecated and will be removed in a future release.",
|
||||
DeprecationWarning)
|
||||
|
||||
class TypingClass:
|
||||
"""Ignore import errors by getting anything"""
|
||||
def __getattr__(self, name):
|
||||
return None # pragma: no cover
|
||||
@@ -1,53 +1,30 @@
|
||||
"""ACME protocol messages."""
|
||||
from collections.abc import Hashable
|
||||
import json
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import Type
|
||||
|
||||
import josepy as jose
|
||||
import collections
|
||||
|
||||
from acme import challenges
|
||||
from acme import errors
|
||||
from acme import fields
|
||||
from acme import jws
|
||||
from acme import jose
|
||||
from acme import util
|
||||
from acme.mixins import ResourceMixin
|
||||
|
||||
OLD_ERROR_PREFIX = "urn:acme:error:"
|
||||
ERROR_PREFIX = "urn:ietf:params:acme:error:"
|
||||
|
||||
ERROR_CODES = {
|
||||
'accountDoesNotExist': 'The request specified an account that does not exist',
|
||||
'alreadyRevoked': 'The request specified a certificate to be revoked that has' \
|
||||
' already been revoked',
|
||||
'badCSR': 'The CSR is unacceptable (e.g., due to a short key)',
|
||||
'badNonce': 'The client sent an unacceptable anti-replay nonce',
|
||||
'badPublicKey': 'The JWS was signed by a public key the server does not support',
|
||||
'badRevocationReason': 'The revocation reason provided is not allowed by the server',
|
||||
'badSignatureAlgorithm': 'The JWS was signed with an algorithm the server does not support',
|
||||
'caa': 'Certification Authority Authorization (CAA) records forbid the CA from issuing' \
|
||||
' a certificate',
|
||||
'compound': 'Specific error conditions are indicated in the "subproblems" array',
|
||||
'connection': ('The server could not connect to the client to verify the'
|
||||
' domain'),
|
||||
'dns': 'There was a problem with a DNS query during identifier validation',
|
||||
'dnssec': 'The server could not validate a DNSSEC signed domain',
|
||||
'incorrectResponse': 'Response received didn\'t match the challenge\'s requirements',
|
||||
# deprecate invalidEmail
|
||||
'invalidEmail': 'The provided email for a registration was invalid',
|
||||
'invalidContact': 'The provided contact URI was invalid',
|
||||
'malformed': 'The request message was malformed',
|
||||
'rejectedIdentifier': 'The server will not issue certificates for the identifier',
|
||||
'orderNotReady': 'The request attempted to finalize an order that is not ready to be finalized',
|
||||
'rateLimited': 'There were too many requests of a given type',
|
||||
'serverInternal': 'The server experienced an internal error',
|
||||
'tls': 'The server experienced a TLS error during domain verification',
|
||||
'unauthorized': 'The client lacks sufficient authorization',
|
||||
'unsupportedContact': 'A contact URL for an account used an unsupported protocol scheme',
|
||||
'unknownHost': 'The server could not resolve a domain name',
|
||||
'unsupportedIdentifier': 'An identifier is of an unsupported type',
|
||||
'externalAccountRequired': 'The server requires external account binding',
|
||||
}
|
||||
|
||||
ERROR_TYPE_DESCRIPTIONS = dict(
|
||||
@@ -59,9 +36,7 @@ ERROR_TYPE_DESCRIPTIONS.update(dict( # add errors with old prefix, deprecate me
|
||||
|
||||
def is_acme_error(err):
|
||||
"""Check if argument is an ACME error."""
|
||||
if isinstance(err, Error) and (err.typ is not None):
|
||||
return (ERROR_PREFIX in err.typ) or (OLD_ERROR_PREFIX in err.typ)
|
||||
return False
|
||||
return (ERROR_PREFIX in str(err)) or (OLD_ERROR_PREFIX in str(err))
|
||||
|
||||
|
||||
class Error(jose.JSONObjectWithFields, errors.Error):
|
||||
@@ -90,9 +65,7 @@ class Error(jose.JSONObjectWithFields, errors.Error):
|
||||
raise ValueError("The supplied code: %s is not a known ACME error"
|
||||
" code" % code)
|
||||
typ = ERROR_PREFIX + code
|
||||
# Mypy will not understand that the Error constructor accepts a named argument
|
||||
# "typ" because of josepy magic. Let's ignore the type check here.
|
||||
return cls(typ=typ, **kwargs) # type: ignore
|
||||
return cls(typ=typ, **kwargs)
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
@@ -117,34 +90,32 @@ class Error(jose.JSONObjectWithFields, errors.Error):
|
||||
code = str(self.typ).split(':')[-1]
|
||||
if code in ERROR_CODES:
|
||||
return code
|
||||
return None
|
||||
|
||||
def __str__(self):
|
||||
return b' :: '.join(
|
||||
part.encode('ascii', 'backslashreplace') for part in
|
||||
return ' :: '.join(
|
||||
part for part in
|
||||
(self.typ, self.description, self.detail, self.title)
|
||||
if part is not None).decode()
|
||||
if part is not None)
|
||||
|
||||
|
||||
class _Constant(jose.JSONDeSerializable, Hashable): # type: ignore
|
||||
class _Constant(jose.JSONDeSerializable, collections.Hashable): # type: ignore
|
||||
"""ACME constant."""
|
||||
__slots__ = ('name',)
|
||||
POSSIBLE_NAMES: Dict[str, '_Constant'] = NotImplemented
|
||||
POSSIBLE_NAMES = NotImplemented
|
||||
|
||||
def __init__(self, name):
|
||||
super(_Constant, self).__init__()
|
||||
self.POSSIBLE_NAMES[name] = self # pylint: disable=unsupported-assignment-operation
|
||||
self.POSSIBLE_NAMES[name] = self
|
||||
self.name = name
|
||||
|
||||
def to_partial_json(self):
|
||||
return self.name
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
if jobj not in cls.POSSIBLE_NAMES: # pylint: disable=unsupported-membership-test
|
||||
def from_json(cls, value):
|
||||
if value not in cls.POSSIBLE_NAMES:
|
||||
raise jose.DeserializationError(
|
||||
'{0} not recognized'.format(cls.__name__))
|
||||
return cls.POSSIBLE_NAMES[jobj]
|
||||
return cls.POSSIBLE_NAMES[value]
|
||||
|
||||
def __repr__(self):
|
||||
return '{0}({1})'.format(self.__class__.__name__, self.name)
|
||||
@@ -155,23 +126,24 @@ class _Constant(jose.JSONDeSerializable, Hashable): # type: ignore
|
||||
def __hash__(self):
|
||||
return hash((self.__class__, self.name))
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self == other
|
||||
|
||||
|
||||
class Status(_Constant):
|
||||
"""ACME "status" field."""
|
||||
POSSIBLE_NAMES: dict = {}
|
||||
POSSIBLE_NAMES = {} # type: dict
|
||||
STATUS_UNKNOWN = Status('unknown')
|
||||
STATUS_PENDING = Status('pending')
|
||||
STATUS_PROCESSING = Status('processing')
|
||||
STATUS_VALID = Status('valid')
|
||||
STATUS_INVALID = Status('invalid')
|
||||
STATUS_REVOKED = Status('revoked')
|
||||
STATUS_READY = Status('ready')
|
||||
STATUS_DEACTIVATED = Status('deactivated')
|
||||
|
||||
|
||||
class IdentifierType(_Constant):
|
||||
"""ACME identifier type."""
|
||||
POSSIBLE_NAMES: Dict[str, 'IdentifierType'] = {}
|
||||
POSSIBLE_NAMES = {} # type: dict
|
||||
IDENTIFIER_FQDN = IdentifierType('dns') # IdentifierDNS in Boulder
|
||||
|
||||
|
||||
@@ -189,41 +161,20 @@ class Identifier(jose.JSONObjectWithFields):
|
||||
class Directory(jose.JSONDeSerializable):
|
||||
"""Directory."""
|
||||
|
||||
_REGISTERED_TYPES: Dict[str, Type[Any]] = {}
|
||||
_REGISTERED_TYPES = {} # type: dict
|
||||
|
||||
class Meta(jose.JSONObjectWithFields):
|
||||
"""Directory Meta."""
|
||||
_terms_of_service = jose.Field('terms-of-service', omitempty=True)
|
||||
_terms_of_service_v2 = jose.Field('termsOfService', omitempty=True)
|
||||
terms_of_service = jose.Field('terms-of-service', omitempty=True)
|
||||
website = jose.Field('website', omitempty=True)
|
||||
caa_identities = jose.Field('caaIdentities', omitempty=True)
|
||||
external_account_required = jose.Field('externalAccountRequired', omitempty=True)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
kwargs = {self._internal_name(k): v for k, v in kwargs.items()}
|
||||
super(Directory.Meta, self).__init__(**kwargs)
|
||||
|
||||
@property
|
||||
def terms_of_service(self):
|
||||
"""URL for the CA TOS"""
|
||||
return self._terms_of_service or self._terms_of_service_v2
|
||||
|
||||
def __iter__(self):
|
||||
# When iterating over fields, use the external name 'terms_of_service' instead of
|
||||
# the internal '_terms_of_service'.
|
||||
for name in super(Directory.Meta, self).__iter__():
|
||||
yield name[1:] if name == '_terms_of_service' else name
|
||||
|
||||
def _internal_name(self, name):
|
||||
return '_' + name if name == 'terms_of_service' else name
|
||||
|
||||
caa_identities = jose.Field('caa-identities', omitempty=True)
|
||||
|
||||
@classmethod
|
||||
def _canon_key(cls, key):
|
||||
return getattr(key, 'resource_type', key)
|
||||
|
||||
@classmethod
|
||||
def register(cls, resource_body_cls: Type[Any]) -> Type[Any]:
|
||||
def register(cls, resource_body_cls):
|
||||
"""Register resource."""
|
||||
resource_type = resource_body_cls.resource_type
|
||||
assert resource_type not in cls._REGISTERED_TYPES
|
||||
@@ -240,13 +191,13 @@ class Directory(jose.JSONDeSerializable):
|
||||
try:
|
||||
return self[name.replace('_', '-')]
|
||||
except KeyError as error:
|
||||
raise AttributeError(str(error))
|
||||
raise AttributeError(str(error) + ': ' + name)
|
||||
|
||||
def __getitem__(self, name):
|
||||
try:
|
||||
return self._jobj[self._canon_key(name)]
|
||||
except KeyError:
|
||||
raise KeyError('Directory field "' + self._canon_key(name) + '" not found')
|
||||
raise KeyError('Directory field not found')
|
||||
|
||||
def to_partial_json(self):
|
||||
return self._jobj
|
||||
@@ -269,7 +220,7 @@ class Resource(jose.JSONObjectWithFields):
|
||||
class ResourceWithURI(Resource):
|
||||
"""ACME Resource with URI.
|
||||
|
||||
:ivar unicode ~.uri: Location of the resource.
|
||||
:ivar unicode uri: Location of the resource.
|
||||
|
||||
"""
|
||||
uri = jose.Field('uri') # no ChallengeResource.uri
|
||||
@@ -279,118 +230,63 @@ class ResourceBody(jose.JSONObjectWithFields):
|
||||
"""ACME Resource Body."""
|
||||
|
||||
|
||||
class ExternalAccountBinding:
|
||||
"""ACME External Account Binding"""
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, account_public_key, kid, hmac_key, directory):
|
||||
"""Create External Account Binding Resource from contact details, kid and hmac."""
|
||||
|
||||
key_json = json.dumps(account_public_key.to_partial_json()).encode()
|
||||
decoded_hmac_key = jose.b64.b64decode(hmac_key)
|
||||
url = directory["newAccount"]
|
||||
|
||||
eab = jws.JWS.sign(key_json, jose.jwk.JWKOct(key=decoded_hmac_key),
|
||||
jose.jwa.HS256, None,
|
||||
url, kid)
|
||||
|
||||
return eab.to_partial_json()
|
||||
|
||||
|
||||
class Registration(ResourceBody):
|
||||
"""Registration Resource Body.
|
||||
|
||||
:ivar josepy.jwk.JWK key: Public key.
|
||||
:ivar acme.jose.jwk.JWK key: Public key.
|
||||
:ivar tuple contact: Contact information following ACME spec,
|
||||
`tuple` of `unicode`.
|
||||
:ivar unicode agreement:
|
||||
:ivar unicode authorizations: URI where
|
||||
`messages.Registration.Authorizations` can be found.
|
||||
:ivar unicode certificates: URI where
|
||||
`messages.Registration.Certificates` can be found.
|
||||
|
||||
"""
|
||||
# on new-reg key server ignores 'key' and populates it based on
|
||||
# JWS.signature.combined.jwk
|
||||
key = jose.Field('key', omitempty=True, decoder=jose.JWK.from_json)
|
||||
# Contact field implements special behavior to allow messages that clear existing
|
||||
# contacts while not expecting the `contact` field when loading from json.
|
||||
# This is implemented in the constructor and *_json methods.
|
||||
contact = jose.Field('contact', omitempty=True, default=())
|
||||
agreement = jose.Field('agreement', omitempty=True)
|
||||
authorizations = jose.Field('authorizations', omitempty=True)
|
||||
certificates = jose.Field('certificates', omitempty=True)
|
||||
status = jose.Field('status', omitempty=True)
|
||||
terms_of_service_agreed = jose.Field('termsOfServiceAgreed', omitempty=True)
|
||||
only_return_existing = jose.Field('onlyReturnExisting', omitempty=True)
|
||||
external_account_binding = jose.Field('externalAccountBinding', omitempty=True)
|
||||
|
||||
class Authorizations(jose.JSONObjectWithFields):
|
||||
"""Authorizations granted to Account in the process of registration.
|
||||
|
||||
:ivar tuple authorizations: URIs to Authorization Resources.
|
||||
|
||||
"""
|
||||
authorizations = jose.Field('authorizations')
|
||||
|
||||
class Certificates(jose.JSONObjectWithFields):
|
||||
"""Certificates granted to Account in the process of registration.
|
||||
|
||||
:ivar tuple certificates: URIs to Certificate Resources.
|
||||
|
||||
"""
|
||||
certificates = jose.Field('certificates')
|
||||
|
||||
phone_prefix = 'tel:'
|
||||
email_prefix = 'mailto:'
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, phone=None, email=None, external_account_binding=None, **kwargs):
|
||||
"""
|
||||
Create registration resource from contact details.
|
||||
|
||||
The `contact` keyword being passed to a Registration object is meaningful, so
|
||||
this function represents empty iterables in its kwargs by passing on an empty
|
||||
`tuple`.
|
||||
"""
|
||||
|
||||
# Note if `contact` was in kwargs.
|
||||
contact_provided = 'contact' in kwargs
|
||||
|
||||
# Pop `contact` from kwargs and add formatted email or phone numbers
|
||||
def from_data(cls, phone=None, email=None, **kwargs):
|
||||
"""Create registration resource from contact details."""
|
||||
details = list(kwargs.pop('contact', ()))
|
||||
if phone is not None:
|
||||
details.append(cls.phone_prefix + phone)
|
||||
if email is not None:
|
||||
details.extend([cls.email_prefix + mail for mail in email.split(',')])
|
||||
|
||||
# Insert formatted contact information back into kwargs
|
||||
# or insert an empty tuple if `contact` provided.
|
||||
if details or contact_provided:
|
||||
kwargs['contact'] = tuple(details)
|
||||
|
||||
if external_account_binding:
|
||||
kwargs['external_account_binding'] = external_account_binding
|
||||
|
||||
details.append(cls.email_prefix + email)
|
||||
kwargs['contact'] = tuple(details)
|
||||
return cls(**kwargs)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Note if the user provides a value for the `contact` member."""
|
||||
if 'contact' in kwargs:
|
||||
# Avoid the __setattr__ used by jose.TypedJSONObjectWithFields
|
||||
object.__setattr__(self, '_add_contact', True)
|
||||
super(Registration, self).__init__(**kwargs)
|
||||
|
||||
def _filter_contact(self, prefix):
|
||||
return tuple(
|
||||
detail[len(prefix):] for detail in self.contact # pylint: disable=not-an-iterable
|
||||
detail[len(prefix):] for detail in self.contact
|
||||
if detail.startswith(prefix))
|
||||
|
||||
def _add_contact_if_appropriate(self, jobj):
|
||||
"""
|
||||
The `contact` member of Registration objects should not be required when
|
||||
de-serializing (as it would be if the Fields' `omitempty` flag were `False`), but
|
||||
it should be included in serializations if it was provided.
|
||||
|
||||
:param jobj: Dictionary containing this Registrations' data
|
||||
:type jobj: dict
|
||||
|
||||
:returns: Dictionary containing Registrations data to transmit to the server
|
||||
:rtype: dict
|
||||
"""
|
||||
if getattr(self, '_add_contact', False):
|
||||
jobj['contact'] = self.encode('contact')
|
||||
|
||||
return jobj
|
||||
|
||||
def to_partial_json(self):
|
||||
"""Modify josepy.JSONDeserializable.to_partial_json()"""
|
||||
jobj = super(Registration, self).to_partial_json()
|
||||
return self._add_contact_if_appropriate(jobj)
|
||||
|
||||
def fields_to_partial_json(self):
|
||||
"""Modify josepy.JSONObjectWithFields.fields_to_partial_json()"""
|
||||
jobj = super(Registration, self).fields_to_partial_json()
|
||||
return self._add_contact_if_appropriate(jobj)
|
||||
|
||||
@property
|
||||
def phones(self):
|
||||
"""All phones found in the ``contact`` field."""
|
||||
@@ -403,13 +299,13 @@ class Registration(ResourceBody):
|
||||
|
||||
|
||||
@Directory.register
|
||||
class NewRegistration(ResourceMixin, Registration):
|
||||
class NewRegistration(Registration):
|
||||
"""New registration."""
|
||||
resource_type = 'new-reg'
|
||||
resource = fields.Resource(resource_type)
|
||||
|
||||
|
||||
class UpdateRegistration(ResourceMixin, Registration):
|
||||
class UpdateRegistration(Registration):
|
||||
"""Update registration."""
|
||||
resource_type = 'reg'
|
||||
resource = fields.Resource(resource_type)
|
||||
@@ -446,25 +342,13 @@ class ChallengeBody(ResourceBody):
|
||||
|
||||
"""
|
||||
__slots__ = ('chall',)
|
||||
# ACMEv1 has a "uri" field in challenges. ACMEv2 has a "url" field. This
|
||||
# challenge object supports either one, but should be accessed through the
|
||||
# name "uri". In Client.answer_challenge, whichever one is set will be
|
||||
# used.
|
||||
_uri = jose.Field('uri', omitempty=True, default=None)
|
||||
_url = jose.Field('url', omitempty=True, default=None)
|
||||
uri = jose.Field('uri')
|
||||
status = jose.Field('status', decoder=Status.from_json,
|
||||
omitempty=True, default=STATUS_PENDING)
|
||||
validated = fields.RFC3339Field('validated', omitempty=True)
|
||||
error = jose.Field('error', decoder=Error.from_json,
|
||||
omitempty=True, default=None)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
kwargs = {self._internal_name(k): v for k, v in kwargs.items()}
|
||||
super(ChallengeBody, self).__init__(**kwargs)
|
||||
|
||||
def encode(self, name):
|
||||
return super(ChallengeBody, self).encode(self._internal_name(name))
|
||||
|
||||
def to_partial_json(self):
|
||||
jobj = super(ChallengeBody, self).to_partial_json()
|
||||
jobj.update(self.chall.to_partial_json())
|
||||
@@ -476,23 +360,9 @@ class ChallengeBody(ResourceBody):
|
||||
jobj_fields['chall'] = challenges.Challenge.from_json(jobj)
|
||||
return jobj_fields
|
||||
|
||||
@property
|
||||
def uri(self):
|
||||
"""The URL of this challenge."""
|
||||
return self._url or self._uri
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.chall, name)
|
||||
|
||||
def __iter__(self):
|
||||
# When iterating over fields, use the external name 'uri' instead of
|
||||
# the internal '_uri'.
|
||||
for name in super(ChallengeBody, self).__iter__():
|
||||
yield name[1:] if name == '_uri' else name
|
||||
|
||||
def _internal_name(self, name):
|
||||
return '_' + name if name == 'uri' else name
|
||||
|
||||
|
||||
class ChallengeResource(Resource):
|
||||
"""Challenge Resource.
|
||||
@@ -505,9 +375,10 @@ class ChallengeResource(Resource):
|
||||
authzr_uri = jose.Field('authzr_uri')
|
||||
|
||||
@property
|
||||
def uri(self):
|
||||
"""The URL of the challenge body."""
|
||||
return self.body.uri
|
||||
def uri(self): # pylint: disable=missing-docstring,no-self-argument
|
||||
# bug? 'method already defined line None'
|
||||
# pylint: disable=function-redefined
|
||||
return self.body.uri # pylint: disable=no-member
|
||||
|
||||
|
||||
class Authorization(ResourceBody):
|
||||
@@ -521,7 +392,7 @@ class Authorization(ResourceBody):
|
||||
:ivar datetime.datetime expires:
|
||||
|
||||
"""
|
||||
identifier = jose.Field('identifier', decoder=Identifier.from_json, omitempty=True)
|
||||
identifier = jose.Field('identifier', decoder=Identifier.from_json)
|
||||
challenges = jose.Field('challenges', omitempty=True)
|
||||
combinations = jose.Field('combinations', omitempty=True)
|
||||
|
||||
@@ -531,34 +402,25 @@ class Authorization(ResourceBody):
|
||||
# be absent'... then acme-spec gives example with 'expires'
|
||||
# present... That's confusing!
|
||||
expires = fields.RFC3339Field('expires', omitempty=True)
|
||||
wildcard = jose.Field('wildcard', omitempty=True)
|
||||
|
||||
# Mypy does not understand the josepy magic happening here, and falsely claims
|
||||
# that challenge is redefined. Let's ignore the type check here.
|
||||
@challenges.decoder # type: ignore
|
||||
def challenges(value): # pylint: disable=no-self-argument,missing-function-docstring
|
||||
@challenges.decoder
|
||||
def challenges(value): # pylint: disable=missing-docstring,no-self-argument
|
||||
return tuple(ChallengeBody.from_json(chall) for chall in value)
|
||||
|
||||
@property
|
||||
def resolved_combinations(self):
|
||||
"""Combinations with challenges instead of indices."""
|
||||
return tuple(tuple(self.challenges[idx] for idx in combo)
|
||||
for combo in self.combinations) # pylint: disable=not-an-iterable
|
||||
for combo in self.combinations)
|
||||
|
||||
|
||||
@Directory.register
|
||||
class NewAuthorization(ResourceMixin, Authorization):
|
||||
class NewAuthorization(Authorization):
|
||||
"""New authorization."""
|
||||
resource_type = 'new-authz'
|
||||
resource = fields.Resource(resource_type)
|
||||
|
||||
|
||||
class UpdateAuthorization(ResourceMixin, Authorization):
|
||||
"""Update authorization."""
|
||||
resource_type = 'authz'
|
||||
resource = fields.Resource(resource_type)
|
||||
|
||||
|
||||
class AuthorizationResource(ResourceWithURI):
|
||||
"""Authorization Resource.
|
||||
|
||||
@@ -571,10 +433,10 @@ class AuthorizationResource(ResourceWithURI):
|
||||
|
||||
|
||||
@Directory.register
|
||||
class CertificateRequest(ResourceMixin, jose.JSONObjectWithFields):
|
||||
class CertificateRequest(jose.JSONObjectWithFields):
|
||||
"""ACME new-cert request.
|
||||
|
||||
:ivar josepy.util.ComparableX509 csr:
|
||||
:ivar acme.jose.util.ComparableX509 csr:
|
||||
`OpenSSL.crypto.X509Req` wrapped in `.ComparableX509`
|
||||
|
||||
"""
|
||||
@@ -586,7 +448,7 @@ class CertificateRequest(ResourceMixin, jose.JSONObjectWithFields):
|
||||
class CertificateResource(ResourceWithURI):
|
||||
"""Certificate Resource.
|
||||
|
||||
:ivar josepy.util.ComparableX509 body:
|
||||
:ivar acme.jose.util.ComparableX509 body:
|
||||
`OpenSSL.crypto.X509` wrapped in `.ComparableX509`
|
||||
:ivar unicode cert_chain_uri: URI found in the 'up' ``Link`` header
|
||||
:ivar tuple authzrs: `tuple` of `AuthorizationResource`.
|
||||
@@ -597,7 +459,7 @@ class CertificateResource(ResourceWithURI):
|
||||
|
||||
|
||||
@Directory.register
|
||||
class Revocation(ResourceMixin, jose.JSONObjectWithFields):
|
||||
class Revocation(jose.JSONObjectWithFields):
|
||||
"""Revocation message.
|
||||
|
||||
:ivar .ComparableX509 certificate: `OpenSSL.crypto.X509` wrapped in
|
||||
@@ -609,58 +471,3 @@ class Revocation(ResourceMixin, jose.JSONObjectWithFields):
|
||||
certificate = jose.Field(
|
||||
'certificate', decoder=jose.decode_cert, encoder=jose.encode_cert)
|
||||
reason = jose.Field('reason')
|
||||
|
||||
|
||||
class Order(ResourceBody):
|
||||
"""Order Resource Body.
|
||||
|
||||
:ivar identifiers: List of identifiers for the certificate.
|
||||
:vartype identifiers: `list` of `.Identifier`
|
||||
:ivar acme.messages.Status status:
|
||||
:ivar authorizations: URLs of authorizations.
|
||||
:vartype authorizations: `list` of `str`
|
||||
:ivar str certificate: URL to download certificate as a fullchain PEM.
|
||||
:ivar str finalize: URL to POST to to request issuance once all
|
||||
authorizations have "valid" status.
|
||||
:ivar datetime.datetime expires: When the order expires.
|
||||
:ivar ~.Error error: Any error that occurred during finalization, if applicable.
|
||||
"""
|
||||
identifiers = jose.Field('identifiers', omitempty=True)
|
||||
status = jose.Field('status', decoder=Status.from_json,
|
||||
omitempty=True)
|
||||
authorizations = jose.Field('authorizations', omitempty=True)
|
||||
certificate = jose.Field('certificate', omitempty=True)
|
||||
finalize = jose.Field('finalize', omitempty=True)
|
||||
expires = fields.RFC3339Field('expires', omitempty=True)
|
||||
error = jose.Field('error', omitempty=True, decoder=Error.from_json)
|
||||
|
||||
# Mypy does not understand the josepy magic happening here, and falsely claims
|
||||
# that identifiers is redefined. Let's ignore the type check here.
|
||||
@identifiers.decoder # type: ignore
|
||||
def identifiers(value): # pylint: disable=no-self-argument,missing-function-docstring
|
||||
return tuple(Identifier.from_json(identifier) for identifier in value)
|
||||
|
||||
class OrderResource(ResourceWithURI):
|
||||
"""Order Resource.
|
||||
|
||||
:ivar acme.messages.Order body:
|
||||
:ivar str csr_pem: The CSR this Order will be finalized with.
|
||||
:ivar authorizations: Fully-fetched AuthorizationResource objects.
|
||||
:vartype authorizations: `list` of `acme.messages.AuthorizationResource`
|
||||
:ivar str fullchain_pem: The fetched contents of the certificate URL
|
||||
produced once the order was finalized, if it's present.
|
||||
:ivar alternative_fullchains_pem: The fetched contents of alternative certificate
|
||||
chain URLs produced once the order was finalized, if present and requested during
|
||||
finalization.
|
||||
:vartype alternative_fullchains_pem: `list` of `str`
|
||||
"""
|
||||
body = jose.Field('body', decoder=Order.from_json)
|
||||
csr_pem = jose.Field('csr_pem', omitempty=True)
|
||||
authorizations = jose.Field('authorizations')
|
||||
fullchain_pem = jose.Field('fullchain_pem', omitempty=True)
|
||||
alternative_fullchains_pem = jose.Field('alternative_fullchains_pem', omitempty=True)
|
||||
|
||||
@Directory.register
|
||||
class NewOrder(Order):
|
||||
"""New order."""
|
||||
resource_type = 'new-order'
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""Tests for acme.messages."""
|
||||
from typing import Dict
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import josepy as jose
|
||||
import mock
|
||||
|
||||
from acme import challenges
|
||||
import test_util
|
||||
from acme import jose
|
||||
from acme import test_util
|
||||
|
||||
|
||||
CERT = test_util.load_comparable_cert('cert.der')
|
||||
CSR = test_util.load_comparable_csr('csr.der')
|
||||
@@ -18,14 +18,15 @@ class ErrorTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
from acme.messages import Error, ERROR_PREFIX
|
||||
self.error = Error.with_code('malformed', detail='foo', title='title')
|
||||
self.error = Error(
|
||||
detail='foo', typ=ERROR_PREFIX + 'malformed', title='title')
|
||||
self.jobj = {
|
||||
'detail': 'foo',
|
||||
'title': 'some title',
|
||||
'type': ERROR_PREFIX + 'malformed',
|
||||
}
|
||||
self.error_custom = Error(typ='custom', detail='bar')
|
||||
self.empty_error = Error()
|
||||
self.jobj_custom = {'type': 'custom', 'detail': 'bar'}
|
||||
|
||||
def test_default_typ(self):
|
||||
from acme.messages import Error
|
||||
@@ -40,9 +41,16 @@ class ErrorTest(unittest.TestCase):
|
||||
hash(Error.from_json(self.error.to_json()))
|
||||
|
||||
def test_description(self):
|
||||
self.assertEqual('The request message was malformed', self.error.description)
|
||||
self.assertEqual(
|
||||
'The request message was malformed', self.error.description)
|
||||
self.assertTrue(self.error_custom.description is None)
|
||||
|
||||
def test_str(self):
|
||||
self.assertEqual(
|
||||
'urn:ietf:params:acme:error:malformed :: The request message was '
|
||||
'malformed :: foo :: title', str(self.error))
|
||||
self.assertEqual('custom :: bar', str(self.error_custom))
|
||||
|
||||
def test_code(self):
|
||||
from acme.messages import Error
|
||||
self.assertEqual('malformed', self.error.code)
|
||||
@@ -50,30 +58,16 @@ class ErrorTest(unittest.TestCase):
|
||||
self.assertEqual(None, Error().code)
|
||||
|
||||
def test_is_acme_error(self):
|
||||
from acme.messages import is_acme_error, Error
|
||||
from acme.messages import is_acme_error
|
||||
self.assertTrue(is_acme_error(self.error))
|
||||
self.assertTrue(is_acme_error(str(self.error)))
|
||||
self.assertFalse(is_acme_error(self.error_custom))
|
||||
self.assertFalse(is_acme_error(Error()))
|
||||
self.assertFalse(is_acme_error(self.empty_error))
|
||||
self.assertFalse(is_acme_error("must pet all the {dogs|rabbits}"))
|
||||
|
||||
def test_unicode_error(self):
|
||||
from acme.messages import Error, is_acme_error
|
||||
arabic_error = Error.with_code(
|
||||
'malformed', detail=u'\u0639\u062f\u0627\u0644\u0629', title='title')
|
||||
self.assertTrue(is_acme_error(arabic_error))
|
||||
|
||||
def test_with_code(self):
|
||||
from acme.messages import Error, is_acme_error
|
||||
self.assertTrue(is_acme_error(Error.with_code('badCSR')))
|
||||
self.assertRaises(ValueError, Error.with_code, 'not an ACME error code')
|
||||
|
||||
def test_str(self):
|
||||
self.assertEqual(
|
||||
str(self.error),
|
||||
u"{0.typ} :: {0.description} :: {0.detail} :: {0.title}"
|
||||
.format(self.error))
|
||||
|
||||
|
||||
class ConstantTest(unittest.TestCase):
|
||||
"""Tests for acme.messages._Constant."""
|
||||
@@ -82,7 +76,7 @@ class ConstantTest(unittest.TestCase):
|
||||
from acme.messages import _Constant
|
||||
|
||||
class MockConstant(_Constant): # pylint: disable=missing-docstring
|
||||
POSSIBLE_NAMES: Dict = {}
|
||||
POSSIBLE_NAMES = {}
|
||||
|
||||
self.MockConstant = MockConstant # pylint: disable=invalid-name
|
||||
self.const_a = MockConstant('a')
|
||||
@@ -106,11 +100,11 @@ class ConstantTest(unittest.TestCase):
|
||||
|
||||
def test_equality(self):
|
||||
const_a_prime = self.MockConstant('a')
|
||||
self.assertNotEqual(self.const_a, self.const_b)
|
||||
self.assertEqual(self.const_a, const_a_prime)
|
||||
self.assertFalse(self.const_a == self.const_b)
|
||||
self.assertTrue(self.const_a == const_a_prime)
|
||||
|
||||
self.assertNotEqual(self.const_a, self.const_b)
|
||||
self.assertEqual(self.const_a, const_a_prime)
|
||||
self.assertTrue(self.const_a != self.const_b)
|
||||
self.assertFalse(self.const_a != const_a_prime)
|
||||
|
||||
|
||||
class DirectoryTest(unittest.TestCase):
|
||||
@@ -154,7 +148,7 @@ class DirectoryTest(unittest.TestCase):
|
||||
'meta': {
|
||||
'terms-of-service': 'https://example.com/acme/terms',
|
||||
'website': 'https://www.example.com/',
|
||||
'caaIdentities': ['example.com'],
|
||||
'caa-identities': ['example.com'],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -162,31 +156,6 @@ class DirectoryTest(unittest.TestCase):
|
||||
from acme.messages import Directory
|
||||
Directory.from_json({'foo': 'bar'})
|
||||
|
||||
def test_iter_meta(self):
|
||||
result = False
|
||||
for k in self.dir.meta:
|
||||
if k == 'terms_of_service':
|
||||
result = self.dir.meta[k] == 'https://example.com/acme/terms'
|
||||
self.assertTrue(result)
|
||||
|
||||
|
||||
class ExternalAccountBindingTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from acme.messages import Directory
|
||||
self.key = jose.jwk.JWKRSA(key=KEY.public_key())
|
||||
self.kid = "kid-for-testing"
|
||||
self.hmac_key = "hmac-key-for-testing"
|
||||
self.dir = Directory({
|
||||
'newAccount': 'http://url/acme/new-account',
|
||||
})
|
||||
|
||||
def test_from_data(self):
|
||||
from acme.messages import ExternalAccountBinding
|
||||
eab = ExternalAccountBinding.from_data(self.key, self.kid, self.hmac_key, self.dir)
|
||||
|
||||
self.assertEqual(len(eab), 3)
|
||||
self.assertEqual(sorted(eab.keys()), sorted(['protected', 'payload', 'signature']))
|
||||
|
||||
|
||||
class RegistrationTest(unittest.TestCase):
|
||||
"""Tests for acme.messages.Registration."""
|
||||
@@ -201,7 +170,8 @@ class RegistrationTest(unittest.TestCase):
|
||||
|
||||
from acme.messages import Registration
|
||||
self.reg = Registration(key=key, contact=contact, agreement=agreement)
|
||||
self.reg_none = Registration()
|
||||
self.reg_none = Registration(authorizations='uri/authorizations',
|
||||
certificates='uri/certificates')
|
||||
|
||||
self.jobj_to = {
|
||||
'contact': contact,
|
||||
@@ -219,22 +189,6 @@ class RegistrationTest(unittest.TestCase):
|
||||
'mailto:admin@foo.com',
|
||||
))
|
||||
|
||||
def test_new_registration_from_data_with_eab(self):
|
||||
from acme.messages import NewRegistration, ExternalAccountBinding, Directory
|
||||
key = jose.jwk.JWKRSA(key=KEY.public_key())
|
||||
kid = "kid-for-testing"
|
||||
hmac_key = "hmac-key-for-testing"
|
||||
directory = Directory({
|
||||
'newAccount': 'http://url/acme/new-account',
|
||||
})
|
||||
eab = ExternalAccountBinding.from_data(key, kid, hmac_key, directory)
|
||||
reg = NewRegistration.from_data(email='admin@foo.com', external_account_binding=eab)
|
||||
self.assertEqual(reg.contact, (
|
||||
'mailto:admin@foo.com',
|
||||
))
|
||||
self.assertEqual(sorted(reg.external_account_binding.keys()),
|
||||
sorted(['protected', 'payload', 'signature']))
|
||||
|
||||
def test_phones(self):
|
||||
self.assertEqual(('1234',), self.reg.phones)
|
||||
|
||||
@@ -252,19 +206,6 @@ class RegistrationTest(unittest.TestCase):
|
||||
from acme.messages import Registration
|
||||
hash(Registration.from_json(self.jobj_from))
|
||||
|
||||
def test_default_not_transmitted(self):
|
||||
from acme.messages import NewRegistration
|
||||
empty_new_reg = NewRegistration()
|
||||
new_reg_with_contact = NewRegistration(contact=())
|
||||
|
||||
self.assertEqual(empty_new_reg.contact, ())
|
||||
self.assertEqual(new_reg_with_contact.contact, ())
|
||||
|
||||
self.assertTrue('contact' not in empty_new_reg.to_partial_json())
|
||||
self.assertTrue('contact' not in empty_new_reg.fields_to_partial_json())
|
||||
self.assertTrue('contact' in new_reg_with_contact.to_partial_json())
|
||||
self.assertTrue('contact' in new_reg_with_contact.fields_to_partial_json())
|
||||
|
||||
|
||||
class UpdateRegistrationTest(unittest.TestCase):
|
||||
"""Tests for acme.messages.UpdateRegistration."""
|
||||
@@ -314,7 +255,8 @@ class ChallengeBodyTest(unittest.TestCase):
|
||||
from acme.messages import Error
|
||||
from acme.messages import STATUS_INVALID
|
||||
self.status = STATUS_INVALID
|
||||
error = Error.with_code('serverInternal', detail='Unable to communicate with DNS server')
|
||||
error = Error(typ='urn:ietf:params:acme:error:serverInternal',
|
||||
detail='Unable to communicate with DNS server')
|
||||
self.challb = ChallengeBody(
|
||||
uri='http://challb', chall=self.chall, status=self.status,
|
||||
error=error)
|
||||
@@ -333,9 +275,6 @@ class ChallengeBodyTest(unittest.TestCase):
|
||||
'detail': 'Unable to communicate with DNS server',
|
||||
}
|
||||
|
||||
def test_encode(self):
|
||||
self.assertEqual(self.challb.encode('uri'), self.challb.uri)
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.jobj_to, self.challb.to_partial_json())
|
||||
|
||||
@@ -451,48 +390,5 @@ class RevocationTest(unittest.TestCase):
|
||||
hash(Revocation.from_json(self.rev.to_json()))
|
||||
|
||||
|
||||
class OrderResourceTest(unittest.TestCase):
|
||||
"""Tests for acme.messages.OrderResource."""
|
||||
|
||||
def setUp(self):
|
||||
from acme.messages import OrderResource
|
||||
self.regr = OrderResource(
|
||||
body=mock.sentinel.body, uri=mock.sentinel.uri)
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.regr.to_json(), {
|
||||
'body': mock.sentinel.body,
|
||||
'uri': mock.sentinel.uri,
|
||||
'authorizations': None,
|
||||
})
|
||||
|
||||
|
||||
class NewOrderTest(unittest.TestCase):
|
||||
"""Tests for acme.messages.NewOrder."""
|
||||
|
||||
def setUp(self):
|
||||
from acme.messages import NewOrder
|
||||
self.reg = NewOrder(
|
||||
identifiers=mock.sentinel.identifiers)
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.reg.to_json(), {
|
||||
'identifiers': mock.sentinel.identifiers,
|
||||
})
|
||||
|
||||
|
||||
class JWSPayloadRFC8555Compliant(unittest.TestCase):
|
||||
"""Test for RFC8555 compliance of JWS generated from resources/challenges"""
|
||||
def test_message_payload(self):
|
||||
from acme.messages import NewAuthorization
|
||||
|
||||
new_order = NewAuthorization()
|
||||
new_order.le_acme_version = 2
|
||||
|
||||
jobj = new_order.json_dumps(indent=2).encode()
|
||||
# RFC8555 states that JWS bodies must not have a resource field.
|
||||
self.assertEqual(jobj, b'{}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
@@ -1,65 +0,0 @@
|
||||
"""Useful mixins for Challenge and Resource objects"""
|
||||
|
||||
|
||||
class VersionedLEACMEMixin:
|
||||
"""This mixin stores the version of Let's Encrypt's endpoint being used."""
|
||||
@property
|
||||
def le_acme_version(self):
|
||||
"""Define the version of ACME protocol to use"""
|
||||
return getattr(self, '_le_acme_version', 1)
|
||||
|
||||
@le_acme_version.setter
|
||||
def le_acme_version(self, version):
|
||||
# We need to use object.__setattr__ to not depend on the specific implementation of
|
||||
# __setattr__ in current class (eg. jose.TypedJSONObjectWithFields raises AttributeError
|
||||
# for any attempt to set an attribute to make objects immutable).
|
||||
object.__setattr__(self, '_le_acme_version', version)
|
||||
|
||||
def __setattr__(self, key, value):
|
||||
if key == 'le_acme_version':
|
||||
# Required for @property to operate properly. See comment above.
|
||||
object.__setattr__(self, key, value)
|
||||
else:
|
||||
super(VersionedLEACMEMixin, self).__setattr__(key, value) # pragma: no cover
|
||||
|
||||
|
||||
class ResourceMixin(VersionedLEACMEMixin):
|
||||
"""
|
||||
This mixin generates a RFC8555 compliant JWS payload
|
||||
by removing the `resource` field if needed (eg. ACME v2 protocol).
|
||||
"""
|
||||
def to_partial_json(self):
|
||||
"""See josepy.JSONDeserializable.to_partial_json()"""
|
||||
return _safe_jobj_compliance(super(ResourceMixin, self),
|
||||
'to_partial_json', 'resource')
|
||||
|
||||
def fields_to_partial_json(self):
|
||||
"""See josepy.JSONObjectWithFields.fields_to_partial_json()"""
|
||||
return _safe_jobj_compliance(super(ResourceMixin, self),
|
||||
'fields_to_partial_json', 'resource')
|
||||
|
||||
|
||||
class TypeMixin(VersionedLEACMEMixin):
|
||||
"""
|
||||
This mixin allows generation of a RFC8555 compliant JWS payload
|
||||
by removing the `type` field if needed (eg. ACME v2 protocol).
|
||||
"""
|
||||
def to_partial_json(self):
|
||||
"""See josepy.JSONDeserializable.to_partial_json()"""
|
||||
return _safe_jobj_compliance(super(TypeMixin, self),
|
||||
'to_partial_json', 'type')
|
||||
|
||||
def fields_to_partial_json(self):
|
||||
"""See josepy.JSONObjectWithFields.fields_to_partial_json()"""
|
||||
return _safe_jobj_compliance(super(TypeMixin, self),
|
||||
'fields_to_partial_json', 'type')
|
||||
|
||||
|
||||
def _safe_jobj_compliance(instance, jobj_method, uncompliant_field):
|
||||
if hasattr(instance, jobj_method):
|
||||
jobj = getattr(instance, jobj_method)()
|
||||
if instance.le_acme_version == 2:
|
||||
jobj.pop(uncompliant_field, None)
|
||||
return jobj
|
||||
|
||||
raise AttributeError('Method {0}() is not implemented.'.format(jobj_method)) # pragma: no cover
|
||||
@@ -1,192 +1,82 @@
|
||||
"""Support for standalone client challenge solvers. """
|
||||
import argparse
|
||||
import collections
|
||||
import functools
|
||||
import http.client as http_client
|
||||
import http.server as BaseHTTPServer
|
||||
import logging
|
||||
import socket
|
||||
import socketserver
|
||||
import threading
|
||||
from typing import List
|
||||
import os
|
||||
import sys
|
||||
|
||||
from six.moves import BaseHTTPServer # type: ignore # pylint: disable=import-error
|
||||
from six.moves import http_client # pylint: disable=import-error
|
||||
from six.moves import socketserver # type: ignore # pylint: disable=import-error
|
||||
|
||||
import OpenSSL
|
||||
|
||||
from acme import challenges
|
||||
from acme import crypto_util
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# six.moves.* | pylint: disable=no-member,attribute-defined-outside-init
|
||||
# pylint: disable=too-few-public-methods,no-init
|
||||
|
||||
|
||||
class TLSServer(socketserver.TCPServer):
|
||||
"""Generic TLS Server."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.ipv6 = kwargs.pop("ipv6", False)
|
||||
if self.ipv6:
|
||||
self.address_family = socket.AF_INET6
|
||||
else:
|
||||
self.address_family = socket.AF_INET
|
||||
self.certs = kwargs.pop("certs", {})
|
||||
self.method = kwargs.pop(
|
||||
"method", crypto_util._DEFAULT_SSL_METHOD)
|
||||
# pylint: disable=protected-access
|
||||
"method", crypto_util._DEFAULT_TLSSNI01_SSL_METHOD)
|
||||
self.allow_reuse_address = kwargs.pop("allow_reuse_address", True)
|
||||
socketserver.TCPServer.__init__(self, *args, **kwargs)
|
||||
|
||||
def _wrap_sock(self):
|
||||
self.socket = crypto_util.SSLSocket(
|
||||
self.socket, cert_selection=self._cert_selection,
|
||||
alpn_selection=getattr(self, '_alpn_selection', None),
|
||||
method=self.method)
|
||||
self.socket, certs=self.certs, method=self.method)
|
||||
|
||||
def _cert_selection(self, connection): # pragma: no cover
|
||||
"""Callback selecting certificate for connection."""
|
||||
server_name = connection.get_servername()
|
||||
return self.certs.get(server_name, None)
|
||||
|
||||
def server_bind(self):
|
||||
def server_bind(self): # pylint: disable=missing-docstring
|
||||
self._wrap_sock()
|
||||
return socketserver.TCPServer.server_bind(self)
|
||||
|
||||
|
||||
class ACMEServerMixin:
|
||||
class ACMEServerMixin: # pylint: disable=old-style-class
|
||||
"""ACME server common settings mixin."""
|
||||
# TODO: c.f. #858
|
||||
server_version = "ACME client standalone challenge solver"
|
||||
allow_reuse_address = True
|
||||
|
||||
|
||||
class BaseDualNetworkedServers:
|
||||
"""Base class for a pair of IPv6 and IPv4 servers that tries to do everything
|
||||
it's asked for both servers, but where failures in one server don't
|
||||
affect the other.
|
||||
class TLSSNI01Server(TLSServer, ACMEServerMixin):
|
||||
"""TLSSNI01 Server."""
|
||||
|
||||
If two servers are instantiated, they will serve on the same port.
|
||||
"""
|
||||
|
||||
def __init__(self, ServerClass, server_address, *remaining_args, **kwargs):
|
||||
port = server_address[1]
|
||||
self.threads: List[threading.Thread] = []
|
||||
self.servers: List[socketserver.BaseServer] = []
|
||||
|
||||
# Must try True first.
|
||||
# Ubuntu, for example, will fail to bind to IPv4 if we've already bound
|
||||
# to IPv6. But that's ok, since it will accept IPv4 connections on the IPv6
|
||||
# socket. On the other hand, FreeBSD will successfully bind to IPv4 on the
|
||||
# same port, which means that server will accept the IPv4 connections.
|
||||
# If Python is compiled without IPv6, we'll error out but (probably) successfully
|
||||
# create the IPv4 server.
|
||||
for ip_version in [True, False]:
|
||||
try:
|
||||
kwargs["ipv6"] = ip_version
|
||||
new_address = (server_address[0],) + (port,) + server_address[2:]
|
||||
new_args = (new_address,) + remaining_args
|
||||
server = ServerClass(*new_args, **kwargs)
|
||||
logger.debug(
|
||||
"Successfully bound to %s:%s using %s", new_address[0],
|
||||
new_address[1], "IPv6" if ip_version else "IPv4")
|
||||
except socket.error:
|
||||
if self.servers:
|
||||
# Already bound using IPv6.
|
||||
logger.debug(
|
||||
"Certbot wasn't able to bind to %s:%s using %s, this "
|
||||
"is often expected due to the dual stack nature of "
|
||||
"IPv6 socket implementations.",
|
||||
new_address[0], new_address[1],
|
||||
"IPv6" if ip_version else "IPv4")
|
||||
else:
|
||||
logger.debug(
|
||||
"Failed to bind to %s:%s using %s", new_address[0],
|
||||
new_address[1], "IPv6" if ip_version else "IPv4")
|
||||
else:
|
||||
self.servers.append(server)
|
||||
# If two servers are set up and port 0 was passed in, ensure we always
|
||||
# bind to the same port for both servers.
|
||||
port = server.socket.getsockname()[1]
|
||||
if not self.servers:
|
||||
raise socket.error("Could not bind to IPv4 or IPv6.")
|
||||
|
||||
def serve_forever(self):
|
||||
"""Wraps socketserver.TCPServer.serve_forever"""
|
||||
for server in self.servers:
|
||||
thread = threading.Thread(
|
||||
target=server.serve_forever)
|
||||
thread.start()
|
||||
self.threads.append(thread)
|
||||
|
||||
def getsocknames(self):
|
||||
"""Wraps socketserver.TCPServer.socket.getsockname"""
|
||||
return [server.socket.getsockname() for server in self.servers]
|
||||
|
||||
def shutdown_and_server_close(self):
|
||||
"""Wraps socketserver.TCPServer.shutdown, socketserver.TCPServer.server_close, and
|
||||
threading.Thread.join"""
|
||||
for server in self.servers:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
for thread in self.threads:
|
||||
thread.join()
|
||||
self.threads = []
|
||||
|
||||
|
||||
class TLSALPN01Server(TLSServer, ACMEServerMixin):
|
||||
"""TLSALPN01 Server."""
|
||||
|
||||
ACME_TLS_1_PROTOCOL = b"acme-tls/1"
|
||||
|
||||
def __init__(self, server_address, certs, challenge_certs, ipv6=False):
|
||||
def __init__(self, server_address, certs):
|
||||
TLSServer.__init__(
|
||||
self, server_address, _BaseRequestHandlerWithLogging, certs=certs,
|
||||
ipv6=ipv6)
|
||||
self.challenge_certs = challenge_certs
|
||||
|
||||
def _cert_selection(self, connection):
|
||||
# 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
|
||||
# negotiation is done after cert selection.
|
||||
# Therefore, currently we always return challenge cert, and terminate
|
||||
# handshake in alpn_selection() if ALPN protos are not what we expect.
|
||||
# [0] https://github.com/openssl/openssl/issues/4952
|
||||
server_name = connection.get_servername()
|
||||
logger.debug("Serving challenge cert for server name %s", server_name)
|
||||
return self.challenge_certs.get(server_name, None)
|
||||
|
||||
def _alpn_selection(self, _connection, alpn_protos):
|
||||
"""Callback to select alpn protocol."""
|
||||
if len(alpn_protos) == 1 and alpn_protos[0] == self.ACME_TLS_1_PROTOCOL:
|
||||
logger.debug("Agreed on %s ALPN", self.ACME_TLS_1_PROTOCOL)
|
||||
return self.ACME_TLS_1_PROTOCOL
|
||||
logger.debug("Cannot agree on ALPN proto. Got: %s", str(alpn_protos))
|
||||
# Explicitly close the connection now, by returning an empty string.
|
||||
# See https://www.pyopenssl.org/en/stable/api/ssl.html#OpenSSL.SSL.Context.set_alpn_select_callback # pylint: disable=line-too-long
|
||||
return b""
|
||||
self, server_address, BaseRequestHandlerWithLogging, certs=certs)
|
||||
|
||||
|
||||
class HTTPServer(BaseHTTPServer.HTTPServer):
|
||||
"""Generic HTTP Server."""
|
||||
class BaseRequestHandlerWithLogging(socketserver.BaseRequestHandler):
|
||||
"""BaseRequestHandler with logging."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.ipv6 = kwargs.pop("ipv6", False)
|
||||
if self.ipv6:
|
||||
self.address_family = socket.AF_INET6
|
||||
else:
|
||||
self.address_family = socket.AF_INET
|
||||
BaseHTTPServer.HTTPServer.__init__(self, *args, **kwargs)
|
||||
def log_message(self, format, *args): # pylint: disable=redefined-builtin
|
||||
"""Log arbitrary message."""
|
||||
logger.debug("%s - - %s", self.client_address[0], format % args)
|
||||
|
||||
def handle(self):
|
||||
"""Handle request."""
|
||||
self.log_message("Incoming request")
|
||||
socketserver.BaseRequestHandler.handle(self)
|
||||
|
||||
|
||||
class HTTP01Server(HTTPServer, ACMEServerMixin):
|
||||
class HTTP01Server(BaseHTTPServer.HTTPServer, ACMEServerMixin):
|
||||
"""HTTP01 Server."""
|
||||
|
||||
def __init__(self, server_address, resources, ipv6=False, timeout=30):
|
||||
HTTPServer.__init__(
|
||||
def __init__(self, server_address, resources):
|
||||
BaseHTTPServer.HTTPServer.__init__(
|
||||
self, server_address, HTTP01RequestHandler.partial_init(
|
||||
simple_http_resources=resources, timeout=timeout), ipv6=ipv6)
|
||||
|
||||
|
||||
class HTTP01DualNetworkedServers(BaseDualNetworkedServers):
|
||||
"""HTTP01Server Wrapper. Tries everything for both. Failures for one don't
|
||||
affect the other."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
BaseDualNetworkedServers.__init__(self, HTTP01Server, *args, **kwargs)
|
||||
simple_http_resources=resources))
|
||||
|
||||
|
||||
class HTTP01RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
|
||||
@@ -203,24 +93,7 @@ class HTTP01RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.simple_http_resources = kwargs.pop("simple_http_resources", set())
|
||||
self._timeout = kwargs.pop('timeout', 30)
|
||||
BaseHTTPServer.BaseHTTPRequestHandler.__init__(self, *args, **kwargs)
|
||||
self.server: HTTP01Server
|
||||
|
||||
# In parent class BaseHTTPRequestHandler, 'timeout' is a class-level property but we
|
||||
# need to define its value during the initialization phase in HTTP01RequestHandler.
|
||||
# However MyPy does not appreciate that we dynamically shadow a class-level property
|
||||
# with an instance-level property (eg. self.timeout = ... in __init__()). So to make
|
||||
# everyone happy, we statically redefine 'timeout' as a method property, and set the
|
||||
# timeout value in a new internal instance-level property _timeout.
|
||||
@property
|
||||
def timeout(self):
|
||||
"""
|
||||
The default timeout this server should apply to requests.
|
||||
:return: timeout to apply
|
||||
:rtype: int
|
||||
"""
|
||||
return self._timeout
|
||||
socketserver.BaseRequestHandler.__init__(self, *args, **kwargs)
|
||||
|
||||
def log_message(self, format, *args): # pylint: disable=redefined-builtin
|
||||
"""Log arbitrary message."""
|
||||
@@ -231,7 +104,7 @@ class HTTP01RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
|
||||
self.log_message("Incoming request")
|
||||
BaseHTTPServer.BaseHTTPRequestHandler.handle(self)
|
||||
|
||||
def do_GET(self): # pylint: disable=invalid-name,missing-function-docstring
|
||||
def do_GET(self): # pylint: disable=invalid-name,missing-docstring
|
||||
if self.path == "/":
|
||||
self.handle_index()
|
||||
elif self.path.startswith("/" + challenges.HTTP01.URI_ROOT_PATH):
|
||||
@@ -269,7 +142,7 @@ class HTTP01RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
|
||||
self.path)
|
||||
|
||||
@classmethod
|
||||
def partial_init(cls, simple_http_resources, timeout):
|
||||
def partial_init(cls, simple_http_resources):
|
||||
"""Partially initialize this handler.
|
||||
|
||||
This is useful because `socketserver.BaseServer` takes
|
||||
@@ -278,18 +151,40 @@ class HTTP01RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
|
||||
|
||||
"""
|
||||
return functools.partial(
|
||||
cls, simple_http_resources=simple_http_resources,
|
||||
timeout=timeout)
|
||||
cls, simple_http_resources=simple_http_resources)
|
||||
|
||||
|
||||
class _BaseRequestHandlerWithLogging(socketserver.BaseRequestHandler):
|
||||
"""BaseRequestHandler with logging."""
|
||||
def simple_tls_sni_01_server(cli_args, forever=True):
|
||||
"""Run simple standalone TLSSNI01 server."""
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
def log_message(self, format, *args): # pylint: disable=redefined-builtin
|
||||
"""Log arbitrary message."""
|
||||
logger.debug("%s - - %s", self.client_address[0], format % args)
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"-p", "--port", default=0, help="Port to serve at. By default "
|
||||
"picks random free port.")
|
||||
args = parser.parse_args(cli_args[1:])
|
||||
|
||||
def handle(self):
|
||||
"""Handle request."""
|
||||
self.log_message("Incoming request")
|
||||
socketserver.BaseRequestHandler.handle(self)
|
||||
certs = {}
|
||||
|
||||
_, hosts, _ = next(os.walk('.'))
|
||||
for host in hosts:
|
||||
with open(os.path.join(host, "cert.pem")) as cert_file:
|
||||
cert_contents = cert_file.read()
|
||||
with open(os.path.join(host, "key.pem")) as key_file:
|
||||
key_contents = key_file.read()
|
||||
certs[host.encode()] = (
|
||||
OpenSSL.crypto.load_privatekey(
|
||||
OpenSSL.crypto.FILETYPE_PEM, key_contents),
|
||||
OpenSSL.crypto.load_certificate(
|
||||
OpenSSL.crypto.FILETYPE_PEM, cert_contents))
|
||||
|
||||
server = TLSSNI01Server(('', int(args.port)), certs=certs)
|
||||
logger.info("Serving at https://%s:%s...", *server.socket.getsockname()[:2])
|
||||
if forever: # pragma: no cover
|
||||
server.serve_forever()
|
||||
else:
|
||||
server.handle_request()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(simple_tls_sni_01_server(sys.argv)) # pragma: no cover
|
||||
|
||||
165
acme/acme/standalone_test.py
Normal file
165
acme/acme/standalone_test.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Tests for acme.standalone."""
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from six.moves import http_client # pylint: disable=import-error
|
||||
from six.moves import socketserver # type: ignore # pylint: disable=import-error
|
||||
|
||||
import requests
|
||||
|
||||
from acme import challenges
|
||||
from acme import crypto_util
|
||||
from acme import errors
|
||||
from acme import jose
|
||||
from acme import test_util
|
||||
|
||||
|
||||
class TLSServerTest(unittest.TestCase):
|
||||
"""Tests for acme.standalone.TLSServer."""
|
||||
|
||||
_multiprocess_can_split_ = True
|
||||
|
||||
def test_bind(self): # pylint: disable=no-self-use
|
||||
from acme.standalone import TLSServer
|
||||
server = TLSServer(
|
||||
('', 0), socketserver.BaseRequestHandler, bind_and_activate=True)
|
||||
server.server_close() # pylint: disable=no-member
|
||||
|
||||
|
||||
class TLSSNI01ServerTest(unittest.TestCase):
|
||||
"""Test for acme.standalone.TLSSNI01Server."""
|
||||
|
||||
_multiprocess_can_split_ = True
|
||||
|
||||
def setUp(self):
|
||||
self.certs = {b'localhost': (
|
||||
test_util.load_pyopenssl_private_key('rsa2048_key.pem'),
|
||||
test_util.load_cert('rsa2048_cert.pem'),
|
||||
)}
|
||||
from acme.standalone import TLSSNI01Server
|
||||
self.server = TLSSNI01Server(("", 0), certs=self.certs)
|
||||
# pylint: disable=no-member
|
||||
self.thread = threading.Thread(target=self.server.serve_forever)
|
||||
self.thread.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown() # pylint: disable=no-member
|
||||
self.thread.join()
|
||||
|
||||
def test_it(self):
|
||||
host, port = self.server.socket.getsockname()[:2]
|
||||
cert = crypto_util.probe_sni(
|
||||
b'localhost', host=host, port=port, timeout=1)
|
||||
self.assertEqual(jose.ComparableX509(cert),
|
||||
jose.ComparableX509(self.certs[b'localhost'][1]))
|
||||
|
||||
|
||||
class HTTP01ServerTest(unittest.TestCase):
|
||||
"""Tests for acme.standalone.HTTP01Server."""
|
||||
|
||||
_multiprocess_can_split_ = True
|
||||
|
||||
def setUp(self):
|
||||
self.account_key = jose.JWK.load(
|
||||
test_util.load_vector('rsa1024_key.pem'))
|
||||
self.resources = set()
|
||||
|
||||
from acme.standalone import HTTP01Server
|
||||
self.server = HTTP01Server(('', 0), resources=self.resources)
|
||||
|
||||
# pylint: disable=no-member
|
||||
self.port = self.server.socket.getsockname()[1]
|
||||
self.thread = threading.Thread(target=self.server.serve_forever)
|
||||
self.thread.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown() # pylint: disable=no-member
|
||||
self.thread.join()
|
||||
|
||||
def test_index(self):
|
||||
response = requests.get(
|
||||
'http://localhost:{0}'.format(self.port), verify=False)
|
||||
self.assertEqual(
|
||||
response.text, 'ACME client standalone challenge solver')
|
||||
self.assertTrue(response.ok)
|
||||
|
||||
def test_404(self):
|
||||
response = requests.get(
|
||||
'http://localhost:{0}/foo'.format(self.port), verify=False)
|
||||
self.assertEqual(response.status_code, http_client.NOT_FOUND)
|
||||
|
||||
def _test_http01(self, add):
|
||||
chall = challenges.HTTP01(token=(b'x' * 16))
|
||||
response, validation = chall.response_and_validation(self.account_key)
|
||||
|
||||
from acme.standalone import HTTP01RequestHandler
|
||||
resource = HTTP01RequestHandler.HTTP01Resource(
|
||||
chall=chall, response=response, validation=validation)
|
||||
if add:
|
||||
self.resources.add(resource)
|
||||
return resource.response.simple_verify(
|
||||
resource.chall, 'localhost', self.account_key.public_key(),
|
||||
port=self.port)
|
||||
|
||||
def test_http01_found(self):
|
||||
self.assertTrue(self._test_http01(add=True))
|
||||
|
||||
def test_http01_not_found(self):
|
||||
self.assertFalse(self._test_http01(add=False))
|
||||
|
||||
|
||||
class TestSimpleTLSSNI01Server(unittest.TestCase):
|
||||
"""Tests for acme.standalone.simple_tls_sni_01_server."""
|
||||
|
||||
_multiprocess_can_split_ = True
|
||||
|
||||
def setUp(self):
|
||||
# mirror ../examples/standalone
|
||||
self.test_cwd = tempfile.mkdtemp()
|
||||
localhost_dir = os.path.join(self.test_cwd, 'localhost')
|
||||
os.makedirs(localhost_dir)
|
||||
shutil.copy(test_util.vector_path('rsa2048_cert.pem'),
|
||||
os.path.join(localhost_dir, 'cert.pem'))
|
||||
shutil.copy(test_util.vector_path('rsa2048_key.pem'),
|
||||
os.path.join(localhost_dir, 'key.pem'))
|
||||
|
||||
from acme.standalone import simple_tls_sni_01_server
|
||||
self.port = 1234
|
||||
self.thread = threading.Thread(
|
||||
target=simple_tls_sni_01_server, kwargs={
|
||||
'cli_args': ('xxx', '--port', str(self.port)),
|
||||
'forever': False,
|
||||
},
|
||||
)
|
||||
self.old_cwd = os.getcwd()
|
||||
os.chdir(self.test_cwd)
|
||||
self.thread.start()
|
||||
|
||||
def tearDown(self):
|
||||
os.chdir(self.old_cwd)
|
||||
self.thread.join()
|
||||
shutil.rmtree(self.test_cwd)
|
||||
|
||||
def test_it(self):
|
||||
max_attempts = 5
|
||||
while max_attempts:
|
||||
max_attempts -= 1
|
||||
try:
|
||||
cert = crypto_util.probe_sni(
|
||||
b'localhost', b'0.0.0.0', self.port)
|
||||
except errors.Error:
|
||||
self.assertTrue(max_attempts > 0, "Timeout!")
|
||||
time.sleep(1) # wait until thread starts
|
||||
else:
|
||||
self.assertEqual(jose.ComparableX509(cert),
|
||||
test_util.load_comparable_cert(
|
||||
'rsa2048_cert.pem'))
|
||||
break
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main() # pragma: no cover
|
||||
@@ -4,12 +4,20 @@
|
||||
|
||||
"""
|
||||
import os
|
||||
import pkg_resources
|
||||
import unittest
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
import josepy as jose
|
||||
from OpenSSL import crypto
|
||||
import pkg_resources
|
||||
import OpenSSL
|
||||
|
||||
from acme import jose
|
||||
|
||||
|
||||
def vector_path(*names):
|
||||
"""Path to a test vector."""
|
||||
return pkg_resources.resource_filename(
|
||||
__name__, os.path.join('testdata', *names))
|
||||
|
||||
|
||||
def load_vector(*names):
|
||||
@@ -25,14 +33,15 @@ def _guess_loader(filename, loader_pem, loader_der):
|
||||
return loader_pem
|
||||
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):
|
||||
"""Load certificate."""
|
||||
loader = _guess_loader(
|
||||
names[-1], crypto.FILETYPE_PEM, crypto.FILETYPE_ASN1)
|
||||
return crypto.load_certificate(loader, load_vector(*names))
|
||||
names[-1], OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.FILETYPE_ASN1)
|
||||
return OpenSSL.crypto.load_certificate(loader, load_vector(*names))
|
||||
|
||||
|
||||
def load_comparable_cert(*names):
|
||||
@@ -43,8 +52,8 @@ def load_comparable_cert(*names):
|
||||
def load_csr(*names):
|
||||
"""Load certificate request."""
|
||||
loader = _guess_loader(
|
||||
names[-1], crypto.FILETYPE_PEM, crypto.FILETYPE_ASN1)
|
||||
return crypto.load_certificate_request(loader, load_vector(*names))
|
||||
names[-1], OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.FILETYPE_ASN1)
|
||||
return OpenSSL.crypto.load_certificate_request(loader, load_vector(*names))
|
||||
|
||||
|
||||
def load_comparable_csr(*names):
|
||||
@@ -63,5 +72,26 @@ def load_rsa_private_key(*names):
|
||||
def load_pyopenssl_private_key(*names):
|
||||
"""Load pyOpenSSL private key."""
|
||||
loader = _guess_loader(
|
||||
names[-1], crypto.FILETYPE_PEM, crypto.FILETYPE_ASN1)
|
||||
return crypto.load_privatekey(loader, load_vector(*names))
|
||||
names[-1], OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.FILETYPE_ASN1)
|
||||
return OpenSSL.crypto.load_privatekey(loader, load_vector(*names))
|
||||
|
||||
|
||||
def skip_unless(condition, reason): # pragma: no cover
|
||||
"""Skip tests unless a condition holds.
|
||||
|
||||
This implements the basic functionality of unittest.skipUnless
|
||||
which is only available on Python 2.7+.
|
||||
|
||||
:param bool condition: If ``False``, the test will be skipped
|
||||
:param str reason: the reason for skipping the test
|
||||
|
||||
:rtype: callable
|
||||
:returns: decorator that hides tests unless condition is ``True``
|
||||
|
||||
"""
|
||||
if hasattr(unittest, "skipUnless"):
|
||||
return unittest.skipUnless(condition, reason)
|
||||
elif condition:
|
||||
return lambda cls: cls
|
||||
else:
|
||||
return lambda cls: None
|
||||
@@ -4,14 +4,12 @@ to use appropriate extension for vector filenames: .pem for PEM and
|
||||
|
||||
The following command has been used to generate test keys:
|
||||
|
||||
for k in 256 512 1024 2048 4096; do openssl genrsa -out rsa${k}_key.pem $k; done
|
||||
for x in 256 512 1024 2048; do openssl genrsa -out rsa${k}_key.pem $k; done
|
||||
|
||||
and for the CSR:
|
||||
|
||||
openssl req -key rsa2048_key.pem -new -subj '/CN=example.com' -outform DER > csr.der
|
||||
|
||||
and for the certificates:
|
||||
and for the certificate:
|
||||
|
||||
openssl req -key rsa2048_key.pem -new -subj '/CN=example.com' -x509 -outform DER > cert.der
|
||||
openssl req -key rsa2048_key.pem -new -subj '/CN=example.com' -x509 > rsa2048_cert.pem
|
||||
openssl req -key rsa1024_key.pem -new -subj '/CN=example.com' -x509 > rsa1024_cert.pem
|
||||
openssl req -key rsa2047_key.pem -new -subj '/CN=example.com' -x509 -outform DER > cert.der
|
||||
@@ -1,6 +1,7 @@
|
||||
"""ACME utilities."""
|
||||
import six
|
||||
|
||||
|
||||
def map_keys(dikt, func):
|
||||
"""Map dictionary keys."""
|
||||
return {func(key): value for key, value in dikt.items()}
|
||||
return dict((func(key), value) for key, value in six.iteritems(dikt))
|
||||
|
||||
@@ -9,7 +9,7 @@ BUILDDIR = _build
|
||||
|
||||
# User-friendly check for sphinx-build
|
||||
ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
|
||||
$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from https://www.sphinx-doc.org/)
|
||||
$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
|
||||
endif
|
||||
|
||||
# Internal variables.
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
JOSE
|
||||
----
|
||||
|
||||
The ``acme.jose`` module was moved to its own package "josepy_".
|
||||
Please refer to its documentation there.
|
||||
.. automodule:: acme.jose
|
||||
:members:
|
||||
|
||||
.. _josepy: https://josepy.readthedocs.io/
|
||||
.. toctree::
|
||||
:glob:
|
||||
|
||||
jose/*
|
||||
|
||||
5
acme/docs/api/jose/base64.rst
Normal file
5
acme/docs/api/jose/base64.rst
Normal file
@@ -0,0 +1,5 @@
|
||||
JOSE Base64
|
||||
-----------
|
||||
|
||||
.. automodule:: acme.jose.b64
|
||||
:members:
|
||||
5
acme/docs/api/jose/errors.rst
Normal file
5
acme/docs/api/jose/errors.rst
Normal file
@@ -0,0 +1,5 @@
|
||||
Errors
|
||||
------
|
||||
|
||||
.. automodule:: acme.jose.errors
|
||||
:members:
|
||||
5
acme/docs/api/jose/interfaces.rst
Normal file
5
acme/docs/api/jose/interfaces.rst
Normal file
@@ -0,0 +1,5 @@
|
||||
Interfaces
|
||||
----------
|
||||
|
||||
.. automodule:: acme.jose.interfaces
|
||||
:members:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user