Compare commits

...

58 Commits

Author SHA1 Message Date
Felix Fontein
58bde04672 Relesae 3.0.0-rc1. 2025-06-14 17:10:13 +02:00
Felix Fontein
e537ea122f Prepare 3.0.0-rc1. 2025-06-14 16:47:32 +02:00
Felix Fontein
056ae1cf69 acme_account: check for 'externalAccountRequired' error (#919)
* Check for 'externalAccountRequired' error.

* Add changelog fragment.
2025-06-12 22:41:07 +02:00
Felix Fontein
d83a923325 Ensure that *everything* is typed in community.crypto (#917)
* Ensure that *everything* is typed in community.crypto.

* Fix comment.

* Ignore type definitions/imports and AssertionErrors for code coverage.
2025-06-09 10:10:19 +02:00
Daniel Ziegenberg
ec063d8515 Add HARICA to the list of tested CAs (#915)
* Add HARICA to the list of tested CAs

Signed-off-by: Daniel Ziegenberg <daniel@ziegenberg.at>

* Add ZeroSSL to list.

---------

Signed-off-by: Daniel Ziegenberg <daniel@ziegenberg.at>
Co-authored-by: Felix Fontein <felix@fontein.de>
2025-06-08 20:58:08 +02:00
Felix Fontein
e90d4d2b0f Improve type hinting. (#914) 2025-06-08 20:48:58 +02:00
Felix Fontein
a6b5884fc6 Also ignore keyfile3. 2025-06-04 15:06:45 +02:00
Felix Fontein
64e2b46eec Run new no-trailing-whitespace test. 2025-06-04 14:26:05 +02:00
Felix Fontein
f68b0d0c08 Improve type hints. (#913) 2025-06-01 21:33:20 +02:00
Felix Fontein
576a06b5b2 Remove no longer needed backend abstractions. (#912) 2025-06-01 09:07:06 +02:00
Felix Fontein
d1a8137d91 Add changelog fragments. 2025-05-31 10:28:02 +02:00
Felix Fontein
82522fc07f Improve typing (#911)
* Make type checking more strict.

* mypy: warn about unreachable code.

* Enable warn_redundant_casts.

* Enable strict_bytes.

* Look at some warn_return_any warnings.
2025-05-31 10:25:55 +02:00
Felix Fontein
6d273bc5b7 Fix invalid-name issues. (#909) 2025-05-30 23:06:24 +02:00
Felix Fontein
31933955e3 CSR: avoid access of private attributes (#910)
* Avoid access of private attributes.

* Add changelog.
2025-05-30 22:46:39 +02:00
Felix Fontein
8792635bef Fix some ansible-lint issues (#907)
* Fix fqcn[action-core].

* Fix fqcn[action].

* Fix jinja[spacing].
2025-05-30 22:03:16 +02:00
Daniel Ziegenberg
7241d5543a Document supported curves for Elliptic Curve keys on ACME Accounts (#904)
Signed-off-by: Daniel Ziegenberg <daniel@ziegenberg.at>
2025-05-30 12:56:16 +02:00
Felix Fontein
52b21b5177 Fix/improve typing. (#905) 2025-05-29 23:10:35 +02:00
Felix Fontein
b8adc3b241 Use ruff format, and then undo most changes with black and isort. (#903) 2025-05-24 08:30:31 +02:00
Felix Fontein
f3db4eeea5 Release 3.0.0-a2. 2025-05-22 22:06:10 +02:00
Felix Fontein
1b05480354 Prepare 3.0.0-a2. 2025-05-22 21:20:41 +02:00
Felix Fontein
43ea6148df Remove Entrust modules and certificate providers (#900)
* Remove Entrust modules and certificate providers.

* Add more information on Entrust removal.

* Remove Entrust content from ignore.txt files.

* Work around bug in ansible-test.
2025-05-22 19:08:48 +00:00
Felix Fontein
41b71bb60c Add RHEL 10.0 to CI. (#899) 2025-05-21 22:16:39 +02:00
Felix Fontein
3fd94c354a Forgot to update name of doc fragment. 2025-05-19 18:04:01 +02:00
Felix Fontein
94416989a8 Release 3.0.0-a1. 2025-05-18 14:33:13 +02:00
Felix Fontein
b08afe4237 Make all doc_fragments private. (#898) 2025-05-18 01:42:18 +02:00
Felix Fontein
7294841a28 Replace to_native with to_text. (#897) 2025-05-18 01:31:33 +02:00
Felix Fontein
9b8e4e81a9 Forgot to mention cryptography. 2025-05-18 01:31:21 +02:00
Felix Fontein
efda8596a5 Prepare 3.0.0-a1 release. 2025-05-18 01:09:30 +02:00
Felix Fontein
318462fa24 Work on issues found by pylint (#896)
* Look at possibly-used-before-assignment.

* Use latest beta releases of ansible-core 2.19 for mypy and pylint.

* Look at unsupported-*.

* Look at unknown-option-value.

* Look at redefined-builtin.

* Look at superfluous-parens.

* Look at unspecified-encoding.

* Adjust to new cryptography version and to ansible-core 2.17's pylint.

* Look at super-with-arguments.

* Look at no-else-*.

* Look at try-except-raise.

* Look at inconsistent-return-statements.

* Look at redefined-outer-name.

* Look at redefined-argument-from-local.

* Look at attribute-defined-outside-init.

* Look at unused-variable.

* Look at protected-access.

* Look at raise-missing-from.

* Look at arguments-differ.

* Look at useless-suppression and use-symbolic-message-instead.

* Look at consider-using-dict-items.

* Look at consider-using-in.

* Look at consider-using-set-comprehension.

* Look at consider-using-with.

* Look at use-dict-literal.
2025-05-18 00:57:28 +02:00
Felix Fontein
a3a5284f97 Add basic typing for Entrust code. (#894) 2025-05-17 17:43:50 +02:00
Felix Fontein
990b40df3e Add pylint (#892)
* Move mypy/flake8/isort config files to more 'natural' places.

* Add pylint.

* Look at no-member.

* Look at pointless-* and unnecessary-pass.

* Look at useless-*.

* Lint.
2025-05-17 16:45:37 +02:00
Felix Fontein
5fbf35df86 Deprecate no longer used options. (#891) 2025-05-16 22:23:05 +02:00
Felix Fontein
56f004dc63 More refactorings (#890)
* Improve typing.

* Improve version parameter validation for x509_certificate* modules.

* Use utils for parsing retry-after.
2025-05-16 21:53:18 +02:00
Felix Fontein
44bcc8cebc Code refactoring (#889)
* Add __all__ to all module and plugin utils.

* Convert quite a few positional args to keyword args.

* Avoid Python 3.8+ syntax.
2025-05-16 06:55:57 +02:00
Felix Fontein
a5a4e022ba Make all module_utils and plugin_utils private (#887)
* Add leading underscore. Remove deprecated module utils.

* Document module and plugin utils as private. Add changelog fragment.

* Convert relative to absolute imports.

* Remove unnecessary imports.
2025-05-11 19:17:58 +02:00
Felix Fontein
f758d94fba Add type hints and type checking (#885)
* Enable basic type checking.

* Fix first errors.

* Add changelog fragment.

* Add types to module_utils and plugin_utils (without module backends).

* Add typing hints for acme_* modules.

* Add typing to X.509 certificate modules, and add more helpers.

* Add typing to remaining module backends.

* Add typing for action, filter, and lookup plugins.

* Bump ansible-core 2.19 beta requirement for typing.

* Add more typing definitions.

* Add typing to some unit tests.
2025-05-11 18:00:11 +02:00
Felix Fontein
82f0176773 Remove ignore.txt files for ansible-core < 2.17. 2025-05-04 21:42:29 +02:00
Felix Fontein
8156468898 Add ansible-lint to CI (#886)
* Enable ansible-lint.

* Fix broken task name.

* Fix command-instead-of-shell instances.

* Clean up tasks to eliminate command-instead-of-module.

* Skip yaml errors.

* Remove .stdout from versions.

* Avoid stdin.
2025-05-03 14:42:41 +02:00
Felix Fontein
12f958c955 Fix assert_required_cryptography_version() calls. 2025-05-03 12:55:50 +02:00
Felix Fontein
83beb7148c Remove six usages. (#884) 2025-05-03 11:12:29 +02:00
Felix Fontein
645b7bf9ed Get rid of backend parameter whenever possible (#883)
* Get rid of backend parameter whenever possible.

* Always auto-detect if backend choices are 'cryptography' and 'auto', resp. always check cryptography version.

* Improve error message.

* Update documentation.
2025-05-03 10:46:53 +02:00
Felix Fontein
fbcb89f092 Support cryptography 3.3 (#882)
* Re-add Debian Bullseye to CI.

* Support cryptography 3.3 as well.
2025-05-02 21:42:06 +02:00
Felix Fontein
86db561193 Get rid of some to_native and to_text calls. (#880) 2025-05-02 15:58:39 +02:00
Felix Fontein
0b8f3306c7 Use unittest.mock. (#881) 2025-05-02 15:39:03 +02:00
Felix Fontein
5231ac8f3f Remove support for cryptography < 3.4 (#878)
* Stop passing backend to cryptography.

* Make public_bytes() fallback the default.

* Remove compatibility code for older cryptography versions.

* Require cryptography 3.4+.

* Restrict to cryptography >= 3.4 in integration tests.

* Remove Debian Bullseye from CI.

It only supports cryptography 3.3.

* Improve imports.

* Remove no longer existing conditional.
2025-05-02 15:27:18 +02:00
Felix Fontein
e8fec768cc Remove prepare_jinja2_compat. (#879) 2025-05-02 13:18:19 +02:00
Felix Fontein
ef230011fd Lint doc fragments. 2025-05-01 16:47:59 +02:00
Felix Fontein
65872e884f Remove Python 2 specific code (#877)
* Get rid of Python 2 special handling.

* Get rid of more Python 2 specific handling.

* Stop using six.

* ipaddress is part of the standard library since Python 3.

* Add changelog.

* Fix import.

* Remove unneeded imports.
2025-05-01 16:21:13 +02:00
Felix Fontein
641e63b08c Replace % and str.format() with f-strings (#875)
* Replace % and str.format() with f-strings.

* Apply suggestions from review.
2025-05-01 11:50:10 +02:00
Felix Fontein
d8f838c365 Modernize some Python constructs (#876)
* Update __future__ import, remove __metaclass__ assignment.

* Removing obsolete encoding comment.

* Remove unneccessary object inheritance.
2025-05-01 10:36:59 +02:00
Felix Fontein
266082db72 Remove more traces of PyOpenSSL, including from EE dependencies (#874)
* Remove PyOpenSSL backends.

* Remove EOL ansible-core's from EE builds.

* Update Pythons in EEs.

* Remove pyopenssl tests.
2025-04-29 09:33:21 +02:00
Felix Fontein
718021b714 Fix typo. 2025-04-29 08:13:41 +02:00
Felix Fontein
d368d1943d Bump version to 3.0.0-dev0, remove deprecated functionality and implement announced breaking changes (#873)
* Bump verison to 3.0.0-dev0.

* Change check mode behavior for *_pipe modules.

* Remove PyOpenSSL backend.

* Remove PyOpenSSL setup.

* Change default of asn1_base64.

* Remove deprecated common module utils.

* Remove get_default_argspec().

* Mark two methods as abstract.

* Remove ACME v1 support.

* Remove retrieve_acme_v1_certificate().

* Remove deprecated docs fragment.

* Change meaning of mode parameter.

* Mark no longer used option as 'to deprecate'.
2025-04-29 08:12:44 +02:00
Felix Fontein
f73a1ce590 Drop compatibility with older versions. (#872) 2025-04-28 21:45:42 +02:00
Felix Fontein
5bcbd4d0f4 Enable formatting with black; add reformat commit to .git-blame-ignore-revs. 2025-04-28 20:35:32 +02:00
Felix Fontein
797bd8a6e2 Reformat again with black, this time without Python 2 workarounds. 2025-04-28 20:34:38 +02:00
Felix Fontein
23de865563 Unvendor distutils.version (#371)
* Unvendor distutils.version.

* Fix version.

* Assume the collection requires ansible-core 2.12+.

This is valid since this only get merged for 3.0.0, which
will drop support for quite a few more ansible-core versions.

* Mark for re-export.
2025-04-28 14:30:37 +02:00
Felix Fontein
4e8a0e456b Prepare basic 3.0.0 setup (#870)
* Drop support for ansible-core < 2.17.

* Galaxy can show included content nowadays. (Not perfect, but a lot better than before.)

* This should have been removed a long time ago.
2025-04-28 12:39:28 +02:00
409 changed files with 18514 additions and 22393 deletions

28
.ansible-lint Normal file
View File

@@ -0,0 +1,28 @@
---
# Copyright (c) Ansible Project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
skip_list:
# Ignore rules that make no sense:
- galaxy[tags]
- galaxy[version-incorrect]
- meta-runtime[unsupported-version]
- no-changed-when
- sanity[cannot-ignore] # some of the rules you cannot ignore actually MUST be ignored, like yamllint:unparsable-with-libyaml
- yaml # we're using yamllint ourselves
# To be checked and maybe fixed:
- ignore-errors
- key-order[task]
- name[casing]
- name[missing]
- name[play]
- name[template]
- no-free-form
- no-handler
- risky-file-permissions
- risky-shell-pipe
- var-naming[no-reserved]
- var-naming[pattern]
- var-naming[read-only]

View File

@@ -83,17 +83,6 @@ stages:
test: '2.17/sanity/1' test: '2.17/sanity/1'
- name: Units - name: Units
test: '2.17/units/1' test: '2.17/units/1'
- stage: Ansible_2_16
displayName: Sanity & Units 2.16
dependsOn: []
jobs:
- template: templates/matrix.yml
parameters:
targets:
- name: Sanity
test: '2.16/sanity/1'
- name: Units
test: '2.16/units/1'
### Docker ### Docker
- stage: Docker_devel - stage: Docker_devel
displayName: Docker devel displayName: Docker devel
@@ -146,23 +135,6 @@ stages:
groups: groups:
- 1 - 1
- 2 - 2
- stage: Docker_2_16
displayName: Docker 2.16
dependsOn: []
jobs:
- template: templates/matrix.yml
parameters:
testFormat: 2.16/linux/{0}
targets:
- name: Fedora 38
test: fedora38
- name: openSUSE 15
test: opensuse15
- name: Alpine 3
test: alpine3
groups:
- 1
- 2
### Community Docker ### Community Docker
- stage: Docker_community_devel - stage: Docker_community_devel
@@ -173,10 +145,10 @@ stages:
parameters: parameters:
testFormat: devel/linux-community/{0} testFormat: devel/linux-community/{0}
targets: targets:
- name: Debian Bullseye
test: debian-bullseye/3.9
- name: Debian Bookworm - name: Debian Bookworm
test: debian-bookworm/3.11 test: debian-bookworm/3.11
- name: Debian Bullseye
test: debian-bullseye/3.9
- name: ArchLinux - name: ArchLinux
test: archlinux/3.13 test: archlinux/3.13
groups: groups:
@@ -212,6 +184,8 @@ stages:
targets: targets:
- name: macOS 15.3 - name: macOS 15.3
test: macos/15.3 test: macos/15.3
- name: RHEL 10.0
test: rhel/10.0
- name: RHEL 9.5 - name: RHEL 9.5
test: rhel/9.5 test: rhel/9.5
- name: FreeBSD 14.2 - name: FreeBSD 14.2
@@ -253,27 +227,6 @@ stages:
groups: groups:
- 1 - 1
- 2 - 2
- stage: Remote_2_16
displayName: Remote 2.16
dependsOn: []
jobs:
- template: templates/matrix.yml
parameters:
testFormat: 2.16/{0}
targets:
- name: macOS 13.2
test: macos/13.2
- name: RHEL 9.2
test: rhel/9.2
- name: RHEL 8.8
test: rhel/8.8
- name: RHEL 7.9
test: rhel/7.9
# - name: FreeBSD 13.2
# test: freebsd/13.2
groups:
- 1
- 2
### Generic ### Generic
- stage: Generic_devel - stage: Generic_devel
displayName: Generic devel displayName: Generic devel
@@ -285,8 +238,8 @@ stages:
testFormat: devel/generic/{0} testFormat: devel/generic/{0}
targets: targets:
- test: "3.8" - test: "3.8"
# - test: "3.9" - test: "3.9"
# - test: "3.10" - test: "3.10"
- test: "3.11" - test: "3.11"
- test: "3.13" - test: "3.13"
groups: groups:
@@ -320,21 +273,6 @@ stages:
groups: groups:
- 1 - 1
- 2 - 2
- stage: Generic_2_16
displayName: Generic 2.16
dependsOn: []
jobs:
- template: templates/matrix.yml
parameters:
nameFormat: Python {0}
testFormat: 2.16/generic/{0}
targets:
- test: "2.7"
- test: "3.6"
- test: "3.11"
groups:
- 1
- 2
## Finally ## Finally
@@ -344,20 +282,16 @@ stages:
- Ansible_devel - Ansible_devel
- Ansible_2_18 - Ansible_2_18
- Ansible_2_17 - Ansible_2_17
- Ansible_2_16
- Remote_devel_extra_vms - Remote_devel_extra_vms
- Remote_devel - Remote_devel
- Remote_2_18 - Remote_2_18
- Remote_2_17 - Remote_2_17
- Remote_2_16
- Docker_devel - Docker_devel
- Docker_2_18 - Docker_2_18
- Docker_2_17 - Docker_2_17
- Docker_2_16
- Docker_community_devel - Docker_community_devel
- Generic_devel - Generic_devel
- Generic_2_18 - Generic_2_18
- Generic_2_17 - Generic_2_17
- Generic_2_16
jobs: jobs:
- template: templates/coverage.yml - template: templates/coverage.yml

View File

@@ -6,7 +6,7 @@
extend-ignore = E203, E402, F401 extend-ignore = E203, E402, F401
count = true count = true
# TODO: decrease this to ~10 # TODO: decrease this to ~10
max-complexity = 48 max-complexity = 60
# black's max-line-length is 89, but it doesn't touch long string literals. # black's max-line-length is 89, but it doesn't touch long string literals.
# Since ansible-test's limit is 160, let's use that here. # Since ansible-test's limit is 160, let's use that here.
max-line-length = 160 max-line-length = 160

View File

@@ -5,4 +5,6 @@
# Reformat YAML: https://github.com/ansible-collections/community.crypto/pull/866 # Reformat YAML: https://github.com/ansible-collections/community.crypto/pull/866
33ef158b094f16d5e04ea9db3ed8bad010744d02 33ef158b094f16d5e04ea9db3ed8bad010744d02
# Reformat with black, keeping Python 2 compatibility: https://github.com/ansible-collections/community.crypto/pull/871 # Reformat with black, keeping Python 2 compatibility: https://github.com/ansible-collections/community.crypto/pull/871
aec1826c34051b9e7f8af7950489915b661e320b aec1826c34051b9e7f8af7950489915b661e320b
# Reformat with black another time, this time without Python 2 compatibility
797bd8a6e2a6f4a37a89ecb15ca34ec88b33258d

View File

@@ -1,291 +0,0 @@
---
# Copyright (c) Ansible Project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# For the comprehensive list of the inputs supported by the ansible-community/ansible-test-gh-action GitHub Action, see
# https://github.com/marketplace/actions/ansible-test
name: EOL CI
'on':
# Run EOL CI against all pushes (direct commits, also merged PRs), Pull Requests
push:
branches:
- main
- stable-*
pull_request:
# Run EOL CI once per day (at 09:00 UTC)
schedule:
- cron: '0 9 * * *'
concurrency:
# Make sure there is at most one active run per PR, but do not cancel any non-PR runs
group: ${{ github.workflow }}-${{ (github.head_ref && github.event.number) || github.run_id }}
cancel-in-progress: true
jobs:
sanity:
name: EOL Sanity (Ⓐ${{ matrix.ansible }})
strategy:
matrix:
ansible:
- '2.9'
- '2.10'
- '2.11'
- '2.12'
- '2.13'
- '2.14'
- '2.15'
runs-on: ubuntu-latest
steps:
- name: Perform sanity testing
uses: felixfontein/ansible-test-gh-action@main
with:
ansible-core-github-repository-slug: ${{ contains(fromJson('["2.9", "2.10", "2.11"]'), matrix.ansible) && 'ansible-community/eol-ansible' || 'ansible/ansible' }}
ansible-core-version: stable-${{ matrix.ansible }}
codecov-token: ${{ secrets.CODECOV_TOKEN }}
coverage: ${{ github.event_name == 'schedule' && 'always' || 'never' }}
pre-test-cmd: >-
git clone --depth=1 --single-branch https://github.com/ansible-collections/community.internal_test_tools.git ../../community/internal_test_tools
pull-request-change-detection: 'true'
testing-type: sanity
units:
runs-on: ubuntu-latest
name: EOL Units (Ⓐ${{ matrix.ansible }})
strategy:
# As soon as the first unit test fails, cancel the others to free up the CI queue
fail-fast: true
matrix:
ansible:
- '2.9'
- '2.10'
- '2.11'
- '2.12'
- '2.13'
- '2.14'
- '2.15'
steps:
- name: >-
Perform unit testing against
Ansible version ${{ matrix.ansible }}
uses: felixfontein/ansible-test-gh-action@main
with:
ansible-core-github-repository-slug: ${{ contains(fromJson('["2.9", "2.10", "2.11"]'), matrix.ansible) && 'ansible-community/eol-ansible' || 'ansible/ansible' }}
ansible-core-version: stable-${{ matrix.ansible }}
codecov-token: ${{ secrets.CODECOV_TOKEN }}
coverage: ${{ github.event_name == 'schedule' && 'always' || 'never' }}
pre-test-cmd: >-
git clone --depth=1 --single-branch https://github.com/ansible-collections/community.internal_test_tools.git ../../community/internal_test_tools
pull-request-change-detection: 'true'
testing-type: units
integration:
runs-on: ubuntu-latest
name: EOL I (Ⓐ${{ matrix.ansible }}+${{ matrix.docker }}+py${{ matrix.python }}:${{ matrix.target }})
strategy:
fail-fast: false
matrix:
ansible:
- ''
docker:
- ''
python:
- ''
target:
- ''
exclude:
- ansible: ''
include:
# 2.9
- ansible: '2.9'
docker: ubuntu1804
python: ''
target: azp/posix/1/
- ansible: '2.9'
docker: ubuntu1804
python: ''
target: azp/posix/2/
- ansible: '2.9'
docker: default
python: '2.7'
target: azp/generic/1/
- ansible: '2.9'
docker: default
python: '2.7'
target: azp/generic/2/
# 2.10
- ansible: '2.10'
docker: centos6
python: ''
target: azp/posix/1/
- ansible: '2.10'
docker: centos6
python: ''
target: azp/posix/2/
- ansible: '2.10'
docker: default
python: '3.6'
target: azp/generic/1/
- ansible: '2.10'
docker: default
python: '3.6'
target: azp/generic/2/
# 2.11
- ansible: '2.11'
docker: alpine3
python: ''
target: azp/posix/1/
- ansible: '2.11'
docker: alpine3
python: ''
target: azp/posix/2/
- ansible: '2.11'
docker: default
python: '3.8'
target: azp/generic/1/
- ansible: '2.11'
docker: default
python: '3.8'
target: azp/generic/2/
# 2.12
- ansible: '2.12'
docker: centos6
python: ''
target: azp/posix/1/
- ansible: '2.12'
docker: centos6
python: ''
target: azp/posix/2/
- ansible: '2.12'
docker: fedora33
python: ''
target: azp/posix/1/
- ansible: '2.12'
docker: fedora33
python: ''
target: azp/posix/2/
- ansible: '2.12'
docker: default
python: '2.6'
target: azp/generic/1/
- ansible: '2.12'
docker: default
python: '3.9'
target: azp/generic/2/
# 2.13
- ansible: '2.13'
docker: opensuse15py2
python: ''
target: azp/posix/1/
- ansible: '2.13'
docker: opensuse15py2
python: ''
target: azp/posix/2/
- ansible: '2.13'
docker: fedora35
python: ''
target: azp/posix/1/
- ansible: '2.13'
docker: fedora35
python: ''
target: azp/posix/2/
- ansible: '2.13'
docker: fedora34
python: ''
target: azp/posix/1/
- ansible: '2.13'
docker: fedora34
python: ''
target: azp/posix/2/
- ansible: '2.13'
docker: ubuntu1804
python: ''
target: azp/posix/1/
- ansible: '2.13'
docker: ubuntu1804
python: ''
target: azp/posix/2/
- ansible: '2.13'
docker: alpine3
python: ''
target: azp/posix/1/
- ansible: '2.13'
docker: alpine3
python: ''
target: azp/posix/2/
- ansible: '2.13'
docker: default
python: '3.8'
target: azp/generic/1/
- ansible: '2.13'
docker: default
python: '3.8'
target: azp/generic/2/
# 2.14
- ansible: '2.14'
docker: ubuntu2004
python: ''
target: azp/posix/1/
- ansible: '2.14'
docker: ubuntu2004
python: ''
target: azp/posix/2/
- ansible: '2.14'
docker: default
python: '3.9'
target: azp/generic/1/
- ansible: '2.14'
docker: default
python: '3.9'
target: azp/generic/2/
# 2.15
- ansible: '2.15'
docker: fedora37
python: ''
target: azp/posix/1/
- ansible: '2.15'
docker: fedora37
python: ''
target: azp/posix/2/
- ansible: '2.15'
docker: default
python: '3.5'
target: azp/generic/1/
- ansible: '2.15'
docker: default
python: '3.5'
target: azp/generic/2/
- ansible: '2.15'
docker: default
python: '3.10'
target: azp/generic/1/
- ansible: '2.15'
docker: default
python: '3.10'
target: azp/generic/2/
steps:
- name: >-
Perform integration testing against
Ansible version ${{ matrix.ansible }}
under Python ${{ matrix.python }}
uses: felixfontein/ansible-test-gh-action@main
with:
ansible-core-github-repository-slug: ${{ contains(fromJson('["2.9", "2.10", "2.11"]'), matrix.ansible) && 'ansible-community/eol-ansible' || 'ansible/ansible' }}
ansible-core-version: stable-${{ matrix.ansible }}
codecov-token: ${{ secrets.CODECOV_TOKEN }}
coverage: ${{ github.event_name == 'schedule' && 'always' || 'never' }}
docker-image: ${{ matrix.docker }}
integration-continue-on-error: 'false'
integration-diff: 'false'
integration-retry-on-error: 'true'
pre-test-cmd: >-
git clone --depth=1 --single-branch https://github.com/ansible-collections/community.internal_test_tools.git ../../community/internal_test_tools
;
git clone --depth=1 --single-branch https://github.com/ansible-collections/community.general.git ../../community/general
pull-request-change-detection: 'true'
target: ${{ matrix.target }}
target-python-version: ${{ matrix.python }}
testing-type: integration

View File

@@ -48,36 +48,28 @@ jobs:
ansible_runner: ansible-runner ansible_runner: ansible-runner
other_deps: |2 other_deps: |2
python_interpreter: python_interpreter:
package_system: python3.11 python3.11-pip python3.11-wheel python3.11-cryptography package_system: python3.12 python3.12-pip python3.12-wheel python3.12-cryptography
python_path: "/usr/bin/python3.11" python_path: "/usr/bin/python3.12"
base_image: docker.io/redhat/ubi9:latest base_image: docker.io/redhat/ubi9:latest
pre_base: '"#"' pre_base: '"#"'
# For some reason ansible-builder will not install EPEL dependencies on RHEL - name: ansible-core 2.17 @ Rocky Linux 9
extra_vars: -e has_no_pyopenssl=true ansible_core: https://github.com/ansible/ansible/archive/stable-2.17.tar.gz
- name: ansible-core 2.15 @ Rocky Linux 9
ansible_core: https://github.com/ansible/ansible/archive/stable-2.15.tar.gz
ansible_runner: ansible-runner
base_image: quay.io/rockylinux/rockylinux:9
pre_base: RUN dnf install -y epel-release
# For some reason ansible-builder will not install EPEL dependencies on Rocky Linux
extra_vars: -e has_no_pyopenssl=true
- name: ansible-core 2.14 @ CentOS Stream 9
ansible_core: https://github.com/ansible/ansible/archive/stable-2.14.tar.gz
ansible_runner: ansible-runner
base_image: quay.io/centos/centos:stream9
pre_base: RUN dnf install -y epel-release epel-next-release
# For some reason, PyOpenSSL is **broken** on CentOS Stream 9 / EPEL
extra_vars: -e has_no_pyopenssl=true
- name: ansible-core 2.13 @ RHEL UBI 8
ansible_core: https://github.com/ansible/ansible/archive/stable-2.13.tar.gz
ansible_runner: ansible-runner ansible_runner: ansible-runner
other_deps: |2 other_deps: |2
python_interpreter: python_interpreter:
package_system: python39 python39-pip python39-wheel python39-cryptography package_system: python3.11 python3.11-pip python3.11-wheel python3.11-cryptography
base_image: docker.io/redhat/ubi8:latest python_path: "/usr/bin/python3.11"
base_image: quay.io/rockylinux/rockylinux:9
pre_base: RUN dnf install -y epel-release
- name: ansible-core 2.18 @ CentOS Stream 9
ansible_core: https://github.com/ansible/ansible/archive/stable-2.18.tar.gz
ansible_runner: ansible-runner
other_deps: |2
python_interpreter:
package_system: python3.11 python3.11-pip python3.11-wheel python3.11-cryptography
python_path: "/usr/bin/python3.11"
base_image: quay.io/centos/centos:stream9
pre_base: '"#"' pre_base: '"#"'
# We don't have PyOpenSSL for Python 3.9
extra_vars: -e has_no_pyopenssl=true
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out code - name: Check out code

23
.mypy.ini Normal file
View File

@@ -0,0 +1,23 @@
# Copyright (c) Ansible Project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
[mypy]
check_untyped_defs = True
disallow_untyped_defs = True
# strict = True -- only try to enable once everything (including dependencies!) is typed
strict_equality = True
strict_bytes = True
warn_redundant_casts = True
# warn_return_any = True
warn_unreachable = True
[mypy-ansible.*]
# ansible-core has partial typing information
follow_untyped_imports = True
[mypy-ansible_collections.community.internal_test_tools.*]
# community.internal_test_tools has no typing information
ignore_missing_imports = True

591
.pylintrc Normal file
View File

@@ -0,0 +1,591 @@
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: 2025 Felix Fontein <felix@fontein.de>
[MAIN]
# Clear in-memory caches upon conclusion of linting. Useful if running pylint
# in a server-like mode.
clear-cache-post-run=no
# Load and enable all available extensions. Use --list-extensions to see a list
# all available extensions.
#enable-all-extensions=
# Specify a score threshold under which the program will exit with error.
fail-under=10
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
# number of processors available to use, and will cap the count on Windows to
# avoid hangs.
jobs=0
# Minimum Python version to use for version dependent checks. Will default to
# the version used to run pylint.
py-version=3.7
# Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no
# In verbose mode, extra non-checker-related info will be displayed.
#verbose=
[BASIC]
# Naming style matching correct argument names.
argument-naming-style=snake_case
# Regular expression matching correct argument names. Overrides argument-
# naming-style. If left empty, argument names will be checked with the set
# naming style.
#argument-rgx=
# Naming style matching correct attribute names.
attr-naming-style=snake_case
# Regular expression matching correct attribute names. Overrides attr-naming-
# style. If left empty, attribute names will be checked with the set naming
# style.
#attr-rgx=
# Bad variable names which should always be refused, separated by a comma.
bad-names=foo,
bar,
baz,
toto,
tutu,
tata
# Bad variable names regexes, separated by a comma. If names match any regex,
# they will always be refused
bad-names-rgxs=
# Naming style matching correct class attribute names.
class-attribute-naming-style=any
# Regular expression matching correct class attribute names. Overrides class-
# attribute-naming-style. If left empty, class attribute names will be checked
# with the set naming style.
#class-attribute-rgx=
# Naming style matching correct class constant names.
class-const-naming-style=UPPER_CASE
# Regular expression matching correct class constant names. Overrides class-
# const-naming-style. If left empty, class constant names will be checked with
# the set naming style.
#class-const-rgx=
# Naming style matching correct class names.
class-naming-style=PascalCase
# Regular expression matching correct class names. Overrides class-naming-
# style. If left empty, class names will be checked with the set naming style.
#class-rgx=
# Naming style matching correct constant names.
const-naming-style=UPPER_CASE
# Regular expression matching correct constant names. Overrides const-naming-
# style. If left empty, constant names will be checked with the set naming
# style.
#const-rgx=
# Minimum line length for functions/classes that require docstrings, shorter
# ones are exempt.
docstring-min-length=-1
# Naming style matching correct function names.
function-naming-style=snake_case
# Regular expression matching correct function names. Overrides function-
# naming-style. If left empty, function names will be checked with the set
# naming style.
#function-rgx=
# Good variable names which should always be accepted, separated by a comma.
good-names=i,
j,
k,
ex,
Run,
_
# Good variable names regexes, separated by a comma. If names match any regex,
# they will always be accepted
good-names-rgxs=
# Include a hint for the correct naming format with invalid-name.
include-naming-hint=no
# Naming style matching correct inline iteration names.
inlinevar-naming-style=any
# Regular expression matching correct inline iteration names. Overrides
# inlinevar-naming-style. If left empty, inline iteration names will be checked
# with the set naming style.
#inlinevar-rgx=
# Naming style matching correct method names.
method-naming-style=snake_case
# Regular expression matching correct method names. Overrides method-naming-
# style. If left empty, method names will be checked with the set naming style.
#method-rgx=
# Naming style matching correct module names.
module-naming-style=snake_case
# Regular expression matching correct module names. Overrides module-naming-
# style. If left empty, module names will be checked with the set naming style.
#module-rgx=
# Colon-delimited sets of names that determine each other's naming style when
# the name regexes allow several styles.
name-group=
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=^_
# List of decorators that produce properties, such as abc.abstractproperty. Add
# to this list to register other decorators that produce valid properties.
# These decorators are taken in consideration only for invalid-name.
property-classes=abc.abstractproperty
# Regular expression matching correct type alias names. If left empty, type
# alias names will be checked with the set naming style.
#typealias-rgx=
# Regular expression matching correct type variable names. If left empty, type
# variable names will be checked with the set naming style.
#typevar-rgx=
# Naming style matching correct variable names.
variable-naming-style=snake_case
# Regular expression matching correct variable names. Overrides variable-
# naming-style. If left empty, variable names will be checked with the set
# naming style.
#variable-rgx=
[CLASSES]
# Warn about protected attribute access inside special methods
check-protected-access-in-special-methods=no
# List of method names used to declare (i.e. assign) instance attributes.
defining-attr-methods=__init__,
__new__,
setUp,
asyncSetUp,
__post_init__
# List of member names, which should be excluded from the protected access
# warning.
exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit
# List of valid names for the first argument in a class method.
valid-classmethod-first-arg=cls
# List of valid names for the first argument in a metaclass class method.
valid-metaclass-classmethod-first-arg=mcs
[DESIGN]
# List of regular expressions of class ancestor names to ignore when counting
# public methods (see R0903)
exclude-too-few-public-methods=
# List of qualified class names to ignore when counting class parents (see
# R0901)
ignored-parents=
# Maximum number of arguments for function / method.
max-args=5
# Maximum number of attributes for a class (see R0902).
max-attributes=7
# Maximum number of boolean expressions in an if statement (see R0916).
max-bool-expr=5
# Maximum number of branch for function / method body.
max-branches=12
# Maximum number of locals for function / method body.
max-locals=15
# Maximum number of parents for a class (see R0901).
max-parents=7
# Maximum number of positional arguments for function / method.
max-positional-arguments=5
# Maximum number of public methods for a class (see R0904).
max-public-methods=20
# Maximum number of return / yield for function / method body.
max-returns=6
# Maximum number of statements in function / method body.
max-statements=50
# Minimum number of public methods for a class (see R0903).
min-public-methods=2
[EXCEPTIONS]
# Exceptions that will emit a warning when caught.
overgeneral-exceptions=builtins.BaseException,builtins.Exception
[FORMAT]
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
expected-line-ending-format=
# Regexp for a line that is allowed to be longer than the limit.
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
# Number of spaces of indent required inside a hanging or continued line.
indent-after-paren=4
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
# tab).
indent-string=' '
# Maximum number of characters on a single line.
max-line-length=160
# Maximum number of lines in a module.
max-module-lines=1000
# Allow the body of a class to be on the same line as the declaration if body
# contains single statement.
single-line-class-stmt=no
# Allow the body of an if to be on the same line as the test if there is no
# else.
single-line-if-stmt=no
[IMPORTS]
# List of modules that can be imported at any level, not just the top level
# one.
allow-any-import-level=
# Allow explicit reexports by alias from a package __init__.
allow-reexport-from-package=no
# Allow wildcard imports from modules that define __all__.
allow-wildcard-with-all=no
# Deprecated modules which should not be used, separated by a comma.
deprecated-modules=
# Output a graph (.gv or any supported image format) of external dependencies
# to the given file (report RP0402 must not be disabled).
ext-import-graph=
# Output a graph (.gv or any supported image format) of all (i.e. internal and
# external) dependencies to the given file (report RP0402 must not be
# disabled).
import-graph=
# Output a graph (.gv or any supported image format) of internal dependencies
# to the given file (report RP0402 must not be disabled).
int-import-graph=
# Force import order to recognize a module as part of the standard
# compatibility libraries.
known-standard-library=
# Force import order to recognize a module as part of a third party library.
known-third-party=enchant
# Couples of modules and preferred modules, separated by a comma.
preferred-modules=
[LOGGING]
# The type of string formatting that logging methods do. `old` means using %
# formatting, `new` is for `{}` formatting.
logging-format-style=old
# Logging modules to check that the string format arguments are in logging
# function parameter format.
logging-modules=logging
[MESSAGES CONTROL]
# Only show warnings with the listed confidence levels. Leave empty to show
# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE,
# UNDEFINED.
confidence=HIGH,
CONTROL_FLOW,
INFERENCE,
INFERENCE_FAILURE,
UNDEFINED
# Disable the message, report, category or checker with the given id(s). You
# can either give multiple identifiers separated by comma (,) or put this
# option multiple times (only on the command line, not in the configuration
# file where it should appear only once). You can also use "--disable=all" to
# disable everything first and then re-enable specific checks. For example, if
# you want to run only the similarities checker, you can use "--disable=all
# --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".
disable=raw-checker-failed,
bad-inline-option,
deprecated-pragma,
duplicate-code,
file-ignored,
import-outside-toplevel,
missing-class-docstring,
missing-function-docstring,
missing-module-docstring,
locally-disabled,
suppressed-message,
use-implicit-booleaness-not-comparison,
use-implicit-booleaness-not-comparison-to-string,
use-implicit-booleaness-not-comparison-to-zero,
superfluous-parens,
too-few-public-methods,
too-many-arguments,
too-many-boolean-expressions,
too-many-branches,
too-many-function-args,
too-many-instance-attributes,
too-many-lines,
too-many-locals,
too-many-nested-blocks,
too-many-positional-arguments,
too-many-return-statements,
too-many-statements,
ungrouped-imports,
useless-parent-delegation,
wrong-import-order,
wrong-import-position,
# To clean up:
broad-exception-caught,
broad-exception-raised,
fixme,
unused-argument,
# Cannot remove yet due to inadequacy of rules
inconsistent-return-statements, # doesn't notice that fail_json() does not return
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
# multiple time (only on the command line, not in the configuration file where
# it should appear only once). See also the "--disable" option for examples.
enable=
[METHOD_ARGS]
# List of qualified names (i.e., library.method) which require a timeout
# parameter e.g. 'requests.api.get,requests.api.post'
timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request
[MISCELLANEOUS]
# List of note tags to take in consideration, separated by a comma.
notes=FIXME,
XXX,
TODO
# Regular expression of note tags to take in consideration.
notes-rgx=
[REFACTORING]
# Maximum number of nested blocks for function / method body
max-nested-blocks=5
# Complete name of functions that never returns. When checking for
# inconsistent-return-statements if a never returning function is called then
# it will be considered as an explicit return statement and no message will be
# printed.
never-returning-functions=sys.exit,argparse.parse_error
# Let 'consider-using-join' be raised when the separator to join on would be
# non-empty (resulting in expected fixes of the type: ``"- " + " -
# ".join(items)``)
suggest-join-with-non-empty-separator=yes
[REPORTS]
# Python expression which should return a score less than or equal to 10. You
# have access to the variables 'fatal', 'error', 'warning', 'refactor',
# 'convention', and 'info' which contain the number of messages in each
# category, as well as 'statement' which is the total number of statements
# analyzed. This score is used by the global evaluation report (RP0004).
evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
# Template used to display messages. This is a python new-style format string
# used to format the message information. See doc for all details.
msg-template=
# Set the output format. Available formats are: text, parseable, colorized,
# json2 (improved json format), json (old json format) and msvs (visual
# studio). You can also give a reporter class, e.g.
# mypackage.mymodule.MyReporterClass.
#output-format=
# Tells whether to display a full report or only the messages.
reports=no
# Activate the evaluation score.
score=yes
[SIMILARITIES]
# Comments are removed from the similarity computation
ignore-comments=yes
# Docstrings are removed from the similarity computation
ignore-docstrings=yes
# Imports are removed from the similarity computation
ignore-imports=yes
# Signatures are removed from the similarity computation
ignore-signatures=yes
# Minimum lines number of a similarity.
min-similarity-lines=4
[SPELLING]
# Limits count of emitted suggestions for spelling mistakes.
max-spelling-suggestions=4
# Spelling dictionary name. No available dictionaries : You need to install
# both the python package and the system dependency for enchant to work.
spelling-dict=
# List of comma separated words that should be considered directives if they
# appear at the beginning of a comment and should not be checked.
spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:
# List of comma separated words that should not be checked.
spelling-ignore-words=
# A path to a file that contains the private dictionary; one word per line.
spelling-private-dict-file=
# Tells whether to store unknown words to the private dictionary (see the
# --spelling-private-dict-file option) instead of raising a message.
spelling-store-unknown-words=no
[STRING]
# This flag controls whether inconsistent-quotes generates a warning when the
# character used as a quote delimiter is used inconsistently within a module.
check-quote-consistency=no
# This flag controls whether the implicit-str-concat should generate a warning
# on implicit string concatenation in sequences defined over several lines.
check-str-concat-over-line-jumps=no
[TYPECHECK]
# List of decorators that produce context managers, such as
# contextlib.contextmanager. Add to this list to register other decorators that
# produce valid context managers.
contextmanager-decorators=contextlib.contextmanager
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E1101 when accessed. Python regular
# expressions are accepted.
generated-members=
# Tells whether to warn about missing members when the owner of the attribute
# is inferred to be None.
ignore-none=yes
# This flag controls whether pylint should warn about no-member and similar
# checks whenever an opaque object is returned when inferring. The inference
# can return multiple potential results while evaluating a Python object, but
# some branches might not be evaluated, which results in partial inference. In
# that case, it might be useful to still emit no-member and other checks for
# the rest of the inferred objects.
ignore-on-opaque-inference=yes
# List of symbolic message names to ignore for Mixin members.
ignored-checks-for-mixins=no-member,
not-async-context-manager,
not-context-manager,
attribute-defined-outside-init
# List of class names for which member attributes should not be checked (useful
# for classes with dynamically set attributes). This supports the use of
# qualified names.
ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace
# Show a hint with possible names when a member name was not found. The aspect
# of finding the hint is based on edit distance.
missing-member-hint=yes
# The minimum edit distance a name should have in order to be considered a
# similar match for a missing member name.
missing-member-hint-distance=1
# The total number of similar names that should be taken in consideration when
# showing a hint for a missing member.
missing-member-max-choices=1
# Regex pattern to define which classes are considered mixins.
mixin-class-rgx=.*[Mm]ixin
# List of decorators that change the signature of a decorated function.
signature-mutators=
[VARIABLES]
# List of additional names supposed to be defined in builtins. Remember that
# you should avoid defining new builtins when possible.
additional-builtins=
# Tells whether unused global variables should be treated as a violation.
allow-global-unused-variables=yes
# List of names allowed to shadow builtins
allowed-redefined-builtins=
# List of strings which can identify a callback function by name. A callback
# name must start or end with one of those strings.
callbacks=cb_,
_cb
# A regular expression matching the name of dummy variables (i.e. expected to
# not be used).
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
# Argument names that match this expression will be ignored.
ignored-argument-names=_.*|^ignored_|^unused_
# Tells whether we should check for unused import in __init__ files.
init-import=no
# List of qualified module names which can have objects that can redefine
# builtins.
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,110 @@ Community Crypto Release Notes
.. contents:: Topics .. contents:: Topics
v3.0.0-rc1
==========
Release Summary
---------------
First release candidate for new major 3.0.0 release. Contains two bugfixes and some refactorings.
Minor Changes
-------------
- Remove various no longer needed abstraction layers for multiple backends (https://github.com/ansible-collections/community.crypto/pull/912).
- Various code refactorings (https://github.com/ansible-collections/community.crypto/pull/905, https://github.com/ansible-collections/community.crypto/pull/909, https://github.com/ansible-collections/community.crypto/pull/911, https://github.com/ansible-collections/community.crypto/pull/913, https://github.com/ansible-collections/community.crypto/pull/914, https://github.com/ansible-collections/community.crypto/pull/917).
Bugfixes
--------
- acme_account - make work with CAs that do not accept any account request without External Account Binding data (https://github.com/ansible-collections/community.crypto/issues/918, https://github.com/ansible-collections/community.crypto/pull/919).
- openssl_csr, openssl_csr_pipe - avoid accessing internal members of cryptography's ``KeyUsage`` extension object (https://github.com/ansible-collections/community.crypto/pull/910).
v3.0.0-a2
=========
Release Summary
---------------
Second pre-release for community.crypto 3.0.0.
This release removes all Entrust content.
Removed Features (previously deprecated)
----------------------------------------
- All Entrust content is being removed since the Entrust service in currently being sunsetted after the sale of Entrust's Public Certificates Business to Sectigo; see `the announcement with key dates <https://www.entrust.com/tls-certificate-information-center>`__ and `the migration brief for customers <https://www.sectigo.com/uploads/resources/EOL_Migration-Brief-End-Customer.pdf>`__ for details. Since this process will be completed in 2025, we decided to remove all Entrust content from community.general 3.0.0 (https://github.com/ansible-collections/community.crypto/issues/895, https://github.com/ansible-collections/community.crypto/pull/901).
- ecs_certificate - the module has been removed. Please use community.crypto 2.x.y if you need this module (https://github.com/ansible-collections/community.crypto/pull/900).
- ecs_domain - the module has been removed. Please use community.crypto 2.x.y if you need this module (https://github.com/ansible-collections/community.crypto/pull/900).
- x509_certificate - the ``entrust`` provider has been removed. Please use community.crypto 2.x.y if you need this provider (https://github.com/ansible-collections/community.crypto/pull/900).
- x509_certificate_pipe - the ``entrust`` provider has been removed. Please use community.crypto 2.x.y if you need this provider (https://github.com/ansible-collections/community.crypto/pull/900).
v3.0.0-a1
=========
Release Summary
---------------
First pre-release for community.crypto 3.0.0.
This release drops compatibility for ansible-core before 2.17, for Python before 3.7, and for cryptography before 3.3.
Minor Changes
-------------
- No longer provide cryptography's ``backend`` parameter. This will break with cryptography < 3.1 (https://github.com/ansible-collections/community.crypto/pull/878).
- On cryptography 36.0.0+, always use ``public_bytes()`` for X.509 extension objects instead of using cryptography internals to obtain DER value of extension (https://github.com/ansible-collections/community.crypto/pull/878).
- Python code modernization: add type hints and type checking (https://github.com/ansible-collections/community.crypto/pull/885).
- Python code modernization: avoid unnecessary string conversion (https://github.com/ansible-collections/community.crypto/pull/880).
- Python code modernization: avoid using ``six`` (https://github.com/ansible-collections/community.crypto/pull/884).
- Python code modernization: remove Python 3 specific code (https://github.com/ansible-collections/community.crypto/pull/877).
- Python code modernization: update ``__future__`` imports, remove Python 2 specific boilerplates (https://github.com/ansible-collections/community.crypto/pull/876).
- Python code modernization: use ``unittest.mock`` instead of ``ansible_collections.community.internal_test_tools.tests.unit.compat.mock`` (https://github.com/ansible-collections/community.crypto/pull/881).
- Python code modernization: use f-strings instead of ``%`` and ``str.format()`` (https://github.com/ansible-collections/community.crypto/pull/875).
- Remove ``backend`` parameter from internal code whenever possible (https://github.com/ansible-collections/community.crypto/pull/883).
- Remove various compatibility code for cryptography < 3.3 (https://github.com/ansible-collections/community.crypto/pull/878).
- Remove vendored copy of ``distutils.version`` in favor of vendored copy included with ansible-core 2.12+ (https://github.com/ansible-collections/community.crypto/pull/371).
- acme_* modules - improve parsing of ``Retry-After`` reply headers in regular ACME requests (https://github.com/ansible-collections/community.crypto/pull/890).
- action_module plugin utils - remove compatibility with older ansible-core/ansible-base/Ansible versions (https://github.com/ansible-collections/community.crypto/pull/872).
- x509_certificate, x509_certificate_pipe - the ``ownca_version`` and ``selfsigned_version`` parameters explicitly only allow the value ``3``. The module already failed for other values in the past, now this is validated as part of the module argument spec (https://github.com/ansible-collections/community.crypto/pull/890).
Breaking Changes / Porting Guide
--------------------------------
- All doc_fragments are now private to the collection and must not be used from other collections or unrelated plugins/modules. Breaking changes in these can happen at any time, even in bugfix releases (https://github.com/ansible-collections/community.crypto/pull/898).
- All module_utils and plugin_utils are now private to the collection and must not be used from other collections or unrelated plugins/modules. Breaking changes in these can happen at any time, even in bugfix releases (https://github.com/ansible-collections/community.crypto/pull/887).
- Ignore value of ``select_crypto_backend`` for all modules except acme_* and ..., and always assume the value ``auto``. This ensures that the ``cryptography`` version is always checked (https://github.com/ansible-collections/community.crypto/pull/883).
- The validation for relative timestamps is now more strict. A string starting with ``+`` or ``-`` must be valid, otherwise validation will fail. In the past such strings were often silently ignored, and in many cases the code which triggered the validation was not able to handle no result (https://github.com/ansible-collections/community.crypto/pull/885).
- acme.certificates module utils - the ``retrieve_acme_v1_certificate()`` helper function has been removed (https://github.com/ansible-collections/community.crypto/pull/873).
- get_certificate - the default for ``asn1_base64`` changed from ``false`` to ``true`` (https://github.com/ansible-collections/community.crypto/pull/873).
- x509_crl - the ``mode`` parameter no longer denotes the update mode, but the CRL file mode. Use ``crl_mode`` instead for the update mode (https://github.com/ansible-collections/community.crypto/pull/873).
Deprecated Features
-------------------
- acme_certificate - deprecate the ``agreement`` option which has no more effect. It will be removed from community.crypto 4.0.0 (https://github.com/ansible-collections/community.crypto/pull/891).
- openssl_pkcs12 - deprecate the ``maciter_size`` option which has no more effect. It will be removed from community.crypto 4.0.0 (https://github.com/ansible-collections/community.crypto/pull/891).
Removed Features (previously deprecated)
----------------------------------------
- The collection no longer supports cryptography < 3.3 (https://github.com/ansible-collections/community.crypto/pull/878, https://github.com/ansible-collections/community.crypto/pull/882).
- acme.acme module utils - the ``get_default_argspec()`` function has been removed. Use ``create_default_argspec()`` instead (https://github.com/ansible-collections/community.crypto/pull/873).
- acme.backends module utils - the methods ``get_ordered_csr_identifiers()`` and ``get_cert_information()`` of ``CryptoBackend`` now must be implemented (https://github.com/ansible-collections/community.crypto/pull/873).
- acme.documentation docs fragment - the ``documentation`` docs fragment has been removed. Use both the ``basic`` and ``account`` docs fragments in ``acme`` instead (https://github.com/ansible-collections/community.crypto/pull/873).
- acme_* modules - support for ACME v1 has been removed (https://github.com/ansible-collections/community.crypto/pull/873).
- community.crypto no longer supports Ansible 2.9, ansible-base 2.10, and ansible-core versions 2.11, 2.12, 2.13, 2.14, 2.15, and 2.16. While content from this collection might still work with some older versions of ansible-core, it will not work with any Python version before 3.7 (https://github.com/ansible-collections/community.crypto/pull/870).
- crypto.basic module utils - remove ``CRYPTOGRAPHY_HAS_*`` flags. All tested features are supported since cryptography 3.0 (https://github.com/ansible-collections/community.crypto/pull/878).
- crypto.cryptography_support module utils - remove ``cryptography_serial_number_of_cert()`` helper function (https://github.com/ansible-collections/community.crypto/pull/878).
- crypto.module_backends.common module utils - this module utils has been removed. Use the ``argspec`` module utils instead (https://github.com/ansible-collections/community.crypto/pull/873).
- crypto.support module utils - remove ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/874).
- execution environment dependencies - remove PyOpenSSL dependency (https://github.com/ansible-collections/community.crypto/pull/874).
- openssl_csr_pipe - the module now ignores check mode and will always behave as if check mode is not active (https://github.com/ansible-collections/community.crypto/pull/873).
- openssl_pkcs12 - support for the ``pyopenssl`` backend has been removed (https://github.com/ansible-collections/community.crypto/pull/873).
- openssl_privatekey_pipe - the module now ignores check mode and will always behave as if check mode is not active (https://github.com/ansible-collections/community.crypto/pull/873).
- time module utils - remove ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/874).
- x509_certificate_pipe - the module now ignores check mode and will always behave as if check mode is not active (https://github.com/ansible-collections/community.crypto/pull/873).
v2.26.1 v2.26.1
======= =======

View File

@@ -1,8 +0,0 @@
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -1,48 +0,0 @@
PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
--------------------------------------------
1. This LICENSE AGREEMENT is between the Python Software Foundation
("PSF"), and the Individual or Organization ("Licensee") accessing and
otherwise using this software ("Python") in source or binary form and
its associated documentation.
2. Subject to the terms and conditions of this License Agreement, PSF hereby
grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce,
analyze, test, perform and/or display publicly, prepare derivative works,
distribute, and otherwise use Python alone or in any derivative version,
provided, however, that PSF's License Agreement and PSF's notice of copyright,
i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010,
2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021 Python Software Foundation;
All Rights Reserved" are retained in Python alone or in any derivative version
prepared by Licensee.
3. In the event Licensee prepares a derivative work that is based on
or incorporates Python or any part thereof, and wants to make
the derivative work available to others as provided herein, then
Licensee hereby agrees to include in any such work a brief summary of
the changes made to Python.
4. PSF is making Python available to Licensee on an "AS IS"
basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT
INFRINGE ANY THIRD PARTY RIGHTS.
5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON,
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
6. This License Agreement will automatically terminate upon a material
breach of its terms and conditions.
7. Nothing in this License Agreement shall be deemed to create any
relationship of agency, partnership, or joint venture between PSF and
Licensee. This License Agreement does not grant permission to use PSF
trademarks or trade name in a trademark sense to endorse or promote
products or services of Licensee, or any third party.
8. By copying, installing or otherwise using Python, Licensee
agrees to be bound by the terms and conditions of this License
Agreement.

View File

@@ -8,7 +8,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
[![Documentation](https://img.shields.io/badge/docs-brightgreen.svg)](https://docs.ansible.com/ansible/devel/collections/community/crypto/) [![Documentation](https://img.shields.io/badge/docs-brightgreen.svg)](https://docs.ansible.com/ansible/devel/collections/community/crypto/)
[![Build Status](https://dev.azure.com/ansible/community.crypto/_apis/build/status/CI?branchName=main)](https://dev.azure.com/ansible/community.crypto/_build?definitionId=21) [![Build Status](https://dev.azure.com/ansible/community.crypto/_apis/build/status/CI?branchName=main)](https://dev.azure.com/ansible/community.crypto/_build?definitionId=21)
[![EOL CI](https://github.com/ansible-collections/community.crypto/actions/workflows/ansible-test.yml/badge.svg?branch=main)](https://github.com/ansible-collections/community.crypto/actions)
[![Nox CI](https://github.com/ansible-collections/community.crypto/actions/workflows/nox.yml/badge.svg?branch=main)](https://github.com/ansible-collections/community.crypto/actions) [![Nox CI](https://github.com/ansible-collections/community.crypto/actions/workflows/nox.yml/badge.svg?branch=main)](https://github.com/ansible-collections/community.crypto/actions)
[![Codecov](https://img.shields.io/codecov/c/github/ansible-collections/community.crypto)](https://codecov.io/gh/ansible-collections/community.crypto) [![Codecov](https://img.shields.io/codecov/c/github/ansible-collections/community.crypto)](https://codecov.io/gh/ansible-collections/community.crypto)
[![REUSE status](https://api.reuse.software/badge/github.com/ansible-collections/community.crypto)](https://api.reuse.software/info/github.com/ansible-collections/community.crypto) [![REUSE status](https://api.reuse.software/badge/github.com/ansible-collections/community.crypto)](https://api.reuse.software/info/github.com/ansible-collections/community.crypto)
@@ -40,13 +39,13 @@ For more information about communication, see the [Ansible communication guide](
## Tested with Ansible ## Tested with Ansible
Tested with the current Ansible 2.9, ansible-base 2.10, ansible-core 2.11, ansible-core 2.12, ansible-core 2.13, ansible-core 2.14, ansible-core 2.15, ansible-core 2.16, ansible-core-2.17, and ansible-core 2.18 releases and the current development version of ansible-core. Ansible versions before 2.9.10 are not supported. Tested with the current ansible-core-2.17, ansible-core 2.18, and ansible-core 2.19 releases and the current development version of ansible-core. Ansible-core versions before 2.17 are not supported; please use community.crypto 2.x.y with these.
## External requirements ## External requirements
The exact requirements for every module are listed in the module documentation. The exact requirements for every module are listed in the module documentation.
Most modules require a recent enough version of [the Python cryptography library](https://pypi.org/project/cryptography/). See the module documentations for the minimal version supported for each module. Most modules require a recent enough version of [the Python cryptography library](https://pypi.org/project/cryptography/); the minimum supported version by this collection is 3.3. See the module documentations for the minimal version supported for each module.
## Collection Documentation ## Collection Documentation
@@ -58,59 +57,6 @@ We also separately publish [**latest commit** collection documentation](https://
If you use the Ansible package and do not update collections independently, use **latest**. If you install or update this collection directly from Galaxy, use **devel**. If you are looking to contribute, use **latest commit**. If you use the Ansible package and do not update collections independently, use **latest**. If you install or update this collection directly from Galaxy, use **devel**. If you are looking to contribute, use **latest commit**.
## Included content
- OpenSSL / PKI modules and plugins:
- certificate_complete_chain module
- openssl_csr_info module and filter
- openssl_csr_pipe module
- openssl_csr module
- openssl_dhparam module
- openssl_pkcs12 module
- openssl_privatekey_convert module
- openssl_privatekey_info module and filter
- openssl_privatekey_pipe module
- openssl_privatekey module
- openssl_publickey_info module and filter
- openssl_publickey module
- openssl_signature_info module
- openssl_signature module
- split_pem filter
- x509_certificate_convert module
- x509_certificate_info module and filter
- x509_certificate_pipe module
- x509_certificate module
- x509_crl_info module and filter
- x509_crl module
- OpenSSH modules and plugins:
- openssh_cert module
- openssh_keypair module
- ACME modules and plugins:
- acme_account_info module
- acme_account module
- acme_ari_info module
- acme_certificate module
- acme_certificate_deactivate_authz module
- acme_certificate_order_create module
- acme_certificate_order_finalize module
- acme_certificate_order_info module
- acme_certificate_order_validate module
- acme_certificate_revoke module
- acme_challenge_cert_helper module
- acme_inspect module
- ECS modules and plugins:
- ecs_certificate module
- ecs_domain module
- GnuPG modules and plugins:
- gpg_fingerprint lookup and filter
- Miscellaneous modules and plugins:
- crypto_info module
- get_certificate module
- luks_device module
- parse_serial and to_serial filters
You can also find a list of all modules and plugins with documentation on the [Ansible docs site](https://docs.ansible.com/ansible/latest/collections/community/crypto/), or the [latest commit collection documentation](https://ansible-collections.github.io/community.crypto/branch/main/).
## Using this collection ## Using this collection
Before using the crypto community collection, you need to install the collection with the `ansible-galaxy` CLI: Before using the crypto community collection, you need to install the collection with the `ansible-galaxy` CLI:
@@ -147,14 +93,6 @@ See the [changelog](https://github.com/ansible-collections/community.crypto/blob
We plan to regularly release minor and patch versions, whenever new features are added or bugs fixed. Our collection follows [semantic versioning](https://semver.org/), so breaking changes will only happen in major releases. We plan to regularly release minor and patch versions, whenever new features are added or bugs fixed. Our collection follows [semantic versioning](https://semver.org/), so breaking changes will only happen in major releases.
Most modules will drop PyOpenSSL support in version 2.0.0 of the collection, i.e. in the next major version. We currently plan to release 2.0.0 somewhen during 2021. Around then, the supported versions of the most common distributions will contain a new enough version of ``cryptography``.
Once 2.0.0 has been released, bugfixes will still be backported to 1.0.0 for some time, and some features might also be backported. If we do not want to backport something ourselves because we think it is not worth the effort, backport PRs by non-maintainers are usually accepted.
In 2.0.0, the following notable features will be removed:
* PyOpenSSL backends of all modules, except ``openssl_pkcs12`` which does not have a ``cryptography`` backend due to lack of support of PKCS#12 functionality in ``cryptography``.
* The ``assertonly`` provider of ``x509_certificate`` will be removed.
## More information ## More information
- [Ansible Collection overview](https://github.com/ansible-collections/overview) - [Ansible Collection overview](https://github.com/ansible-collections/overview)
@@ -168,6 +106,6 @@ This collection is primarily licensed and distributed as a whole under the GNU G
See [LICENSES/GPL-3.0-or-later.txt](https://github.com/ansible-collections/community.crypto/blob/main/COPYING) for the full text. See [LICENSES/GPL-3.0-or-later.txt](https://github.com/ansible-collections/community.crypto/blob/main/COPYING) for the full text.
Parts of the collection are licensed under the [Apache 2.0 license](https://github.com/ansible-collections/community.crypto/blob/main/LICENSES/Apache-2.0.txt) (`plugins/module_utils/crypto/_obj2txt.py` and `plugins/module_utils/crypto/_objects_data.py`), the [BSD 2-Clause license](https://github.com/ansible-collections/community.crypto/blob/main/LICENSES/BSD-2-Clause.txt) (`plugins/module_utils/ecs/api.py`), the [BSD 3-Clause license](https://github.com/ansible-collections/community.crypto/blob/main/LICENSES/BSD-3-Clause.txt) (`plugins/module_utils/crypto/_obj2txt.py`, `tests/integration/targets/prepare_jinja2_compat/filter_plugins/jinja_compatibility.py`), and the [PSF 2.0 license](https://github.com/ansible-collections/community.crypto/blob/main/LICENSES/PSF-2.0.txt) (`plugins/module_utils/_version.py`). This only applies to vendored files in ``plugins/module_utils/`` and to the ECS module utils. Parts of the collection are licensed under the [Apache 2.0 license](https://github.com/ansible-collections/community.crypto/blob/main/LICENSES/Apache-2.0.txt) (`plugins/module_utils/_crypto/_obj2txt.py` and `plugins/module_utils/_crypto/_objects_data.py`) and the [BSD 3-Clause license](https://github.com/ansible-collections/community.crypto/blob/main/LICENSES/BSD-3-Clause.txt) (`plugins/module_utils/_crypto/_obj2txt.py`). This only applies to vendored files in ``plugins/module_utils/``.
All files have a machine readable `SDPX-License-Identifier:` comment denoting its respective license(s) or an equivalent entry in an accompanying `.license` file. Only changelog fragments (which will not be part of a release) are covered by a blanket statement in `REUSE.toml`. This conforms to the [REUSE specification](https://reuse.software/spec/). All files have a machine readable `SDPX-License-Identifier:` comment denoting its respective license(s) or an equivalent entry in an accompanying `.license` file. Only changelog fragments (which will not be part of a release) are covered by a blanket statement in `REUSE.toml`. This conforms to the [REUSE specification](https://reuse.software/spec/).

View File

@@ -9,16 +9,25 @@
[sessions.lint] [sessions.lint]
run_isort = true run_isort = true
isort_config = "tests/nox-config-isort.cfg" isort_config = ".isort.cfg"
run_black = false run_black = true
run_flake8 = true run_flake8 = true
flake8_config = "tests/nox-config-flake8.ini" flake8_config = ".flake8"
run_pylint = false run_pylint = true
pylint_rcfile = ".pylintrc"
pylint_ansible_core_package = "ansible-core>=2.19.0b4"
run_yamllint = true run_yamllint = true
yamllint_config = ".yamllint" yamllint_config = ".yamllint"
yamllint_config_plugins = ".yamllint-docs" yamllint_config_plugins = ".yamllint-docs"
yamllint_config_plugins_examples = ".yamllint-examples" yamllint_config_plugins_examples = ".yamllint-examples"
run_mypy = false run_mypy = true
mypy_ansible_core_package = "ansible-core>=2.19.0b4"
mypy_config = ".mypy.ini"
mypy_extra_deps = [
"cryptography",
"types-mock",
"types-PyYAML",
]
[sessions.docs_check] [sessions.docs_check]
validate_collection_refs="all" validate_collection_refs="all"
@@ -31,6 +40,13 @@ run_no_unwanted_files = true
no_unwanted_files_module_extensions = [".py"] no_unwanted_files_module_extensions = [".py"]
no_unwanted_files_yaml_extensions = [".yml"] no_unwanted_files_yaml_extensions = [".yml"]
run_action_groups = true run_action_groups = true
run_no_trailing_whitespace = true
no_trailing_whitespace_skip_paths = [
"tests/integration/targets/luks_device/files/keyfile3",
]
no_trailing_whitespace_skip_directories = [
"tests/unit/plugins/module_utils/_acme/fixtures/",
]
[[sessions.extra_checks.action_groups_config]] [[sessions.extra_checks.action_groups_config]]
name = "acme" name = "acme"
@@ -40,9 +56,9 @@ exclusions = [
"acme_certificate_renewal_info", # does not support ACME account "acme_certificate_renewal_info", # does not support ACME account
"acme_challenge_cert_helper", # does not support (and need) any common parameters "acme_challenge_cert_helper", # does not support (and need) any common parameters
] ]
doc_fragment = "community.crypto.attributes.actiongroup_acme" doc_fragment = "community.crypto._attributes.actiongroup_acme"
[sessions.build_import_check] [sessions.build_import_check]
run_galaxy_importer = true run_galaxy_importer = true
# [sessions.ansible_lint] [sessions.ansible_lint]

View File

@@ -1643,3 +1643,164 @@ releases:
- 867-passphrase-encoding-nolog.yml - 867-passphrase-encoding-nolog.yml
- 868-luks-remove-keyslot.yml - 868-luks-remove-keyslot.yml
release_date: '2025-04-28' release_date: '2025-04-28'
3.0.0-a1:
changes:
breaking_changes:
- All doc_fragments are now private to the collection and must not be used
from other collections or unrelated plugins/modules. Breaking changes in
these can happen at any time, even in bugfix releases (https://github.com/ansible-collections/community.crypto/pull/898).
- All module_utils and plugin_utils are now private to the collection and
must not be used from other collections or unrelated plugins/modules. Breaking
changes in these can happen at any time, even in bugfix releases (https://github.com/ansible-collections/community.crypto/pull/887).
- Ignore value of ``select_crypto_backend`` for all modules except acme_*
and ..., and always assume the value ``auto``. This ensures that the ``cryptography``
version is always checked (https://github.com/ansible-collections/community.crypto/pull/883).
- The validation for relative timestamps is now more strict. A string starting
with ``+`` or ``-`` must be valid, otherwise validation will fail. In the
past such strings were often silently ignored, and in many cases the code
which triggered the validation was not able to handle no result (https://github.com/ansible-collections/community.crypto/pull/885).
- acme.certificates module utils - the ``retrieve_acme_v1_certificate()``
helper function has been removed (https://github.com/ansible-collections/community.crypto/pull/873).
- get_certificate - the default for ``asn1_base64`` changed from ``false``
to ``true`` (https://github.com/ansible-collections/community.crypto/pull/873).
- x509_crl - the ``mode`` parameter no longer denotes the update mode, but
the CRL file mode. Use ``crl_mode`` instead for the update mode (https://github.com/ansible-collections/community.crypto/pull/873).
deprecated_features:
- acme_certificate - deprecate the ``agreement`` option which has no more
effect. It will be removed from community.crypto 4.0.0 (https://github.com/ansible-collections/community.crypto/pull/891).
- openssl_pkcs12 - deprecate the ``maciter_size`` option which has no more
effect. It will be removed from community.crypto 4.0.0 (https://github.com/ansible-collections/community.crypto/pull/891).
minor_changes:
- No longer provide cryptography's ``backend`` parameter. This will break
with cryptography < 3.1 (https://github.com/ansible-collections/community.crypto/pull/878).
- On cryptography 36.0.0+, always use ``public_bytes()`` for X.509 extension
objects instead of using cryptography internals to obtain DER value of extension
(https://github.com/ansible-collections/community.crypto/pull/878).
- 'Python code modernization: add type hints and type checking (https://github.com/ansible-collections/community.crypto/pull/885).'
- 'Python code modernization: avoid unnecessary string conversion (https://github.com/ansible-collections/community.crypto/pull/880).'
- 'Python code modernization: avoid using ``six`` (https://github.com/ansible-collections/community.crypto/pull/884).'
- 'Python code modernization: remove Python 3 specific code (https://github.com/ansible-collections/community.crypto/pull/877).'
- 'Python code modernization: update ``__future__`` imports, remove Python
2 specific boilerplates (https://github.com/ansible-collections/community.crypto/pull/876).'
- 'Python code modernization: use ``unittest.mock`` instead of ``ansible_collections.community.internal_test_tools.tests.unit.compat.mock``
(https://github.com/ansible-collections/community.crypto/pull/881).'
- 'Python code modernization: use f-strings instead of ``%`` and ``str.format()``
(https://github.com/ansible-collections/community.crypto/pull/875).'
- Remove ``backend`` parameter from internal code whenever possible (https://github.com/ansible-collections/community.crypto/pull/883).
- Remove various compatibility code for cryptography < 3.3 (https://github.com/ansible-collections/community.crypto/pull/878).
- Remove vendored copy of ``distutils.version`` in favor of vendored copy
included with ansible-core 2.12+ (https://github.com/ansible-collections/community.crypto/pull/371).
- acme_* modules - improve parsing of ``Retry-After`` reply headers in regular
ACME requests (https://github.com/ansible-collections/community.crypto/pull/890).
- action_module plugin utils - remove compatibility with older ansible-core/ansible-base/Ansible
versions (https://github.com/ansible-collections/community.crypto/pull/872).
- x509_certificate, x509_certificate_pipe - the ``ownca_version`` and ``selfsigned_version``
parameters explicitly only allow the value ``3``. The module already failed
for other values in the past, now this is validated as part of the module
argument spec (https://github.com/ansible-collections/community.crypto/pull/890).
release_summary: 'First pre-release for community.crypto 3.0.0.
This release drops compatibility for ansible-core before 2.17, for Python
before 3.7, and for cryptography before 3.3.
'
removed_features:
- The collection no longer supports cryptography < 3.3 (https://github.com/ansible-collections/community.crypto/pull/878,
https://github.com/ansible-collections/community.crypto/pull/882).
- acme.acme module utils - the ``get_default_argspec()`` function has been
removed. Use ``create_default_argspec()`` instead (https://github.com/ansible-collections/community.crypto/pull/873).
- acme.backends module utils - the methods ``get_ordered_csr_identifiers()``
and ``get_cert_information()`` of ``CryptoBackend`` now must be implemented
(https://github.com/ansible-collections/community.crypto/pull/873).
- acme.documentation docs fragment - the ``documentation`` docs fragment has
been removed. Use both the ``basic`` and ``account`` docs fragments in ``acme``
instead (https://github.com/ansible-collections/community.crypto/pull/873).
- acme_* modules - support for ACME v1 has been removed (https://github.com/ansible-collections/community.crypto/pull/873).
- community.crypto no longer supports Ansible 2.9, ansible-base 2.10, and
ansible-core versions 2.11, 2.12, 2.13, 2.14, 2.15, and 2.16. While content
from this collection might still work with some older versions of ansible-core,
it will not work with any Python version before 3.7 (https://github.com/ansible-collections/community.crypto/pull/870).
- crypto.basic module utils - remove ``CRYPTOGRAPHY_HAS_*`` flags. All tested
features are supported since cryptography 3.0 (https://github.com/ansible-collections/community.crypto/pull/878).
- crypto.cryptography_support module utils - remove ``cryptography_serial_number_of_cert()``
helper function (https://github.com/ansible-collections/community.crypto/pull/878).
- crypto.module_backends.common module utils - this module utils has been
removed. Use the ``argspec`` module utils instead (https://github.com/ansible-collections/community.crypto/pull/873).
- crypto.support module utils - remove ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/874).
- execution environment dependencies - remove PyOpenSSL dependency (https://github.com/ansible-collections/community.crypto/pull/874).
- openssl_csr_pipe - the module now ignores check mode and will always behave
as if check mode is not active (https://github.com/ansible-collections/community.crypto/pull/873).
- openssl_pkcs12 - support for the ``pyopenssl`` backend has been removed
(https://github.com/ansible-collections/community.crypto/pull/873).
- openssl_privatekey_pipe - the module now ignores check mode and will always
behave as if check mode is not active (https://github.com/ansible-collections/community.crypto/pull/873).
- time module utils - remove ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/874).
- x509_certificate_pipe - the module now ignores check mode and will always
behave as if check mode is not active (https://github.com/ansible-collections/community.crypto/pull/873).
fragments:
- 3.0.0-a1.yml
- 371-distutils-vendor-removed.yml
- 870-ansible-core.yml
- 872-action-module.yml
- 873-deprecation-removals.yml
- 874-pyopenssl.yml
- 878-backend.yml
- 883-backend.yml
- 887-module_utils-plugin_utils.yml
- 890-refactoring.yml
- 891-deprecation.yml
- 898-doc_fragments.yml
- refactoring.yml
- relative-timestamps.yml
release_date: '2025-05-18'
3.0.0-a2:
changes:
release_summary: 'Second pre-release for community.crypto 3.0.0.
This release removes all Entrust content.
'
removed_features:
- All Entrust content is being removed since the Entrust service in currently
being sunsetted after the sale of Entrust's Public Certificates Business
to Sectigo; see `the announcement with key dates <https://www.entrust.com/tls-certificate-information-center>`__
and `the migration brief for customers <https://www.sectigo.com/uploads/resources/EOL_Migration-Brief-End-Customer.pdf>`__
for details. Since this process will be completed in 2025, we decided to
remove all Entrust content from community.general 3.0.0 (https://github.com/ansible-collections/community.crypto/issues/895,
https://github.com/ansible-collections/community.crypto/pull/901).
- ecs_certificate - the module has been removed. Please use community.crypto
2.x.y if you need this module (https://github.com/ansible-collections/community.crypto/pull/900).
- ecs_domain - the module has been removed. Please use community.crypto 2.x.y
if you need this module (https://github.com/ansible-collections/community.crypto/pull/900).
- x509_certificate - the ``entrust`` provider has been removed. Please use
community.crypto 2.x.y if you need this provider (https://github.com/ansible-collections/community.crypto/pull/900).
- x509_certificate_pipe - the ``entrust`` provider has been removed. Please
use community.crypto 2.x.y if you need this provider (https://github.com/ansible-collections/community.crypto/pull/900).
fragments:
- 3.0.0-a2.yml
- 900-remove-entrust.yml
release_date: '2025-05-22'
3.0.0-rc1:
changes:
bugfixes:
- acme_account - make work with CAs that do not accept any account request
without External Account Binding data (https://github.com/ansible-collections/community.crypto/issues/918,
https://github.com/ansible-collections/community.crypto/pull/919).
- openssl_csr, openssl_csr_pipe - avoid accessing internal members of cryptography's
``KeyUsage`` extension object (https://github.com/ansible-collections/community.crypto/pull/910).
minor_changes:
- Remove various no longer needed abstraction layers for multiple backends
(https://github.com/ansible-collections/community.crypto/pull/912).
- Various code refactorings (https://github.com/ansible-collections/community.crypto/pull/905,
https://github.com/ansible-collections/community.crypto/pull/909, https://github.com/ansible-collections/community.crypto/pull/911,
https://github.com/ansible-collections/community.crypto/pull/913, https://github.com/ansible-collections/community.crypto/pull/914,
https://github.com/ansible-collections/community.crypto/pull/917).
release_summary: First release candidate for new major 3.0.0 release. Contains
two bugfixes and some refactorings.
fragments:
- 3.0.0-rc1.yml
- 910-csr.yml
- 919-acme_account-ear.yml
- refactoring.yml
release_date: '2025-06-14'

View File

@@ -5,7 +5,7 @@
namespace: community namespace: community
name: crypto name: crypto
version: 2.26.1 version: 3.0.0-rc1
readme: README.md readme: README.md
authors: authors:
- Ansible (github.com/ansible) - Ansible (github.com/ansible)

View File

@@ -11,11 +11,3 @@ openssl [platform:rpm]
python3-cryptography [platform:dpkg] python3-cryptography [platform:dpkg]
python3-cryptography [platform:rpm] python3-cryptography [platform:rpm]
python3-openssl [platform:dpkg] python3-openssl [platform:dpkg]
# On RHEL 9+, CentOS Stream 9+, and Rocky Linux 9+, python3-pyOpenSSL is part of EPEL
python3-pyOpenSSL [platform:rpm !platform:rhel !platform:centos !platform:rocky]
python3-pyOpenSSL [platform:rhel-8]
python3-pyOpenSSL [platform:rhel !platform:rhel-6 !platform:rhel-7 !platform:rhel-8 epel]
python3-pyOpenSSL [platform:centos-8]
python3-pyOpenSSL [platform:centos !platform:centos-6 !platform:centos-7 !platform:centos-8 epel]
python3-pyOpenSSL [platform:rocky-8]
python3-pyOpenSSL [platform:rocky !platform:rocky-8 epel]

View File

@@ -3,7 +3,7 @@
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
requires_ansible: '>=2.9.10' requires_ansible: '>=2.17.0'
action_groups: action_groups:
acme: acme:
@@ -20,6 +20,14 @@ action_groups:
plugin_routing: plugin_routing:
modules: modules:
ecs_certificate:
tombstone:
removal_version: 3.0.0
warning_text: The 'community.crypto.ecs_certificate' module has been removed due to the upcoming sunsetting of the ECS service. Please use community.crypto 2.x.y to continue using this module
ecs_domain:
tombstone:
removal_version: 3.0.0
warning_text: The 'community.crypto.ecs_domain' module has been removed due to the upcoming sunsetting of the ECS service. Please use community.crypto 2.x.y to continue using this module.
acme_account_facts: acme_account_facts:
tombstone: tombstone:
removal_version: 2.0.0 removal_version: 2.0.0

View File

@@ -1,89 +1,76 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Felix Fontein <felix@fontein.de> # Copyright (c) 2020, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function from __future__ import annotations
__metaclass__ = type
import base64 import base64
import typing as t
from ansible.module_utils.common.text.converters import to_bytes, to_native from ansible.module_utils.common.text.converters import to_bytes
from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( from ansible_collections.community.crypto.plugins.module_utils._crypto.basic import (
OpenSSLObjectError, OpenSSLObjectError,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.privatekey import ( from ansible_collections.community.crypto.plugins.module_utils._crypto.module_backends.privatekey import (
get_privatekey_argument_spec, get_privatekey_argument_spec,
select_backend, select_backend,
) )
from ansible_collections.community.crypto.plugins.plugin_utils.action_module import ( from ansible_collections.community.crypto.plugins.plugin_utils._action_module import (
ActionModuleBase, ActionModuleBase,
) )
class PrivateKeyModule(object): if t.TYPE_CHECKING:
def __init__(self, module, module_backend): from ansible_collections.community.crypto.plugins.module_utils._argspec import ( # pragma: no cover
ArgumentSpec,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.module_backends.privatekey import ( # pragma: no cover
PrivateKeyBackend,
)
from ansible_collections.community.crypto.plugins.plugin_utils._action_module import ( # pragma: no cover
AnsibleActionModule,
)
class PrivateKeyModule:
def __init__(
self, module: AnsibleActionModule, module_backend: PrivateKeyBackend
) -> None:
self.module = module self.module = module
self.module_backend = module_backend self.module_backend = module_backend
self.check_mode = module.check_mode self.check_mode = module.check_mode
self.changed = False self.changed = False
self.return_current_key = module.params["return_current_key"] self.return_current_key: bool = module.params["return_current_key"]
if module.params["content"] is not None: content: str | None = module.params["content"]
if module.params["content_base64"]: content_base64: bool = module.params["content_base64"]
if content is not None:
if content_base64:
try: try:
data = base64.b64decode(module.params["content"]) data = base64.b64decode(content)
except Exception as e: except Exception as e:
module.fail_json( module.fail_json(msg=f"Cannot decode Base64 encoded data: {e}")
msg="Cannot decode Base64 encoded data: {0}".format(e)
)
else: else:
data = to_bytes(module.params["content"]) data = to_bytes(content)
module_backend.set_existing(data) module_backend.set_existing(privatekey_bytes=data)
def generate(self, module): def generate(self, module: AnsibleActionModule) -> None:
"""Generate a keypair.""" """Generate a keypair."""
if self.module_backend.needs_regeneration(): if self.module_backend.needs_regeneration():
# Regenerate # Regenerate
if not self.check_mode: self.module_backend.generate_private_key()
self.module_backend.generate_private_key() # Call get_private_key_data() to make sure that exceptions are raised now:
privatekey_data = self.module_backend.get_private_key_data() self.module_backend.get_private_key_data()
self.privatekey_bytes = privatekey_data
else:
self.module.deprecate(
"Check mode support for openssl_privatekey_pipe will change in community.crypto 3.0.0"
" to behave the same as without check mode. You can get that behavior right now"
" by adding `check_mode: false` to the openssl_privatekey_pipe task. If you think this"
" breaks your use-case of this module, please create an issue in the"
" community.crypto repository",
version="3.0.0",
collection_name="community.crypto",
)
self.changed = True self.changed = True
elif self.module_backend.needs_conversion(): elif self.module_backend.needs_conversion():
# Convert # Convert
if not self.check_mode: self.module_backend.convert_private_key()
self.module_backend.convert_private_key() # Call get_private_key_data() to make sure that exceptions are raised now:
privatekey_data = self.module_backend.get_private_key_data() self.module_backend.get_private_key_data()
self.privatekey_bytes = privatekey_data
else:
self.module.deprecate(
"Check mode support for openssl_privatekey_pipe will change in community.crypto 3.0.0"
" to behave the same as without check mode. You can get that behavior right now"
" by adding `check_mode: false` to the openssl_privatekey_pipe task. If you think this"
" breaks your use-case of this module, please create an issue in the"
" community.crypto repository",
version="3.0.0",
collection_name="community.crypto",
)
self.changed = True self.changed = True
def dump(self): def dump(self) -> dict[str, t.Any]:
"""Serialize the object into a dictionary.""" """Serialize the object into a dictionary."""
result = self.module_backend.dump( result = self.module_backend.dump(
include_key=self.changed or self.return_current_key include_key=self.changed or self.return_current_key
@@ -93,26 +80,21 @@ class PrivateKeyModule(object):
class ActionModule(ActionModuleBase): class ActionModule(ActionModuleBase):
@staticmethod def setup_module(self) -> tuple[ArgumentSpec, dict[str, t.Any]]:
def setup_module():
argument_spec = get_privatekey_argument_spec() argument_spec = get_privatekey_argument_spec()
argument_spec.argument_spec.update( argument_spec.argument_spec.update(
dict( {
content=dict(type="str", no_log=True), "content": {"type": "str", "no_log": True},
content_base64=dict(type="bool", default=False), "content_base64": {"type": "bool", "default": False},
return_current_key=dict(type="bool", default=False), "return_current_key": {"type": "bool", "default": False},
) }
)
return argument_spec, dict(
supports_check_mode=True,
) )
return argument_spec, {
"supports_check_mode": True,
}
@staticmethod def run_module(self, module: AnsibleActionModule) -> None:
def run_module(module): module_backend = select_backend(module=module)
backend, module_backend = select_backend(
module=module,
backend=module.params["select_crypto_backend"],
)
try: try:
private_key = PrivateKeyModule(module, module_backend) private_key = PrivateKeyModule(module, module_backend)
@@ -132,4 +114,4 @@ class ActionModule(ActionModuleBase):
module.params["content"] = "ANSIBLE_NO_LOG_VALUE" module.params["content"] = "ANSIBLE_NO_LOG_VALUE"
module.exit_json(**result) module.exit_json(**result)
except OpenSSLObjectError as exc: except OpenSSLObjectError as exc:
module.fail_json(msg=to_native(exc)) module.fail_json(msg=str(exc))

View File

@@ -1,118 +1,14 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net> # Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this doc fragment is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
__metaclass__ = type class ModuleDocFragment:
class ModuleDocFragment(object):
# Standard files documentation fragment
#
# NOTE: This document fragment is DEPRECATED and will be removed from community.crypto 3.0.0.
# Use both the BASIC and ACCOUNT fragments as a replacement.
DOCUMENTATION = r"""
notes:
- If a new enough version of the C(cryptography) library is available (see Requirements for details), it will be used instead
of the C(openssl) binary. This can be explicitly disabled or enabled with the O(select_crypto_backend) option. Note that
using the C(openssl) binary will be slower and less secure, as private key contents always have to be stored on disk (see
O(account_key_content)).
- Although the defaults are chosen so that the module can be used with the L(Let's Encrypt,https://letsencrypt.org/) CA,
the module can in principle be used with any CA providing an ACME endpoint, such as L(Buypass Go SSL,https://www.buypass.com/ssl/products/acme).
- So far, the ACME modules have only been tested by the developers against Let's Encrypt (staging and production), Buypass
(staging and production), ZeroSSL (production), and L(Pebble testing server,https://github.com/letsencrypt/Pebble). We
have got community feedback that they also work with Sectigo ACME Service for InCommon. If you experience problems with
another ACME server, please L(create an issue,https://github.com/ansible-collections/community.crypto/issues/new/choose)
to help us supporting it. Feedback that an ACME server not mentioned does work is also appreciated.
requirements:
- either openssl or L(cryptography,https://cryptography.io/) >= 1.5
- ipaddress
options:
account_key_src:
description:
- Path to a file containing the ACME account RSA or Elliptic Curve key.
- 'Private keys can be created with the M(community.crypto.openssl_privatekey) or M(community.crypto.openssl_privatekey_pipe)
modules. If the requisite (cryptography) is not available, keys can also be created directly with the C(openssl) command
line tool: RSA keys can be created with C(openssl genrsa ...). Elliptic curve keys can be created with C(openssl ecparam
-genkey ...). Any other tool creating private keys in PEM format can be used as well.'
- Mutually exclusive with O(account_key_content).
- Required if O(account_key_content) is not used.
type: path
aliases: [account_key]
account_key_content:
description:
- Content of the ACME account RSA or Elliptic Curve key.
- Mutually exclusive with O(account_key_src).
- Required if O(account_key_src) is not used.
- B(Warning:) the content will be written into a temporary file, which will be deleted by Ansible when the module completes.
Since this is an important private key it can be used to change the account key, or to revoke your certificates
without knowing their private keys , this might not be acceptable.
- In case C(cryptography) is used, the content is not written into a temporary file. It can still happen that it is
written to disk by Ansible in the process of moving the module with its argument to the node where it is executed.
type: str
account_key_passphrase:
description:
- Phassphrase to use to decode the account key.
- B(Note:) this is not supported by the C(openssl) backend, only by the C(cryptography) backend.
type: str
version_added: 1.6.0
account_uri:
description:
- If specified, assumes that the account URI is as given. If the account key does not match this account, or an account
with this URI does not exist, the module fails.
type: str
acme_version:
description:
- The ACME version of the endpoint.
- Must be V(1) for the classic Let's Encrypt and Buypass ACME endpoints, or V(2) for standardized ACME v2 endpoints.
- The value V(1) is deprecated since community.crypto 2.0.0 and will be removed from community.crypto 3.0.0.
required: true
type: int
choices: [1, 2]
acme_directory:
description:
- The ACME directory to use. This is the entry point URL to access the ACME CA server API.
- For safety reasons the default is set to the Let's Encrypt staging server (for the ACME v1 protocol). This will create
technically correct, but untrusted certificates.
- "For Let's Encrypt, all staging endpoints can be found here: U(https://letsencrypt.org/docs/staging-environment/).
For Buypass, all endpoints can be found here: U(https://community.buypass.com/t/63d4ay/buypass-go-ssl-endpoints)."
- For B(Let's Encrypt), the production directory URL for ACME v2 is U(https://acme-v02.api.letsencrypt.org/directory).
- For B(Buypass), the production directory URL for ACME v2 and v1 is U(https://api.buypass.com/acme/directory).
- For B(ZeroSSL), the production directory URL for ACME v2 is U(https://acme.zerossl.com/v2/DV90).
- For B(Sectigo), the production directory URL for ACME v2 is U(https://acme-qa.secure.trust-provider.com/v2/DV).
- The notes for this module contain a list of ACME services this module has been tested against.
required: true
type: str
validate_certs:
description:
- Whether calls to the ACME directory will validate TLS certificates.
- B(Warning:) Should B(only ever) be set to V(false) for testing purposes, for example when testing against a local
Pebble server.
type: bool
default: true
select_crypto_backend:
description:
- Determines which crypto backend to use.
- The default choice is V(auto), which tries to use C(cryptography) if available, and falls back to C(openssl).
- If set to V(openssl), will try to use the C(openssl) binary.
- If set to V(cryptography), will try to use the L(cryptography,https://cryptography.io/) library.
type: str
default: auto
choices: [auto, cryptography, openssl]
request_timeout:
description:
- The time Ansible should wait for a response from the ACME API.
- This timeout is applied to all HTTP(S) requests (HEAD, GET, POST).
type: int
default: 10
version_added: 2.3.0
"""
# Basic documentation fragment without account data # Basic documentation fragment without account data
BASIC = r""" BASIC = r"""
notes: notes:
@@ -120,21 +16,22 @@ notes:
the module can in principle be used with any CA providing an ACME endpoint, such as L(Buypass Go SSL,https://www.buypass.com/ssl/products/acme). the module can in principle be used with any CA providing an ACME endpoint, such as L(Buypass Go SSL,https://www.buypass.com/ssl/products/acme).
- So far, the ACME modules have only been tested by the developers against Let's Encrypt (staging and production), Buypass - So far, the ACME modules have only been tested by the developers against Let's Encrypt (staging and production), Buypass
(staging and production), ZeroSSL (production), and L(Pebble testing server,https://github.com/letsencrypt/Pebble). We (staging and production), ZeroSSL (production), and L(Pebble testing server,https://github.com/letsencrypt/Pebble). We
have got community feedback that they also work with Sectigo ACME Service for InCommon. If you experience problems with have got community feedback that they also work with Sectigo ACME Service for InCommon and with HARICA. If you experience problems with
another ACME server, please L(create an issue,https://github.com/ansible-collections/community.crypto/issues/new/choose) another ACME server, please L(create an issue,https://github.com/ansible-collections/community.crypto/issues/new/choose)
to help us supporting it. Feedback that an ACME server not mentioned does work is also appreciated. to help us supporting it. Feedback that an ACME server not mentioned does work is also appreciated.
requirements: requirements:
- either openssl or L(cryptography,https://cryptography.io/) >= 1.5 - either C(openssl)
- ipaddress - or L(cryptography,https://cryptography.io/) >= 3.3
options: options:
acme_version: acme_version:
description: description:
- The ACME version of the endpoint. - The ACME version of the endpoint.
- Must be V(1) for the classic Let's Encrypt and Buypass ACME endpoints, or V(2) for standardized ACME v2 endpoints. - Must be V(2) for standardized ACME v2 endpoints.
- The value V(1) is deprecated since community.crypto 2.0.0 and will be removed from community.crypto 3.0.0. - The value V(1) is no longer supported since community.crypto 3.0.0.
required: true
type: int type: int
choices: [1, 2] default: 2
choices:
- 2
acme_directory: acme_directory:
description: description:
- The ACME directory to use. This is the entry point URL to access the ACME CA server API. - The ACME directory to use. This is the entry point URL to access the ACME CA server API.
@@ -146,6 +43,7 @@ options:
- For B(Buypass), the production directory URL for ACME v2 and v1 is U(https://api.buypass.com/acme/directory). - For B(Buypass), the production directory URL for ACME v2 and v1 is U(https://api.buypass.com/acme/directory).
- For B(ZeroSSL), the production directory URL for ACME v2 is U(https://acme.zerossl.com/v2/DV90). - For B(ZeroSSL), the production directory URL for ACME v2 is U(https://acme.zerossl.com/v2/DV90).
- For B(Sectigo), the production directory URL for ACME v2 is U(https://acme-qa.secure.trust-provider.com/v2/DV). - For B(Sectigo), the production directory URL for ACME v2 is U(https://acme-qa.secure.trust-provider.com/v2/DV).
- For B(HARICA), the production directory URL for ACME v2 is U(https://acme.harica.gr/XXX/directory) with XXX being specific to your account.
- The notes for this module contain a list of ACME services this module has been tested against. - The notes for this module contain a list of ACME services this module has been tested against.
required: true required: true
type: str type: str
@@ -185,6 +83,7 @@ options:
account_key_src: account_key_src:
description: description:
- Path to a file containing the ACME account RSA or Elliptic Curve key. - Path to a file containing the ACME account RSA or Elliptic Curve key.
- "For Elliptic Curve keys only the following curves are supported: V(secp256r1), V(secp384r1), and V(secp521r1)."
- 'Private keys can be created with the M(community.crypto.openssl_privatekey) or M(community.crypto.openssl_privatekey_pipe) - 'Private keys can be created with the M(community.crypto.openssl_privatekey) or M(community.crypto.openssl_privatekey_pipe)
modules. If the requisite (cryptography) is not available, keys can also be created directly with the C(openssl) command modules. If the requisite (cryptography) is not available, keys can also be created directly with the C(openssl) command
line tool: RSA keys can be created with C(openssl genrsa ...). Elliptic curve keys can be created with C(openssl ecparam line tool: RSA keys can be created with C(openssl genrsa ...). Elliptic curve keys can be created with C(openssl ecparam
@@ -192,10 +91,12 @@ options:
- Mutually exclusive with O(account_key_content). - Mutually exclusive with O(account_key_content).
- Required if O(account_key_content) is not used. - Required if O(account_key_content) is not used.
type: path type: path
aliases: [account_key] aliases:
- account_key
account_key_content: account_key_content:
description: description:
- Content of the ACME account RSA or Elliptic Curve key. - Content of the ACME account RSA or Elliptic Curve key.
- "For Elliptic Curve keys only the following curves are supported: V(secp256r1), V(secp384r1), and V(secp521r1)."
- Mutually exclusive with O(account_key_src). - Mutually exclusive with O(account_key_src).
- Required if O(account_key_src) is not used. - Required if O(account_key_src) is not used.
- B(Warning:) the content will be written into a temporary file, which will be deleted by Ansible when the module completes. - B(Warning:) the content will be written into a temporary file, which will be deleted by Ansible when the module completes.

View File

@@ -1,17 +1,14 @@
# -*- coding: utf-8 -*-
# Copyright (c) Ansible Project # Copyright (c) Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this doc fragment is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
__metaclass__ = type class ModuleDocFragment:
class ModuleDocFragment(object):
# Standard documentation fragment # Standard documentation fragment
DOCUMENTATION = r""" DOCUMENTATION = r"""
options: {} options: {}

View File

@@ -0,0 +1,23 @@
# Copyright (c) 2025 Ansible project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this doc fragment is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
class ModuleDocFragment:
"""
Doc fragments for cryptography requirements.
Must be kept in sync with plugins/module_utils/_cryptography_dep.py.
"""
# Corresponds to the plugins.module_utils._cryptography_dep.COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION constant
MINIMUM = r"""
requirements:
- cryptography >= 3.3
options: {}
"""

View File

@@ -1,18 +1,15 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org> # Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org>
# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at> # Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this doc fragment is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
__metaclass__ = type class ModuleDocFragment:
class ModuleDocFragment(object):
# Standard files documentation fragment # Standard files documentation fragment
DOCUMENTATION = r""" DOCUMENTATION = r"""
description: description:
@@ -27,7 +24,7 @@ attributes:
- If relative timestamps are used and O(ignore_timestamps=false), the module is not idempotent. - If relative timestamps are used and O(ignore_timestamps=false), the module is not idempotent.
- The option O(force=true) generally disables idempotency. - The option O(force=true) generally disables idempotency.
requirements: requirements:
- cryptography >= 1.6 (if using V(selfsigned) or V(ownca) provider) - cryptography >= 3.3 (if using V(selfsigned) or V(ownca) provider)
options: options:
force: force:
description: description:
@@ -76,6 +73,9 @@ options:
- Determines which crypto backend to use. - Determines which crypto backend to use.
- The default choice is V(auto), which tries to use C(cryptography) if available. - The default choice is V(auto), which tries to use C(cryptography) if available.
- If set to V(cryptography), will try to use the L(cryptography,https://cryptography.io/) library. - If set to V(cryptography), will try to use the L(cryptography,https://cryptography.io/) library.
- Note that with community.crypto 3.0.0, all values behave the same.
This option will be deprecated in a later version.
We recommend to not set it explicitly.
type: str type: str
default: auto default: auto
choices: [auto, cryptography] choices: [auto, cryptography]
@@ -131,91 +131,6 @@ options:
default: https://acme-v02.api.letsencrypt.org/directory default: https://acme-v02.api.letsencrypt.org/directory
""" """
BACKEND_ENTRUST_DOCUMENTATION = r"""
options:
entrust_cert_type:
description:
- Specify the type of certificate requested.
- This is only used by the V(entrust) provider.
type: str
default: STANDARD_SSL
choices: [STANDARD_SSL, ADVANTAGE_SSL, UC_SSL, EV_SSL, WILDCARD_SSL, PRIVATE_SSL, PD_SSL, CDS_ENT_LITE, CDS_ENT_PRO, SMIME_ENT]
entrust_requester_email:
description:
- The email of the requester of the certificate (for tracking purposes).
- This is only used by the V(entrust) provider.
- This is required if the provider is V(entrust).
type: str
entrust_requester_name:
description:
- The name of the requester of the certificate (for tracking purposes).
- This is only used by the V(entrust) provider.
- This is required if the provider is V(entrust).
type: str
entrust_requester_phone:
description:
- The phone number of the requester of the certificate (for tracking purposes).
- This is only used by the V(entrust) provider.
- This is required if the provider is V(entrust).
type: str
entrust_api_user:
description:
- The username for authentication to the Entrust Certificate Services (ECS) API.
- This is only used by the V(entrust) provider.
- This is required if the provider is V(entrust).
type: str
entrust_api_key:
description:
- The key (password) for authentication to the Entrust Certificate Services (ECS) API.
- This is only used by the V(entrust) provider.
- This is required if the provider is V(entrust).
type: str
entrust_api_client_cert_path:
description:
- The path to the client certificate used to authenticate to the Entrust Certificate Services (ECS) API.
- This is only used by the V(entrust) provider.
- This is required if the provider is V(entrust).
type: path
entrust_api_client_cert_key_path:
description:
- The path to the private key of the client certificate used to authenticate to the Entrust Certificate Services (ECS) API.
- This is only used by the V(entrust) provider.
- This is required if the provider is V(entrust).
type: path
entrust_not_after:
description:
- The point in time at which the certificate stops being valid.
- Time can be specified either as relative time or as an absolute timestamp.
- A valid absolute time format is C(ASN.1 TIME) such as V(2019-06-18).
- A valid relative time format is V([+-]timespec) where timespec can be an integer + C([w | d | h | m | s]), such as V(+365d) or V(+32w1d2h)).
- Time will always be interpreted as UTC.
- Note that only the date (day, month, year) is supported for specifying the expiry date of the issued certificate.
- The full date-time is adjusted to EST (GMT -5:00) before issuance, which may result in a certificate with an expiration date one day
earlier than expected if a relative time is used.
- The minimum certificate lifetime is 90 days, and maximum is three years.
- If this value is not specified, the certificate will stop being valid 365 days the date of issue.
- This is only used by the V(entrust) provider.
- Please note that this value is B(not) covered by the O(ignore_timestamps) option.
type: str
default: +365d
entrust_api_specification_path:
description:
- The path to the specification file defining the Entrust Certificate Services (ECS) API configuration.
- You can use this to keep a local copy of the specification to avoid downloading it every time the module is used.
- This is only used by the V(entrust) provider.
type: path
default: https://cloud.entrust.net/EntrustCloud/documentation/cms-api-2.1.0.yaml
"""
BACKEND_OWNCA_DOCUMENTATION = r""" BACKEND_OWNCA_DOCUMENTATION = r"""
description: description:
- The V(ownca) provider is intended for generating an OpenSSL certificate signed with your own - The V(ownca) provider is intended for generating an OpenSSL certificate signed with your own
@@ -267,6 +182,8 @@ options:
- This is only used by the V(ownca) provider. - This is only used by the V(ownca) provider.
type: int type: int
default: 3 default: 3
choices:
- 3
ownca_not_before: ownca_not_before:
description: description:
@@ -309,7 +226,6 @@ options:
ignored. ignored.
- A value of V(never_create) never creates a SKI. If the CSR provides one, that one is used. - A value of V(never_create) never creates a SKI. If the CSR provides one, that one is used.
- This is only used by the V(ownca) provider. - This is only used by the V(ownca) provider.
- Note that this is only supported if the C(cryptography) backend is used!
type: str type: str
choices: [create_if_not_provided, always_create, never_create] choices: [create_if_not_provided, always_create, never_create]
default: create_if_not_provided default: create_if_not_provided
@@ -321,7 +237,6 @@ options:
- The Authority Key Identifier is generated from the CA certificate's Subject Key Identifier, - The Authority Key Identifier is generated from the CA certificate's Subject Key Identifier,
if available. If it is not available, the CA certificate's public key will be used. if available. If it is not available, the CA certificate's public key will be used.
- This is only used by the V(ownca) provider. - This is only used by the V(ownca) provider.
- Note that this is only supported if the C(cryptography) backend is used!
type: bool type: bool
default: true default: true
""" """
@@ -355,6 +270,8 @@ options:
- This is only used by the V(selfsigned) provider. - This is only used by the V(selfsigned) provider.
type: int type: int
default: 3 default: 3
choices:
- 3
selfsigned_digest: selfsigned_digest:
description: description:
@@ -377,7 +294,8 @@ options:
- This is only used by the V(selfsigned) provider. - This is only used by the V(selfsigned) provider.
type: str type: str
default: +0s default: +0s
aliases: [ selfsigned_notBefore ] aliases:
- selfsigned_notBefore
selfsigned_not_after: selfsigned_not_after:
description: description:
@@ -395,7 +313,8 @@ options:
Please see U(https://support.apple.com/en-us/HT210176) for more details. Please see U(https://support.apple.com/en-us/HT210176) for more details.
type: str type: str
default: +3650d default: +3650d
aliases: [ selfsigned_notAfter ] aliases:
- selfsigned_notAfter
selfsigned_create_subject_key_identifier: selfsigned_create_subject_key_identifier:
description: description:
@@ -406,7 +325,6 @@ options:
ignored. ignored.
- A value of V(never_create) never creates a SKI. If the CSR provides one, that one is used. - A value of V(never_create) never creates a SKI. If the CSR provides one, that one is used.
- This is only used by the V(selfsigned) provider. - This is only used by the V(selfsigned) provider.
- Note that this is only supported if the C(cryptography) backend is used!
type: str type: str
choices: [create_if_not_provided, always_create, never_create] choices: [create_if_not_provided, always_create, never_create]
default: create_if_not_provided default: create_if_not_provided

View File

@@ -1,17 +1,14 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Yanis Guenane <yanis+ansible@guenane.org> # Copyright (c) 2017, Yanis Guenane <yanis+ansible@guenane.org>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this doc fragment is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
__metaclass__ = type class ModuleDocFragment:
class ModuleDocFragment(object):
# Standard files documentation fragment # Standard files documentation fragment
DOCUMENTATION = r""" DOCUMENTATION = r"""
description: description:
@@ -23,7 +20,7 @@ attributes:
idempotent: idempotent:
support: full support: full
requirements: requirements:
- cryptography >= 1.3 - cryptography >= 3.3
options: options:
digest: digest:
description: description:
@@ -75,37 +72,51 @@ options:
description: description:
- The countryName field of the certificate signing request subject. - The countryName field of the certificate signing request subject.
type: str type: str
aliases: [C, countryName] aliases:
- C
- countryName
state_or_province_name: state_or_province_name:
description: description:
- The stateOrProvinceName field of the certificate signing request subject. - The stateOrProvinceName field of the certificate signing request subject.
type: str type: str
aliases: [ST, stateOrProvinceName] aliases:
- ST
- stateOrProvinceName
locality_name: locality_name:
description: description:
- The localityName field of the certificate signing request subject. - The localityName field of the certificate signing request subject.
type: str type: str
aliases: [L, localityName] aliases:
- L
- localityName
organization_name: organization_name:
description: description:
- The organizationName field of the certificate signing request subject. - The organizationName field of the certificate signing request subject.
type: str type: str
aliases: [O, organizationName] aliases:
- O
- organizationName
organizational_unit_name: organizational_unit_name:
description: description:
- The organizationalUnitName field of the certificate signing request subject. - The organizationalUnitName field of the certificate signing request subject.
type: str type: str
aliases: [OU, organizationalUnitName] aliases:
- OU
- organizationalUnitName
common_name: common_name:
description: description:
- The commonName field of the certificate signing request subject. - The commonName field of the certificate signing request subject.
type: str type: str
aliases: [CN, commonName] aliases:
- CN
- commonName
email_address: email_address:
description: description:
- The emailAddress field of the certificate signing request subject. - The emailAddress field of the certificate signing request subject.
type: str type: str
aliases: [E, emailAddress] aliases:
- E
- emailAddress
subject_alt_name: subject_alt_name:
description: description:
- Subject Alternative Name (SAN) extension to attach to the certificate signing request. - Subject Alternative Name (SAN) extension to attach to the certificate signing request.
@@ -116,63 +127,75 @@ options:
- More at U(https://tools.ietf.org/html/rfc5280#section-4.2.1.6). - More at U(https://tools.ietf.org/html/rfc5280#section-4.2.1.6).
type: list type: list
elements: str elements: str
aliases: [subjectAltName] aliases:
- subjectAltName
subject_alt_name_critical: subject_alt_name_critical:
description: description:
- Should the subjectAltName extension be considered as critical. - Should the subjectAltName extension be considered as critical.
type: bool type: bool
default: false default: false
aliases: [subjectAltName_critical] aliases:
- subjectAltName_critical
use_common_name_for_san: use_common_name_for_san:
description: description:
- If set to V(true), the module will fill the common name in for O(subject_alt_name) with C(DNS:) prefix if no SAN is - If set to V(true), the module will fill the common name in for O(subject_alt_name) with C(DNS:) prefix if no SAN is
specified. specified.
type: bool type: bool
default: true default: true
aliases: [useCommonNameForSAN] aliases:
- useCommonNameForSAN
key_usage: key_usage:
description: description:
- This defines the purpose (for example encipherment, signature, certificate signing) of the key contained in the certificate. - This defines the purpose (for example encipherment, signature, certificate signing) of the key contained in the certificate.
type: list type: list
elements: str elements: str
aliases: [keyUsage] aliases:
- keyUsage
key_usage_critical: key_usage_critical:
description: description:
- Should the keyUsage extension be considered as critical. - Should the keyUsage extension be considered as critical.
type: bool type: bool
default: false default: false
aliases: [keyUsage_critical] aliases:
- keyUsage_critical
extended_key_usage: extended_key_usage:
description: description:
- Additional restrictions (for example client authentication, server authentication) on the allowed purposes for which - Additional restrictions (for example client authentication, server authentication) on the allowed purposes for which
the public key may be used. the public key may be used.
type: list type: list
elements: str elements: str
aliases: [extKeyUsage, extendedKeyUsage] aliases:
- extKeyUsage
- extendedKeyUsage
extended_key_usage_critical: extended_key_usage_critical:
description: description:
- Should the extkeyUsage extension be considered as critical. - Should the extkeyUsage extension be considered as critical.
type: bool type: bool
default: false default: false
aliases: [extKeyUsage_critical, extendedKeyUsage_critical] aliases:
- extKeyUsage_critical
- extendedKeyUsage_critical
basic_constraints: basic_constraints:
description: description:
- Indicates basic constraints, such as if the certificate is a CA. - Indicates basic constraints, such as if the certificate is a CA.
type: list type: list
elements: str elements: str
aliases: [basicConstraints] aliases:
- basicConstraints
basic_constraints_critical: basic_constraints_critical:
description: description:
- Should the basicConstraints extension be considered as critical. - Should the basicConstraints extension be considered as critical.
type: bool type: bool
default: false default: false
aliases: [basicConstraints_critical] aliases:
- basicConstraints_critical
ocsp_must_staple: ocsp_must_staple:
description: description:
- Indicates that the certificate should contain the OCSP Must Staple extension (U(https://tools.ietf.org/html/rfc7633)). - Indicates that the certificate should contain the OCSP Must Staple extension (U(https://tools.ietf.org/html/rfc7633)).
type: bool type: bool
default: false default: false
aliases: [ocspMustStaple] aliases:
- ocspMustStaple
ocsp_must_staple_critical: ocsp_must_staple_critical:
description: description:
- Should the OCSP Must Staple extension be considered as critical. - Should the OCSP Must Staple extension be considered as critical.
@@ -180,7 +203,8 @@ options:
OCSP Must Staple are required to reject such certificates (see U(https://tools.ietf.org/html/rfc7633#section-4)). OCSP Must Staple are required to reject such certificates (see U(https://tools.ietf.org/html/rfc7633#section-4)).
type: bool type: bool
default: false default: false
aliases: [ocspMustStaple_critical] aliases:
- ocspMustStaple_critical
name_constraints_permitted: name_constraints_permitted:
description: description:
- For CA certificates, this specifies a list of identifiers which describe subtrees of names that this CA is allowed - For CA certificates, this specifies a list of identifiers which describe subtrees of names that this CA is allowed
@@ -207,6 +231,9 @@ options:
- Determines which crypto backend to use. - Determines which crypto backend to use.
- The default choice is V(auto), which tries to use C(cryptography) if available. - The default choice is V(auto), which tries to use C(cryptography) if available.
- If set to V(cryptography), will try to use the L(cryptography,https://cryptography.io/) library. - If set to V(cryptography), will try to use the L(cryptography,https://cryptography.io/) library.
- Note that with community.crypto 3.0.0, all values behave the same.
This option will be deprecated in a later version.
We recommend to not set it explicitly.
type: str type: str
default: auto default: auto
choices: [auto, cryptography] choices: [auto, cryptography]
@@ -215,7 +242,6 @@ options:
- Create the Subject Key Identifier from the public key. - Create the Subject Key Identifier from the public key.
- Please note that commercial CAs can ignore the value, respectively use a value of their own choice instead. Specifying - Please note that commercial CAs can ignore the value, respectively use a value of their own choice instead. Specifying
this option is mostly useful for self-signed certificates or for own CAs. this option is mostly useful for self-signed certificates or for own CAs.
- Note that this is only supported if the C(cryptography) backend is used!
type: bool type: bool
default: false default: false
subject_key_identifier: subject_key_identifier:
@@ -225,7 +251,6 @@ options:
- Please note that commercial CAs ignore this value, respectively use a value of their own choice. Specifying this option - Please note that commercial CAs ignore this value, respectively use a value of their own choice. Specifying this option
is mostly useful for self-signed certificates or for own CAs. is mostly useful for self-signed certificates or for own CAs.
- Note that this option can only be used if O(create_subject_key_identifier) is V(false). - Note that this option can only be used if O(create_subject_key_identifier) is V(false).
- Note that this is only supported if the C(cryptography) backend is used!
type: str type: str
authority_key_identifier: authority_key_identifier:
description: description:
@@ -233,7 +258,6 @@ options:
- 'Example: V(00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33).' - 'Example: V(00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33).'
- Please note that commercial CAs ignore this value, respectively use a value of their own choice. Specifying this option - Please note that commercial CAs ignore this value, respectively use a value of their own choice. Specifying this option
is mostly useful for self-signed certificates or for own CAs. is mostly useful for self-signed certificates or for own CAs.
- Note that this is only supported if the C(cryptography) backend is used!
- The C(AuthorityKeyIdentifier) extension will only be added if at least one of O(authority_key_identifier), O(authority_cert_issuer) - The C(AuthorityKeyIdentifier) extension will only be added if at least one of O(authority_key_identifier), O(authority_cert_issuer)
and O(authority_cert_serial_number) is specified. and O(authority_cert_serial_number) is specified.
type: str type: str
@@ -246,7 +270,6 @@ options:
- If specified, O(authority_cert_serial_number) must also be specified. - If specified, O(authority_cert_serial_number) must also be specified.
- Please note that commercial CAs ignore this value, respectively use a value of their own choice. Specifying this option - Please note that commercial CAs ignore this value, respectively use a value of their own choice. Specifying this option
is mostly useful for self-signed certificates or for own CAs. is mostly useful for self-signed certificates or for own CAs.
- Note that this is only supported if the C(cryptography) backend is used!
- The C(AuthorityKeyIdentifier) extension will only be added if at least one of O(authority_key_identifier), O(authority_cert_issuer) - The C(AuthorityKeyIdentifier) extension will only be added if at least one of O(authority_key_identifier), O(authority_cert_issuer)
and O(authority_cert_serial_number) is specified. and O(authority_cert_serial_number) is specified.
type: list type: list
@@ -255,7 +278,6 @@ options:
description: description:
- The authority cert serial number. - The authority cert serial number.
- If specified, O(authority_cert_issuer) must also be specified. - If specified, O(authority_cert_issuer) must also be specified.
- Note that this is only supported if the C(cryptography) backend is used!
- Please note that commercial CAs ignore this value, respectively use a value of their own choice. Specifying this option - Please note that commercial CAs ignore this value, respectively use a value of their own choice. Specifying this option
is mostly useful for self-signed certificates or for own CAs. is mostly useful for self-signed certificates or for own CAs.
- The C(AuthorityKeyIdentifier) extension will only be added if at least one of O(authority_key_identifier), O(authority_cert_issuer) - The C(AuthorityKeyIdentifier) extension will only be added if at least one of O(authority_key_identifier), O(authority_cert_issuer)
@@ -266,7 +288,6 @@ options:
crl_distribution_points: crl_distribution_points:
description: description:
- Allows to specify one or multiple CRL distribution points. - Allows to specify one or multiple CRL distribution points.
- Only supported by the C(cryptography) backend.
type: list type: list
elements: dict elements: dict
suboptions: suboptions:
@@ -282,7 +303,6 @@ options:
- Describes how the CRL can be retrieved relative to the CRL issuer. - Describes how the CRL can be retrieved relative to the CRL issuer.
- Mutually exclusive with O(crl_distribution_points[].full_name). - Mutually exclusive with O(crl_distribution_points[].full_name).
- 'Example: V(/CN=example.com).' - 'Example: V(/CN=example.com).'
- Can only be used when cryptography >= 1.6 is installed.
type: list type: list
elements: str elements: str
crl_issuer: crl_issuer:

View File

@@ -1,17 +1,14 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016, Yanis Guenane <yanis+ansible@guenane.org> # Copyright (c) 2016, Yanis Guenane <yanis+ansible@guenane.org>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this doc fragment is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
__metaclass__ = type class ModuleDocFragment:
class ModuleDocFragment(object):
# Standard files documentation fragment # Standard files documentation fragment
DOCUMENTATION = r""" DOCUMENTATION = r"""
description: description:
@@ -27,7 +24,7 @@ attributes:
details: details:
- The option O(regenerate=always) generally disables idempotency. - The option O(regenerate=always) generally disables idempotency.
requirements: requirements:
- cryptography >= 1.2.3 (older versions might work as well) - cryptography >= 3.3
options: options:
size: size:
description: description:
@@ -37,9 +34,6 @@ options:
type: type:
description: description:
- The algorithm used to generate the TLS/SSL private key. - The algorithm used to generate the TLS/SSL private key.
- Note that V(ECC), V(X25519), V(X448), V(Ed25519), and V(Ed448) require the C(cryptography) backend. V(X25519) needs
cryptography 2.5 or newer, while V(X448), V(Ed25519), and V(Ed448) require cryptography 2.6 or newer. For V(ECC),
the minimal cryptography version required depends on the O(curve) option.
type: str type: str
default: RSA default: RSA
choices: [DSA, ECC, Ed25519, Ed448, RSA, X25519, X448] choices: [DSA, ECC, Ed25519, Ed448, RSA, X25519, X448]
@@ -86,6 +80,9 @@ options:
- Determines which crypto backend to use. - Determines which crypto backend to use.
- The default choice is V(auto), which tries to use C(cryptography) if available. - The default choice is V(auto), which tries to use C(cryptography) if available.
- If set to V(cryptography), will try to use the L(cryptography,https://cryptography.io/) library. - If set to V(cryptography), will try to use the L(cryptography,https://cryptography.io/) library.
- Note that with community.crypto 3.0.0, all values behave the same.
This option will be deprecated in a later version.
We recommend to not set it explicitly.
type: str type: str
default: auto default: auto
choices: [auto, cryptography] choices: [auto, cryptography]
@@ -106,7 +103,6 @@ options:
parameters are as expected. parameters are as expected.
- If set to V(regenerate) (default), generates a new private key. - If set to V(regenerate) (default), generates a new private key.
- If set to V(convert), the key will be converted to the new format instead. - If set to V(convert), the key will be converted to the new format instead.
- Only supported by the C(cryptography) backend.
type: str type: str
default: regenerate default: regenerate
choices: [regenerate, convert] choices: [regenerate, convert]

View File

@@ -1,21 +1,18 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2022, Felix Fontein <felix@fontein.de> # Copyright (c) 2022, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this doc fragment is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
__metaclass__ = type class ModuleDocFragment:
class ModuleDocFragment(object):
# Standard files documentation fragment # Standard files documentation fragment
DOCUMENTATION = r""" DOCUMENTATION = r"""
requirements: requirements:
- cryptography >= 1.2.3 (older versions might work as well) - cryptography >= 3.3
attributes: attributes:
diff_mode: diff_mode:
support: none support: none

View File

@@ -1,16 +1,14 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2022, Felix Fontein <felix@fontein.de> # Copyright (c) 2022, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this doc fragment is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
__metaclass__ = type class ModuleDocFragment:
class ModuleDocFragment(object):
DOCUMENTATION = r""" DOCUMENTATION = r"""
options: options:
name_encoding: name_encoding:

View File

@@ -1,46 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c), Entrust Datacard Corporation, 2019
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
class ModuleDocFragment(object):
# Plugin options for Entrust Certificate Services (ECS) credentials
DOCUMENTATION = r"""
options:
entrust_api_user:
description:
- The username for authentication to the Entrust Certificate Services (ECS) API.
type: str
required: true
entrust_api_key:
description:
- The key (password) for authentication to the Entrust Certificate Services (ECS) API.
type: str
required: true
entrust_api_client_cert_path:
description:
- The path to the client certificate used to authenticate to the Entrust Certificate Services (ECS) API.
type: path
required: true
entrust_api_client_cert_key_path:
description:
- The path to the key for the client certificate used to authenticate to the Entrust Certificate Services (ECS) API.
type: path
required: true
entrust_api_specification_path:
description:
- The path to the specification file defining the Entrust Certificate Services (ECS) API configuration.
- You can use this to keep a local copy of the specification to avoid downloading it every time the module is used.
type: path
default: https://cloud.entrust.net/EntrustCloud/documentation/cms-api-2.1.0.yaml
requirements:
- "PyYAML >= 3.11"
"""

View File

@@ -1,13 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2023, Felix Fontein <felix@fontein.de> # Copyright (c) 2023, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function from __future__ import annotations
__metaclass__ = type
DOCUMENTATION = r""" DOCUMENTATION = r"""
name: gpg_fingerprint name: gpg_fingerprint
short_description: Retrieve a GPG fingerprint from a GPG public or private key short_description: Retrieve a GPG fingerprint from a GPG public or private key
@@ -42,36 +39,37 @@ _value:
type: string type: string
""" """
import typing as t
from ansible.errors import AnsibleFilterError from ansible.errors import AnsibleFilterError
from ansible.module_utils.common.text.converters import to_bytes, to_native from ansible.module_utils.common.text.converters import to_bytes
from ansible.module_utils.six import string_types from ansible_collections.community.crypto.plugins.module_utils._gnupg.cli import (
from ansible_collections.community.crypto.plugins.module_utils.gnupg.cli import (
GPGError, GPGError,
get_fingerprint_from_bytes, get_fingerprint_from_bytes,
) )
from ansible_collections.community.crypto.plugins.plugin_utils.gnupg import ( from ansible_collections.community.crypto.plugins.plugin_utils._gnupg import (
PluginGPGRunner, PluginGPGRunner,
) )
def gpg_fingerprint(input): def gpg_fingerprint(gpg_key_content: str | bytes) -> str:
if not isinstance(input, string_types): if not isinstance(gpg_key_content, (str, bytes)):
raise AnsibleFilterError( raise AnsibleFilterError(
"The input for the community.crypto.gpg_fingerprint filter must be a string; got {type} instead".format( f"The input for the community.crypto.gpg_fingerprint filter must be a string; got {type(gpg_key_content)} instead"
type=type(input)
)
) )
try: try:
gpg = PluginGPGRunner() gpg = PluginGPGRunner()
return get_fingerprint_from_bytes(gpg, to_bytes(input)) return get_fingerprint_from_bytes(
gpg_runner=gpg, content=to_bytes(gpg_key_content)
)
except GPGError as exc: except GPGError as exc:
raise AnsibleFilterError(to_native(exc)) raise AnsibleFilterError(str(exc)) from exc
class FilterModule(object): class FilterModule:
"""Ansible jinja2 filters""" """Ansible jinja2 filters"""
def filters(self): def filters(self) -> dict[str, t.Callable]:
return { return {
"gpg_fingerprint": gpg_fingerprint, "gpg_fingerprint": gpg_fingerprint,
} }

View File

@@ -1,14 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2022, Felix Fontein <felix@fontein.de> # Copyright (c) 2022, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function from __future__ import annotations
__metaclass__ = type
DOCUMENTATION = r""" DOCUMENTATION = r"""
name: openssl_csr_info name: openssl_csr_info
short_description: Retrieve information from OpenSSL Certificate Signing Requests (CSR) short_description: Retrieve information from OpenSSL Certificate Signing Requests (CSR)
@@ -25,7 +21,7 @@ options:
type: string type: string
required: true required: true
extends_documentation_fragment: extends_documentation_fragment:
- community.crypto.name_encoding - community.crypto._name_encoding
seealso: seealso:
- module: community.crypto.openssl_csr_info - module: community.crypto.openssl_csr_info
- plugin: community.crypto.to_serial - plugin: community.crypto.to_serial
@@ -278,52 +274,54 @@ _value:
sample: 12345 sample: 12345
""" """
import typing as t
from ansible.errors import AnsibleFilterError from ansible.errors import AnsibleFilterError
from ansible.module_utils.common.text.converters import to_bytes, to_native from ansible.module_utils.common.text.converters import to_bytes, to_text
from ansible.module_utils.six import string_types from ansible_collections.community.crypto.plugins.module_utils._crypto.basic import (
from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import (
OpenSSLObjectError, OpenSSLObjectError,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.csr_info import ( from ansible_collections.community.crypto.plugins.module_utils._crypto.module_backends.csr_info import (
get_csr_info, get_csr_info,
) )
from ansible_collections.community.crypto.plugins.plugin_utils.filter_module import ( from ansible_collections.community.crypto.plugins.plugin_utils._filter_module import (
FilterModuleMock, FilterModuleMock,
) )
def openssl_csr_info_filter(data, name_encoding="ignore"): def openssl_csr_info_filter(
data: str | bytes, name_encoding: t.Literal["ignore", "idna", "unicode"] = "ignore"
) -> dict[str, t.Any]:
"""Extract information from X.509 PEM certificate.""" """Extract information from X.509 PEM certificate."""
if not isinstance(data, string_types): if not isinstance(data, (str, bytes)):
raise AnsibleFilterError( raise AnsibleFilterError(
"The community.crypto.openssl_csr_info input must be a text type, not %s" f"The community.crypto.openssl_csr_info input must be a text type, not {type(data)}"
% type(data)
) )
if not isinstance(name_encoding, string_types): if not isinstance(name_encoding, (str, bytes)):
raise AnsibleFilterError( raise AnsibleFilterError(
"The name_encoding option must be of a text type, not %s" f"The name_encoding option must be of a text type, not {type(name_encoding)}"
% type(name_encoding)
) )
name_encoding = to_native(name_encoding) name_encoding = t.cast(
t.Literal["ignore", "idna", "unicode"], to_text(name_encoding)
)
if name_encoding not in ("ignore", "idna", "unicode"): if name_encoding not in ("ignore", "idna", "unicode"):
raise AnsibleFilterError( raise AnsibleFilterError(
'The name_encoding option must be one of the values "ignore", "idna", or "unicode", not "%s"' f'The name_encoding option must be one of the values "ignore", "idna", or "unicode", not "{name_encoding}"'
% name_encoding
) )
module = FilterModuleMock({"name_encoding": name_encoding}) module = FilterModuleMock({"name_encoding": name_encoding})
try: try:
return get_csr_info( return get_csr_info(
module, "cryptography", content=to_bytes(data), validate_signature=True module=module, content=to_bytes(data), validate_signature=True
) )
except OpenSSLObjectError as exc: except OpenSSLObjectError as exc:
raise AnsibleFilterError(to_native(exc)) raise AnsibleFilterError(str(exc)) from exc
class FilterModule(object): class FilterModule:
"""Ansible jinja2 filters""" """Ansible jinja2 filters"""
def filters(self): def filters(self) -> dict[str, t.Callable]:
return { return {
"openssl_csr_info": openssl_csr_info_filter, "openssl_csr_info": openssl_csr_info_filter,
} }

View File

@@ -1,14 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2022, Felix Fontein <felix@fontein.de> # Copyright (c) 2022, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function from __future__ import annotations
__metaclass__ = type
DOCUMENTATION = r""" DOCUMENTATION = r"""
name: openssl_privatekey_info name: openssl_privatekey_info
short_description: Retrieve information from OpenSSL private keys short_description: Retrieve information from OpenSSL private keys
@@ -36,7 +32,7 @@ options:
type: bool type: bool
default: false default: false
extends_documentation_fragment: extends_documentation_fragment:
- community.crypto.name_encoding - community.crypto._name_encoding
seealso: seealso:
- module: community.crypto.openssl_privatekey_info - module: community.crypto.openssl_privatekey_info
""" """
@@ -150,62 +146,62 @@ _value:
type: dict type: dict
""" """
import typing as t
from ansible.errors import AnsibleFilterError from ansible.errors import AnsibleFilterError
from ansible.module_utils.common.text.converters import to_bytes, to_native from ansible.module_utils.common.text.converters import to_bytes, to_text
from ansible.module_utils.six import string_types from ansible_collections.community.crypto.plugins.module_utils._crypto.basic import (
from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import (
OpenSSLObjectError, OpenSSLObjectError,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.privatekey_info import ( from ansible_collections.community.crypto.plugins.module_utils._crypto.module_backends.privatekey_info import (
PrivateKeyParseError, PrivateKeyParseError,
get_privatekey_info, get_privatekey_info,
) )
from ansible_collections.community.crypto.plugins.plugin_utils.filter_module import ( from ansible_collections.community.crypto.plugins.plugin_utils._filter_module import (
FilterModuleMock, FilterModuleMock,
) )
def openssl_privatekey_info_filter( def openssl_privatekey_info_filter(
data, passphrase=None, return_private_key_data=False data: str | bytes,
): passphrase: str | bytes | None = None,
return_private_key_data: bool = False,
) -> dict[str, t.Any]:
"""Extract information from X.509 PEM certificate.""" """Extract information from X.509 PEM certificate."""
if not isinstance(data, string_types): if not isinstance(data, (str, bytes)):
raise AnsibleFilterError( raise AnsibleFilterError(
"The community.crypto.openssl_privatekey_info input must be a text type, not %s" f"The community.crypto.openssl_privatekey_info input must be a text type, not {type(data)}"
% type(data)
) )
if passphrase is not None and not isinstance(passphrase, string_types): if passphrase is not None and not isinstance(passphrase, (str, bytes)):
raise AnsibleFilterError( raise AnsibleFilterError(
"The passphrase option must be a text type, not %s" % type(passphrase) f"The passphrase option must be a text type, not {type(passphrase)}"
) )
if not isinstance(return_private_key_data, bool): if not isinstance(return_private_key_data, bool):
raise AnsibleFilterError( raise AnsibleFilterError(
"The return_private_key_data option must be a boolean, not %s" f"The return_private_key_data option must be a boolean, not {type(return_private_key_data)}"
% type(return_private_key_data)
) )
module = FilterModuleMock({}) module = FilterModuleMock({})
try: try:
result = get_privatekey_info( result = get_privatekey_info(
module, module=module,
"cryptography",
content=to_bytes(data), content=to_bytes(data),
passphrase=passphrase, passphrase=to_text(passphrase) if passphrase is not None else None,
return_private_key_data=return_private_key_data, return_private_key_data=return_private_key_data,
) )
result.pop("can_parse_key", None) result.pop("can_parse_key", None)
result.pop("key_is_consistent", None) result.pop("key_is_consistent", None)
return result return result
except PrivateKeyParseError as exc: except PrivateKeyParseError as exc:
raise AnsibleFilterError(exc.error_message) raise AnsibleFilterError(exc.error_message) from exc
except OpenSSLObjectError as exc: except OpenSSLObjectError as exc:
raise AnsibleFilterError(to_native(exc)) raise AnsibleFilterError(str(exc)) from exc
class FilterModule(object): class FilterModule:
"""Ansible jinja2 filters""" """Ansible jinja2 filters"""
def filters(self): def filters(self) -> dict[str, t.Callable]:
return { return {
"openssl_privatekey_info": openssl_privatekey_info_filter, "openssl_privatekey_info": openssl_privatekey_info_filter,
} }

View File

@@ -1,14 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2022, Felix Fontein <felix@fontein.de> # Copyright (c) 2022, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function from __future__ import annotations
__metaclass__ = type
DOCUMENTATION = r""" DOCUMENTATION = r"""
name: openssl_publickey_info name: openssl_publickey_info
short_description: Retrieve information from OpenSSL public keys in PEM format short_description: Retrieve information from OpenSSL public keys in PEM format
@@ -127,42 +123,42 @@ _value:
returned: When RV(_value.type=DSA) or RV(_value.type=ECC) returned: When RV(_value.type=DSA) or RV(_value.type=ECC)
""" """
import typing as t
from ansible.errors import AnsibleFilterError from ansible.errors import AnsibleFilterError
from ansible.module_utils.common.text.converters import to_bytes, to_native from ansible.module_utils.common.text.converters import to_bytes
from ansible.module_utils.six import string_types from ansible_collections.community.crypto.plugins.module_utils._crypto.basic import (
from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import (
OpenSSLObjectError, OpenSSLObjectError,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.publickey_info import ( from ansible_collections.community.crypto.plugins.module_utils._crypto.module_backends.publickey_info import (
PublicKeyParseError, PublicKeyParseError,
get_publickey_info, get_publickey_info,
) )
from ansible_collections.community.crypto.plugins.plugin_utils.filter_module import ( from ansible_collections.community.crypto.plugins.plugin_utils._filter_module import (
FilterModuleMock, FilterModuleMock,
) )
def openssl_publickey_info_filter(data): def openssl_publickey_info_filter(data: str | bytes) -> dict[str, t.Any]:
"""Extract information from OpenSSL PEM public key.""" """Extract information from OpenSSL PEM public key."""
if not isinstance(data, string_types): if not isinstance(data, (str, bytes)):
raise AnsibleFilterError( raise AnsibleFilterError(
"The community.crypto.openssl_publickey_info input must be a text type, not %s" f"The community.crypto.openssl_publickey_info input must be a text type, not {type(data)}"
% type(data)
) )
module = FilterModuleMock({}) module = FilterModuleMock({})
try: try:
return get_publickey_info(module, "cryptography", content=to_bytes(data)) return get_publickey_info(module=module, content=to_bytes(data))
except PublicKeyParseError as exc: except PublicKeyParseError as exc:
raise AnsibleFilterError(exc.error_message) raise AnsibleFilterError(exc.error_message) from exc
except OpenSSLObjectError as exc: except OpenSSLObjectError as exc:
raise AnsibleFilterError(to_native(exc)) raise AnsibleFilterError(str(exc)) from exc
class FilterModule(object): class FilterModule:
"""Ansible jinja2 filters""" """Ansible jinja2 filters"""
def filters(self): def filters(self) -> dict[str, t.Callable]:
return { return {
"openssl_publickey_info": openssl_publickey_info_filter, "openssl_publickey_info": openssl_publickey_info_filter,
} }

View File

@@ -1,13 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2024, Felix Fontein <felix@fontein.de> # Copyright (c) 2024, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function from __future__ import annotations
__metaclass__ = type
DOCUMENTATION = r""" DOCUMENTATION = r"""
name: parse_serial name: parse_serial
short_description: Convert a serial number as a colon-separated list of hex numbers to an integer short_description: Convert a serial number as a colon-separated list of hex numbers to an integer
@@ -42,31 +39,30 @@ _value:
type: int type: int
""" """
import typing as t
from ansible.errors import AnsibleFilterError from ansible.errors import AnsibleFilterError
from ansible.module_utils.common.text.converters import to_native from ansible.module_utils.common.text.converters import to_text
from ansible.module_utils.six import string_types from ansible_collections.community.crypto.plugins.module_utils._serial import (
from ansible_collections.community.crypto.plugins.module_utils.serial import (
parse_serial, parse_serial,
) )
def parse_serial_filter(input): def parse_serial_filter(serial_str: str | bytes) -> int:
if not isinstance(input, string_types): if not isinstance(serial_str, (str, bytes)):
raise AnsibleFilterError( raise AnsibleFilterError(
"The input for the community.crypto.parse_serial filter must be a string; got {type} instead".format( f"The input for the community.crypto.parse_serial filter must be a string; got {type(serial_str)} instead"
type=type(input)
)
) )
try: try:
return parse_serial(to_native(input)) return parse_serial(to_text(serial_str))
except ValueError as exc: except ValueError as exc:
raise AnsibleFilterError(to_native(exc)) raise AnsibleFilterError(str(exc)) from exc
class FilterModule(object): class FilterModule:
"""Ansible jinja2 filters""" """Ansible jinja2 filters"""
def filters(self): def filters(self) -> dict[str, t.Callable]:
return { return {
"parse_serial": parse_serial_filter, "parse_serial": parse_serial_filter,
} }

View File

@@ -1,14 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2022, Felix Fontein <felix@fontein.de> # Copyright (c) 2022, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function from __future__ import annotations
__metaclass__ = type
DOCUMENTATION = r""" DOCUMENTATION = r"""
name: split_pem name: split_pem
short_description: Split PEM file contents into multiple objects short_description: Split PEM file contents into multiple objects
@@ -42,30 +38,29 @@ _value:
elements: string elements: string
""" """
import typing as t
from ansible.errors import AnsibleFilterError from ansible.errors import AnsibleFilterError
from ansible.module_utils.common.text.converters import to_text from ansible.module_utils.common.text.converters import to_text
from ansible.module_utils.six import string_types from ansible_collections.community.crypto.plugins.module_utils._crypto.pem import (
from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import (
split_pem_list, split_pem_list,
) )
def split_pem_filter(data): def split_pem_filter(data: str | bytes) -> list[str]:
"""Split PEM file.""" """Split PEM file."""
if not isinstance(data, string_types): if not isinstance(data, (str, bytes)):
raise AnsibleFilterError( raise AnsibleFilterError(
"The community.crypto.split_pem input must be a text type, not %s" f"The community.crypto.split_pem input must be a text type, not {type(data)}"
% type(data)
) )
data = to_text(data) return split_pem_list(to_text(data))
return split_pem_list(data)
class FilterModule(object): class FilterModule:
"""Ansible jinja2 filters""" """Ansible jinja2 filters"""
def filters(self): def filters(self) -> dict[str, t.Callable]:
return { return {
"split_pem": split_pem_filter, "split_pem": split_pem_filter,
} }

View File

@@ -1,13 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2024, Felix Fontein <felix@fontein.de> # Copyright (c) 2024, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function from __future__ import annotations
__metaclass__ = type
DOCUMENTATION = r""" DOCUMENTATION = r"""
name: to_serial name: to_serial
short_description: Convert an integer to a colon-separated list of hex numbers short_description: Convert an integer to a colon-separated list of hex numbers
@@ -42,33 +39,31 @@ _value:
type: string type: string
""" """
import typing as t
from ansible.errors import AnsibleFilterError from ansible.errors import AnsibleFilterError
from ansible.module_utils.common.text.converters import to_native from ansible_collections.community.crypto.plugins.module_utils._serial import to_serial
from ansible.module_utils.six import integer_types
from ansible_collections.community.crypto.plugins.module_utils.serial import to_serial
def to_serial_filter(input): def to_serial_filter(serial_int: int) -> str:
if not isinstance(input, integer_types): if not isinstance(serial_int, int):
raise AnsibleFilterError( raise AnsibleFilterError(
"The input for the community.crypto.to_serial filter must be an integer; got {type} instead".format( f"The input for the community.crypto.to_serial filter must be an integer; got {type(serial_int)} instead"
type=type(input)
)
) )
if input < 0: if serial_int < 0:
raise AnsibleFilterError( raise AnsibleFilterError(
"The input for the community.crypto.to_serial filter must not be negative" "The input for the community.crypto.to_serial filter must not be negative"
) )
try: try:
return to_serial(input) return to_serial(serial_int)
except ValueError as exc: except ValueError as exc:
raise AnsibleFilterError(to_native(exc)) raise AnsibleFilterError(str(exc)) from exc
class FilterModule(object): class FilterModule:
"""Ansible jinja2 filters""" """Ansible jinja2 filters"""
def filters(self): def filters(self) -> dict[str, t.Callable]:
return { return {
"to_serial": to_serial_filter, "to_serial": to_serial_filter,
} }

View File

@@ -1,14 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2022, Felix Fontein <felix@fontein.de> # Copyright (c) 2022, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function from __future__ import annotations
__metaclass__ = type
DOCUMENTATION = r""" DOCUMENTATION = r"""
name: x509_certificate_info name: x509_certificate_info
short_description: Retrieve information from X.509 certificates in PEM format short_description: Retrieve information from X.509 certificates in PEM format
@@ -25,7 +21,7 @@ options:
type: string type: string
required: true required: true
extends_documentation_fragment: extends_documentation_fragment:
- community.crypto.name_encoding - community.crypto._name_encoding
seealso: seealso:
- module: community.crypto.x509_certificate_info - module: community.crypto.x509_certificate_info
- plugin: community.crypto.to_serial - plugin: community.crypto.to_serial
@@ -312,50 +308,52 @@ _value:
type: str type: str
""" """
import typing as t
from ansible.errors import AnsibleFilterError from ansible.errors import AnsibleFilterError
from ansible.module_utils.common.text.converters import to_bytes, to_native from ansible.module_utils.common.text.converters import to_bytes, to_text
from ansible.module_utils.six import string_types from ansible_collections.community.crypto.plugins.module_utils._crypto.basic import (
from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import (
OpenSSLObjectError, OpenSSLObjectError,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate_info import ( from ansible_collections.community.crypto.plugins.module_utils._crypto.module_backends.certificate_info import (
get_certificate_info, get_certificate_info,
) )
from ansible_collections.community.crypto.plugins.plugin_utils.filter_module import ( from ansible_collections.community.crypto.plugins.plugin_utils._filter_module import (
FilterModuleMock, FilterModuleMock,
) )
def x509_certificate_info_filter(data, name_encoding="ignore"): def x509_certificate_info_filter(
data: str | bytes, name_encoding: t.Literal["ignore", "idna", "unicode"] = "ignore"
) -> dict[str, t.Any]:
"""Extract information from X.509 PEM certificate.""" """Extract information from X.509 PEM certificate."""
if not isinstance(data, string_types): if not isinstance(data, (str, bytes)):
raise AnsibleFilterError( raise AnsibleFilterError(
"The community.crypto.x509_certificate_info input must be a text type, not %s" f"The community.crypto.x509_certificate_info input must be a text type, not {type(data)}"
% type(data)
) )
if not isinstance(name_encoding, string_types): if not isinstance(name_encoding, (str, bytes)):
raise AnsibleFilterError( raise AnsibleFilterError(
"The name_encoding option must be of a text type, not %s" f"The name_encoding option must be of a text type, not {type(name_encoding)}"
% type(name_encoding)
) )
name_encoding = to_native(name_encoding) name_encoding = t.cast(
t.Literal["ignore", "idna", "unicode"], to_text(name_encoding)
)
if name_encoding not in ("ignore", "idna", "unicode"): if name_encoding not in ("ignore", "idna", "unicode"):
raise AnsibleFilterError( raise AnsibleFilterError(
'The name_encoding option must be one of the values "ignore", "idna", or "unicode", not "%s"' f'The name_encoding option must be one of the values "ignore", "idna", or "unicode", not "{name_encoding}"'
% name_encoding
) )
module = FilterModuleMock({"name_encoding": name_encoding}) module = FilterModuleMock({"name_encoding": name_encoding})
try: try:
return get_certificate_info(module, "cryptography", content=to_bytes(data)) return get_certificate_info(module=module, content=to_bytes(data))
except OpenSSLObjectError as exc: except OpenSSLObjectError as exc:
raise AnsibleFilterError(to_native(exc)) raise AnsibleFilterError(str(exc)) from exc
class FilterModule(object): class FilterModule:
"""Ansible jinja2 filters""" """Ansible jinja2 filters"""
def filters(self): def filters(self) -> dict[str, t.Callable]:
return { return {
"x509_certificate_info": x509_certificate_info_filter, "x509_certificate_info": x509_certificate_info_filter,
} }

View File

@@ -1,14 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2022, Felix Fontein <felix@fontein.de> # Copyright (c) 2022, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function from __future__ import annotations
__metaclass__ = type
DOCUMENTATION = r""" DOCUMENTATION = r"""
name: x509_crl_info name: x509_crl_info
short_description: Retrieve information from X.509 CRLs in PEM format short_description: Retrieve information from X.509 CRLs in PEM format
@@ -33,7 +29,7 @@ options:
default: true default: true
version_added: 1.7.0 version_added: 1.7.0
extends_documentation_fragment: extends_documentation_fragment:
- community.crypto.name_encoding - community.crypto._name_encoding
seealso: seealso:
- module: community.crypto.x509_crl_info - module: community.crypto.x509_crl_info
- plugin: community.crypto.to_serial - plugin: community.crypto.to_serial
@@ -159,68 +155,72 @@ _value:
import base64 import base64
import binascii import binascii
import typing as t
from ansible.errors import AnsibleFilterError from ansible.errors import AnsibleFilterError
from ansible.module_utils.common.text.converters import to_bytes, to_native from ansible.module_utils.common.text.converters import to_bytes, to_text
from ansible.module_utils.six import string_types from ansible_collections.community.crypto.plugins.module_utils._crypto.basic import (
from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import (
OpenSSLObjectError, OpenSSLObjectError,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.crl_info import ( from ansible_collections.community.crypto.plugins.module_utils._crypto.module_backends.crl_info import (
get_crl_info, get_crl_info,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import ( from ansible_collections.community.crypto.plugins.module_utils._crypto.pem import (
identify_pem_format, identify_pem_format,
) )
from ansible_collections.community.crypto.plugins.plugin_utils.filter_module import ( from ansible_collections.community.crypto.plugins.plugin_utils._filter_module import (
FilterModuleMock, FilterModuleMock,
) )
def x509_crl_info_filter(data, name_encoding="ignore", list_revoked_certificates=True): def x509_crl_info_filter(
data: str | bytes,
name_encoding: t.Literal["ignore", "idna", "unicode"] = "ignore",
list_revoked_certificates: bool = True,
) -> dict[str, t.Any]:
"""Extract information from X.509 PEM certificate.""" """Extract information from X.509 PEM certificate."""
if not isinstance(data, string_types): if not isinstance(data, (str, bytes)):
raise AnsibleFilterError( raise AnsibleFilterError(
"The community.crypto.x509_crl_info input must be a text type, not %s" f"The community.crypto.x509_crl_info input must be a text type, not {type(data)}"
% type(data)
) )
if not isinstance(name_encoding, string_types): if not isinstance(name_encoding, (str, bytes)):
raise AnsibleFilterError( raise AnsibleFilterError(
"The name_encoding option must be of a text type, not %s" f"The name_encoding option must be of a text type, not {type(name_encoding)}"
% type(name_encoding)
) )
if not isinstance(list_revoked_certificates, bool): if not isinstance(list_revoked_certificates, bool):
raise AnsibleFilterError( raise AnsibleFilterError(
"The list_revoked_certificates option must be a boolean, not %s" f"The list_revoked_certificates option must be a boolean, not {type(list_revoked_certificates)}"
% type(list_revoked_certificates)
) )
name_encoding = to_native(name_encoding) name_encoding = t.cast(
t.Literal["ignore", "idna", "unicode"], to_text(name_encoding)
)
if name_encoding not in ("ignore", "idna", "unicode"): if name_encoding not in ("ignore", "idna", "unicode"):
raise AnsibleFilterError( raise AnsibleFilterError(
'The name_encoding option must be one of the values "ignore", "idna", or "unicode", not "%s"' f'The name_encoding option must be one of the values "ignore", "idna", or "unicode", not "{name_encoding}"'
% name_encoding
) )
data = to_bytes(data) data_bytes = to_bytes(data)
if not identify_pem_format(data): if not identify_pem_format(data_bytes):
try: try:
data = base64.b64decode(to_native(data)) data_bytes = base64.b64decode(to_text(data_bytes))
except (binascii.Error, TypeError, ValueError, UnicodeEncodeError): except (binascii.Error, TypeError, ValueError, UnicodeEncodeError):
pass pass
module = FilterModuleMock({"name_encoding": name_encoding}) module = FilterModuleMock({"name_encoding": name_encoding})
try: try:
return get_crl_info( return get_crl_info(
module, content=data, list_revoked_certificates=list_revoked_certificates module=module,
content=data_bytes,
list_revoked_certificates=list_revoked_certificates,
) )
except OpenSSLObjectError as exc: except OpenSSLObjectError as exc:
raise AnsibleFilterError(to_native(exc)) raise AnsibleFilterError(str(exc)) from exc
class FilterModule(object): class FilterModule:
"""Ansible jinja2 filters""" """Ansible jinja2 filters"""
def filters(self): def filters(self) -> dict[str, t.Callable]:
return { return {
"x509_crl_info": x509_crl_info_filter, "x509_crl_info": x509_crl_info_filter,
} }

View File

@@ -1,13 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2023, Felix Fontein <felix@fontein.de> # Copyright (c) 2023, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function from __future__ import annotations
__metaclass__ = type
DOCUMENTATION = r""" DOCUMENTATION = r"""
name: gpg_fingerprint name: gpg_fingerprint
short_description: Retrieve a GPG fingerprint from a GPG public or private key file short_description: Retrieve a GPG fingerprint from a GPG public or private key file
@@ -45,27 +42,42 @@ _value:
elements: string elements: string
""" """
import os
import typing as t
from ansible.errors import AnsibleLookupError from ansible.errors import AnsibleLookupError
from ansible.module_utils.common.text.converters import to_native from ansible.module_utils.common.text.converters import to_text
from ansible.plugins.lookup import LookupBase from ansible.plugins.lookup import LookupBase
from ansible_collections.community.crypto.plugins.module_utils.gnupg.cli import ( from ansible_collections.community.crypto.plugins.module_utils._gnupg.cli import (
GPGError, GPGError,
get_fingerprint_from_file, get_fingerprint_from_file,
) )
from ansible_collections.community.crypto.plugins.plugin_utils.gnupg import ( from ansible_collections.community.crypto.plugins.plugin_utils._gnupg import (
PluginGPGRunner, PluginGPGRunner,
) )
class LookupModule(LookupBase): class LookupModule(LookupBase):
def run(self, terms, variables=None, **kwargs): def run(
self, terms: list[t.Any], variables: None = None, **kwargs: t.Any
) -> list[str]:
self.set_options(direct=kwargs) self.set_options(direct=kwargs)
if self._loader is None:
raise AssertionError(
"Contract violation: self._loader is None"
) # pragma: no cover
try: try:
gpg = PluginGPGRunner(cwd=self._loader.get_basedir()) gpg = PluginGPGRunner(cwd=self._loader.get_basedir())
result = [] result = []
for path in terms: for i, path in enumerate(terms):
result.append(get_fingerprint_from_file(gpg, path)) if not isinstance(path, (str, bytes, os.PathLike)):
raise AnsibleLookupError(
f"Lookup parameter #{i} should be string or a path object, but got {type(path)}"
)
result.append(
get_fingerprint_from_file(gpg_runner=gpg, path=to_text(path))
)
return result return result
except GPGError as exc: except GPGError as exc:
raise AnsibleLookupError(to_native(exc)) raise AnsibleLookupError(str(exc)) from exc

View File

@@ -1,43 +1,48 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net> # Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de> # Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
__metaclass__ = type import typing as t
from ansible.module_utils.common._collections_compat import Mapping from ansible.module_utils.common._collections_compat import Mapping
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( from ansible_collections.community.crypto.plugins.module_utils._acme.errors import (
ACMEProtocolException, ACMEProtocolException,
ModuleFailException, ModuleFailException,
) )
class ACMEAccount(object): if t.TYPE_CHECKING:
from ansible_collections.community.crypto.plugins.module_utils._acme.acme import ( # pragma: no cover
ACMEClient,
)
class ACMEAccount:
""" """
ACME account object. Allows to create new accounts, check for existence of accounts, ACME account object. Allows to create new accounts, check for existence of accounts,
retrieve account data. retrieve account data.
""" """
def __init__(self, client): def __init__(self, *, client: ACMEClient) -> None:
# Set to true to enable logging of all signed requests # Set to true to enable logging of all signed requests
self._debug = False self._debug: bool = False
self.client = client self.client = client
def _new_reg( def _new_reg(
self, self,
contact=None, *,
agreement=None, contact: list[str] | None = None,
terms_agreed=False, terms_agreed: bool = False,
allow_creation=True, allow_creation: bool = True,
external_account_binding=None, external_account_binding: dict[str, t.Any] | None = None,
): ) -> tuple[bool, dict[str, t.Any] | None]:
""" """
Registers a new ACME account. Returns a pair ``(created, data)``. Registers a new ACME account. Returns a pair ``(created, data)``.
Here, ``created`` is ``True`` if the account was created and Here, ``created`` is ``True`` if the account was created and
@@ -53,26 +58,18 @@ class ACMEAccount(object):
""" """
contact = contact or [] contact = contact or []
if self.client.version == 1: if (
new_reg = {"resource": "new-reg", "contact": contact} external_account_binding is not None
if agreement: or self.client.directory["meta"].get("externalAccountRequired")
new_reg["agreement"] = agreement ) and allow_creation:
else: # Some ACME servers such as ZeroSSL do not like it when you try to register an existing account
new_reg["agreement"] = self.client.directory["meta"]["terms-of-service"] # and provide external_account_binding credentials. Thus we first send a request with allow_creation=False
if external_account_binding is not None: # to see whether the account already exists.
raise ModuleFailException(
"External account binding is not supported for ACME v1"
)
url = self.client.directory["new-reg"]
else:
if (
external_account_binding is not None
or self.client.directory["meta"].get("externalAccountRequired")
) and allow_creation:
# Some ACME servers such as ZeroSSL do not like it when you try to register an existing account
# and provide external_account_binding credentials. Thus we first send a request with allow_creation=False
# to see whether the account already exists.
# Unfortunately, for other ACME servers it's the other way around: (at least some) HARICA endpoints
# do not allow *any* access without external account data. That's why we catch errors and check
# for 'externalAccountRequired'.
try:
# Note that we pass contact here: ZeroSSL does not accept registration calls without contacts, even # Note that we pass contact here: ZeroSSL does not accept registration calls without contacts, even
# if onlyReturnExisting is set to true. # if onlyReturnExisting is set to true.
created, data = self._new_reg(contact=contact, allow_creation=False) created, data = self._new_reg(contact=contact, allow_creation=False)
@@ -80,52 +77,63 @@ class ACMEAccount(object):
# An account already exists! Return data # An account already exists! Return data
return created, data return created, data
# An account does not yet exist. Try to create one next. # An account does not yet exist. Try to create one next.
except ACMEProtocolException as exc:
if (
exc.error_type
!= "urn:ietf:params:acme:error:externalAccountRequired"
or external_account_binding is None
):
# Either another error happened, or we got 'externalAccountRequired' and external account data was not supplied
# => re-raise exception!
raise
# In this case, the server really wants external account data.
# The below code tries to create the account with external account data present.
new_reg = {"contact": contact} new_reg: dict[str, t.Any] = {"contact": contact}
if not allow_creation: if not allow_creation:
# https://tools.ietf.org/html/rfc8555#section-7.3.1 # https://tools.ietf.org/html/rfc8555#section-7.3.1
new_reg["onlyReturnExisting"] = True new_reg["onlyReturnExisting"] = True
if terms_agreed: if terms_agreed:
new_reg["termsOfServiceAgreed"] = True new_reg["termsOfServiceAgreed"] = True
url = self.client.directory["newAccount"] url = self.client.directory["newAccount"]
if external_account_binding is not None: if external_account_binding is not None:
new_reg["externalAccountBinding"] = self.client.sign_request( new_reg["externalAccountBinding"] = self.client.sign_request(
{ protected={
"alg": external_account_binding["alg"], "alg": external_account_binding["alg"],
"kid": external_account_binding["kid"], "kid": external_account_binding["kid"],
"url": url, "url": url,
}, },
self.client.account_jwk, payload=self.client.account_jwk,
self.client.backend.create_mac_key( key_data=self.client.backend.create_mac_key(
external_account_binding["alg"], external_account_binding["key"] alg=external_account_binding["alg"],
), key=external_account_binding["key"],
) ),
elif ( )
self.client.directory["meta"].get("externalAccountRequired") elif (
and allow_creation self.client.directory["meta"].get("externalAccountRequired")
): and allow_creation
raise ModuleFailException( ):
"To create an account, an external account binding must be specified. " raise ModuleFailException(
"Use the acme_account module with the external_account_binding option." "To create an account, an external account binding must be specified. Use the acme_account module with the external_account_binding option."
) )
result, info = self.client.send_signed_request( result, info = self.client.send_signed_request(
url, new_reg, fail_on_error=False url, new_reg, fail_on_error=False
) )
if not isinstance(result, Mapping): if not isinstance(result, Mapping):
raise ACMEProtocolException( raise ACMEProtocolException(
self.client.module, module=self.client.module,
msg="Invalid account creation reply from ACME server", msg="Invalid account creation reply from ACME server",
info=info, info=info,
content=result, content_json=result,
) )
if info["status"] in ([200, 201] if self.client.version == 1 else [201]): if info["status"] == 201:
# Account did not exist # Account did not exist
if "location" in info: if "location" in info:
self.client.set_account_uri(info["location"]) self.client.set_account_uri(info["location"])
return True, result return True, result
elif info["status"] == (409 if self.client.version == 1 else 200): if info["status"] == 200:
# Account did exist # Account did exist
if result.get("status") == "deactivated": if result.get("status") == "deactivated":
# A bug in Pebble (https://github.com/letsencrypt/pebble/issues/179) and # A bug in Pebble (https://github.com/letsencrypt/pebble/issues/179) and
@@ -136,12 +144,11 @@ class ACMEAccount(object):
# requests authorized by that account's key." # requests authorized by that account's key."
if not allow_creation: if not allow_creation:
return False, None return False, None
else: raise ModuleFailException("Account is deactivated")
raise ModuleFailException("Account is deactivated")
if "location" in info: if "location" in info:
self.client.set_account_uri(info["location"]) self.client.set_account_uri(info["location"])
return False, result return False, result
elif ( if (
info["status"] in (400, 404) info["status"] in (400, 404)
and result["type"] == "urn:ietf:params:acme:error:accountDoesNotExist" and result["type"] == "urn:ietf:params:acme:error:accountDoesNotExist"
and not allow_creation and not allow_creation
@@ -150,7 +157,7 @@ class ACMEAccount(object):
# (According to RFC 8555, Section 7.3.1, the HTTP status code MUST be 400. # (According to RFC 8555, Section 7.3.1, the HTTP status code MUST be 400.
# Unfortunately Digicert does not care and sends 404 instead.) # Unfortunately Digicert does not care and sends 404 instead.)
return False, None return False, None
elif ( if (
info["status"] == 403 info["status"] == 403
and result["type"] == "urn:ietf:params:acme:error:unauthorized" and result["type"] == "urn:ietf:params:acme:error:unauthorized"
and "deactivated" in (result.get("detail") or "") and "deactivated" in (result.get("detail") or "")
@@ -160,17 +167,15 @@ class ACMEAccount(object):
# might need adjustment in error detection. # might need adjustment in error detection.
if not allow_creation: if not allow_creation:
return False, None return False, None
else: raise ModuleFailException("Account is deactivated")
raise ModuleFailException("Account is deactivated") raise ACMEProtocolException(
else: module=self.client.module,
raise ACMEProtocolException( msg="Registering ACME account failed",
self.client.module, info=info,
msg="Registering ACME account failed", content_json=result,
info=info, )
content_json=result,
)
def get_account_data(self): def get_account_data(self) -> dict[str, t.Any] | None:
""" """
Retrieve account information. Can only be called when the account Retrieve account information. Can only be called when the account
URI is already known (such as after calling setup_account). URI is already known (such as after calling setup_account).
@@ -178,34 +183,28 @@ class ACMEAccount(object):
""" """
if self.client.account_uri is None: if self.client.account_uri is None:
raise ModuleFailException("Account URI unknown") raise ModuleFailException("Account URI unknown")
if self.client.version == 1: # try POST-as-GET first (draft-15 or newer)
data: dict[str, t.Any] | None = None
result, info = self.client.send_signed_request(
self.client.account_uri, data, fail_on_error=False
)
# check whether that failed with a malformed request error
if (
info["status"] >= 400
and isinstance(result, Mapping)
and result.get("type") == "urn:ietf:params:acme:error:malformed"
):
# retry as a regular POST (with no changed data) for pre-draft-15 ACME servers
data = {} data = {}
data["resource"] = "reg"
result, info = self.client.send_signed_request( result, info = self.client.send_signed_request(
self.client.account_uri, data, fail_on_error=False self.client.account_uri, data, fail_on_error=False
) )
else: if not isinstance(result, dict):
# try POST-as-GET first (draft-15 or newer)
data = None
result, info = self.client.send_signed_request(
self.client.account_uri, data, fail_on_error=False
)
# check whether that failed with a malformed request error
if (
info["status"] >= 400
and result.get("type") == "urn:ietf:params:acme:error:malformed"
):
# retry as a regular POST (with no changed data) for pre-draft-15 ACME servers
data = {}
result, info = self.client.send_signed_request(
self.client.account_uri, data, fail_on_error=False
)
if not isinstance(result, Mapping):
raise ACMEProtocolException( raise ACMEProtocolException(
self.client.module, module=self.client.module,
msg="Invalid account data retrieved from ACME server", msg="Invalid account data retrieved from ACME server",
info=info, info=info,
content=result, content_json=result,
) )
if ( if (
info["status"] in (400, 403) info["status"] in (400, 403)
@@ -221,22 +220,44 @@ class ACMEAccount(object):
return None return None
if info["status"] < 200 or info["status"] >= 300: if info["status"] < 200 or info["status"] >= 300:
raise ACMEProtocolException( raise ACMEProtocolException(
self.client.module, module=self.client.module,
msg="Error retrieving account data", msg="Error retrieving account data",
info=info, info=info,
content_json=result, content_json=result,
) )
return result return result
@t.overload
def setup_account( def setup_account(
self, self,
contact=None, *,
agreement=None, contact: list[str] | None = None,
terms_agreed=False, terms_agreed: bool = False,
allow_creation=True, allow_creation: t.Literal[True] = True,
remove_account_uri_if_not_exists=False, remove_account_uri_if_not_exists: bool = False,
external_account_binding=None, external_account_binding: dict[str, t.Any] | None = None,
): ) -> tuple[bool, dict[str, t.Any]]: ...
@t.overload
def setup_account(
self,
*,
contact: list[str] | None = None,
terms_agreed: bool = False,
allow_creation: bool = True,
remove_account_uri_if_not_exists: bool = False,
external_account_binding: dict[str, t.Any] | None = None,
) -> tuple[bool, dict[str, t.Any] | None]: ...
def setup_account(
self,
*,
contact: list[str] | None = None,
terms_agreed: bool = False,
allow_creation: bool = True,
remove_account_uri_if_not_exists: bool = False,
external_account_binding: dict[str, t.Any] | None = None,
) -> tuple[bool, dict[str, t.Any] | None]:
""" """
Detect or create an account on the ACME server. For ACME v1, Detect or create an account on the ACME server. For ACME v1,
as the only way (without knowing an account URI) to test if an as the only way (without knowing an account URI) to test if an
@@ -277,8 +298,7 @@ class ACMEAccount(object):
) )
else: else:
created, account_data = self._new_reg( created, account_data = self._new_reg(
contact, contact=contact,
agreement=agreement,
terms_agreed=terms_agreed, terms_agreed=terms_agreed,
allow_creation=allow_creation and not self.client.module.check_mode, allow_creation=allow_creation and not self.client.module.check_mode,
external_account_binding=external_account_binding, external_account_binding=external_account_binding,
@@ -292,7 +312,9 @@ class ACMEAccount(object):
account_data = {"contact": contact or []} account_data = {"contact": contact or []}
return created, account_data return created, account_data
def update_account(self, account_data, contact=None): def update_account(
self, *, account_data: dict[str, t.Any], contact: list[str] | None = None
) -> tuple[bool, dict[str, t.Any]]:
""" """
Update an account on the ACME server. Check mode is fully respected. Update an account on the ACME server. Check mode is fully respected.
@@ -305,8 +327,11 @@ class ACMEAccount(object):
https://tools.ietf.org/html/rfc8555#section-7.3.2 https://tools.ietf.org/html/rfc8555#section-7.3.2
""" """
if self.client.account_uri is None:
raise ModuleFailException("Cannot update account without account URI")
# Create request # Create request
update_request = {} update_request: dict[str, t.Any] = {}
if contact is not None and account_data.get("contact", []) != contact: if contact is not None and account_data.get("contact", []) != contact:
update_request["contact"] = list(contact) update_request["contact"] = list(contact)
@@ -319,17 +344,19 @@ class ACMEAccount(object):
account_data = dict(account_data) account_data = dict(account_data)
account_data.update(update_request) account_data.update(update_request)
else: else:
if self.client.version == 1: raw_account_data, info = self.client.send_signed_request(
update_request["resource"] = "reg"
account_data, info = self.client.send_signed_request(
self.client.account_uri, update_request self.client.account_uri, update_request
) )
if not isinstance(account_data, Mapping): if not isinstance(raw_account_data, Mapping):
raise ACMEProtocolException( raise ACMEProtocolException(
self.client.module, module=self.client.module,
msg="Invalid account updating reply from ACME server", msg="Invalid account updating reply from ACME server",
info=info, info=info,
content=account_data, content_json=account_data,
) )
account_data = raw_account_data
return True, account_data return True, account_data
__all__ = ("ACMEAccount",)

View File

@@ -1,62 +1,66 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net> # Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de> # Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
__metaclass__ = type
from __future__ import annotations
import copy import copy
import datetime import datetime
import json import json
import locale import locale
import time import time
import traceback import typing as t
from ansible.module_utils.basic import missing_required_lib from ansible.module_utils.basic import missing_required_lib
from ansible.module_utils.common.text.converters import to_bytes from ansible.module_utils.common.text.converters import to_bytes
from ansible.module_utils.six import PY3
from ansible.module_utils.urls import fetch_url from ansible.module_utils.urls import fetch_url
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_cryptography import ( from ansible_collections.community.crypto.plugins.module_utils._acme.backend_cryptography import (
CRYPTOGRAPHY_ERROR, CRYPTOGRAPHY_ERROR,
CRYPTOGRAPHY_MINIMAL_VERSION, CRYPTOGRAPHY_MINIMAL_VERSION,
CRYPTOGRAPHY_VERSION, CRYPTOGRAPHY_VERSION,
HAS_CURRENT_CRYPTOGRAPHY, HAS_CURRENT_CRYPTOGRAPHY,
CryptographyBackend, CryptographyBackend,
) )
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_openssl_cli import ( from ansible_collections.community.crypto.plugins.module_utils._acme.backend_openssl_cli import (
OpenSSLCLIBackend, OpenSSLCLIBackend,
) )
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( from ansible_collections.community.crypto.plugins.module_utils._acme.errors import (
ACMEProtocolException, ACMEProtocolException,
KeyParsingError, KeyParsingError,
ModuleFailException, ModuleFailException,
NetworkException, NetworkException,
format_http_status, format_http_status,
) )
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import ( from ansible_collections.community.crypto.plugins.module_utils._acme.utils import (
compute_cert_id, compute_cert_id,
nopad_b64, nopad_b64,
parse_retry_after, parse_retry_after,
) )
from ansible_collections.community.crypto.plugins.module_utils.argspec import ( from ansible_collections.community.crypto.plugins.module_utils._argspec import (
ArgumentSpec, ArgumentSpec,
) )
from ansible_collections.community.crypto.plugins.module_utils._time import (
get_now_datetime,
)
try: if t.TYPE_CHECKING:
import ipaddress # noqa: F401, pylint: disable=unused-import import http.client # pragma: no cover
except ImportError: import os # pragma: no cover
HAS_IPADDRESS = False import urllib.error # pragma: no cover
IPADDRESS_IMPORT_ERROR = traceback.format_exc()
else: from ansible.module_utils.basic import AnsibleModule # pragma: no cover
HAS_IPADDRESS = True from ansible_collections.community.crypto.plugins.module_utils._acme.account import ( # pragma: no cover
IPADDRESS_IMPORT_ERROR = None ACMEAccount,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.backends import ( # pragma: no cover
CertificateInformation,
CryptoBackend,
)
# -1 usually means connection problems # -1 usually means connection problems
@@ -65,26 +69,36 @@ RETRY_STATUS_CODES = (-1, 408, 429, 503)
RETRY_COUNT = 10 RETRY_COUNT = 10
def _decode_retry(module, response, info, retry_count): def _decode_retry(
*,
module: AnsibleModule,
response: urllib.error.HTTPError | http.client.HTTPResponse | None,
info: dict[str, t.Any],
retry_count: int,
) -> bool:
if info["status"] not in RETRY_STATUS_CODES: if info["status"] not in RETRY_STATUS_CODES:
return False return False
if retry_count >= RETRY_COUNT: if retry_count >= RETRY_COUNT:
raise ACMEProtocolException( raise ACMEProtocolException(
module, module=module,
msg="Giving up after {retry} retries".format(retry=RETRY_COUNT), msg=f"Giving up after {RETRY_COUNT} retries",
info=info, info=info,
response=response, response=response,
) )
# 429 and 503 should have a Retry-After header (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) # 429 and 503 should have a Retry-After header (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After)
now = get_now_datetime(with_timezone=True)
try: try:
retry_after = min(max(1, int(info.get("retry-after"))), 60) then = parse_retry_after(
info.get("retry-after", "10"), relative_with_timezone=True, now=now
)
retry_after = (then - now).total_seconds()
retry_after = min(max(1, retry_after), 60)
except (TypeError, ValueError): except (TypeError, ValueError):
retry_after = 10 retry_after = 10
module.log( module.log(
"Retrieved a %s HTTP status on %s, retrying in %s seconds" f"Retrieved a {format_http_status(info['status'])} HTTP status on {info['url']}, retrying in {retry_after} seconds"
% (format_http_status(info["status"]), info["url"], retry_after)
) )
time.sleep(retry_after) time.sleep(retry_after)
@@ -92,27 +106,28 @@ def _decode_retry(module, response, info, retry_count):
def _assert_fetch_url_success( def _assert_fetch_url_success(
module, *,
response, module: AnsibleModule,
info, response: urllib.error.HTTPError | http.client.HTTPResponse | None,
allow_redirect=False, info: dict[str, t.Any],
allow_client_error=True, allow_redirect: bool = False,
allow_server_error=True, allow_client_error: bool = True,
): allow_server_error: bool = True,
) -> None:
if info["status"] < 0: if info["status"] < 0:
raise NetworkException( raise NetworkException(msg=f"Failure downloading {info['url']}, {info['msg']}")
msg="Failure downloading %s, %s" % (info["url"], info["msg"])
)
if ( if (
(300 <= info["status"] < 400 and not allow_redirect) (300 <= info["status"] < 400 and not allow_redirect)
or (400 <= info["status"] < 500 and not allow_client_error) or (400 <= info["status"] < 500 and not allow_client_error)
or (info["status"] >= 500 and not allow_server_error) or (info["status"] >= 500 and not allow_server_error)
): ):
raise ACMEProtocolException(module, info=info, response=response) raise ACMEProtocolException(module=module, info=info, response=response)
def _is_failed(info, expected_status_codes=None): def _is_failed(
*, info: dict[str, t.Any], expected_status_codes: t.Iterable[int] | None = None
) -> bool:
if info["status"] < 200 or info["status"] >= 400: if info["status"] < 200 or info["status"] >= 400:
return True return True
if ( if (
@@ -123,7 +138,7 @@ def _is_failed(info, expected_status_codes=None):
return False return False
class ACMEDirectory(object): class ACMEDirectory:
""" """
The ACME server directory. Gives access to the available resources, The ACME server directory. Gives access to the available resources,
and allows to obtain a Replay-Nonce. The acme_directory URL and allows to obtain a Replay-Nonce. The acme_directory URL
@@ -132,22 +147,24 @@ class ACMEDirectory(object):
https://tools.ietf.org/html/rfc8555#section-7.1.1 https://tools.ietf.org/html/rfc8555#section-7.1.1
""" """
def __init__(self, module, account): def __init__(self, *, module: AnsibleModule, client: ACMEClient) -> None:
self.module = module self.module = module
self.directory_root = module.params["acme_directory"] self.directory_root = module.params["acme_directory"]
self.version = module.params["acme_version"] self.version = module.params["acme_version"]
self.directory, dummy = account.get_request(self.directory_root, get_only=True) directory, info = client.get_request(self.directory_root, get_only=True)
if not isinstance(directory, dict):
raise ACMEProtocolException(
module=module,
msg=f"ACME directory is not a dictionary, but {type(directory)}",
info=info,
content_json=directory,
)
self.directory = directory
self.request_timeout = module.params["request_timeout"] self.request_timeout = module.params["request_timeout"]
# Check whether self.version matches what we expect # Check whether self.version matches what we expect
if self.version == 1:
for key in ("new-reg", "new-authz", "new-cert"):
if key not in self.directory:
raise ModuleFailException(
"ACME directory does not seem to follow protocol ACME v1"
)
if self.version == 2: if self.version == 2:
for key in ("newNonce", "newAccount", "newOrder"): for key in ("newNonce", "newAccount", "newOrder"):
if key not in self.directory: if key not in self.directory:
@@ -158,17 +175,17 @@ class ACMEDirectory(object):
if "meta" not in self.directory: if "meta" not in self.directory:
self.directory["meta"] = {} self.directory["meta"] = {}
def __getitem__(self, key): def __getitem__(self, key: str) -> t.Any:
return self.directory[key] return self.directory[key]
def __contains__(self, key): def __contains__(self, key: str) -> bool:
return key in self.directory return key in self.directory
def get(self, key, default_value=None): def get(self, key: str, default_value: t.Any = None) -> t.Any:
return self.directory.get(key, default_value) return self.directory.get(key, default_value)
def get_nonce(self, resource=None): def get_nonce(self, resource: str | None = None) -> str:
url = self.directory_root if self.version == 1 else self.directory["newNonce"] url = self.directory["newNonce"]
if resource is not None: if resource is not None:
url = resource url = resource
retry_count = 0 retry_count = 0
@@ -176,42 +193,43 @@ class ACMEDirectory(object):
response, info = fetch_url( response, info = fetch_url(
self.module, url, method="HEAD", timeout=self.request_timeout self.module, url, method="HEAD", timeout=self.request_timeout
) )
if _decode_retry(self.module, response, info, retry_count): if _decode_retry(
module=self.module,
response=response,
info=info,
retry_count=retry_count,
):
retry_count += 1 retry_count += 1
continue continue
if info["status"] not in (200, 204): if info["status"] not in (200, 204):
raise NetworkException( raise NetworkException(
"Failed to get replay-nonce, got status {0}".format( f"Failed to get replay-nonce, got status {format_http_status(info['status'])}"
format_http_status(info["status"])
)
) )
if "replay-nonce" in info: if "replay-nonce" in info:
return info["replay-nonce"] return info["replay-nonce"]
self.module.log( self.module.log(
"HEAD to {0} did return status {1}, but no replay-nonce header!".format( f"HEAD to {url} did return status {format_http_status(info['status'])}, but no replay-nonce header!"
url, format_http_status(info["status"])
)
) )
if retry_count >= 5: if retry_count >= 5:
raise ACMEProtocolException( raise ACMEProtocolException(
self.module, module=self.module,
msg="Was not able to obtain nonce, giving up after 5 retries", msg="Was not able to obtain nonce, giving up after 5 retries",
info=info, info=info,
response=response, response=response,
) )
retry_count += 1 retry_count += 1
def has_renewal_info_endpoint(self): def has_renewal_info_endpoint(self) -> bool:
return "renewalInfo" in self.directory return "renewalInfo" in self.directory
class ACMEClient(object): class ACMEClient:
""" """
ACME client object. Handles the authorized communication with the ACME client object. Handles the authorized communication with the
ACME server. ACME server.
""" """
def __init__(self, module, backend): def __init__(self, *, module: AnsibleModule, backend: CryptoBackend) -> None:
# Set to true to enable logging of all signed requests # Set to true to enable logging of all signed requests
self._debug = False self._debug = False
@@ -241,8 +259,8 @@ class ACMEClient(object):
) )
except KeyParsingError as e: except KeyParsingError as e:
raise ModuleFailException( raise ModuleFailException(
"Error while parsing account key: {msg}".format(msg=e.msg) f"Error while parsing account key: {e.msg}"
) ) from e
self.account_jwk = self.account_key_data["jwk"] self.account_jwk = self.account_key_data["jwk"]
self.account_jws_header = { self.account_jws_header = {
"alg": self.account_key_data["alg"], "alg": self.account_key_data["alg"],
@@ -252,28 +270,75 @@ class ACMEClient(object):
# Make sure self.account_jws_header is updated # Make sure self.account_jws_header is updated
self.set_account_uri(self.account_uri) self.set_account_uri(self.account_uri)
self.directory = ACMEDirectory(module, self) self.directory = ACMEDirectory(module=module, client=self)
def set_account_uri(self, uri): def set_account_uri(self, uri: str) -> None:
""" """
Set account URI. For ACME v2, it needs to be used to sending signed Set account URI. For ACME v2, it needs to be used to sending signed
requests. requests.
""" """
self.account_uri = uri self.account_uri = uri
if self.version != 1: if self.account_jws_header:
self.account_jws_header.pop("jwk") self.account_jws_header.pop("jwk", None)
self.account_jws_header["kid"] = self.account_uri self.account_jws_header["kid"] = self.account_uri
def parse_key(self, key_file=None, key_content=None, passphrase=None): def parse_key(
self,
*,
key_file: str | os.PathLike | None = None,
key_content: str | None = None,
passphrase: str | None = None,
) -> dict[str, t.Any]:
""" """
Parses an RSA or Elliptic Curve key file in PEM format and returns key_data. Parses an RSA or Elliptic Curve key file in PEM format and returns key_data.
In case of an error, raises KeyParsingError. In case of an error, raises KeyParsingError.
""" """
if key_file is None and key_content is None: if key_file is None and key_content is None:
raise AssertionError("One of key_file and key_content must be specified!") raise AssertionError(
return self.backend.parse_key(key_file, key_content, passphrase=passphrase) "One of key_file and key_content must be specified!"
) # pragma: no cover
return self.backend.parse_key(
key_file=key_file, key_content=key_content, passphrase=passphrase
)
def sign_request(self, protected, payload, key_data, encode_payload=True): @t.overload
def sign_request(
self,
*,
protected: dict[str, t.Any],
payload: dict[str, t.Any] | None,
key_data: dict[str, t.Any],
encode_payload: t.Literal[True] = True,
) -> dict[str, t.Any]: ...
@t.overload
def sign_request(
self,
*,
protected: dict[str, t.Any],
payload: str | bytes | None,
key_data: dict[str, t.Any],
encode_payload: t.Literal[False],
) -> dict[str, t.Any]: ...
@t.overload
def sign_request(
self,
*,
protected: dict[str, t.Any],
payload: str | bytes | dict[str, t.Any] | None,
key_data: dict[str, t.Any],
encode_payload: bool = True,
) -> dict[str, t.Any]: ...
def sign_request(
self,
*,
protected: dict[str, t.Any],
payload: str | bytes | dict[str, t.Any] | None,
key_data: dict[str, t.Any],
encode_payload: bool = True,
) -> dict[str, t.Any]:
""" """
Signs an ACME request. Signs an ACME request.
""" """
@@ -289,41 +354,101 @@ class ACMEClient(object):
protected64 = nopad_b64(self.module.jsonify(protected).encode("utf8")) protected64 = nopad_b64(self.module.jsonify(protected).encode("utf8"))
except Exception as e: except Exception as e:
raise ModuleFailException( raise ModuleFailException(
"Failed to encode payload / headers as JSON: {0}".format(e) f"Failed to encode payload / headers as JSON: {e}"
) ) from e
return self.backend.sign(payload64, protected64, key_data) return self.backend.sign(
payload64=payload64, protected64=protected64, key_data=key_data
)
def _log(self, msg, data=None): def _log(self, msg: str, *, data: t.Any = None) -> None:
""" """
Write arguments to acme.log when logging is enabled. Write arguments to acme.log when logging is enabled.
""" """
if self._debug: if self._debug:
with open("acme.log", "ab") as f: with open("acme.log", "ab") as f:
f.write( timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%s")
"[{0}] {1}\n".format( f.write(f"[{timestamp}] {msg}\n".encode("utf-8"))
datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%s"), msg
).encode("utf-8")
)
if data is not None: if data is not None:
f.write( f.write(
"{0}\n\n".format( f"{json.dumps(data, indent=2, sort_keys=True)}\n\n".encode(
json.dumps(data, indent=2, sort_keys=True) "utf-8"
).encode("utf-8") )
) )
@t.overload
def send_signed_request(
self,
url: str,
payload: dict[str, t.Any] | None,
*,
key_data: dict[str, t.Any] | None = None,
jws_header: dict[str, t.Any] | None = None,
parse_json_result: t.Literal[True] = True,
encode_payload: t.Literal[True] = True,
fail_on_error: bool = True,
error_msg: str | None = None,
expected_status_codes: t.Iterable[int] | None = None,
) -> tuple[object | bytes, dict[str, t.Any]]: ...
@t.overload
def send_signed_request(
self,
url: str,
payload: str | bytes | None,
*,
key_data: dict[str, t.Any] | None = None,
jws_header: dict[str, t.Any] | None = None,
parse_json_result: t.Literal[True] = True,
encode_payload: t.Literal[False],
fail_on_error: bool = True,
error_msg: str | None = None,
expected_status_codes: t.Iterable[int] | None = None,
) -> tuple[object | bytes, dict[str, t.Any]]: ...
@t.overload
def send_signed_request(
self,
url: str,
payload: dict[str, t.Any] | None,
*,
key_data: dict[str, t.Any] | None = None,
jws_header: dict[str, t.Any] | None = None,
parse_json_result: t.Literal[False],
encode_payload: t.Literal[True] = True,
fail_on_error: bool = True,
error_msg: str | None = None,
expected_status_codes: t.Iterable[int] | None = None,
) -> tuple[bytes, dict[str, t.Any]]: ...
@t.overload
def send_signed_request(
self,
url: str,
payload: str | bytes | None,
*,
key_data: dict[str, t.Any] | None = None,
jws_header: dict[str, t.Any] | None = None,
parse_json_result: t.Literal[False],
encode_payload: t.Literal[False],
fail_on_error: bool = True,
error_msg: str | None = None,
expected_status_codes: t.Iterable[int] | None = None,
) -> tuple[bytes, dict[str, t.Any]]: ...
def send_signed_request( def send_signed_request(
self, self,
url, url: str,
payload, payload: str | bytes | dict[str, t.Any] | None,
key_data=None, *,
jws_header=None, key_data: dict[str, t.Any] | None = None,
parse_json_result=True, jws_header: dict[str, t.Any] | None = None,
encode_payload=True, parse_json_result: bool = True,
fail_on_error=True, encode_payload: bool = True,
error_msg=None, fail_on_error: bool = True,
expected_status_codes=None, error_msg: str | None = None,
): expected_status_codes: t.Iterable[int] | None = None,
) -> tuple[object | bytes, dict[str, t.Any]]:
""" """
Sends a JWS signed HTTP POST request to the ACME server and returns Sends a JWS signed HTTP POST request to the ACME server and returns
the response as dictionary (if parse_json_result is True) or in raw form the response as dictionary (if parse_json_result is True) or in raw form
@@ -334,26 +459,28 @@ class ACMEClient(object):
(https://tools.ietf.org/html/rfc8555#section-6.3) (https://tools.ietf.org/html/rfc8555#section-6.3)
""" """
key_data = key_data or self.account_key_data key_data = key_data or self.account_key_data
if key_data is None:
raise ModuleFailException("Missing key data")
jws_header = jws_header or self.account_jws_header jws_header = jws_header or self.account_jws_header
if jws_header is None:
raise ModuleFailException("Missing JWS header")
failed_tries = 0 failed_tries = 0
while True: while True:
protected = copy.deepcopy(jws_header) protected = copy.deepcopy(jws_header)
protected["nonce"] = self.directory.get_nonce() protected["nonce"] = self.directory.get_nonce()
if self.version != 1: protected["url"] = url
protected["url"] = url
self._log("URL", url) self._log("URL", data=url)
self._log("protected", protected) self._log("protected", data=protected)
self._log("payload", payload) self._log("payload", data=payload)
data = self.sign_request( data = self.sign_request(
protected, payload, key_data, encode_payload=encode_payload protected=protected,
payload=payload,
key_data=key_data,
encode_payload=encode_payload,
) )
if self.version == 1: self._log("signed request", data=data)
data["header"] = jws_header.copy() data_str = self.module.jsonify(data)
for k, v in protected.items():
data["header"].pop(k, None)
self._log("signed request", data)
data = self.module.jsonify(data)
headers = { headers = {
"Content-Type": "application/jose+json", "Content-Type": "application/jose+json",
@@ -361,21 +488,23 @@ class ACMEClient(object):
resp, info = fetch_url( resp, info = fetch_url(
self.module, self.module,
url, url,
data=data, data=data_str,
headers=headers, headers=headers,
method="POST", method="POST",
timeout=self.request_timeout, timeout=self.request_timeout,
) )
if _decode_retry(self.module, resp, info, failed_tries): if _decode_retry(
module=self.module, response=resp, info=info, retry_count=failed_tries
):
failed_tries += 1 failed_tries += 1
continue continue
_assert_fetch_url_success(self.module, resp, info) _assert_fetch_url_success(module=self.module, response=resp, info=info)
result = {} result: object | bytes = {}
try: try:
# In Python 2, reading from a closed response yields a TypeError. # In Python 2, reading from a closed response yields a TypeError.
# In Python 3, read() simply returns '' # In Python 3, read() simply returns ''
if PY3 and resp.closed: if resp.closed:
raise TypeError raise TypeError
content = resp.read() content = resp.read()
except (AttributeError, TypeError): except (AttributeError, TypeError):
@@ -388,16 +517,15 @@ class ACMEClient(object):
) or 400 <= info["status"] < 600: ) or 400 <= info["status"] < 600:
try: try:
decoded_result = self.module.from_json(content.decode("utf8")) decoded_result = self.module.from_json(content.decode("utf8"))
self._log("parsed result", decoded_result) self._log("parsed result", data=decoded_result)
# In case of badNonce error, try again (up to 5 times) # In case of badNonce error, try again (up to 5 times)
# (https://tools.ietf.org/html/rfc8555#section-6.7) # (https://tools.ietf.org/html/rfc8555#section-6.7)
if all( if (
( 400 <= info["status"] < 600
400 <= info["status"] < 600, and failed_tries <= 5
decoded_result.get("type") and isinstance(decoded_result, dict)
== "urn:ietf:params:acme:error:badNonce", and decoded_result.get("type")
failed_tries <= 5, == "urn:ietf:params:acme:error:badNonce"
)
): ):
failed_tries += 1 failed_tries += 1
continue continue
@@ -405,20 +533,18 @@ class ACMEClient(object):
result = decoded_result result = decoded_result
else: else:
result = content result = content
except ValueError: except ValueError as exc:
raise NetworkException( raise NetworkException(
"Failed to parse the ACME response: {0} {1}".format( f"Failed to parse the ACME response: {url} {content}"
url, content ) from exc
)
)
else: else:
result = content result = content
if fail_on_error and _is_failed( if fail_on_error and _is_failed(
info, expected_status_codes=expected_status_codes info=info, expected_status_codes=expected_status_codes
): ):
raise ACMEProtocolException( raise ACMEProtocolException(
self.module, module=self.module,
msg=error_msg, msg=error_msg,
info=info, info=info,
content=content, content=content,
@@ -426,21 +552,48 @@ class ACMEClient(object):
) )
return result, info return result, info
@t.overload
def get_request( def get_request(
self, self,
uri, uri: str,
parse_json_result=True, *,
headers=None, parse_json_result: t.Literal[True] = True,
get_only=False, headers: dict[str, str] | None = None,
fail_on_error=True, get_only: bool = False,
error_msg=None, fail_on_error: bool = True,
expected_status_codes=None, error_msg: str | None = None,
): expected_status_codes: t.Iterable[int] | None = None,
) -> tuple[object, dict[str, t.Any]]: ...
@t.overload
def get_request(
self,
uri: str,
*,
parse_json_result: t.Literal[False],
headers: dict[str, str] | None = None,
get_only: bool = False,
fail_on_error: bool = True,
error_msg: str | None = None,
expected_status_codes: t.Iterable[int] | None = None,
) -> tuple[bytes, dict[str, t.Any]]: ...
def get_request(
self,
uri: str,
*,
parse_json_result: bool = True,
headers: dict[str, str] | None = None,
get_only: bool = False,
fail_on_error: bool = True,
error_msg: str | None = None,
expected_status_codes: t.Iterable[int] | None = None,
) -> tuple[object | bytes, dict[str, t.Any]]:
""" """
Perform a GET-like request. Will try POST-as-GET for ACMEv2, with fallback Perform a GET-like request. Will try POST-as-GET for ACMEv2, with fallback
to GET if server replies with a status code of 405. to GET if server replies with a status code of 405.
""" """
if not get_only and self.version != 1: if not get_only:
# Try POST-as-GET # Try POST-as-GET
content, info = self.send_signed_request( content, info = self.send_signed_request(
uri, None, parse_json_result=False, fail_on_error=False uri, None, parse_json_result=False, fail_on_error=False
@@ -463,16 +616,21 @@ class ACMEClient(object):
headers=headers, headers=headers,
timeout=self.request_timeout, timeout=self.request_timeout,
) )
if not _decode_retry(self.module, resp, info, retry_count): if not _decode_retry(
module=self.module,
response=resp,
info=info,
retry_count=retry_count,
):
break break
retry_count += 1 retry_count += 1
_assert_fetch_url_success(self.module, resp, info) _assert_fetch_url_success(module=self.module, response=resp, info=info)
try: try:
# In Python 2, reading from a closed response yields a TypeError. # In Python 2, reading from a closed response yields a TypeError.
# In Python 3, read() simply returns '' # In Python 3, read() simply returns ''
if PY3 and resp.closed: if resp.closed:
raise TypeError raise TypeError
content = resp.read() content = resp.read()
except (AttributeError, TypeError): except (AttributeError, TypeError):
@@ -480,6 +638,7 @@ class ACMEClient(object):
# Process result # Process result
parsed_json_result = False parsed_json_result = False
result: object | bytes
if parse_json_result: if parse_json_result:
result = {} result = {}
if content: if content:
@@ -487,38 +646,39 @@ class ACMEClient(object):
try: try:
result = self.module.from_json(content.decode("utf8")) result = self.module.from_json(content.decode("utf8"))
parsed_json_result = True parsed_json_result = True
except ValueError: except ValueError as exc:
raise NetworkException( raise NetworkException(
"Failed to parse the ACME response: {0} {1}".format( f"Failed to parse the ACME response: {uri} {content!r}"
uri, content ) from exc
)
)
else: else:
result = content result = content
else: else:
result = content result = content
if fail_on_error and _is_failed( if fail_on_error and _is_failed(
info, expected_status_codes=expected_status_codes info=info, expected_status_codes=expected_status_codes
): ):
raise ACMEProtocolException( raise ACMEProtocolException(
self.module, module=self.module,
msg=error_msg, msg=error_msg,
info=info, info=info,
content=content, content=content,
content_json=result if parsed_json_result else None, content_json=(
t.cast(dict[str, t.Any], result) if parsed_json_result else None
),
) )
return result, info return result, info
def get_renewal_info( def get_renewal_info(
self, self,
cert_id=None, *,
cert_info=None, cert_id: str | None = None,
cert_filename=None, cert_info: CertificateInformation | None = None,
cert_content=None, cert_filename: str | os.PathLike | None = None,
include_retry_after=False, cert_content: str | bytes | None = None,
retry_after_relative_with_timezone=True, include_retry_after: bool = False,
): retry_after_relative_with_timezone: bool = True,
) -> dict[str, t.Any]:
if not self.directory.has_renewal_info_endpoint(): if not self.directory.has_renewal_info_endpoint():
raise ModuleFailException( raise ModuleFailException(
"The ACME endpoint does not support ACME Renewal Information retrieval" "The ACME endpoint does not support ACME Renewal Information retrieval"
@@ -526,18 +686,23 @@ class ACMEClient(object):
if cert_id is None: if cert_id is None:
cert_id = compute_cert_id( cert_id = compute_cert_id(
self.backend, backend=self.backend,
cert_info=cert_info, cert_info=cert_info,
cert_filename=cert_filename, cert_filename=cert_filename,
cert_content=cert_content, cert_content=cert_content,
) )
url = "{base}/{cert_id}".format( url = f"{self.directory.directory['renewalInfo'].rstrip('/')}/{cert_id}"
base=self.directory.directory["renewalInfo"].rstrip("/"), cert_id=cert_id
)
data, info = self.get_request( data, info = self.get_request(
url, parse_json_result=True, fail_on_error=True, get_only=True url, parse_json_result=True, fail_on_error=True, get_only=True
) )
if not isinstance(data, dict):
raise ACMEProtocolException(
module=self.module,
msg="Unexpected renewal information",
info=info,
content_json=data,
)
# Include Retry-After header if asked for # Include Retry-After header if asked for
if include_retry_after and "retry-after" in info: if include_retry_after and "retry-after" in info:
@@ -551,60 +716,42 @@ class ACMEClient(object):
return data return data
def get_default_argspec():
"""
Provides default argument spec for the options documented in the acme doc fragment.
DEPRECATED: will be removed in community.crypto 3.0.0
"""
return dict(
acme_directory=dict(type="str", required=True),
acme_version=dict(type="int", required=True, choices=[1, 2]),
validate_certs=dict(type="bool", default=True),
select_crypto_backend=dict(
type="str", default="auto", choices=["auto", "openssl", "cryptography"]
),
request_timeout=dict(type="int", default=10),
account_key_src=dict(type="path", aliases=["account_key"]),
account_key_content=dict(type="str", no_log=True),
account_key_passphrase=dict(type="str", no_log=True),
account_uri=dict(type="str"),
)
def create_default_argspec( def create_default_argspec(
with_account=True, *,
require_account_key=True, with_account: bool = True,
with_certificate=False, require_account_key: bool = True,
): with_certificate: bool = False,
) -> ArgumentSpec:
""" """
Provides default argument spec for the options documented in the acme doc fragment. Provides default argument spec for the options documented in the acme doc fragment.
""" """
result = ArgumentSpec( result = ArgumentSpec(
argument_spec=dict( argument_spec={
acme_directory=dict(type="str", required=True), "acme_directory": {"type": "str", "required": True},
acme_version=dict(type="int", required=True, choices=[1, 2]), "acme_version": {"type": "int", "choices": [2], "default": 2},
validate_certs=dict(type="bool", default=True), "validate_certs": {"type": "bool", "default": True},
select_crypto_backend=dict( "select_crypto_backend": {
type="str", default="auto", choices=["auto", "openssl", "cryptography"] "type": "str",
), "default": "auto",
request_timeout=dict(type="int", default=10), "choices": ["auto", "openssl", "cryptography"],
), },
"request_timeout": {"type": "int", "default": 10},
},
) )
if with_account: if with_account:
result.update_argspec( result.update_argspec(
account_key_src=dict(type="path", aliases=["account_key"]), account_key_src={"type": "path", "aliases": ["account_key"]},
account_key_content=dict(type="str", no_log=True), account_key_content={"type": "str", "no_log": True},
account_key_passphrase=dict(type="str", no_log=True), account_key_passphrase={"type": "str", "no_log": True},
account_uri=dict(type="str"), account_uri={"type": "str"},
) )
if require_account_key: if require_account_key:
result.update(required_one_of=[["account_key_src", "account_key_content"]]) result.update(required_one_of=[["account_key_src", "account_key_content"]])
result.update(mutually_exclusive=[["account_key_src", "account_key_content"]]) result.update(mutually_exclusive=[["account_key_src", "account_key_content"]])
if with_certificate: if with_certificate:
result.update_argspec( result.update_argspec(
csr=dict(type="path"), csr={"type": "path"},
csr_content=dict(type="str"), csr_content={"type": "str"},
) )
result.update( result.update(
required_one_of=[["csr", "csr_content"]], required_one_of=[["csr", "csr_content"]],
@@ -613,12 +760,9 @@ def create_default_argspec(
return result return result
def create_backend(module, needs_acme_v2): def create_backend(
if not HAS_IPADDRESS: module: AnsibleModule, *, needs_acme_v2: bool = True
module.fail_json( ) -> CryptoBackend:
msg=missing_required_lib("ipaddress"), exception=IPADDRESS_IMPORT_ERROR
)
backend = module.params["select_crypto_backend"] backend = module.params["select_crypto_backend"]
# Backend autodetect # Backend autodetect
@@ -626,37 +770,32 @@ def create_backend(module, needs_acme_v2):
backend = "cryptography" if HAS_CURRENT_CRYPTOGRAPHY else "openssl" backend = "cryptography" if HAS_CURRENT_CRYPTOGRAPHY else "openssl"
# Create backend object # Create backend object
module_backend: CryptoBackend
if backend == "cryptography": if backend == "cryptography":
if CRYPTOGRAPHY_ERROR is not None: if CRYPTOGRAPHY_ERROR is not None:
# Either we could not import cryptography at all, or there was an unexpected error # Either we could not import cryptography at all, or there was an unexpected error
if CRYPTOGRAPHY_VERSION is None: if CRYPTOGRAPHY_VERSION is None:
msg = missing_required_lib("cryptography") msg = missing_required_lib("cryptography")
else: else:
msg = "Unexpected error while preparing cryptography: {0}".format( msg = f"Unexpected error while preparing cryptography: {CRYPTOGRAPHY_ERROR.splitlines()[-1]}"
CRYPTOGRAPHY_ERROR.splitlines()[-1]
)
module.fail_json(msg=msg, exception=CRYPTOGRAPHY_ERROR) module.fail_json(msg=msg, exception=CRYPTOGRAPHY_ERROR)
if not HAS_CURRENT_CRYPTOGRAPHY: if not HAS_CURRENT_CRYPTOGRAPHY:
# We succeeded importing cryptography, but its version is too old. # We succeeded importing cryptography, but its version is too old.
mrl = missing_required_lib(
f"cryptography >= {CRYPTOGRAPHY_MINIMAL_VERSION}"
)
module.fail_json( module.fail_json(
msg="Found cryptography, but only version {0}. {1}".format( msg=f"Found cryptography, but only version {CRYPTOGRAPHY_VERSION}. {mrl}"
CRYPTOGRAPHY_VERSION,
missing_required_lib(
"cryptography >= {0}".format(CRYPTOGRAPHY_MINIMAL_VERSION)
),
)
) )
module.debug( module.debug(
"Using cryptography backend (library version {0})".format( f"Using cryptography backend (library version {CRYPTOGRAPHY_VERSION})"
CRYPTOGRAPHY_VERSION
)
) )
module_backend = CryptographyBackend(module) module_backend = CryptographyBackend(module=module)
elif backend == "openssl": elif backend == "openssl":
module.debug("Using OpenSSL binary backend") module.debug("Using OpenSSL binary backend")
module_backend = OpenSSLCLIBackend(module) module_backend = OpenSSLCLIBackend(module=module)
else: else:
module.fail_json(msg='Unknown crypto backend "{0}"!'.format(backend)) module.fail_json(msg=f'Unknown crypto backend "{backend}"!')
# Check common module parameters # Check common module parameters
if not module.params["validate_certs"]: if not module.params["validate_certs"]:
@@ -666,20 +805,16 @@ def create_backend(module, needs_acme_v2):
"development purposes, but *never* for production purposes." "development purposes, but *never* for production purposes."
) )
if needs_acme_v2 and module.params["acme_version"] < 2:
module.fail_json(
msg="The {0} module requires the ACME v2 protocol!".format(module._name)
)
if module.params["acme_version"] == 1:
module.deprecate(
"The value 1 for 'acme_version' is deprecated. Please switch to ACME v2",
version="3.0.0",
collection_name="community.crypto",
)
# AnsibleModule() changes the locale, so change it back to C because we rely # AnsibleModule() changes the locale, so change it back to C because we rely
# on datetime.datetime.strptime() when parsing certificate dates. # on datetime.datetime.strptime() when parsing certificate dates.
locale.setlocale(locale.LC_ALL, "C") locale.setlocale(locale.LC_ALL, "C")
return module_backend return module_backend
__all__ = (
"ACMEDirectory",
"ACMEClient",
"create_default_argspec",
"create_backend",
)

View File

@@ -1,58 +1,55 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net> # Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de> # Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
__metaclass__ = type
from __future__ import annotations
import base64 import base64
import binascii import binascii
import os import os
import traceback import traceback
import typing as t
from ansible.module_utils.common.text.converters import to_bytes, to_native, to_text from ansible.module_utils.common.text.converters import to_bytes, to_text
from ansible_collections.community.crypto.plugins.module_utils.acme.backends import ( from ansible_collections.community.crypto.plugins.module_utils._acme.backends import (
CertificateInformation, CertificateInformation,
CryptoBackend, CryptoBackend,
) )
from ansible_collections.community.crypto.plugins.module_utils.acme.certificates import ( from ansible_collections.community.crypto.plugins.module_utils._acme.certificates import (
ChainMatcher, ChainMatcher,
) )
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( from ansible_collections.community.crypto.plugins.module_utils._acme.errors import (
BackendException, BackendException,
KeyParsingError, KeyParsingError,
) )
from ansible_collections.community.crypto.plugins.module_utils.acme.io import read_file from ansible_collections.community.crypto.plugins.module_utils._acme.io import read_file
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import ( from ansible_collections.community.crypto.plugins.module_utils._acme.utils import (
nopad_b64, nopad_b64,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( from ansible_collections.community.crypto.plugins.module_utils._crypto.cryptography_support import (
CRYPTOGRAPHY_TIMEZONE, CRYPTOGRAPHY_TIMEZONE,
cryptography_name_to_oid, cryptography_name_to_oid,
cryptography_serial_number_of_cert,
get_not_valid_after, get_not_valid_after,
get_not_valid_before, get_not_valid_before,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.math import ( from ansible_collections.community.crypto.plugins.module_utils._crypto.math import (
convert_int_to_bytes, convert_int_to_bytes,
convert_int_to_hex, convert_int_to_hex,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import ( from ansible_collections.community.crypto.plugins.module_utils._crypto.pem import (
extract_first_pem, extract_first_pem,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( from ansible_collections.community.crypto.plugins.module_utils._crypto.support import (
parse_name_field, parse_name_field,
) )
from ansible_collections.community.crypto.plugins.module_utils.time import ( from ansible_collections.community.crypto.plugins.module_utils._time import (
add_or_remove_timezone, add_or_remove_timezone,
) )
from ansible_collections.community.crypto.plugins.module_utils.version import ( from ansible_collections.community.crypto.plugins.module_utils._version import (
LooseVersion, LooseVersion,
) )
@@ -81,73 +78,83 @@ else:
HAS_CURRENT_CRYPTOGRAPHY = LooseVersion(CRYPTOGRAPHY_VERSION) >= LooseVersion( HAS_CURRENT_CRYPTOGRAPHY = LooseVersion(CRYPTOGRAPHY_VERSION) >= LooseVersion(
CRYPTOGRAPHY_MINIMAL_VERSION CRYPTOGRAPHY_MINIMAL_VERSION
) )
try:
if HAS_CURRENT_CRYPTOGRAPHY: if t.TYPE_CHECKING:
_cryptography_backend = cryptography.hazmat.backends.default_backend() import datetime # pragma: no cover
except Exception:
CRYPTOGRAPHY_ERROR = traceback.format_exc() from ansible.module_utils.basic import AnsibleModule # pragma: no cover
from ansible_collections.community.crypto.plugins.module_utils._acme.certificates import ( # pragma: no cover
CertificateChain,
Criterium,
)
class CryptographyChainMatcher(ChainMatcher): class CryptographyChainMatcher(ChainMatcher):
@staticmethod @staticmethod
def _parse_key_identifier(key_identifier, name, criterium_idx, module): def _parse_key_identifier(
*,
key_identifier: str | None,
name: str,
criterium_idx: int,
module: AnsibleModule,
) -> bytes | None:
if key_identifier: if key_identifier:
try: try:
return binascii.unhexlify(key_identifier.replace(":", "")) return binascii.unhexlify(key_identifier.replace(":", ""))
except Exception: except Exception:
if criterium_idx is None: module.warn(
module.warn( f"Criterium {criterium_idx} in select_chain has invalid {name} value. Ignoring criterium."
"Criterium has invalid {0} value. Ignoring criterium.".format( )
name
)
)
else:
module.warn(
"Criterium {0} in select_chain has invalid {1} value. "
"Ignoring criterium.".format(criterium_idx, name)
)
return None return None
def __init__(self, criterium, module): def __init__(self, *, criterium: Criterium, module: AnsibleModule) -> None:
self.criterium = criterium self.criterium = criterium
self.test_certificates = criterium.test_certificates self.test_certificates = criterium.test_certificates
self.subject = [] self.subject: list[tuple[cryptography.x509.oid.ObjectIdentifier, str]] = []
self.issuer = [] self.issuer: list[tuple[cryptography.x509.oid.ObjectIdentifier, str]] = []
if criterium.subject: if criterium.subject:
self.subject = [ self.subject = [
(cryptography_name_to_oid(k), to_native(v)) (cryptography_name_to_oid(k), to_text(v))
for k, v in parse_name_field(criterium.subject, "subject") for k, v in parse_name_field(
criterium.subject, name_field_name="subject"
)
] ]
if criterium.issuer: if criterium.issuer:
self.issuer = [ self.issuer = [
(cryptography_name_to_oid(k), to_native(v)) (cryptography_name_to_oid(k), to_text(v))
for k, v in parse_name_field(criterium.issuer, "issuer") for k, v in parse_name_field(criterium.issuer, name_field_name="issuer")
] ]
self.subject_key_identifier = CryptographyChainMatcher._parse_key_identifier( self.subject_key_identifier = CryptographyChainMatcher._parse_key_identifier(
criterium.subject_key_identifier, key_identifier=criterium.subject_key_identifier,
"subject_key_identifier", name="subject_key_identifier",
criterium.index, criterium_idx=criterium.index,
module, module=module,
) )
self.authority_key_identifier = CryptographyChainMatcher._parse_key_identifier( self.authority_key_identifier = CryptographyChainMatcher._parse_key_identifier(
criterium.authority_key_identifier, key_identifier=criterium.authority_key_identifier,
"authority_key_identifier", name="authority_key_identifier",
criterium.index, criterium_idx=criterium.index,
module, module=module,
) )
self.module = module
def _match_subject(self, x509_subject, match_subject): def _match_subject(
self,
*,
x509_subject: cryptography.x509.Name,
match_subject: list[tuple[cryptography.x509.oid.ObjectIdentifier, str]],
) -> bool:
for oid, value in match_subject: for oid, value in match_subject:
found = False found = False
for attribute in x509_subject: for attribute in x509_subject:
if attribute.oid == oid and value == to_native(attribute.value): if attribute.oid == oid and value == to_text(attribute.value):
found = True found = True
break break
if not found: if not found:
return False return False
return True return True
def match(self, certificate): def match(self, *, certificate: CertificateChain) -> bool:
""" """
Check whether an alternate chain matches the specified criterium. Check whether an alternate chain matches the specified criterium.
""" """
@@ -158,96 +165,106 @@ class CryptographyChainMatcher(ChainMatcher):
chain = chain[:1] chain = chain[:1]
for cert in chain: for cert in chain:
try: try:
x509 = cryptography.x509.load_pem_x509_certificate( x509 = cryptography.x509.load_pem_x509_certificate(to_bytes(cert))
to_bytes(cert), cryptography.hazmat.backends.default_backend()
)
matches = True matches = True
if not self._match_subject(x509.subject, self.subject): if not self._match_subject(
x509_subject=x509.subject, match_subject=self.subject
):
matches = False matches = False
if not self._match_subject(x509.issuer, self.issuer): if not self._match_subject(
x509_subject=x509.issuer, match_subject=self.issuer
):
matches = False matches = False
if self.subject_key_identifier: if self.subject_key_identifier:
try: try:
ext = x509.extensions.get_extension_for_class( ext_ski = x509.extensions.get_extension_for_class(
cryptography.x509.SubjectKeyIdentifier cryptography.x509.SubjectKeyIdentifier
) )
if self.subject_key_identifier != ext.value.digest: if self.subject_key_identifier != ext_ski.value.digest:
matches = False matches = False
except cryptography.x509.ExtensionNotFound: except cryptography.x509.ExtensionNotFound:
matches = False matches = False
if self.authority_key_identifier: if self.authority_key_identifier:
try: try:
ext = x509.extensions.get_extension_for_class( ext_aki = x509.extensions.get_extension_for_class(
cryptography.x509.AuthorityKeyIdentifier cryptography.x509.AuthorityKeyIdentifier
) )
if self.authority_key_identifier != ext.value.key_identifier: if (
self.authority_key_identifier
!= ext_aki.value.key_identifier
):
matches = False matches = False
except cryptography.x509.ExtensionNotFound: except cryptography.x509.ExtensionNotFound:
matches = False matches = False
if matches: if matches:
return True return True
except Exception as e: except Exception as e:
self.module.warn( self.module.warn(f"Error while loading certificate {cert}: {e}")
"Error while loading certificate {0}: {1}".format(cert, e)
)
return False return False
class CryptographyBackend(CryptoBackend): class CryptographyBackend(CryptoBackend):
def __init__(self, module): def __init__(self, *, module: AnsibleModule) -> None:
super(CryptographyBackend, self).__init__( super().__init__(module=module, with_timezone=CRYPTOGRAPHY_TIMEZONE)
module, with_timezone=CRYPTOGRAPHY_TIMEZONE
)
def parse_key(self, key_file=None, key_content=None, passphrase=None): def parse_key(
self,
*,
key_file: str | os.PathLike | None = None,
key_content: str | None = None,
passphrase: str | None = None,
) -> dict[str, t.Any]:
""" """
Parses an RSA or Elliptic Curve key file in PEM format and returns key_data. Parses an RSA or Elliptic Curve key file in PEM format and returns key_data.
Raises KeyParsingError in case of errors. Raises KeyParsingError in case of errors.
""" """
# If key_content is not given, read key_file # If key_content is not given, read key_file
if key_content is None: if key_content is None:
key_content = read_file(key_file) if key_file is None:
raise KeyParsingError(
"one of key_file and key_content must be specified"
)
b_key_content = read_file(key_file)
else: else:
key_content = to_bytes(key_content) b_key_content = to_bytes(key_content)
# Parse key # Parse key
try: try:
key = cryptography.hazmat.primitives.serialization.load_pem_private_key( key = cryptography.hazmat.primitives.serialization.load_pem_private_key(
key_content, b_key_content,
password=to_bytes(passphrase) if passphrase is not None else None, password=to_bytes(passphrase) if passphrase is not None else None,
backend=_cryptography_backend,
) )
except Exception as e: except Exception as e:
raise KeyParsingError("error while loading key: {0}".format(e)) raise KeyParsingError(f"error while loading key: {e}") from e
if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey): if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey):
pk = key.public_key().public_numbers() rsa_pk = key.public_key().public_numbers()
return { return {
"key_obj": key, "key_obj": key,
"type": "rsa", "type": "rsa",
"alg": "RS256", "alg": "RS256",
"jwk": { "jwk": {
"kty": "RSA", "kty": "RSA",
"e": nopad_b64(convert_int_to_bytes(pk.e)), "e": nopad_b64(convert_int_to_bytes(rsa_pk.e)),
"n": nopad_b64(convert_int_to_bytes(pk.n)), "n": nopad_b64(convert_int_to_bytes(rsa_pk.n)),
}, },
"hash": "sha256", "hash": "sha256",
} }
elif isinstance( if isinstance(
key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey
): ):
pk = key.public_key().public_numbers() ec_pk = key.public_key().public_numbers()
if pk.curve.name == "secp256r1": if ec_pk.curve.name == "secp256r1":
bits = 256 bits = 256
alg = "ES256" alg = "ES256"
hashalg = "sha256" hashalg = "sha256"
point_size = 32 point_size = 32
curve = "P-256" curve = "P-256"
elif pk.curve.name == "secp384r1": elif ec_pk.curve.name == "secp384r1":
bits = 384 bits = 384
alg = "ES384" alg = "ES384"
hashalg = "sha384" hashalg = "sha384"
point_size = 48 point_size = 48
curve = "P-384" curve = "P-384"
elif pk.curve.name == "secp521r1": elif ec_pk.curve.name == "secp521r1":
# Not yet supported on Let's Encrypt side, see # Not yet supported on Let's Encrypt side, see
# https://github.com/letsencrypt/boulder/issues/2217 # https://github.com/letsencrypt/boulder/issues/2217
bits = 521 bits = 521
@@ -256,9 +273,7 @@ class CryptographyBackend(CryptoBackend):
point_size = 66 point_size = 66
curve = "P-521" curve = "P-521"
else: else:
raise KeyParsingError( raise KeyParsingError(f"unknown elliptic curve: {ec_pk.curve.name}")
"unknown elliptic curve: {0}".format(pk.curve.name)
)
num_bytes = (bits + 7) // 8 num_bytes = (bits + 7) // 8
return { return {
"key_obj": key, "key_obj": key,
@@ -267,17 +282,19 @@ class CryptographyBackend(CryptoBackend):
"jwk": { "jwk": {
"kty": "EC", "kty": "EC",
"crv": curve, "crv": curve,
"x": nopad_b64(convert_int_to_bytes(pk.x, count=num_bytes)), "x": nopad_b64(convert_int_to_bytes(ec_pk.x, count=num_bytes)),
"y": nopad_b64(convert_int_to_bytes(pk.y, count=num_bytes)), "y": nopad_b64(convert_int_to_bytes(ec_pk.y, count=num_bytes)),
}, },
"hash": hashalg, "hash": hashalg,
"point_size": point_size, "point_size": point_size,
} }
else: raise KeyParsingError(f'unknown key type "{type(key)}"')
raise KeyParsingError('unknown key type "{0}"'.format(type(key)))
def sign(self, payload64, protected64, key_data): def sign(
sign_payload = "{0}.{1}".format(protected64, payload64).encode("utf8") self, *, payload64: str, protected64: str, key_data: dict[str, t.Any]
) -> dict[str, t.Any]:
sign_payload = f"{protected64}.{payload64}".encode("utf8")
hashalg: type[cryptography.hazmat.primitives.hashes.HashAlgorithm]
if "mac_obj" in key_data: if "mac_obj" in key_data:
mac = key_data["mac_obj"]() mac = key_data["mac_obj"]()
mac.update(sign_payload) mac.update(sign_payload)
@@ -303,9 +320,11 @@ class CryptographyBackend(CryptoBackend):
r, s = cryptography.hazmat.primitives.asymmetric.utils.decode_dss_signature( r, s = cryptography.hazmat.primitives.asymmetric.utils.decode_dss_signature(
key_data["key_obj"].sign(sign_payload, ecdsa) key_data["key_obj"].sign(sign_payload, ecdsa)
) )
rr = convert_int_to_hex(r, 2 * key_data["point_size"]) rr = convert_int_to_hex(r, digits=2 * key_data["point_size"])
ss = convert_int_to_hex(s, 2 * key_data["point_size"]) ss = convert_int_to_hex(s, digits=2 * key_data["point_size"])
signature = binascii.unhexlify(rr) + binascii.unhexlify(ss) signature = binascii.unhexlify(rr) + binascii.unhexlify(ss)
else:
raise AssertionError("Can never be reached") # pragma: no cover
return { return {
"protected": protected64, "protected": protected64,
@@ -313,8 +332,9 @@ class CryptographyBackend(CryptoBackend):
"signature": nopad_b64(signature), "signature": nopad_b64(signature),
} }
def create_mac_key(self, alg, key): def create_mac_key(self, *, alg: str, key: str) -> dict[str, t.Any]:
"""Create a MAC key.""" """Create a MAC key."""
hashalg: type[cryptography.hazmat.primitives.hashes.HashAlgorithm]
if alg == "HS256": if alg == "HS256":
hashalg = cryptography.hazmat.primitives.hashes.SHA256 hashalg = cryptography.hazmat.primitives.hashes.SHA256
hashbytes = 32 hashbytes = 32
@@ -326,20 +346,16 @@ class CryptographyBackend(CryptoBackend):
hashbytes = 64 hashbytes = 64
else: else:
raise BackendException( raise BackendException(
"Unsupported MAC key algorithm for cryptography backend: {0}".format( f"Unsupported MAC key algorithm for cryptography backend: {alg}"
alg
)
) )
key_bytes = base64.urlsafe_b64decode(key) key_bytes = base64.urlsafe_b64decode(key)
if len(key_bytes) < hashbytes: if len(key_bytes) < hashbytes:
raise BackendException( raise BackendException(
"{0} key must be at least {1} bytes long (after Base64 decoding)".format( f"{alg} key must be at least {hashbytes} bytes long (after Base64 decoding)"
alg, hashbytes
)
) )
return { return {
"mac_obj": lambda: cryptography.hazmat.primitives.hmac.HMAC( "mac_obj": lambda: cryptography.hazmat.primitives.hmac.HMAC(
key_bytes, hashalg(), _cryptography_backend key_bytes, hashalg()
), ),
"type": "hmac", "type": "hmac",
"alg": alg, "alg": alg,
@@ -349,7 +365,12 @@ class CryptographyBackend(CryptoBackend):
}, },
} }
def get_ordered_csr_identifiers(self, csr_filename=None, csr_content=None): def get_ordered_csr_identifiers(
self,
*,
csr_filename: str | os.PathLike | None = None,
csr_content: str | bytes | None = None,
) -> list[tuple[str, str]]:
""" """
Return a list of requested identifiers (CN and SANs) for the CSR. Return a list of requested identifiers (CN and SANs) for the CSR.
Each identifier is a pair (type, identifier), where type is either Each identifier is a pair (type, identifier), where type is either
@@ -359,15 +380,19 @@ class CryptographyBackend(CryptoBackend):
as the first element in the result. as the first element in the result.
""" """
if csr_content is None: if csr_content is None:
csr_content = read_file(csr_filename) if csr_filename is None:
raise BackendException(
"One of csr_content and csr_filename has to be provided"
)
b_csr_content = read_file(csr_filename)
else: else:
csr_content = to_bytes(csr_content) b_csr_content = to_bytes(csr_content)
csr = cryptography.x509.load_pem_x509_csr(csr_content, _cryptography_backend) csr = cryptography.x509.load_pem_x509_csr(b_csr_content)
identifiers = set() identifiers = set()
result = [] result = []
def add_identifier(identifier): def add_identifier(identifier: tuple[str, str]) -> None:
if identifier in identifiers: if identifier in identifiers:
return return
identifiers.add(identifier) identifiers.add(identifier)
@@ -375,7 +400,7 @@ class CryptographyBackend(CryptoBackend):
for sub in csr.subject: for sub in csr.subject:
if sub.oid == cryptography.x509.oid.NameOID.COMMON_NAME: if sub.oid == cryptography.x509.oid.NameOID.COMMON_NAME:
add_identifier(("dns", sub.value)) add_identifier(("dns", t.cast(str, sub.value)))
for extension in csr.extensions: for extension in csr.extensions:
if ( if (
extension.oid extension.oid
@@ -388,11 +413,16 @@ class CryptographyBackend(CryptoBackend):
add_identifier(("ip", name.value.compressed)) add_identifier(("ip", name.value.compressed))
else: else:
raise BackendException( raise BackendException(
"Found unsupported SAN identifier {0}".format(name) f"Found unsupported SAN identifier {name}"
) )
return result return result
def get_csr_identifiers(self, csr_filename=None, csr_content=None): def get_csr_identifiers(
self,
*,
csr_filename: str | os.PathLike | None = None,
csr_content: str | bytes | bytes | None = None,
) -> set[tuple[str, str]]:
""" """
Return a set of requested identifiers (CN and SANs) for the CSR. Return a set of requested identifiers (CN and SANs) for the CSR.
Each identifier is a pair (type, identifier), where type is either Each identifier is a pair (type, identifier), where type is either
@@ -404,7 +434,13 @@ class CryptographyBackend(CryptoBackend):
) )
) )
def get_cert_days(self, cert_filename=None, cert_content=None, now=None): def get_cert_days(
self,
*,
cert_filename: str | os.PathLike | None = None,
cert_content: str | bytes | None = None,
now: datetime.datetime | None = None,
) -> int:
""" """
Return the days the certificate in cert_filename remains valid and -1 Return the days the certificate in cert_filename remains valid and -1
if the file was not found. If cert_filename contains more than one if the file was not found. If cert_filename contains more than one
@@ -423,18 +459,16 @@ class CryptographyBackend(CryptoBackend):
return -1 return -1
# Make sure we have at most one PEM. Otherwise cryptography 36.0.0 will barf. # Make sure we have at most one PEM. Otherwise cryptography 36.0.0 will barf.
cert_content = to_bytes(extract_first_pem(to_text(cert_content)) or "") b_cert_content = to_bytes(extract_first_pem(to_text(cert_content)) or "")
try: try:
cert = cryptography.x509.load_pem_x509_certificate( cert = cryptography.x509.load_pem_x509_certificate(b_cert_content)
cert_content, _cryptography_backend
)
except Exception as e: except Exception as e:
if cert_filename is None: if cert_filename is None:
raise BackendException("Cannot parse certificate: {0}".format(e)) raise BackendException(f"Cannot parse certificate: {e}") from e
raise BackendException( raise BackendException(
"Cannot parse certificate {0}: {1}".format(cert_filename, e) f"Cannot parse certificate {cert_filename}: {e}"
) ) from e
if now is None: if now is None:
now = self.get_now() now = self.get_now()
@@ -442,13 +476,18 @@ class CryptographyBackend(CryptoBackend):
now = add_or_remove_timezone(now, with_timezone=CRYPTOGRAPHY_TIMEZONE) now = add_or_remove_timezone(now, with_timezone=CRYPTOGRAPHY_TIMEZONE)
return (get_not_valid_after(cert) - now).days return (get_not_valid_after(cert) - now).days
def create_chain_matcher(self, criterium): def create_chain_matcher(self, *, criterium: Criterium) -> ChainMatcher:
""" """
Given a Criterium object, creates a ChainMatcher object. Given a Criterium object, creates a ChainMatcher object.
""" """
return CryptographyChainMatcher(criterium, self.module) return CryptographyChainMatcher(criterium=criterium, module=self.module)
def get_cert_information(self, cert_filename=None, cert_content=None): def get_cert_information(
self,
*,
cert_filename: str | os.PathLike | None = None,
cert_content: str | bytes | None = None,
) -> CertificateInformation:
""" """
Return some information on a X.509 certificate as a CertificateInformation object. Return some information on a X.509 certificate as a CertificateInformation object.
""" """
@@ -458,41 +497,48 @@ class CryptographyBackend(CryptoBackend):
cert_content = to_bytes(cert_content) cert_content = to_bytes(cert_content)
# Make sure we have at most one PEM. Otherwise cryptography 36.0.0 will barf. # Make sure we have at most one PEM. Otherwise cryptography 36.0.0 will barf.
cert_content = to_bytes(extract_first_pem(to_text(cert_content)) or "") b_cert_content = to_bytes(extract_first_pem(to_text(cert_content)) or "")
try: try:
cert = cryptography.x509.load_pem_x509_certificate( cert = cryptography.x509.load_pem_x509_certificate(b_cert_content)
cert_content, _cryptography_backend
)
except Exception as e: except Exception as e:
if cert_filename is None: if cert_filename is None:
raise BackendException("Cannot parse certificate: {0}".format(e)) raise BackendException(f"Cannot parse certificate: {e}") from e
raise BackendException( raise BackendException(
"Cannot parse certificate {0}: {1}".format(cert_filename, e) f"Cannot parse certificate {cert_filename}: {e}"
) ) from e
ski = None ski = None
try: try:
ext = cert.extensions.get_extension_for_class( ext_ski = cert.extensions.get_extension_for_class(
cryptography.x509.SubjectKeyIdentifier cryptography.x509.SubjectKeyIdentifier
) )
ski = ext.value.digest ski = ext_ski.value.digest
except cryptography.x509.ExtensionNotFound: except cryptography.x509.ExtensionNotFound:
pass pass
aki = None aki = None
try: try:
ext = cert.extensions.get_extension_for_class( ext_aki = cert.extensions.get_extension_for_class(
cryptography.x509.AuthorityKeyIdentifier cryptography.x509.AuthorityKeyIdentifier
) )
aki = ext.value.key_identifier aki = ext_aki.value.key_identifier
except cryptography.x509.ExtensionNotFound: except cryptography.x509.ExtensionNotFound:
pass pass
return CertificateInformation( return CertificateInformation(
not_valid_after=get_not_valid_after(cert), not_valid_after=get_not_valid_after(cert),
not_valid_before=get_not_valid_before(cert), not_valid_before=get_not_valid_before(cert),
serial_number=cryptography_serial_number_of_cert(cert), serial_number=cert.serial_number,
subject_key_identifier=ski, subject_key_identifier=ski,
authority_key_identifier=aki, authority_key_identifier=aki,
) )
__all__ = (
"CRYPTOGRAPHY_MINIMAL_VERSION",
"CRYPTOGRAPHY_ERROR",
"CRYPTOGRAPHY_VERSION",
"CRYPTOGRAPHY_ERROR",
"CryptographyBackend",
)

View File

@@ -1,56 +1,66 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net> # Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de> # Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
__metaclass__ = type
from __future__ import annotations
import base64 import base64
import binascii import binascii
import datetime import datetime
import ipaddress
import os import os
import re import re
import tempfile import tempfile
import traceback import traceback
import typing as t
from ansible.module_utils.common.text.converters import to_bytes, to_native, to_text from ansible.module_utils.common.text.converters import to_bytes, to_text
from ansible_collections.community.crypto.plugins.module_utils.acme.backends import ( from ansible_collections.community.crypto.plugins.module_utils._acme.backends import (
CertificateInformation, CertificateInformation,
CryptoBackend, CryptoBackend,
) )
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( from ansible_collections.community.crypto.plugins.module_utils._acme.errors import (
BackendException, BackendException,
KeyParsingError, KeyParsingError,
) )
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import ( from ansible_collections.community.crypto.plugins.module_utils._acme.utils import (
nopad_b64, nopad_b64,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.math import ( from ansible_collections.community.crypto.plugins.module_utils._crypto.math import (
convert_bytes_to_int, convert_bytes_to_int,
) )
from ansible_collections.community.crypto.plugins.module_utils.time import ( from ansible_collections.community.crypto.plugins.module_utils._time import (
ensure_utc_timezone, ensure_utc_timezone,
) )
try: if t.TYPE_CHECKING:
import ipaddress from ansible.module_utils.basic import AnsibleModule # pragma: no cover
except ImportError: from ansible_collections.community.crypto.plugins.module_utils._acme.certificates import ( # pragma: no cover
pass Criterium,
)
_OPENSSL_ENVIRONMENT_UPDATE = dict(LANG="C", LC_ALL="C", LC_MESSAGES="C", LC_CTYPE="C") _OPENSSL_ENVIRONMENT_UPDATE = {
"LANG": "C",
"LC_ALL": "C",
"LC_MESSAGES": "C",
"LC_CTYPE": "C",
}
def _extract_date(out_text, name, cert_filename_suffix=""): def _extract_date(
out_text: str, *, name: str, cert_filename_suffix: str = ""
) -> datetime.datetime:
matcher = re.search(rf"\s+{name}\s*:\s+(.*)", out_text)
if matcher is None:
raise BackendException(f"No '{name}' date found{cert_filename_suffix}")
date_str = matcher.group(1)
try: try:
date_str = re.search(r"\s+%s\s*:\s+(.*)" % name, out_text).group(1)
# For some reason Python's strptime() does not return any timezone information, # For some reason Python's strptime() does not return any timezone information,
# even though the information is there and a supported timezone for all supported # even though the information is there and a supported timezone for all supported
# Python implementations (GMT). So we have to modify the datetime object by # Python implementations (GMT). So we have to modify the datetime object by
@@ -58,45 +68,73 @@ def _extract_date(out_text, name, cert_filename_suffix=""):
return ensure_utc_timezone( return ensure_utc_timezone(
datetime.datetime.strptime(date_str, "%b %d %H:%M:%S %Y %Z") datetime.datetime.strptime(date_str, "%b %d %H:%M:%S %Y %Z")
) )
except AttributeError:
raise BackendException(
"No '{0}' date found{1}".format(name, cert_filename_suffix)
)
except ValueError as exc: except ValueError as exc:
raise BackendException( raise BackendException(
"Failed to parse '{0}' date{1}: {2}".format(name, cert_filename_suffix, exc) f"Failed to parse '{name}' date{cert_filename_suffix}: {exc}"
) ) from exc
def _decode_octets(octets_text): def _decode_octets(octets_text: str) -> bytes:
return binascii.unhexlify(re.sub(r"(\s|:)", "", octets_text).encode("utf-8")) return binascii.unhexlify(re.sub(r"(\s|:)", "", octets_text).encode("utf-8"))
def _extract_octets(out_text, name, required=True, potential_prefixes=None): @t.overload
regexp = r"\s+%s:\s*\n\s+%s([A-Fa-f0-9]{2}(?::[A-Fa-f0-9]{2})*)\s*\n" % ( def _extract_octets(
name, out_text: str,
( *,
("(?:%s)" % "|".join(re.escape(pp) for pp in potential_prefixes)) name: str,
if potential_prefixes required: t.Literal[False],
else "" potential_prefixes: t.Iterable[str] | None = None,
), ) -> bytes | None: ...
@t.overload
def _extract_octets(
out_text: str,
*,
name: str,
required: t.Literal[True],
potential_prefixes: t.Iterable[str] | None = None,
) -> bytes: ...
def _extract_octets(
out_text: str,
*,
name: str,
required: bool = True,
potential_prefixes: t.Iterable[str] | None = None,
) -> bytes | None:
part = (
f"(?:{'|'.join(re.escape(pp) for pp in potential_prefixes)})"
if potential_prefixes
else ""
) )
regexp = rf"\s+{name}:\s*\n\s+{part}([A-Fa-f0-9]{{2}}(?::[A-Fa-f0-9]{{2}})*)\s*\n"
match = re.search(regexp, out_text, re.MULTILINE | re.DOTALL) match = re.search(regexp, out_text, re.MULTILINE | re.DOTALL)
if match is not None: if match is not None:
return _decode_octets(match.group(1)) return _decode_octets(match.group(1))
if not required: if not required:
return None return None
raise BackendException("No '{0}' octet string found".format(name)) raise BackendException(f"No '{name}' octet string found")
class OpenSSLCLIBackend(CryptoBackend): class OpenSSLCLIBackend(CryptoBackend):
def __init__(self, module, openssl_binary=None): def __init__(
super(OpenSSLCLIBackend, self).__init__(module, with_timezone=True) self, *, module: AnsibleModule, openssl_binary: str | None = None
) -> None:
super().__init__(module=module, with_timezone=True)
if openssl_binary is None: if openssl_binary is None:
openssl_binary = module.get_bin_path("openssl", True) openssl_binary = module.get_bin_path("openssl", True)
self.openssl_binary = openssl_binary self.openssl_binary = openssl_binary
def parse_key(self, key_file=None, key_content=None, passphrase=None): def parse_key(
self,
*,
key_file: str | os.PathLike | None = None,
key_content: str | None = None,
passphrase: str | None = None,
) -> dict[str, t.Any]:
""" """
Parses an RSA or Elliptic Curve key file in PEM format and returns key_data. Parses an RSA or Elliptic Curve key file in PEM format and returns key_data.
Raises KeyParsingError in case of errors. Raises KeyParsingError in case of errors.
@@ -105,6 +143,10 @@ class OpenSSLCLIBackend(CryptoBackend):
raise KeyParsingError("openssl backend does not support key passphrases") raise KeyParsingError("openssl backend does not support key passphrases")
# If key_file is not given, but key_content, write that to a temporary file # If key_file is not given, but key_content, write that to a temporary file
if key_file is None: if key_file is None:
if key_content is None:
raise KeyParsingError(
"one of key_file and key_content must be specified"
)
fd, tmpsrc = tempfile.mkstemp() fd, tmpsrc = tempfile.mkstemp()
self.module.add_cleanup_file(tmpsrc) # Ansible will delete the file on exit self.module.add_cleanup_file(tmpsrc) # Ansible will delete the file on exit
f = os.fdopen(fd, "wb") f = os.fdopen(fd, "wb")
@@ -117,14 +159,14 @@ class OpenSSLCLIBackend(CryptoBackend):
except Exception: except Exception:
pass pass
raise KeyParsingError( raise KeyParsingError(
"failed to create temporary content file: %s" % to_native(err), f"failed to create temporary content file: {err}",
exception=traceback.format_exc(), exception=traceback.format_exc(),
) ) from err
f.close() f.close()
# Parse key # Parse key
account_key_type = None account_key_type = None
with open(key_file, "rt") as f: with open(key_file, "r", encoding="utf-8") as fi:
for line in f: for line in fi:
m = re.match( m = re.match(
r"^\s*-{5,}BEGIN\s+(EC|RSA)\s+PRIVATE\s+KEY-{5,}\s*$", line r"^\s*-{5,}BEGIN\s+(EC|RSA)\s+PRIVATE\s+KEY-{5,}\s*$", line
) )
@@ -138,46 +180,50 @@ class OpenSSLCLIBackend(CryptoBackend):
# FIXME: add some kind of auto-detection # FIXME: add some kind of auto-detection
account_key_type = "rsa" account_key_type = "rsa"
if account_key_type not in ("rsa", "ec"): if account_key_type not in ("rsa", "ec"):
raise KeyParsingError('unknown key type "%s"' % account_key_type) raise KeyParsingError(f'unknown key type "{account_key_type}"')
openssl_keydump_cmd = [ openssl_keydump_cmd = [
self.openssl_binary, self.openssl_binary,
account_key_type, account_key_type,
"-in", "-in",
key_file, str(key_file),
"-noout", "-noout",
"-text", "-text",
] ]
rc, out, err = self.module.run_command( rc, out, stderr = self.module.run_command(
openssl_keydump_cmd, openssl_keydump_cmd,
check_rc=False, check_rc=False,
environ_update=_OPENSSL_ENVIRONMENT_UPDATE, environ_update=_OPENSSL_ENVIRONMENT_UPDATE,
) )
if rc != 0: if rc != 0:
raise BackendException( raise BackendException(
"Error while running {cmd}: {stderr}".format( f"Error while running {' '.join(openssl_keydump_cmd)}: {stderr}"
cmd=" ".join(openssl_keydump_cmd), stderr=to_text(err)
)
) )
out_text = to_text(out, errors="surrogate_or_strict") out_text = to_text(out, errors="surrogate_or_strict")
if account_key_type == "rsa": if account_key_type == "rsa":
pub_hex = re.search( matcher = re.search(
r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent", r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent",
out_text, out_text,
re.MULTILINE | re.DOTALL, re.MULTILINE | re.DOTALL,
).group(1) )
if matcher is None:
raise KeyParsingError("cannot parse RSA key: modulus not found")
pub_hex = matcher.group(1)
pub_exp = re.search( matcher = re.search(
r"\npublicExponent: ([0-9]+)", out_text, re.MULTILINE | re.DOTALL r"\npublicExponent: ([0-9]+)", out_text, re.MULTILINE | re.DOTALL
).group(1) )
pub_exp = "{0:x}".format(int(pub_exp)) if matcher is None:
raise KeyParsingError("cannot parse RSA key: public exponent not found")
pub_exp = matcher.group(1)
pub_exp = f"{int(pub_exp):x}"
if len(pub_exp) % 2: if len(pub_exp) % 2:
pub_exp = "0{0}".format(pub_exp) pub_exp = f"0{pub_exp}"
return { return {
"key_file": key_file, "key_file": str(key_file),
"type": "rsa", "type": "rsa",
"alg": "RS256", "alg": "RS256",
"jwk": { "jwk": {
@@ -187,7 +233,7 @@ class OpenSSLCLIBackend(CryptoBackend):
}, },
"hash": "sha256", "hash": "sha256",
} }
elif account_key_type == "ec": if account_key_type == "ec":
pub_data = re.search( pub_data = re.search(
r"pub:\s*\n\s+04:([a-f0-9\:\s]+?)\nASN1 OID: (\S+)(?:\nNIST CURVE: (\S+))?", r"pub:\s*\n\s+04:([a-f0-9\:\s]+?)\nASN1 OID: (\S+)(?:\nNIST CURVE: (\S+))?",
out_text, out_text,
@@ -220,12 +266,12 @@ class OpenSSLCLIBackend(CryptoBackend):
curve = "P-521" curve = "P-521"
else: else:
raise KeyParsingError( raise KeyParsingError(
"unknown elliptic curve: %s / %s" % (asn1_oid_curve, nist_curve) f"unknown elliptic curve: {asn1_oid_curve} / {nist_curve}"
) )
num_bytes = (bits + 7) // 8 num_bytes = (bits + 7) // 8
if len(pub_hex) != 2 * num_bytes: if len(pub_hex) != 2 * num_bytes:
raise KeyParsingError( raise KeyParsingError(
"bad elliptic curve point (%s / %s)" % (asn1_oid_curve, nist_curve) f"bad elliptic curve point ({asn1_oid_curve} / {nist_curve})"
) )
return { return {
"key_file": key_file, "key_file": key_file,
@@ -240,18 +286,23 @@ class OpenSSLCLIBackend(CryptoBackend):
"hash": hashalg, "hash": hashalg,
"point_size": point_size, "point_size": point_size,
} }
raise KeyParsingError(
f"Internal error: unexpected account_key_type = {account_key_type!r}"
)
def sign(self, payload64, protected64, key_data): def sign(
sign_payload = "{0}.{1}".format(protected64, payload64).encode("utf8") self, *, payload64: str, protected64: str, key_data: dict[str, t.Any]
) -> dict[str, t.Any]:
sign_payload = f"{protected64}.{payload64}".encode("utf8")
if key_data["type"] == "hmac": if key_data["type"] == "hmac":
hex_key = to_native( hex_key = (
binascii.hexlify(base64.urlsafe_b64decode(key_data["jwk"]["k"])) binascii.hexlify(base64.urlsafe_b64decode(key_data["jwk"]["k"]))
) ).decode("ascii")
cmd_postfix = [ cmd_postfix = [
"-mac", "-mac",
"hmac", "hmac",
"-macopt", "-macopt",
"hexkey:{0}".format(hex_key), f"hexkey:{hex_key}",
"-binary", "-binary",
] ]
else: else:
@@ -259,9 +310,11 @@ class OpenSSLCLIBackend(CryptoBackend):
openssl_sign_cmd = [ openssl_sign_cmd = [
self.openssl_binary, self.openssl_binary,
"dgst", "dgst",
"-{0}".format(key_data["hash"]), f"-{key_data['hash']}",
] + cmd_postfix ] + cmd_postfix
out: bytes | str
rc, out, err = self.module.run_command( rc, out, err = self.module.run_command(
openssl_sign_cmd, openssl_sign_cmd,
data=sign_payload, data=sign_payload,
@@ -271,13 +324,11 @@ class OpenSSLCLIBackend(CryptoBackend):
) )
if rc != 0: if rc != 0:
raise BackendException( raise BackendException(
"Error while running {cmd}: {stderr}".format( f"Error while running {' '.join(openssl_sign_cmd)}: {err}"
cmd=" ".join(openssl_sign_cmd), stderr=to_text(err)
)
) )
if key_data["type"] == "ec": if key_data["type"] == "ec":
dummy, der_out, dummy = self.module.run_command( dummy, der_out, dummy2 = self.module.run_command(
[self.openssl_binary, "asn1parse", "-inform", "DER"], [self.openssl_binary, "asn1parse", "-inform", "DER"],
data=out, data=out,
binary_data=True, binary_data=True,
@@ -285,14 +336,13 @@ class OpenSSLCLIBackend(CryptoBackend):
) )
expected_len = 2 * key_data["point_size"] expected_len = 2 * key_data["point_size"]
sig = re.findall( sig = re.findall(
r"prim:\s+INTEGER\s+:([0-9A-F]{1,%s})\n" % expected_len, rf"prim:\s+INTEGER\s+:([0-9A-F]{{1,{expected_len}}})\n",
to_text(der_out, errors="surrogate_or_strict"), to_text(der_out, errors="surrogate_or_strict"),
) )
if len(sig) != 2: if len(sig) != 2:
der_output = to_text(der_out, errors="surrogate_or_strict")
raise BackendException( raise BackendException(
"failed to generate Elliptic Curve signature; cannot parse DER output: {0}".format( f"failed to generate Elliptic Curve signature; cannot parse DER output: {der_output}"
to_text(der_out, errors="surrogate_or_strict")
)
) )
sig[0] = (expected_len - len(sig[0])) * "0" + sig[0] sig[0] = (expected_len - len(sig[0])) * "0" + sig[0]
sig[1] = (expected_len - len(sig[1])) * "0" + sig[1] sig[1] = (expected_len - len(sig[1])) * "0" + sig[1]
@@ -304,7 +354,7 @@ class OpenSSLCLIBackend(CryptoBackend):
"signature": nopad_b64(to_bytes(out)), "signature": nopad_b64(to_bytes(out)),
} }
def create_mac_key(self, alg, key): def create_mac_key(self, *, alg: str, key: str) -> dict[str, t.Any]:
"""Create a MAC key.""" """Create a MAC key."""
if alg == "HS256": if alg == "HS256":
hashalg = "sha256" hashalg = "sha256"
@@ -317,14 +367,12 @@ class OpenSSLCLIBackend(CryptoBackend):
hashbytes = 64 hashbytes = 64
else: else:
raise BackendException( raise BackendException(
"Unsupported MAC key algorithm for OpenSSL backend: {0}".format(alg) f"Unsupported MAC key algorithm for OpenSSL backend: {alg}"
) )
key_bytes = base64.urlsafe_b64decode(key) key_bytes = base64.urlsafe_b64decode(key)
if len(key_bytes) < hashbytes: if len(key_bytes) < hashbytes:
raise BackendException( raise BackendException(
"{0} key must be at least {1} bytes long (after Base64 decoding)".format( f"{alg} key must be at least {hashbytes} bytes long (after Base64 decoding)"
alg, hashbytes
)
) )
return { return {
"type": "hmac", "type": "hmac",
@@ -337,14 +385,19 @@ class OpenSSLCLIBackend(CryptoBackend):
} }
@staticmethod @staticmethod
def _normalize_ip(ip): def _normalize_ip(ip: str) -> str:
try: try:
return to_native(ipaddress.ip_address(to_text(ip)).compressed) return ipaddress.ip_address(ip).compressed
except ValueError: except ValueError:
# We do not want to error out on something IPAddress() cannot parse # We do not want to error out on something IPAddress() cannot parse
return ip return ip
def get_ordered_csr_identifiers(self, csr_filename=None, csr_content=None): def get_ordered_csr_identifiers(
self,
*,
csr_filename: str | os.PathLike | None = None,
csr_content: str | bytes | None = None,
) -> list[tuple[str, str]]:
""" """
Return a list of requested identifiers (CN and SANs) for the CSR. Return a list of requested identifiers (CN and SANs) for the CSR.
Each identifier is a pair (type, identifier), where type is either Each identifier is a pair (type, identifier), where type is either
@@ -357,13 +410,13 @@ class OpenSSLCLIBackend(CryptoBackend):
data = None data = None
if csr_content is not None: if csr_content is not None:
filename = "/dev/stdin" filename = "/dev/stdin"
data = csr_content.encode("utf-8") data = to_bytes(csr_content)
openssl_csr_cmd = [ openssl_csr_cmd = [
self.openssl_binary, self.openssl_binary,
"req", "req",
"-in", "-in",
filename, str(filename),
"-noout", "-noout",
"-text", "-text",
] ]
@@ -376,15 +429,13 @@ class OpenSSLCLIBackend(CryptoBackend):
) )
if rc != 0: if rc != 0:
raise BackendException( raise BackendException(
"Error while running {cmd}: {stderr}".format( f"Error while running {' '.join(openssl_csr_cmd)}: {err}"
cmd=" ".join(openssl_csr_cmd), stderr=to_text(err)
)
) )
identifiers = set() identifiers = set()
result = [] result = []
def add_identifier(identifier): def add_identifier(identifier: tuple[str, str]) -> None:
if identifier in identifiers: if identifier in identifiers:
return return
identifiers.add(identifier) identifiers.add(identifier)
@@ -410,12 +461,15 @@ class OpenSSLCLIBackend(CryptoBackend):
elif san.lower().startswith("ip address:"): elif san.lower().startswith("ip address:"):
add_identifier(("ip", self._normalize_ip(san[11:]))) add_identifier(("ip", self._normalize_ip(san[11:])))
else: else:
raise BackendException( raise BackendException(f'Found unsupported SAN identifier "{san}"')
'Found unsupported SAN identifier "{0}"'.format(san)
)
return result return result
def get_csr_identifiers(self, csr_filename=None, csr_content=None): def get_csr_identifiers(
self,
*,
csr_filename: str | os.PathLike | None = None,
csr_content: str | bytes | None = None,
) -> set[tuple[str, str]]:
""" """
Return a set of requested identifiers (CN and SANs) for the CSR. Return a set of requested identifiers (CN and SANs) for the CSR.
Each identifier is a pair (type, identifier), where type is either Each identifier is a pair (type, identifier), where type is either
@@ -427,7 +481,13 @@ class OpenSSLCLIBackend(CryptoBackend):
) )
) )
def get_cert_days(self, cert_filename=None, cert_content=None, now=None): def get_cert_days(
self,
*,
cert_filename: str | os.PathLike | None = None,
cert_content: str | bytes | None = None,
now: datetime.datetime | None = None,
) -> int:
""" """
Return the days the certificate in cert_filename remains valid and -1 Return the days the certificate in cert_filename remains valid and -1
if the file was not found. If cert_filename contains more than one if the file was not found. If cert_filename contains more than one
@@ -439,12 +499,12 @@ class OpenSSLCLIBackend(CryptoBackend):
data = None data = None
if cert_content is not None: if cert_content is not None:
filename = "/dev/stdin" filename = "/dev/stdin"
data = cert_content.encode("utf-8") data = to_bytes(cert_content)
cert_filename_suffix = "" cert_filename_suffix = ""
elif cert_filename is not None: elif cert_filename is not None:
if not os.path.exists(cert_filename): if not os.path.exists(cert_filename):
return -1 return -1
cert_filename_suffix = " in {0}".format(cert_filename) cert_filename_suffix = f" in {cert_filename}"
else: else:
return -1 return -1
@@ -452,7 +512,7 @@ class OpenSSLCLIBackend(CryptoBackend):
self.openssl_binary, self.openssl_binary,
"x509", "x509",
"-in", "-in",
filename, str(filename),
"-noout", "-noout",
"-text", "-text",
] ]
@@ -465,14 +525,12 @@ class OpenSSLCLIBackend(CryptoBackend):
) )
if rc != 0: if rc != 0:
raise BackendException( raise BackendException(
"Error while running {cmd}: {stderr}".format( f"Error while running {' '.join(openssl_cert_cmd)}: {err}"
cmd=" ".join(openssl_cert_cmd), stderr=to_text(err)
)
) )
out_text = to_text(out, errors="surrogate_or_strict") out_text = to_text(out, errors="surrogate_or_strict")
not_after = _extract_date( not_after = _extract_date(
out_text, "Not After", cert_filename_suffix=cert_filename_suffix out_text, name="Not After", cert_filename_suffix=cert_filename_suffix
) )
if now is None: if now is None:
now = self.get_now() now = self.get_now()
@@ -480,7 +538,7 @@ class OpenSSLCLIBackend(CryptoBackend):
now = ensure_utc_timezone(now) now = ensure_utc_timezone(now)
return (not_after - now).days return (not_after - now).days
def create_chain_matcher(self, criterium): def create_chain_matcher(self, *, criterium: Criterium) -> t.NoReturn:
""" """
Given a Criterium object, creates a ChainMatcher object. Given a Criterium object, creates a ChainMatcher object.
""" """
@@ -488,14 +546,19 @@ class OpenSSLCLIBackend(CryptoBackend):
'Alternate chain matching can only be used with the "cryptography" backend.' 'Alternate chain matching can only be used with the "cryptography" backend.'
) )
def get_cert_information(self, cert_filename=None, cert_content=None): def get_cert_information(
self,
*,
cert_filename: str | os.PathLike | None = None,
cert_content: str | bytes | None = None,
) -> CertificateInformation:
""" """
Return some information on a X.509 certificate as a CertificateInformation object. Return some information on a X.509 certificate as a CertificateInformation object.
""" """
filename = cert_filename filename = cert_filename
data = None data = None
if cert_filename is not None: if cert_filename is not None:
cert_filename_suffix = " in {0}".format(cert_filename) cert_filename_suffix = f" in {cert_filename}"
else: else:
filename = "/dev/stdin" filename = "/dev/stdin"
data = to_bytes(cert_content) data = to_bytes(cert_content)
@@ -505,7 +568,7 @@ class OpenSSLCLIBackend(CryptoBackend):
self.openssl_binary, self.openssl_binary,
"x509", "x509",
"-in", "-in",
filename, str(filename),
"-noout", "-noout",
"-text", "-text",
] ]
@@ -518,18 +581,16 @@ class OpenSSLCLIBackend(CryptoBackend):
) )
if rc != 0: if rc != 0:
raise BackendException( raise BackendException(
"Error while running {cmd}: {stderr}".format( f"Error while running {' '.join(openssl_cert_cmd)}: {err}"
cmd=" ".join(openssl_cert_cmd), stderr=to_text(err)
)
) )
out_text = to_text(out, errors="surrogate_or_strict") out_text = to_text(out, errors="surrogate_or_strict")
not_after = _extract_date( not_after = _extract_date(
out_text, "Not After", cert_filename_suffix=cert_filename_suffix out_text, name="Not After", cert_filename_suffix=cert_filename_suffix
) )
not_before = _extract_date( not_before = _extract_date(
out_text, "Not Before", cert_filename_suffix=cert_filename_suffix out_text, name="Not Before", cert_filename_suffix=cert_filename_suffix
) )
sn = re.search( sn = re.search(
@@ -541,13 +602,15 @@ class OpenSSLCLIBackend(CryptoBackend):
serial = int(sn.group(1)) serial = int(sn.group(1))
else: else:
serial = convert_bytes_to_int( serial = convert_bytes_to_int(
_extract_octets(out_text, "Serial Number", required=True) _extract_octets(out_text, name="Serial Number", required=True)
) )
ski = _extract_octets(out_text, "X509v3 Subject Key Identifier", required=False) ski = _extract_octets(
out_text, name="X509v3 Subject Key Identifier", required=False
)
aki = _extract_octets( aki = _extract_octets(
out_text, out_text,
"X509v3 Authority Key Identifier", name="X509v3 Authority Key Identifier",
required=False, required=False,
potential_prefixes=["keyid:", ""], potential_prefixes=["keyid:", ""],
) )
@@ -559,3 +622,6 @@ class OpenSSLCLIBackend(CryptoBackend):
subject_key_identifier=ski, subject_key_identifier=ski,
authority_key_identifier=aki, authority_key_identifier=aki,
) )
__all__ = ("OpenSSLCLIBackend",)

View File

@@ -0,0 +1,242 @@
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import abc
import datetime
import re
import typing as t
from ansible_collections.community.crypto.plugins.module_utils._acme.errors import (
BackendException,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.basic import (
OpenSSLObjectError,
)
from ansible_collections.community.crypto.plugins.module_utils._time import (
UTC,
ensure_utc_timezone,
from_epoch_seconds,
get_epoch_seconds,
get_now_datetime,
get_relative_time_option,
remove_timezone,
)
if t.TYPE_CHECKING:
import os # pragma: no cover
from ansible.module_utils.basic import AnsibleModule # pragma: no cover
from ansible_collections.community.crypto.plugins.module_utils._acme.certificates import ( # pragma: no cover
ChainMatcher,
Criterium,
)
class CertificateInformation(t.NamedTuple):
not_valid_after: datetime.datetime
not_valid_before: datetime.datetime
serial_number: int
subject_key_identifier: bytes | None
authority_key_identifier: bytes | None
_FRACTIONAL_MATCHER = re.compile(
r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(|\.\d+)(Z|[+-]\d{2}:?\d{2}.*)$"
)
def _reduce_fractional_digits(timestamp_str: str) -> str:
"""
Given a RFC 3339 timestamp that includes too many digits for the fractional seconds part, reduces these to at most 6.
"""
# RFC 3339 (https://www.rfc-editor.org/info/rfc3339)
m = _FRACTIONAL_MATCHER.match(timestamp_str)
if not m:
raise BackendException(f"Cannot parse ISO 8601 timestamp {timestamp_str!r}")
timestamp, fractional, timezone = m.groups()
if len(fractional) > 7:
# Python does not support anything smaller than microseconds
# (Golang supports nanoseconds, Boulder often emits more fractional digits, which Python chokes on)
fractional = fractional[:7]
return f"{timestamp}{fractional}{timezone}"
def _parse_acme_timestamp(
timestamp_str: str, *, with_timezone: bool
) -> datetime.datetime:
"""
Parses a RFC 3339 timestamp.
"""
# RFC 3339 (https://www.rfc-editor.org/info/rfc3339)
timestamp_str = _reduce_fractional_digits(timestamp_str)
for time_format in (
"%Y-%m-%dT%H:%M:%SZ",
"%Y-%m-%dT%H:%M:%S.%fZ",
"%Y-%m-%dT%H:%M:%S%z",
"%Y-%m-%dT%H:%M:%S.%f%z",
):
try:
result = datetime.datetime.strptime(timestamp_str, time_format)
except ValueError:
pass
else:
return (
ensure_utc_timezone(result)
if with_timezone
else remove_timezone(result)
)
raise BackendException(f"Cannot parse ISO 8601 timestamp {timestamp_str!r}")
class CryptoBackend(metaclass=abc.ABCMeta):
def __init__(self, *, module: AnsibleModule, with_timezone: bool = False) -> None:
self.module = module
self._with_timezone = with_timezone
def get_now(self) -> datetime.datetime:
return get_now_datetime(with_timezone=self._with_timezone)
def parse_acme_timestamp(self, timestamp_str: str) -> datetime.datetime:
# RFC 3339 (https://www.rfc-editor.org/info/rfc3339)
return _parse_acme_timestamp(timestamp_str, with_timezone=self._with_timezone)
def parse_module_parameter(self, *, value: str, name: str) -> datetime.datetime:
try:
result = get_relative_time_option(
value, input_name=name, with_timezone=self._with_timezone
)
if result is None:
raise BackendException(f"Invalid value for {name}: {value!r}")
return result
except OpenSSLObjectError as exc:
raise BackendException(str(exc)) from exc
def interpolate_timestamp(
self,
timestamp_start: datetime.datetime,
timestamp_end: datetime.datetime,
*,
percentage: float,
) -> datetime.datetime:
start = get_epoch_seconds(timestamp_start)
end = get_epoch_seconds(timestamp_end)
return from_epoch_seconds(
start + percentage * (end - start), with_timezone=self._with_timezone
)
def get_utc_datetime(
self,
year: int,
month: int,
day: int,
hour: int = 0,
minute: int = 0,
second: int = 0,
microsecond: int = 0,
tzinfo: datetime.timezone | None = None,
) -> datetime.datetime:
has_tzinfo = tzinfo is not None
if self._with_timezone and not has_tzinfo:
tzinfo = UTC
result = datetime.datetime(
year, month, day, hour, minute, second, microsecond, tzinfo
)
if self._with_timezone and has_tzinfo:
result = ensure_utc_timezone(result)
return result
@abc.abstractmethod
def parse_key(
self,
*,
key_file: str | os.PathLike | None = None,
key_content: str | None = None,
passphrase: str | None = None,
) -> dict[str, t.Any]:
"""
Parses an RSA or Elliptic Curve key file in PEM format and returns key_data.
Raises KeyParsingError in case of errors.
"""
@abc.abstractmethod
def sign(
self, *, payload64: str, protected64: str, key_data: dict[str, t.Any]
) -> dict[str, t.Any]:
pass
@abc.abstractmethod
def create_mac_key(self, *, alg: str, key: str) -> dict[str, t.Any]:
"""Create a MAC key."""
@abc.abstractmethod
def get_ordered_csr_identifiers(
self,
*,
csr_filename: str | os.PathLike | None = None,
csr_content: str | bytes | None = None,
) -> list[tuple[str, str]]:
"""
Return a list of requested identifiers (CN and SANs) for the CSR.
Each identifier is a pair (type, identifier), where type is either
'dns' or 'ip'.
The list is deduplicated, and if a CNAME is present, it will be returned
as the first element in the result.
"""
@abc.abstractmethod
def get_csr_identifiers(
self,
*,
csr_filename: str | os.PathLike | None = None,
csr_content: str | bytes | None = None,
) -> set[tuple[str, str]]:
"""
Return a set of requested identifiers (CN and SANs) for the CSR.
Each identifier is a pair (type, identifier), where type is either
'dns' or 'ip'.
"""
@abc.abstractmethod
def get_cert_days(
self,
*,
cert_filename: str | os.PathLike | None = None,
cert_content: str | bytes | None = None,
now: datetime.datetime | None = None,
) -> int:
"""
Return the days the certificate in cert_filename remains valid and -1
if the file was not found. If cert_filename contains more than one
certificate, only the first one will be considered.
If now is not specified, datetime.datetime.now() is used.
"""
@abc.abstractmethod
def create_chain_matcher(self, *, criterium: Criterium) -> ChainMatcher:
"""
Given a Criterium object, creates a ChainMatcher object.
"""
@abc.abstractmethod
def get_cert_information(
self,
*,
cert_filename: str | os.PathLike | None = None,
cert_content: str | bytes | None = None,
) -> CertificateInformation:
"""
Return some information on a X.509 certificate as a CertificateInformation object.
"""
__all__ = ("CertificateInformation", "CryptoBackend")

View File

@@ -0,0 +1,418 @@
# Copyright (c) 2024 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import os
import typing as t
from ansible_collections.community.crypto.plugins.module_utils._acme.account import (
ACMEAccount,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.acme import (
ACMEClient,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.certificates import (
CertificateChain,
Criterium,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.challenges import (
Authorization,
wait_for_validation,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.errors import (
ModuleFailException,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.io import (
write_file,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.orders import Order
from ansible_collections.community.crypto.plugins.module_utils._acme.utils import (
pem_to_der,
)
if t.TYPE_CHECKING:
from ansible.module_utils.basic import AnsibleModule # pragma: no cover
from ansible_collections.community.crypto.plugins.module_utils._acme.backends import ( # pragma: no cover
CryptoBackend,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.certificates import ( # pragma: no cover
ChainMatcher,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.challenges import ( # pragma: no cover
Challenge,
)
class ACMECertificateClient:
"""
ACME v2 client class. Uses an ACME account object and a CSR to
start and validate ACME challenges and download the respective
certificates.
"""
def __init__(
self,
*,
module: AnsibleModule,
backend: CryptoBackend,
client: ACMEClient | None = None,
account: ACMEAccount | None = None,
) -> None:
self.module = module
self.version = module.params["acme_version"]
self.csr = module.params.get("csr")
self.csr_content = module.params.get("csr_content")
if client is None:
client = ACMEClient(module=module, backend=backend)
self.client = client
if account is None:
account = ACMEAccount(client=self.client)
self.account = account
self.order_uri = module.params.get("order_uri")
self.order_creation_error_strategy = module.params.get(
"order_creation_error_strategy", "auto"
)
self.order_creation_max_retries = module.params.get(
"order_creation_max_retries", 3
)
# Make sure account exists
dummy, account_data = self.account.setup_account(allow_creation=False)
if account_data is None:
raise ModuleFailException(msg="Account does not exist or is deactivated.")
if self.csr is not None and not os.path.exists(self.csr):
raise ModuleFailException(f"CSR {self.csr} not found")
# Extract list of identifiers from CSR
if self.csr is not None or self.csr_content is not None:
self.identifiers: list[tuple[str, str]] | None = (
self.client.backend.get_ordered_csr_identifiers(
csr_filename=self.csr, csr_content=self.csr_content
)
)
else:
self.identifiers = None
def parse_select_chain(
self, select_chain: list[dict[str, t.Any]] | None
) -> list[ChainMatcher]:
select_chain_matcher = []
if select_chain:
for criterium_idx, criterium in enumerate(select_chain):
try:
select_chain_matcher.append(
self.client.backend.create_chain_matcher(
criterium=Criterium(
criterium=criterium, index=criterium_idx
)
)
)
except ValueError as exc:
self.module.warn(
f"Error while parsing criterium: {exc}. Ignoring criterium."
)
return select_chain_matcher
def load_order(self) -> Order:
if not self.order_uri:
raise ModuleFailException("The order URI has not been provided")
order = Order.from_url(client=self.client, url=self.order_uri)
order.load_authorizations(client=self.client)
return order
def create_order(
self, *, replaces_cert_id: str | None = None, profile: str | None = None
) -> Order:
"""
Create a new order.
"""
if self.identifiers is None:
raise ModuleFailException("No identifiers have been provided")
order = Order.create_with_error_handling(
client=self.client,
identifiers=self.identifiers,
error_strategy=self.order_creation_error_strategy,
error_max_retries=self.order_creation_max_retries,
replaces_cert_id=replaces_cert_id,
profile=profile,
message_callback=self.module.warn,
)
self.order_uri = order.url
order.load_authorizations(client=self.client)
return order
def get_challenges_data(
self, order: Order
) -> tuple[list[dict[str, t.Any]], dict[str, list[str]]]:
"""
Get challenge details.
Return a tuple of generic challenge details, and specialized DNS challenge details.
"""
data: list[dict[str, t.Any]] = []
data_dns: dict[str, list[str]] = {}
dns_challenge_type = "dns-01"
for authz in order.authorizations.values():
# Skip valid authentications: their challenges are already valid
# and do not need to be returned
if authz.status == "valid":
continue
challenge_data = authz.get_challenge_data(client=self.client)
data.append(
{
"identifier": authz.identifier,
"identifier_type": authz.identifier_type,
"challenges": challenge_data,
}
)
dns_challenge = challenge_data.get(dns_challenge_type)
if dns_challenge:
values = data_dns.get(dns_challenge["record"])
if values is None:
values = []
data_dns[dns_challenge["record"]] = values
values.append(dns_challenge["resource_value"])
return data, data_dns
def check_that_authorizations_can_be_used(self, order: Order) -> None:
bad_authzs = []
for authz in order.authorizations.values():
if authz.status not in ("valid", "pending"):
bad_authzs.append(
f"{authz.combined_identifier} (status={authz.status!r})"
)
if bad_authzs:
bad_authzs_str = ", ".join(sorted(bad_authzs))
raise ModuleFailException(
f"Some of the authorizations for the order are in a bad state, so the order can no longer be satisfied: {bad_authzs_str}",
)
def collect_invalid_authzs(self, order: Order) -> list[Authorization]:
return [
authz
for authz in order.authorizations.values()
if authz.status == "invalid"
]
def collect_pending_authzs(self, order: Order) -> list[Authorization]:
return [
authz
for authz in order.authorizations.values()
if authz.status == "pending"
]
def call_validate(
self,
pending_authzs: list[Authorization],
*,
get_challenge: t.Callable[[Authorization], str],
wait: bool = True,
) -> list[tuple[Authorization, str, Challenge | None]]:
authzs_with_challenges_to_wait_for = []
for authz in pending_authzs:
challenge_type = get_challenge(authz)
authz.call_validate(
client=self.client, challenge_type=challenge_type, wait=wait
)
authzs_with_challenges_to_wait_for.append(
(
authz,
challenge_type,
authz.find_challenge(challenge_type=challenge_type),
)
)
return authzs_with_challenges_to_wait_for
def wait_for_validation(self, authzs_to_wait_for: list[Authorization]) -> None:
wait_for_validation(authzs=authzs_to_wait_for, client=self.client)
def _download_alternate_chains(
self, cert: CertificateChain
) -> list[CertificateChain]:
alternate_chains = []
for alternate in cert.alternates:
try:
alt_cert = CertificateChain.download(client=self.client, url=alternate)
except ModuleFailException as e:
self.module.warn(
f"Error while downloading alternative certificate {alternate}: {e}"
)
continue
if alt_cert.cert is not None:
alternate_chains.append(alt_cert)
else:
self.module.warn(
f"Error while downloading alternative certificate {alternate}: no certificate found"
)
return alternate_chains
@t.overload
def download_certificate(
self, order: Order, *, download_all_chains: t.Literal[True] = True
) -> tuple[CertificateChain, list[CertificateChain]]: ...
@t.overload
def download_certificate(
self, order: Order, *, download_all_chains: t.Literal[False]
) -> tuple[CertificateChain, None]: ...
@t.overload
def download_certificate(
self, order: Order, *, download_all_chains: bool = True
) -> tuple[CertificateChain, list[CertificateChain] | None]: ...
def download_certificate(
self, order: Order, *, download_all_chains: bool = True
) -> tuple[CertificateChain, list[CertificateChain] | None]:
"""
Download certificate from a valid oder.
"""
if order.status != "valid":
raise ModuleFailException(
f"The order must be valid, but has state {order.status!r}!"
)
if not order.certificate_uri:
raise ModuleFailException(
f"Order's crtificate URL {order.certificate_uri!r} is empty!"
)
cert = CertificateChain.download(client=self.client, url=order.certificate_uri)
if cert.cert is None:
raise ModuleFailException(
f"Certificate at {order.certificate_uri} is empty!"
)
alternate_chains = None
if download_all_chains:
alternate_chains = self._download_alternate_chains(cert)
return cert, alternate_chains
@t.overload
def get_certificate(
self, order: Order, *, download_all_chains: t.Literal[True] = True
) -> tuple[CertificateChain, list[CertificateChain] | None]: ...
@t.overload
def get_certificate(
self, order: Order, *, download_all_chains: t.Literal[False]
) -> tuple[CertificateChain, list[CertificateChain] | None]: ...
@t.overload
def get_certificate(
self, order: Order, *, download_all_chains: bool = True
) -> tuple[CertificateChain, list[CertificateChain] | None]: ...
def get_certificate(
self, order: Order, *, download_all_chains: bool = True
) -> tuple[CertificateChain, list[CertificateChain] | None]:
"""
Request a new certificate and downloads it, and optionally all certificate chains.
First verifies whether all authorizations are valid; if not, aborts with an error.
"""
if self.csr is None and self.csr_content is None:
raise ModuleFailException("No CSR has been provided")
for authz in order.authorizations.values():
if authz.status != "valid":
authz.raise_error(
error_msg=f'Status is {authz.status!r} and not "valid"',
module=self.module,
)
order.finalize(
client=self.client,
csr_der=pem_to_der(pem_filename=self.csr, pem_content=self.csr_content),
)
return self.download_certificate(order, download_all_chains=download_all_chains)
def find_matching_chain(
self,
*,
chains: list[CertificateChain],
select_chain_matcher: t.Iterable[ChainMatcher],
) -> CertificateChain | None:
for criterium_idx, matcher in enumerate(select_chain_matcher):
for chain in chains:
if matcher.match(certificate=chain):
self.module.debug(
f"Found matching chain for criterium {criterium_idx}"
)
return chain
return None
def write_cert_chain(
self,
*,
cert: CertificateChain,
cert_dest: str | os.PathLike | None = None,
fullchain_dest: str | os.PathLike | None = None,
chain_dest: str | os.PathLike | None = None,
) -> bool:
changed = False
if cert.cert is None:
raise ValueError("Certificate is not present")
if cert_dest and write_file(
module=self.module, dest=cert_dest, content=cert.cert.encode("utf8")
):
changed = True
if fullchain_dest and write_file(
module=self.module,
dest=fullchain_dest,
content=(cert.cert + "\n".join(cert.chain)).encode("utf8"),
):
changed = True
if chain_dest and write_file(
module=self.module,
dest=chain_dest,
content=("\n".join(cert.chain)).encode("utf8"),
):
changed = True
return changed
def deactivate_authzs(self, order: Order) -> None:
"""
Deactivates all valid authz's. Does not raise exceptions.
https://community.letsencrypt.org/t/authorization-deactivation/19860/2
https://tools.ietf.org/html/rfc8555#section-7.5.2
"""
if len(order.authorization_uris) > len(order.authorizations):
for authz_uri in order.authorization_uris:
authz = None
try:
authz = Authorization.deactivate_url(
client=self.client, url=authz_uri
)
except Exception:
# ignore errors
pass
if authz is None or authz.status != "deactivated":
self.module.warn(
warning=f"Could not deactivate authz object {authz_uri}."
)
else:
for authz in order.authorizations.values():
try:
authz.deactivate(client=self.client)
except Exception:
# ignore errors
pass
if authz.status != "deactivated":
self.module.warn(
warning=f"Could not deactivate authz object {authz.url}."
)
__all__ = ("ACMECertificateClient",)

View File

@@ -0,0 +1,132 @@
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import abc
import typing as t
from ansible_collections.community.crypto.plugins.module_utils._acme.errors import (
ModuleFailException,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.utils import (
der_to_pem,
process_links,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.pem import (
split_pem_list,
)
if t.TYPE_CHECKING:
from ansible_collections.community.crypto.plugins.module_utils._acme.acme import ( # pragma: no cover
ACMEClient,
)
_CertificateChain = t.TypeVar("_CertificateChain", bound="CertificateChain")
class CertificateChain:
"""
Download and parse the certificate chain.
https://tools.ietf.org/html/rfc8555#section-7.4.2
"""
def __init__(self, url: str):
self.url = url
self.cert: str | None = None
self.chain: list[str] = []
self.alternates: list[str] = []
@classmethod
def download(
cls: t.Type[_CertificateChain], *, client: ACMEClient, url: str
) -> _CertificateChain:
content, info = client.get_request(
url,
parse_json_result=False,
headers={"Accept": "application/pem-certificate-chain"},
)
if not content or not info["content-type"].startswith(
"application/pem-certificate-chain"
):
raise ModuleFailException(
f"Cannot download certificate chain from {url}, as content type is not application/pem-certificate-chain: {content!r} (headers: {info})"
)
result = cls(url)
# Parse data
certs = split_pem_list(content.decode("utf-8"), keep_inbetween=True)
if certs:
result.cert = certs[0]
result.chain = certs[1:]
process_links(
info=info,
callback=lambda link, relation: result._process_links( # pylint: disable=protected-access
client=client, link=link, relation=relation
),
)
if result.cert is None:
raise ModuleFailException(
f"Failed to parse certificate chain download from {url}: {content!r} (headers: {info})"
)
return result
def _process_links(self, *, client: ACMEClient, link: str, relation: str) -> None:
if relation == "up":
# Process link-up headers if there was no chain in reply
if not self.chain:
chain_result, chain_info = client.get_request(
link, parse_json_result=False
)
if chain_info["status"] in [200, 201]:
self.chain.append(der_to_pem(chain_result))
elif relation == "alternate":
self.alternates.append(link)
def to_json(self) -> dict[str, bytes]:
if self.cert is None:
raise ValueError("Has no certificate")
cert = self.cert.encode("utf8")
chain = ("\n".join(self.chain)).encode("utf8")
return {
"cert": cert,
"chain": chain,
"full_chain": cert + chain,
}
class Criterium:
def __init__(self, *, criterium: dict[str, t.Any], index: int):
self.index = index
self.test_certificates: t.Literal["first", "last", "all"] = criterium[
"test_certificates"
]
self.subject: dict[str, t.Any] | None = criterium["subject"]
self.issuer: dict[str, t.Any] | None = criterium["issuer"]
self.subject_key_identifier: str | None = criterium["subject_key_identifier"]
self.authority_key_identifier: str | None = criterium[
"authority_key_identifier"
]
class ChainMatcher(metaclass=abc.ABCMeta):
@abc.abstractmethod
def match(self, *, certificate: CertificateChain) -> bool:
"""
Check whether a certificate chain (CertificateChain instance) matches.
"""
__all__ = ("CertificateChain", "Criterium", "ChainMatcher")

View File

@@ -0,0 +1,437 @@
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import base64
import hashlib
import ipaddress
import json
import re
import time
import typing as t
from ansible.module_utils.common.text.converters import to_bytes
from ansible_collections.community.crypto.plugins.module_utils._acme.errors import (
ACMEProtocolException,
ModuleFailException,
format_error_problem,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.utils import (
nopad_b64,
)
if t.TYPE_CHECKING:
from ansible.module_utils.basic import AnsibleModule # pragma: no cover
from ansible_collections.community.crypto.plugins.module_utils._acme.acme import ( # pragma: no cover
ACMEClient,
)
def create_key_authorization(*, client: ACMEClient, token: str) -> str:
"""
Returns the key authorization for the given token
https://tools.ietf.org/html/rfc8555#section-8.1
"""
accountkey_json = json.dumps(
client.account_jwk, sort_keys=True, separators=(",", ":")
)
thumbprint = nopad_b64(hashlib.sha256(accountkey_json.encode("utf8")).digest())
return f"{token}.{thumbprint}"
def combine_identifier(*, identifier_type: str, identifier: str) -> str:
return f"{identifier_type}:{identifier}"
def normalize_combined_identifier(identifier: str) -> str:
identifier_type, identifier = split_identifier(identifier)
# Normalize DNS names and IPs
identifier = identifier.lower()
return combine_identifier(identifier_type=identifier_type, identifier=identifier)
def split_identifier(identifier: str) -> tuple[str, str]:
parts = identifier.split(":", 1)
if len(parts) != 2:
raise ModuleFailException(
f'Identifier "{identifier}" is not of the form <type>:<identifier>'
)
return parts[0], parts[1]
_Challenge = t.TypeVar("_Challenge", bound="Challenge")
class Challenge:
def __init__(self, *, data: dict[str, t.Any], url: str) -> None:
self.data = data
self.type: str = data["type"]
self.url = url
self.status: str = data["status"]
self.token: str | None = data.get("token")
@classmethod
def from_json(
cls: t.Type[_Challenge],
*,
client: ACMEClient,
data: dict[str, t.Any],
url: str | None = None,
) -> _Challenge:
return cls(data=data, url=url or data["url"])
def call_validate(self, client: ACMEClient) -> None:
challenge_response: dict[str, t.Any] = {}
client.send_signed_request(
self.url,
challenge_response,
error_msg="Failed to validate challenge",
expected_status_codes=[200, 202],
)
def to_json(self) -> dict[str, t.Any]:
return self.data.copy()
def get_validation_data(
self, *, client: ACMEClient, identifier_type: str, identifier: str
) -> dict[str, t.Any] | None:
if self.token is None:
return None
token = re.sub(r"[^A-Za-z0-9_\-]", "_", self.token)
key_authorization = create_key_authorization(client=client, token=token)
if self.type == "http-01":
# https://tools.ietf.org/html/rfc8555#section-8.3
return {
"resource": f".well-known/acme-challenge/{token}",
"resource_value": key_authorization,
}
if self.type == "dns-01":
if identifier_type != "dns":
return None
# https://tools.ietf.org/html/rfc8555#section-8.4
resource = "_acme-challenge"
value = nopad_b64(hashlib.sha256(to_bytes(key_authorization)).digest())
record = f"{resource}.{identifier[2:] if identifier.startswith('*.') else identifier}"
return {
"resource": resource,
"resource_value": value,
"record": record,
}
if self.type == "tls-alpn-01":
# https://www.rfc-editor.org/rfc/rfc8737.html#section-3
if identifier_type == "ip":
# IPv4/IPv6 address: use reverse mapping (RFC1034, RFC3596)
resource = ipaddress.ip_address(identifier).reverse_pointer
if not resource.endswith("."):
resource += "."
else:
resource = identifier
b_value = base64.b64encode(
hashlib.sha256(to_bytes(key_authorization)).digest()
)
return {
"resource": resource,
"resource_original": combine_identifier(
identifier_type=identifier_type, identifier=identifier
),
"resource_value": b_value,
}
# Unknown challenge type: ignore
return None
_Authorization = t.TypeVar("_Authorization", bound="Authorization")
class Authorization:
def __init__(self, *, url: str) -> None:
self.url = url
self.data: dict[str, t.Any] | None = None
self.challenges: list[Challenge] = []
self.status: str | None = None
self.identifier_type: str | None = None
self.identifier: str | None = None
def _setup(self, *, client: ACMEClient, data: dict[str, t.Any]) -> None:
data["uri"] = self.url
self.data = data
# While 'challenges' is a required field, apparently not every CA cares
# (https://github.com/ansible-collections/community.crypto/issues/824)
if data.get("challenges"):
self.challenges = [
Challenge.from_json(client=client, data=challenge)
for challenge in data["challenges"]
]
else:
self.challenges = []
self.status = data["status"]
self.identifier = data["identifier"]["value"]
self.identifier_type = data["identifier"]["type"]
if data.get("wildcard", False):
self.identifier = f"*.{self.identifier}"
@classmethod
def from_json(
cls: t.Type[_Authorization],
*,
client: ACMEClient,
data: dict[str, t.Any],
url: str,
) -> _Authorization:
result = cls(url=url)
result._setup(client=client, data=data)
return result
@classmethod
def from_url(
cls: t.Type[_Authorization], *, client: ACMEClient, url: str
) -> _Authorization:
result = cls(url=url)
result.refresh(client=client)
return result
@classmethod
def create(
cls: t.Type[_Authorization],
*,
client: ACMEClient,
identifier_type: str,
identifier: str,
) -> _Authorization:
"""
Create a new authorization for the given identifier.
Return the authorization object of the new authorization
https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.4
"""
new_authz = {
"identifier": {
"type": identifier_type,
"value": identifier,
},
}
if "newAuthz" not in client.directory.directory:
raise ACMEProtocolException(
module=client.module,
msg="ACME endpoint does not support pre-authorization",
)
url = client.directory["newAuthz"]
result, info = client.send_signed_request(
url,
new_authz,
error_msg="Failed to request challenges",
expected_status_codes=[200, 201],
)
if not isinstance(result, dict):
raise ACMEProtocolException(
module=client.module,
msg="Unexpected authorization creation result",
content_json=result,
)
return cls.from_json(client=client, data=result, url=info["location"])
@property
def combined_identifier(self) -> str:
if self.identifier_type is None or self.identifier is None:
raise ValueError("Data not present")
return combine_identifier(
identifier_type=self.identifier_type, identifier=self.identifier
)
def to_json(self) -> dict[str, t.Any]:
if self.data is None:
raise ValueError("Data not present")
return self.data.copy()
def refresh(self, *, client: ACMEClient) -> bool:
result, info = client.get_request(self.url)
if not isinstance(result, dict):
raise ACMEProtocolException(
module=client.module,
msg="Unexpected authorization data",
info=info,
content_json=result,
)
changed = self.data != result
self._setup(client=client, data=result)
return changed
def get_challenge_data(self, *, client: ACMEClient) -> dict[str, t.Any]:
"""
Returns a dict with the data for all proposed (and supported) challenges
of the given authorization.
"""
if self.identifier_type is None or self.identifier is None:
raise ValueError("Data not present")
data = {}
for challenge in self.challenges:
validation_data = challenge.get_validation_data(
client=client,
identifier_type=self.identifier_type,
identifier=self.identifier,
)
if validation_data is not None:
data[challenge.type] = validation_data
return data
def raise_error(self, *, error_msg: str, module: AnsibleModule) -> t.NoReturn:
"""
Aborts with a specific error for a challenge.
"""
error_details = []
# multiple challenges could have failed at this point, gather error
# details for all of them before failing
for challenge in self.challenges:
if challenge.status == "invalid":
msg = f"Challenge {challenge.type}"
if "error" in challenge.data:
problem = format_error_problem(
challenge.data["error"],
subproblem_prefix=f"{challenge.type}.",
)
msg = f"{msg}: {problem}"
error_details.append(msg)
raise ACMEProtocolException(
module=module,
msg=f"Failed to validate challenge for {self.combined_identifier}: {error_msg}. {'; '.join(error_details)}",
extras={
"identifier": self.combined_identifier,
"authorization": self.data,
},
)
def find_challenge(self, *, challenge_type: str) -> Challenge | None:
for challenge in self.challenges:
if challenge_type == challenge.type:
return challenge
return None
def wait_for_validation(self, *, client: ACMEClient) -> bool:
while True:
self.refresh(client=client)
if self.status in ["valid", "invalid", "revoked"]:
break
time.sleep(2)
if self.status == "invalid":
self.raise_error(error_msg='Status is "invalid"', module=client.module)
return self.status == "valid"
def call_validate(
self, *, client: ACMEClient, challenge_type: str, wait: bool = True
) -> bool:
"""
Validate the authorization provided in the auth dict. Returns True
when the validation was successful and False when it was not.
"""
challenge = self.find_challenge(challenge_type=challenge_type)
if challenge is None:
raise ModuleFailException(
f'Found no challenge of type "{challenge_type}" for identifier {self.combined_identifier}!'
)
challenge.call_validate(client)
if not wait:
return self.status == "valid"
return self.wait_for_validation(client=client)
def can_deactivate(self) -> bool:
"""
Deactivates this authorization.
https://community.letsencrypt.org/t/authorization-deactivation/19860/2
https://tools.ietf.org/html/rfc8555#section-7.5.2
"""
return self.status in ("valid", "pending")
def deactivate(self, *, client: ACMEClient) -> bool | None:
"""
Deactivates this authorization.
https://community.letsencrypt.org/t/authorization-deactivation/19860/2
https://tools.ietf.org/html/rfc8555#section-7.5.2
"""
if not self.can_deactivate():
return None
authz_deactivate = {"status": "deactivated"}
result, info = client.send_signed_request(
self.url, authz_deactivate, fail_on_error=False
)
if (
200 <= info["status"] < 300
and isinstance(result, dict)
and result.get("status") == "deactivated"
):
self.status = "deactivated"
return True
return False
@classmethod
def deactivate_url(
cls: t.Type[_Authorization], *, client: ACMEClient, url: str
) -> _Authorization:
"""
Deactivates this authorization.
https://community.letsencrypt.org/t/authorization-deactivation/19860/2
https://tools.ietf.org/html/rfc8555#section-7.5.2
"""
authz = cls(url=url)
authz_deactivate = {"status": "deactivated"}
result, _info = client.send_signed_request(
url, authz_deactivate, fail_on_error=True
)
if not isinstance(result, dict):
raise ACMEProtocolException(
module=client.module,
msg="Unexpected challenge deactivation result",
content_json=result,
)
authz._setup(client=client, data=result)
return authz
def wait_for_validation(
*, authzs: t.Iterable[Authorization], client: ACMEClient
) -> None:
"""
Wait until a list of authz is valid. Fail if at least one of them is invalid or revoked.
"""
while authzs:
authzs_next = []
for authz in authzs:
authz.refresh(client=client)
if authz.status in ["valid", "invalid", "revoked"]:
if authz.status != "valid":
authz.raise_error(
error_msg='Status is not "valid"', module=client.module
)
else:
authzs_next.append(authz)
if authzs_next:
time.sleep(2)
authzs = authzs_next
__all__ = (
"create_key_authorization",
"combine_identifier",
"normalize_combined_identifier",
"split_identifier",
"Challenge",
"Authorization",
"wait_for_validation",
)

View File

@@ -0,0 +1,195 @@
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import typing as t
from http.client import responses as http_responses
from ansible.module_utils.common.text.converters import to_text
if t.TYPE_CHECKING:
import http.client # pragma: no cover
import urllib.error # pragma: no cover
from ansible.module_utils.basic import AnsibleModule # pragma: no cover
def format_http_status(status_code: int) -> str:
expl = http_responses.get(status_code)
if not expl:
return str(status_code)
return f"{status_code} {expl}"
def format_error_problem(
problem: dict[str, t.Any], *, subproblem_prefix: str = ""
) -> str:
error_type = problem.get(
"type", "about:blank"
) # https://www.rfc-editor.org/rfc/rfc7807#section-3.1
if "title" in problem:
msg = f'Error "{problem["title"]}" ({error_type})'
else:
msg = f"Error {error_type}"
if "detail" in problem:
msg += f': "{problem["detail"]}"'
subproblems = problem.get("subproblems")
if subproblems is not None:
msg = f"{msg} Subproblems:"
for index, subproblem in enumerate(subproblems):
index_str = f"{subproblem_prefix}{index}"
subproblem_str = format_error_problem(
subproblem, subproblem_prefix=f"{index_str}."
)
msg = f"{msg}\n({index_str}) {subproblem_str}"
return msg
class ModuleFailException(Exception):
"""
If raised, module.fail_json() will be called with the given parameters after cleanup.
"""
def __init__(self, msg: str, **args: t.Any) -> None:
super().__init__(self, msg)
self.msg = msg
self.module_fail_args = args
def do_fail(self, *, module: AnsibleModule, **arguments: t.Any) -> t.NoReturn:
module.fail_json(msg=self.msg, other=self.module_fail_args, **arguments)
class ACMEProtocolException(ModuleFailException):
def __init__(
self,
*,
module: AnsibleModule,
msg: str | None = None,
info: dict[str, t.Any] | None = None,
response: urllib.error.HTTPError | http.client.HTTPResponse | None = None,
content: bytes | None = None,
content_json: object | bytes | None = None,
extras: dict[str, t.Any] | None = None,
) -> None:
# Try to get hold of content, if response is given and content is not provided
if content is None and content_json is None and response is not None:
try:
# In Python 2, reading from a closed response yields a TypeError.
# In Python 3, read() simply returns ''
if response.closed:
raise TypeError
content = response.read()
except (AttributeError, TypeError):
if info is not None:
content = info.pop("body", None)
# Make sure that content_json is None or a dictionary
content_json_json: dict[str, t.Any] | None = None
if content_json is not None and not isinstance(content_json, dict):
if content is None and isinstance(content_json, bytes):
content = content_json
content_json = None
elif content_json is not None:
content_json_json = content_json.copy()
# Try to get hold of JSON decoded content, when content is given and JSON not provided
if content_json_json is None and content is not None and module is not None:
try:
cjj = module.from_json(to_text(content))
if isinstance(cjj, dict):
content_json_json = cjj
except Exception:
pass
extras = extras or {}
error_code = None
error_type = None
if msg is None:
msg = "ACME request failed"
add_msg = ""
if info is not None:
url = info["url"]
code = info["status"]
extras["http_url"] = url
extras["http_status"] = code
error_code = code
if (
code is not None
and code >= 400
and content_json_json is not None
and "type" in content_json_json
):
error_type = content_json_json["type"]
if (
"status" in content_json_json
and content_json_json["status"] != code
):
code_msg = f"status {content_json_json['status']} (HTTP status: {format_http_status(code)})"
else:
code_msg = f"status {format_http_status(code)}"
if code == -1 and info.get("msg"):
code_msg = f"error: {info['msg']}"
subproblems = content_json_json.pop("subproblems", None)
add_msg = f" {format_error_problem(content_json_json)}."
extras["problem"] = content_json_json
extras["subproblems"] = subproblems or []
if subproblems is not None:
add_msg = f"{add_msg} Subproblems:"
for index, problem in enumerate(subproblems):
problem = format_error_problem(
problem, subproblem_prefix=f"{index}."
)
add_msg = f"{add_msg}\n({index}) {problem}."
else:
code_msg = f"HTTP status {format_http_status(code)}"
if code == -1 and info.get("msg"):
code_msg = f"error: {info['msg']}"
if content_json_json is not None:
add_msg = f" The JSON error result: {content_json_json}"
elif content is not None:
add_msg = f" The raw error result: {to_text(content)}"
msg = f"{msg} for {url} with {code_msg}"
elif content_json_json is not None:
add_msg = f" The JSON result: {content_json_json}"
elif content is not None:
add_msg = f" The raw result: {to_text(content)}"
super().__init__(f"{msg}.{add_msg}", **extras)
self.problem: dict[str, t.Any] = {}
self.subproblems: list[dict[str, t.Any]] = []
self.error_code = error_code
self.error_type = error_type
for k, v in extras.items():
setattr(self, k, v)
class BackendException(ModuleFailException):
pass
class NetworkException(ModuleFailException):
pass
class KeyParsingError(ModuleFailException):
pass
__all__ = (
"format_http_status",
"format_error_problem",
"ModuleFailException",
"ACMEProtocolException",
"BackendException",
"NetworkException",
"KeyParsingError",
)

View File

@@ -1,38 +1,41 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2013, Romeo Theriault <romeot () hawaii.edu> # Copyright (c) 2013, Romeo Theriault <romeot () hawaii.edu>
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net> # Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de> # Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
__metaclass__ = type
from __future__ import annotations
import os import os
import shutil import shutil
import tempfile import tempfile
import traceback import traceback
import typing as t
from ansible.module_utils.common.text.converters import to_native from ansible_collections.community.crypto.plugins.module_utils._acme.errors import (
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
ModuleFailException, ModuleFailException,
) )
def read_file(fn, mode="b"): if t.TYPE_CHECKING:
from ansible.module_utils.basic import AnsibleModule # pragma: no cover
def read_file(fn: str | os.PathLike) -> bytes:
try: try:
with open(fn, "r" + mode) as f: with open(fn, "rb") as f:
return f.read() return f.read()
except Exception as e: except Exception as e:
raise ModuleFailException('Error while reading file "{0}": {1}'.format(fn, e)) raise ModuleFailException(f'Error while reading file "{fn}": {e}') from e
# This function was adapted from an earlier version of https://github.com/ansible/ansible/blob/devel/lib/ansible/modules/uri.py # This function was adapted from an earlier version of https://github.com/ansible/ansible/blob/devel/lib/ansible/modules/uri.py
def write_file(module, dest, content): def write_file(
*, module: AnsibleModule, dest: str | os.PathLike[str], content: bytes
) -> bool:
""" """
Write content to destination file dest, only if the content Write content to destination file dest, only if the content
has changed. has changed.
@@ -50,9 +53,9 @@ def write_file(module, dest, content):
pass pass
os.remove(tmpsrc) os.remove(tmpsrc)
raise ModuleFailException( raise ModuleFailException(
"failed to create temporary content file: %s" % to_native(err), f"failed to create temporary content file: {err}",
exception=traceback.format_exc(), exception=traceback.format_exc(),
) ) from err
f.close() f.close()
checksum_src = None checksum_src = None
checksum_dest = None checksum_dest = None
@@ -62,26 +65,26 @@ def write_file(module, dest, content):
os.remove(tmpsrc) os.remove(tmpsrc)
except Exception: except Exception:
pass pass
raise ModuleFailException("Source %s does not exist" % (tmpsrc)) raise ModuleFailException(f"Source {tmpsrc} does not exist")
if not os.access(tmpsrc, os.R_OK): if not os.access(tmpsrc, os.R_OK):
os.remove(tmpsrc) os.remove(tmpsrc)
raise ModuleFailException("Source %s not readable" % (tmpsrc)) raise ModuleFailException(f"Source {tmpsrc} not readable")
checksum_src = module.sha1(tmpsrc) checksum_src = module.sha1(tmpsrc)
# check if there is no dest file # check if there is no dest file
if os.path.exists(dest): if os.path.exists(dest):
# raise an error if copy has no permission on dest # raise an error if copy has no permission on dest
if not os.access(dest, os.W_OK): if not os.access(dest, os.W_OK):
os.remove(tmpsrc) os.remove(tmpsrc)
raise ModuleFailException("Destination %s not writable" % (dest)) raise ModuleFailException(f"Destination {dest} not writable")
if not os.access(dest, os.R_OK): if not os.access(dest, os.R_OK):
os.remove(tmpsrc) os.remove(tmpsrc)
raise ModuleFailException("Destination %s not readable" % (dest)) raise ModuleFailException(f"Destination {dest} not readable")
checksum_dest = module.sha1(dest) checksum_dest = module.sha1(str(dest))
else: else:
dirname = os.path.dirname(dest) or "." dirname = os.path.dirname(dest) or "."
if not os.access(dirname, os.W_OK): if not os.access(dirname, os.W_OK):
os.remove(tmpsrc) os.remove(tmpsrc)
raise ModuleFailException("Destination dir %s not writable" % (dirname)) raise ModuleFailException(f"Destination dir {dirname} not writable")
if checksum_src != checksum_dest: if checksum_src != checksum_dest:
try: try:
shutil.copyfile(tmpsrc, dest) shutil.copyfile(tmpsrc, dest)
@@ -89,8 +92,11 @@ def write_file(module, dest, content):
except Exception as err: except Exception as err:
os.remove(tmpsrc) os.remove(tmpsrc)
raise ModuleFailException( raise ModuleFailException(
"failed to copy %s to %s: %s" % (tmpsrc, dest, to_native(err)), f"failed to copy {tmpsrc} to {dest}: {err}",
exception=traceback.format_exc(), exception=traceback.format_exc(),
) ) from err
os.remove(tmpsrc) os.remove(tmpsrc)
return changed return changed
__all__ = ("read_file", "write_file")

View File

@@ -1,32 +1,53 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net> # Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de> # Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
__metaclass__ = type
from __future__ import annotations
import time import time
import typing as t
from ansible_collections.community.crypto.plugins.module_utils.acme.challenges import ( from ansible_collections.community.crypto.plugins.module_utils._acme.challenges import (
Authorization, Authorization,
normalize_combined_identifier, normalize_combined_identifier,
) )
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( from ansible_collections.community.crypto.plugins.module_utils._acme.errors import (
ACMEProtocolException, ACMEProtocolException,
ModuleFailException,
) )
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import ( from ansible_collections.community.crypto.plugins.module_utils._acme.utils import (
nopad_b64, nopad_b64,
) )
class Order(object): if t.TYPE_CHECKING:
def _setup(self, client, data): from ansible_collections.community.crypto.plugins.module_utils._acme.acme import ( # pragma: no cover
ACMEClient,
)
_Order = t.TypeVar("_Order", bound="Order")
class Order:
def __init__(self, *, url: str) -> None:
self.url = url
self.data: dict[str, t.Any] | None = None
self.status: str | None = None
self.identifiers: list[tuple[str, str]] = []
self.replaces_cert_id: str | None = None
self.finalize_uri: str | None = None
self.certificate_uri: str | None = None
self.authorization_uris: list[str] = []
self.authorizations: dict[str, Authorization] = {}
def _setup(self, *, client: ACMEClient, data: dict[str, t.Any]) -> None:
self.data = data self.data = data
self.status = data["status"] self.status = data["status"]
@@ -39,33 +60,29 @@ class Order(object):
self.authorization_uris = data["authorizations"] self.authorization_uris = data["authorizations"]
self.authorizations = {} self.authorizations = {}
def __init__(self, url):
self.url = url
self.data = None
self.status = None
self.identifiers = []
self.replaces_cert_id = None
self.finalize_uri = None
self.certificate_uri = None
self.authorization_uris = []
self.authorizations = {}
@classmethod @classmethod
def from_json(cls, client, data, url): def from_json(
result = cls(url) cls: t.Type[_Order], *, client: ACMEClient, data: dict[str, t.Any], url: str
result._setup(client, data) ) -> _Order:
result = cls(url=url)
result._setup(client=client, data=data)
return result return result
@classmethod @classmethod
def from_url(cls, client, url): def from_url(cls: t.Type[_Order], *, client: ACMEClient, url: str) -> _Order:
result = cls(url) result = cls(url=url)
result.refresh(client) result.refresh(client=client)
return result return result
@classmethod @classmethod
def create(cls, client, identifiers, replaces_cert_id=None, profile=None): def create(
cls: t.Type[_Order],
*,
client: ACMEClient,
identifiers: list[tuple[str, str]],
replaces_cert_id: str | None = None,
profile: str | None = None,
) -> _Order:
""" """
Start a new certificate order (ACME v2 protocol). Start a new certificate order (ACME v2 protocol).
https://tools.ietf.org/html/rfc8555#section-7.4 https://tools.ietf.org/html/rfc8555#section-7.4
@@ -78,7 +95,7 @@ class Order(object):
"value": identifier, "value": identifier,
} }
) )
new_order = {"identifiers": acme_identifiers} new_order: dict[str, t.Any] = {"identifiers": acme_identifiers}
if replaces_cert_id is not None: if replaces_cert_id is not None:
new_order["replaces"] = replaces_cert_id new_order["replaces"] = replaces_cert_id
if profile is not None: if profile is not None:
@@ -89,19 +106,28 @@ class Order(object):
error_msg="Failed to start new order", error_msg="Failed to start new order",
expected_status_codes=[201], expected_status_codes=[201],
) )
return cls.from_json(client, result, info["location"]) if not isinstance(result, dict):
raise ACMEProtocolException(
module=client.module,
msg="Unexpected new order response",
content_json=result,
)
return cls.from_json(client=client, data=result, url=info["location"])
@classmethod @classmethod
def create_with_error_handling( def create_with_error_handling(
cls, cls: t.Type[_Order],
client, *,
identifiers, client: ACMEClient,
error_strategy="auto", identifiers: list[tuple[str, str]],
error_max_retries=3, error_strategy: t.Literal[
replaces_cert_id=None, "auto", "fail", "always", "retry_without_replaces_cert_id"
profile=None, ] = "auto",
message_callback=None, error_max_retries: int = 3,
): replaces_cert_id: str | None = None,
profile: str | None = None,
message_callback: t.Callable[[str], None] | None = None,
) -> _Order:
""" """
error_strategy can be one of the following strings: error_strategy can be one of the following strings:
@@ -118,8 +144,8 @@ class Order(object):
tries += 1 tries += 1
try: try:
return cls.create( return cls.create(
client, client=client,
identifiers, identifiers=identifiers,
replaces_cert_id=replaces_cert_id, replaces_cert_id=replaces_cert_id,
profile=profile, profile=profile,
) )
@@ -139,52 +165,57 @@ class Order(object):
): ):
if message_callback: if message_callback:
message_callback( message_callback(
"Stop passing `replaces={replaces}` due to error {code} {type} when creating ACME order".format( f"Stop passing `replaces={replaces_cert_id}` due to error {exc.error_code} {exc.error_type} when creating ACME order"
code=exc.error_code,
type=exc.error_type,
replaces=replaces_cert_id,
)
) )
replaces_cert_id = None replaces_cert_id = None
continue continue
raise raise
def refresh(self, client): def refresh(self, *, client: ACMEClient) -> bool:
result, dummy = client.get_request(self.url) result, info = client.get_request(self.url)
if not isinstance(result, dict):
raise ACMEProtocolException(
module=client.module,
msg="Unexpected authorization data",
info=info,
content_json=result,
)
changed = self.data != result changed = self.data != result
self._setup(client, result) self._setup(client=client, data=result)
return changed return changed
def load_authorizations(self, client): def load_authorizations(self, *, client: ACMEClient) -> None:
for auth_uri in self.authorization_uris: for auth_uri in self.authorization_uris:
authz = Authorization.from_url(client, auth_uri) authz = Authorization.from_url(client=client, url=auth_uri)
self.authorizations[ self.authorizations[
normalize_combined_identifier(authz.combined_identifier) normalize_combined_identifier(authz.combined_identifier)
] = authz ] = authz
def wait_for_finalization(self, client): def wait_for_finalization(self, *, client: ACMEClient) -> None:
while True: while True:
self.refresh(client) self.refresh(client=client)
if self.status in ["valid", "invalid", "pending", "ready"]: if self.status in ["valid", "invalid", "pending", "ready"]:
break break
time.sleep(2) time.sleep(2)
if self.status != "valid": if self.status != "valid":
raise ACMEProtocolException( raise ACMEProtocolException(
client.module, module=client.module,
'Failed to wait for order to complete; got status "{status}"'.format( msg=f'Failed to wait for order to complete; got status "{self.status}"',
status=self.status
),
content_json=self.data, content_json=self.data,
) )
def finalize(self, client, csr_der, wait=True): def finalize(
self, *, client: ACMEClient, csr_der: bytes, wait: bool = True
) -> None:
""" """
Create a new certificate based on the csr. Create a new certificate based on the csr.
Return the certificate object as dict Return the certificate object as dict
https://tools.ietf.org/html/rfc8555#section-7.4 https://tools.ietf.org/html/rfc8555#section-7.4
""" """
if self.finalize_uri is None:
raise ModuleFailException("finalize_uri must be set")
new_cert = { new_cert = {
"csr": nopad_b64(csr_der), "csr": nopad_b64(csr_der),
} }
@@ -198,15 +229,16 @@ class Order(object):
# Instead of using the result, we call self.refresh(client) below. # Instead of using the result, we call self.refresh(client) below.
if wait: if wait:
self.wait_for_finalization(client) self.wait_for_finalization(client=client)
else: else:
self.refresh(client) self.refresh(client=client)
if self.status not in ["procesing", "valid", "invalid"]: if self.status not in ["procesing", "valid", "invalid"]:
raise ACMEProtocolException( raise ACMEProtocolException(
client.module, module=client.module,
'Failed to finalize order; got status "{status}"'.format( msg=f'Failed to finalize order; got status "{self.status}"',
status=self.status
),
info=info, info=info,
content_json=result, content_json=result,
) )
__all__ = ("Order",)

View File

@@ -1,49 +1,55 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net> # Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de> # Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
__metaclass__ = type
from __future__ import annotations
import base64 import base64
import datetime import datetime
import os
import re import re
import textwrap import textwrap
import traceback import traceback
import typing as t
from urllib.parse import unquote
from ansible.module_utils.common.text.converters import to_native from ansible_collections.community.crypto.plugins.module_utils._acme.errors import (
from ansible.module_utils.six.moves.urllib.parse import unquote
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
ModuleFailException, ModuleFailException,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.math import ( from ansible_collections.community.crypto.plugins.module_utils._crypto.math import (
convert_int_to_bytes, convert_int_to_bytes,
) )
from ansible_collections.community.crypto.plugins.module_utils.time import ( from ansible_collections.community.crypto.plugins.module_utils._time import (
get_now_datetime, get_now_datetime,
) )
def nopad_b64(data): if t.TYPE_CHECKING:
return base64.urlsafe_b64encode(data).decode("utf8").replace("=", "") from ansible_collections.community.crypto.plugins.module_utils._acme.backends import ( # pragma: no cover
CertificateInformation,
CryptoBackend,
def der_to_pem(der_cert):
"""
Convert the DER format certificate in der_cert to a PEM format certificate and return it.
"""
return """-----BEGIN CERTIFICATE-----\n{0}\n-----END CERTIFICATE-----\n""".format(
"\n".join(textwrap.wrap(base64.b64encode(der_cert).decode("utf8"), 64))
) )
def pem_to_der(pem_filename=None, pem_content=None): def nopad_b64(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode("utf8").replace("=", "")
def der_to_pem(der_cert: bytes) -> str:
"""
Convert the DER format certificate in der_cert to a PEM format certificate and return it.
"""
content = "\n".join(textwrap.wrap(base64.b64encode(der_cert).decode("utf8"), 64))
return f"-----BEGIN CERTIFICATE-----\n{content}\n-----END CERTIFICATE-----\n"
def pem_to_der(
*, pem_filename: str | os.PathLike | None = None, pem_content: str | None = None
) -> bytes:
""" """
Load PEM file, or use PEM file's content, and convert to DER. Load PEM file, or use PEM file's content, and convert to DER.
@@ -54,13 +60,13 @@ def pem_to_der(pem_filename=None, pem_content=None):
lines = pem_content.splitlines() lines = pem_content.splitlines()
elif pem_filename is not None: elif pem_filename is not None:
try: try:
with open(pem_filename, "rt") as f: with open(pem_filename, "r", encoding="utf-8") as f:
lines = list(f) lines = list(f)
except Exception as err: except Exception as err:
raise ModuleFailException( raise ModuleFailException(
"cannot load PEM file {0}: {1}".format(pem_filename, to_native(err)), f"cannot load PEM file {pem_filename}: {err}",
exception=traceback.format_exc(), exception=traceback.format_exc(),
) ) from err
else: else:
raise ModuleFailException( raise ModuleFailException(
"One of pem_filename and pem_content must be provided" "One of pem_filename and pem_content must be provided"
@@ -78,7 +84,9 @@ def pem_to_der(pem_filename=None, pem_content=None):
return base64.b64decode("".join(certificate_lines)) return base64.b64decode("".join(certificate_lines))
def process_links(info, callback): def process_links(
*, info: dict[str, t.Any], callback: t.Callable[[str, str], None]
) -> None:
""" """
Process link header, calls callback for every link header with the URL and relation as options. Process link header, calls callback for every link header with the URL and relation as options.
@@ -90,7 +98,12 @@ def process_links(info, callback):
callback(unquote(url), relation) callback(unquote(url), relation)
def parse_retry_after(value, relative_with_timezone=True, now=None): def parse_retry_after(
value: str,
*,
relative_with_timezone: bool = True,
now: datetime.datetime | None = None,
) -> datetime.datetime:
""" """
Parse the value of a Retry-After header and return a timestamp. Parse the value of a Retry-After header and return a timestamp.
@@ -100,7 +113,7 @@ def parse_retry_after(value, relative_with_timezone=True, now=None):
try: try:
delta = datetime.timedelta(seconds=int(value)) delta = datetime.timedelta(seconds=int(value))
if now is None: if now is None:
now = get_now_datetime(relative_with_timezone) now = get_now_datetime(with_timezone=relative_with_timezone)
return now + delta return now + delta
except ValueError: except ValueError:
pass pass
@@ -110,16 +123,17 @@ def parse_retry_after(value, relative_with_timezone=True, now=None):
except ValueError: except ValueError:
pass pass
raise ValueError("Cannot parse Retry-After header value %s" % repr(value)) raise ValueError(f"Cannot parse Retry-After header value {repr(value)}")
def compute_cert_id( def compute_cert_id(
backend, *,
cert_info=None, backend: CryptoBackend,
cert_filename=None, cert_info: CertificateInformation | None = None,
cert_content=None, cert_filename: str | os.PathLike | None = None,
none_if_required_information_is_missing=False, cert_content: str | bytes | None = None,
): none_if_required_information_is_missing: bool = False,
) -> str | None:
# Obtain certificate info if not provided # Obtain certificate info if not provided
if cert_info is None: if cert_info is None:
cert_info = backend.get_cert_information( cert_info = backend.get_cert_information(
@@ -133,15 +147,27 @@ def compute_cert_id(
raise ModuleFailException( raise ModuleFailException(
"Certificate has no Authority Key Identifier extension" "Certificate has no Authority Key Identifier extension"
) )
aki = to_native( aki = (
base64.urlsafe_b64encode(cert_info.authority_key_identifier) (base64.urlsafe_b64encode(cert_info.authority_key_identifier))
).replace("=", "") .decode("ascii")
.replace("=", "")
)
# Convert serial number to string # Convert serial number to string
serial_bytes = convert_int_to_bytes(cert_info.serial_number) serial_bytes = convert_int_to_bytes(cert_info.serial_number)
if ord(serial_bytes[:1]) >= 128: if ord(serial_bytes[:1]) >= 128:
serial_bytes = b"\x00" + serial_bytes serial_bytes = b"\x00" + serial_bytes
serial = to_native(base64.urlsafe_b64encode(serial_bytes)).replace("=", "") serial = (base64.urlsafe_b64encode(serial_bytes)).decode("ascii").replace("=", "")
# Compose cert ID # Compose cert ID
return "{aki}.{serial}".format(aki=aki, serial=serial) return f"{aki}.{serial}"
__all__ = (
"nopad_b64",
"der_to_pem",
"pem_to_der",
"process_links",
"parse_retry_after",
"compute_cert_id",
)

View File

@@ -0,0 +1,124 @@
# Copyright (c) 2020, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import typing as t
from ansible.module_utils.basic import AnsibleModule
_T = t.TypeVar("_T")
def _ensure_list(value: list[_T] | tuple[_T] | None) -> list[_T]:
if value is None:
return []
return list(value)
class ArgumentSpec:
def __init__(
self,
argument_spec: dict[str, t.Any] | None = None,
*,
mutually_exclusive: list[list[str] | tuple[str, ...]] | None = None,
required_together: list[list[str] | tuple[str, ...]] | None = None,
required_one_of: list[list[str] | tuple[str, ...]] | None = None,
required_if: (
list[
tuple[str, t.Any, list[str] | tuple[str, ...]]
| tuple[str, t.Any, list[str] | tuple[str, ...], bool]
]
| None
) = None,
required_by: dict[str, tuple[str, ...] | list[str]] | None = None,
) -> None:
self.argument_spec = argument_spec or {}
self.mutually_exclusive = _ensure_list(mutually_exclusive)
self.required_together = _ensure_list(required_together)
self.required_one_of = _ensure_list(required_one_of)
self.required_if = _ensure_list(required_if)
self.required_by = required_by or {}
def update_argspec(self, **kwargs: t.Any) -> t.Self:
self.argument_spec.update(kwargs)
return self
def update(
self,
*,
mutually_exclusive: list[list[str] | tuple[str, ...]] | None = None,
required_together: list[list[str] | tuple[str, ...]] | None = None,
required_one_of: list[list[str] | tuple[str, ...]] | None = None,
required_if: (
list[
tuple[str, t.Any, list[str] | tuple[str, ...]]
| tuple[str, t.Any, list[str] | tuple[str, ...], bool]
]
| None
) = None,
required_by: dict[str, tuple[str, ...] | list[str]] | None = None,
) -> t.Self:
if mutually_exclusive:
self.mutually_exclusive.extend(mutually_exclusive)
if required_together:
self.required_together.extend(required_together)
if required_one_of:
self.required_one_of.extend(required_one_of)
if required_if:
self.required_if.extend(required_if)
if required_by:
for k, v in required_by.items():
if k in self.required_by:
v = list(self.required_by[k]) + list(v)
self.required_by[k] = v
return self
def merge(self, other: t.Self) -> t.Self:
self.update_argspec(**other.argument_spec)
self.update(
mutually_exclusive=other.mutually_exclusive,
required_together=other.required_together,
required_one_of=other.required_one_of,
required_if=other.required_if,
required_by=other.required_by,
)
return self
def create_ansible_module_helper(
self, clazz: type[_T], args: tuple, **kwargs: t.Any
) -> _T:
for forbidden_name in (
"argument_spec",
"mutually_exclusive",
"required_together",
"required_one_of",
"required_if",
"required_by",
):
if forbidden_name in kwargs:
raise ValueError(
f"You must not provide a {forbidden_name} keyword parameter to create_ansible_module_helper()"
)
instance = clazz( # type: ignore
*args,
argument_spec=self.argument_spec,
mutually_exclusive=self.mutually_exclusive,
required_together=self.required_together,
required_one_of=self.required_one_of,
required_if=self.required_if,
required_by=self.required_by,
**kwargs,
)
return instance
def create_ansible_module(self, **kwargs: t.Any) -> AnsibleModule:
return self.create_ansible_module_helper(AnsibleModule, (), **kwargs)
__all__ = ("ArgumentSpec",)

View File

@@ -1,55 +1,52 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Jordan Borean <jborean93@gmail.com> # Copyright (c) 2020, Jordan Borean <jborean93@gmail.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
__metaclass__ = type import enum
import re import re
from ansible.module_utils.common.text.converters import to_bytes from ansible.module_utils.common.text.converters import to_bytes
""" # An ASN.1 serialized as a string in the OpenSSL format:
An ASN.1 serialized as a string in the OpenSSL format: # [modifier,]type[:value]
[modifier,]type[:value] #
# 'modifier':
modifier: # The modifier can be 'IMPLICIT:<tag_number><tag_class>,' or 'EXPLICIT:<tag_number><tag_class>' where IMPLICIT
The modifier can be 'IMPLICIT:<tag_number><tag_class>,' or 'EXPLICIT:<tag_number><tag_class>' where IMPLICIT # changes the tag of the universal value to encode and EXPLICIT prefixes its tag to the existing universal value.
changes the tag of the universal value to encode and EXPLICIT prefixes its tag to the existing universal value. # The tag_number must be set while the tag_class can be 'U', 'A', 'P', or 'C" for 'Universal', 'Application',
The tag_number must be set while the tag_class can be 'U', 'A', 'P', or 'C" for 'Universal', 'Application', # 'Private', or 'Context Specific' with C being the default.
'Private', or 'Context Specific' with C being the default. #
# 'type':
type: # The underlying ASN.1 type of the value specified. Currently only the following have been implemented:
The underlying ASN.1 type of the value specified. Currently only the following have been implemented: # UTF8: The value must be a UTF-8 encoded string.
UTF8: The value must be a UTF-8 encoded string. #
# 'value':
value: # The value to encode, the format of this value depends on the <type> specified.
The value to encode, the format of this value depends on the <type> specified.
"""
ASN1_STRING_REGEX = re.compile( ASN1_STRING_REGEX = re.compile(
r"^((?P<tag_type>IMPLICIT|EXPLICIT):(?P<tag_number>\d+)(?P<tag_class>U|A|P|C)?,)?" r"^((?P<tag_type>IMPLICIT|EXPLICIT):(?P<tag_number>\d+)(?P<tag_class>U|A|P|C)?,)?"
r"(?P<value_type>[\w\d]+):(?P<value>.*)" r"(?P<value_type>[\w\d]+):(?P<value>.*)"
) )
class TagClass: class TagClass(enum.Enum):
universal = 0 UNIVERSAL = 0
application = 1 APPLICATION = 1
context_specific = 2 CONTEXT_SPECIFIC = 2
private = 3 PRIVATE = 3
# Universal tag numbers that can be encoded. # Universal tag numbers that can be encoded.
class TagNumber: class TagNumber(enum.Enum):
utf8_string = 12 UTF8_STRING = 12
def _pack_octet_integer(value): def _pack_octet_integer(value: int) -> bytes:
"""Packs an integer value into 1 or multiple octets.""" """Packs an integer value into 1 or multiple octets."""
# NOTE: This is *NOT* the same as packing an ASN.1 INTEGER like value. # NOTE: This is *NOT* the same as packing an ASN.1 INTEGER like value.
octets = bytearray() octets = bytearray()
@@ -71,7 +68,7 @@ def _pack_octet_integer(value):
return bytes(octets) return bytes(octets)
def serialize_asn1_string_as_der(value): def serialize_asn1_string_as_der(value: str) -> bytes:
"""Deserializes an ASN.1 string to a DER encoded byte string.""" """Deserializes an ASN.1 string to a DER encoded byte string."""
asn1_match = ASN1_STRING_REGEX.match(value) asn1_match = ASN1_STRING_REGEX.match(value)
if not asn1_match: if not asn1_match:
@@ -87,32 +84,47 @@ def serialize_asn1_string_as_der(value):
if value_type != "UTF8": if value_type != "UTF8":
raise ValueError( raise ValueError(
'The ASN.1 serialized string is not a known type "{0}", only UTF8 types are ' f'The ASN.1 serialized string is not a known type "{value_type}", only UTF8 types are supported'
"supported".format(value_type)
) )
b_value = to_bytes(asn1_value, encoding="utf-8", errors="surrogate_or_strict") b_value = to_bytes(asn1_value, encoding="utf-8", errors="surrogate_or_strict")
# We should only do a universal type tag if not IMPLICITLY tagged or the tag class is not universal. # We should only do a universal type tag if not IMPLICITLY tagged or the tag class is not universal.
if not tag_type or (tag_type == "EXPLICIT" and tag_class != "U"): if not tag_type or (tag_type == "EXPLICIT" and tag_class != "U"):
b_value = pack_asn1(TagClass.universal, False, TagNumber.utf8_string, b_value) b_value = pack_asn1(
tag_class=TagClass.UNIVERSAL,
constructed=False,
tag_number=TagNumber.UTF8_STRING,
b_data=b_value,
)
if tag_type: if tag_type:
tag_class = { tag_class_enum = {
"U": TagClass.universal, "U": TagClass.UNIVERSAL,
"A": TagClass.application, "A": TagClass.APPLICATION,
"P": TagClass.private, "P": TagClass.PRIVATE,
"C": TagClass.context_specific, "C": TagClass.CONTEXT_SPECIFIC,
}[tag_class] }[tag_class]
# When adding support for more types this should be looked into further. For now it works with UTF8Strings. # When adding support for more types this should be looked into further. For now it works with UTF8Strings.
constructed = tag_type == "EXPLICIT" and tag_class != TagClass.universal constructed = tag_type == "EXPLICIT" and tag_class_enum != TagClass.UNIVERSAL
b_value = pack_asn1(tag_class, constructed, int(tag_number), b_value) b_value = pack_asn1(
tag_class=tag_class_enum,
constructed=constructed,
tag_number=int(tag_number),
b_data=b_value,
)
return b_value return b_value
def pack_asn1(tag_class, constructed, tag_number, b_data): def pack_asn1(
*,
tag_class: TagClass,
constructed: bool,
tag_number: TagNumber | int,
b_data: bytes,
) -> bytes:
"""Pack the value into an ASN.1 data structure. """Pack the value into an ASN.1 data structure.
The structure for an ASN.1 element is The structure for an ASN.1 element is
@@ -121,16 +133,15 @@ def pack_asn1(tag_class, constructed, tag_number, b_data):
""" """
b_asn1_data = bytearray() b_asn1_data = bytearray()
if tag_class < 0 or tag_class > 3:
raise ValueError("tag_class must be between 0 and 3 not %s" % tag_class)
# Bit 8 and 7 denotes the class. # Bit 8 and 7 denotes the class.
identifier_octets = tag_class << 6 identifier_octets = tag_class.value << 6
# Bit 6 denotes whether the value is primitive or constructed. # Bit 6 denotes whether the value is primitive or constructed.
identifier_octets |= (1 if constructed else 0) << 5 identifier_octets |= (1 if constructed else 0) << 5
# Bits 5-1 contain the tag number, if it cannot be encoded in these 5 bits # Bits 5-1 contain the tag number, if it cannot be encoded in these 5 bits
# then they are set and another octet(s) is used to denote the tag number. # then they are set and another octet(s) is used to denote the tag number.
if isinstance(tag_number, TagNumber):
tag_number = tag_number.value
if tag_number < 31: if tag_number < 31:
identifier_octets |= tag_number identifier_octets |= tag_number
b_asn1_data.append(identifier_octets) b_asn1_data.append(identifier_octets)
@@ -160,3 +171,6 @@ def pack_asn1(tag_class, constructed, tag_number, b_data):
b_asn1_data.extend(length_octets) b_asn1_data.extend(length_octets)
return bytes(b_asn1_data) + b_data return bytes(b_asn1_data) + b_data
__all__ = ("TagClass", "TagNumber", "serialize_asn1_string_as_der", "pack_asn1")

View File

@@ -4,6 +4,9 @@
# dynamically by Ansible, still belong to the author of the module, and may assign # dynamically by Ansible, still belong to the author of the module, and may assign
# their own license to the complete work. # their own license to the complete work.
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
# This excerpt is dual licensed under the terms of the Apache License, Version # This excerpt is dual licensed under the terms of the Apache License, Version
# 2.0, and the BSD License. See the LICENSE file at # 2.0, and the BSD License. See the LICENSE file at
# https://github.com/pyca/cryptography/blob/master/LICENSE for complete details. # https://github.com/pyca/cryptography/blob/master/LICENSE for complete details.
@@ -26,10 +29,9 @@
# pyca/cryptography@3057f91ea9a05fb593825006d87a391286a4d828 # pyca/cryptography@3057f91ea9a05fb593825006d87a391286a4d828
# pyca/cryptography@d607dd7e5bc5c08854ec0c9baff70ba4a35be36f # pyca/cryptography@d607dd7e5bc5c08854ec0c9baff70ba4a35be36f
from __future__ import absolute_import, division, print_function from __future__ import annotations
import typing as t
__metaclass__ = type
# WARNING: this function no longer works with cryptography 35.0.0 and newer! # WARNING: this function no longer works with cryptography 35.0.0 and newer!
@@ -37,7 +39,7 @@ __metaclass__ = type
# cryptography versions! # cryptography versions!
def obj2txt(openssl_lib, openssl_ffi, obj): def obj2txt(openssl_lib: t.Any, openssl_ffi: t.Any, obj: t.Any) -> str:
# Set to 80 on the recommendation of # Set to 80 on the recommendation of
# https://www.openssl.org/docs/crypto/OBJ_nid2ln.html#return_values # https://www.openssl.org/docs/crypto/OBJ_nid2ln.html#return_values
# #
@@ -57,4 +59,8 @@ def obj2txt(openssl_lib, openssl_ffi, obj):
buf_len = res + 1 buf_len = res + 1
buf = openssl_ffi.new("char[]", buf_len) buf = openssl_ffi.new("char[]", buf_len)
res = openssl_lib.OBJ_obj2txt(buf, buf_len, obj, 1) res = openssl_lib.OBJ_obj2txt(buf, buf_len, obj, 1)
return openssl_ffi.buffer(buf, res)[:].decode() bytes_str: bytes = openssl_ffi.buffer(buf, res)[:]
return bytes_str.decode()
__all__ = ("obj2txt",)

View File

@@ -0,0 +1,38 @@
# Copyright (c) 2019, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
from ansible_collections.community.crypto.plugins.module_utils._crypto._objects_data import (
OID_MAP,
)
OID_LOOKUP: dict[str, str] = {}
NORMALIZE_NAMES: dict[str, str] = {}
NORMALIZE_NAMES_SHORT: dict[str, str] = {}
for dotted, names in OID_MAP.items():
for name in names:
if name in NORMALIZE_NAMES and OID_LOOKUP[name] != dotted:
raise AssertionError( # pragma: no cover
f'Name collision during setup: "{name}" for OIDs {dotted} and {OID_LOOKUP[name]}'
)
NORMALIZE_NAMES[name] = names[0]
NORMALIZE_NAMES_SHORT[name] = names[-1]
OID_LOOKUP[name] = dotted
for alias, original in [("userID", "userId")]:
if alias in NORMALIZE_NAMES:
raise AssertionError( # pragma: no cover
f'Name collision during adding aliases: "{alias}" (alias for "{original}") is already mapped to OID {OID_LOOKUP[alias]}'
)
NORMALIZE_NAMES[alias] = original
NORMALIZE_NAMES_SHORT[alias] = NORMALIZE_NAMES_SHORT[original]
OID_LOOKUP[alias] = OID_LOOKUP[original]
__all__ = ("OID_LOOKUP", "NORMALIZE_NAMES", "NORMALIZE_NAMES_SHORT")

View File

@@ -14,10 +14,10 @@
# SPDX-License-Identifier: Apache-2.0 # SPDX-License-Identifier: Apache-2.0
# https://github.com/openssl/openssl/blob/master/LICENSE.txt or LICENSES/Apache-2.0.txt # https://github.com/openssl/openssl/blob/master/LICENSE.txt or LICENSES/Apache-2.0.txt
from __future__ import absolute_import, division, print_function # Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
__metaclass__ = type
OID_MAP = { OID_MAP = {
@@ -1175,3 +1175,6 @@ OID_MAP = {
"2.23.43.1.4.11": ("wap-wsg-idm-ecid-wtls11",), "2.23.43.1.4.11": ("wap-wsg-idm-ecid-wtls11",),
"2.23.43.1.4.12": ("wap-wsg-idm-ecid-wtls12",), "2.23.43.1.4.12": ("wap-wsg-idm-ecid-wtls12",),
} }
__all__ = ("OID_MAP",)

View File

@@ -0,0 +1,29 @@
# Copyright (c) 2016, Yanis Guenane <yanis+ansible@guenane.org>
# Copyright (c) 2020, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
try:
import cryptography # noqa: F401, pylint: disable=unused-import
HAS_CRYPTOGRAPHY = True
except ImportError:
# Error handled in the calling module.
HAS_CRYPTOGRAPHY = False
class OpenSSLObjectError(Exception):
pass
class OpenSSLBadPassphraseError(OpenSSLObjectError):
pass
__all__ = ("HAS_CRYPTOGRAPHY", "OpenSSLObjectError", "OpenSSLBadPassphraseError")

View File

@@ -1,16 +1,15 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2019, Felix Fontein <felix@fontein.de> # Copyright (c) 2019, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
__metaclass__ = type import typing as t
from ansible_collections.community.crypto.plugins.module_utils._version import (
from ansible_collections.community.crypto.plugins.module_utils.version import (
LooseVersion as _LooseVersion, LooseVersion as _LooseVersion,
) )
@@ -22,9 +21,20 @@ except ImportError:
# Error handled in the calling module. # Error handled in the calling module.
pass pass
from ._obj2txt import obj2txt from ansible_collections.community.crypto.plugins.module_utils._crypto._obj2txt import (
from .basic import HAS_CRYPTOGRAPHY obj2txt,
from .cryptography_support import CRYPTOGRAPHY_TIMEZONE, cryptography_decode_name )
from ansible_collections.community.crypto.plugins.module_utils._crypto.basic import (
HAS_CRYPTOGRAPHY,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.cryptography_support import (
CRYPTOGRAPHY_TIMEZONE,
cryptography_decode_name,
)
if t.TYPE_CHECKING:
import datetime # pragma: no cover
# TODO: once cryptography has a _utc variant of InvalidityDate.invalidity_date, set this # TODO: once cryptography has a _utc variant of InvalidityDate.invalidity_date, set this
@@ -52,16 +62,18 @@ if HAS_CRYPTOGRAPHY:
"aa_compromise": x509.ReasonFlags.aa_compromise, "aa_compromise": x509.ReasonFlags.aa_compromise,
"remove_from_crl": x509.ReasonFlags.remove_from_crl, "remove_from_crl": x509.ReasonFlags.remove_from_crl,
} }
REVOCATION_REASON_MAP_INVERSE = dict() REVOCATION_REASON_MAP_INVERSE = {}
for k, v in REVOCATION_REASON_MAP.items(): for k, v in REVOCATION_REASON_MAP.items():
REVOCATION_REASON_MAP_INVERSE[v] = k REVOCATION_REASON_MAP_INVERSE[v] = k
else: else:
REVOCATION_REASON_MAP = dict() REVOCATION_REASON_MAP = {}
REVOCATION_REASON_MAP_INVERSE = dict() REVOCATION_REASON_MAP_INVERSE = {}
def cryptography_decode_revoked_certificate(cert): def cryptography_decode_revoked_certificate(
cert: x509.RevokedCertificate,
) -> dict[str, t.Any]:
result = { result = {
"serial_number": cert.serial_number, "serial_number": cert.serial_number,
"revocation_date": get_revocation_date(cert), "revocation_date": get_revocation_date(cert),
@@ -73,27 +85,31 @@ def cryptography_decode_revoked_certificate(cert):
"invalidity_date_critical": False, "invalidity_date_critical": False,
} }
try: try:
ext = cert.extensions.get_extension_for_class(x509.CertificateIssuer) ext_ci = cert.extensions.get_extension_for_class(x509.CertificateIssuer)
result["issuer"] = list(ext.value) result["issuer"] = list(ext_ci.value)
result["issuer_critical"] = ext.critical result["issuer_critical"] = ext_ci.critical
except x509.ExtensionNotFound: except x509.ExtensionNotFound:
pass pass
try: try:
ext = cert.extensions.get_extension_for_class(x509.CRLReason) ext_cr = cert.extensions.get_extension_for_class(x509.CRLReason)
result["reason"] = ext.value.reason result["reason"] = ext_cr.value.reason
result["reason_critical"] = ext.critical result["reason_critical"] = ext_cr.critical
except x509.ExtensionNotFound: except x509.ExtensionNotFound:
pass pass
try: try:
ext = cert.extensions.get_extension_for_class(x509.InvalidityDate) ext_id = cert.extensions.get_extension_for_class(x509.InvalidityDate)
result["invalidity_date"] = get_invalidity_date(ext.value) result["invalidity_date"] = get_invalidity_date(ext_id.value)
result["invalidity_date_critical"] = ext.critical result["invalidity_date_critical"] = ext_id.critical
except x509.ExtensionNotFound: except x509.ExtensionNotFound:
pass pass
return result return result
def cryptography_dump_revoked(entry, idn_rewrite="ignore"): def cryptography_dump_revoked(
entry: dict[str, t.Any],
*,
idn_rewrite: t.Literal["ignore", "idna", "unicode"] = "ignore",
) -> dict[str, t.Any]:
return { return {
"serial_number": entry["serial_number"], "serial_number": entry["serial_number"],
"revocation_date": entry["revocation_date"].strftime(TIMESTAMP_FORMAT), "revocation_date": entry["revocation_date"].strftime(TIMESTAMP_FORMAT),
@@ -121,48 +137,74 @@ def cryptography_dump_revoked(entry, idn_rewrite="ignore"):
} }
def cryptography_get_signature_algorithm_oid_from_crl(crl): def cryptography_get_signature_algorithm_oid_from_crl(
crl: x509.CertificateRevocationList,
) -> x509.oid.ObjectIdentifier:
try: try:
return crl.signature_algorithm_oid return crl.signature_algorithm_oid
except AttributeError: except AttributeError:
# Older cryptography versions do not have signature_algorithm_oid yet # Older cryptography versions do not have signature_algorithm_oid yet
dotted = obj2txt( dotted = obj2txt(
crl._backend._lib, crl._backend._ffi, crl._x509_crl.sig_alg.algorithm crl._backend._lib, # type: ignore[attr-defined] # pylint: disable=protected-access
crl._backend._ffi, # type: ignore[attr-defined] # pylint: disable=protected-access
crl._x509_crl.sig_alg.algorithm, # type: ignore[attr-defined] # pylint: disable=protected-access
) )
return x509.oid.ObjectIdentifier(dotted) return x509.oid.ObjectIdentifier(dotted)
def get_next_update(obj): def get_next_update(obj: x509.CertificateRevocationList) -> datetime.datetime | None:
if CRYPTOGRAPHY_TIMEZONE: if CRYPTOGRAPHY_TIMEZONE:
return obj.next_update_utc return obj.next_update_utc
return obj.next_update return obj.next_update
def get_last_update(obj): def get_last_update(obj: x509.CertificateRevocationList) -> datetime.datetime:
if CRYPTOGRAPHY_TIMEZONE: if CRYPTOGRAPHY_TIMEZONE:
return obj.last_update_utc return obj.last_update_utc
return obj.last_update return obj.last_update
def get_revocation_date(obj): def get_revocation_date(obj: x509.RevokedCertificate) -> datetime.datetime:
if CRYPTOGRAPHY_TIMEZONE: if CRYPTOGRAPHY_TIMEZONE:
return obj.revocation_date_utc return obj.revocation_date_utc
return obj.revocation_date return obj.revocation_date
def get_invalidity_date(obj): def get_invalidity_date(obj: x509.InvalidityDate) -> datetime.datetime:
if CRYPTOGRAPHY_TIMEZONE_INVALIDITY_DATE: if CRYPTOGRAPHY_TIMEZONE_INVALIDITY_DATE:
return obj.invalidity_date_utc return obj.invalidity_date_utc
return obj.invalidity_date return obj.invalidity_date
def set_next_update(builder, value): def set_next_update(
builder: x509.CertificateRevocationListBuilder, *, value: datetime.datetime
) -> x509.CertificateRevocationListBuilder:
return builder.next_update(value) return builder.next_update(value)
def set_last_update(builder, value): def set_last_update(
builder: x509.CertificateRevocationListBuilder, *, value: datetime.datetime
) -> x509.CertificateRevocationListBuilder:
return builder.last_update(value) return builder.last_update(value)
def set_revocation_date(builder, value): def set_revocation_date(
builder: x509.RevokedCertificateBuilder, *, value: datetime.datetime
) -> x509.RevokedCertificateBuilder:
return builder.revocation_date(value) return builder.revocation_date(value)
__all__ = (
"REVOCATION_REASON_MAP",
"REVOCATION_REASON_MAP_INVERSE",
"cryptography_decode_revoked_certificate",
"cryptography_dump_revoked",
"cryptography_get_signature_algorithm_oid_from_crl",
"get_next_update",
"get_last_update",
"get_revocation_date",
"get_invalidity_date",
"set_next_update",
"set_last_update",
"set_revocation_date",
)

View File

@@ -1,19 +1,14 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2019, Felix Fontein <felix@fontein.de> # Copyright (c) 2019, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
__metaclass__ = type def binary_exp_mod(f: int, e: int, *, m: int) -> int:
import sys
def binary_exp_mod(f, e, m):
"""Computes f^e mod m in O(log e) multiplications modulo m.""" """Computes f^e mod m in O(log e) multiplications modulo m."""
# Compute len_e = floor(log_2(e)) # Compute len_e = floor(log_2(e))
len_e = -1 len_e = -1
@@ -30,14 +25,14 @@ def binary_exp_mod(f, e, m):
return result return result
def simple_gcd(a, b): def simple_gcd(a: int, b: int) -> int:
"""Compute GCD of its two inputs.""" """Compute GCD of its two inputs."""
while b != 0: while b != 0:
a, b = b, a % b a, b = b, a % b
return a return a
def quick_is_not_prime(n): def quick_is_not_prime(n: int) -> bool:
"""Does some quick checks to see if we can poke a hole into the primality of n. """Does some quick checks to see if we can poke a hole into the primality of n.
A result of `False` does **not** mean that the number is prime; it just means A result of `False` does **not** mean that the number is prime; it just means
@@ -105,85 +100,27 @@ def quick_is_not_prime(n):
return False return False
python_version = (sys.version_info[0], sys.version_info[1]) def count_bytes(no: int) -> int:
if python_version >= (2, 7) or python_version >= (3, 1): """
# Ansible still supports Python 2.6 on remote nodes Given an integer, compute the number of bytes necessary to store its absolute value.
"""
def count_bytes(no): no = abs(no)
""" if no == 0:
Given an integer, compute the number of bytes necessary to store its absolute value. return 0
""" return (no.bit_length() + 7) // 8
no = abs(no)
if no == 0:
return 0
return (no.bit_length() + 7) // 8
def count_bits(no):
"""
Given an integer, compute the number of bits necessary to store its absolute value.
"""
no = abs(no)
if no == 0:
return 0
return no.bit_length()
else:
# Slow, but works
def count_bytes(no):
"""
Given an integer, compute the number of bytes necessary to store its absolute value.
"""
no = abs(no)
count = 0
while no > 0:
no >>= 8
count += 1
return count
def count_bits(no):
"""
Given an integer, compute the number of bits necessary to store its absolute value.
"""
no = abs(no)
count = 0
while no > 0:
no >>= 1
count += 1
return count
if sys.version_info[0] >= 3: def count_bits(no: int) -> int:
# Python 3 (and newer) """
def _convert_int_to_bytes(count, no): Given an integer, compute the number of bits necessary to store its absolute value.
return no.to_bytes(count, byteorder="big") """
no = abs(no)
def _convert_bytes_to_int(data): if no == 0:
return int.from_bytes(data, byteorder="big", signed=False) return 0
return no.bit_length()
def _to_hex(no):
return hex(no)[2:]
else:
# Python 2
def _convert_int_to_bytes(count, n):
if n == 0 and count == 0:
return ""
h = "%x" % n
if len(h) > 2 * count:
raise Exception("Number {1} needs more than {0} bytes!".format(count, n))
return ("0" * (2 * count - len(h)) + h).decode("hex")
def _convert_bytes_to_int(data):
v = 0
for x in data:
v = (v << 8) | ord(x)
return v
def _to_hex(no):
return "%x" % no
def convert_int_to_bytes(no, count=None): def convert_int_to_bytes(no: int, *, count: int | None = None) -> bytes:
""" """
Convert the absolute value of an integer to a byte string in network byte order. Convert the absolute value of an integer to a byte string in network byte order.
@@ -196,10 +133,10 @@ def convert_int_to_bytes(no, count=None):
no = abs(no) no = abs(no)
if count is None: if count is None:
count = count_bytes(no) count = count_bytes(no)
return _convert_int_to_bytes(count, no) return no.to_bytes(count, byteorder="big")
def convert_int_to_hex(no, digits=None): def convert_int_to_hex(no: int, *, digits: int | None = None) -> str:
""" """
Convert the absolute value of an integer to a string of hexadecimal digits. Convert the absolute value of an integer to a string of hexadecimal digits.
@@ -208,14 +145,26 @@ def convert_int_to_hex(no, digits=None):
the string will be longer. the string will be longer.
""" """
no = abs(no) no = abs(no)
value = _to_hex(no) value = f"{no:x}"
if digits is not None and len(value) < digits: if digits is not None and len(value) < digits:
value = "0" * (digits - len(value)) + value value = "0" * (digits - len(value)) + value
return value return value
def convert_bytes_to_int(data): def convert_bytes_to_int(data: bytes) -> int:
""" """
Convert a byte string to an unsigned integer in network byte order. Convert a byte string to an unsigned integer in network byte order.
""" """
return _convert_bytes_to_int(data) return int.from_bytes(data, byteorder="big", signed=False)
__all__ = (
"binary_exp_mod",
"simple_gcd",
"quick_is_not_prime",
"count_bytes",
"count_bits",
"convert_int_to_bytes",
"convert_int_to_hex",
"convert_bytes_to_int",
)

View File

@@ -0,0 +1,421 @@
# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org>
# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import abc
import typing as t
from ansible_collections.community.crypto.plugins.module_utils._argspec import (
ArgumentSpec,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.basic import (
OpenSSLBadPassphraseError,
OpenSSLObjectError,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.cryptography_support import (
cryptography_compare_public_keys,
get_not_valid_after,
get_not_valid_before,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.module_backends.certificate_info import (
get_certificate_info,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.support import (
load_certificate,
load_certificate_privatekey,
load_certificate_request,
)
from ansible_collections.community.crypto.plugins.module_utils._cryptography_dep import (
COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION,
assert_required_cryptography_version,
)
if t.TYPE_CHECKING:
import datetime # pragma: no cover
from ansible.module_utils.basic import AnsibleModule # pragma: no cover
from ansible_collections.community.crypto.plugins.module_utils._crypto.cryptography_support import ( # pragma: no cover
CertificatePrivateKeyTypes,
)
from cryptography.hazmat.primitives.asymmetric.types import ( # pragma: no cover
CertificateIssuerPrivateKeyTypes,
)
MINIMAL_CRYPTOGRAPHY_VERSION = COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION
try:
import cryptography
from cryptography import x509
except ImportError:
pass
class CertificateError(OpenSSLObjectError):
pass
class CertificateBackend(metaclass=abc.ABCMeta):
def __init__(self, *, module: AnsibleModule) -> None:
self.module = module
self.force: bool = module.params["force"]
self.ignore_timestamps: bool = module.params["ignore_timestamps"]
self.privatekey_path: str | None = module.params["privatekey_path"]
privatekey_content: str | None = module.params["privatekey_content"]
if privatekey_content is not None:
self.privatekey_content: bytes | None = privatekey_content.encode("utf-8")
else:
self.privatekey_content = None
self.privatekey_passphrase: str | None = module.params["privatekey_passphrase"]
self.csr_path: str | None = module.params["csr_path"]
csr_content = module.params["csr_content"]
if csr_content is not None:
self.csr_content: bytes | None = csr_content.encode("utf-8")
else:
self.csr_content = None
# The following are default values which make sure check() works as
# before if providers do not explicitly change these properties.
self.create_subject_key_identifier: str = "never_create"
self.create_authority_key_identifier: bool = False
self.privatekey: CertificatePrivateKeyTypes | None = None
self.csr: x509.CertificateSigningRequest | None = None
self.cert: x509.Certificate | None = None
self.existing_certificate: x509.Certificate | None = None
self.existing_certificate_bytes: bytes | None = None
self.check_csr_subject: bool = True
self.check_csr_extensions: bool = True
self.diff_before = self._get_info(None)
self.diff_after = self._get_info(None)
def _get_info(self, data: bytes | None) -> dict[str, t.Any]:
if data is None:
return {}
try:
result = get_certificate_info(
module=self.module, content=data, prefer_one_fingerprint=True
)
result["can_parse_certificate"] = True
return result
except Exception:
return {"can_parse_certificate": False}
@abc.abstractmethod
def generate_certificate(self) -> None:
"""(Re-)Generate certificate."""
@abc.abstractmethod
def get_certificate_data(self) -> bytes:
"""Return bytes for self.cert."""
def set_existing(self, certificate_bytes: bytes | None) -> None:
"""Set existing certificate bytes. None indicates that the key does not exist."""
self.existing_certificate_bytes = certificate_bytes
self.diff_after = self.diff_before = self._get_info(
self.existing_certificate_bytes
)
def has_existing(self) -> bool:
"""Query whether an existing certificate is/has been there."""
return self.existing_certificate_bytes is not None
def _ensure_private_key_loaded(self) -> None:
"""Load the provided private key into self.privatekey."""
if self.privatekey is not None:
return
if self.privatekey_path is None and self.privatekey_content is None:
return
try:
self.privatekey = load_certificate_privatekey(
path=self.privatekey_path,
content=self.privatekey_content,
passphrase=self.privatekey_passphrase,
)
except OpenSSLBadPassphraseError as exc:
raise CertificateError(exc) from exc
def _ensure_csr_loaded(self) -> None:
"""Load the CSR into self.csr."""
if self.csr is not None:
return
if self.csr_path is None and self.csr_content is None:
return
self.csr = load_certificate_request(
path=self.csr_path,
content=self.csr_content,
)
def _ensure_existing_certificate_loaded(self) -> None:
"""Load the existing certificate into self.existing_certificate."""
if self.existing_certificate is not None:
return
if self.existing_certificate_bytes is None:
return
self.existing_certificate = load_certificate(
path=None,
content=self.existing_certificate_bytes,
)
def _check_privatekey(self) -> bool:
"""Check whether provided parameters match, assuming self.existing_certificate and self.privatekey have been populated."""
if self.existing_certificate is None:
raise AssertionError( # pragma: no cover
"Contract violation: existing_certificate has not been populated"
)
if self.privatekey is None:
raise AssertionError( # pragma: no cover
"Contract violation: privatekey has not been populated"
)
return cryptography_compare_public_keys(
self.existing_certificate.public_key(), self.privatekey.public_key()
)
def _check_csr(self) -> bool:
"""Check whether provided parameters match, assuming self.existing_certificate and self.csr have been populated."""
if self.existing_certificate is None:
raise AssertionError( # pragma: no cover
"Contract violation: existing_certificate has not been populated"
)
if self.csr is None:
raise AssertionError(
"Contract violation: csr has not been populated"
) # pragma: no cover
# Verify that CSR is signed by certificate's private key
if not self.csr.is_signature_valid:
return False
if not cryptography_compare_public_keys(
self.csr.public_key(), self.existing_certificate.public_key()
):
return False
# Check subject
if (
self.check_csr_subject
and self.csr.subject != self.existing_certificate.subject
):
return False
# Check extensions
if not self.check_csr_extensions:
return True
cert_exts = list(self.existing_certificate.extensions)
csr_exts = list(self.csr.extensions)
if self.create_subject_key_identifier != "never_create":
# Filter out SubjectKeyIdentifier extension before comparison
cert_exts = list(
filter(
lambda x: not isinstance(x.value, x509.SubjectKeyIdentifier),
cert_exts,
)
)
csr_exts = list(
filter(
lambda x: not isinstance(x.value, x509.SubjectKeyIdentifier),
csr_exts,
)
)
if self.create_authority_key_identifier:
# Filter out AuthorityKeyIdentifier extension before comparison
cert_exts = list(
filter(
lambda x: not isinstance(x.value, x509.AuthorityKeyIdentifier),
cert_exts,
)
)
csr_exts = list(
filter(
lambda x: not isinstance(x.value, x509.AuthorityKeyIdentifier),
csr_exts,
)
)
if len(cert_exts) != len(csr_exts):
return False
for cert_ext in cert_exts:
try:
csr_ext = self.csr.extensions.get_extension_for_oid(cert_ext.oid)
if cert_ext != csr_ext:
return False
except cryptography.x509.ExtensionNotFound:
return False
return True
def _check_subject_key_identifier(self) -> bool:
"""Check whether Subject Key Identifier matches, assuming self.existing_certificate and self.csr have been populated."""
if self.existing_certificate is None:
raise AssertionError( # pragma: no cover
"Contract violation: existing_certificate has not been populated"
)
if self.csr is None:
raise AssertionError(
"Contract violation: csr has not been populated"
) # pragma: no cover
# Get hold of certificate's SKI
try:
ext = self.existing_certificate.extensions.get_extension_for_class(
x509.SubjectKeyIdentifier
)
except cryptography.x509.ExtensionNotFound:
return False
# Get hold of CSR's SKI for 'create_if_not_provided'
csr_ext = None
if self.create_subject_key_identifier == "create_if_not_provided":
try:
csr_ext = self.csr.extensions.get_extension_for_class(
x509.SubjectKeyIdentifier
)
except cryptography.x509.ExtensionNotFound:
pass
if csr_ext is None:
# If CSR had no SKI, or we chose to ignore it ('always_create'), compare with created SKI
if (
ext.value.digest
!= x509.SubjectKeyIdentifier.from_public_key(
self.existing_certificate.public_key()
).digest
):
return False
else:
# If CSR had SKI and we did not ignore it ('create_if_not_provided'), compare SKIs
if ext.value.digest != csr_ext.value.digest:
return False
return True
def needs_regeneration(
self,
*,
not_before: datetime.datetime | None = None,
not_after: datetime.datetime | None = None,
) -> bool:
"""Check whether a regeneration is necessary."""
if self.force or self.existing_certificate_bytes is None:
return True
try:
self._ensure_existing_certificate_loaded()
except Exception:
return True
assert self.existing_certificate is not None
# Check whether private key matches
self._ensure_private_key_loaded()
if self.privatekey is not None and not self._check_privatekey():
return True
# Check whether CSR matches
self._ensure_csr_loaded()
if self.csr is not None and not self._check_csr():
return True
# Check SubjectKeyIdentifier
if (
self.create_subject_key_identifier != "never_create"
and not self._check_subject_key_identifier()
):
return True
# Check not before
if not_before is not None and not self.ignore_timestamps:
if get_not_valid_before(self.existing_certificate) != not_before:
return True
# Check not after
if not_after is not None and not self.ignore_timestamps:
if get_not_valid_after(self.existing_certificate) != not_after:
return True
return False
def dump(self, *, include_certificate: bool) -> dict[str, t.Any]:
"""Serialize the object into a dictionary."""
result: dict[str, t.Any] = {
"privatekey": self.privatekey_path,
"csr": self.csr_path,
}
# Get hold of certificate bytes
certificate_bytes = self.existing_certificate_bytes
if self.cert is not None:
certificate_bytes = self.get_certificate_data()
self.diff_after = self._get_info(certificate_bytes)
if include_certificate:
# Store result
result["certificate"] = (
certificate_bytes.decode("utf-8") if certificate_bytes else None
)
result["diff"] = {
"before": self.diff_before,
"after": self.diff_after,
}
return result
class CertificateProvider(metaclass=abc.ABCMeta):
@abc.abstractmethod
def validate_module_args(self, module: AnsibleModule) -> None:
"""Check module arguments"""
@abc.abstractmethod
def create_backend(self, module: AnsibleModule) -> CertificateBackend:
"""Create an implementation for a backend.
Return value must be instance of CertificateBackend.
"""
def select_backend(
*, module: AnsibleModule, provider: CertificateProvider
) -> CertificateBackend:
provider.validate_module_args(module)
assert_required_cryptography_version(
module, minimum_cryptography_version=MINIMAL_CRYPTOGRAPHY_VERSION
)
return provider.create_backend(module)
def get_certificate_argument_spec() -> ArgumentSpec:
return ArgumentSpec(
argument_spec={
"provider": {
"type": "str",
"choices": [],
}, # choices will be filled by add_XXX_provider_to_argument_spec() in certificate_xxx.py
"force": {
"type": "bool",
"default": False,
},
"csr_path": {"type": "path"},
"csr_content": {"type": "str"},
"ignore_timestamps": {"type": "bool", "default": True},
"select_crypto_backend": {
"type": "str",
"default": "auto",
"choices": ["auto", "cryptography"],
},
# General properties of a certificate
"privatekey_path": {"type": "path"},
"privatekey_content": {"type": "str", "no_log": True},
"privatekey_passphrase": {"type": "str", "no_log": True},
},
mutually_exclusive=[
["csr_path", "csr_content"],
["privatekey_path", "privatekey_content"],
],
)
__all__ = (
"CertificateError",
"CertificateBackend",
"CertificateProvider",
"get_certificate_argument_spec",
)

View File

@@ -0,0 +1,148 @@
# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org>
# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import os
import tempfile
import traceback
import typing as t
from ansible.module_utils.common.text.converters import to_bytes
from ansible_collections.community.crypto.plugins.module_utils._crypto.module_backends.certificate import (
CertificateBackend,
CertificateError,
CertificateProvider,
)
if t.TYPE_CHECKING:
from ansible.module_utils.basic import AnsibleModule # pragma: no cover
from ansible_collections.community.crypto.plugins.module_utils._argspec import ( # pragma: no cover
ArgumentSpec,
)
class AcmeCertificateBackend(CertificateBackend):
def __init__(self, *, module: AnsibleModule) -> None:
super().__init__(module=module)
self.accountkey_path: str = module.params["acme_accountkey_path"]
self.challenge_path: str = module.params["acme_challenge_path"]
self.use_chain: bool = module.params["acme_chain"]
self.acme_directory: str = module.params["acme_directory"]
self.cert_bytes: bytes | None = None
if self.csr_content is None:
if self.csr_path is None:
raise CertificateError(
"csr_path or csr_content is required for ownca provider"
)
if not os.path.exists(self.csr_path):
raise CertificateError(
f"The certificate signing request file {self.csr_path} does not exist"
)
if not os.path.exists(self.accountkey_path):
raise CertificateError(
f"The account key {self.accountkey_path} does not exist"
)
if not os.path.exists(self.challenge_path):
raise CertificateError(
f"The challenge path {self.challenge_path} does not exist"
)
self.acme_tiny_path = self.module.get_bin_path("acme-tiny", required=True)
def generate_certificate(self) -> None:
"""(Re-)Generate certificate."""
command = [self.acme_tiny_path]
if self.use_chain:
command.append("--chain")
command.extend(["--account-key", self.accountkey_path])
if self.csr_content is not None:
# We need to temporarily write the CSR to disk
fd, tmpsrc = tempfile.mkstemp()
self.module.add_cleanup_file(tmpsrc) # Ansible will delete the file on exit
f = os.fdopen(fd, "wb")
try:
f.write(self.csr_content)
except Exception as err:
try:
f.close()
except Exception:
pass
self.module.fail_json(
msg=f"failed to create temporary CSR file: {err}",
exception=traceback.format_exc(),
)
f.close()
command.extend(["--csr", tmpsrc])
else:
assert self.csr_path is not None
command.extend(["--csr", self.csr_path])
command.extend(["--acme-dir", self.challenge_path])
command.extend(["--directory-url", self.acme_directory])
try:
self.cert_bytes = to_bytes(
self.module.run_command(command, check_rc=True)[1]
)
except OSError as exc:
raise CertificateError(exc) from exc
def get_certificate_data(self) -> bytes:
"""Return bytes for self.cert."""
if self.cert_bytes is None:
raise AssertionError(
"Contract violation: cert_bytes is None"
) # pragma: no cover
return self.cert_bytes
def dump(self, *, include_certificate: bool) -> dict[str, t.Any]:
result = super().dump(include_certificate=include_certificate)
result["accountkey"] = self.accountkey_path
return result
class AcmeCertificateProvider(CertificateProvider):
def validate_module_args(self, module: AnsibleModule) -> None:
if module.params["acme_accountkey_path"] is None:
module.fail_json(
msg="The acme_accountkey_path option must be specified for the acme provider."
)
if module.params["acme_challenge_path"] is None:
module.fail_json(
msg="The acme_challenge_path option must be specified for the acme provider."
)
def create_backend(self, module: AnsibleModule) -> AcmeCertificateBackend:
return AcmeCertificateBackend(module=module)
def add_acme_provider_to_argument_spec(argument_spec: ArgumentSpec) -> None:
argument_spec.argument_spec["provider"]["choices"].append("acme")
argument_spec.argument_spec.update(
{
"acme_accountkey_path": {"type": "path"},
"acme_challenge_path": {"type": "path"},
"acme_chain": {"type": "bool", "default": False},
"acme_directory": {
"type": "str",
"default": "https://acme-v02.api.letsencrypt.org/directory",
},
}
)
__all__ = (
"AcmeCertificateBackend",
"AcmeCertificateProvider",
"add_acme_provider_to_argument_spec",
)

View File

@@ -1,172 +1,313 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org> # Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org>
# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at> # Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at>
# Copyright (c) 2020, Felix Fontein <felix@fontein.de> # Copyright (c) 2020, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
__metaclass__ = type
import abc
import binascii import binascii
import traceback import typing as t
from ansible.module_utils import six from ansible.module_utils.common.text.converters import to_text
from ansible.module_utils.basic import missing_required_lib from ansible_collections.community.crypto.plugins.module_utils._crypto.cryptography_support import (
from ansible.module_utils.common.text.converters import to_native
from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import (
CRYPTOGRAPHY_TIMEZONE, CRYPTOGRAPHY_TIMEZONE,
cryptography_decode_name, cryptography_decode_name,
cryptography_get_extensions_from_cert, cryptography_get_extensions_from_cert,
cryptography_oid_to_name, cryptography_oid_to_name,
cryptography_serial_number_of_cert,
get_not_valid_after, get_not_valid_after,
get_not_valid_before, get_not_valid_before,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.publickey_info import ( from ansible_collections.community.crypto.plugins.module_utils._crypto.module_backends.publickey_info import (
get_publickey_info, get_publickey_info,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( from ansible_collections.community.crypto.plugins.module_utils._crypto.support import (
get_fingerprint_of_bytes, get_fingerprint_of_bytes,
load_certificate, load_certificate,
) )
from ansible_collections.community.crypto.plugins.module_utils.time import ( from ansible_collections.community.crypto.plugins.module_utils._cryptography_dep import (
COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION,
assert_required_cryptography_version,
)
from ansible_collections.community.crypto.plugins.module_utils._time import (
get_now_datetime, get_now_datetime,
) )
from ansible_collections.community.crypto.plugins.module_utils.version import (
LooseVersion,
)
MINIMAL_CRYPTOGRAPHY_VERSION = "1.6" if t.TYPE_CHECKING:
import datetime # pragma: no cover
from ansible.module_utils.basic import AnsibleModule # pragma: no cover
from ansible_collections.community.crypto.plugins.module_utils._argspec import ( # pragma: no cover
ArgumentSpec,
)
from ansible_collections.community.crypto.plugins.plugin_utils._action_module import ( # pragma: no cover
AnsibleActionModule,
)
from ansible_collections.community.crypto.plugins.plugin_utils._filter_module import ( # pragma: no cover
FilterModuleMock,
)
from cryptography.hazmat.primitives.asymmetric.types import (
PublicKeyTypes, # pragma: no cover
)
GeneralAnsibleModule = t.Union[
AnsibleModule, AnsibleActionModule, FilterModuleMock
] # pragma: no cover
MINIMAL_CRYPTOGRAPHY_VERSION = COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION
CRYPTOGRAPHY_IMP_ERR = None
try: try:
import cryptography import cryptography
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__)
except ImportError: except ImportError:
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc() pass
CRYPTOGRAPHY_FOUND = False
else:
CRYPTOGRAPHY_FOUND = True
TIMESTAMP_FORMAT = "%Y%m%d%H%M%SZ" TIMESTAMP_FORMAT = "%Y%m%d%H%M%SZ"
@six.add_metaclass(abc.ABCMeta) class CertificateInfoRetrieval:
class CertificateInfoRetrieval(object): cert: x509.Certificate
def __init__(self, module, backend, content):
def __init__(self, *, module: GeneralAnsibleModule, content: bytes) -> None:
# content must be a bytes string # content must be a bytes string
self.module = module self.module = module
self.backend = backend
self.content = content self.content = content
self.name_encoding = module.params.get("name_encoding", "ignore")
@abc.abstractmethod def _get_der_bytes(self) -> bytes:
def _get_der_bytes(self): return self.cert.public_bytes(serialization.Encoding.DER)
pass
@abc.abstractmethod def _get_signature_algorithm(self) -> str:
def _get_signature_algorithm(self): return cryptography_oid_to_name(self.cert.signature_algorithm_oid)
pass
@abc.abstractmethod def _get_subject_ordered(self) -> list[list[str]]:
def _get_subject_ordered(self): result: list[list[str]] = []
pass for attribute in self.cert.subject:
result.append(
[cryptography_oid_to_name(attribute.oid), to_text(attribute.value)]
)
return result
@abc.abstractmethod def _get_issuer_ordered(self) -> list[list[str]]:
def _get_issuer_ordered(self): result = []
pass for attribute in self.cert.issuer:
result.append(
[cryptography_oid_to_name(attribute.oid), to_text(attribute.value)]
)
return result
@abc.abstractmethod def _get_version(self) -> int | str:
def _get_version(self): if self.cert.version == x509.Version.v1:
pass return 1
if self.cert.version == x509.Version.v3:
return 3
return "unknown" # type: ignore[unreachable]
@abc.abstractmethod def _get_key_usage(self) -> tuple[list[str] | None, bool]:
def _get_key_usage(self): try:
pass current_key_ext = self.cert.extensions.get_extension_for_class(
x509.KeyUsage
)
current_key_usage = current_key_ext.value
key_usage = {
"digital_signature": current_key_usage.digital_signature,
"content_commitment": current_key_usage.content_commitment,
"key_encipherment": current_key_usage.key_encipherment,
"data_encipherment": current_key_usage.data_encipherment,
"key_agreement": current_key_usage.key_agreement,
"key_cert_sign": current_key_usage.key_cert_sign,
"crl_sign": current_key_usage.crl_sign,
"encipher_only": False,
"decipher_only": False,
}
if key_usage["key_agreement"]:
key_usage.update(
{
"encipher_only": current_key_usage.encipher_only,
"decipher_only": current_key_usage.decipher_only,
}
)
@abc.abstractmethod key_usage_names = {
def _get_extended_key_usage(self): "digital_signature": "Digital Signature",
pass "content_commitment": "Non Repudiation",
"key_encipherment": "Key Encipherment",
"data_encipherment": "Data Encipherment",
"key_agreement": "Key Agreement",
"key_cert_sign": "Certificate Sign",
"crl_sign": "CRL Sign",
"encipher_only": "Encipher Only",
"decipher_only": "Decipher Only",
}
return (
sorted(
[
key_usage_names[name]
for name, value in key_usage.items()
if value
]
),
current_key_ext.critical,
)
except cryptography.x509.ExtensionNotFound:
return None, False
@abc.abstractmethod def _get_extended_key_usage(self) -> tuple[list[str] | None, bool]:
def _get_basic_constraints(self): try:
pass ext_keyusage_ext = self.cert.extensions.get_extension_for_class(
x509.ExtendedKeyUsage
)
return (
sorted(
[cryptography_oid_to_name(eku) for eku in ext_keyusage_ext.value]
),
ext_keyusage_ext.critical,
)
except cryptography.x509.ExtensionNotFound:
return None, False
@abc.abstractmethod def _get_basic_constraints(self) -> tuple[list[str] | None, bool]:
def _get_ocsp_must_staple(self): try:
pass ext_keyusage_ext = self.cert.extensions.get_extension_for_class(
x509.BasicConstraints
)
result = []
result.append(f"CA:{'TRUE' if ext_keyusage_ext.value.ca else 'FALSE'}")
if ext_keyusage_ext.value.path_length is not None:
result.append(f"pathlen:{ext_keyusage_ext.value.path_length}")
return sorted(result), ext_keyusage_ext.critical
except cryptography.x509.ExtensionNotFound:
return None, False
@abc.abstractmethod def _get_ocsp_must_staple(self) -> tuple[bool | None, bool]:
def _get_subject_alt_name(self): try:
pass tlsfeature_ext = self.cert.extensions.get_extension_for_class(
x509.TLSFeature
)
value = (
cryptography.x509.TLSFeatureType.status_request in tlsfeature_ext.value
)
return value, tlsfeature_ext.critical
except cryptography.x509.ExtensionNotFound:
return None, False
@abc.abstractmethod def _get_subject_alt_name(self) -> tuple[list[str] | None, bool]:
def get_not_before(self): try:
pass san_ext = self.cert.extensions.get_extension_for_class(
x509.SubjectAlternativeName
)
result = [
cryptography_decode_name(san, idn_rewrite=self.name_encoding)
for san in san_ext.value
]
return result, san_ext.critical
except cryptography.x509.ExtensionNotFound:
return None, False
@abc.abstractmethod def get_not_before(self) -> datetime.datetime:
def get_not_after(self): return get_not_valid_before(self.cert)
pass
@abc.abstractmethod def get_not_after(self) -> datetime.datetime:
def _get_public_key_pem(self): return get_not_valid_after(self.cert)
pass
@abc.abstractmethod def _get_public_key_pem(self) -> bytes:
def _get_public_key_object(self): return self.cert.public_key().public_bytes(
pass serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
@abc.abstractmethod def _get_public_key_object(self) -> PublicKeyTypes:
def _get_subject_key_identifier(self): return self.cert.public_key()
pass
@abc.abstractmethod def _get_subject_key_identifier(self) -> bytes | None:
def _get_authority_key_identifier(self): try:
pass ext = self.cert.extensions.get_extension_for_class(
x509.SubjectKeyIdentifier
)
return ext.value.digest
except cryptography.x509.ExtensionNotFound:
return None
@abc.abstractmethod def _get_authority_key_identifier(
def _get_serial_number(self): self,
pass ) -> tuple[bytes | None, list[str] | None, int | None]:
try:
ext = self.cert.extensions.get_extension_for_class(
x509.AuthorityKeyIdentifier
)
issuer = None
if ext.value.authority_cert_issuer is not None:
issuer = [
cryptography_decode_name(san, idn_rewrite=self.name_encoding)
for san in ext.value.authority_cert_issuer
]
return (
ext.value.key_identifier,
issuer,
ext.value.authority_cert_serial_number,
)
except cryptography.x509.ExtensionNotFound:
return None, None, None
@abc.abstractmethod def _get_serial_number(self) -> int:
def _get_all_extensions(self): return self.cert.serial_number
pass
@abc.abstractmethod def _get_all_extensions(self) -> dict[str, dict[str, bool | str]]:
def _get_ocsp_uri(self): return cryptography_get_extensions_from_cert(self.cert)
pass
@abc.abstractmethod def _get_ocsp_uri(self) -> str | None:
def _get_issuer_uri(self): try:
pass ext = self.cert.extensions.get_extension_for_class(
x509.AuthorityInformationAccess
)
for desc in ext.value:
if desc.access_method == x509.oid.AuthorityInformationAccessOID.OCSP:
if isinstance(desc.access_location, x509.UniformResourceIdentifier):
return desc.access_location.value
except x509.ExtensionNotFound:
pass
return None
def get_info(self, prefer_one_fingerprint=False, der_support_enabled=False): def _get_issuer_uri(self) -> str | None:
result = dict() try:
ext = self.cert.extensions.get_extension_for_class(
x509.AuthorityInformationAccess
)
for desc in ext.value:
if (
desc.access_method
== x509.oid.AuthorityInformationAccessOID.CA_ISSUERS
):
if isinstance(desc.access_location, x509.UniformResourceIdentifier):
return desc.access_location.value
except x509.ExtensionNotFound:
pass
return None
def get_info(
self, *, prefer_one_fingerprint: bool = False, der_support_enabled: bool = False
) -> dict[str, t.Any]:
result: dict[str, t.Any] = {}
self.cert = load_certificate( self.cert = load_certificate(
None,
content=self.content, content=self.content,
backend=self.backend,
der_support_enabled=der_support_enabled, der_support_enabled=der_support_enabled,
) )
result["signature_algorithm"] = self._get_signature_algorithm() result["signature_algorithm"] = self._get_signature_algorithm()
subject = self._get_subject_ordered() subject = self._get_subject_ordered()
issuer = self._get_issuer_ordered() issuer = self._get_issuer_ordered()
result["subject"] = dict() result["subject"] = {}
for k, v in subject: for k, v in subject:
result["subject"][k] = v result["subject"][k] = v
result["subject_ordered"] = subject result["subject_ordered"] = subject
result["issuer"] = dict() result["issuer"] = {}
for k, v in issuer: for k, v in issuer:
result["issuer"][k] = v result["issuer"][k] = v
result["issuer_ordered"] = issuer result["issuer_ordered"] = issuer
@@ -193,11 +334,10 @@ class CertificateInfoRetrieval(object):
with_timezone=CRYPTOGRAPHY_TIMEZONE with_timezone=CRYPTOGRAPHY_TIMEZONE
) )
result["public_key"] = to_native(self._get_public_key_pem()) result["public_key"] = to_text(self._get_public_key_pem())
public_key_info = get_publickey_info( public_key_info = get_publickey_info(
self.module, module=self.module,
self.backend,
key=self._get_public_key_object(), key=self._get_public_key_object(),
prefer_one_fingerprint=prefer_one_fingerprint, prefer_one_fingerprint=prefer_one_fingerprint,
) )
@@ -213,16 +353,20 @@ class CertificateInfoRetrieval(object):
self._get_der_bytes(), prefer_one=prefer_one_fingerprint self._get_der_bytes(), prefer_one=prefer_one_fingerprint
) )
ski = self._get_subject_key_identifier() ski_bytes = self._get_subject_key_identifier()
if ski is not None: if ski_bytes is not None:
ski = to_native(binascii.hexlify(ski)) ski = binascii.hexlify(ski_bytes).decode("ascii")
ski = ":".join([ski[i : i + 2] for i in range(0, len(ski), 2)]) ski = ":".join([ski[i : i + 2] for i in range(0, len(ski), 2)])
else:
ski = None
result["subject_key_identifier"] = ski result["subject_key_identifier"] = ski
aki, aci, acsn = self._get_authority_key_identifier() aki_bytes, aci, acsn = self._get_authority_key_identifier()
if aki is not None: if aki_bytes is not None:
aki = to_native(binascii.hexlify(aki)) aki = binascii.hexlify(aki_bytes).decode("ascii")
aki = ":".join([aki[i : i + 2] for i in range(0, len(aki), 2)]) aki = ":".join([aki[i : i + 2] for i in range(0, len(aki), 2)])
else:
aki = None
result["authority_key_identifier"] = aki result["authority_key_identifier"] = aki
result["authority_cert_issuer"] = aci result["authority_cert_issuer"] = aci
result["authority_cert_serial_number"] = acsn result["authority_cert_serial_number"] = acsn
@@ -235,265 +379,23 @@ class CertificateInfoRetrieval(object):
return result return result
class CertificateInfoRetrievalCryptography(CertificateInfoRetrieval): def get_certificate_info(
"""Validate the supplied cert, using the cryptography backend""" *,
module: GeneralAnsibleModule,
def __init__(self, module, content): content: bytes,
super(CertificateInfoRetrievalCryptography, self).__init__( prefer_one_fingerprint: bool = False,
module, "cryptography", content ) -> dict[str, t.Any]:
) info = CertificateInfoRetrieval(module=module, content=content)
self.name_encoding = module.params.get("name_encoding", "ignore")
def _get_der_bytes(self):
return self.cert.public_bytes(serialization.Encoding.DER)
def _get_signature_algorithm(self):
return cryptography_oid_to_name(self.cert.signature_algorithm_oid)
def _get_subject_ordered(self):
result = []
for attribute in self.cert.subject:
result.append([cryptography_oid_to_name(attribute.oid), attribute.value])
return result
def _get_issuer_ordered(self):
result = []
for attribute in self.cert.issuer:
result.append([cryptography_oid_to_name(attribute.oid), attribute.value])
return result
def _get_version(self):
if self.cert.version == x509.Version.v1:
return 1
if self.cert.version == x509.Version.v3:
return 3
return "unknown"
def _get_key_usage(self):
try:
current_key_ext = self.cert.extensions.get_extension_for_class(
x509.KeyUsage
)
current_key_usage = current_key_ext.value
key_usage = dict(
digital_signature=current_key_usage.digital_signature,
content_commitment=current_key_usage.content_commitment,
key_encipherment=current_key_usage.key_encipherment,
data_encipherment=current_key_usage.data_encipherment,
key_agreement=current_key_usage.key_agreement,
key_cert_sign=current_key_usage.key_cert_sign,
crl_sign=current_key_usage.crl_sign,
encipher_only=False,
decipher_only=False,
)
if key_usage["key_agreement"]:
key_usage.update(
dict(
encipher_only=current_key_usage.encipher_only,
decipher_only=current_key_usage.decipher_only,
)
)
key_usage_names = dict(
digital_signature="Digital Signature",
content_commitment="Non Repudiation",
key_encipherment="Key Encipherment",
data_encipherment="Data Encipherment",
key_agreement="Key Agreement",
key_cert_sign="Certificate Sign",
crl_sign="CRL Sign",
encipher_only="Encipher Only",
decipher_only="Decipher Only",
)
return (
sorted(
[
key_usage_names[name]
for name, value in key_usage.items()
if value
]
),
current_key_ext.critical,
)
except cryptography.x509.ExtensionNotFound:
return None, False
def _get_extended_key_usage(self):
try:
ext_keyusage_ext = self.cert.extensions.get_extension_for_class(
x509.ExtendedKeyUsage
)
return (
sorted(
[cryptography_oid_to_name(eku) for eku in ext_keyusage_ext.value]
),
ext_keyusage_ext.critical,
)
except cryptography.x509.ExtensionNotFound:
return None, False
def _get_basic_constraints(self):
try:
ext_keyusage_ext = self.cert.extensions.get_extension_for_class(
x509.BasicConstraints
)
result = []
result.append(
"CA:{0}".format("TRUE" if ext_keyusage_ext.value.ca else "FALSE")
)
if ext_keyusage_ext.value.path_length is not None:
result.append("pathlen:{0}".format(ext_keyusage_ext.value.path_length))
return sorted(result), ext_keyusage_ext.critical
except cryptography.x509.ExtensionNotFound:
return None, False
def _get_ocsp_must_staple(self):
try:
try:
# This only works with cryptography >= 2.1
tlsfeature_ext = self.cert.extensions.get_extension_for_class(
x509.TLSFeature
)
value = (
cryptography.x509.TLSFeatureType.status_request
in tlsfeature_ext.value
)
except AttributeError:
# Fallback for cryptography < 2.1
oid = x509.oid.ObjectIdentifier("1.3.6.1.5.5.7.1.24")
tlsfeature_ext = self.cert.extensions.get_extension_for_oid(oid)
value = tlsfeature_ext.value.value == b"\x30\x03\x02\x01\x05"
return value, tlsfeature_ext.critical
except cryptography.x509.ExtensionNotFound:
return None, False
def _get_subject_alt_name(self):
try:
san_ext = self.cert.extensions.get_extension_for_class(
x509.SubjectAlternativeName
)
result = [
cryptography_decode_name(san, idn_rewrite=self.name_encoding)
for san in san_ext.value
]
return result, san_ext.critical
except cryptography.x509.ExtensionNotFound:
return None, False
def get_not_before(self):
return get_not_valid_before(self.cert)
def get_not_after(self):
return get_not_valid_after(self.cert)
def _get_public_key_pem(self):
return self.cert.public_key().public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
def _get_public_key_object(self):
return self.cert.public_key()
def _get_subject_key_identifier(self):
try:
ext = self.cert.extensions.get_extension_for_class(
x509.SubjectKeyIdentifier
)
return ext.value.digest
except cryptography.x509.ExtensionNotFound:
return None
def _get_authority_key_identifier(self):
try:
ext = self.cert.extensions.get_extension_for_class(
x509.AuthorityKeyIdentifier
)
issuer = None
if ext.value.authority_cert_issuer is not None:
issuer = [
cryptography_decode_name(san, idn_rewrite=self.name_encoding)
for san in ext.value.authority_cert_issuer
]
return (
ext.value.key_identifier,
issuer,
ext.value.authority_cert_serial_number,
)
except cryptography.x509.ExtensionNotFound:
return None, None, None
def _get_serial_number(self):
return cryptography_serial_number_of_cert(self.cert)
def _get_all_extensions(self):
return cryptography_get_extensions_from_cert(self.cert)
def _get_ocsp_uri(self):
try:
ext = self.cert.extensions.get_extension_for_class(
x509.AuthorityInformationAccess
)
for desc in ext.value:
if desc.access_method == x509.oid.AuthorityInformationAccessOID.OCSP:
if isinstance(desc.access_location, x509.UniformResourceIdentifier):
return desc.access_location.value
except x509.ExtensionNotFound:
pass
return None
def _get_issuer_uri(self):
try:
ext = self.cert.extensions.get_extension_for_class(
x509.AuthorityInformationAccess
)
for desc in ext.value:
if (
desc.access_method
== x509.oid.AuthorityInformationAccessOID.CA_ISSUERS
):
if isinstance(desc.access_location, x509.UniformResourceIdentifier):
return desc.access_location.value
except x509.ExtensionNotFound:
pass
return None
def get_certificate_info(module, backend, content, prefer_one_fingerprint=False):
if backend == "cryptography":
info = CertificateInfoRetrievalCryptography(module, content)
return info.get_info(prefer_one_fingerprint=prefer_one_fingerprint) return info.get_info(prefer_one_fingerprint=prefer_one_fingerprint)
def select_backend(module, backend, content): def select_backend(
if backend == "auto": *, module: GeneralAnsibleModule, content: bytes
# Detection what is possible ) -> CertificateInfoRetrieval:
can_use_cryptography = ( assert_required_cryptography_version(
CRYPTOGRAPHY_FOUND module, minimum_cryptography_version=MINIMAL_CRYPTOGRAPHY_VERSION
and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION) )
) return CertificateInfoRetrieval(module=module, content=content)
# Try cryptography
if can_use_cryptography:
backend = "cryptography"
# Success? __all__ = ("CertificateInfoRetrieval", "get_certificate_info", "select_backend")
if backend == "auto":
module.fail_json(
msg=(
"Cannot detect any of the required Python libraries "
"cryptography (>= {0})"
).format(MINIMAL_CRYPTOGRAPHY_VERSION)
)
if backend == "cryptography":
if not CRYPTOGRAPHY_FOUND:
module.fail_json(
msg=missing_required_lib(
"cryptography >= {0}".format(MINIMAL_CRYPTOGRAPHY_VERSION)
),
exception=CRYPTOGRAPHY_IMP_ERR,
)
return backend, CertificateInfoRetrievalCryptography(module, content)
else:
raise ValueError("Unsupported value for backend: {0}".format(backend))

View File

@@ -1,131 +1,140 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org> # Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org>
# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at> # Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
__metaclass__ = type
from __future__ import annotations
import os import os
import typing as t
from random import randrange from random import randrange
from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( from ansible_collections.community.crypto.plugins.module_utils._crypto.basic import (
OpenSSLBadPassphraseError, OpenSSLBadPassphraseError,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( from ansible_collections.community.crypto.plugins.module_utils._crypto.cryptography_support import (
CRYPTOGRAPHY_TIMEZONE, CRYPTOGRAPHY_TIMEZONE,
cryptography_compare_public_keys, cryptography_compare_public_keys,
cryptography_key_needs_digest_for_signing, cryptography_key_needs_digest_for_signing,
cryptography_serial_number_of_cert,
cryptography_verify_certificate_signature, cryptography_verify_certificate_signature,
get_not_valid_after, get_not_valid_after,
get_not_valid_before, get_not_valid_before,
is_potential_certificate_issuer_public_key,
set_not_valid_after, set_not_valid_after,
set_not_valid_before, set_not_valid_before,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate import ( from ansible_collections.community.crypto.plugins.module_utils._crypto.module_backends.certificate import (
CRYPTOGRAPHY_VERSION,
CertificateBackend, CertificateBackend,
CertificateError, CertificateError,
CertificateProvider, CertificateProvider,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( from ansible_collections.community.crypto.plugins.module_utils._crypto.support import (
load_certificate, load_certificate,
load_privatekey, load_certificate_issuer_privatekey,
select_message_digest, select_message_digest,
) )
from ansible_collections.community.crypto.plugins.module_utils.time import ( from ansible_collections.community.crypto.plugins.module_utils._time import (
get_relative_time_option, get_relative_time_option,
) )
from ansible_collections.community.crypto.plugins.module_utils.version import (
LooseVersion,
) if t.TYPE_CHECKING:
import datetime # pragma: no cover
from ansible.module_utils.basic import AnsibleModule # pragma: no cover
from ansible_collections.community.crypto.plugins.module_utils._argspec import ( # pragma: no cover
ArgumentSpec,
)
from cryptography.hazmat.primitives.asymmetric.types import ( # pragma: no cover
CertificateIssuerPrivateKeyTypes,
)
try: try:
import cryptography import cryptography
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import Encoding from cryptography.hazmat.primitives.serialization import Encoding
except ImportError: except ImportError:
pass pass
class OwnCACertificateBackendCryptography(CertificateBackend): class OwnCACertificateBackendCryptography(CertificateBackend):
def __init__(self, module): def __init__(self, *, module: AnsibleModule) -> None:
super(OwnCACertificateBackendCryptography, self).__init__( super().__init__(module=module)
module, "cryptography"
)
self.create_subject_key_identifier = module.params[ self.create_subject_key_identifier: t.Literal[
"ownca_create_subject_key_identifier" "create_if_not_provided", "always_create", "never_create"
] ] = module.params["ownca_create_subject_key_identifier"]
self.create_authority_key_identifier = module.params[ self.create_authority_key_identifier: bool = module.params[
"ownca_create_authority_key_identifier" "ownca_create_authority_key_identifier"
] ]
self.notBefore = get_relative_time_option( self.not_before = get_relative_time_option(
module.params["ownca_not_before"], module.params["ownca_not_before"],
"ownca_not_before", input_name="ownca_not_before",
backend=self.backend,
with_timezone=CRYPTOGRAPHY_TIMEZONE, with_timezone=CRYPTOGRAPHY_TIMEZONE,
) )
self.notAfter = get_relative_time_option( self.not_after = get_relative_time_option(
module.params["ownca_not_after"], module.params["ownca_not_after"],
"ownca_not_after", input_name="ownca_not_after",
backend=self.backend,
with_timezone=CRYPTOGRAPHY_TIMEZONE, with_timezone=CRYPTOGRAPHY_TIMEZONE,
) )
self.digest = select_message_digest(module.params["ownca_digest"]) self.digest = select_message_digest(module.params["ownca_digest"])
self.version = module.params["ownca_version"]
self.serial_number = x509.random_serial_number() self.serial_number = x509.random_serial_number()
self.ca_cert_path = module.params["ownca_path"] self.ca_cert_path: str | None = module.params["ownca_path"]
self.ca_cert_content = module.params["ownca_content"] ca_cert_content: str | None = module.params["ownca_content"]
if self.ca_cert_content is not None: if ca_cert_content is not None:
self.ca_cert_content = self.ca_cert_content.encode("utf-8") self.ca_cert_content: bytes | None = ca_cert_content.encode("utf-8")
self.ca_privatekey_path = module.params["ownca_privatekey_path"] else:
self.ca_privatekey_content = module.params["ownca_privatekey_content"] self.ca_cert_content = None
if self.ca_privatekey_content is not None: self.ca_privatekey_path: str | None = module.params["ownca_privatekey_path"]
self.ca_privatekey_content = self.ca_privatekey_content.encode("utf-8") ca_privatekey_content: str | None = module.params["ownca_privatekey_content"]
self.ca_privatekey_passphrase = module.params["ownca_privatekey_passphrase"] if ca_privatekey_content is not None:
self.ca_privatekey_content: bytes | None = ca_privatekey_content.encode(
"utf-8"
)
else:
self.ca_privatekey_content = None
self.ca_privatekey_passphrase: str | None = module.params[
"ownca_privatekey_passphrase"
]
if self.csr_content is None and self.csr_path is None: if self.csr_content is None:
raise CertificateError( if self.csr_path is None:
"csr_path or csr_content is required for ownca provider" raise CertificateError(
) "csr_path or csr_content is required for ownca provider"
if self.csr_content is None and not os.path.exists(self.csr_path):
raise CertificateError(
"The certificate signing request file {0} does not exist".format(
self.csr_path
) )
) if not os.path.exists(self.csr_path):
if self.ca_cert_content is None and not os.path.exists(self.ca_cert_path): raise CertificateError(
f"The certificate signing request file {self.csr_path} does not exist"
)
if self.ca_cert_path is not None and not os.path.exists(self.ca_cert_path):
raise CertificateError( raise CertificateError(
"The CA certificate file {0} does not exist".format(self.ca_cert_path) f"The CA certificate file {self.ca_cert_path} does not exist"
) )
if self.ca_privatekey_content is None and not os.path.exists( if self.ca_privatekey_path is not None and not os.path.exists(
self.ca_privatekey_path self.ca_privatekey_path
): ):
raise CertificateError( raise CertificateError(
"The CA private key file {0} does not exist".format( f"The CA private key file {self.ca_privatekey_path} does not exist"
self.ca_privatekey_path
)
) )
self._ensure_csr_loaded() self._ensure_csr_loaded()
self.ca_cert = load_certificate( self.ca_cert = load_certificate(
path=self.ca_cert_path, content=self.ca_cert_content, backend=self.backend path=self.ca_cert_path,
content=self.ca_cert_content,
) )
if not is_potential_certificate_issuer_public_key(self.ca_cert.public_key()):
raise CertificateError(
"CA certificate's public key cannot be used to sign certificates"
)
try: try:
self.ca_private_key = load_privatekey( self.ca_private_key = load_certificate_issuer_privatekey(
path=self.ca_privatekey_path, path=self.ca_privatekey_path,
content=self.ca_privatekey_content, content=self.ca_privatekey_content,
passphrase=self.ca_privatekey_passphrase, passphrase=self.ca_privatekey_passphrase,
backend=self.backend,
) )
except OpenSSLBadPassphraseError as exc: except OpenSSLBadPassphraseError as exc:
module.fail_json(msg=str(exc)) module.fail_json(msg=str(exc))
@@ -140,20 +149,23 @@ class OwnCACertificateBackendCryptography(CertificateBackend):
if cryptography_key_needs_digest_for_signing(self.ca_private_key): if cryptography_key_needs_digest_for_signing(self.ca_private_key):
if self.digest is None: if self.digest is None:
raise CertificateError( raise CertificateError(
"The digest %s is not supported with the cryptography backend" f"The digest {module.params['ownca_digest']} is not supported with the cryptography backend"
% module.params["ownca_digest"]
) )
else: else:
self.digest = None self.digest = None
def generate_certificate(self): def generate_certificate(self) -> None:
"""(Re-)Generate certificate.""" """(Re-)Generate certificate."""
if self.csr is None:
raise AssertionError(
"Contract violation: csr has not been populated"
) # pragma: no cover
cert_builder = x509.CertificateBuilder() cert_builder = x509.CertificateBuilder()
cert_builder = cert_builder.subject_name(self.csr.subject) cert_builder = cert_builder.subject_name(self.csr.subject)
cert_builder = cert_builder.issuer_name(self.ca_cert.subject) cert_builder = cert_builder.issuer_name(self.ca_cert.subject)
cert_builder = cert_builder.serial_number(self.serial_number) cert_builder = cert_builder.serial_number(self.serial_number)
cert_builder = set_not_valid_before(cert_builder, self.notBefore) cert_builder = set_not_valid_before(cert_builder, self.not_before)
cert_builder = set_not_valid_after(cert_builder, self.notAfter) cert_builder = set_not_valid_after(cert_builder, self.not_after)
cert_builder = cert_builder.public_key(self.csr.public_key()) cert_builder = cert_builder.public_key(self.csr.public_key())
has_ski = False has_ski = False
for extension in self.csr.extensions: for extension in self.csr.extensions:
@@ -183,54 +195,50 @@ class OwnCACertificateBackendCryptography(CertificateBackend):
x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier( x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(
ext.value ext.value
) )
if CRYPTOGRAPHY_VERSION >= LooseVersion("2.7")
else x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(
ext
)
), ),
critical=False, critical=False,
) )
except cryptography.x509.ExtensionNotFound: except cryptography.x509.ExtensionNotFound:
public_key = self.ca_cert.public_key()
assert is_potential_certificate_issuer_public_key(public_key)
cert_builder = cert_builder.add_extension( cert_builder = cert_builder.add_extension(
x509.AuthorityKeyIdentifier.from_issuer_public_key( x509.AuthorityKeyIdentifier.from_issuer_public_key(public_key),
self.ca_cert.public_key()
),
critical=False, critical=False,
) )
try: certificate = cert_builder.sign(
certificate = cert_builder.sign( private_key=self.ca_private_key,
private_key=self.ca_private_key, algorithm=self.digest,
algorithm=self.digest, )
backend=default_backend(),
)
except TypeError as e:
if (
str(e) == "Algorithm must be a registered hash algorithm."
and self.digest is None
):
self.module.fail_json(
msg="Signing with Ed25519 and Ed448 keys requires cryptography 2.8 or newer."
)
raise
self.cert = certificate self.cert = certificate
def get_certificate_data(self): def get_certificate_data(self) -> bytes:
"""Return bytes for self.cert.""" """Return bytes for self.cert."""
if self.cert is None:
raise AssertionError(
"Contract violation: cert has not been populated"
) # pragma: no cover
return self.cert.public_bytes(Encoding.PEM) return self.cert.public_bytes(Encoding.PEM)
def needs_regeneration(self): def needs_regeneration(
if super(OwnCACertificateBackendCryptography, self).needs_regeneration( self,
not_before=self.notBefore, not_after=self.notAfter *,
not_before: datetime.datetime | None = None,
not_after: datetime.datetime | None = None,
) -> bool:
if super().needs_regeneration(
not_before=self.not_before, not_after=self.not_after
): ):
return True return True
self._ensure_existing_certificate_loaded() self._ensure_existing_certificate_loaded()
assert self.existing_certificate is not None
# Check whether certificate is signed by CA certificate # Check whether certificate is signed by CA certificate
if not cryptography_verify_certificate_signature( if not cryptography_verify_certificate_signature(
self.existing_certificate, self.ca_cert.public_key() certificate=self.existing_certificate,
signer_public_key=self.ca_cert.public_key(),
): ):
return True return True
@@ -241,38 +249,34 @@ class OwnCACertificateBackendCryptography(CertificateBackend):
# Check AuthorityKeyIdentifier # Check AuthorityKeyIdentifier
if self.create_authority_key_identifier: if self.create_authority_key_identifier:
try: try:
ext = self.ca_cert.extensions.get_extension_for_class( ext_ski = self.ca_cert.extensions.get_extension_for_class(
x509.SubjectKeyIdentifier x509.SubjectKeyIdentifier
) )
expected_ext = ( expected_ext = (
x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier( x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(
ext.value ext_ski.value
)
if CRYPTOGRAPHY_VERSION >= LooseVersion("2.7")
else x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(
ext
) )
) )
except cryptography.x509.ExtensionNotFound: except cryptography.x509.ExtensionNotFound:
public_key = self.ca_cert.public_key()
assert is_potential_certificate_issuer_public_key(public_key)
expected_ext = x509.AuthorityKeyIdentifier.from_issuer_public_key( expected_ext = x509.AuthorityKeyIdentifier.from_issuer_public_key(
self.ca_cert.public_key() public_key
) )
try: try:
ext = self.existing_certificate.extensions.get_extension_for_class( ext_aki = self.existing_certificate.extensions.get_extension_for_class(
x509.AuthorityKeyIdentifier x509.AuthorityKeyIdentifier
) )
if ext.value != expected_ext: if ext_aki.value != expected_ext:
return True return True
except cryptography.x509.ExtensionNotFound: except cryptography.x509.ExtensionNotFound:
return True return True
return False return False
def dump(self, include_certificate): def dump(self, *, include_certificate: bool) -> dict[str, t.Any]:
result = super(OwnCACertificateBackendCryptography, self).dump( result = super().dump(include_certificate=include_certificate)
include_certificate
)
result.update( result.update(
{ {
"ca_cert": self.ca_cert_path, "ca_cert": self.ca_cert_path,
@@ -283,14 +287,15 @@ class OwnCACertificateBackendCryptography(CertificateBackend):
if self.module.check_mode: if self.module.check_mode:
result.update( result.update(
{ {
"notBefore": self.notBefore.strftime("%Y%m%d%H%M%SZ"), "notBefore": self.not_before.strftime("%Y%m%d%H%M%SZ"),
"notAfter": self.notAfter.strftime("%Y%m%d%H%M%SZ"), "notAfter": self.not_after.strftime("%Y%m%d%H%M%SZ"),
"serial_number": self.serial_number, "serial_number": self.serial_number,
} }
) )
else: else:
if self.cert is None: if self.cert is None:
self.cert = self.existing_certificate self.cert = self.existing_certificate
assert self.cert is not None
result.update( result.update(
{ {
"notBefore": get_not_valid_before(self.cert).strftime( "notBefore": get_not_valid_before(self.cert).strftime(
@@ -299,14 +304,14 @@ class OwnCACertificateBackendCryptography(CertificateBackend):
"notAfter": get_not_valid_after(self.cert).strftime( "notAfter": get_not_valid_after(self.cert).strftime(
"%Y%m%d%H%M%SZ" "%Y%m%d%H%M%SZ"
), ),
"serial_number": cryptography_serial_number_of_cert(self.cert), "serial_number": self.cert.serial_number,
} }
) )
return result return result
def generate_serial_number(): def generate_serial_number() -> int:
"""Generate a serial number for a certificate""" """Generate a serial number for a certificate"""
while True: while True:
result = randrange(0, 1 << 160) result = randrange(0, 1 << 160)
@@ -315,7 +320,7 @@ def generate_serial_number():
class OwnCACertificateProvider(CertificateProvider): class OwnCACertificateProvider(CertificateProvider):
def validate_module_args(self, module): def validate_module_args(self, module: AnsibleModule) -> None:
if ( if (
module.params["ownca_path"] is None module.params["ownca_path"] is None
and module.params["ownca_content"] is None and module.params["ownca_content"] is None
@@ -331,34 +336,32 @@ class OwnCACertificateProvider(CertificateProvider):
msg="One of ownca_privatekey_path and ownca_privatekey_content must be specified for the ownca provider." msg="One of ownca_privatekey_path and ownca_privatekey_content must be specified for the ownca provider."
) )
def needs_version_two_certs(self, module): def create_backend(
return module.params["ownca_version"] == 2 self, module: AnsibleModule
) -> OwnCACertificateBackendCryptography:
def create_backend(self, module, backend): return OwnCACertificateBackendCryptography(module=module)
if backend == "cryptography":
return OwnCACertificateBackendCryptography(module)
def add_ownca_provider_to_argument_spec(argument_spec): def add_ownca_provider_to_argument_spec(argument_spec: ArgumentSpec) -> None:
argument_spec.argument_spec["provider"]["choices"].append("ownca") argument_spec.argument_spec["provider"]["choices"].append("ownca")
argument_spec.argument_spec.update( argument_spec.argument_spec.update(
dict( {
ownca_path=dict(type="path"), "ownca_path": {"type": "path"},
ownca_content=dict(type="str"), "ownca_content": {"type": "str"},
ownca_privatekey_path=dict(type="path"), "ownca_privatekey_path": {"type": "path"},
ownca_privatekey_content=dict(type="str", no_log=True), "ownca_privatekey_content": {"type": "str", "no_log": True},
ownca_privatekey_passphrase=dict(type="str", no_log=True), "ownca_privatekey_passphrase": {"type": "str", "no_log": True},
ownca_digest=dict(type="str", default="sha256"), "ownca_digest": {"type": "str", "default": "sha256"},
ownca_version=dict(type="int", default=3), "ownca_version": {"type": "int", "default": 3, "choices": [3]}, # not used
ownca_not_before=dict(type="str", default="+0s"), "ownca_not_before": {"type": "str", "default": "+0s"},
ownca_not_after=dict(type="str", default="+3650d"), "ownca_not_after": {"type": "str", "default": "+3650d"},
ownca_create_subject_key_identifier=dict( "ownca_create_subject_key_identifier": {
type="str", "type": "str",
default="create_if_not_provided", "default": "create_if_not_provided",
choices=["create_if_not_provided", "always_create", "never_create"], "choices": ["create_if_not_provided", "always_create", "never_create"],
), },
ownca_create_authority_key_identifier=dict(type="bool", default=True), "ownca_create_authority_key_identifier": {"type": "bool", "default": True},
) }
) )
argument_spec.mutually_exclusive.extend( argument_spec.mutually_exclusive.extend(
[ [
@@ -366,3 +369,10 @@ def add_ownca_provider_to_argument_spec(argument_spec):
["ownca_privatekey_path", "ownca_privatekey_content"], ["ownca_privatekey_path", "ownca_privatekey_content"],
] ]
) )
__all__ = (
"OwnCACertificateBackendCryptography",
"OwnCACertificateProvider",
"add_ownca_provider_to_argument_spec",
)

View File

@@ -0,0 +1,278 @@
# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org>
# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import os
import typing as t
from random import randrange
from ansible_collections.community.crypto.plugins.module_utils._crypto.cryptography_support import (
CRYPTOGRAPHY_TIMEZONE,
cryptography_key_needs_digest_for_signing,
cryptography_verify_certificate_signature,
get_not_valid_after,
get_not_valid_before,
is_potential_certificate_issuer_private_key,
set_not_valid_after,
set_not_valid_before,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.module_backends.certificate import (
CertificateBackend,
CertificateError,
CertificateProvider,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.support import (
select_message_digest,
)
from ansible_collections.community.crypto.plugins.module_utils._time import (
get_relative_time_option,
)
if t.TYPE_CHECKING:
import datetime # pragma: no cover
from ansible.module_utils.basic import AnsibleModule # pragma: no cover
from ansible_collections.community.crypto.plugins.module_utils._argspec import ( # pragma: no cover
ArgumentSpec,
)
from cryptography.hazmat.primitives.asymmetric.types import ( # pragma: no cover
CertificateIssuerPrivateKeyTypes,
)
try:
import cryptography
from cryptography import x509
from cryptography.hazmat.primitives.serialization import Encoding
except ImportError:
pass
class SelfSignedCertificateBackendCryptography(CertificateBackend):
privatekey: CertificateIssuerPrivateKeyTypes
def __init__(self, *, module: AnsibleModule) -> None:
super().__init__(module=module)
self.create_subject_key_identifier: t.Literal[
"create_if_not_provided", "always_create", "never_create"
] = module.params["selfsigned_create_subject_key_identifier"]
self.not_before = get_relative_time_option(
module.params["selfsigned_not_before"],
input_name="selfsigned_not_before",
with_timezone=CRYPTOGRAPHY_TIMEZONE,
)
self.not_after = get_relative_time_option(
module.params["selfsigned_not_after"],
input_name="selfsigned_not_after",
with_timezone=CRYPTOGRAPHY_TIMEZONE,
)
self.digest = select_message_digest(module.params["selfsigned_digest"])
self.serial_number = x509.random_serial_number()
if self.csr_path is not None and not os.path.exists(self.csr_path):
raise CertificateError(
f"The certificate signing request file {self.csr_path} does not exist"
)
if self.privatekey_path is not None and not os.path.exists(
self.privatekey_path
):
raise CertificateError(
f"The private key file {self.privatekey_path} does not exist"
)
self._module = module
self._ensure_private_key_loaded()
if self.privatekey is None:
raise CertificateError("Private key has not been provided")
if not is_potential_certificate_issuer_private_key(self.privatekey):
raise CertificateError("Private key cannot be used to sign certificates")
if cryptography_key_needs_digest_for_signing(self.privatekey):
if self.digest is None:
raise CertificateError(
f"The digest {module.params['selfsigned_digest']} is not supported with the cryptography backend"
)
else:
self.digest = None
self._ensure_csr_loaded()
if self.csr is None:
# Create empty CSR on the fly
csr = cryptography.x509.CertificateSigningRequestBuilder()
csr = csr.subject_name(cryptography.x509.Name([]))
self.csr = csr.sign(self.privatekey, self.digest)
def generate_certificate(self) -> None:
"""(Re-)Generate certificate."""
if self.csr is None:
raise AssertionError(
"Contract violation: csr has not been populated"
) # pragma: no cover
if self.privatekey is None:
raise AssertionError( # pragma: no cover
"Contract violation: privatekey has not been populated"
)
try:
cert_builder = x509.CertificateBuilder()
cert_builder = cert_builder.subject_name(self.csr.subject)
cert_builder = cert_builder.issuer_name(self.csr.subject)
cert_builder = cert_builder.serial_number(self.serial_number)
cert_builder = set_not_valid_before(cert_builder, self.not_before)
cert_builder = set_not_valid_after(cert_builder, self.not_after)
cert_builder = cert_builder.public_key(self.privatekey.public_key())
has_ski = False
for extension in self.csr.extensions:
if isinstance(extension.value, x509.SubjectKeyIdentifier):
if self.create_subject_key_identifier == "always_create":
continue
has_ski = True
cert_builder = cert_builder.add_extension(
extension.value, critical=extension.critical
)
if not has_ski and self.create_subject_key_identifier != "never_create":
cert_builder = cert_builder.add_extension(
x509.SubjectKeyIdentifier.from_public_key(
self.privatekey.public_key()
),
critical=False,
)
except ValueError as e:
raise CertificateError(str(e)) from e
certificate = cert_builder.sign(
private_key=self.privatekey,
algorithm=self.digest,
)
self.cert = certificate
def get_certificate_data(self) -> bytes:
"""Return bytes for self.cert."""
if self.cert is None:
raise AssertionError(
"Contract violation: cert has not been populated"
) # pragma: no cover
return self.cert.public_bytes(Encoding.PEM)
def needs_regeneration(
self,
*,
not_before: datetime.datetime | None = None,
not_after: datetime.datetime | None = None,
) -> bool:
assert self.privatekey is not None
if super().needs_regeneration(
not_before=self.not_before, not_after=self.not_after
):
return True
self._ensure_existing_certificate_loaded()
assert self.existing_certificate is not None
# Check whether certificate is signed by private key
if not cryptography_verify_certificate_signature(
certificate=self.existing_certificate,
signer_public_key=self.privatekey.public_key(),
):
return True
return False
def dump(self, *, include_certificate: bool) -> dict[str, t.Any]:
result = super().dump(include_certificate=include_certificate)
if self.module.check_mode:
result.update(
{
"notBefore": self.not_before.strftime("%Y%m%d%H%M%SZ"),
"notAfter": self.not_after.strftime("%Y%m%d%H%M%SZ"),
"serial_number": self.serial_number,
}
)
else:
if self.cert is None:
self.cert = self.existing_certificate
assert self.cert is not None
result.update(
{
"notBefore": get_not_valid_before(self.cert).strftime(
"%Y%m%d%H%M%SZ"
),
"notAfter": get_not_valid_after(self.cert).strftime(
"%Y%m%d%H%M%SZ"
),
"serial_number": self.cert.serial_number,
}
)
return result
def generate_serial_number() -> int:
"""Generate a serial number for a certificate"""
while True:
result = randrange(0, 1 << 160)
if result >= 1000:
return result
class SelfSignedCertificateProvider(CertificateProvider):
def validate_module_args(self, module: AnsibleModule) -> None:
if (
module.params["privatekey_path"] is None
and module.params["privatekey_content"] is None
):
module.fail_json(
msg="One of privatekey_path and privatekey_content must be specified for the selfsigned provider."
)
def create_backend(
self, module: AnsibleModule
) -> SelfSignedCertificateBackendCryptography:
return SelfSignedCertificateBackendCryptography(module=module)
def add_selfsigned_provider_to_argument_spec(argument_spec: ArgumentSpec) -> None:
argument_spec.argument_spec["provider"]["choices"].append("selfsigned")
argument_spec.argument_spec.update(
{
"selfsigned_version": {
"type": "int",
"default": 3,
"choices": [3],
}, # not used
"selfsigned_digest": {"type": "str", "default": "sha256"},
"selfsigned_not_before": {
"type": "str",
"default": "+0s",
"aliases": ["selfsigned_notBefore"],
},
"selfsigned_not_after": {
"type": "str",
"default": "+3650d",
"aliases": ["selfsigned_notAfter"],
},
"selfsigned_create_subject_key_identifier": {
"type": "str",
"default": "create_if_not_provided",
"choices": ["create_if_not_provided", "always_create", "never_create"],
},
}
)
__all__ = (
"SelfSignedCertificateBackendCryptography",
"SelfSignedCertificateProvider",
"add_selfsigned_provider_to_argument_spec",
)

View File

@@ -0,0 +1,134 @@
# Copyright (c) 2020, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import typing as t
from ansible_collections.community.crypto.plugins.module_utils._crypto.cryptography_crl import (
TIMESTAMP_FORMAT,
cryptography_decode_revoked_certificate,
cryptography_dump_revoked,
cryptography_get_signature_algorithm_oid_from_crl,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.cryptography_support import (
cryptography_oid_to_name,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.pem import (
identify_pem_format,
)
from ansible_collections.community.crypto.plugins.module_utils._cryptography_dep import (
COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION,
assert_required_cryptography_version,
)
if t.TYPE_CHECKING:
from ansible.module_utils.basic import AnsibleModule # pragma: no cover
from ansible_collections.community.crypto.plugins.plugin_utils._action_module import ( # pragma: no cover
AnsibleActionModule,
)
from ansible_collections.community.crypto.plugins.plugin_utils._filter_module import ( # pragma: no cover
FilterModuleMock,
)
from cryptography.hazmat.primitives.asymmetric.types import ( # pragma: no cover
PrivateKeyTypes,
)
GeneralAnsibleModule = t.Union[
AnsibleModule, AnsibleActionModule, FilterModuleMock
] # pragma: no cover
# crypto_utils
MINIMAL_CRYPTOGRAPHY_VERSION = COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION
try:
from cryptography import x509
except ImportError:
pass
class CRLInfoRetrieval:
def __init__(
self,
*,
module: GeneralAnsibleModule,
content: bytes,
list_revoked_certificates: bool = True,
) -> None:
# content must be a bytes string
self.module = module
self.content = content
self.list_revoked_certificates = list_revoked_certificates
self.name_encoding = module.params.get("name_encoding", "ignore")
def get_info(self) -> dict[str, t.Any]:
crl_pem = identify_pem_format(self.content)
try:
if crl_pem:
crl = x509.load_pem_x509_crl(self.content)
else:
crl = x509.load_der_x509_crl(self.content)
except ValueError as e:
self.module.fail_json(msg=f"Error while decoding CRL: {e}")
result: dict[str, t.Any] = {
"changed": False,
"format": "pem" if crl_pem else "der",
"last_update": None,
"next_update": None,
"digest": None,
"issuer_ordered": None,
"issuer": None,
}
result["last_update"] = crl.last_update.strftime(TIMESTAMP_FORMAT)
result["next_update"] = (
crl.next_update.strftime(TIMESTAMP_FORMAT) if crl.next_update else None
)
result["digest"] = cryptography_oid_to_name(
cryptography_get_signature_algorithm_oid_from_crl(crl)
)
issuer = []
for attribute in crl.issuer:
issuer.append([cryptography_oid_to_name(attribute.oid), attribute.value])
result["issuer_ordered"] = issuer
issuer_dict = {}
for k, v in issuer:
issuer_dict[k] = v
result["issuer"] = issuer_dict
if self.list_revoked_certificates:
result["revoked_certificates"] = []
for cert in crl:
entry = cryptography_decode_revoked_certificate(cert)
result["revoked_certificates"].append(
cryptography_dump_revoked(entry, idn_rewrite=self.name_encoding)
)
return result
def get_crl_info(
*,
module: GeneralAnsibleModule,
content: bytes,
list_revoked_certificates: bool = True,
) -> dict[str, t.Any]:
assert_required_cryptography_version(
module, minimum_cryptography_version=MINIMAL_CRYPTOGRAPHY_VERSION
)
info = CRLInfoRetrieval(
module=module,
content=content,
list_revoked_certificates=list_revoked_certificates,
)
return info.get_info()
__all__ = ("CRLInfoRetrieval", "get_crl_info")

View File

@@ -0,0 +1,335 @@
# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org>
# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at>
# Copyright (c) 2020, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import binascii
import typing as t
from ansible.module_utils.common.text.converters import to_text
from ansible_collections.community.crypto.plugins.module_utils._crypto.cryptography_support import (
cryptography_decode_name,
cryptography_get_extensions_from_csr,
cryptography_oid_to_name,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.module_backends.publickey_info import (
get_publickey_info,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.support import (
load_certificate_request,
)
from ansible_collections.community.crypto.plugins.module_utils._cryptography_dep import (
COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION,
assert_required_cryptography_version,
)
if t.TYPE_CHECKING:
from ansible.module_utils.basic import AnsibleModule # pragma: no cover
from ansible_collections.community.crypto.plugins.plugin_utils._action_module import ( # pragma: no cover
AnsibleActionModule,
)
from ansible_collections.community.crypto.plugins.plugin_utils._filter_module import ( # pragma: no cover
FilterModuleMock,
)
from cryptography.hazmat.primitives.asymmetric.types import ( # pragma: no cover
CertificatePublicKeyTypes,
PrivateKeyTypes,
)
GeneralAnsibleModule = t.Union[
AnsibleModule, AnsibleActionModule, FilterModuleMock
] # pragma: no cover
MINIMAL_CRYPTOGRAPHY_VERSION = COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION
try:
import cryptography
from cryptography import x509
from cryptography.hazmat.primitives import serialization
except ImportError:
pass
TIMESTAMP_FORMAT = "%Y%m%d%H%M%SZ"
class CSRInfoRetrieval:
csr: x509.CertificateSigningRequest
def __init__(
self, *, module: GeneralAnsibleModule, content: bytes, validate_signature: bool
) -> None:
self.module = module
self.content = content
self.validate_signature = validate_signature
self.name_encoding: t.Literal["ignore", "idna", "unicode"] = module.params.get(
"name_encoding", "ignore"
)
def _get_subject_ordered(self) -> list[list[str]]:
result: list[list[str]] = []
for attribute in self.csr.subject:
result.append(
[cryptography_oid_to_name(attribute.oid), to_text(attribute.value)]
)
return result
def _get_key_usage(self) -> tuple[list[str] | None, bool]:
try:
current_key_ext = self.csr.extensions.get_extension_for_class(x509.KeyUsage)
current_key_usage = current_key_ext.value
key_usage = {
"digital_signature": current_key_usage.digital_signature,
"content_commitment": current_key_usage.content_commitment,
"key_encipherment": current_key_usage.key_encipherment,
"data_encipherment": current_key_usage.data_encipherment,
"key_agreement": current_key_usage.key_agreement,
"key_cert_sign": current_key_usage.key_cert_sign,
"crl_sign": current_key_usage.crl_sign,
"encipher_only": False,
"decipher_only": False,
}
if key_usage["key_agreement"]:
key_usage.update(
{
"encipher_only": current_key_usage.encipher_only,
"decipher_only": current_key_usage.decipher_only,
}
)
key_usage_names = {
"digital_signature": "Digital Signature",
"content_commitment": "Non Repudiation",
"key_encipherment": "Key Encipherment",
"data_encipherment": "Data Encipherment",
"key_agreement": "Key Agreement",
"key_cert_sign": "Certificate Sign",
"crl_sign": "CRL Sign",
"encipher_only": "Encipher Only",
"decipher_only": "Decipher Only",
}
return (
sorted(
[
key_usage_names[name]
for name, value in key_usage.items()
if value
]
),
current_key_ext.critical,
)
except cryptography.x509.ExtensionNotFound:
return None, False
def _get_extended_key_usage(self) -> tuple[list[str] | None, bool]:
try:
ext_keyusage_ext = self.csr.extensions.get_extension_for_class(
x509.ExtendedKeyUsage
)
return (
sorted(
[cryptography_oid_to_name(eku) for eku in ext_keyusage_ext.value]
),
ext_keyusage_ext.critical,
)
except cryptography.x509.ExtensionNotFound:
return None, False
def _get_basic_constraints(self) -> tuple[list[str] | None, bool]:
try:
ext_keyusage_ext = self.csr.extensions.get_extension_for_class(
x509.BasicConstraints
)
result = [f"CA:{'TRUE' if ext_keyusage_ext.value.ca else 'FALSE'}"]
if ext_keyusage_ext.value.path_length is not None:
result.append(f"pathlen:{ext_keyusage_ext.value.path_length}")
return sorted(result), ext_keyusage_ext.critical
except cryptography.x509.ExtensionNotFound:
return None, False
def _get_ocsp_must_staple(self) -> tuple[bool | None, bool]:
try:
# This only works with cryptography >= 2.1
tlsfeature_ext = self.csr.extensions.get_extension_for_class(
x509.TLSFeature
)
value = (
cryptography.x509.TLSFeatureType.status_request in tlsfeature_ext.value
)
return value, tlsfeature_ext.critical
except cryptography.x509.ExtensionNotFound:
return None, False
def _get_subject_alt_name(self) -> tuple[list[str] | None, bool]:
try:
san_ext = self.csr.extensions.get_extension_for_class(
x509.SubjectAlternativeName
)
result = [
cryptography_decode_name(san, idn_rewrite=self.name_encoding)
for san in san_ext.value
]
return result, san_ext.critical
except cryptography.x509.ExtensionNotFound:
return None, False
def _get_name_constraints(self) -> tuple[list[str] | None, list[str] | None, bool]:
try:
nc_ext = self.csr.extensions.get_extension_for_class(x509.NameConstraints)
permitted = [
cryptography_decode_name(san, idn_rewrite=self.name_encoding)
for san in nc_ext.value.permitted_subtrees or []
]
excluded = [
cryptography_decode_name(san, idn_rewrite=self.name_encoding)
for san in nc_ext.value.excluded_subtrees or []
]
return permitted, excluded, nc_ext.critical
except cryptography.x509.ExtensionNotFound:
return None, None, False
def _get_public_key_pem(self) -> bytes:
return self.csr.public_key().public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
def _get_public_key_object(self) -> CertificatePublicKeyTypes:
return self.csr.public_key()
def _get_subject_key_identifier(self) -> bytes | None:
try:
ext = self.csr.extensions.get_extension_for_class(x509.SubjectKeyIdentifier)
return ext.value.digest
except cryptography.x509.ExtensionNotFound:
return None
def _get_authority_key_identifier(
self,
) -> tuple[bytes | None, list[str] | None, int | None]:
try:
ext = self.csr.extensions.get_extension_for_class(
x509.AuthorityKeyIdentifier
)
issuer = None
if ext.value.authority_cert_issuer is not None:
issuer = [
cryptography_decode_name(san, idn_rewrite=self.name_encoding)
for san in ext.value.authority_cert_issuer
]
return (
ext.value.key_identifier,
issuer,
ext.value.authority_cert_serial_number,
)
except cryptography.x509.ExtensionNotFound:
return None, None, None
def _get_all_extensions(self) -> dict[str, dict[str, bool | str]]:
return cryptography_get_extensions_from_csr(self.csr)
def _is_signature_valid(self) -> bool:
return self.csr.is_signature_valid
def get_info(self, *, prefer_one_fingerprint: bool = False) -> dict[str, t.Any]:
result: dict[str, t.Any] = {}
self.csr = load_certificate_request(
content=self.content,
)
subject = self._get_subject_ordered()
result["subject"] = {}
for k, v in subject:
result["subject"][k] = v
result["subject_ordered"] = subject
result["key_usage"], result["key_usage_critical"] = self._get_key_usage()
result["extended_key_usage"], result["extended_key_usage_critical"] = (
self._get_extended_key_usage()
)
result["basic_constraints"], result["basic_constraints_critical"] = (
self._get_basic_constraints()
)
result["ocsp_must_staple"], result["ocsp_must_staple_critical"] = (
self._get_ocsp_must_staple()
)
result["subject_alt_name"], result["subject_alt_name_critical"] = (
self._get_subject_alt_name()
)
(
result["name_constraints_permitted"],
result["name_constraints_excluded"],
result["name_constraints_critical"],
) = self._get_name_constraints()
result["public_key"] = to_text(self._get_public_key_pem())
public_key_info = get_publickey_info(
module=self.module,
key=self._get_public_key_object(),
prefer_one_fingerprint=prefer_one_fingerprint,
)
result.update(
{
"public_key_type": public_key_info["type"],
"public_key_data": public_key_info["public_data"],
"public_key_fingerprints": public_key_info["fingerprints"],
}
)
ski_bytes = self._get_subject_key_identifier()
ski = None
if ski_bytes is not None:
ski = binascii.hexlify(ski_bytes).decode("ascii")
ski = ":".join([ski[i : i + 2] for i in range(0, len(ski), 2)])
result["subject_key_identifier"] = ski
aki_bytes, aci, acsn = self._get_authority_key_identifier()
aki = None
if aki_bytes is not None:
aki = binascii.hexlify(aki_bytes).decode("ascii")
aki = ":".join([aki[i : i + 2] for i in range(0, len(aki), 2)])
result["authority_key_identifier"] = aki
result["authority_cert_issuer"] = aci
result["authority_cert_serial_number"] = acsn
result["extensions_by_oid"] = self._get_all_extensions()
result["signature_valid"] = self._is_signature_valid()
if self.validate_signature and not result["signature_valid"]:
self.module.fail_json(msg="CSR signature is invalid!", **result)
return result
def get_csr_info(
*,
module: GeneralAnsibleModule,
content: bytes,
validate_signature: bool = True,
prefer_one_fingerprint: bool = False,
) -> dict[str, t.Any]:
info = CSRInfoRetrieval(
module=module, content=content, validate_signature=validate_signature
)
return info.get_info(prefer_one_fingerprint=prefer_one_fingerprint)
def select_backend(
*, module: GeneralAnsibleModule, content: bytes, validate_signature: bool = True
) -> CSRInfoRetrieval:
assert_required_cryptography_version(
module, minimum_cryptography_version=MINIMAL_CRYPTOGRAPHY_VERSION
)
return CSRInfoRetrieval(
module=module, content=content, validate_signature=validate_signature
)
__all__ = ("CSRInfoRetrieval", "get_csr_info", "select_backend")

View File

@@ -0,0 +1,293 @@
# Copyright (c) 2022, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import traceback
import typing as t
from ansible.module_utils.common.text.converters import to_bytes
from ansible_collections.community.crypto.plugins.module_utils._argspec import (
ArgumentSpec,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.basic import (
OpenSSLObjectError,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.cryptography_support import (
cryptography_compare_private_keys,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.pem import (
identify_private_key_format,
)
from ansible_collections.community.crypto.plugins.module_utils._cryptography_dep import (
COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION,
assert_required_cryptography_version,
)
from ansible_collections.community.crypto.plugins.module_utils._io import load_file
if t.TYPE_CHECKING:
from ansible.module_utils.basic import AnsibleModule # pragma: no cover
from cryptography.hazmat.primitives.asymmetric.types import ( # pragma: no cover
PrivateKeyTypes,
)
MINIMAL_CRYPTOGRAPHY_VERSION = COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION
try:
import cryptography
import cryptography.exceptions
import cryptography.hazmat.backends
import cryptography.hazmat.primitives.asymmetric.dsa
import cryptography.hazmat.primitives.asymmetric.ec
import cryptography.hazmat.primitives.asymmetric.ed448
import cryptography.hazmat.primitives.asymmetric.ed25519
import cryptography.hazmat.primitives.asymmetric.rsa
import cryptography.hazmat.primitives.asymmetric.utils
import cryptography.hazmat.primitives.asymmetric.x448
import cryptography.hazmat.primitives.asymmetric.x25519
import cryptography.hazmat.primitives.serialization
except ImportError:
pass
class PrivateKeyError(OpenSSLObjectError):
pass
# From the object called `module`, only the following properties are used:
#
# - module.params[]
# - module.warn(msg: str)
# - module.fail_json(msg: str, **kwargs)
class PrivateKeyConvertBackend:
def __init__(self, *, module: AnsibleModule) -> None:
self.module = module
self.src_path: str | None = module.params["src_path"]
self.src_content: str | None = module.params["src_content"]
self.src_passphrase: str | None = module.params["src_passphrase"]
self.format: t.Literal["pkcs1", "pkcs8", "raw"] = module.params["format"]
self.dest_passphrase: str | None = module.params["dest_passphrase"]
self.src_private_key: PrivateKeyTypes | None = None
if self.src_path is not None:
self.src_private_key_bytes = load_file(path=self.src_path, module=module)
else:
if self.src_content is None:
raise AssertionError("src_content is None") # pragma: no cover
self.src_private_key_bytes = self.src_content.encode("utf-8")
self.dest_private_key: PrivateKeyTypes | None = None
self.dest_private_key_bytes: bytes | None = None
def get_private_key_data(self) -> bytes:
"""Return bytes for self.src_private_key in output format"""
if self.src_private_key is None:
raise AssertionError("src_private_key not set") # pragma: no cover
# Select export format and encoding
try:
export_encoding = cryptography.hazmat.primitives.serialization.Encoding.PEM
if self.format == "pkcs1":
# "TraditionalOpenSSL" format is PKCS1
export_format = (
cryptography.hazmat.primitives.serialization.PrivateFormat.TraditionalOpenSSL
)
elif self.format == "pkcs8":
export_format = (
cryptography.hazmat.primitives.serialization.PrivateFormat.PKCS8
)
elif self.format == "raw":
export_format = (
cryptography.hazmat.primitives.serialization.PrivateFormat.Raw
)
export_encoding = (
cryptography.hazmat.primitives.serialization.Encoding.Raw
)
else:
# pylint does not notice that all possible values for self.format have been covered.
raise AssertionError("Can never be reached") # pragma: no cover
except AttributeError:
self.module.fail_json(
msg=f'Cryptography backend does not support the selected output format "{self.format}"'
)
# Select key encryption
encryption_algorithm: (
cryptography.hazmat.primitives.serialization.KeySerializationEncryption
) = cryptography.hazmat.primitives.serialization.NoEncryption()
if self.dest_passphrase:
encryption_algorithm = (
cryptography.hazmat.primitives.serialization.BestAvailableEncryption(
to_bytes(self.dest_passphrase)
)
)
# Serialize key
try:
return self.src_private_key.private_bytes(
encoding=export_encoding,
format=export_format,
encryption_algorithm=encryption_algorithm,
)
except ValueError:
self.module.fail_json(
msg=f'Cryptography backend cannot serialize the private key in the required format "{self.format}"'
)
except Exception:
self.module.fail_json(
msg=f'Error while serializing the private key in the required format "{self.format}"',
exception=traceback.format_exc(),
)
def set_existing_destination(self, *, privatekey_bytes: bytes | None) -> None:
"""Set existing private key bytes. None indicates that the key does not exist."""
self.dest_private_key_bytes = privatekey_bytes
def has_existing_destination(self) -> bool:
"""Query whether an existing private key is/has been there."""
return self.dest_private_key_bytes is not None
def _load_private_key(
self,
*,
data: bytes,
passphrase: str | None,
current_hint: PrivateKeyTypes | None = None,
) -> tuple[str, PrivateKeyTypes]:
"""Check whether data can be loaded as a private key with the provided passphrase. Return tuple (type, private_key)."""
try:
# Interpret bytes depending on format.
key_format = identify_private_key_format(data)
if key_format == "raw":
if passphrase is not None:
raise PrivateKeyError("Cannot load raw key with passphrase")
if len(data) == 56:
return (
key_format,
cryptography.hazmat.primitives.asymmetric.x448.X448PrivateKey.from_private_bytes(
data
),
)
if len(data) == 57:
return (
key_format,
cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey.from_private_bytes(
data
),
)
if len(data) == 32:
if isinstance(
current_hint,
cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey,
):
try:
return (
key_format,
cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey.from_private_bytes(
data
),
)
except Exception:
return (
key_format,
cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey.from_private_bytes(
data
),
)
else:
try:
return (
key_format,
cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey.from_private_bytes(
data
),
)
except Exception:
return (
key_format,
cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey.from_private_bytes(
data
),
)
raise PrivateKeyError("Cannot load raw key")
return (
key_format,
cryptography.hazmat.primitives.serialization.load_pem_private_key(
data,
None if passphrase is None else to_bytes(passphrase),
),
)
except Exception as e:
raise PrivateKeyError(e) from e
def needs_conversion(self) -> bool:
"""Check whether a conversion is necessary. Must only be called if needs_regeneration() returned False."""
dummy, self.src_private_key = self._load_private_key(
data=self.src_private_key_bytes, passphrase=self.src_passphrase
)
if not self.has_existing_destination():
return True
assert self.dest_private_key_bytes is not None
try:
key_format, self.dest_private_key = self._load_private_key(
data=self.dest_private_key_bytes,
passphrase=self.dest_passphrase,
current_hint=self.src_private_key,
)
except Exception:
return True
return key_format != self.format or not cryptography_compare_private_keys(
self.dest_private_key, self.src_private_key
)
def dump(self) -> dict[str, t.Any]:
"""Serialize the object into a dictionary."""
return {}
def select_backend(module: AnsibleModule) -> PrivateKeyConvertBackend:
assert_required_cryptography_version(
module, minimum_cryptography_version=MINIMAL_CRYPTOGRAPHY_VERSION
)
return PrivateKeyConvertBackend(module=module)
def get_privatekey_argument_spec() -> ArgumentSpec:
return ArgumentSpec(
argument_spec={
"src_path": {"type": "path"},
"src_content": {"type": "str"},
"src_passphrase": {"type": "str", "no_log": True},
"dest_passphrase": {"type": "str", "no_log": True},
"format": {
"type": "str",
"required": True,
"choices": ["pkcs1", "pkcs8", "raw"],
},
},
mutually_exclusive=[
["src_path", "src_content"],
],
required_one_of=[
["src_path", "src_content"],
],
)
__all__ = (
"PrivateKeyError",
"PrivateKeyConvertBackend",
"select_backend",
"get_privatekey_argument_spec",
)

View File

@@ -0,0 +1,342 @@
# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org>
# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at>
# Copyright (c) 2020, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import typing as t
from ansible.module_utils.common.text.converters import to_bytes, to_text
from ansible_collections.community.crypto.plugins.module_utils._crypto.basic import (
OpenSSLObjectError,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.math import (
binary_exp_mod,
quick_is_not_prime,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.module_backends.publickey_info import (
_get_cryptography_public_key_info,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.support import (
get_fingerprint_of_bytes,
load_privatekey,
)
from ansible_collections.community.crypto.plugins.module_utils._cryptography_dep import (
COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION,
assert_required_cryptography_version,
)
if t.TYPE_CHECKING:
from ansible.module_utils.basic import AnsibleModule # pragma: no cover
from ansible_collections.community.crypto.plugins.plugin_utils._action_module import ( # pragma: no cover
AnsibleActionModule,
)
from ansible_collections.community.crypto.plugins.plugin_utils._filter_module import ( # pragma: no cover
FilterModuleMock,
)
from cryptography.hazmat.primitives.asymmetric.types import ( # pragma: no cover
PrivateKeyTypes,
)
GeneralAnsibleModule = t.Union[
AnsibleModule, AnsibleActionModule, FilterModuleMock
] # pragma: no cover
MINIMAL_CRYPTOGRAPHY_VERSION = COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION
try:
import cryptography
from cryptography.hazmat.primitives import serialization
except ImportError:
pass
SIGNATURE_TEST_DATA = b"1234"
def _get_cryptography_private_key_info(
key: PrivateKeyTypes, *, need_private_key_data: bool = False
) -> tuple[str, dict[str, t.Any], dict[str, t.Any]]:
key_type, key_public_data = _get_cryptography_public_key_info(key.public_key())
key_private_data: dict[str, t.Any] = {}
if need_private_key_data:
if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey):
rsa_private_numbers = key.private_numbers()
key_private_data["p"] = rsa_private_numbers.p
key_private_data["q"] = rsa_private_numbers.q
key_private_data["exponent"] = rsa_private_numbers.d
elif isinstance(
key, cryptography.hazmat.primitives.asymmetric.dsa.DSAPrivateKey
):
dsa_private_numbers = key.private_numbers()
key_private_data["x"] = dsa_private_numbers.x
elif isinstance(
key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey
):
ecc_private_numbers = key.private_numbers()
key_private_data["multiplier"] = ecc_private_numbers.private_value
return key_type, key_public_data, key_private_data
def _check_dsa_consistency(
*, key_public_data: dict[str, t.Any], key_private_data: dict[str, t.Any]
) -> bool | None:
# Get parameters
p: int | None = key_public_data.get("p")
if p is None:
return None
q: int | None = key_public_data.get("q")
if q is None:
return None
g: int | None = key_public_data.get("g")
if g is None:
return None
y: int | None = key_public_data.get("y")
if y is None:
return None
x: int | None = key_private_data.get("x")
if x is None:
return None
# Make sure that g is not 0, 1 or -1 in Z/pZ
if g < 2 or g >= p - 1:
return False
# Make sure that x is in range
if x < 1 or x >= q:
return False
# Check whether q divides p-1
if (p - 1) % q != 0:
return False
# Check that g**q mod p == 1
if binary_exp_mod(g, q, m=p) != 1:
return False
# Check whether g**x mod p == y
if binary_exp_mod(g, x, m=p) != y:
return False
# Check (quickly) whether p or q are not primes
if quick_is_not_prime(q) or quick_is_not_prime(p):
return False
return True
def _is_cryptography_key_consistent(
key: PrivateKeyTypes,
*,
key_public_data: dict[str, t.Any],
key_private_data: dict[str, t.Any],
warn_func: t.Callable[[str], None] | None = None,
) -> bool | None:
if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey):
# key._backend was removed in cryptography 42.0.0
backend = getattr(key, "_backend", None)
if backend is not None:
return bool(backend._lib.RSA_check_key(key._rsa_cdata)) # type: ignore # pylint: disable=protected-access
if isinstance(key, cryptography.hazmat.primitives.asymmetric.dsa.DSAPrivateKey):
result = _check_dsa_consistency(
key_public_data=key_public_data, key_private_data=key_private_data
)
if result is not None:
return result
signature = key.sign(
SIGNATURE_TEST_DATA, cryptography.hazmat.primitives.hashes.SHA256()
)
try:
key.public_key().verify(
signature,
SIGNATURE_TEST_DATA,
cryptography.hazmat.primitives.hashes.SHA256(),
)
return True
except cryptography.exceptions.InvalidSignature:
return False
if isinstance(
key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey
):
signature = key.sign(
SIGNATURE_TEST_DATA,
cryptography.hazmat.primitives.asymmetric.ec.ECDSA(
cryptography.hazmat.primitives.hashes.SHA256()
),
)
try:
key.public_key().verify(
signature,
SIGNATURE_TEST_DATA,
cryptography.hazmat.primitives.asymmetric.ec.ECDSA(
cryptography.hazmat.primitives.hashes.SHA256()
),
)
return True
except cryptography.exceptions.InvalidSignature:
return False
has_simple_sign_function = False
if isinstance(
key, cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey
):
has_simple_sign_function = True
if isinstance(key, cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey):
has_simple_sign_function = True
if has_simple_sign_function:
signature = key.sign(SIGNATURE_TEST_DATA) # type: ignore
try:
key.public_key().verify(signature, SIGNATURE_TEST_DATA) # type: ignore
return True
except cryptography.exceptions.InvalidSignature:
return False
# For X25519 and X448, there's no test yet.
if warn_func is not None:
warn_func(f"Cannot determine consistency for key of type {type(key)}")
return None
class PrivateKeyConsistencyError(OpenSSLObjectError):
def __init__(self, msg: str, *, result: dict[str, t.Any]) -> None:
super().__init__(msg)
self.error_message = msg
self.result = result
class PrivateKeyParseError(OpenSSLObjectError):
def __init__(self, msg: str, *, result: dict[str, t.Any]) -> None:
super().__init__(msg)
self.error_message = msg
self.result = result
class PrivateKeyInfoRetrieval:
key: PrivateKeyTypes
def __init__(
self,
*,
module: GeneralAnsibleModule,
content: bytes,
passphrase: str | None = None,
return_private_key_data: bool = False,
check_consistency: bool = False,
):
self.module = module
self.content = content
self.passphrase = passphrase
self.return_private_key_data = return_private_key_data
self.check_consistency = check_consistency
def _get_public_key(self, *, binary: bool) -> bytes:
return self.key.public_key().public_bytes(
serialization.Encoding.DER if binary else serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
def _get_key_info(
self, *, need_private_key_data: bool = False
) -> tuple[str, dict[str, t.Any], dict[str, t.Any]]:
return _get_cryptography_private_key_info(
self.key, need_private_key_data=need_private_key_data
)
def _is_key_consistent(
self, *, key_public_data: dict[str, t.Any], key_private_data: dict[str, t.Any]
) -> bool | None:
return _is_cryptography_key_consistent(
self.key,
key_public_data=key_public_data,
key_private_data=key_private_data,
warn_func=self.module.warn,
)
def get_info(self, *, prefer_one_fingerprint: bool = False) -> dict[str, t.Any]:
result: dict[str, t.Any] = {
"can_parse_key": False,
"key_is_consistent": None,
}
priv_key_detail = self.content
try:
self.key = load_privatekey(
path=None,
content=priv_key_detail,
passphrase=(
to_bytes(self.passphrase)
if self.passphrase is not None
else self.passphrase
),
)
result["can_parse_key"] = True
except OpenSSLObjectError as exc:
raise PrivateKeyParseError(str(exc), result=result) from exc
result["public_key"] = to_text(self._get_public_key(binary=False))
pk = self._get_public_key(binary=True)
result["public_key_fingerprints"] = (
get_fingerprint_of_bytes(pk, prefer_one=prefer_one_fingerprint)
if pk is not None
else {}
)
key_type, key_public_data, key_private_data = self._get_key_info(
need_private_key_data=self.return_private_key_data or self.check_consistency
)
result["type"] = key_type
result["public_data"] = key_public_data
if self.return_private_key_data:
result["private_data"] = key_private_data
if self.check_consistency:
result["key_is_consistent"] = self._is_key_consistent(
key_public_data=key_public_data, key_private_data=key_private_data
)
if result["key_is_consistent"] is False:
# Only fail when it is False, to avoid to fail on None (which means "we do not know")
msg = "Private key is not consistent! (See https://blog.hboeck.de/archives/888-How-I-tricked-Symantec-with-a-Fake-Private-Key.html)"
raise PrivateKeyConsistencyError(msg, result=result)
return result
def get_privatekey_info(
*,
module: GeneralAnsibleModule,
content: bytes,
passphrase: str | None = None,
return_private_key_data: bool = False,
prefer_one_fingerprint: bool = False,
) -> dict[str, t.Any]:
info = PrivateKeyInfoRetrieval(
module=module,
content=content,
passphrase=passphrase,
return_private_key_data=return_private_key_data,
)
return info.get_info(prefer_one_fingerprint=prefer_one_fingerprint)
def select_backend(
*,
module: GeneralAnsibleModule,
content: bytes,
passphrase: str | None = None,
return_private_key_data: bool = False,
check_consistency: bool = False,
) -> PrivateKeyInfoRetrieval:
assert_required_cryptography_version(
module, minimum_cryptography_version=MINIMAL_CRYPTOGRAPHY_VERSION
)
return PrivateKeyInfoRetrieval(
module=module,
content=content,
passphrase=passphrase,
return_private_key_data=return_private_key_data,
check_consistency=check_consistency,
)
__all__ = (
"PrivateKeyConsistencyError",
"PrivateKeyParseError",
"PrivateKeyInfoRetrieval",
"get_privatekey_info",
"select_backend",
)

View File

@@ -0,0 +1,184 @@
# Copyright (c) 2020-2021, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import typing as t
from ansible_collections.community.crypto.plugins.module_utils._crypto.basic import (
OpenSSLObjectError,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.support import (
get_fingerprint_of_bytes,
load_publickey,
)
from ansible_collections.community.crypto.plugins.module_utils._cryptography_dep import (
COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION,
assert_required_cryptography_version,
)
if t.TYPE_CHECKING:
from ansible.module_utils.basic import AnsibleModule # pragma: no cover
from ansible_collections.community.crypto.plugins.plugin_utils._action_module import ( # pragma: no cover
AnsibleActionModule,
)
from ansible_collections.community.crypto.plugins.plugin_utils._filter_module import ( # pragma: no cover
FilterModuleMock,
)
from cryptography.hazmat.primitives.asymmetric.types import ( # pragma: no cover
PublicKeyTypes,
)
GeneralAnsibleModule = t.Union[
AnsibleModule, AnsibleActionModule, FilterModuleMock
] # pragma: no cover
MINIMAL_CRYPTOGRAPHY_VERSION = COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION
try:
import cryptography
import cryptography.hazmat.primitives.asymmetric.ed448
import cryptography.hazmat.primitives.asymmetric.ed25519
import cryptography.hazmat.primitives.asymmetric.x448
import cryptography.hazmat.primitives.asymmetric.x25519
from cryptography.hazmat.primitives import serialization
except ImportError:
pass
def _get_cryptography_public_key_info(
key: PublicKeyTypes,
) -> tuple[str, dict[str, t.Any]]:
key_public_data: dict[str, t.Any] = {}
if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey):
key_type = "RSA"
rsa_public_numbers = key.public_numbers()
key_public_data["size"] = key.key_size
key_public_data["modulus"] = rsa_public_numbers.n
key_public_data["exponent"] = rsa_public_numbers.e
elif isinstance(key, cryptography.hazmat.primitives.asymmetric.dsa.DSAPublicKey):
key_type = "DSA"
dsa_parameter_numbers = key.parameters().parameter_numbers()
dsa_public_numbers = key.public_numbers()
key_public_data["size"] = key.key_size
key_public_data["p"] = dsa_parameter_numbers.p
key_public_data["q"] = dsa_parameter_numbers.q
key_public_data["g"] = dsa_parameter_numbers.g
key_public_data["y"] = dsa_public_numbers.y
elif isinstance(
key, cryptography.hazmat.primitives.asymmetric.x25519.X25519PublicKey
):
key_type = "X25519"
elif isinstance(key, cryptography.hazmat.primitives.asymmetric.x448.X448PublicKey):
key_type = "X448"
elif isinstance(
key, cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PublicKey
):
key_type = "Ed25519"
elif isinstance(
key, cryptography.hazmat.primitives.asymmetric.ed448.Ed448PublicKey
):
key_type = "Ed448"
elif isinstance(
key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey
):
key_type = "ECC"
ecc_public_numbers = key.public_numbers()
key_public_data["curve"] = key.curve.name
key_public_data["x"] = ecc_public_numbers.x
key_public_data["y"] = ecc_public_numbers.y
key_public_data["exponent_size"] = key.curve.key_size
else:
key_type = f"unknown ({type(key)})"
return key_type, key_public_data
class PublicKeyParseError(OpenSSLObjectError):
def __init__(self, msg: str, *, result: dict[str, t.Any]) -> None:
super().__init__(msg)
self.error_message = msg
self.result = result
class PublicKeyInfoRetrieval:
def __init__(
self,
*,
module: GeneralAnsibleModule,
content: bytes | None = None,
key: PublicKeyTypes | None = None,
) -> None:
# content must be a bytes string
self.module = module
self.content = content
self.key = key
def _get_public_key(self, binary: bool) -> bytes:
if self.key is None:
raise AssertionError("key must be set") # pragma: no cover
return self.key.public_bytes(
serialization.Encoding.DER if binary else serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
def _get_key_info(self) -> tuple[str, dict[str, t.Any]]:
if self.key is None:
raise AssertionError("key must be set") # pragma: no cover
return _get_cryptography_public_key_info(self.key)
def get_info(self, *, prefer_one_fingerprint: bool = False) -> dict[str, t.Any]:
result: dict[str, t.Any] = {}
if self.key is None:
try:
self.key = load_publickey(content=self.content)
except OpenSSLObjectError as e:
raise PublicKeyParseError(str(e), result={}) from e
pk = self._get_public_key(binary=True)
result["fingerprints"] = (
get_fingerprint_of_bytes(pk, prefer_one=prefer_one_fingerprint)
if pk is not None
else {}
)
key_type, key_public_data = self._get_key_info()
result["type"] = key_type
result["public_data"] = key_public_data
return result
def get_publickey_info(
*,
module: GeneralAnsibleModule,
content: bytes | None = None,
key: PublicKeyTypes | None = None,
prefer_one_fingerprint: bool = False,
) -> dict[str, t.Any]:
info = PublicKeyInfoRetrieval(module=module, content=content, key=key)
return info.get_info(prefer_one_fingerprint=prefer_one_fingerprint)
def select_backend(
*,
module: GeneralAnsibleModule,
content: bytes | None = None,
key: PublicKeyTypes | None = None,
) -> PublicKeyInfoRetrieval:
assert_required_cryptography_version(
module, minimum_cryptography_version=MINIMAL_CRYPTOGRAPHY_VERSION
)
return PublicKeyInfoRetrieval(module=module, content=content, key=key)
__all__ = (
"PublicKeyParseError",
"PublicKeyInfoRetrieval",
"get_publickey_info",
"select_backend",
)

View File

@@ -1,13 +1,13 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2019, Felix Fontein <felix@fontein.de> # Copyright (c) 2019, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
__metaclass__ = type import typing as t
PEM_START = "-----BEGIN " PEM_START = "-----BEGIN "
@@ -17,7 +17,7 @@ PKCS8_PRIVATEKEY_NAMES = ("PRIVATE KEY", "ENCRYPTED PRIVATE KEY")
PKCS1_PRIVATEKEY_SUFFIX = " PRIVATE KEY" PKCS1_PRIVATEKEY_SUFFIX = " PRIVATE KEY"
def identify_pem_format(content, encoding="utf-8"): def identify_pem_format(content: bytes, *, encoding: str = "utf-8") -> bool:
"""Given the contents of a binary file, tests whether this could be a PEM file.""" """Given the contents of a binary file, tests whether this could be a PEM file."""
try: try:
first_pem = extract_first_pem(content.decode(encoding)) first_pem = extract_first_pem(content.decode(encoding))
@@ -35,7 +35,9 @@ def identify_pem_format(content, encoding="utf-8"):
return False return False
def identify_private_key_format(content, encoding="utf-8"): def identify_private_key_format(
content: bytes, *, encoding: str = "utf-8"
) -> t.Literal["raw", "pkcs1", "pkcs8", "unknown-pem"]:
"""Given the contents of a private key file, identifies its format.""" """Given the contents of a private key file, identifies its format."""
# See https://github.com/openssl/openssl/blob/master/crypto/pem/pem_pkey.c#L40-L85 # See https://github.com/openssl/openssl/blob/master/crypto/pem/pem_pkey.c#L40-L85
# (PEM_read_bio_PrivateKey) # (PEM_read_bio_PrivateKey)
@@ -64,12 +66,12 @@ def identify_private_key_format(content, encoding="utf-8"):
return "raw" return "raw"
def split_pem_list(text, keep_inbetween=False): def split_pem_list(text: str, *, keep_inbetween: bool = False) -> list[str]:
""" """
Split concatenated PEM objects into a list of strings, where each is one PEM object. Split concatenated PEM objects into a list of strings, where each is one PEM object.
""" """
result = [] result = []
current = [] if keep_inbetween else None current: list[str] | None = [] if keep_inbetween else None
for line in text.splitlines(True): for line in text.splitlines(True):
if line.strip(): if line.strip():
if not keep_inbetween and line.startswith("-----BEGIN "): if not keep_inbetween and line.startswith("-----BEGIN "):
@@ -82,7 +84,7 @@ def split_pem_list(text, keep_inbetween=False):
return result return result
def extract_first_pem(text): def extract_first_pem(text: str) -> str | None:
""" """
Given one PEM or multiple concatenated PEM objects, return only the first one, or None if there is none. Given one PEM or multiple concatenated PEM objects, return only the first one, or None if there is none.
""" """
@@ -92,7 +94,7 @@ def extract_first_pem(text):
return all_pems[0] return all_pems[0]
def _extract_type(line, start=PEM_START): def _extract_type(line: str, *, start: str = PEM_START) -> str | None:
if not line.startswith(start): if not line.startswith(start):
return None return None
if not line.endswith(PEM_END): if not line.endswith(PEM_END):
@@ -100,39 +102,40 @@ def _extract_type(line, start=PEM_START):
return line[len(start) : -len(PEM_END)] return line[len(start) : -len(PEM_END)]
def extract_pem(content, strict=False): def extract_pem(content: str, *, strict: bool = False) -> tuple[str, str]:
lines = content.splitlines() lines = content.splitlines()
if len(lines) < 3: if len(lines) < 3:
raise ValueError( raise ValueError(f"PEM must have at least 3 lines, have only {len(lines)}")
"PEM must have at least 3 lines, have only {count}".format(count=len(lines))
)
header_type = _extract_type(lines[0]) header_type = _extract_type(lines[0])
if header_type is None: if header_type is None:
raise ValueError( raise ValueError(
"First line is not of format {start}...{end}: {line!r}".format( f"First line is not of format {PEM_START}...{PEM_END}: {lines[0]!r}"
start=PEM_START, end=PEM_END, line=lines[0]
)
) )
footer_type = _extract_type(lines[-1], start=PEM_END_START) footer_type = _extract_type(lines[-1], start=PEM_END_START)
if strict: if strict:
if header_type != footer_type: if header_type != footer_type:
raise ValueError( raise ValueError(
"Header type ({header}) is different from footer type ({footer})".format( f"Header type ({header_type}) is different from footer type ({footer_type})"
header=header_type, footer=footer_type
)
) )
for idx, line in enumerate(lines[1:-2]): for idx, line in enumerate(lines[1:-2]):
if len(line) != 64: if len(line) != 64:
raise ValueError( raise ValueError(f"Line {idx} has length {len(line)} instead of 64")
"Line {idx} has length {len} instead of 64".format(
idx=idx, len=len(line)
)
)
if not (0 < len(lines[-2]) <= 64): if not (0 < len(lines[-2]) <= 64):
raise ValueError( raise ValueError(
"Last line has length {len}, should be in (0, 64]".format( f"Last line has length {len(lines[-2])}, should be in (0, 64]"
len=len(lines[-2])
)
) )
content = lines[1:-1] return header_type, "".join(lines[1:-1])
return header_type, "".join(content)
__all__ = (
"PEM_START",
"PEM_END_START",
"PEM_END",
"PKCS8_PRIVATEKEY_NAMES",
"PKCS1_PRIVATEKEY_SUFFIX",
"identify_pem_format",
"identify_private_key_format",
"split_pem_list",
"extract_first_pem",
"extract_pem",
)

View File

@@ -0,0 +1,478 @@
# Copyright (c) 2016, Yanis Guenane <yanis+ansible@guenane.org>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import abc
import errno
import hashlib
import os
import typing as t
from ansible.module_utils.common.text.converters import to_bytes
from ansible_collections.community.crypto.plugins.module_utils._crypto.cryptography_support import (
is_potential_certificate_issuer_private_key,
is_potential_certificate_private_key,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.pem import (
identify_pem_format,
)
try:
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.serialization import load_pem_private_key
except ImportError:
# Error handled in the calling module.
pass
from ansible_collections.community.crypto.plugins.module_utils._crypto.basic import (
OpenSSLBadPassphraseError,
OpenSSLObjectError,
)
if t.TYPE_CHECKING:
from ansible.module_utils.basic import AnsibleModule # pragma: no cover
from ansible_collections.community.crypto.plugins.module_utils._crypto.cryptography_support import ( # pragma: no cover
CertificatePrivateKeyTypes,
)
from cryptography.hazmat.primitives.asymmetric.types import ( # pragma: no cover
CertificateIssuerPrivateKeyTypes,
PrivateKeyTypes,
PublicKeyTypes,
)
# This list of preferred fingerprints is used when prefer_one=True is supplied to the
# fingerprinting methods.
PREFERRED_FINGERPRINTS = (
"sha256",
"sha3_256",
"sha512",
"sha3_512",
"sha384",
"sha3_384",
"sha1",
"md5",
)
def get_fingerprint_of_bytes(
source: bytes, *, prefer_one: bool = False
) -> dict[str, str]:
"""Generate the fingerprint of the given bytes."""
fingerprint = {}
algorithms: t.Iterable[str] = hashlib.algorithms_guaranteed
if prefer_one:
# Sort algorithms to have the ones in PREFERRED_FINGERPRINTS at the beginning
prefered_algorithms = [
algorithm for algorithm in PREFERRED_FINGERPRINTS if algorithm in algorithms
]
prefered_algorithms += sorted(
[
algorithm
for algorithm in algorithms
if algorithm not in PREFERRED_FINGERPRINTS
]
)
algorithms = prefered_algorithms
for algo in algorithms:
f = getattr(hashlib, algo)
try:
h = f(source)
except ValueError:
# This can happen for hash algorithms not supported in FIPS mode
# (https://github.com/ansible/ansible/issues/67213)
continue
try:
# Certain hash functions have a hexdigest() which expects a length parameter
pubkey_digest = h.hexdigest()
except TypeError:
pubkey_digest = h.hexdigest(32)
fingerprint[algo] = ":".join(
pubkey_digest[i : i + 2] for i in range(0, len(pubkey_digest), 2)
)
if prefer_one:
break
return fingerprint
def get_fingerprint_of_privatekey(
privatekey: PrivateKeyTypes, *, prefer_one: bool = False
) -> dict[str, str]:
"""Generate the fingerprint of the public key."""
publickey = privatekey.public_key().public_bytes(
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
)
return get_fingerprint_of_bytes(publickey, prefer_one=prefer_one)
def get_fingerprint(
*,
path: os.PathLike | str | None = None,
passphrase: str | bytes | None = None,
content: bytes | None = None,
prefer_one: bool = False,
) -> dict[str, str]:
"""Generate the fingerprint of the public key."""
privatekey = load_privatekey(
path=path,
passphrase=passphrase,
content=content,
check_passphrase=False,
)
return get_fingerprint_of_privatekey(privatekey, prefer_one=prefer_one)
def load_privatekey(
*,
path: os.PathLike | str | None = None,
passphrase: str | bytes | None = None,
check_passphrase: bool = True,
content: bytes | None = None,
) -> PrivateKeyTypes:
"""Load the specified OpenSSL private key.
The content can also be specified via content; in that case,
this function will not load the key from disk.
"""
try:
if content is None:
if path is None:
raise OpenSSLObjectError("Must provide either path or content")
with open(path, "rb") as b_priv_key_fh:
priv_key_detail = b_priv_key_fh.read()
else:
priv_key_detail = content
except (IOError, OSError) as exc:
raise OpenSSLObjectError(exc) from exc
try:
return load_pem_private_key(
priv_key_detail,
None if passphrase is None else to_bytes(passphrase),
)
except TypeError as exc:
raise OpenSSLBadPassphraseError(
"Wrong or empty passphrase provided for private key"
) from exc
except ValueError as exc:
raise OpenSSLBadPassphraseError(
"Wrong passphrase provided for private key"
) from exc
def load_certificate_privatekey(
*,
path: os.PathLike | str | None = None,
content: bytes | None = None,
passphrase: str | bytes | None = None,
check_passphrase: bool = True,
) -> CertificatePrivateKeyTypes:
"""
Load the specified OpenSSL private key that can be used as a private key for certificates.
"""
private_key = load_privatekey(
path=path,
passphrase=passphrase,
check_passphrase=check_passphrase,
content=content,
)
if not is_potential_certificate_private_key(private_key):
raise OpenSSLObjectError(
f"Key of type {type(private_key)} not supported for certificates"
)
return private_key
def load_certificate_issuer_privatekey(
*,
path: os.PathLike | str | None = None,
content: bytes | None = None,
passphrase: str | bytes | None = None,
check_passphrase: bool = True,
) -> CertificateIssuerPrivateKeyTypes:
"""
Load the specified OpenSSL private key that can be used for issuing certificates.
"""
private_key = load_privatekey(
path=path,
passphrase=passphrase,
check_passphrase=check_passphrase,
content=content,
)
if not is_potential_certificate_issuer_private_key(private_key):
raise OpenSSLObjectError(
f"Key of type {type(private_key)} not supported for issuing certificates"
)
return private_key
def load_publickey(
*, path: os.PathLike | str | None = None, content: bytes | None = None
) -> PublicKeyTypes:
if content is None:
if path is None:
raise OpenSSLObjectError("Must provide either path or content")
try:
with open(path, "rb") as b_priv_key_fh:
content = b_priv_key_fh.read()
except (IOError, OSError) as exc:
raise OpenSSLObjectError(exc) from exc
try:
return serialization.load_pem_public_key(content)
except Exception as e:
raise OpenSSLObjectError(f"Error while deserializing key: {e}") from e
def load_certificate(
*,
path: os.PathLike | str | None = None,
content: bytes | None = None,
der_support_enabled: bool = False,
) -> x509.Certificate:
"""Load the specified certificate."""
try:
if content is None:
if path is None:
raise OpenSSLObjectError("Must provide either path or content")
with open(path, "rb") as cert_fh:
cert_content = cert_fh.read()
else:
cert_content = content
except (IOError, OSError) as exc:
raise OpenSSLObjectError(exc) from exc
if der_support_enabled is False or identify_pem_format(cert_content):
try:
return x509.load_pem_x509_certificate(cert_content)
except ValueError as exc:
raise OpenSSLObjectError(exc) from exc
elif der_support_enabled:
try:
return x509.load_der_x509_certificate(cert_content)
except ValueError as exc:
raise OpenSSLObjectError(f"Cannot parse DER certificate: {exc}") from exc
def load_certificate_request(
*, path: os.PathLike | str | None = None, content: bytes | None = None
) -> x509.CertificateSigningRequest:
"""Load the specified certificate signing request."""
try:
if content is None:
if path is None:
raise OpenSSLObjectError("Must provide either path or content")
with open(path, "rb") as csr_fh:
csr_content = csr_fh.read()
else:
csr_content = content
except (IOError, OSError) as exc:
raise OpenSSLObjectError(exc) from exc
try:
return x509.load_pem_x509_csr(csr_content)
except ValueError as exc:
raise OpenSSLObjectError(exc) from exc
@t.overload
def parse_name_field(
input_dict: dict[str, list[str] | str],
*,
name_field_name: str | None = None,
) -> list[tuple[str, str]]: ...
@t.overload
def parse_name_field(
input_dict: dict[str, list[str | bytes] | str | bytes],
*,
name_field_name: str | None = None,
) -> list[tuple[str, str | bytes]]: ...
def parse_name_field(
input_dict: dict[str, t.Any],
*,
name_field_name: str | None = None,
) -> list:
"""Take a dict with key: value or key: list_of_values mappings and return a list of tuples"""
def error_str(key: str) -> str:
if name_field_name is None:
return f"{key}"
return f"{key} in {name_field_name}"
result = []
for key, value in input_dict.items():
if isinstance(value, list):
for entry in value:
if not isinstance(entry, (str, bytes)):
raise TypeError(f"Values {error_str(key)} must be strings")
if not entry:
raise ValueError(
f"Values for {error_str(key)} must not be empty strings"
)
result.append((key, entry))
elif isinstance(value, (str, bytes)):
if not value:
raise ValueError(
f"Value for {error_str(key)} must not be an empty string"
)
result.append((key, value))
else:
raise TypeError(
f"Value for {error_str(key)} must be either a string or a list of strings"
)
return result
@t.overload
def parse_ordered_name_field(
input_list: list[dict[str, list[str] | str]],
*,
name_field_name: str,
) -> list[tuple[str, str]]: ...
@t.overload
def parse_ordered_name_field(
input_list: list[dict[str, list[str | bytes] | str | bytes]],
*,
name_field_name: str,
) -> list[tuple[str, str | bytes]]: ...
def parse_ordered_name_field(
input_list: list[dict[str, t.Any]],
*,
name_field_name: str,
) -> list:
"""Take a dict with key: value or key: list_of_values mappings and return a list of tuples"""
result = []
for index, entry in enumerate(input_list):
if len(entry) != 1:
raise ValueError(
f"Entry #{index + 1} in {name_field_name} must be a dictionary with exactly one key-value pair"
)
try:
result.extend(parse_name_field(entry, name_field_name=name_field_name))
except (TypeError, ValueError) as exc:
raise ValueError(
f"Error while processing entry #{index + 1} in {name_field_name}: {exc}"
) from exc
return result
@t.overload
def select_message_digest(
digest_string: t.Literal["sha256", "sha384", "sha512", "sha1", "md5"],
) -> hashes.SHA256 | hashes.SHA384 | hashes.SHA512 | hashes.SHA1 | hashes.MD5: ...
@t.overload
def select_message_digest(
digest_string: str,
) -> (
hashes.SHA256 | hashes.SHA384 | hashes.SHA512 | hashes.SHA1 | hashes.MD5 | None
): ...
def select_message_digest(
digest_string: str,
) -> hashes.SHA256 | hashes.SHA384 | hashes.SHA512 | hashes.SHA1 | hashes.MD5 | None:
if digest_string == "sha256":
return hashes.SHA256()
if digest_string == "sha384":
return hashes.SHA384()
if digest_string == "sha512":
return hashes.SHA512()
if digest_string == "sha1":
return hashes.SHA1()
if digest_string == "md5":
return hashes.MD5()
return None
class OpenSSLObject(metaclass=abc.ABCMeta):
def __init__(self, *, path: str, state: str, force: bool, check_mode: bool) -> None:
self.path = path
self.state = state
self.force = force
self.name = os.path.basename(path)
self.changed = False
self.check_mode = check_mode
def check(self, module: AnsibleModule, *, perms_required: bool = True) -> bool:
"""Ensure the resource is in its desired state."""
def _check_state() -> bool:
return os.path.exists(self.path)
def _check_perms(module: AnsibleModule) -> bool:
file_args = module.load_file_common_arguments(module.params)
if module.check_file_absent_if_check_mode(file_args["path"]):
return False
return not module.set_fs_attributes_if_different(file_args, False)
if not perms_required:
return _check_state()
return _check_state() and _check_perms(module)
@abc.abstractmethod
def dump(self) -> dict[str, t.Any]:
"""Serialize the object into a dictionary."""
@abc.abstractmethod
def generate(self, module: AnsibleModule) -> None:
"""Generate the resource."""
def remove(self, module: AnsibleModule) -> None:
"""Remove the resource from the filesystem."""
if self.check_mode:
if os.path.exists(self.path):
self.changed = True
return
try:
os.remove(self.path)
self.changed = True
except OSError as exc:
if exc.errno != errno.ENOENT:
raise OpenSSLObjectError(exc) from exc
__all__ = (
"get_fingerprint_of_bytes",
"get_fingerprint_of_privatekey",
"get_fingerprint",
"load_privatekey",
"load_certificate_privatekey",
"load_certificate_issuer_privatekey",
"load_publickey",
"load_certificate",
"load_certificate_request",
"parse_name_field",
"parse_ordered_name_field",
"select_message_digest",
"OpenSSLObject",
)

View File

@@ -0,0 +1,83 @@
# Copyright (c) 2025 Ansible project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
"""
Module utils for cryptography requirements.
Must be kept in sync with plugins/doc_fragments/cryptography_dep.py.
"""
from __future__ import annotations
import traceback
import typing as t
from ansible.module_utils.basic import missing_required_lib
from ansible_collections.community.crypto.plugins.module_utils._version import (
LooseVersion,
)
if t.TYPE_CHECKING:
from ansible.module_utils.basic import AnsibleModule # pragma: no cover
from ansible_collections.community.crypto.plugins.plugin_utils._action_module import ( # pragma: no cover
AnsibleActionModule,
)
from ansible_collections.community.crypto.plugins.plugin_utils._filter_module import ( # pragma: no cover
FilterModuleMock,
)
GeneralAnsibleModule = t.Union[
AnsibleModule, AnsibleActionModule, FilterModuleMock
] # pragma: no cover
_CRYPTOGRAPHY_IMP_ERR: str | None = None
_CRYPTOGRAPHY_FILE: str | None = None
try:
import cryptography
from cryptography import x509 # noqa: F401, pylint: disable=unused-import
CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__)
_CRYPTOGRAPHY_FILE = cryptography.__file__
except ImportError:
_CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
CRYPTOGRAPHY_FOUND = False
CRYPTOGRAPHY_VERSION = LooseVersion("0.0")
else:
CRYPTOGRAPHY_FOUND = True
# Corresponds to the community.crypto.cryptography_dep.minimum doc fragment
COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION = "3.3"
def assert_required_cryptography_version(
module: GeneralAnsibleModule,
*,
minimum_cryptography_version: str = COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION,
) -> None:
if not CRYPTOGRAPHY_FOUND:
module.fail_json(
msg=missing_required_lib(f"cryptography >= {minimum_cryptography_version}"),
exception=_CRYPTOGRAPHY_IMP_ERR,
)
if CRYPTOGRAPHY_VERSION < LooseVersion(minimum_cryptography_version):
module.fail_json(
msg=(
f"Cannot detect the required Python library cryptography (>= {minimum_cryptography_version})."
f" Only found a too old version ({CRYPTOGRAPHY_VERSION}) at {_CRYPTOGRAPHY_FILE}."
),
)
__all__ = (
"COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION",
"CRYPTOGRAPHY_FOUND",
"CRYPTOGRAPHY_VERSION",
"assert_required_cryptography_version",
)

View File

@@ -1,27 +1,25 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2023, Felix Fontein <felix@fontein.de> # Copyright (c) 2023, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
__metaclass__ = type
import abc import abc
import os import os
from ansible.module_utils import six
class GPGError(Exception): class GPGError(Exception):
pass pass
@six.add_metaclass(abc.ABCMeta) class GPGRunner(metaclass=abc.ABCMeta):
class GPGRunner(object):
@abc.abstractmethod @abc.abstractmethod
def run_command(self, command, check_rc=True, data=None): def run_command(
self, command: list[str], *, check_rc: bool = True, data: bytes | None = None
) -> tuple[int, str, str]:
""" """
Run ``[gpg] + command`` and return ``(rc, stdout, stderr)``. Run ``[gpg] + command`` and return ``(rc, stdout, stderr)``.
@@ -33,29 +31,24 @@ class GPGRunner(object):
Raises a ``GPGError`` in case of errors. Raises a ``GPGError`` in case of errors.
""" """
pass
def get_fingerprint_from_stdout(stdout): def get_fingerprint_from_stdout(*, stdout: str) -> str:
lines = stdout.splitlines(False) lines = stdout.splitlines(False)
for line in lines: for line in lines:
if line.startswith("fpr:"): if line.startswith("fpr:"):
parts = line.split(":") parts = line.split(":")
if len(parts) <= 9 or not parts[9]: if len(parts) <= 9 or not parts[9]:
raise GPGError( raise GPGError(
'Result line "{line}" does not have fingerprint as 10th component'.format( f'Result line "{line}" does not have fingerprint as 10th component'
line=line
)
) )
return parts[9] return parts[9]
raise GPGError( raise GPGError(f'Cannot extract fingerprint from stdout "{stdout}"')
'Cannot extract fingerprint from stdout "{stdout}"'.format(stdout=stdout)
)
def get_fingerprint_from_file(gpg_runner, path): def get_fingerprint_from_file(*, gpg_runner: GPGRunner, path: str) -> str:
if not os.path.exists(path): if not os.path.exists(path):
raise GPGError("{path} does not exist".format(path=path)) raise GPGError(f"{path} does not exist")
stdout = gpg_runner.run_command( stdout = gpg_runner.run_command(
[ [
"--no-keyring", "--no-keyring",
@@ -67,10 +60,10 @@ def get_fingerprint_from_file(gpg_runner, path):
], ],
check_rc=True, check_rc=True,
)[1] )[1]
return get_fingerprint_from_stdout(stdout) return get_fingerprint_from_stdout(stdout=stdout)
def get_fingerprint_from_bytes(gpg_runner, content): def get_fingerprint_from_bytes(*, gpg_runner: GPGRunner, content: bytes) -> str:
stdout = gpg_runner.run_command( stdout = gpg_runner.run_command(
[ [
"--no-keyring", "--no-keyring",
@@ -83,4 +76,13 @@ def get_fingerprint_from_bytes(gpg_runner, content):
data=content, data=content,
check_rc=True, check_rc=True,
)[1] )[1]
return get_fingerprint_from_stdout(stdout) return get_fingerprint_from_stdout(stdout=stdout)
__all__ = (
"GPGError",
"GPGRunner",
"get_fingerprint_from_stdout",
"get_fingerprint_from_file",
"get_fingerprint_from_bytes",
)

View File

@@ -1,21 +1,23 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2016, Yanis Guenane <yanis+ansible@guenane.org> # Copyright (c) 2016, Yanis Guenane <yanis+ansible@guenane.org>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
__metaclass__ = type
from __future__ import annotations
import errno import errno
import os import os
import tempfile import tempfile
import typing as t
def load_file(path, module=None): if t.TYPE_CHECKING:
from ansible.module_utils.basic import AnsibleModule # pragma: no cover
def load_file(*, path: str | os.PathLike, module: AnsibleModule | None = None) -> bytes:
""" """
Load the file as a bytes string. Load the file as a bytes string.
""" """
@@ -25,10 +27,15 @@ def load_file(path, module=None):
except Exception as exc: except Exception as exc:
if module is None: if module is None:
raise raise
module.fail_json("Error while loading {0} - {1}".format(path, str(exc))) module.fail_json(f"Error while loading {path} - {exc}")
def load_file_if_exists(path, module=None, ignore_errors=False): def load_file_if_exists(
*,
path: str | os.PathLike,
module: AnsibleModule | None = None,
ignore_errors: bool = False,
) -> bytes | None:
""" """
Load the file as a bytes string. If the file does not exist, ``None`` is returned. Load the file as a bytes string. If the file does not exist, ``None`` is returned.
@@ -46,16 +53,22 @@ def load_file_if_exists(path, module=None, ignore_errors=False):
return None return None
if module is None: if module is None:
raise raise
module.fail_json("Error while loading {0} - {1}".format(path, str(exc))) module.fail_json(f"Error while loading {path} - {exc}")
except Exception as exc: except Exception as exc:
if ignore_errors: if ignore_errors:
return None return None
if module is None: if module is None:
raise raise
module.fail_json("Error while loading {0} - {1}".format(path, str(exc))) module.fail_json(f"Error while loading {path} - {exc}")
def write_file(module, content, default_mode=None, path=None): def write_file(
*,
module: AnsibleModule,
content: bytes,
default_mode: str | int | None = None,
path: str | os.PathLike | None = None,
) -> None:
""" """
Writes content into destination file as securely as possible. Writes content into destination file as securely as possible.
Uses file arguments from module. Uses file arguments from module.
@@ -89,9 +102,7 @@ def write_file(module, content, default_mode=None, path=None):
os.remove(tmp_name) os.remove(tmp_name)
except Exception: except Exception:
pass pass
module.fail_json( module.fail_json(msg=f"Error while writing result into temporary file: {e}")
msg="Error while writing result into temporary file: {0}".format(e)
)
# Update destination to wanted permissions # Update destination to wanted permissions
if os.path.exists(file_args["path"]): if os.path.exists(file_args["path"]):
module.set_fs_attributes_if_different(file_args, False) module.set_fs_attributes_if_different(file_args, False)
@@ -107,4 +118,7 @@ def write_file(module, content, default_mode=None, path=None):
os.remove(tmp_name) os.remove(tmp_name)
except Exception: except Exception:
pass pass
module.fail_json(msg="Error while writing result: {0}".format(e)) module.fail_json(msg=f"Error while writing result: {e}")
__all__ = ("load_file", "load_file_if_exists", "write_file")

View File

@@ -1,28 +1,45 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2021, Andrew Pantuso (@ajpantuso) <ajpantuso@gmail.com> # Copyright (c) 2021, Andrew Pantuso (@ajpantuso) <ajpantuso@gmail.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
__metaclass__ = type
import abc import abc
import os import os
import stat import stat
import traceback import traceback
import typing as t
from ansible.module_utils import six from ansible_collections.community.crypto.plugins.module_utils._openssh.utils import (
from ansible.module_utils.common.text.converters import to_native
from ansible_collections.community.crypto.plugins.module_utils.openssh.utils import (
parse_openssh_version, parse_openssh_version,
) )
def restore_on_failure(f): if t.TYPE_CHECKING:
def backup_and_restore(module, path, *args, **kwargs): from ansible.module_utils.basic import AnsibleModule # pragma: no cover
from ansible_collections.community.crypto.plugins.module_utils._openssh.certificate import ( # pragma: no cover
OpensshCertificateTimeParameters,
)
from cryptography.hazmat.primitives.asymmetric.types import ( # pragma: no cover
CertificateIssuerPrivateKeyTypes,
PrivateKeyTypes,
)
Param = t.ParamSpec("Param") # pragma: no cover
def restore_on_failure(
f: t.Callable[t.Concatenate[AnsibleModule, str | os.PathLike, Param], None],
) -> t.Callable[t.Concatenate[AnsibleModule, str | os.PathLike, Param], None]:
def backup_and_restore(
module: AnsibleModule,
path: str | os.PathLike,
*args: Param.args,
**kwargs: Param.kwargs,
) -> None:
backup_file = module.backup_local(path) if os.path.exists(path) else None backup_file = module.backup_local(path) if os.path.exists(path) else None
try: try:
@@ -31,19 +48,38 @@ def restore_on_failure(f):
if backup_file is not None: if backup_file is not None:
module.atomic_move(os.path.abspath(backup_file), os.path.abspath(path)) module.atomic_move(os.path.abspath(backup_file), os.path.abspath(path))
raise raise
else: if backup_file is not None:
module.add_cleanup_file(backup_file) module.add_cleanup_file(backup_file)
return backup_and_restore return backup_and_restore
@restore_on_failure @restore_on_failure
def safe_atomic_move(module, path, destination): def safe_atomic_move(
module: AnsibleModule, path: str | os.PathLike, destination: str | os.PathLike
) -> None:
module.atomic_move(os.path.abspath(path), os.path.abspath(destination)) module.atomic_move(os.path.abspath(path), os.path.abspath(destination))
def _restore_all_on_failure(f): def _restore_all_on_failure(
def backup_and_restore(self, sources_and_destinations, *args, **kwargs): f: t.Callable[
t.Concatenate[
OpensshModule, list[tuple[str | os.PathLike, str | os.PathLike]], Param
],
None,
],
) -> t.Callable[
t.Concatenate[
OpensshModule, list[tuple[str | os.PathLike, str | os.PathLike]], Param
],
None,
]:
def backup_and_restore(
self: OpensshModule,
sources_and_destinations: list[tuple[str | os.PathLike, str | os.PathLike]],
*args: Param.args,
**kwargs: Param.kwargs,
) -> None:
backups = [ backups = [
(d, self.module.backup_local(d)) (d, self.module.backup_local(d))
for s, d in sources_and_destinations for s, d in sources_and_destinations
@@ -58,92 +94,103 @@ def _restore_all_on_failure(f):
os.path.abspath(backup), os.path.abspath(destination) os.path.abspath(backup), os.path.abspath(destination)
) )
raise raise
else: for destination, backup in backups:
for destination, backup in backups: self.module.add_cleanup_file(backup)
self.module.add_cleanup_file(backup)
return backup_and_restore return backup_and_restore
@six.add_metaclass(abc.ABCMeta) _OpensshModule = t.TypeVar("_OpensshModule", bound="OpensshModule")
class OpensshModule(object):
def __init__(self, module):
class OpensshModule(metaclass=abc.ABCMeta):
def __init__(self, *, module: AnsibleModule) -> None:
self.module = module self.module = module
self.changed = False self.changed: bool = False
self.check_mode = self.module.check_mode self.check_mode: bool = self.module.check_mode
def execute(self): def execute(self) -> t.NoReturn:
try: try:
self._execute() self._execute()
except Exception as e: except Exception as e:
self.module.fail_json( self.module.fail_json(
msg="unexpected error occurred: %s" % to_native(e), msg=f"unexpected error occurred: {e}",
exception=traceback.format_exc(), exception=traceback.format_exc(),
) )
self.module.exit_json(**self.result) self.module.exit_json(**self.result)
@abc.abstractmethod @abc.abstractmethod
def _execute(self): def _execute(self) -> None:
pass pass
@property @property
def result(self): def result(self) -> dict[str, t.Any]:
result = self._result result = self._result
result["changed"] = self.changed result["changed"] = self.changed
if self.module._diff: if self.module._diff: # pylint: disable=protected-access
result["diff"] = self.diff result["diff"] = self.diff
return result return result
@property @property
@abc.abstractmethod @abc.abstractmethod
def _result(self): def _result(self) -> dict[str, t.Any]:
pass pass
@property @property
@abc.abstractmethod @abc.abstractmethod
def diff(self): def diff(self) -> dict[str, t.Any]:
pass pass
@staticmethod @staticmethod
def skip_if_check_mode(f): def skip_if_check_mode(
def wrapper(self, *args, **kwargs): f: t.Callable[t.Concatenate[_OpensshModule, Param], None],
) -> t.Callable[t.Concatenate[_OpensshModule, Param], None]:
def wrapper(
self: _OpensshModule, *args: Param.args, **kwargs: Param.kwargs
) -> None:
if not self.check_mode: if not self.check_mode:
f(self, *args, **kwargs) f(self, *args, **kwargs)
return wrapper return wrapper # type: ignore
@staticmethod @staticmethod
def trigger_change(f): def trigger_change(
def wrapper(self, *args, **kwargs): f: t.Callable[t.Concatenate[_OpensshModule, Param], None],
) -> t.Callable[t.Concatenate[_OpensshModule, Param], None]:
def wrapper(
self: _OpensshModule, *args: Param.args, **kwargs: Param.kwargs
) -> None:
f(self, *args, **kwargs) f(self, *args, **kwargs)
self.changed = True self.changed = True
return wrapper return wrapper # type: ignore
def _check_if_base_dir(self, path): def _check_if_base_dir(self, path: str | os.PathLike) -> None:
base_dir = os.path.dirname(path) or "." base_dir = os.path.dirname(path) or "."
if not os.path.isdir(base_dir): if not os.path.isdir(base_dir):
self.module.fail_json( self.module.fail_json(
name=base_dir, name=base_dir,
msg="The directory %s does not exist or the file is not a directory" msg=f"The directory {base_dir} does not exist or the file is not a directory",
% base_dir,
) )
def _get_ssh_version(self): def _get_ssh_version(self) -> str | None:
ssh_bin = self.module.get_bin_path("ssh") ssh_bin = self.module.get_bin_path("ssh")
if not ssh_bin: if not ssh_bin:
return "" return None
return parse_openssh_version( return parse_openssh_version(
self.module.run_command([ssh_bin, "-V", "-q"], check_rc=True)[2].strip() self.module.run_command([ssh_bin, "-V", "-q"], check_rc=True)[2].strip()
) )
@_restore_all_on_failure @_restore_all_on_failure
def _safe_secure_move(self, sources_and_destinations): def _safe_secure_move(
self,
sources_and_destinations: list[tuple[str | os.PathLike, str | os.PathLike]],
) -> None:
"""Moves a list of files from 'source' to 'destination' and restores 'destination' from backup upon failure. """Moves a list of files from 'source' to 'destination' and restores 'destination' from backup upon failure.
If 'destination' does not already exist, then 'source' permissions are preserved to prevent If 'destination' does not already exist, then 'source' permissions are preserved to prevent
exposing protected data ('atomic_move' uses the 'destination' base directory mask for exposing protected data ('atomic_move' uses the 'destination' base directory mask for
@@ -157,7 +204,7 @@ class OpensshModule(object):
else: else:
self.module.preserved_copy(source, destination) self.module.preserved_copy(source, destination)
def _update_permissions(self, path): def _update_permissions(self, path: str | os.PathLike) -> None:
file_args = self.module.load_file_common_arguments(self.module.params) file_args = self.module.load_file_common_arguments(self.module.params)
file_args["path"] = path file_args["path"] = path
@@ -169,26 +216,34 @@ class OpensshModule(object):
self.changed = True self.changed = True
class KeygenCommand(object): if t.TYPE_CHECKING:
def __init__(self, module):
class _RunCommandKwarg(t.TypedDict):
check_rc: t.NotRequired[bool]
environ_update: t.NotRequired[dict[str, str] | None]
class KeygenCommand:
def __init__(self, module: AnsibleModule) -> None:
self._bin_path = module.get_bin_path("ssh-keygen", True) self._bin_path = module.get_bin_path("ssh-keygen", True)
self._run_command = module.run_command self._run_command = module.run_command
def generate_certificate( def generate_certificate(
self, self,
certificate_path, *,
identifier, certificate_path: str,
options, identifier: str,
pkcs11_provider, options: list[str] | None,
principals, pkcs11_provider: str | None,
serial_number, principals: list[str] | None,
signature_algorithm, serial_number: int | None,
signing_key_path, signature_algorithm: str | None,
type, signing_key_path: str,
time_parameters, cert_type: t.Literal["host", "user"] | None,
use_agent, time_parameters: OpensshCertificateTimeParameters,
**kwargs use_agent: bool,
): **kwargs: t.Unpack[_RunCommandKwarg],
) -> tuple[int, str, str]:
args = [self._bin_path, "-s", signing_key_path, "-P", "", "-I", identifier] args = [self._bin_path, "-s", signing_key_path, "-P", "", "-I", identifier]
if options: if options:
@@ -200,7 +255,7 @@ class KeygenCommand(object):
args.extend(["-n", ",".join(principals)]) args.extend(["-n", ",".join(principals)])
if serial_number is not None: if serial_number is not None:
args.extend(["-z", str(serial_number)]) args.extend(["-z", str(serial_number)])
if type == "host": if cert_type == "host":
args.extend(["-h"]) args.extend(["-h"])
if use_agent: if use_agent:
args.extend(["-U"]) args.extend(["-U"])
@@ -212,7 +267,15 @@ class KeygenCommand(object):
return self._run_command(args, **kwargs) return self._run_command(args, **kwargs)
def generate_keypair(self, private_key_path, size, type, comment, **kwargs): def generate_keypair(
self,
*,
private_key_path: str,
size: int,
key_type: str,
comment: str | None,
**kwargs: t.Unpack[_RunCommandKwarg],
) -> tuple[int, str, str]:
args = [ args = [
self._bin_path, self._bin_path,
"-q", "-q",
@@ -221,7 +284,7 @@ class KeygenCommand(object):
"-b", "-b",
str(size), str(size),
"-t", "-t",
type, key_type,
"-f", "-f",
private_key_path, private_key_path,
"-C", "-C",
@@ -233,34 +296,44 @@ class KeygenCommand(object):
return self._run_command(args, data=data, **kwargs) return self._run_command(args, data=data, **kwargs)
def get_certificate_info(self, certificate_path, **kwargs): def get_certificate_info(
self, *, certificate_path: str, **kwargs: t.Unpack[_RunCommandKwarg]
) -> tuple[int, str, str]:
return self._run_command( return self._run_command(
[self._bin_path, "-L", "-f", certificate_path], **kwargs [self._bin_path, "-L", "-f", certificate_path], **kwargs
) )
def get_matching_public_key(self, private_key_path, **kwargs): def get_matching_public_key(
self, *, private_key_path: str, **kwargs: t.Unpack[_RunCommandKwarg]
) -> tuple[int, str, str]:
return self._run_command( return self._run_command(
[self._bin_path, "-P", "", "-y", "-f", private_key_path], **kwargs [self._bin_path, "-P", "", "-y", "-f", private_key_path], **kwargs
) )
def get_private_key(self, private_key_path, **kwargs): def get_private_key(
self, *, private_key_path: str, **kwargs: t.Unpack[_RunCommandKwarg]
) -> tuple[int, str, str]:
return self._run_command( return self._run_command(
[self._bin_path, "-l", "-f", private_key_path], **kwargs [self._bin_path, "-l", "-f", private_key_path], **kwargs
) )
def update_comment( def update_comment(
self, private_key_path, comment, force_new_format=True, **kwargs self,
): *,
private_key_path: str,
comment: str,
force_new_format: bool = True,
**kwargs: t.Unpack[_RunCommandKwarg],
) -> tuple[int, str, str]:
if os.path.exists(private_key_path) and not os.access( if os.path.exists(private_key_path) and not os.access(
private_key_path, os.W_OK private_key_path, os.W_OK
): ):
try: try:
os.chmod(private_key_path, stat.S_IWUSR + stat.S_IRUSR) os.chmod(private_key_path, stat.S_IWUSR + stat.S_IRUSR)
except (IOError, OSError) as e: except (IOError, OSError) as e:
raise e( raise ValueError(
"The private key at %s is not writeable preventing a comment update" f"The private key at {private_key_path} is not writeable preventing a comment update ({e})"
% private_key_path ) from e
)
command = [self._bin_path, "-q"] command = [self._bin_path, "-q"]
if force_new_format: if force_new_format:
@@ -269,31 +342,36 @@ class KeygenCommand(object):
return self._run_command(command, **kwargs) return self._run_command(command, **kwargs)
class PrivateKey(object): _PrivateKey = t.TypeVar("_PrivateKey", bound="PrivateKey")
def __init__(self, size, key_type, fingerprint, format=""):
class PrivateKey:
def __init__(
self, *, size: int, key_type: str, fingerprint: str, key_format: str = ""
) -> None:
self._size = size self._size = size
self._type = key_type self._type = key_type
self._fingerprint = fingerprint self._fingerprint = fingerprint
self._format = format self._format = key_format
@property @property
def size(self): def size(self) -> int:
return self._size return self._size
@property @property
def type(self): def type(self) -> str:
return self._type return self._type
@property @property
def fingerprint(self): def fingerprint(self) -> str:
return self._fingerprint return self._fingerprint
@property @property
def format(self): def format(self) -> str:
return self._format return self._format
@classmethod @classmethod
def from_string(cls, string): def from_string(cls: t.Type[_PrivateKey], string: str) -> _PrivateKey:
properties = string.split() properties = string.split()
return cls( return cls(
@@ -302,7 +380,7 @@ class PrivateKey(object):
fingerprint=properties[1], fingerprint=properties[1],
) )
def to_dict(self): def to_dict(self) -> dict[str, t.Any]:
return { return {
"size": self._size, "size": self._size,
"type": self._type, "type": self._type,
@@ -311,13 +389,16 @@ class PrivateKey(object):
} }
class PublicKey(object): _PublicKey = t.TypeVar("_PublicKey", bound="PublicKey")
def __init__(self, type_string, data, comment):
class PublicKey:
def __init__(self, *, type_string: str, data: str, comment: str | None) -> None:
self._type_string = type_string self._type_string = type_string
self._data = data self._data = data
self._comment = comment self._comment = comment
def __eq__(self, other): def __eq__(self, other: object) -> bool:
if not isinstance(other, type(self)): if not isinstance(other, type(self)):
return NotImplemented return NotImplemented
@@ -333,30 +414,30 @@ class PublicKey(object):
] ]
) )
def __ne__(self, other): def __ne__(self, other: object) -> bool:
return not self == other return not self == other
def __str__(self): def __str__(self) -> str:
return "%s %s" % (self._type_string, self._data) return f"{self._type_string} {self._data}"
@property @property
def comment(self): def comment(self) -> str | None:
return self._comment return self._comment
@comment.setter @comment.setter
def comment(self, value): def comment(self, value: str | None) -> None:
self._comment = value self._comment = value
@property @property
def data(self): def data(self) -> str:
return self._data return self._data
@property @property
def type_string(self): def type_string(self) -> str:
return self._type_string return self._type_string
@classmethod @classmethod
def from_string(cls, string): def from_string(cls: t.Type[_PublicKey], string: str) -> _PublicKey:
properties = string.strip("\n").split(" ", 2) properties = string.strip("\n").split(" ", 2)
return cls( return cls(
@@ -366,12 +447,9 @@ class PublicKey(object):
) )
@classmethod @classmethod
def load(cls, path): def load(cls: t.Type[_PublicKey], path: str | os.PathLike) -> _PublicKey | None:
try: with open(path, "r", encoding="utf-8") as f:
with open(path, "r") as f: properties = f.read().strip(" \n").split(" ", 2)
properties = f.read().strip(" \n").split(" ", 2)
except (IOError, OSError):
raise
if len(properties) < 2: if len(properties) < 2:
return None return None
@@ -382,22 +460,36 @@ class PublicKey(object):
comment="" if len(properties) <= 2 else properties[2], comment="" if len(properties) <= 2 else properties[2],
) )
def to_dict(self): def to_dict(self) -> dict[str, t.Any]:
return { return {
"comment": self._comment, "comment": self._comment,
"public_key": self._data, "public_key": self._data,
} }
def parse_private_key_format(path): def parse_private_key_format(
with open(path, "r") as file: *,
path: str | os.PathLike,
) -> t.Literal["SSH", "PKCS8", "PKCS1", ""]:
with open(path, "r", encoding="utf-8") as file:
header = file.readline().strip() header = file.readline().strip()
if header == "-----BEGIN OPENSSH PRIVATE KEY-----": if header == "-----BEGIN OPENSSH PRIVATE KEY-----":
return "SSH" return "SSH"
elif header == "-----BEGIN PRIVATE KEY-----": if header == "-----BEGIN PRIVATE KEY-----":
return "PKCS8" return "PKCS8"
elif header == "-----BEGIN RSA PRIVATE KEY-----": if header == "-----BEGIN RSA PRIVATE KEY-----":
return "PKCS1" return "PKCS1"
return "" return ""
__all__ = (
"restore_on_failure",
"safe_atomic_move",
"OpensshModule",
"KeygenCommand",
"PrivateKey",
"PublicKey",
"parse_private_key_format",
)

View File

@@ -1,30 +1,31 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2018, David Kainz <dkainz@mgit.at> <dave.jokain@gmx.at> # Copyright (c) 2018, David Kainz <dkainz@mgit.at> <dave.jokain@gmx.at>
# Copyright (c) 2021, Andrew Pantuso (@ajpantuso) <ajpantuso@gmail.com> # Copyright (c) 2021, Andrew Pantuso (@ajpantuso) <ajpantuso@gmail.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
__metaclass__ = type
import abc import abc
import os import os
import typing as t
from ansible.module_utils import six
from ansible.module_utils.basic import missing_required_lib from ansible.module_utils.basic import missing_required_lib
from ansible.module_utils.common.text.converters import to_bytes, to_native, to_text from ansible.module_utils.common.text.converters import to_bytes, to_text
from ansible_collections.community.crypto.plugins.module_utils.openssh.backends.common import ( from ansible_collections.community.crypto.plugins.module_utils._cryptography_dep import (
COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION,
)
from ansible_collections.community.crypto.plugins.module_utils._openssh.backends.common import (
KeygenCommand, KeygenCommand,
OpensshModule, OpensshModule,
PrivateKey, PrivateKey,
PublicKey, PublicKey,
parse_private_key_format, parse_private_key_format,
) )
from ansible_collections.community.crypto.plugins.module_utils.openssh.cryptography import ( from ansible_collections.community.crypto.plugins.module_utils._openssh.cryptography import (
HAS_OPENSSH_PRIVATE_FORMAT, CRYPTOGRAPHY_VERSION,
HAS_OPENSSH_SUPPORT, HAS_OPENSSH_SUPPORT,
InvalidCommentError, InvalidCommentError,
InvalidPassphraseError, InvalidPassphraseError,
@@ -32,42 +33,52 @@ from ansible_collections.community.crypto.plugins.module_utils.openssh.cryptogra
OpenSSHError, OpenSSHError,
OpensshKeypair, OpensshKeypair,
) )
from ansible_collections.community.crypto.plugins.module_utils.openssh.utils import ( from ansible_collections.community.crypto.plugins.module_utils._openssh.utils import (
any_in, any_in,
file_mode, file_mode,
secure_write, secure_write,
) )
from ansible_collections.community.crypto.plugins.module_utils.version import ( from ansible_collections.community.crypto.plugins.module_utils._version import (
LooseVersion, LooseVersion,
) )
@six.add_metaclass(abc.ABCMeta) if t.TYPE_CHECKING:
class KeypairBackend(OpensshModule): from ansible.module_utils.basic import AnsibleModule # pragma: no cover
from cryptography.hazmat.primitives.asymmetric.types import ( # pragma: no cover
CertificateIssuerPrivateKeyTypes,
PrivateKeyTypes,
)
def __init__(self, module):
super(KeypairBackend, self).__init__(module)
self.comment = self.module.params["comment"] class KeypairBackend(OpensshModule, metaclass=abc.ABCMeta):
self.private_key_path = self.module.params["path"] def __init__(self, *, module: AnsibleModule) -> None:
super().__init__(module=module)
self.comment: str | None = self.module.params["comment"]
self.private_key_path: str = self.module.params["path"]
self.public_key_path = self.private_key_path + ".pub" self.public_key_path = self.private_key_path + ".pub"
self.regenerate = ( self.regenerate: t.Literal[
"never", "fail", "partial_idempotence", "full_idempotence", "always"
] = (
self.module.params["regenerate"] self.module.params["regenerate"]
if not self.module.params["force"] if not self.module.params["force"]
else "always" else "always"
) )
self.state = self.module.params["state"] self.state: t.Literal["present", "absent"] = self.module.params["state"]
self.type = self.module.params["type"] self.type: t.Literal["rsa", "dsa", "rsa1", "ecdsa", "ed25519"] = (
self.module.params["type"]
)
self.size = self._get_size(self.module.params["size"]) self.size: int = self._get_size(self.module.params["size"])
self._validate_path() self._validate_path()
self.original_private_key = None self.original_private_key: PrivateKey | None = None
self.original_public_key = None self.original_public_key: PublicKey | None = None
self.private_key = None self.private_key: PrivateKey | None = None
self.public_key = None self.public_key: PublicKey | None = None
def _get_size(self, size): def _get_size(self, size: int | None) -> int:
if self.type in ("rsa", "rsa1"): if self.type in ("rsa", "rsa1"):
result = 4096 if size is None else size result = 4096 if size is None else size
if result < 1024: if result < 1024:
@@ -95,21 +106,20 @@ class KeypairBackend(OpensshModule):
result = 256 result = 256
else: else:
return self.module.fail_json( return self.module.fail_json(
msg="%s is not a valid value for key type" % self.type msg=f"{self.type} is not a valid value for key type"
) )
return result return result
def _validate_path(self): def _validate_path(self) -> None:
self._check_if_base_dir(self.private_key_path) self._check_if_base_dir(self.private_key_path)
if os.path.isdir(self.private_key_path): if os.path.isdir(self.private_key_path):
self.module.fail_json( self.module.fail_json(
msg="%s is a directory. Please specify a path to a file." msg=f"{self.private_key_path} is a directory. Please specify a path to a file."
% self.private_key_path
) )
def _execute(self): def _execute(self) -> None:
self.original_private_key = self._load_private_key() self.original_private_key = self._load_private_key()
self.original_public_key = self._load_public_key() self.original_public_key = self._load_public_key()
@@ -130,7 +140,7 @@ class KeypairBackend(OpensshModule):
if self._should_remove(): if self._should_remove():
self._remove() self._remove()
def _load_private_key(self): def _load_private_key(self) -> PrivateKey | None:
result = None result = None
if self._private_key_exists(): if self._private_key_exists():
try: try:
@@ -140,14 +150,14 @@ class KeypairBackend(OpensshModule):
return result return result
def _private_key_exists(self): def _private_key_exists(self) -> bool:
return os.path.exists(self.private_key_path) return os.path.exists(self.private_key_path)
@abc.abstractmethod @abc.abstractmethod
def _get_private_key(self): def _get_private_key(self) -> PrivateKey:
pass pass
def _load_public_key(self): def _load_public_key(self) -> PublicKey | None:
result = None result = None
if self._public_key_exists(): if self._public_key_exists():
try: try:
@@ -156,10 +166,10 @@ class KeypairBackend(OpensshModule):
pass pass
return result return result
def _public_key_exists(self): def _public_key_exists(self) -> bool:
return os.path.exists(self.public_key_path) return os.path.exists(self.public_key_path)
def _validate_key_load(self): def _validate_key_load(self) -> None:
if ( if (
self._private_key_exists() self._private_key_exists()
and self.regenerate in ("never", "fail", "partial_idempotence") and self.regenerate in ("never", "fail", "partial_idempotence")
@@ -172,15 +182,15 @@ class KeypairBackend(OpensshModule):
) )
@abc.abstractmethod @abc.abstractmethod
def _private_key_readable(self): def _private_key_readable(self) -> bool:
pass pass
def _should_generate(self): def _should_generate(self) -> bool:
if self.original_private_key is None: if self.original_private_key is None:
return True return True
elif self.regenerate == "never": if self.regenerate == "never":
return False return False
elif self.regenerate == "fail": if self.regenerate == "fail":
if not self._private_key_valid(): if not self._private_key_valid():
self.module.fail_json( self.module.fail_json(
msg="Key has wrong type and/or size. Will not proceed. " msg="Key has wrong type and/or size. Will not proceed. "
@@ -188,12 +198,11 @@ class KeypairBackend(OpensshModule):
+ "`partial_idempotence`, `full_idempotence` or `always`, or with `force=true`." + "`partial_idempotence`, `full_idempotence` or `always`, or with `force=true`."
) )
return False return False
elif self.regenerate in ("partial_idempotence", "full_idempotence"): if self.regenerate in ("partial_idempotence", "full_idempotence"):
return not self._private_key_valid() return not self._private_key_valid()
else: return True
return True
def _private_key_valid(self): def _private_key_valid(self) -> bool:
if self.original_private_key is None: if self.original_private_key is None:
return False return False
@@ -201,17 +210,17 @@ class KeypairBackend(OpensshModule):
[ [
self.size == self.original_private_key.size, self.size == self.original_private_key.size,
self.type == self.original_private_key.type, self.type == self.original_private_key.type,
self._private_key_valid_backend(), self._private_key_valid_backend(self.original_private_key),
] ]
) )
@abc.abstractmethod @abc.abstractmethod
def _private_key_valid_backend(self): def _private_key_valid_backend(self, original_private_key: PrivateKey) -> bool:
pass pass
@OpensshModule.trigger_change @OpensshModule.trigger_change
@OpensshModule.skip_if_check_mode @OpensshModule.skip_if_check_mode
def _generate(self): def _generate(self) -> None:
temp_private_key, temp_public_key = self._generate_temp_keypair() temp_private_key, temp_public_key = self._generate_temp_keypair()
try: try:
@@ -222,9 +231,9 @@ class KeypairBackend(OpensshModule):
] ]
) )
except OSError as e: except OSError as e:
self.module.fail_json(msg=to_native(e)) self.module.fail_json(msg=str(e))
def _generate_temp_keypair(self): def _generate_temp_keypair(self) -> tuple[str, str]:
temp_private_key = os.path.join( temp_private_key = os.path.join(
self.module.tmpdir, os.path.basename(self.private_key_path) self.module.tmpdir, os.path.basename(self.private_key_path)
) )
@@ -233,7 +242,7 @@ class KeypairBackend(OpensshModule):
try: try:
self._generate_keypair(temp_private_key) self._generate_keypair(temp_private_key)
except (IOError, OSError) as e: except (IOError, OSError) as e:
self.module.fail_json(msg=to_native(e)) self.module.fail_json(msg=str(e))
for f in (temp_private_key, temp_public_key): for f in (temp_private_key, temp_public_key):
self.module.add_cleanup_file(f) self.module.add_cleanup_file(f)
@@ -241,25 +250,26 @@ class KeypairBackend(OpensshModule):
return temp_private_key, temp_public_key return temp_private_key, temp_public_key
@abc.abstractmethod @abc.abstractmethod
def _generate_keypair(self, private_key_path): def _generate_keypair(self, private_key_path: str) -> None:
pass pass
def _public_key_valid(self): def _public_key_valid(self) -> bool:
if self.original_public_key is None: if self.original_public_key is None:
return False return False
valid_public_key = self._get_public_key() valid_public_key = self._get_public_key()
valid_public_key.comment = self.comment if valid_public_key:
valid_public_key.comment = self.comment
return self.original_public_key == valid_public_key return self.original_public_key == valid_public_key
@abc.abstractmethod @abc.abstractmethod
def _get_public_key(self): def _get_public_key(self) -> PublicKey | t.Literal[""]:
pass pass
@OpensshModule.trigger_change @OpensshModule.trigger_change
@OpensshModule.skip_if_check_mode @OpensshModule.skip_if_check_mode
def _restore_public_key(self): def _restore_public_key(self) -> None:
try: try:
temp_public_key = self._create_temp_public_key( temp_public_key = self._create_temp_public_key(
str(self._get_public_key()) + "\n" str(self._get_public_key()) + "\n"
@@ -274,8 +284,8 @@ class KeypairBackend(OpensshModule):
if self.comment: if self.comment:
self._update_comment() self._update_comment()
def _create_temp_public_key(self, content): def _create_temp_public_key(self, content: str | bytes) -> str:
temp_public_key = os.path.join( temp_public_key: str = os.path.join(
self.module.tmpdir, os.path.basename(self.public_key_path) self.module.tmpdir, os.path.basename(self.public_key_path)
) )
@@ -284,36 +294,36 @@ class KeypairBackend(OpensshModule):
try: try:
secure_write( secure_write(
temp_public_key, path=temp_public_key,
existing_permissions or default_permissions, mode=existing_permissions or default_permissions,
to_bytes(content), content=to_bytes(content),
) )
except (IOError, OSError) as e: except (IOError, OSError) as e:
self.module.fail_json(msg=to_native(e)) self.module.fail_json(msg=str(e))
self.module.add_cleanup_file(temp_public_key) self.module.add_cleanup_file(temp_public_key)
return temp_public_key return temp_public_key
@abc.abstractmethod @abc.abstractmethod
def _update_comment(self): def _update_comment(self) -> None:
pass pass
def _should_remove(self): def _should_remove(self) -> bool:
return self._private_key_exists() or self._public_key_exists() return self._private_key_exists() or self._public_key_exists()
@OpensshModule.trigger_change @OpensshModule.trigger_change
@OpensshModule.skip_if_check_mode @OpensshModule.skip_if_check_mode
def _remove(self): def _remove(self) -> None:
try: try:
if self._private_key_exists(): if self._private_key_exists():
os.remove(self.private_key_path) os.remove(self.private_key_path)
if self._public_key_exists(): if self._public_key_exists():
os.remove(self.public_key_path) os.remove(self.public_key_path)
except (IOError, OSError) as e: except (IOError, OSError) as e:
self.module.fail_json(msg=to_native(e)) self.module.fail_json(msg=str(e))
@property @property
def _result(self): def _result(self) -> dict[str, t.Any]:
private_key = self.private_key or self.original_private_key private_key = self.private_key or self.original_private_key
public_key = self.public_key or self.original_public_key public_key = self.public_key or self.original_public_key
@@ -327,7 +337,7 @@ class KeypairBackend(OpensshModule):
} }
@property @property
def diff(self): def diff(self) -> dict[str, t.Any]:
before = ( before = (
self.original_private_key.to_dict() if self.original_private_key else {} self.original_private_key.to_dict() if self.original_private_key else {}
) )
@@ -345,39 +355,42 @@ class KeypairBackend(OpensshModule):
class KeypairBackendOpensshBin(KeypairBackend): class KeypairBackendOpensshBin(KeypairBackend):
def __init__(self, module): def __init__(self, *, module: AnsibleModule) -> None:
super(KeypairBackendOpensshBin, self).__init__(module) super().__init__(module=module)
if self.module.params["private_key_format"] != "auto": if self.module.params["private_key_format"] != "auto":
self.module.fail_json( self.module.fail_json(
msg="'auto' is the only valid option for " msg="'auto' is the only valid option for 'private_key_format' when 'backend' is not 'cryptography'"
+ "'private_key_format' when 'backend' is not 'cryptography'"
) )
self.ssh_keygen = KeygenCommand(self.module) self.ssh_keygen = KeygenCommand(self.module)
def _generate_keypair(self, private_key_path): def _generate_keypair(self, private_key_path: str) -> None:
self.ssh_keygen.generate_keypair( self.ssh_keygen.generate_keypair(
private_key_path, self.size, self.type, self.comment, check_rc=True private_key_path=private_key_path,
size=self.size,
key_type=self.type,
comment=self.comment,
check_rc=True,
) )
def _get_private_key(self): def _get_private_key(self) -> PrivateKey:
rc, private_key_content, err = self.ssh_keygen.get_private_key( rc, private_key_content, err = self.ssh_keygen.get_private_key(
self.private_key_path, check_rc=False private_key_path=self.private_key_path, check_rc=False
) )
if rc != 0: if rc != 0:
raise ValueError(err) raise ValueError(err)
return PrivateKey.from_string(private_key_content) return PrivateKey.from_string(private_key_content)
def _get_public_key(self): def _get_public_key(self) -> PublicKey | t.Literal[""]:
public_key_content = self.ssh_keygen.get_matching_public_key( public_key_content = self.ssh_keygen.get_matching_public_key(
self.private_key_path, check_rc=True private_key_path=self.private_key_path, check_rc=True
)[1] )[1]
return PublicKey.from_string(public_key_content) return PublicKey.from_string(public_key_content)
def _private_key_readable(self): def _private_key_readable(self) -> bool:
rc, stdout, stderr = self.ssh_keygen.get_matching_public_key( rc, _stdout, stderr = self.ssh_keygen.get_matching_public_key(
self.private_key_path, check_rc=False private_key_path=self.private_key_path, check_rc=False
) )
return not ( return not (
rc == 255 rc == 255
@@ -389,28 +402,29 @@ class KeypairBackendOpensshBin(KeypairBackend):
) )
) )
def _update_comment(self): def _update_comment(self) -> None:
assert self.comment is not None
try: try:
ssh_version = self._get_ssh_version() or "7.8" ssh_version = self._get_ssh_version() or "7.8"
force_new_format = ( force_new_format = (
LooseVersion("6.5") <= LooseVersion(ssh_version) < LooseVersion("7.8") LooseVersion("6.5") <= LooseVersion(ssh_version) < LooseVersion("7.8")
) )
self.ssh_keygen.update_comment( self.ssh_keygen.update_comment(
self.private_key_path, private_key_path=self.private_key_path,
self.comment, comment=self.comment or "",
force_new_format=force_new_format, force_new_format=force_new_format,
check_rc=True, check_rc=True,
) )
except (IOError, OSError) as e: except (IOError, OSError) as e:
self.module.fail_json(msg=to_native(e)) self.module.fail_json(msg=str(e))
def _private_key_valid_backend(self): def _private_key_valid_backend(self, original_private_key: PrivateKey) -> bool:
return True return True
class KeypairBackendCryptography(KeypairBackend): class KeypairBackendCryptography(KeypairBackend):
def __init__(self, module): def __init__(self, *, module: AnsibleModule) -> None:
super(KeypairBackendCryptography, self).__init__(module) super().__init__(module=module)
if self.type == "rsa1": if self.type == "rsa1":
self.module.fail_json( self.module.fail_json(
@@ -422,12 +436,15 @@ class KeypairBackendCryptography(KeypairBackend):
if module.params["passphrase"] if module.params["passphrase"]
else None else None
) )
self.private_key_format = self._get_key_format( key_format: t.Literal["auto", "pkcs1", "pkcs8", "ssh"] = module.params[
module.params["private_key_format"] "private_key_format"
) ]
self.private_key_format = self._get_key_format(key_format)
def _get_key_format(self, key_format): def _get_key_format(
result = "SSH" self, key_format: t.Literal["auto", "pkcs1", "pkcs8", "ssh"]
) -> t.Literal["SSH", "PKCS1", "PKCS8"]:
result: t.Literal["SSH", "PKCS1", "PKCS8"] = "SSH"
if key_format == "auto": if key_format == "auto":
# Default to OpenSSH 7.8 compatibility when OpenSSH is not installed # Default to OpenSSH 7.8 compatibility when OpenSSH is not installed
@@ -440,21 +457,13 @@ class KeypairBackendCryptography(KeypairBackend):
# OpenSSH made SSH formatted private keys available in version 6.5, # OpenSSH made SSH formatted private keys available in version 6.5,
# but still defaulted to PKCS1 format with the exception of ed25519 keys # but still defaulted to PKCS1 format with the exception of ed25519 keys
result = "PKCS1" result = "PKCS1"
if result == "SSH" and not HAS_OPENSSH_PRIVATE_FORMAT:
self.module.fail_json(
msg=missing_required_lib(
"cryptography >= 3.0",
reason="to load/dump private keys in the default OpenSSH format for OpenSSH >= 7.8 "
+ "or for ed25519 keys",
)
)
else: else:
result = key_format.upper() result = key_format.upper() # type: ignore
return result return result
def _generate_keypair(self, private_key_path): def _generate_keypair(self, private_key_path: str) -> None:
assert self.type != "rsa1"
keypair = OpensshKeypair.generate( keypair = OpensshKeypair.generate(
keytype=self.type, keytype=self.type,
size=self.size, size=self.size,
@@ -463,14 +472,14 @@ class KeypairBackendCryptography(KeypairBackend):
) )
encoded_private_key = OpensshKeypair.encode_openssh_privatekey( encoded_private_key = OpensshKeypair.encode_openssh_privatekey(
keypair.asymmetric_keypair, self.private_key_format asym_keypair=keypair.asymmetric_keypair, key_format=self.private_key_format
) )
secure_write(private_key_path, 0o600, encoded_private_key) secure_write(path=private_key_path, mode=0o600, content=encoded_private_key)
public_key_path = private_key_path + ".pub" public_key_path = private_key_path + ".pub"
secure_write(public_key_path, 0o644, keypair.public_key) secure_write(path=public_key_path, mode=0o644, content=keypair.public_key)
def _get_private_key(self): def _get_private_key(self) -> PrivateKey:
keypair = OpensshKeypair.load( keypair = OpensshKeypair.load(
path=self.private_key_path, passphrase=self.passphrase, no_public_key=True path=self.private_key_path, passphrase=self.passphrase, no_public_key=True
) )
@@ -479,10 +488,10 @@ class KeypairBackendCryptography(KeypairBackend):
size=keypair.size, size=keypair.size,
key_type=keypair.key_type, key_type=keypair.key_type,
fingerprint=keypair.fingerprint, fingerprint=keypair.fingerprint,
format=parse_private_key_format(self.private_key_path), key_format=parse_private_key_format(path=self.private_key_path),
) )
def _get_public_key(self): def _get_public_key(self) -> PublicKey | t.Literal[""]:
try: try:
keypair = OpensshKeypair.load( keypair = OpensshKeypair.load(
path=self.private_key_path, path=self.private_key_path,
@@ -495,7 +504,7 @@ class KeypairBackendCryptography(KeypairBackend):
return PublicKey.from_string(to_text(keypair.public_key)) return PublicKey.from_string(to_text(keypair.public_key))
def _private_key_readable(self): def _private_key_readable(self) -> bool:
try: try:
OpensshKeypair.load( OpensshKeypair.load(
path=self.private_key_path, path=self.private_key_path,
@@ -512,39 +521,43 @@ class KeypairBackendCryptography(KeypairBackend):
OpensshKeypair.load( OpensshKeypair.load(
path=self.private_key_path, passphrase=None, no_public_key=True path=self.private_key_path, passphrase=None, no_public_key=True
) )
return False
except (InvalidPrivateKeyFileError, InvalidPassphraseError): except (InvalidPrivateKeyFileError, InvalidPassphraseError):
return True return True
else:
return False
return True return True
def _update_comment(self): def _update_comment(self) -> None:
assert self.comment is not None
keypair = OpensshKeypair.load( keypair = OpensshKeypair.load(
path=self.private_key_path, passphrase=self.passphrase, no_public_key=True path=self.private_key_path, passphrase=self.passphrase, no_public_key=True
) )
try: try:
keypair.comment = self.comment keypair.comment = self.comment
except InvalidCommentError as e: except InvalidCommentError as e:
self.module.fail_json(msg=to_native(e)) self.module.fail_json(msg=str(e))
try: try:
temp_public_key = self._create_temp_public_key(keypair.public_key + b"\n") temp_public_key = self._create_temp_public_key(keypair.public_key + b"\n")
self._safe_secure_move([(temp_public_key, self.public_key_path)]) self._safe_secure_move([(temp_public_key, self.public_key_path)])
except (IOError, OSError) as e: except (IOError, OSError) as e:
self.module.fail_json(msg=to_native(e)) self.module.fail_json(msg=str(e))
def _private_key_valid_backend(self): def _private_key_valid_backend(self, original_private_key: PrivateKey) -> bool:
# avoids breaking behavior and prevents # avoids breaking behavior and prevents
# automatic conversions with OpenSSH upgrades # automatic conversions with OpenSSH upgrades
if self.module.params["private_key_format"] == "auto": if self.module.params["private_key_format"] == "auto":
return True return True
return self.private_key_format == self.original_private_key.format return self.private_key_format == original_private_key.format
def select_backend(module, backend): def select_backend(
can_use_cryptography = HAS_OPENSSH_SUPPORT *, module: AnsibleModule, backend: t.Literal["auto", "opensshbin", "cryptography"]
) -> KeypairBackend:
can_use_cryptography = HAS_OPENSSH_SUPPORT and LooseVersion(
CRYPTOGRAPHY_VERSION
) >= LooseVersion(COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION)
can_use_opensshbin = bool(module.get_bin_path("ssh-keygen")) can_use_opensshbin = bool(module.get_bin_path("ssh-keygen"))
if backend == "auto": if backend == "auto":
@@ -554,17 +567,24 @@ def select_backend(module, backend):
backend = "cryptography" backend = "cryptography"
else: else:
module.fail_json( module.fail_json(
msg="Cannot find either the OpenSSH binary in the PATH " msg=(
+ "or cryptography >= 2.6 installed on this system" f"Cannot find either the OpenSSH binary in the PATH or cryptography >= {COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION} installed on this system"
)
) )
if backend == "opensshbin": if backend == "opensshbin":
if not can_use_opensshbin: if not can_use_opensshbin:
module.fail_json(msg="Cannot find the OpenSSH binary in the PATH") module.fail_json(msg="Cannot find the OpenSSH binary in the PATH")
return backend, KeypairBackendOpensshBin(module) return KeypairBackendOpensshBin(module=module)
elif backend == "cryptography": if backend == "cryptography":
if not can_use_cryptography: if not can_use_cryptography:
module.fail_json(msg=missing_required_lib("cryptography >= 2.6")) module.fail_json(
return backend, KeypairBackendCryptography(module) msg=missing_required_lib(
else: f"cryptography >= {COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION}"
raise ValueError("Unsupported value for backend: {0}".format(backend)) )
)
return KeypairBackendCryptography(module=module)
raise ValueError(f"Unsupported value for backend: {backend}")
__all__ = ("KeypairBackend", "select_backend")

View File

@@ -0,0 +1,857 @@
# Copyright (c) 2021, Andrew Pantuso (@ajpantuso) <ajpantuso@gmail.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import abc
import binascii
import datetime as _datetime
import os
import typing as t
from base64 import b64encode
from datetime import datetime
from hashlib import sha256
from ansible.module_utils.common.text.converters import to_text
from ansible_collections.community.crypto.plugins.module_utils._openssh.utils import (
OpensshParser,
_OpensshWriter,
)
from ansible_collections.community.crypto.plugins.module_utils._time import UTC as _UTC
from ansible_collections.community.crypto.plugins.module_utils._time import (
add_or_remove_timezone as _add_or_remove_timezone,
)
from ansible_collections.community.crypto.plugins.module_utils._time import (
convert_relative_to_datetime,
)
if t.TYPE_CHECKING:
from ansible_collections.community.crypto.plugins.module_utils._openssh.cryptography import ( # pragma: no cover
KeyType,
)
DateFormat = t.Literal["human_readable", "openssh", "timestamp"] # pragma: no cover
DateFormatStr = t.Literal["human_readable", "openssh"] # pragma: no cover
DateFormatInt = t.Literal["timestamp"] # pragma: no cover
else:
KeyType = None # pylint: disable=invalid-name
# Protocol References
# -------------------
# https://datatracker.ietf.org/doc/html/rfc4251
# https://datatracker.ietf.org/doc/html/rfc4253
# https://datatracker.ietf.org/doc/html/rfc5656
# https://datatracker.ietf.org/doc/html/rfc8032
# https://cvsweb.openbsd.org/src/usr.bin/ssh/PROTOCOL.certkeys?annotate=HEAD
#
# Inspired by:
# ------------
# https://github.com/pyca/cryptography/blob/main/src/cryptography/hazmat/primitives/serialization/ssh.py
# https://github.com/paramiko/paramiko/blob/master/paramiko/message.py
# See https://cvsweb.openbsd.org/src/usr.bin/ssh/PROTOCOL.certkeys?annotate=HEAD
_USER_TYPE = 1
_HOST_TYPE = 2
_SSH_TYPE_STRINGS: dict[KeyType | str, bytes] = {
"rsa": b"ssh-rsa",
"dsa": b"ssh-dss",
"ecdsa-nistp256": b"ecdsa-sha2-nistp256",
"ecdsa-nistp384": b"ecdsa-sha2-nistp384",
"ecdsa-nistp521": b"ecdsa-sha2-nistp521",
"ed25519": b"ssh-ed25519",
}
_CERT_SUFFIX_V01 = b"-cert-v01@openssh.com"
# See https://datatracker.ietf.org/doc/html/rfc5656#section-6.1
_ECDSA_CURVE_IDENTIFIERS = {
"ecdsa-nistp256": b"nistp256",
"ecdsa-nistp384": b"nistp384",
"ecdsa-nistp521": b"nistp521",
}
_ECDSA_CURVE_IDENTIFIERS_LOOKUP = {
b"nistp256": "ecdsa-nistp256",
b"nistp384": "ecdsa-nistp384",
b"nistp521": "ecdsa-nistp521",
}
_ALWAYS = _add_or_remove_timezone(datetime(1970, 1, 1), with_timezone=True)
_FOREVER = datetime(9999, 12, 31, 23, 59, 59, 999999, _UTC)
_CRITICAL_OPTIONS = (
"force-command",
"source-address",
"verify-required",
)
_DIRECTIVES = (
"clear",
"no-x11-forwarding",
"no-agent-forwarding",
"no-port-forwarding",
"no-pty",
"no-user-rc",
)
_EXTENSIONS = (
"permit-x11-forwarding",
"permit-agent-forwarding",
"permit-port-forwarding",
"permit-pty",
"permit-user-rc",
)
class OpensshCertificateTimeParameters:
def __init__(
self, *, valid_from: str | bytes | int, valid_to: str | bytes | int
) -> None:
self._valid_from = self.to_datetime(valid_from)
self._valid_to = self.to_datetime(valid_to)
if self._valid_from > self._valid_to:
raise ValueError(
f"Valid from: {valid_from!r} must not be greater than Valid to: {valid_to!r}"
)
def __eq__(self, other: object) -> bool:
if not isinstance(other, type(self)):
return NotImplemented
return (
self._valid_from == other._valid_from and self._valid_to == other._valid_to
)
def __ne__(self, other: object) -> bool:
return not self == other
@property
def validity_string(self) -> str:
if not (self._valid_from == _ALWAYS and self._valid_to == _FOREVER):
return f"{self.valid_from(date_format='openssh')}:{self.valid_to(date_format='openssh')}"
return ""
@t.overload
def valid_from(self, date_format: DateFormatStr) -> str: ...
@t.overload
def valid_from(self, date_format: DateFormatInt) -> int: ...
@t.overload
def valid_from(self, date_format: DateFormat) -> str | int: ...
def valid_from(self, date_format: DateFormat) -> str | int:
return self.format_datetime(self._valid_from, date_format=date_format)
@t.overload
def valid_to(self, date_format: DateFormatStr) -> str: ...
@t.overload
def valid_to(self, date_format: DateFormatInt) -> int: ...
@t.overload
def valid_to(self, date_format: DateFormat) -> str | int: ...
def valid_to(self, date_format: DateFormat) -> str | int:
return self.format_datetime(self._valid_to, date_format=date_format)
def within_range(self, valid_at: str | bytes | int | None) -> bool:
if valid_at is not None:
valid_at_datetime = self.to_datetime(valid_at)
return self._valid_from <= valid_at_datetime <= self._valid_to
return True
@t.overload
@staticmethod
def format_datetime(dt: datetime, *, date_format: DateFormatStr) -> str: ...
@t.overload
@staticmethod
def format_datetime(dt: datetime, *, date_format: DateFormatInt) -> int: ...
@t.overload
@staticmethod
def format_datetime(dt: datetime, *, date_format: DateFormat) -> str | int: ...
@staticmethod
def format_datetime(dt: datetime, *, date_format: DateFormat) -> str | int:
if date_format in ("human_readable", "openssh"):
if dt == _ALWAYS:
return "always"
if dt == _FOREVER:
return "forever"
return (
dt.isoformat().replace("+00:00", "")
if date_format == "human_readable"
else dt.strftime("%Y%m%d%H%M%S")
)
if date_format == "timestamp":
td = dt - _ALWAYS
return int(
(td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6) / 10**6
)
raise ValueError(f"{date_format} is not a valid format")
@staticmethod
def to_datetime(time_string_or_timestamp: str | bytes | int) -> datetime:
if isinstance(time_string_or_timestamp, (str, bytes)):
return OpensshCertificateTimeParameters._time_string_to_datetime(
to_text(time_string_or_timestamp.strip())
)
if isinstance(time_string_or_timestamp, int):
return OpensshCertificateTimeParameters._timestamp_to_datetime(
time_string_or_timestamp
)
raise ValueError(
f"Value must be of type (str, unicode, int) not {type(time_string_or_timestamp)}"
)
@staticmethod
def _timestamp_to_datetime(timestamp: int) -> datetime:
if timestamp == 0x0:
return _ALWAYS
if timestamp == 0xFFFFFFFFFFFFFFFF:
return _FOREVER
try:
return datetime.fromtimestamp(timestamp, tz=_datetime.timezone.utc)
except OverflowError as e:
raise ValueError from e
@staticmethod
def _time_string_to_datetime(time_string: str) -> datetime:
if time_string == "always":
return _ALWAYS
if time_string == "forever":
return _FOREVER
if is_relative_time_string(time_string):
result = convert_relative_to_datetime(time_string, with_timezone=True)
if result is None:
raise ValueError
return result
result = None
for time_format in ("%Y-%m-%d", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
try:
result = _add_or_remove_timezone(
datetime.strptime(time_string, time_format),
with_timezone=True,
)
except ValueError:
pass
if result is None:
raise ValueError
return result
_OpensshCertificateOption = t.TypeVar(
"_OpensshCertificateOption", bound="OpensshCertificateOption"
)
class OpensshCertificateOption:
def __init__(
self,
*,
option_type: t.Literal["critical", "extension"],
name: str | bytes,
data: str | bytes,
):
if option_type not in ("critical", "extension"):
raise ValueError("type must be either 'critical' or 'extension'")
if not isinstance(name, (str, bytes)):
raise TypeError(f"name must be a string not {type(name)}")
if not isinstance(data, (str, bytes)):
raise TypeError(f"data must be a string not {type(data)}")
self._option_type = option_type
self._name = name.lower()
self._data = data
def __eq__(self, other: object) -> bool:
if not isinstance(other, type(self)):
return NotImplemented
return all(
[
self._option_type == other._option_type,
self._name == other._name,
self._data == other._data,
]
)
def __hash__(self) -> int:
return hash((self._option_type, self._name, self._data))
def __ne__(self, other: object) -> bool:
return not self == other
def __str__(self) -> str:
if self._data:
return f"{self._name!r}={self._data!r}"
return f"{self._name!r}"
@property
def data(self) -> str | bytes:
return self._data
@property
def name(self) -> str | bytes:
return self._name
@property
def type(self) -> t.Literal["critical", "extension"]:
return self._option_type
@classmethod
def from_string(
cls: t.Type[_OpensshCertificateOption], option_string: str
) -> _OpensshCertificateOption:
if not isinstance(option_string, str):
raise ValueError(
f"option_string must be a string not {type(option_string)}"
)
option_type = None
if ":" in option_string:
option_type, value = option_string.strip().split(":", 1)
if "=" in value:
name, data = value.split("=", 1)
else:
name, data = value, ""
elif "=" in option_string:
name, data = option_string.strip().split("=", 1)
else:
name, data = option_string.strip(), ""
return cls(
# We have str, but we're expecting a specific literal:
option_type=option_type or get_option_type(name.lower()), # type: ignore
name=name,
data=data,
)
if t.TYPE_CHECKING:
class _OpensshCertificateInfoKwarg(t.TypedDict):
nonce: t.NotRequired[bytes | None]
serial: t.NotRequired[int | None]
cert_type: t.NotRequired[int | None]
key_id: t.NotRequired[bytes | None]
principals: t.NotRequired[list[bytes] | None]
valid_after: t.NotRequired[int | None]
valid_before: t.NotRequired[int | None]
critical_options: t.NotRequired[list[tuple[bytes, bytes]] | None]
extensions: t.NotRequired[list[tuple[bytes, bytes]] | None]
reserved: t.NotRequired[bytes | None]
signing_key: t.NotRequired[bytes | None]
class OpensshCertificateInfo(metaclass=abc.ABCMeta):
"""Encapsulates all certificate information which is signed by a CA key"""
def __init__(
self,
*,
nonce: bytes | None = None,
serial: int | None = None,
cert_type: int | None = None,
key_id: bytes | None = None,
principals: list[bytes] | None = None,
valid_after: int | None = None,
valid_before: int | None = None,
critical_options: list[tuple[bytes, bytes]] | None = None,
extensions: list[tuple[bytes, bytes]] | None = None,
reserved: bytes | None = None,
signing_key: bytes | None = None,
):
self.nonce = nonce
self.serial = serial
self._cert_type: int | None = cert_type
self.key_id = key_id
self.principals = principals
self.valid_after = valid_after
self.valid_before = valid_before
self.critical_options = critical_options
self.extensions = extensions
self.reserved = reserved
self.signing_key = signing_key
self.type_string: bytes | None = None
@property
def cert_type(self) -> t.Literal["user", "host", ""]:
if self._cert_type == _USER_TYPE:
return "user"
if self._cert_type == _HOST_TYPE:
return "host"
return ""
@cert_type.setter
def cert_type(self, cert_type: t.Literal["user", "host"] | int) -> None:
if cert_type in ("user", _USER_TYPE):
self._cert_type = _USER_TYPE
elif cert_type in ("host", _HOST_TYPE):
self._cert_type = _HOST_TYPE
else:
raise ValueError(f"{cert_type} is not a valid certificate type")
def signing_key_fingerprint(self) -> bytes:
if self.signing_key is None:
raise ValueError("signing_key not present")
return fingerprint(self.signing_key)
@abc.abstractmethod
def public_key_fingerprint(self) -> bytes:
pass
@abc.abstractmethod
def parse_public_numbers(self, parser: OpensshParser) -> None:
pass
class OpensshRSACertificateInfo(OpensshCertificateInfo):
def __init__(
self,
*,
e: int | None = None,
n: int | None = None,
**kwargs: t.Unpack[_OpensshCertificateInfoKwarg],
) -> None:
super().__init__(**kwargs)
self.type_string = _SSH_TYPE_STRINGS["rsa"] + _CERT_SUFFIX_V01
self.e = e
self.n = n
# See https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
def public_key_fingerprint(self) -> bytes:
if self.e is None or self.n is None:
return b""
writer = _OpensshWriter()
writer.string(_SSH_TYPE_STRINGS["rsa"])
writer.mpint(self.e)
writer.mpint(self.n)
return fingerprint(writer.bytes())
def parse_public_numbers(self, parser: OpensshParser) -> None:
self.e = parser.mpint()
self.n = parser.mpint()
class OpensshDSACertificateInfo(OpensshCertificateInfo):
def __init__(
self,
*,
p: int | None = None,
q: int | None = None,
g: int | None = None,
y: int | None = None,
**kwargs: t.Unpack[_OpensshCertificateInfoKwarg],
) -> None:
super().__init__(**kwargs)
self.type_string = _SSH_TYPE_STRINGS["dsa"] + _CERT_SUFFIX_V01
self.p = p
self.q = q
self.g = g
self.y = y
# See https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
def public_key_fingerprint(self) -> bytes:
if self.p is None or self.q is None or self.g is None or self.y is None:
return b""
writer = _OpensshWriter()
writer.string(_SSH_TYPE_STRINGS["dsa"])
writer.mpint(self.p)
writer.mpint(self.q)
writer.mpint(self.g)
writer.mpint(self.y)
return fingerprint(writer.bytes())
def parse_public_numbers(self, parser: OpensshParser) -> None:
self.p = parser.mpint()
self.q = parser.mpint()
self.g = parser.mpint()
self.y = parser.mpint()
class OpensshECDSACertificateInfo(OpensshCertificateInfo):
def __init__(
self,
*,
curve: bytes | None = None,
public_key: bytes | None = None,
**kwargs: t.Unpack[_OpensshCertificateInfoKwarg],
):
super().__init__(**kwargs)
self._curve: bytes | None = None
if curve is not None:
self.curve = curve
self.public_key = public_key
@property
def curve(self) -> bytes | None:
return self._curve
@curve.setter
def curve(self, curve: bytes) -> None:
if curve in _ECDSA_CURVE_IDENTIFIERS.values():
self._curve = curve
self.type_string = (
_SSH_TYPE_STRINGS[_ECDSA_CURVE_IDENTIFIERS_LOOKUP[curve]]
+ _CERT_SUFFIX_V01
)
else:
raise ValueError(
"Curve must be one of {(b','.join(_ECDSA_CURVE_IDENTIFIERS.values())).decode('UTF-8')}"
)
# See https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
def public_key_fingerprint(self) -> bytes:
if self.curve is None or self.public_key is None:
return b""
writer = _OpensshWriter()
writer.string(_SSH_TYPE_STRINGS[_ECDSA_CURVE_IDENTIFIERS_LOOKUP[self.curve]])
writer.string(self.curve)
writer.string(self.public_key)
return fingerprint(writer.bytes())
def parse_public_numbers(self, parser: OpensshParser) -> None:
self.curve = parser.string()
self.public_key = parser.string()
class OpensshED25519CertificateInfo(OpensshCertificateInfo):
def __init__(
self,
*,
pk: bytes | None = None,
**kwargs: t.Unpack[_OpensshCertificateInfoKwarg],
) -> None:
super().__init__(**kwargs)
self.type_string = _SSH_TYPE_STRINGS["ed25519"] + _CERT_SUFFIX_V01
self.pk = pk
def public_key_fingerprint(self) -> bytes:
if self.pk is None:
return b""
writer = _OpensshWriter()
writer.string(_SSH_TYPE_STRINGS["ed25519"])
writer.string(self.pk)
return fingerprint(writer.bytes())
def parse_public_numbers(self, parser: OpensshParser) -> None:
self.pk = parser.string()
_OpensshCertificate = t.TypeVar("_OpensshCertificate", bound="OpensshCertificate")
# See https://cvsweb.openbsd.org/src/usr.bin/ssh/PROTOCOL.certkeys?annotate=HEAD
class OpensshCertificate:
"""Encapsulates a formatted OpenSSH certificate including signature and signing key"""
def __init__(self, *, cert_info: OpensshCertificateInfo, signature: bytes):
self._cert_info = cert_info
self.signature = signature
@classmethod
def load(
cls: t.Type[_OpensshCertificate], path: str | os.PathLike
) -> _OpensshCertificate:
if not os.path.exists(path):
raise ValueError(f"{path} is not a valid path.")
try:
with open(path, "rb") as cert_file:
data = cert_file.read()
except (IOError, OSError) as e:
raise ValueError(f"{path} cannot be opened for reading: {e}") from e
try:
format_identifier, b64_cert = data.split(b" ")[:2]
cert = binascii.a2b_base64(b64_cert)
except (binascii.Error, ValueError) as e:
raise ValueError("Certificate not in OpenSSH format") from e
for key_type, string in _SSH_TYPE_STRINGS.items():
if format_identifier == string + _CERT_SUFFIX_V01:
pub_key_type = t.cast(KeyType, key_type)
break
else:
raise ValueError(
f"Invalid certificate format identifier: {format_identifier!r}"
)
parser = OpensshParser(data=cert)
if format_identifier != parser.string():
raise ValueError("Certificate formats do not match")
try:
cert_info = cls._parse_cert_info(pub_key_type, parser)
signature = parser.string()
except (TypeError, ValueError) as e:
raise ValueError(f"Invalid certificate data: {e}") from e
if parser.remaining_bytes():
raise ValueError(
f"{parser.remaining_bytes()} bytes of additional data was not parsed while loading {path}"
)
return cls(
cert_info=cert_info,
signature=signature,
)
@property
def type_string(self) -> str:
return to_text(self._cert_info.type_string)
@property
def nonce(self) -> bytes:
if self._cert_info.nonce is None:
raise ValueError
return self._cert_info.nonce
@property
def public_key(self) -> str:
return to_text(self._cert_info.public_key_fingerprint())
@property
def serial(self) -> int:
if self._cert_info.serial is None:
raise ValueError
return self._cert_info.serial
@property
def type(self) -> t.Literal["user", "host"]:
result = self._cert_info.cert_type
if result == "":
raise ValueError
return result
@property
def key_id(self) -> str:
return to_text(self._cert_info.key_id)
@property
def principals(self) -> list[str]:
if self._cert_info.principals is None:
raise ValueError
return [to_text(p) for p in self._cert_info.principals]
@property
def valid_after(self) -> int:
if self._cert_info.valid_after is None:
raise ValueError
return self._cert_info.valid_after
@property
def valid_before(self) -> int:
if self._cert_info.valid_before is None:
raise ValueError
return self._cert_info.valid_before
@property
def critical_options(self) -> list[OpensshCertificateOption]:
if self._cert_info.critical_options is None:
raise ValueError
return [
OpensshCertificateOption(
option_type="critical", name=to_text(n), data=to_text(d)
)
for n, d in self._cert_info.critical_options
]
@property
def extensions(self) -> list[OpensshCertificateOption]:
if self._cert_info.extensions is None:
raise ValueError
return [
OpensshCertificateOption(
option_type="extension", name=to_text(n), data=to_text(d)
)
for n, d in self._cert_info.extensions
]
@property
def reserved(self) -> bytes:
if self._cert_info.reserved is None:
raise ValueError
return self._cert_info.reserved
@property
def signing_key(self) -> str:
return to_text(self._cert_info.signing_key_fingerprint())
@property
def signature_type(self) -> str:
signature_data = OpensshParser.signature_data(signature_string=self.signature)
return to_text(signature_data["signature_type"])
@staticmethod
def _parse_cert_info(
pub_key_type: KeyType, parser: OpensshParser
) -> OpensshCertificateInfo:
cert_info = get_cert_info_object(pub_key_type)
cert_info.nonce = parser.string()
cert_info.parse_public_numbers(parser)
cert_info.serial = parser.uint64()
# mypy doesn't understand that the setter accepts other types than the getter:
cert_info.cert_type = parser.uint32() # type: ignore
cert_info.key_id = parser.string()
cert_info.principals = parser.string_list()
cert_info.valid_after = parser.uint64()
cert_info.valid_before = parser.uint64()
cert_info.critical_options = parser.option_list()
cert_info.extensions = parser.option_list()
cert_info.reserved = parser.string()
cert_info.signing_key = parser.string()
return cert_info
def to_dict(self) -> dict[str, t.Any]:
time_parameters = OpensshCertificateTimeParameters(
valid_from=self.valid_after, valid_to=self.valid_before
)
return {
"type_string": self.type_string,
"nonce": self.nonce,
"serial": self.serial,
"cert_type": self.type,
"identifier": self.key_id,
"principals": self.principals,
"valid_after": time_parameters.valid_from(date_format="human_readable"),
"valid_before": time_parameters.valid_to(date_format="human_readable"),
"critical_options": [
str(critical_option) for critical_option in self.critical_options
],
"extensions": [str(extension) for extension in self.extensions],
"reserved": self.reserved,
"public_key": self.public_key,
"signing_key": self.signing_key,
}
def apply_directives(directives: t.Iterable[str]) -> list[OpensshCertificateOption]:
if any(d not in _DIRECTIVES for d in directives):
raise ValueError(f"directives must be one of {', '.join(_DIRECTIVES)}")
directive_to_option = {
"no-x11-forwarding": OpensshCertificateOption(
option_type="extension", name="permit-x11-forwarding", data=""
),
"no-agent-forwarding": OpensshCertificateOption(
option_type="extension", name="permit-agent-forwarding", data=""
),
"no-port-forwarding": OpensshCertificateOption(
option_type="extension", name="permit-port-forwarding", data=""
),
"no-pty": OpensshCertificateOption(
option_type="extension", name="permit-pty", data=""
),
"no-user-rc": OpensshCertificateOption(
option_type="extension", name="permit-user-rc", data=""
),
}
if "clear" in directives:
return []
return list(
set(default_options()) - set(directive_to_option[d] for d in directives)
)
def default_options() -> list[OpensshCertificateOption]:
return [
OpensshCertificateOption(option_type="extension", name=name, data="")
for name in _EXTENSIONS
]
def fingerprint(public_key: bytes) -> bytes:
"""Generates a SHA256 hash and formats output to resemble ``ssh-keygen``"""
h = sha256()
h.update(public_key)
return b"SHA256:" + b64encode(h.digest()).rstrip(b"=")
def get_cert_info_object(key_type: KeyType) -> OpensshCertificateInfo:
if key_type == "rsa":
return OpensshRSACertificateInfo()
if key_type == "dsa":
return OpensshDSACertificateInfo()
if key_type in ("ecdsa-nistp256", "ecdsa-nistp384", "ecdsa-nistp521"):
return OpensshECDSACertificateInfo()
if key_type == "ed25519":
return OpensshED25519CertificateInfo()
raise ValueError(f"{key_type} is not a valid key type")
def get_option_type(name: str) -> t.Literal["critical", "extension"]:
if name in _CRITICAL_OPTIONS:
return "critical"
if name in _EXTENSIONS:
return "extension"
raise ValueError(
f"{name} is not a valid option. Custom options must start with 'critical:' or 'extension:' to indicate type"
)
def is_relative_time_string(time_string: str) -> bool:
return time_string.startswith("+") or time_string.startswith("-")
def parse_option_list(
option_list: t.Iterable[str],
) -> tuple[list[OpensshCertificateOption], list[OpensshCertificateOption]]:
critical_options = []
directives = []
extensions = []
for option in option_list:
if option.lower() in _DIRECTIVES:
directives.append(option.lower())
else:
option_object = OpensshCertificateOption.from_string(option)
if option_object.type == "critical":
critical_options.append(option_object)
else:
extensions.append(option_object)
return critical_options, list(set(extensions + apply_directives(directives)))
__all__ = (
"OpensshCertificateTimeParameters",
"OpensshCertificateOption",
"OpensshCertificateInfo",
"OpensshRSACertificateInfo",
"OpensshDSACertificateInfo",
"OpensshECDSACertificateInfo",
"OpensshED25519CertificateInfo",
"OpensshCertificate",
"apply_directives",
"default_options",
"fingerprint",
"get_cert_info_object",
"get_option_type",
"is_relative_time_string",
"parse_option_list",
)

View File

@@ -1,40 +1,30 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2021, Andrew Pantuso (@ajpantuso) <ajpantuso@gmail.com> # Copyright (c) 2021, Andrew Pantuso (@ajpantuso) <ajpantuso@gmail.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
__metaclass__ = type
import os import os
import typing as t
from base64 import b64decode, b64encode from base64 import b64decode, b64encode
from getpass import getuser from getpass import getuser
from socket import gethostname from socket import gethostname
from ansible_collections.community.crypto.plugins.module_utils.version import (
LooseVersion,
)
try: try:
from cryptography import __version__ as CRYPTOGRAPHY_VERSION from cryptography import __version__ as CRYPTOGRAPHY_VERSION
from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm
from cryptography.hazmat.backends.openssl import backend
from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import dsa, ec, padding, rsa from cryptography.hazmat.primitives.asymmetric import dsa, ec, padding, rsa
from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PrivateKey
from cryptography.hazmat.primitives.asymmetric.ed25519 import ( from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey, Ed25519PrivateKey,
Ed25519PublicKey, Ed25519PublicKey,
) )
if LooseVersion(CRYPTOGRAPHY_VERSION) >= LooseVersion("3.0"):
HAS_OPENSSH_PRIVATE_FORMAT = True
else:
HAS_OPENSSH_PRIVATE_FORMAT = False
HAS_OPENSSH_SUPPORT = True HAS_OPENSSH_SUPPORT = True
_ALGORITHM_PARAMETERS = { _ALGORITHM_PARAMETERS = {
@@ -75,11 +65,35 @@ try:
}, },
} }
except ImportError: except ImportError:
HAS_OPENSSH_PRIVATE_FORMAT = False
HAS_OPENSSH_SUPPORT = False HAS_OPENSSH_SUPPORT = False
CRYPTOGRAPHY_VERSION = "0.0" CRYPTOGRAPHY_VERSION = "0.0"
_ALGORITHM_PARAMETERS = {} _ALGORITHM_PARAMETERS = {}
from ansible_collections.community.crypto.plugins.module_utils._crypto.cryptography_support import (
is_potential_certificate_issuer_private_key,
)
if t.TYPE_CHECKING:
KeyFormat = t.Literal["SSH", "PKCS8", "PKCS1"] # pragma: no cover
KeySerializationFormat = t.Literal["PEM", "DER", "SSH"] # pragma: no cover
KeyType = t.Literal["rsa", "dsa", "ed25519", "ecdsa"] # pragma: no cover
PrivateKeyTypes = t.Union[
rsa.RSAPrivateKey,
dsa.DSAPrivateKey,
ec.EllipticCurvePrivateKey,
Ed25519PrivateKey,
] # pragma: no cover
PublicKeyTypes = t.Union[
rsa.RSAPublicKey, dsa.DSAPublicKey, ec.EllipticCurvePublicKey, Ed25519PublicKey
] # pragma: no cover
from cryptography.hazmat.primitives.asymmetric.types import (
PublicKeyTypes as AllPublicKeyTypes, # pragma: no cover
)
_TEXT_ENCODING = "UTF-8" _TEXT_ENCODING = "UTF-8"
@@ -127,11 +141,20 @@ class InvalidSignatureError(OpenSSHError):
pass pass
class AsymmetricKeypair(object): _AsymmetricKeypair = t.TypeVar("_AsymmetricKeypair", bound="AsymmetricKeypair")
class AsymmetricKeypair:
"""Container for newly generated asymmetric key pairs or those loaded from existing files""" """Container for newly generated asymmetric key pairs or those loaded from existing files"""
@classmethod @classmethod
def generate(cls, keytype="rsa", size=None, passphrase=None): def generate(
cls: t.Type[_AsymmetricKeypair],
*,
keytype: KeyType = "rsa",
size: int | None = None,
passphrase: bytes | None = None,
) -> _AsymmetricKeypair:
"""Returns an Asymmetric_Keypair object generated with the supplied parameters """Returns an Asymmetric_Keypair object generated with the supplied parameters
or defaults to an unencrypted RSA-2048 key or defaults to an unencrypted RSA-2048 key
@@ -140,20 +163,21 @@ class AsymmetricKeypair(object):
:passphrase: Secret of type Bytes used to encrypt the private key being generated :passphrase: Secret of type Bytes used to encrypt the private key being generated
""" """
if keytype not in _ALGORITHM_PARAMETERS.keys(): if keytype not in _ALGORITHM_PARAMETERS:
raise InvalidKeyTypeError( raise InvalidKeyTypeError(
"%s is not a valid keytype. Valid keytypes are %s" f"{keytype} is not a valid keytype. Valid keytypes are {', '.join(_ALGORITHM_PARAMETERS)}"
% (keytype, ", ".join(_ALGORITHM_PARAMETERS.keys()))
) )
if not size: if not size:
size = _ALGORITHM_PARAMETERS[keytype]["default_size"] size = _ALGORITHM_PARAMETERS[keytype]["default_size"] # type: ignore
else: else:
if size not in _ALGORITHM_PARAMETERS[keytype]["valid_sizes"]: if size not in _ALGORITHM_PARAMETERS[keytype]["valid_sizes"]: # type: ignore
raise InvalidKeySizeError( raise InvalidKeySizeError(
"%s is not a valid key size for %s keys" % (size, keytype) f"{size} is not a valid key size for {keytype} keys"
) )
size = t.cast(int, size)
privatekey: PrivateKeyTypes
if passphrase: if passphrase:
encryption_algorithm = get_encryption_algorithm(passphrase) encryption_algorithm = get_encryption_algorithm(passphrase)
else: else:
@@ -165,19 +189,16 @@ class AsymmetricKeypair(object):
# if improper padding is used during signing # if improper padding is used during signing
public_exponent=65537, public_exponent=65537,
key_size=size, key_size=size,
backend=backend,
) )
elif keytype == "dsa": elif keytype == "dsa":
privatekey = dsa.generate_private_key( privatekey = dsa.generate_private_key(
key_size=size, key_size=size,
backend=backend,
) )
elif keytype == "ed25519": elif keytype == "ed25519":
privatekey = Ed25519PrivateKey.generate() privatekey = Ed25519PrivateKey.generate()
elif keytype == "ecdsa": elif keytype == "ecdsa":
privatekey = ec.generate_private_key( privatekey = ec.generate_private_key(
_ALGORITHM_PARAMETERS["ecdsa"]["curves"][size], _ALGORITHM_PARAMETERS["ecdsa"]["curves"][size], # type: ignore
backend=backend,
) )
publickey = privatekey.public_key() publickey = privatekey.public_key()
@@ -192,13 +213,14 @@ class AsymmetricKeypair(object):
@classmethod @classmethod
def load( def load(
cls, cls: t.Type[_AsymmetricKeypair],
path, *,
passphrase=None, path: str | os.PathLike,
private_key_format="PEM", passphrase: bytes | None = None,
public_key_format="PEM", private_key_format: KeySerializationFormat = "PEM",
no_public_key=False, public_key_format: KeySerializationFormat = "PEM",
): no_public_key: bool = False,
) -> _AsymmetricKeypair:
"""Returns an Asymmetric_Keypair object loaded from the supplied file path """Returns an Asymmetric_Keypair object loaded from the supplied file path
:path: A path to an existing private key to be loaded :path: A path to an existing private key to be loaded
@@ -213,30 +235,51 @@ class AsymmetricKeypair(object):
else: else:
encryption_algorithm = serialization.NoEncryption() encryption_algorithm = serialization.NoEncryption()
privatekey = load_privatekey(path, passphrase, private_key_format) privatekey = load_privatekey(
path=path, passphrase=passphrase, key_format=private_key_format
)
publickey: AllPublicKeyTypes
if no_public_key: if no_public_key:
publickey = privatekey.public_key() publickey = privatekey.public_key()
else: else:
publickey = load_publickey(path + ".pub", public_key_format) # TODO: Maybe we should check whether the public key actually fits the private key?
publickey = load_publickey(
path=str(path) + ".pub", key_format=public_key_format
)
# Ed25519 keys are always of size 256 and do not have a key_size attribute # Ed25519 keys are always of size 256 and do not have a key_size attribute
if isinstance(privatekey, Ed25519PrivateKey): if isinstance(privatekey, Ed25519PrivateKey):
size = _ALGORITHM_PARAMETERS["ed25519"]["default_size"] size: int = _ALGORITHM_PARAMETERS["ed25519"]["default_size"] # type: ignore
else: else:
size = privatekey.key_size size = privatekey.key_size
keytype: KeyType
if isinstance(privatekey, rsa.RSAPrivateKey): if isinstance(privatekey, rsa.RSAPrivateKey):
keytype = "rsa" keytype = "rsa"
if not isinstance(publickey, rsa.RSAPublicKey):
raise InvalidKeyTypeError(
f"Private key is an RSA key, but public key is of type '{type(publickey)}'"
)
elif isinstance(privatekey, dsa.DSAPrivateKey): elif isinstance(privatekey, dsa.DSAPrivateKey):
keytype = "dsa" keytype = "dsa"
if not isinstance(publickey, dsa.DSAPublicKey):
raise InvalidKeyTypeError(
f"Private key is a DSA key, but public key is of type '{type(publickey)}'"
)
elif isinstance(privatekey, ec.EllipticCurvePrivateKey): elif isinstance(privatekey, ec.EllipticCurvePrivateKey):
keytype = "ecdsa" keytype = "ecdsa"
if not isinstance(publickey, ec.EllipticCurvePublicKey):
raise InvalidKeyTypeError(
f"Private key is an Elliptic Curve key, but public key is of type '{type(publickey)}'"
)
elif isinstance(privatekey, Ed25519PrivateKey): elif isinstance(privatekey, Ed25519PrivateKey):
keytype = "ed25519" keytype = "ed25519"
if not isinstance(publickey, Ed25519PublicKey):
raise InvalidKeyTypeError(
f"Private key is an Ed25519 key, but public key is of type '{type(publickey)}'"
)
else: else:
raise InvalidKeyTypeError( raise InvalidKeyTypeError(f"Key type '{type(privatekey)}' is not supported")
"Key type '%s' is not supported" % type(privatekey)
)
return cls( return cls(
keytype=keytype, keytype=keytype,
@@ -246,7 +289,15 @@ class AsymmetricKeypair(object):
encryption_algorithm=encryption_algorithm, encryption_algorithm=encryption_algorithm,
) )
def __init__(self, keytype, size, privatekey, publickey, encryption_algorithm): def __init__(
self,
*,
keytype: KeyType,
size: int,
privatekey: PrivateKeyTypes,
publickey: PublicKeyTypes,
encryption_algorithm: serialization.KeySerializationEncryption,
) -> None:
""" """
:keytype: One of rsa, dsa, ecdsa, ed25519 :keytype: One of rsa, dsa, ecdsa, ed25519
:size: The key length for the private key of this key pair :size: The key length for the private key of this key pair
@@ -262,13 +313,13 @@ class AsymmetricKeypair(object):
self.__encryption_algorithm = encryption_algorithm self.__encryption_algorithm = encryption_algorithm
try: try:
self.verify(self.sign(b"message"), b"message") self.verify(signature=self.sign(b"message"), data=b"message")
except InvalidSignatureError: except InvalidSignatureError as e:
raise InvalidPublicKeyFileError( raise InvalidPublicKeyFileError(
"The private key and public key of this keypair do not match" "The private key and public key of this keypair do not match"
) ) from e
def __eq__(self, other): def __eq__(self, other: object) -> bool:
if not isinstance(other, AsymmetricKeypair): if not isinstance(other, AsymmetricKeypair):
return NotImplemented return NotImplemented
@@ -278,55 +329,54 @@ class AsymmetricKeypair(object):
self.encryption_algorithm, other.encryption_algorithm self.encryption_algorithm, other.encryption_algorithm
) )
def __ne__(self, other): def __ne__(self, other: object) -> bool:
return not self == other return not self == other
@property @property
def private_key(self): def private_key(self) -> PrivateKeyTypes:
"""Returns the private key of this key pair""" """Returns the private key of this key pair"""
return self.__privatekey return self.__privatekey
@property @property
def public_key(self): def public_key(self) -> PublicKeyTypes:
"""Returns the public key of this key pair""" """Returns the public key of this key pair"""
return self.__publickey return self.__publickey
@property @property
def size(self): def size(self) -> int:
"""Returns the size of the private key of this key pair""" """Returns the size of the private key of this key pair"""
return self.__size return self.__size
@property @property
def key_type(self): def key_type(self) -> KeyType:
"""Returns the key type of this key pair""" """Returns the key type of this key pair"""
return self.__keytype return self.__keytype
@property @property
def encryption_algorithm(self): def encryption_algorithm(self) -> serialization.KeySerializationEncryption:
"""Returns the key encryption algorithm of this key pair""" """Returns the key encryption algorithm of this key pair"""
return self.__encryption_algorithm return self.__encryption_algorithm
def sign(self, data): def sign(self, data: bytes) -> bytes:
"""Returns signature of data signed with the private key of this key pair """Returns signature of data signed with the private key of this key pair
:data: byteslike data to sign :data: byteslike data to sign
""" """
try: try:
signature = self.__privatekey.sign( return self.__privatekey.sign(
data, **_ALGORITHM_PARAMETERS[self.__keytype]["signer_params"] data,
**_ALGORITHM_PARAMETERS[self.__keytype]["signer_params"], # type: ignore
) )
except TypeError as e: except TypeError as e:
raise InvalidDataError(e) raise InvalidDataError(e) from e
return signature def verify(self, *, signature: bytes, data: bytes) -> None:
def verify(self, signature, data):
"""Verifies that the signature associated with the provided data was signed """Verifies that the signature associated with the provided data was signed
by the private key of this key pair. by the private key of this key pair.
@@ -334,15 +384,15 @@ class AsymmetricKeypair(object):
:data: byteslike data signed by the provided signature :data: byteslike data signed by the provided signature
""" """
try: try:
return self.__publickey.verify( self.__publickey.verify(
signature, signature,
data, data,
**_ALGORITHM_PARAMETERS[self.__keytype]["signer_params"] **_ALGORITHM_PARAMETERS[self.__keytype]["signer_params"], # type: ignore
) )
except InvalidSignature: except InvalidSignature as e:
raise InvalidSignatureError raise InvalidSignatureError from e
def update_passphrase(self, passphrase=None): def update_passphrase(self, passphrase: bytes | None = None) -> None:
"""Updates the encryption algorithm of this key pair """Updates the encryption algorithm of this key pair
:passphrase: Byte secret used to encrypt this key pair :passphrase: Byte secret used to encrypt this key pair
@@ -354,11 +404,21 @@ class AsymmetricKeypair(object):
self.__encryption_algorithm = serialization.NoEncryption() self.__encryption_algorithm = serialization.NoEncryption()
class OpensshKeypair(object): _OpensshKeypair = t.TypeVar("_OpensshKeypair", bound="OpensshKeypair")
class OpensshKeypair:
"""Container for OpenSSH encoded asymmetric key pairs""" """Container for OpenSSH encoded asymmetric key pairs"""
@classmethod @classmethod
def generate(cls, keytype="rsa", size=None, passphrase=None, comment=None): def generate(
cls: t.Type[_OpensshKeypair],
*,
keytype: KeyType = "rsa",
size: int | None = None,
passphrase: bytes | None = None,
comment: str | None = None,
) -> _OpensshKeypair:
"""Returns an Openssh_Keypair object generated using the supplied parameters or defaults to a RSA-2048 key """Returns an Openssh_Keypair object generated using the supplied parameters or defaults to a RSA-2048 key
:keytype: One of rsa, dsa, ecdsa, ed25519 :keytype: One of rsa, dsa, ecdsa, ed25519
@@ -368,11 +428,17 @@ class OpensshKeypair(object):
""" """
if comment is None: if comment is None:
comment = "%s@%s" % (getuser(), gethostname()) comment = f"{getuser()}@{gethostname()}"
asym_keypair = AsymmetricKeypair.generate(keytype, size, passphrase) asym_keypair = AsymmetricKeypair.generate(
openssh_privatekey = cls.encode_openssh_privatekey(asym_keypair, "SSH") keytype=keytype, size=size, passphrase=passphrase
openssh_publickey = cls.encode_openssh_publickey(asym_keypair, comment) )
openssh_privatekey = cls.encode_openssh_privatekey(
asym_keypair=asym_keypair, key_format="SSH"
)
openssh_publickey = cls.encode_openssh_publickey(
asym_keypair=asym_keypair, comment=comment
)
fingerprint = calculate_fingerprint(openssh_publickey) fingerprint = calculate_fingerprint(openssh_publickey)
return cls( return cls(
@@ -384,7 +450,13 @@ class OpensshKeypair(object):
) )
@classmethod @classmethod
def load(cls, path, passphrase=None, no_public_key=False): def load(
cls: t.Type[_OpensshKeypair],
*,
path: str | os.PathLike,
passphrase: bytes | None = None,
no_public_key: bool = False,
) -> _OpensshKeypair:
"""Returns an Openssh_Keypair object loaded from the supplied file path """Returns an Openssh_Keypair object loaded from the supplied file path
:path: A path to an existing private key to be loaded :path: A path to an existing private key to be loaded
@@ -395,13 +467,21 @@ class OpensshKeypair(object):
if no_public_key: if no_public_key:
comment = "" comment = ""
else: else:
comment = extract_comment(path + ".pub") comment = extract_comment(str(path) + ".pub")
asym_keypair = AsymmetricKeypair.load( asym_keypair = AsymmetricKeypair.load(
path, passphrase, "SSH", "SSH", no_public_key path=path,
passphrase=passphrase,
private_key_format="SSH",
public_key_format="SSH",
no_public_key=no_public_key,
)
openssh_privatekey = cls.encode_openssh_privatekey(
asym_keypair=asym_keypair, key_format="SSH"
)
openssh_publickey = cls.encode_openssh_publickey(
asym_keypair=asym_keypair, comment=comment
) )
openssh_privatekey = cls.encode_openssh_privatekey(asym_keypair, "SSH")
openssh_publickey = cls.encode_openssh_publickey(asym_keypair, comment)
fingerprint = calculate_fingerprint(openssh_publickey) fingerprint = calculate_fingerprint(openssh_publickey)
return cls( return cls(
@@ -413,7 +493,9 @@ class OpensshKeypair(object):
) )
@staticmethod @staticmethod
def encode_openssh_privatekey(asym_keypair, key_format): def encode_openssh_privatekey(
*, asym_keypair: AsymmetricKeypair, key_format: KeyFormat
) -> bytes:
"""Returns an OpenSSH encoded private key for a given keypair """Returns an OpenSSH encoded private key for a given keypair
:asym_keypair: Asymmetric_Keypair from the private key is extracted :asym_keypair: Asymmetric_Keypair from the private key is extracted
@@ -421,11 +503,7 @@ class OpensshKeypair(object):
""" """
if key_format == "SSH": if key_format == "SSH":
# Default to PEM format if SSH not available privatekey_format = serialization.PrivateFormat.OpenSSH
if not HAS_OPENSSH_PRIVATE_FORMAT:
privatekey_format = serialization.PrivateFormat.PKCS8
else:
privatekey_format = serialization.PrivateFormat.OpenSSH
elif key_format == "PKCS8": elif key_format == "PKCS8":
privatekey_format = serialization.PrivateFormat.PKCS8 privatekey_format = serialization.PrivateFormat.PKCS8
elif key_format == "PKCS1": elif key_format == "PKCS1":
@@ -448,7 +526,9 @@ class OpensshKeypair(object):
return encoded_privatekey return encoded_privatekey
@staticmethod @staticmethod
def encode_openssh_publickey(asym_keypair, comment): def encode_openssh_publickey(
*, asym_keypair: AsymmetricKeypair, comment: str
) -> bytes:
"""Returns an OpenSSH encoded public key for a given keypair """Returns an OpenSSH encoded public key for a given keypair
:asym_keypair: Asymmetric_Keypair from the public key is extracted :asym_keypair: Asymmetric_Keypair from the public key is extracted
@@ -462,14 +542,20 @@ class OpensshKeypair(object):
validate_comment(comment) validate_comment(comment)
encoded_publickey += ( encoded_publickey += (
(" %s" % comment).encode(encoding=_TEXT_ENCODING) if comment else b"" (b" " + comment.encode(encoding=_TEXT_ENCODING)) if comment else b""
) )
return encoded_publickey return encoded_publickey
def __init__( def __init__(
self, asym_keypair, openssh_privatekey, openssh_publickey, fingerprint, comment self,
): *,
asym_keypair: AsymmetricKeypair,
openssh_privatekey: bytes,
openssh_publickey: bytes,
fingerprint: str,
comment: str | None,
) -> None:
""" """
:asym_keypair: An Asymmetric_Keypair object from which the OpenSSH encoded keypair is derived :asym_keypair: An Asymmetric_Keypair object from which the OpenSSH encoded keypair is derived
:openssh_privatekey: An OpenSSH encoded private key :openssh_privatekey: An OpenSSH encoded private key
@@ -484,7 +570,7 @@ class OpensshKeypair(object):
self.__fingerprint = fingerprint self.__fingerprint = fingerprint
self.__comment = comment self.__comment = comment
def __eq__(self, other): def __eq__(self, other: object) -> bool:
if not isinstance(other, OpensshKeypair): if not isinstance(other, OpensshKeypair):
return NotImplemented return NotImplemented
@@ -494,49 +580,49 @@ class OpensshKeypair(object):
) )
@property @property
def asymmetric_keypair(self): def asymmetric_keypair(self) -> AsymmetricKeypair:
"""Returns the underlying asymmetric key pair of this OpenSSH encoded key pair""" """Returns the underlying asymmetric key pair of this OpenSSH encoded key pair"""
return self.__asym_keypair return self.__asym_keypair
@property @property
def private_key(self): def private_key(self) -> bytes:
"""Returns the OpenSSH formatted private key of this key pair""" """Returns the OpenSSH formatted private key of this key pair"""
return self.__openssh_privatekey return self.__openssh_privatekey
@property @property
def public_key(self): def public_key(self) -> bytes:
"""Returns the OpenSSH formatted public key of this key pair""" """Returns the OpenSSH formatted public key of this key pair"""
return self.__openssh_publickey return self.__openssh_publickey
@property @property
def size(self): def size(self) -> int:
"""Returns the size of the private key of this key pair""" """Returns the size of the private key of this key pair"""
return self.__asym_keypair.size return self.__asym_keypair.size
@property @property
def key_type(self): def key_type(self) -> KeyType:
"""Returns the key type of this key pair""" """Returns the key type of this key pair"""
return self.__asym_keypair.key_type return self.__asym_keypair.key_type
@property @property
def fingerprint(self): def fingerprint(self) -> str:
"""Returns the fingerprint (SHA256 Hash) of the public key of this key pair""" """Returns the fingerprint (SHA256 Hash) of the public key of this key pair"""
return self.__fingerprint return self.__fingerprint
@property @property
def comment(self): def comment(self) -> str | None:
"""Returns the comment applied to the OpenSSH formatted public key of this key pair""" """Returns the comment applied to the OpenSSH formatted public key of this key pair"""
return self.__comment return self.__comment
@comment.setter @comment.setter
def comment(self, comment): def comment(self, comment: str) -> bytes:
"""Updates the comment applied to the OpenSSH formatted public key of this key pair """Updates the comment applied to the OpenSSH formatted public key of this key pair
:comment: Text to update the OpenSSH public key comment :comment: Text to update the OpenSSH public key comment
@@ -546,7 +632,7 @@ class OpensshKeypair(object):
self.__comment = comment self.__comment = comment
encoded_comment = ( encoded_comment = (
(" %s" % self.__comment).encode(encoding=_TEXT_ENCODING) f" {self.__comment}".encode(encoding=_TEXT_ENCODING)
if self.__comment if self.__comment
else b"" else b""
) )
@@ -555,7 +641,7 @@ class OpensshKeypair(object):
) )
return self.__openssh_publickey return self.__openssh_publickey
def update_passphrase(self, passphrase): def update_passphrase(self, passphrase: bytes | None) -> None:
"""Updates the passphrase used to encrypt the private key of this keypair """Updates the passphrase used to encrypt the private key of this keypair
:passphrase: Text secret used for encryption :passphrase: Text secret used for encryption
@@ -563,69 +649,69 @@ class OpensshKeypair(object):
self.__asym_keypair.update_passphrase(passphrase) self.__asym_keypair.update_passphrase(passphrase)
self.__openssh_privatekey = OpensshKeypair.encode_openssh_privatekey( self.__openssh_privatekey = OpensshKeypair.encode_openssh_privatekey(
self.__asym_keypair, "SSH" asym_keypair=self.__asym_keypair, key_format="SSH"
) )
def load_privatekey(path, passphrase, key_format): def load_privatekey(
*,
path: str | os.PathLike,
passphrase: bytes | None,
key_format: KeySerializationFormat,
) -> PrivateKeyTypes:
privatekey_loaders = { privatekey_loaders = {
"PEM": serialization.load_pem_private_key, "PEM": serialization.load_pem_private_key,
"DER": serialization.load_der_private_key, "DER": serialization.load_der_private_key,
"SSH": serialization.load_ssh_private_key,
} }
# OpenSSH formatted private keys are not available in Cryptography <3.0
if hasattr(serialization, "load_ssh_private_key"):
privatekey_loaders["SSH"] = serialization.load_ssh_private_key
else:
privatekey_loaders["SSH"] = serialization.load_pem_private_key
try: try:
privatekey_loader = privatekey_loaders[key_format] privatekey_loader = privatekey_loaders[key_format]
except KeyError: except KeyError as e:
raise InvalidKeyFormatError( raise InvalidKeyFormatError(
"%s is not a valid key format (%s)" f"{key_format} is not a valid key format ({','.join(privatekey_loaders)})"
% (key_format, ",".join(privatekey_loaders.keys())) ) from e
)
if not os.path.exists(path): if not os.path.exists(path):
raise InvalidPrivateKeyFileError("No file was found at %s" % path) raise InvalidPrivateKeyFileError(f"No file was found at {path}")
try: try:
with open(path, "rb") as f: with open(path, "rb") as f:
content = f.read() content = f.read()
try:
privatekey = privatekey_loader( privatekey = privatekey_loader(
data=content, data=content,
password=passphrase, password=passphrase,
backend=backend,
) )
except ValueError as exc:
except ValueError as e: # Revert to PEM if key could not be loaded in SSH format
# Revert to PEM if key could not be loaded in SSH format if key_format == "SSH":
if key_format == "SSH":
try:
privatekey = privatekey_loaders["PEM"]( privatekey = privatekey_loaders["PEM"](
data=content, data=content,
password=passphrase, password=passphrase,
backend=backend,
) )
except ValueError as e: else:
raise InvalidPrivateKeyFileError(e) raise InvalidPrivateKeyFileError(exc) from exc
except TypeError as e: except ValueError as e:
raise InvalidPassphraseError(e) raise InvalidPrivateKeyFileError(e) from e
except UnsupportedAlgorithm as e:
raise InvalidAlgorithmError(e)
else:
raise InvalidPrivateKeyFileError(e)
except TypeError as e: except TypeError as e:
raise InvalidPassphraseError(e) raise InvalidPassphraseError(e) from e
except UnsupportedAlgorithm as e: except UnsupportedAlgorithm as e:
raise InvalidAlgorithmError(e) raise InvalidAlgorithmError(e) from e
if not is_potential_certificate_issuer_private_key(privatekey) or isinstance(
privatekey, Ed448PrivateKey
):
raise InvalidPrivateKeyFileError(
f"{privatekey} is not a supported private key type"
)
return privatekey return privatekey
def load_publickey(path, key_format): def load_publickey(
*, path: str | os.PathLike, key_format: KeySerializationFormat
) -> AllPublicKeyTypes:
publickey_loaders = { publickey_loaders = {
"PEM": serialization.load_pem_public_key, "PEM": serialization.load_pem_public_key,
"DER": serialization.load_der_public_key, "DER": serialization.load_der_public_key,
@@ -634,14 +720,13 @@ def load_publickey(path, key_format):
try: try:
publickey_loader = publickey_loaders[key_format] publickey_loader = publickey_loaders[key_format]
except KeyError: except KeyError as e:
raise InvalidKeyFormatError( raise InvalidKeyFormatError(
"%s is not a valid key format (%s)" f"{key_format} is not a valid key format ({','.join(publickey_loaders)})"
% (key_format, ",".join(publickey_loaders.keys())) ) from e
)
if not os.path.exists(path): if not os.path.exists(path):
raise InvalidPublicKeyFileError("No file was found at %s" % path) raise InvalidPublicKeyFileError(f"No file was found at {path}")
try: try:
with open(path, "rb") as f: with open(path, "rb") as f:
@@ -649,58 +734,63 @@ def load_publickey(path, key_format):
publickey = publickey_loader( publickey = publickey_loader(
data=content, data=content,
backend=backend,
) )
except ValueError as e: except ValueError as e:
raise InvalidPublicKeyFileError(e) raise InvalidPublicKeyFileError(e) from e
except UnsupportedAlgorithm as e: except UnsupportedAlgorithm as e:
raise InvalidAlgorithmError(e) raise InvalidAlgorithmError(e) from e
return publickey return publickey
def compare_publickeys(pk1, pk2): def compare_publickeys(pk1: PublicKeyTypes, pk2: PublicKeyTypes) -> bool:
a = isinstance(pk1, Ed25519PublicKey) a = isinstance(pk1, Ed25519PublicKey)
b = isinstance(pk2, Ed25519PublicKey) b = isinstance(pk2, Ed25519PublicKey)
if a or b: if a or b:
if not a or not b: if not a or not b:
return False return False
a = pk1.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) a_bytes = pk1.public_bytes(
b = pk2.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) serialization.Encoding.Raw, serialization.PublicFormat.Raw
return a == b )
else: b_bytes = pk2.public_bytes(
return pk1.public_numbers() == pk2.public_numbers() serialization.Encoding.Raw, serialization.PublicFormat.Raw
)
return a_bytes == b_bytes
return pk1.public_numbers() == pk2.public_numbers() # type: ignore
def compare_encryption_algorithms(ea1, ea2): def compare_encryption_algorithms(
ea1: serialization.KeySerializationEncryption,
ea2: serialization.KeySerializationEncryption,
) -> bool:
if isinstance(ea1, serialization.NoEncryption) and isinstance( if isinstance(ea1, serialization.NoEncryption) and isinstance(
ea2, serialization.NoEncryption ea2, serialization.NoEncryption
): ):
return True return True
elif isinstance(ea1, serialization.BestAvailableEncryption) and isinstance( if isinstance(ea1, serialization.BestAvailableEncryption) and isinstance(
ea2, serialization.BestAvailableEncryption ea2, serialization.BestAvailableEncryption
): ):
return ea1.password == ea2.password return ea1.password == ea2.password
else: return False
return False
def get_encryption_algorithm(passphrase): def get_encryption_algorithm(
passphrase: bytes,
) -> serialization.KeySerializationEncryption:
try: try:
return serialization.BestAvailableEncryption(passphrase) return serialization.BestAvailableEncryption(passphrase)
except ValueError as e: except ValueError as e:
raise InvalidPassphraseError(e) raise InvalidPassphraseError(e) from e
def validate_comment(comment): def validate_comment(comment: str) -> None:
if not hasattr(comment, "encode"): if not hasattr(comment, "encode"):
raise InvalidCommentError("%s cannot be encoded to text" % comment) raise InvalidCommentError(f"{comment} cannot be encoded to text")
def extract_comment(path): def extract_comment(path: str | os.PathLike) -> str:
if not os.path.exists(path): if not os.path.exists(path):
raise InvalidPublicKeyFileError("No file was found at %s" % path) raise InvalidPublicKeyFileError(f"No file was found at {path}")
try: try:
with open(path, "rb") as f: with open(path, "rb") as f:
@@ -710,16 +800,42 @@ def extract_comment(path):
else: else:
comment = "" comment = ""
except (IOError, OSError) as e: except (IOError, OSError) as e:
raise InvalidPublicKeyFileError(e) raise InvalidPublicKeyFileError(e) from e
return comment return comment
def calculate_fingerprint(openssh_publickey): def calculate_fingerprint(openssh_publickey: bytes) -> str:
digest = hashes.Hash(hashes.SHA256(), backend=backend) digest = hashes.Hash(hashes.SHA256())
decoded_pubkey = b64decode(openssh_publickey.split(b" ")[1]) decoded_pubkey = b64decode(openssh_publickey.split(b" ")[1])
digest.update(decoded_pubkey) digest.update(decoded_pubkey)
return "SHA256:%s" % b64encode(digest.finalize()).decode( value = b64encode(digest.finalize()).decode(encoding=_TEXT_ENCODING).rstrip("=")
encoding=_TEXT_ENCODING return f"SHA256:{value}"
).rstrip("=")
__all__ = (
"HAS_OPENSSH_SUPPORT",
"CRYPTOGRAPHY_VERSION",
"OpenSSHError",
"InvalidAlgorithmError",
"InvalidCommentError",
"InvalidDataError",
"InvalidPrivateKeyFileError",
"InvalidPublicKeyFileError",
"InvalidKeyFormatError",
"InvalidKeySizeError",
"InvalidKeyTypeError",
"InvalidPassphraseError",
"InvalidSignatureError",
"AsymmetricKeypair",
"OpensshKeypair",
"load_privatekey",
"load_publickey",
"compare_publickeys",
"compare_encryption_algorithms",
"get_encryption_algorithm",
"validate_comment",
"extract_comment",
"calculate_fingerprint",
)

View File

@@ -1,22 +1,19 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2020, Doug Stanley <doug+ansible@technologixllc.com> # Copyright (c) 2020, Doug Stanley <doug+ansible@technologixllc.com>
# Copyright (c) 2021, Andrew Pantuso (@ajpantuso) <ajpantuso@gmail.com> # Copyright (c) 2021, Andrew Pantuso (@ajpantuso) <ajpantuso@gmail.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
__metaclass__ = type
import os import os
import re import re
import typing as t
from contextlib import contextmanager from contextlib import contextmanager
from struct import Struct from struct import Struct
from ansible.module_utils.six import PY3
# Protocol References # Protocol References
# ------------------- # -------------------
@@ -30,9 +27,6 @@ from ansible.module_utils.six import PY3
# https://github.com/pyca/cryptography/blob/main/src/cryptography/hazmat/primitives/serialization/ssh.py # https://github.com/pyca/cryptography/blob/main/src/cryptography/hazmat/primitives/serialization/ssh.py
# https://github.com/paramiko/paramiko/blob/master/paramiko/message.py # https://github.com/paramiko/paramiko/blob/master/paramiko/message.py
if PY3:
long = int
# 0 (False) or 1 (True) encoded as a single byte # 0 (False) or 1 (True) encoded as a single byte
_BOOLEAN = Struct(b"?") _BOOLEAN = Struct(b"?")
# Unsigned 8-bit integer in network-byte-order # Unsigned 8-bit integer in network-byte-order
@@ -48,17 +42,20 @@ _UINT64 = Struct(b"!Q")
_UINT64_MAX = 0xFFFFFFFFFFFFFFFF _UINT64_MAX = 0xFFFFFFFFFFFFFFFF
def any_in(sequence, *elements): _T = t.TypeVar("_T")
def any_in(sequence: t.Iterable[_T], *elements: _T) -> bool:
return any(e in sequence for e in elements) return any(e in sequence for e in elements)
def file_mode(path): def file_mode(path: str | os.PathLike) -> int:
if not os.path.exists(path): if not os.path.exists(path):
return 0o000 return 0o000
return os.stat(path).st_mode & 0o777 return os.stat(path).st_mode & 0o777
def parse_openssh_version(version_string): def parse_openssh_version(version_string: str) -> str | None:
"""Parse the version output of ssh -V and return version numbers that can be compared""" """Parse the version output of ssh -V and return version numbers that can be compared"""
parsed_result = re.match( parsed_result = re.match(
@@ -73,7 +70,7 @@ def parse_openssh_version(version_string):
@contextmanager @contextmanager
def secure_open(path, mode): def secure_open(*, path: str | os.PathLike, mode: int) -> t.Iterator[int]:
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode) fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
try: try:
yield fd yield fd
@@ -81,48 +78,48 @@ def secure_open(path, mode):
os.close(fd) os.close(fd)
def secure_write(path, mode, content): def secure_write(*, path: str | os.PathLike, mode: int, content: bytes) -> None:
with secure_open(path, mode) as fd: with secure_open(path=path, mode=mode) as fd:
os.write(fd, content) os.write(fd, content)
# See https://datatracker.ietf.org/doc/html/rfc4251#section-5 for SSH data types # See https://datatracker.ietf.org/doc/html/rfc4251#section-5 for SSH data types
class OpensshParser(object): class OpensshParser:
"""Parser for OpenSSH encoded objects""" """Parser for OpenSSH encoded objects"""
BOOLEAN_OFFSET = 1 BOOLEAN_OFFSET = 1
UINT32_OFFSET = 4 UINT32_OFFSET = 4
UINT64_OFFSET = 8 UINT64_OFFSET = 8
def __init__(self, data): def __init__(self, *, data: bytes | bytearray) -> None:
if not isinstance(data, (bytes, bytearray)): if not isinstance(data, (bytes, bytearray)):
raise TypeError("Data must be bytes-like not %s" % type(data)) raise TypeError(f"Data must be bytes-like not {type(data)}")
self._data = memoryview(data) if PY3 else data self._data = memoryview(data)
self._pos = 0 self._pos = 0
def boolean(self): def boolean(self) -> bool:
next_pos = self._check_position(self.BOOLEAN_OFFSET) next_pos = self._check_position(self.BOOLEAN_OFFSET)
value = _BOOLEAN.unpack(self._data[self._pos : next_pos])[0] value: bool = _BOOLEAN.unpack(self._data[self._pos : next_pos])[0]
self._pos = next_pos self._pos = next_pos
return value return value
def uint32(self): def uint32(self) -> int:
next_pos = self._check_position(self.UINT32_OFFSET) next_pos = self._check_position(self.UINT32_OFFSET)
value = _UINT32.unpack(self._data[self._pos : next_pos])[0] value: int = _UINT32.unpack(self._data[self._pos : next_pos])[0]
self._pos = next_pos self._pos = next_pos
return value return value
def uint64(self): def uint64(self) -> int:
next_pos = self._check_position(self.UINT64_OFFSET) next_pos = self._check_position(self.UINT64_OFFSET)
value = _UINT64.unpack(self._data[self._pos : next_pos])[0] value: int = _UINT64.unpack(self._data[self._pos : next_pos])[0]
self._pos = next_pos self._pos = next_pos
return value return value
def string(self): def string(self) -> bytes:
length = self.uint32() length = self.uint32()
next_pos = self._check_position(length) next_pos = self._check_position(length)
@@ -130,70 +127,69 @@ class OpensshParser(object):
value = self._data[self._pos : next_pos] value = self._data[self._pos : next_pos]
self._pos = next_pos self._pos = next_pos
# Cast to bytes is required as a memoryview slice is itself a memoryview # Cast to bytes is required as a memoryview slice is itself a memoryview
return value if not PY3 else bytes(value) return bytes(value)
def mpint(self): def mpint(self) -> int:
return self._big_int(self.string(), "big", signed=True) return self._big_int(self.string(), "big", signed=True)
def name_list(self): def name_list(self) -> list[str]:
raw_string = self.string() raw_string = self.string()
return raw_string.decode("ASCII").split(",") return raw_string.decode("ASCII").split(",")
# Convenience function, but not an official data type from SSH # Convenience function, but not an official data type from SSH
def string_list(self): def string_list(self) -> list[bytes]:
result = [] result = []
raw_string = self.string() raw_string = self.string()
if raw_string: if raw_string:
parser = OpensshParser(raw_string) parser = OpensshParser(data=raw_string)
while parser.remaining_bytes(): while parser.remaining_bytes():
result.append(parser.string()) result.append(parser.string())
return result return result
# Convenience function, but not an official data type from SSH # Convenience function, but not an official data type from SSH
def option_list(self): def option_list(self) -> list[tuple[bytes, bytes]]:
result = [] result = []
raw_string = self.string() raw_string = self.string()
if raw_string: if raw_string:
parser = OpensshParser(raw_string) parser = OpensshParser(data=raw_string)
while parser.remaining_bytes(): while parser.remaining_bytes():
name = parser.string() name = parser.string()
data = parser.string() data = parser.string()
if data: if data:
# data is doubly-encoded # data is doubly-encoded
data = OpensshParser(data).string() data = OpensshParser(data=data).string()
result.append((name, data)) result.append((name, data))
return result return result
def seek(self, offset): def seek(self, offset: int) -> int:
self._pos = self._check_position(offset) self._pos = self._check_position(offset)
return self._pos return self._pos
def remaining_bytes(self): def remaining_bytes(self) -> int:
return len(self._data) - self._pos return len(self._data) - self._pos
def _check_position(self, offset): def _check_position(self, offset: int) -> int:
if self._pos + offset > len(self._data): if self._pos + offset > len(self._data):
raise ValueError("Insufficient data remaining at position: %s" % self._pos) raise ValueError(f"Insufficient data remaining at position: {self._pos}")
elif self._pos + offset < 0: if self._pos + offset < 0:
raise ValueError("Position cannot be less than zero.") raise ValueError("Position cannot be less than zero.")
else: return self._pos + offset
return self._pos + offset
@classmethod @classmethod
def signature_data(cls, signature_string): def signature_data(cls, *, signature_string: bytes) -> dict[str, bytes | int]:
signature_data = {} signature_data: dict[str, bytes | int] = {}
parser = cls(signature_string) parser = cls(data=signature_string)
signature_type = parser.string() signature_type = parser.string()
signature_blob = parser.string() signature_blob = parser.string()
blob_parser = cls(signature_blob) blob_parser = cls(data=signature_blob)
if signature_type in (b"ssh-rsa", b"rsa-sha2-256", b"rsa-sha2-512"): if signature_type in (b"ssh-rsa", b"rsa-sha2-256", b"rsa-sha2-512"):
# https://datatracker.ietf.org/doc/html/rfc4253#section-6.6 # https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
# https://datatracker.ietf.org/doc/html/rfc8332#section-3 # https://datatracker.ietf.org/doc/html/rfc8332#section-3
@@ -215,63 +211,28 @@ class OpensshParser(object):
signature_data["R"] = cls._big_int(signature_blob[:32], "little") signature_data["R"] = cls._big_int(signature_blob[:32], "little")
signature_data["S"] = cls._big_int(signature_blob[32:], "little") signature_data["S"] = cls._big_int(signature_blob[32:], "little")
else: else:
raise ValueError("%s is not a valid signature type" % signature_type) raise ValueError(f"{signature_type!r} is not a valid signature type")
signature_data["signature_type"] = signature_type signature_data["signature_type"] = signature_type
return signature_data return signature_data
@classmethod @classmethod
def _big_int(cls, raw_string, byte_order, signed=False): def _big_int(
cls,
raw_string: bytes,
byte_order: t.Literal["big", "little"],
signed: bool = False,
) -> int:
if byte_order not in ("big", "little"): if byte_order not in ("big", "little"):
raise ValueError( raise ValueError(
"Byte_order must be one of (big, little) not %s" % byte_order f"Byte_order must be one of (big, little) not {byte_order}"
) )
if PY3: return int.from_bytes(raw_string, byte_order, signed=signed)
return int.from_bytes(raw_string, byte_order, signed=signed)
result = 0
byte_length = len(raw_string)
if byte_length > 0:
# Check sign-bit
msb = raw_string[0] if byte_order == "big" else raw_string[-1]
negative = bool(ord(msb) & 0x80)
# Match pad value for two's complement
pad = b"\xff" if signed and negative else b"\x00"
# The definition of ``mpint`` enforces that unnecessary bytes are not encoded so they are added back
pad_length = 4 - byte_length % 4
if pad_length < 4:
raw_string = (
pad * pad_length + raw_string
if byte_order == "big"
else raw_string + pad * pad_length
)
byte_length += pad_length
# Accumulate arbitrary precision integer bytes in the appropriate order
if byte_order == "big":
for i in range(0, byte_length, cls.UINT32_OFFSET):
left_shift = result << cls.UINT32_OFFSET * 8
result = (
left_shift
+ _UINT32.unpack(raw_string[i : i + cls.UINT32_OFFSET])[0]
)
else:
for i in range(byte_length, 0, -cls.UINT32_OFFSET):
left_shift = result << cls.UINT32_OFFSET * 8
result = (
left_shift
+ _UINT32_LE.unpack(raw_string[i - cls.UINT32_OFFSET : i])[0]
)
# Adjust for two's complement
if signed and negative:
result -= 1 << (8 * byte_length)
return result
class _OpensshWriter(object): class _OpensshWriter:
"""Writes SSH encoded values to a bytes-like buffer """Writes SSH encoded values to a bytes-like buffer
.. warning:: .. warning::
@@ -280,79 +241,79 @@ class _OpensshWriter(object):
in validating parsed material. in validating parsed material.
""" """
def __init__(self, buffer=None): def __init__(self, *, buffer: bytearray | None = None):
if buffer is not None: if buffer is not None:
if not isinstance(buffer, (bytes, bytearray)): if not isinstance(buffer, bytearray):
raise TypeError( raise TypeError(f"Buffer must be a bytearray, not {type(buffer)}")
"Buffer must be a bytes-like object not %s" % type(buffer)
)
else: else:
buffer = bytearray() buffer = bytearray()
self._buff = buffer self._buff: bytearray = buffer
def boolean(self, value): def boolean(self, value: bool) -> t.Self:
if not isinstance(value, bool): if not isinstance(value, bool):
raise TypeError("Value must be of type bool not %s" % type(value)) raise TypeError(f"Value must be of type bool not {type(value)}")
self._buff.extend(_BOOLEAN.pack(value)) self._buff.extend(_BOOLEAN.pack(value))
return self return self
def uint32(self, value): def uint32(self, value: int) -> t.Self:
if not isinstance(value, int): if not isinstance(value, int):
raise TypeError("Value must be of type int not %s" % type(value)) raise TypeError(f"Value must be of type int not {type(value)}")
if value < 0 or value > _UINT32_MAX: if value < 0 or value > _UINT32_MAX:
raise ValueError( raise ValueError(
"Value must be a positive integer less than %s" % _UINT32_MAX f"Value must be a positive integer less than {_UINT32_MAX}"
) )
self._buff.extend(_UINT32.pack(value)) self._buff.extend(_UINT32.pack(value))
return self return self
def uint64(self, value): def uint64(self, value: int) -> t.Self:
if not isinstance(value, (long, int)): if not isinstance(value, int):
raise TypeError("Value must be of type (long, int) not %s" % type(value)) raise TypeError(f"Value must be of type int not {type(value)}")
if value < 0 or value > _UINT64_MAX: if value < 0 or value > _UINT64_MAX:
raise ValueError( raise ValueError(
"Value must be a positive integer less than %s" % _UINT64_MAX f"Value must be a positive integer less than {_UINT64_MAX}"
) )
self._buff.extend(_UINT64.pack(value)) self._buff.extend(_UINT64.pack(value))
return self return self
def string(self, value): def string(self, value: bytes | bytearray) -> t.Self:
if not isinstance(value, (bytes, bytearray)): if not isinstance(value, (bytes, bytearray)):
raise TypeError("Value must be bytes-like not %s" % type(value)) raise TypeError(f"Value must be bytes-like not {type(value)}")
self.uint32(len(value)) self.uint32(len(value))
self._buff.extend(value) self._buff.extend(value)
return self return self
def mpint(self, value): def mpint(self, value: int) -> t.Self:
if not isinstance(value, (int, long)): if not isinstance(value, int):
raise TypeError("Value must be of type (long, int) not %s" % type(value)) raise TypeError(f"Value must be of type int not {type(value)}")
self.string(self._int_to_mpint(value)) self.string(self._int_to_mpint(value))
return self return self
def name_list(self, value): def name_list(self, value: list[str]) -> t.Self:
if not isinstance(value, list): if not isinstance(value, list):
raise TypeError("Value must be a list of byte strings not %s" % type(value)) raise TypeError(f"Value must be a list of byte strings not {type(value)}")
try: try:
self.string(",".join(value).encode("ASCII")) self.string(",".join(value).encode("ASCII"))
except UnicodeEncodeError as e: except UnicodeEncodeError as e:
raise ValueError("Name-list's must consist of US-ASCII characters: %s" % e) raise ValueError(
f"Name-list's must consist of US-ASCII characters: {e}"
) from e
return self return self
def string_list(self, value): def string_list(self, value: list[bytes]) -> t.Self:
if not isinstance(value, list): if not isinstance(value, list):
raise TypeError("Value must be a list of byte string not %s" % type(value)) raise TypeError(f"Value must be a list of byte string not {type(value)}")
writer = _OpensshWriter() writer = _OpensshWriter()
for s in value: for s in value:
@@ -362,7 +323,7 @@ class _OpensshWriter(object):
return self return self
def option_list(self, value): def option_list(self, value: list[tuple[bytes, bytes]]) -> t.Self:
if not isinstance(value, list) or (value and not isinstance(value[0], tuple)): if not isinstance(value, list) or (value and not isinstance(value[0], tuple)):
raise TypeError("Value must be a list of tuples") raise TypeError("Value must be a list of tuples")
@@ -377,43 +338,23 @@ class _OpensshWriter(object):
return self return self
@staticmethod @staticmethod
def _int_to_mpint(num): def _int_to_mpint(num: int) -> bytes:
if PY3: byte_length = (num.bit_length() + 7) // 8
byte_length = (num.bit_length() + 7) // 8 try:
try: return num.to_bytes(byte_length, "big", signed=True)
result = num.to_bytes(byte_length, "big", signed=True) # Handles values which require \x00 or \xFF to pad sign-bit
# Handles values which require \x00 or \xFF to pad sign-bit except OverflowError:
except OverflowError: return num.to_bytes(byte_length + 1, "big", signed=True)
result = num.to_bytes(byte_length + 1, "big", signed=True)
else:
result = bytes()
# 0 and -1 are treated as special cases since they are used as sentinels for all other values
if num == 0:
result += b"\x00"
elif num == -1:
result += b"\xff"
elif num > 0:
while num >> 32:
result = _UINT32.pack(num & _UINT32_MAX) + result
num = num >> 32
# Pack last 4 bytes individually to discard insignificant bytes
while num:
result = _UBYTE.pack(num & _UBYTE_MAX) + result
num = num >> 8
# Zero pad final byte if most-significant bit is 1 as per mpint definition
if ord(result[0]) & 0x80:
result = b"\x00" + result
else:
while (num >> 32) < -1:
result = _UINT32.pack(num & _UINT32_MAX) + result
num = num >> 32
while num < -1:
result = _UBYTE.pack(num & _UBYTE_MAX) + result
num = num >> 8
if not ord(result[0]) & 0x80:
result = b"\xff" + result
return result def bytes(self) -> bytes:
def bytes(self):
return bytes(self._buff) return bytes(self._buff)
__all__ = (
"any_in",
"file_mode",
"parse_openssh_version",
"secure_open",
"secure_write",
"OpensshParser",
)

View File

@@ -1,21 +1,19 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2024, Felix Fontein <felix@fontein.de> # Copyright (c) 2024, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function # Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
__metaclass__ = type from ansible.module_utils.common.text.converters import to_text
from ansible_collections.community.crypto.plugins.module_utils._crypto.math import (
from ansible.module_utils.common.text.converters import to_native
from ansible_collections.community.crypto.plugins.module_utils.crypto.math import (
convert_int_to_hex, convert_int_to_hex,
) )
def th(number): def th(number: int) -> str:
abs_number = abs(number) abs_number = abs(number)
mod_10 = abs_number % 10 mod_10 = abs_number % 10
mod_100 = abs_number % 100 mod_100 = abs_number % 100
@@ -29,32 +27,33 @@ def th(number):
return "th" return "th"
def parse_serial(value): def parse_serial(value: str | bytes) -> int:
""" """
Given a colon-separated string of hexadecimal byte values, converts it to an integer. Given a colon-separated string of hexadecimal byte values, converts it to an integer.
""" """
value = to_native(value) value_str = to_text(value)
result = 0 result = 0
for i, part in enumerate(value.split(":")): for i, part in enumerate(value_str.split(":")):
try: try:
part_value = int(part, 16) part_value = int(part, 16)
if part_value < 0 or part_value > 255: if part_value < 0 or part_value > 255:
raise ValueError("the value is not in range [0, 255]") raise ValueError("the value is not in range [0, 255]")
except ValueError as exc: except ValueError as exc:
raise ValueError( raise ValueError(
"The {idx}{th} part {part!r} is not a hexadecimal number in range [0, 255]: {exc}".format( f"The {i + 1}{th(i + 1)} part {part!r} is not a hexadecimal number in range [0, 255]: {exc}"
idx=i + 1, th=th(i + 1), part=part, exc=exc ) from exc
)
)
result = (result << 8) | part_value result = (result << 8) | part_value
return result return result
def to_serial(value): def to_serial(value: int) -> str:
""" """
Given an integer, converts its absolute value to a colon-separated string of hexadecimal byte values. Given an integer, converts its absolute value to a colon-separated string of hexadecimal byte values.
""" """
value = convert_int_to_hex(value).upper() value_str = convert_int_to_hex(value).upper()
if len(value) % 2 != 0: if len(value_str) % 2 != 0:
value = "0" + value value_str = f"0{value_str}"
return ":".join(value[i : i + 2] for i in range(0, len(value), 2)) return ":".join(value_str[i : i + 2] for i in range(0, len(value_str), 2))
__all__ = ("parse_serial", "to_serial")

View File

@@ -0,0 +1,170 @@
# Copyright (c) 2024, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import datetime
import re
from ansible.module_utils.common.text.converters import to_text
from ansible_collections.community.crypto.plugins.module_utils._crypto.basic import (
OpenSSLObjectError,
)
UTC = datetime.timezone.utc
def get_now_datetime(*, with_timezone: bool) -> datetime.datetime:
if with_timezone:
return datetime.datetime.now(tz=UTC)
return datetime.datetime.utcnow()
def ensure_utc_timezone(timestamp: datetime.datetime) -> datetime.datetime:
if timestamp.tzinfo is UTC:
return timestamp
if timestamp.tzinfo is None:
# We assume that naive datetime objects use timezone UTC!
return timestamp.replace(tzinfo=UTC)
return timestamp.astimezone(UTC)
def remove_timezone(timestamp: datetime.datetime) -> datetime.datetime:
# Convert to native datetime object
if timestamp.tzinfo is None:
return timestamp
if timestamp.tzinfo is not UTC:
timestamp = timestamp.astimezone(UTC)
return timestamp.replace(tzinfo=None)
def add_or_remove_timezone(
timestamp: datetime.datetime, *, with_timezone: bool
) -> datetime.datetime:
return (
ensure_utc_timezone(timestamp) if with_timezone else remove_timezone(timestamp)
)
def get_epoch_seconds(timestamp: datetime.datetime) -> float:
if timestamp.tzinfo is None:
# timestamp.timestamp() is offset by the local timezone if timestamp has no timezone
timestamp = ensure_utc_timezone(timestamp)
return timestamp.timestamp()
def from_epoch_seconds(
timestamp: int | float, *, with_timezone: bool
) -> datetime.datetime:
if with_timezone:
return datetime.datetime.fromtimestamp(timestamp, UTC)
return datetime.datetime.utcfromtimestamp(timestamp)
def convert_relative_to_datetime(
relative_time_string: str,
*,
with_timezone: bool = False,
now: datetime.datetime | None = None,
) -> datetime.datetime | None:
"""Get a datetime.datetime or None from a string in the time format described in sshd_config(5)"""
parsed_result = re.match(
r"^(?P<prefix>[+-])((?P<weeks>\d+)[wW])?((?P<days>\d+)[dD])?((?P<hours>\d+)[hH])?((?P<minutes>\d+)[mM])?((?P<seconds>\d+)[sS]?)?$",
relative_time_string,
)
if parsed_result is None or len(relative_time_string) == 1:
# not matched or only a single "+" or "-"
return None
offset = datetime.timedelta(0)
if parsed_result.group("weeks") is not None:
offset += datetime.timedelta(weeks=int(parsed_result.group("weeks")))
if parsed_result.group("days") is not None:
offset += datetime.timedelta(days=int(parsed_result.group("days")))
if parsed_result.group("hours") is not None:
offset += datetime.timedelta(hours=int(parsed_result.group("hours")))
if parsed_result.group("minutes") is not None:
offset += datetime.timedelta(minutes=int(parsed_result.group("minutes")))
if parsed_result.group("seconds") is not None:
offset += datetime.timedelta(seconds=int(parsed_result.group("seconds")))
if now is None:
now = get_now_datetime(with_timezone=with_timezone)
else:
now = add_or_remove_timezone(now, with_timezone=with_timezone)
if parsed_result.group("prefix") == "+":
return now + offset
return now - offset
def get_relative_time_option(
input_string: str,
*,
input_name: str,
with_timezone: bool = False,
now: datetime.datetime | None = None,
) -> datetime.datetime:
"""
Return an absolute timespec if a relative timespec or an ASN1 formatted
string is provided.
The return value will be a datetime object.
"""
result = to_text(input_string)
if result is None:
raise OpenSSLObjectError(
f'The timespec "{input_string}" for {input_name} is not valid'
)
# Relative time
if result.startswith("+") or result.startswith("-"):
res = convert_relative_to_datetime(result, with_timezone=with_timezone, now=now)
if res is None:
raise OpenSSLObjectError(
f'The timespec "{input_string}" for {input_name} is invalid'
)
return res
# Absolute time
for date_fmt, length in [
(
"%Y%m%d%H%M%SZ",
15,
), # this also parses '202401020304Z', but as datetime(2024, 1, 2, 3, 0, 4)
("%Y%m%d%H%MZ", 13),
(
"%Y%m%d%H%M%S%z",
14 + 5,
), # this also parses '202401020304+0000', but as datetime(2024, 1, 2, 3, 0, 4, tzinfo=...)
("%Y%m%d%H%M%z", 12 + 5),
]:
if len(result) != length:
continue
try:
res = datetime.datetime.strptime(result, date_fmt)
except ValueError:
pass
else:
return add_or_remove_timezone(res, with_timezone=with_timezone)
raise OpenSSLObjectError(
f'The time spec "{input_string}" for {input_name} is invalid'
)
__all__ = (
"get_now_datetime",
"ensure_utc_timezone",
"remove_timezone",
"add_or_remove_timezone",
"get_epoch_seconds",
"from_epoch_seconds",
"convert_relative_to_datetime",
"get_relative_time_option",
)

View File

@@ -1,348 +1,15 @@
# Vendored copy of distutils/version.py from CPython 3.9.5 # Copyright (c) 2021, Felix Fontein <felix@fontein.de>
# # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# Implements multiple version numbering conventions for the # SPDX-License-Identifier: GPL-3.0-or-later
# Python Module Distribution Utilities.
#
# Copyright (c) 2001-2022 Python Software Foundation. All rights reserved.
# PSF License (see LICENSES/PSF-2.0.txt or https://opensource.org/licenses/Python-2.0)
# SPDX-License-Identifier: PSF-2.0
#
"""Provides classes to represent module version numbers (one class for # Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
each style of version numbering). There are currently two such classes # Do not use this from other collections or standalone plugins/modules!
implemented: StrictVersion and LooseVersion.
Every version number class implements the following interface: """Provide version object to compare version numbers."""
* the 'parse' method takes a string and parses it to some internal
representation; if the string is an invalid version number,
'parse' raises a ValueError exception
* the class constructor takes an optional string argument which,
if supplied, is passed to 'parse'
* __str__ reconstructs the string that was passed to 'parse' (or
an equivalent string -- ie. one that will generate an equivalent
version number instance)
* __repr__ generates Python code to recreate the version number instance
* _cmp compares the current instance with either another instance
of the same class or a string (which will be parsed to an instance
of the same class, thus must follow the same rules)
"""
from __future__ import absolute_import, division, print_function from __future__ import annotations
from ansible.module_utils.compat.version import LooseVersion
__metaclass__ = type __all__ = ("LooseVersion",)
import re
try:
RE_FLAGS = re.VERBOSE | re.ASCII
except AttributeError:
RE_FLAGS = re.VERBOSE
class Version:
"""Abstract base class for version numbering classes. Just provides
constructor (__init__) and reproducer (__repr__), because those
seem to be the same for all version numbering classes; and route
rich comparisons to _cmp.
"""
def __init__(self, vstring=None):
if vstring:
self.parse(vstring)
def __repr__(self):
return "%s ('%s')" % (self.__class__.__name__, str(self))
def __eq__(self, other):
c = self._cmp(other)
if c is NotImplemented:
return c
return c == 0
def __lt__(self, other):
c = self._cmp(other)
if c is NotImplemented:
return c
return c < 0
def __le__(self, other):
c = self._cmp(other)
if c is NotImplemented:
return c
return c <= 0
def __gt__(self, other):
c = self._cmp(other)
if c is NotImplemented:
return c
return c > 0
def __ge__(self, other):
c = self._cmp(other)
if c is NotImplemented:
return c
return c >= 0
# Interface for version-number classes -- must be implemented
# by the following classes (the concrete ones -- Version should
# be treated as an abstract class).
# __init__ (string) - create and take same action as 'parse'
# (string parameter is optional)
# parse (string) - convert a string representation to whatever
# internal representation is appropriate for
# this style of version numbering
# __str__ (self) - convert back to a string; should be very similar
# (if not identical to) the string supplied to parse
# __repr__ (self) - generate Python code to recreate
# the instance
# _cmp (self, other) - compare two version numbers ('other' may
# be an unparsed version string, or another
# instance of your version class)
class StrictVersion(Version):
"""Version numbering for anal retentives and software idealists.
Implements the standard interface for version number classes as
described above. A version number consists of two or three
dot-separated numeric components, with an optional "pre-release" tag
on the end. The pre-release tag consists of the letter 'a' or 'b'
followed by a number. If the numeric components of two version
numbers are equal, then one with a pre-release tag will always
be deemed earlier (lesser) than one without.
The following are valid version numbers (shown in the order that
would be obtained by sorting according to the supplied cmp function):
0.4 0.4.0 (these two are equivalent)
0.4.1
0.5a1
0.5b3
0.5
0.9.6
1.0
1.0.4a3
1.0.4b1
1.0.4
The following are examples of invalid version numbers:
1
2.7.2.2
1.3.a4
1.3pl1
1.3c4
The rationale for this version numbering system will be explained
in the distutils documentation.
"""
version_re = re.compile(r'^(\d+) \. (\d+) (\. (\d+))? ([ab](\d+))?$',
RE_FLAGS)
def parse(self, vstring):
match = self.version_re.match(vstring)
if not match:
raise ValueError("invalid version number '%s'" % vstring)
(major, minor, patch, prerelease, prerelease_num) = \
match.group(1, 2, 4, 5, 6)
if patch:
self.version = tuple(map(int, [major, minor, patch]))
else:
self.version = tuple(map(int, [major, minor])) + (0,)
if prerelease:
self.prerelease = (prerelease[0], int(prerelease_num))
else:
self.prerelease = None
def __str__(self):
if self.version[2] == 0:
vstring = '.'.join(map(str, self.version[0:2]))
else:
vstring = '.'.join(map(str, self.version))
if self.prerelease:
vstring = vstring + self.prerelease[0] + str(self.prerelease[1])
return vstring
def _cmp(self, other):
if isinstance(other, str):
other = StrictVersion(other)
elif not isinstance(other, StrictVersion):
return NotImplemented
if self.version != other.version:
# numeric versions don't match
# prerelease stuff doesn't matter
if self.version < other.version:
return -1
else:
return 1
# have to compare prerelease
# case 1: neither has prerelease; they're equal
# case 2: self has prerelease, other doesn't; other is greater
# case 3: self doesn't have prerelease, other does: self is greater
# case 4: both have prerelease: must compare them!
if (not self.prerelease and not other.prerelease):
return 0
elif (self.prerelease and not other.prerelease):
return -1
elif (not self.prerelease and other.prerelease):
return 1
elif (self.prerelease and other.prerelease):
if self.prerelease == other.prerelease:
return 0
elif self.prerelease < other.prerelease:
return -1
else:
return 1
else:
raise AssertionError("never get here")
# end class StrictVersion
# The rules according to Greg Stein:
# 1) a version number has 1 or more numbers separated by a period or by
# sequences of letters. If only periods, then these are compared
# left-to-right to determine an ordering.
# 2) sequences of letters are part of the tuple for comparison and are
# compared lexicographically
# 3) recognize the numeric components may have leading zeroes
#
# The LooseVersion class below implements these rules: a version number
# string is split up into a tuple of integer and string components, and
# comparison is a simple tuple comparison. This means that version
# numbers behave in a predictable and obvious way, but a way that might
# not necessarily be how people *want* version numbers to behave. There
# wouldn't be a problem if people could stick to purely numeric version
# numbers: just split on period and compare the numbers as tuples.
# However, people insist on putting letters into their version numbers;
# the most common purpose seems to be:
# - indicating a "pre-release" version
# ('alpha', 'beta', 'a', 'b', 'pre', 'p')
# - indicating a post-release patch ('p', 'pl', 'patch')
# but of course this can't cover all version number schemes, and there's
# no way to know what a programmer means without asking him.
#
# The problem is what to do with letters (and other non-numeric
# characters) in a version number. The current implementation does the
# obvious and predictable thing: keep them as strings and compare
# lexically within a tuple comparison. This has the desired effect if
# an appended letter sequence implies something "post-release":
# eg. "0.99" < "0.99pl14" < "1.0", and "5.001" < "5.001m" < "5.002".
#
# However, if letters in a version number imply a pre-release version,
# the "obvious" thing isn't correct. Eg. you would expect that
# "1.5.1" < "1.5.2a2" < "1.5.2", but under the tuple/lexical comparison
# implemented here, this just isn't so.
#
# Two possible solutions come to mind. The first is to tie the
# comparison algorithm to a particular set of semantic rules, as has
# been done in the StrictVersion class above. This works great as long
# as everyone can go along with bondage and discipline. Hopefully a
# (large) subset of Python module programmers will agree that the
# particular flavour of bondage and discipline provided by StrictVersion
# provides enough benefit to be worth using, and will submit their
# version numbering scheme to its domination. The free-thinking
# anarchists in the lot will never give in, though, and something needs
# to be done to accommodate them.
#
# Perhaps a "moderately strict" version class could be implemented that
# lets almost anything slide (syntactically), and makes some heuristic
# assumptions about non-digits in version number strings. This could
# sink into special-case-hell, though; if I was as talented and
# idiosyncratic as Larry Wall, I'd go ahead and implement a class that
# somehow knows that "1.2.1" < "1.2.2a2" < "1.2.2" < "1.2.2pl3", and is
# just as happy dealing with things like "2g6" and "1.13++". I don't
# think I'm smart enough to do it right though.
#
# In any case, I've coded the test suite for this module (see
# ../test/test_version.py) specifically to fail on things like comparing
# "1.2a2" and "1.2". That's not because the *code* is doing anything
# wrong, it's because the simple, obvious design doesn't match my
# complicated, hairy expectations for real-world version numbers. It
# would be a snap to fix the test suite to say, "Yep, LooseVersion does
# the Right Thing" (ie. the code matches the conception). But I'd rather
# have a conception that matches common notions about version numbers.
class LooseVersion(Version):
"""Version numbering for anarchists and software realists.
Implements the standard interface for version number classes as
described above. A version number consists of a series of numbers,
separated by either periods or strings of letters. When comparing
version numbers, the numeric components will be compared
numerically, and the alphabetic components lexically. The following
are all valid version numbers, in no particular order:
1.5.1
1.5.2b2
161
3.10a
8.02
3.4j
1996.07.12
3.2.pl0
3.1.1.6
2g6
11g
0.960923
2.2beta29
1.13++
5.5.kw
2.0b1pl0
In fact, there is no such thing as an invalid version number under
this scheme; the rules for comparison are simple and predictable,
but may not always give the results you want (for some definition
of "want").
"""
component_re = re.compile(r'(\d+ | [a-z]+ | \.)', re.VERBOSE)
def __init__(self, vstring=None):
if vstring:
self.parse(vstring)
def parse(self, vstring):
# I've given up on thinking I can reconstruct the version string
# from the parsed tuple -- so I just store the string here for
# use by __str__
self.vstring = vstring
components = [x for x in self.component_re.split(vstring) if x and x != '.']
for i, obj in enumerate(components):
try:
components[i] = int(obj)
except ValueError:
pass
self.version = components
def __str__(self):
return self.vstring
def __repr__(self):
return "LooseVersion ('%s')" % str(self)
def _cmp(self, other):
if isinstance(other, str):
other = LooseVersion(other)
elif not isinstance(other, LooseVersion):
return NotImplemented
if self.version == other.version:
return 0
if self.version < other.version:
return -1
if self.version > other.version:
return 1
# end class LooseVersion

View File

@@ -1,203 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import abc
import datetime
import re
from collections import namedtuple
from ansible.module_utils import six
from ansible.module_utils.common.text.converters import to_native
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
BackendException,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import (
OpenSSLObjectError,
)
from ansible_collections.community.crypto.plugins.module_utils.time import (
UTC,
ensure_utc_timezone,
from_epoch_seconds,
get_epoch_seconds,
get_now_datetime,
get_relative_time_option,
remove_timezone,
)
CertificateInformation = namedtuple(
"CertificateInformation",
(
"not_valid_after",
"not_valid_before",
"serial_number",
"subject_key_identifier",
"authority_key_identifier",
),
)
_FRACTIONAL_MATCHER = re.compile(
r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(|\.\d+)(Z|[+-]\d{2}:?\d{2}.*)$"
)
def _reduce_fractional_digits(timestamp_str):
"""
Given a RFC 3339 timestamp that includes too many digits for the fractional seconds part, reduces these to at most 6.
"""
# RFC 3339 (https://www.rfc-editor.org/info/rfc3339)
m = _FRACTIONAL_MATCHER.match(timestamp_str)
if not m:
raise BackendException(
"Cannot parse ISO 8601 timestamp {0!r}".format(timestamp_str)
)
timestamp, fractional, timezone = m.groups()
if len(fractional) > 7:
# Python does not support anything smaller than microseconds
# (Golang supports nanoseconds, Boulder often emits more fractional digits, which Python chokes on)
fractional = fractional[:7]
return "%s%s%s" % (timestamp, fractional, timezone)
def _parse_acme_timestamp(timestamp_str, with_timezone):
"""
Parses a RFC 3339 timestamp.
"""
# RFC 3339 (https://www.rfc-editor.org/info/rfc3339)
timestamp_str = _reduce_fractional_digits(timestamp_str)
for format in (
"%Y-%m-%dT%H:%M:%SZ",
"%Y-%m-%dT%H:%M:%S.%fZ",
"%Y-%m-%dT%H:%M:%S%z",
"%Y-%m-%dT%H:%M:%S.%f%z",
):
# Note that %z will not work with Python 2... https://stackoverflow.com/a/27829491
try:
result = datetime.datetime.strptime(timestamp_str, format)
except ValueError:
pass
else:
return (
ensure_utc_timezone(result)
if with_timezone
else remove_timezone(result)
)
raise BackendException(
"Cannot parse ISO 8601 timestamp {0!r}".format(timestamp_str)
)
@six.add_metaclass(abc.ABCMeta)
class CryptoBackend(object):
def __init__(self, module, with_timezone=False):
self.module = module
self._with_timezone = with_timezone
def get_now(self):
return get_now_datetime(with_timezone=self._with_timezone)
def parse_acme_timestamp(self, timestamp_str):
# RFC 3339 (https://www.rfc-editor.org/info/rfc3339)
return _parse_acme_timestamp(timestamp_str, with_timezone=self._with_timezone)
def parse_module_parameter(self, value, name):
try:
return get_relative_time_option(
value, name, backend="cryptography", with_timezone=self._with_timezone
)
except OpenSSLObjectError as exc:
raise BackendException(to_native(exc))
def interpolate_timestamp(self, timestamp_start, timestamp_end, percentage):
start = get_epoch_seconds(timestamp_start)
end = get_epoch_seconds(timestamp_end)
return from_epoch_seconds(
start + percentage * (end - start), with_timezone=self._with_timezone
)
def get_utc_datetime(self, *args, **kwargs):
kwargs_ext = dict(kwargs)
if self._with_timezone and ("tzinfo" not in kwargs_ext and len(args) < 8):
kwargs_ext["tzinfo"] = UTC
result = datetime.datetime(*args, **kwargs_ext)
if self._with_timezone and ("tzinfo" in kwargs or len(args) >= 8):
result = ensure_utc_timezone(result)
return result
@abc.abstractmethod
def parse_key(self, key_file=None, key_content=None, passphrase=None):
"""
Parses an RSA or Elliptic Curve key file in PEM format and returns key_data.
Raises KeyParsingError in case of errors.
"""
@abc.abstractmethod
def sign(self, payload64, protected64, key_data):
pass
@abc.abstractmethod
def create_mac_key(self, alg, key):
"""Create a MAC key."""
def get_ordered_csr_identifiers(self, csr_filename=None, csr_content=None):
"""
Return a list of requested identifiers (CN and SANs) for the CSR.
Each identifier is a pair (type, identifier), where type is either
'dns' or 'ip'.
The list is deduplicated, and if a CNAME is present, it will be returned
as the first element in the result.
"""
self.module.deprecate(
"Every backend must override the get_ordered_csr_identifiers() method."
" The default implementation will be removed in 3.0.0 and this method will be marked as `abstractmethod` by then.",
version="3.0.0",
collection_name="community.crypto",
)
return sorted(
self.get_csr_identifiers(csr_filename=csr_filename, csr_content=csr_content)
)
@abc.abstractmethod
def get_csr_identifiers(self, csr_filename=None, csr_content=None):
"""
Return a set of requested identifiers (CN and SANs) for the CSR.
Each identifier is a pair (type, identifier), where type is either
'dns' or 'ip'.
"""
@abc.abstractmethod
def get_cert_days(self, cert_filename=None, cert_content=None, now=None):
"""
Return the days the certificate in cert_filename remains valid and -1
if the file was not found. If cert_filename contains more than one
certificate, only the first one will be considered.
If now is not specified, datetime.datetime.now() is used.
"""
@abc.abstractmethod
def create_chain_matcher(self, criterium):
"""
Given a Criterium object, creates a ChainMatcher object.
"""
def get_cert_information(self, cert_filename=None, cert_content=None):
"""
Return some information on a X.509 certificate as a CertificateInformation object.
"""
# Not implementing this method in a backend is DEPRECATED and will be
# disallowed in community.crypto 3.0.0. This method will be marked as
# @abstractmethod by then.
raise BackendException("This backend does not support get_cert_information()")

View File

@@ -1,337 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2024 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import os
from ansible_collections.community.crypto.plugins.module_utils.acme.account import (
ACMEAccount,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
ACMEClient,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.certificates import (
CertificateChain,
Criterium,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.challenges import (
Authorization,
wait_for_validation,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
ModuleFailException,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.io import write_file
from ansible_collections.community.crypto.plugins.module_utils.acme.orders import Order
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
pem_to_der,
)
class ACMECertificateClient(object):
"""
ACME v2 client class. Uses an ACME account object and a CSR to
start and validate ACME challenges and download the respective
certificates.
"""
def __init__(self, module, backend, client=None, account=None):
self.module = module
self.version = module.params["acme_version"]
self.csr = module.params.get("csr")
self.csr_content = module.params.get("csr_content")
if client is None:
client = ACMEClient(module, backend)
self.client = client
if account is None:
account = ACMEAccount(self.client)
self.account = account
self.order_uri = module.params.get("order_uri")
self.order_creation_error_strategy = module.params.get(
"order_creation_error_strategy", "auto"
)
self.order_creation_max_retries = module.params.get(
"order_creation_max_retries", 3
)
# Make sure account exists
dummy, account_data = self.account.setup_account(allow_creation=False)
if account_data is None:
raise ModuleFailException(msg="Account does not exist or is deactivated.")
if self.csr is not None and not os.path.exists(self.csr):
raise ModuleFailException("CSR %s not found" % (self.csr))
# Extract list of identifiers from CSR
if self.csr is not None or self.csr_content is not None:
self.identifiers = self.client.backend.get_ordered_csr_identifiers(
csr_filename=self.csr, csr_content=self.csr_content
)
else:
self.identifiers = None
def parse_select_chain(self, select_chain):
select_chain_matcher = []
if select_chain:
for criterium_idx, criterium in enumerate(select_chain):
try:
select_chain_matcher.append(
self.client.backend.create_chain_matcher(
Criterium(criterium, index=criterium_idx)
)
)
except ValueError as exc:
self.module.warn(
"Error while parsing criterium: {error}. Ignoring criterium.".format(
error=exc
)
)
return select_chain_matcher
def load_order(self):
if not self.order_uri:
raise ModuleFailException("The order URI has not been provided")
order = Order.from_url(self.client, self.order_uri)
order.load_authorizations(self.client)
return order
def create_order(self, replaces_cert_id=None, profile=None):
"""
Create a new order.
"""
if self.identifiers is None:
raise ModuleFailException("No identifiers have been provided")
order = Order.create_with_error_handling(
self.client,
self.identifiers,
error_strategy=self.order_creation_error_strategy,
error_max_retries=self.order_creation_max_retries,
replaces_cert_id=replaces_cert_id,
profile=profile,
message_callback=self.module.warn,
)
self.order_uri = order.url
order.load_authorizations(self.client)
return order
def get_challenges_data(self, order):
"""
Get challenge details.
Return a tuple of generic challenge details, and specialized DNS challenge details.
"""
# Get general challenge data
data = []
for authz in order.authorizations.values():
# Skip valid authentications: their challenges are already valid
# and do not need to be returned
if authz.status == "valid":
continue
data.append(
dict(
identifier=authz.identifier,
identifier_type=authz.identifier_type,
challenges=authz.get_challenge_data(self.client),
)
)
# Get DNS challenge data
data_dns = {}
dns_challenge_type = "dns-01"
for entry in data:
dns_challenge = entry["challenges"].get(dns_challenge_type)
if dns_challenge:
values = data_dns.get(dns_challenge["record"])
if values is None:
values = []
data_dns[dns_challenge["record"]] = values
values.append(dns_challenge["resource_value"])
return data, data_dns
def check_that_authorizations_can_be_used(self, order):
bad_authzs = []
for authz in order.authorizations.values():
if authz.status not in ("valid", "pending"):
bad_authzs.append(
"{authz} (status={status!r})".format(
authz=authz.combined_identifier,
status=authz.status,
)
)
if bad_authzs:
raise ModuleFailException(
"Some of the authorizations for the order are in a bad state, so the order"
" can no longer be satisfied: {bad_authzs}".format(
bad_authzs=", ".join(sorted(bad_authzs)),
),
)
def collect_invalid_authzs(self, order):
return [
authz
for authz in order.authorizations.values()
if authz.status == "invalid"
]
def collect_pending_authzs(self, order):
return [
authz
for authz in order.authorizations.values()
if authz.status == "pending"
]
def call_validate(self, pending_authzs, get_challenge, wait=True):
authzs_with_challenges_to_wait_for = []
for authz in pending_authzs:
challenge_type = get_challenge(authz)
authz.call_validate(self.client, challenge_type, wait=wait)
authzs_with_challenges_to_wait_for.append(
(authz, challenge_type, authz.find_challenge(challenge_type))
)
return authzs_with_challenges_to_wait_for
def wait_for_validation(self, authzs_to_wait_for):
wait_for_validation(authzs_to_wait_for, self.client)
def _download_alternate_chains(self, cert):
alternate_chains = []
for alternate in cert.alternates:
try:
alt_cert = CertificateChain.download(self.client, alternate)
except ModuleFailException as e:
self.module.warn(
"Error while downloading alternative certificate {0}: {1}".format(
alternate, e
)
)
continue
if alt_cert.cert is not None:
alternate_chains.append(alt_cert)
else:
self.module.warn(
"Error while downloading alternative certificate {0}: no certificate found".format(
alternate
)
)
return alternate_chains
def download_certificate(self, order, download_all_chains=True):
"""
Download certificate from a valid oder.
"""
if order.status != "valid":
raise ModuleFailException(
"The order must be valid, but has state {state!r}!".format(
state=order.state
)
)
if not order.certificate_uri:
raise ModuleFailException(
"Order's crtificate URL {url!r} is empty!".format(
url=order.certificate_uri
)
)
cert = CertificateChain.download(self.client, order.certificate_uri)
if cert.cert is None:
raise ModuleFailException(
"Certificate at {url} is empty!".format(url=order.certificate_uri)
)
alternate_chains = None
if download_all_chains:
alternate_chains = self._download_alternate_chains(cert)
return cert, alternate_chains
def get_certificate(self, order, download_all_chains=True):
"""
Request a new certificate and downloads it, and optionally all certificate chains.
First verifies whether all authorizations are valid; if not, aborts with an error.
"""
if self.csr is None and self.csr_content is None:
raise ModuleFailException("No CSR has been provided")
for identifier, authz in order.authorizations.items():
if authz.status != "valid":
authz.raise_error(
'Status is {status!r} and not "valid"'.format(status=authz.status),
module=self.module,
)
order.finalize(self.client, pem_to_der(self.csr, self.csr_content))
return self.download_certificate(order, download_all_chains=download_all_chains)
def find_matching_chain(self, chains, select_chain_matcher):
for criterium_idx, matcher in enumerate(select_chain_matcher):
for chain in chains:
if matcher.match(chain):
self.module.debug(
"Found matching chain for criterium {0}".format(criterium_idx)
)
return chain
return None
def write_cert_chain(
self, cert, cert_dest=None, fullchain_dest=None, chain_dest=None
):
changed = False
if cert_dest and write_file(self.module, cert_dest, cert.cert.encode("utf8")):
changed = True
if fullchain_dest and write_file(
self.module,
fullchain_dest,
(cert.cert + "\n".join(cert.chain)).encode("utf8"),
):
changed = True
if chain_dest and write_file(
self.module, chain_dest, ("\n".join(cert.chain)).encode("utf8")
):
changed = True
return changed
def deactivate_authzs(self, order):
"""
Deactivates all valid authz's. Does not raise exceptions.
https://community.letsencrypt.org/t/authorization-deactivation/19860/2
https://tools.ietf.org/html/rfc8555#section-7.5.2
"""
if len(order.authorization_uris) > len(order.authorizations):
for authz_uri in order.authorization_uris:
authz = None
try:
authz = Authorization.deactivate_url(self.client, authz_uri)
except Exception:
# ignore errors
pass
if authz is None or authz.status != "deactivated":
self.module.warn(
warning="Could not deactivate authz object {0}.".format(
authz_uri
)
)
else:
for authz in order.authorizations.values():
try:
authz.deactivate(self.client)
except Exception:
# ignore errors
pass
if authz.status != "deactivated":
self.module.warn(
warning="Could not deactivate authz object {0}.".format(
authz.url
)
)

View File

@@ -1,148 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import abc
from ansible.module_utils import six
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
ModuleFailException,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
der_to_pem,
nopad_b64,
process_links,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import (
split_pem_list,
)
class CertificateChain(object):
"""
Download and parse the certificate chain.
https://tools.ietf.org/html/rfc8555#section-7.4.2
"""
def __init__(self, url):
self.url = url
self.cert = None
self.chain = []
self.alternates = []
@classmethod
def download(cls, client, url):
content, info = client.get_request(
url,
parse_json_result=False,
headers={"Accept": "application/pem-certificate-chain"},
)
if not content or not info["content-type"].startswith(
"application/pem-certificate-chain"
):
raise ModuleFailException(
"Cannot download certificate chain from {0}, as content type is not application/pem-certificate-chain: {1} (headers: {2})".format(
url, content, info
)
)
result = cls(url)
# Parse data
certs = split_pem_list(content.decode("utf-8"), keep_inbetween=True)
if certs:
result.cert = certs[0]
result.chain = certs[1:]
process_links(
info, lambda link, relation: result._process_links(client, link, relation)
)
if result.cert is None:
raise ModuleFailException(
"Failed to parse certificate chain download from {0}: {1} (headers: {2})".format(
url, content, info
)
)
return result
def _process_links(self, client, link, relation):
if relation == "up":
# Process link-up headers if there was no chain in reply
if not self.chain:
chain_result, chain_info = client.get_request(
link, parse_json_result=False
)
if chain_info["status"] in [200, 201]:
self.chain.append(der_to_pem(chain_result))
elif relation == "alternate":
self.alternates.append(link)
def to_json(self):
cert = self.cert.encode("utf8")
chain = ("\n".join(self.chain)).encode("utf8")
return {
"cert": cert,
"chain": chain,
"full_chain": cert + chain,
}
class Criterium(object):
def __init__(self, criterium, index=None):
self.index = index
self.test_certificates = criterium["test_certificates"]
self.subject = criterium["subject"]
self.issuer = criterium["issuer"]
self.subject_key_identifier = criterium["subject_key_identifier"]
self.authority_key_identifier = criterium["authority_key_identifier"]
@six.add_metaclass(abc.ABCMeta)
class ChainMatcher(object):
@abc.abstractmethod
def match(self, certificate):
"""
Check whether a certificate chain (CertificateChain instance) matches.
"""
def retrieve_acme_v1_certificate(client, csr_der):
"""
Create a new certificate based on the CSR (ACME v1 protocol).
Return the certificate object as dict
https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.5
"""
new_cert = {
"resource": "new-cert",
"csr": nopad_b64(csr_der),
}
result, info = client.send_signed_request(
client.directory["new-cert"],
new_cert,
error_msg="Failed to receive certificate",
expected_status_codes=[200, 201],
)
cert = CertificateChain(info["location"])
cert.cert = der_to_pem(result)
def f(link, relation):
if relation == "up":
chain_result, chain_info = client.get_request(link, parse_json_result=False)
if chain_info["status"] in [200, 201]:
del cert.chain[:]
cert.chain.append(der_to_pem(chain_result))
process_links(info, f)
return cert

View File

@@ -1,384 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import base64
import hashlib
import json
import re
import time
from ansible.module_utils.common.text.converters import to_bytes
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
ACMEProtocolException,
ModuleFailException,
format_error_problem,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
nopad_b64,
)
try:
import ipaddress
except ImportError:
pass
def create_key_authorization(client, token):
"""
Returns the key authorization for the given token
https://tools.ietf.org/html/rfc8555#section-8.1
"""
accountkey_json = json.dumps(
client.account_jwk, sort_keys=True, separators=(",", ":")
)
thumbprint = nopad_b64(hashlib.sha256(accountkey_json.encode("utf8")).digest())
return "{0}.{1}".format(token, thumbprint)
def combine_identifier(identifier_type, identifier):
return "{type}:{identifier}".format(type=identifier_type, identifier=identifier)
def normalize_combined_identifier(identifier):
identifier_type, identifier = split_identifier(identifier)
# Normalize DNS names and IPs
identifier = identifier.lower()
return combine_identifier(identifier_type, identifier)
def split_identifier(identifier):
parts = identifier.split(":", 1)
if len(parts) != 2:
raise ModuleFailException(
'Identifier "{identifier}" is not of the form <type>:<identifier>'.format(
identifier=identifier
)
)
return parts
class Challenge(object):
def __init__(self, data, url):
self.data = data
self.type = data["type"]
self.url = url
self.status = data["status"]
self.token = data.get("token")
@classmethod
def from_json(cls, client, data, url=None):
return cls(data, url or (data["uri"] if client.version == 1 else data["url"]))
def call_validate(self, client):
challenge_response = {}
if client.version == 1:
token = re.sub(r"[^A-Za-z0-9_\-]", "_", self.token)
key_authorization = create_key_authorization(client, token)
challenge_response["resource"] = "challenge"
challenge_response["keyAuthorization"] = key_authorization
challenge_response["type"] = self.type
client.send_signed_request(
self.url,
challenge_response,
error_msg="Failed to validate challenge",
expected_status_codes=[200, 202],
)
def to_json(self):
return self.data.copy()
def get_validation_data(self, client, identifier_type, identifier):
token = re.sub(r"[^A-Za-z0-9_\-]", "_", self.token)
key_authorization = create_key_authorization(client, token)
if self.type == "http-01":
# https://tools.ietf.org/html/rfc8555#section-8.3
return {
"resource": ".well-known/acme-challenge/{token}".format(token=token),
"resource_value": key_authorization,
}
if self.type == "dns-01":
if identifier_type != "dns":
return None
# https://tools.ietf.org/html/rfc8555#section-8.4
resource = "_acme-challenge"
value = nopad_b64(hashlib.sha256(to_bytes(key_authorization)).digest())
record = "{0}.{1}".format(
resource, identifier[2:] if identifier.startswith("*.") else identifier
)
return {
"resource": resource,
"resource_value": value,
"record": record,
}
if self.type == "tls-alpn-01":
# https://www.rfc-editor.org/rfc/rfc8737.html#section-3
if identifier_type == "ip":
# IPv4/IPv6 address: use reverse mapping (RFC1034, RFC3596)
resource = ipaddress.ip_address(identifier).reverse_pointer
if not resource.endswith("."):
resource += "."
else:
resource = identifier
value = base64.b64encode(
hashlib.sha256(to_bytes(key_authorization)).digest()
)
return {
"resource": resource,
"resource_original": combine_identifier(identifier_type, identifier),
"resource_value": value,
}
# Unknown challenge type: ignore
return None
class Authorization(object):
def _setup(self, client, data):
data["uri"] = self.url
self.data = data
# While 'challenges' is a required field, apparently not every CA cares
# (https://github.com/ansible-collections/community.crypto/issues/824)
if data.get("challenges"):
self.challenges = [
Challenge.from_json(client, challenge)
for challenge in data["challenges"]
]
else:
self.challenges = []
if client.version == 1 and "status" not in data:
# https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.1.2
# "status (required, string): ...
# If this field is missing, then the default value is "pending"."
self.status = "pending"
else:
self.status = data["status"]
self.identifier = data["identifier"]["value"]
self.identifier_type = data["identifier"]["type"]
if data.get("wildcard", False):
self.identifier = "*.{0}".format(self.identifier)
def __init__(self, url):
self.url = url
self.data = None
self.challenges = []
self.status = None
self.identifier_type = None
self.identifier = None
@classmethod
def from_json(cls, client, data, url):
result = cls(url)
result._setup(client, data)
return result
@classmethod
def from_url(cls, client, url):
result = cls(url)
result.refresh(client)
return result
@classmethod
def create(cls, client, identifier_type, identifier):
"""
Create a new authorization for the given identifier.
Return the authorization object of the new authorization
https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.4
"""
new_authz = {
"identifier": {
"type": identifier_type,
"value": identifier,
},
}
if client.version == 1:
url = client.directory["new-authz"]
new_authz["resource"] = "new-authz"
else:
if "newAuthz" not in client.directory.directory:
raise ACMEProtocolException(
client.module, "ACME endpoint does not support pre-authorization"
)
url = client.directory["newAuthz"]
result, info = client.send_signed_request(
url,
new_authz,
error_msg="Failed to request challenges",
expected_status_codes=[200, 201],
)
return cls.from_json(client, result, info["location"])
@property
def combined_identifier(self):
return combine_identifier(self.identifier_type, self.identifier)
def to_json(self):
return self.data.copy()
def refresh(self, client):
result, dummy = client.get_request(self.url)
changed = self.data != result
self._setup(client, result)
return changed
def get_challenge_data(self, client):
"""
Returns a dict with the data for all proposed (and supported) challenges
of the given authorization.
"""
data = {}
for challenge in self.challenges:
validation_data = challenge.get_validation_data(
client, self.identifier_type, self.identifier
)
if validation_data is not None:
data[challenge.type] = validation_data
return data
def raise_error(self, error_msg, module=None):
"""
Aborts with a specific error for a challenge.
"""
error_details = []
# multiple challenges could have failed at this point, gather error
# details for all of them before failing
for challenge in self.challenges:
if challenge.status == "invalid":
msg = "Challenge {type}".format(type=challenge.type)
if "error" in challenge.data:
msg = "{msg}: {problem}".format(
msg=msg,
problem=format_error_problem(
challenge.data["error"],
subproblem_prefix="{0}.".format(challenge.type),
),
)
error_details.append(msg)
raise ACMEProtocolException(
module,
"Failed to validate challenge for {identifier}: {error}. {details}".format(
identifier=self.combined_identifier,
error=error_msg,
details="; ".join(error_details),
),
extras=dict(
identifier=self.combined_identifier,
authorization=self.data,
),
)
def find_challenge(self, challenge_type):
for challenge in self.challenges:
if challenge_type == challenge.type:
return challenge
return None
def wait_for_validation(self, client, callenge_type):
while True:
self.refresh(client)
if self.status in ["valid", "invalid", "revoked"]:
break
time.sleep(2)
if self.status == "invalid":
self.raise_error('Status is "invalid"', module=client.module)
return self.status == "valid"
def call_validate(self, client, challenge_type, wait=True):
"""
Validate the authorization provided in the auth dict. Returns True
when the validation was successful and False when it was not.
"""
challenge = self.find_challenge(challenge_type)
if challenge is None:
raise ModuleFailException(
'Found no challenge of type "{challenge}" for identifier {identifier}!'.format(
challenge=challenge_type,
identifier=self.combined_identifier,
)
)
challenge.call_validate(client)
if not wait:
return self.status == "valid"
return self.wait_for_validation(client, challenge_type)
def can_deactivate(self):
"""
Deactivates this authorization.
https://community.letsencrypt.org/t/authorization-deactivation/19860/2
https://tools.ietf.org/html/rfc8555#section-7.5.2
"""
return self.status in ("valid", "pending")
def deactivate(self, client):
"""
Deactivates this authorization.
https://community.letsencrypt.org/t/authorization-deactivation/19860/2
https://tools.ietf.org/html/rfc8555#section-7.5.2
"""
if not self.can_deactivate():
return
authz_deactivate = {"status": "deactivated"}
if client.version == 1:
authz_deactivate["resource"] = "authz"
result, info = client.send_signed_request(
self.url, authz_deactivate, fail_on_error=False
)
if 200 <= info["status"] < 300 and result.get("status") == "deactivated":
self.status = "deactivated"
return True
return False
@classmethod
def deactivate_url(cls, client, url):
"""
Deactivates this authorization.
https://community.letsencrypt.org/t/authorization-deactivation/19860/2
https://tools.ietf.org/html/rfc8555#section-7.5.2
"""
authz = cls(url)
authz_deactivate = {"status": "deactivated"}
if client.version == 1:
authz_deactivate["resource"] = "authz"
result, info = client.send_signed_request(
url, authz_deactivate, fail_on_error=True
)
authz._setup(client, result)
return authz
def wait_for_validation(authzs, client):
"""
Wait until a list of authz is valid. Fail if at least one of them is invalid or revoked.
"""
while authzs:
authzs_next = []
for authz in authzs:
authz.refresh(client)
if authz.status in ["valid", "invalid", "revoked"]:
if authz.status != "valid":
authz.raise_error('Status is not "valid"', module=client.module)
else:
authzs_next.append(authz)
if authzs_next:
time.sleep(2)
authzs = authzs_next

View File

@@ -1,190 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
from ansible.module_utils.common.text.converters import to_text
from ansible.module_utils.six import PY3, binary_type
from ansible.module_utils.six.moves.http_client import responses as http_responses
def format_http_status(status_code):
expl = http_responses.get(status_code)
if not expl:
return str(status_code)
return "%d %s" % (status_code, expl)
def format_error_problem(problem, subproblem_prefix=""):
error_type = problem.get(
"type", "about:blank"
) # https://www.rfc-editor.org/rfc/rfc7807#section-3.1
if "title" in problem:
msg = 'Error "{title}" ({type})'.format(
type=error_type,
title=problem["title"],
)
else:
msg = "Error {type}".format(type=error_type)
if "detail" in problem:
msg += ': "{detail}"'.format(detail=problem["detail"])
subproblems = problem.get("subproblems")
if subproblems is not None:
msg = "{msg} Subproblems:".format(msg=msg)
for index, problem in enumerate(subproblems):
index_str = "{prefix}{index}".format(prefix=subproblem_prefix, index=index)
msg = "{msg}\n({index}) {problem}".format(
msg=msg,
index=index_str,
problem=format_error_problem(
problem, subproblem_prefix="{0}.".format(index_str)
),
)
return msg
class ModuleFailException(Exception):
"""
If raised, module.fail_json() will be called with the given parameters after cleanup.
"""
def __init__(self, msg, **args):
super(ModuleFailException, self).__init__(self, msg)
self.msg = msg
self.module_fail_args = args
def do_fail(self, module, **arguments):
module.fail_json(msg=self.msg, other=self.module_fail_args, **arguments)
class ACMEProtocolException(ModuleFailException):
def __init__(
self,
module,
msg=None,
info=None,
response=None,
content=None,
content_json=None,
extras=None,
):
# Try to get hold of content, if response is given and content is not provided
if content is None and content_json is None and response is not None:
try:
# In Python 2, reading from a closed response yields a TypeError.
# In Python 3, read() simply returns ''
if PY3 and response.closed:
raise TypeError
content = response.read()
except (AttributeError, TypeError):
content = info.pop("body", None)
# Make sure that content_json is None or a dictionary
if content_json is not None and not isinstance(content_json, dict):
if content is None and isinstance(content_json, binary_type):
content = content_json
content_json = None
# Try to get hold of JSON decoded content, when content is given and JSON not provided
if content_json is None and content is not None and module is not None:
try:
content_json = module.from_json(to_text(content))
except Exception:
pass
extras = extras or dict()
error_code = None
error_type = None
if msg is None:
msg = "ACME request failed"
add_msg = ""
if info is not None:
url = info["url"]
code = info["status"]
extras["http_url"] = url
extras["http_status"] = code
error_code = code
if (
code is not None
and code >= 400
and content_json is not None
and "type" in content_json
):
error_type = content_json["type"]
if "status" in content_json and content_json["status"] != code:
code_msg = (
"status {problem_code} (HTTP status: {http_code})".format(
http_code=format_http_status(code),
problem_code=content_json["status"],
)
)
else:
code_msg = "status {problem_code}".format(
problem_code=format_http_status(code)
)
if code == -1 and info.get("msg"):
code_msg = "error: {msg}".format(msg=info["msg"])
subproblems = content_json.pop("subproblems", None)
add_msg = " {problem}.".format(
problem=format_error_problem(content_json)
)
extras["problem"] = content_json
extras["subproblems"] = subproblems or []
if subproblems is not None:
add_msg = "{add_msg} Subproblems:".format(add_msg=add_msg)
for index, problem in enumerate(subproblems):
add_msg = "{add_msg}\n({index}) {problem}.".format(
add_msg=add_msg,
index=index,
problem=format_error_problem(
problem, subproblem_prefix="{0}.".format(index)
),
)
else:
code_msg = "HTTP status {code}".format(code=format_http_status(code))
if code == -1 and info.get("msg"):
code_msg = "error: {msg}".format(msg=info["msg"])
if content_json is not None:
add_msg = " The JSON error result: {content}".format(
content=content_json
)
elif content is not None:
add_msg = " The raw error result: {content}".format(
content=to_text(content)
)
msg = "{msg} for {url} with {code}".format(msg=msg, url=url, code=code_msg)
elif content_json is not None:
add_msg = " The JSON result: {content}".format(content=content_json)
elif content is not None:
add_msg = " The raw result: {content}".format(content=to_text(content))
super(ACMEProtocolException, self).__init__(
"{msg}.{add_msg}".format(msg=msg, add_msg=add_msg), **extras
)
self.problem = {}
self.subproblems = []
self.error_code = error_code
self.error_type = error_type
for k, v in extras.items():
setattr(self, k, v)
class BackendException(ModuleFailException):
pass
class NetworkException(ModuleFailException):
pass
class KeyParsingError(ModuleFailException):
pass

View File

@@ -1,93 +0,0 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2020, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
from ansible.module_utils.basic import AnsibleModule
def _ensure_list(value):
if value is None:
return []
return list(value)
class ArgumentSpec:
def __init__(
self,
argument_spec=None,
mutually_exclusive=None,
required_together=None,
required_one_of=None,
required_if=None,
required_by=None,
):
self.argument_spec = argument_spec or {}
self.mutually_exclusive = _ensure_list(mutually_exclusive)
self.required_together = _ensure_list(required_together)
self.required_one_of = _ensure_list(required_one_of)
self.required_if = _ensure_list(required_if)
self.required_by = required_by or {}
def update_argspec(self, **kwargs):
self.argument_spec.update(kwargs)
return self
def update(
self,
mutually_exclusive=None,
required_together=None,
required_one_of=None,
required_if=None,
required_by=None,
):
if mutually_exclusive:
self.mutually_exclusive.extend(mutually_exclusive)
if required_together:
self.required_together.extend(required_together)
if required_one_of:
self.required_one_of.extend(required_one_of)
if required_if:
self.required_if.extend(required_if)
if required_by:
for k, v in required_by.items():
if k in self.required_by:
v = list(self.required_by[k]) + list(v)
self.required_by[k] = v
return self
def merge(self, other):
self.update_argspec(**other.argument_spec)
self.update(
mutually_exclusive=other.mutually_exclusive,
required_together=other.required_together,
required_one_of=other.required_one_of,
required_if=other.required_if,
required_by=other.required_by,
)
return self
def create_ansible_module_helper(self, clazz, args, **kwargs):
return clazz(
*args,
argument_spec=self.argument_spec,
mutually_exclusive=self.mutually_exclusive,
required_together=self.required_together,
required_one_of=self.required_one_of,
required_if=self.required_if,
required_by=self.required_by,
**kwargs
)
def create_ansible_module(self, **kwargs):
return self.create_ansible_module_helper(AnsibleModule, (), **kwargs)
__all__ = ("ArgumentSpec",)

View File

@@ -1,40 +0,0 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2019, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
from ._objects_data import OID_MAP
OID_LOOKUP = dict()
NORMALIZE_NAMES = dict()
NORMALIZE_NAMES_SHORT = dict()
for dotted, names in OID_MAP.items():
for name in names:
if name in NORMALIZE_NAMES and OID_LOOKUP[name] != dotted:
raise AssertionError(
'Name collision during setup: "{0}" for OIDs {1} and {2}'.format(
name, dotted, OID_LOOKUP[name]
)
)
NORMALIZE_NAMES[name] = names[0]
NORMALIZE_NAMES_SHORT[name] = names[-1]
OID_LOOKUP[name] = dotted
for alias, original in [("userID", "userId")]:
if alias in NORMALIZE_NAMES:
raise AssertionError(
'Name collision during adding aliases: "{0}" (alias for "{1}") is already mapped to OID {2}'.format(
alias, original, OID_LOOKUP[alias]
)
)
NORMALIZE_NAMES[alias] = original
NORMALIZE_NAMES_SHORT[alias] = NORMALIZE_NAMES_SHORT[original]
OID_LOOKUP[alias] = OID_LOOKUP[original]

View File

@@ -1,171 +0,0 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2016, Yanis Guenane <yanis+ansible@guenane.org>
# Copyright (c) 2020, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
from ansible_collections.community.crypto.plugins.module_utils.version import (
LooseVersion,
)
try:
import cryptography
from cryptography import x509
# Older versions of cryptography (< 2.1) do not have __hash__ functions for
# general name objects (DNSName, IPAddress, ...), while providing overloaded
# equality and string representation operations. This makes it impossible to
# use them in hash-based data structures such as set or dict. Since we are
# actually doing that in x509_certificate, and potentially in other code,
# we need to monkey-patch __hash__ for these classes to make sure our code
# works fine.
if LooseVersion(cryptography.__version__) < LooseVersion("2.1"):
# A very simply hash function which relies on the representation
# of an object to be implemented. This is the case since at least
# cryptography 1.0, see
# https://github.com/pyca/cryptography/commit/7a9abce4bff36c05d26d8d2680303a6f64a0e84f
def simple_hash(self):
return hash(repr(self))
# The hash functions for the following types were added for cryptography 2.1:
# https://github.com/pyca/cryptography/commit/fbfc36da2a4769045f2373b004ddf0aff906cf38
x509.DNSName.__hash__ = simple_hash
x509.DirectoryName.__hash__ = simple_hash
x509.GeneralName.__hash__ = simple_hash
x509.IPAddress.__hash__ = simple_hash
x509.OtherName.__hash__ = simple_hash
x509.RegisteredID.__hash__ = simple_hash
if LooseVersion(cryptography.__version__) < LooseVersion("1.2"):
# The hash functions for the following types were added for cryptography 1.2:
# https://github.com/pyca/cryptography/commit/b642deed88a8696e5f01ce6855ccf89985fc35d0
# https://github.com/pyca/cryptography/commit/d1b5681f6db2bde7a14625538bd7907b08dfb486
x509.RFC822Name.__hash__ = simple_hash
x509.UniformResourceIdentifier.__hash__ = simple_hash
# Test whether we have support for DSA, EC, Ed25519, Ed448, RSA, X25519 and/or X448
try:
# added in 0.5 - https://cryptography.io/en/latest/hazmat/primitives/asymmetric/dsa/
import cryptography.hazmat.primitives.asymmetric.dsa
CRYPTOGRAPHY_HAS_DSA = True
try:
# added later in 1.5
cryptography.hazmat.primitives.asymmetric.dsa.DSAPrivateKey.sign
CRYPTOGRAPHY_HAS_DSA_SIGN = True
except AttributeError:
CRYPTOGRAPHY_HAS_DSA_SIGN = False
except ImportError:
CRYPTOGRAPHY_HAS_DSA = False
CRYPTOGRAPHY_HAS_DSA_SIGN = False
try:
# added in 2.6 - https://cryptography.io/en/latest/hazmat/primitives/asymmetric/ed25519/
import cryptography.hazmat.primitives.asymmetric.ed25519
CRYPTOGRAPHY_HAS_ED25519 = True
try:
# added with the primitive in 2.6
cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey.sign
CRYPTOGRAPHY_HAS_ED25519_SIGN = True
except AttributeError:
CRYPTOGRAPHY_HAS_ED25519_SIGN = False
except ImportError:
CRYPTOGRAPHY_HAS_ED25519 = False
CRYPTOGRAPHY_HAS_ED25519_SIGN = False
try:
# added in 2.6 - https://cryptography.io/en/latest/hazmat/primitives/asymmetric/ed448/
import cryptography.hazmat.primitives.asymmetric.ed448
CRYPTOGRAPHY_HAS_ED448 = True
try:
# added with the primitive in 2.6
cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey.sign
CRYPTOGRAPHY_HAS_ED448_SIGN = True
except AttributeError:
CRYPTOGRAPHY_HAS_ED448_SIGN = False
except ImportError:
CRYPTOGRAPHY_HAS_ED448 = False
CRYPTOGRAPHY_HAS_ED448_SIGN = False
try:
# added in 0.5 - https://cryptography.io/en/latest/hazmat/primitives/asymmetric/ec/
import cryptography.hazmat.primitives.asymmetric.ec
CRYPTOGRAPHY_HAS_EC = True
try:
# added later in 1.5
cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey.sign
CRYPTOGRAPHY_HAS_EC_SIGN = True
except AttributeError:
CRYPTOGRAPHY_HAS_EC_SIGN = False
except ImportError:
CRYPTOGRAPHY_HAS_EC = False
CRYPTOGRAPHY_HAS_EC_SIGN = False
try:
# added in 0.5 - https://cryptography.io/en/latest/hazmat/primitives/asymmetric/rsa/
import cryptography.hazmat.primitives.asymmetric.rsa
CRYPTOGRAPHY_HAS_RSA = True
try:
# added later in 1.4
cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey.sign
CRYPTOGRAPHY_HAS_RSA_SIGN = True
except AttributeError:
CRYPTOGRAPHY_HAS_RSA_SIGN = False
except ImportError:
CRYPTOGRAPHY_HAS_RSA = False
CRYPTOGRAPHY_HAS_RSA_SIGN = False
try:
# added in 2.0 - https://cryptography.io/en/latest/hazmat/primitives/asymmetric/x25519/
import cryptography.hazmat.primitives.asymmetric.x25519
CRYPTOGRAPHY_HAS_X25519 = True
try:
# added later in 2.5
cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey.private_bytes
CRYPTOGRAPHY_HAS_X25519_FULL = True
except AttributeError:
CRYPTOGRAPHY_HAS_X25519_FULL = False
except ImportError:
CRYPTOGRAPHY_HAS_X25519 = False
CRYPTOGRAPHY_HAS_X25519_FULL = False
try:
# added in 2.5 - https://cryptography.io/en/latest/hazmat/primitives/asymmetric/x448/
import cryptography.hazmat.primitives.asymmetric.x448
CRYPTOGRAPHY_HAS_X448 = True
except ImportError:
CRYPTOGRAPHY_HAS_X448 = False
HAS_CRYPTOGRAPHY = True
except ImportError:
# Error handled in the calling module.
CRYPTOGRAPHY_HAS_EC = False
CRYPTOGRAPHY_HAS_EC_SIGN = False
CRYPTOGRAPHY_HAS_ED25519 = False
CRYPTOGRAPHY_HAS_ED25519_SIGN = False
CRYPTOGRAPHY_HAS_ED448 = False
CRYPTOGRAPHY_HAS_ED448_SIGN = False
CRYPTOGRAPHY_HAS_DSA = False
CRYPTOGRAPHY_HAS_DSA_SIGN = False
CRYPTOGRAPHY_HAS_RSA = False
CRYPTOGRAPHY_HAS_RSA_SIGN = False
CRYPTOGRAPHY_HAS_X25519 = False
CRYPTOGRAPHY_HAS_X25519_FULL = False
CRYPTOGRAPHY_HAS_X448 = False
HAS_CRYPTOGRAPHY = False
class OpenSSLObjectError(Exception):
pass
class OpenSSLBadPassphraseError(OpenSSLObjectError):
pass

View File

@@ -1,418 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org>
# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import abc
import traceback
from ansible.module_utils import six
from ansible.module_utils.basic import missing_required_lib
from ansible_collections.community.crypto.plugins.module_utils.argspec import (
ArgumentSpec,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import (
OpenSSLBadPassphraseError,
OpenSSLObjectError,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import (
cryptography_compare_public_keys,
get_not_valid_after,
get_not_valid_before,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate_info import (
get_certificate_info,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
load_certificate,
load_certificate_request,
load_privatekey,
)
from ansible_collections.community.crypto.plugins.module_utils.version import (
LooseVersion,
)
MINIMAL_CRYPTOGRAPHY_VERSION = "1.6"
CRYPTOGRAPHY_IMP_ERR = None
CRYPTOGRAPHY_VERSION = None
try:
import cryptography
from cryptography import x509
CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__)
except ImportError:
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
CRYPTOGRAPHY_FOUND = False
else:
CRYPTOGRAPHY_FOUND = True
class CertificateError(OpenSSLObjectError):
pass
@six.add_metaclass(abc.ABCMeta)
class CertificateBackend(object):
def __init__(self, module, backend):
self.module = module
self.backend = backend
self.force = module.params["force"]
self.ignore_timestamps = module.params["ignore_timestamps"]
self.privatekey_path = module.params["privatekey_path"]
self.privatekey_content = module.params["privatekey_content"]
if self.privatekey_content is not None:
self.privatekey_content = self.privatekey_content.encode("utf-8")
self.privatekey_passphrase = module.params["privatekey_passphrase"]
self.csr_path = module.params["csr_path"]
self.csr_content = module.params["csr_content"]
if self.csr_content is not None:
self.csr_content = self.csr_content.encode("utf-8")
# The following are default values which make sure check() works as
# before if providers do not explicitly change these properties.
self.create_subject_key_identifier = "never_create"
self.create_authority_key_identifier = False
self.privatekey = None
self.csr = None
self.cert = None
self.existing_certificate = None
self.existing_certificate_bytes = None
self.check_csr_subject = True
self.check_csr_extensions = True
self.diff_before = self._get_info(None)
self.diff_after = self._get_info(None)
def _get_info(self, data):
if data is None:
return dict()
try:
result = get_certificate_info(
self.module, self.backend, data, prefer_one_fingerprint=True
)
result["can_parse_certificate"] = True
return result
except Exception:
return dict(can_parse_certificate=False)
@abc.abstractmethod
def generate_certificate(self):
"""(Re-)Generate certificate."""
pass
@abc.abstractmethod
def get_certificate_data(self):
"""Return bytes for self.cert."""
pass
def set_existing(self, certificate_bytes):
"""Set existing certificate bytes. None indicates that the key does not exist."""
self.existing_certificate_bytes = certificate_bytes
self.diff_after = self.diff_before = self._get_info(
self.existing_certificate_bytes
)
def has_existing(self):
"""Query whether an existing certificate is/has been there."""
return self.existing_certificate_bytes is not None
def _ensure_private_key_loaded(self):
"""Load the provided private key into self.privatekey."""
if self.privatekey is not None:
return
if self.privatekey_path is None and self.privatekey_content is None:
return
try:
self.privatekey = load_privatekey(
path=self.privatekey_path,
content=self.privatekey_content,
passphrase=self.privatekey_passphrase,
backend=self.backend,
)
except OpenSSLBadPassphraseError as exc:
raise CertificateError(exc)
def _ensure_csr_loaded(self):
"""Load the CSR into self.csr."""
if self.csr is not None:
return
if self.csr_path is None and self.csr_content is None:
return
self.csr = load_certificate_request(
path=self.csr_path,
content=self.csr_content,
backend=self.backend,
)
def _ensure_existing_certificate_loaded(self):
"""Load the existing certificate into self.existing_certificate."""
if self.existing_certificate is not None:
return
if self.existing_certificate_bytes is None:
return
self.existing_certificate = load_certificate(
path=None,
content=self.existing_certificate_bytes,
backend=self.backend,
)
def _check_privatekey(self):
"""Check whether provided parameters match, assuming self.existing_certificate and self.privatekey have been populated."""
if self.backend == "cryptography":
return cryptography_compare_public_keys(
self.existing_certificate.public_key(), self.privatekey.public_key()
)
def _check_csr(self):
"""Check whether provided parameters match, assuming self.existing_certificate and self.csr have been populated."""
if self.backend == "cryptography":
# Verify that CSR is signed by certificate's private key
if not self.csr.is_signature_valid:
return False
if not cryptography_compare_public_keys(
self.csr.public_key(), self.existing_certificate.public_key()
):
return False
# Check subject
if (
self.check_csr_subject
and self.csr.subject != self.existing_certificate.subject
):
return False
# Check extensions
if not self.check_csr_extensions:
return True
cert_exts = list(self.existing_certificate.extensions)
csr_exts = list(self.csr.extensions)
if self.create_subject_key_identifier != "never_create":
# Filter out SubjectKeyIdentifier extension before comparison
cert_exts = list(
filter(
lambda x: not isinstance(x.value, x509.SubjectKeyIdentifier),
cert_exts,
)
)
csr_exts = list(
filter(
lambda x: not isinstance(x.value, x509.SubjectKeyIdentifier),
csr_exts,
)
)
if self.create_authority_key_identifier:
# Filter out AuthorityKeyIdentifier extension before comparison
cert_exts = list(
filter(
lambda x: not isinstance(x.value, x509.AuthorityKeyIdentifier),
cert_exts,
)
)
csr_exts = list(
filter(
lambda x: not isinstance(x.value, x509.AuthorityKeyIdentifier),
csr_exts,
)
)
if len(cert_exts) != len(csr_exts):
return False
for cert_ext in cert_exts:
try:
csr_ext = self.csr.extensions.get_extension_for_oid(cert_ext.oid)
if cert_ext != csr_ext:
return False
except cryptography.x509.ExtensionNotFound:
return False
return True
def _check_subject_key_identifier(self):
"""Check whether Subject Key Identifier matches, assuming self.existing_certificate has been populated."""
# Get hold of certificate's SKI
try:
ext = self.existing_certificate.extensions.get_extension_for_class(
x509.SubjectKeyIdentifier
)
except cryptography.x509.ExtensionNotFound:
return False
# Get hold of CSR's SKI for 'create_if_not_provided'
csr_ext = None
if self.create_subject_key_identifier == "create_if_not_provided":
try:
csr_ext = self.csr.extensions.get_extension_for_class(
x509.SubjectKeyIdentifier
)
except cryptography.x509.ExtensionNotFound:
pass
if csr_ext is None:
# If CSR had no SKI, or we chose to ignore it ('always_create'), compare with created SKI
if (
ext.value.digest
!= x509.SubjectKeyIdentifier.from_public_key(
self.existing_certificate.public_key()
).digest
):
return False
else:
# If CSR had SKI and we did not ignore it ('create_if_not_provided'), compare SKIs
if ext.value.digest != csr_ext.value.digest:
return False
return True
def needs_regeneration(self, not_before=None, not_after=None):
"""Check whether a regeneration is necessary."""
if self.force or self.existing_certificate_bytes is None:
return True
try:
self._ensure_existing_certificate_loaded()
except Exception:
return True
# Check whether private key matches
self._ensure_private_key_loaded()
if self.privatekey is not None and not self._check_privatekey():
return True
# Check whether CSR matches
self._ensure_csr_loaded()
if self.csr is not None and not self._check_csr():
return True
# Check SubjectKeyIdentifier
if (
self.create_subject_key_identifier != "never_create"
and not self._check_subject_key_identifier()
):
return True
# Check not before
if not_before is not None and not self.ignore_timestamps:
if get_not_valid_before(self.existing_certificate) != not_before:
return True
# Check not after
if not_after is not None and not self.ignore_timestamps:
if get_not_valid_after(self.existing_certificate) != not_after:
return True
return False
def dump(self, include_certificate):
"""Serialize the object into a dictionary."""
result = {"privatekey": self.privatekey_path, "csr": self.csr_path}
# Get hold of certificate bytes
certificate_bytes = self.existing_certificate_bytes
if self.cert is not None:
certificate_bytes = self.get_certificate_data()
self.diff_after = self._get_info(certificate_bytes)
if include_certificate:
# Store result
result["certificate"] = (
certificate_bytes.decode("utf-8") if certificate_bytes else None
)
result["diff"] = dict(
before=self.diff_before,
after=self.diff_after,
)
return result
@six.add_metaclass(abc.ABCMeta)
class CertificateProvider(object):
@abc.abstractmethod
def validate_module_args(self, module):
"""Check module arguments"""
@abc.abstractmethod
def needs_version_two_certs(self, module):
"""Whether the provider needs to create a version 2 certificate."""
@abc.abstractmethod
def create_backend(self, module, backend):
"""Create an implementation for a backend.
Return value must be instance of CertificateBackend.
"""
def select_backend(module, backend, provider):
"""
:type module: AnsibleModule
:type backend: str
:type provider: CertificateProvider
"""
provider.validate_module_args(module)
backend = module.params["select_crypto_backend"]
if backend == "auto":
# Detect what backend we can use
can_use_cryptography = (
CRYPTOGRAPHY_FOUND
and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION)
)
# If cryptography is available we'll use it
if can_use_cryptography:
backend = "cryptography"
# Fail if no backend has been found
if backend == "auto":
module.fail_json(
msg=(
"Cannot detect the required Python library " "cryptography (>= {0})"
).format(MINIMAL_CRYPTOGRAPHY_VERSION)
)
if backend == "cryptography":
if not CRYPTOGRAPHY_FOUND:
module.fail_json(
msg=missing_required_lib(
"cryptography >= {0}".format(MINIMAL_CRYPTOGRAPHY_VERSION)
),
exception=CRYPTOGRAPHY_IMP_ERR,
)
if provider.needs_version_two_certs(module):
module.fail_json(
msg="The cryptography backend does not support v2 certificates"
)
return provider.create_backend(module, backend)
def get_certificate_argument_spec():
return ArgumentSpec(
argument_spec=dict(
provider=dict(
type="str", choices=[]
), # choices will be filled by add_XXX_provider_to_argument_spec() in certificate_xxx.py
force=dict(
type="bool",
default=False,
),
csr_path=dict(type="path"),
csr_content=dict(type="str"),
ignore_timestamps=dict(type="bool", default=True),
select_crypto_backend=dict(
type="str", default="auto", choices=["auto", "cryptography"]
),
# General properties of a certificate
privatekey_path=dict(type="path"),
privatekey_content=dict(type="str", no_log=True),
privatekey_passphrase=dict(type="str", no_log=True),
),
mutually_exclusive=[
["csr_path", "csr_content"],
["privatekey_path", "privatekey_content"],
],
)

View File

@@ -1,129 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org>
# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import os
import tempfile
import traceback
from ansible.module_utils.common.text.converters import to_bytes, to_native
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate import (
CertificateBackend,
CertificateError,
CertificateProvider,
)
class AcmeCertificateBackend(CertificateBackend):
def __init__(self, module, backend):
super(AcmeCertificateBackend, self).__init__(module, backend)
self.accountkey_path = module.params["acme_accountkey_path"]
self.challenge_path = module.params["acme_challenge_path"]
self.use_chain = module.params["acme_chain"]
self.acme_directory = module.params["acme_directory"]
if self.csr_content is None and self.csr_path is None:
raise CertificateError(
"csr_path or csr_content is required for ownca provider"
)
if self.csr_content is None and not os.path.exists(self.csr_path):
raise CertificateError(
"The certificate signing request file %s does not exist" % self.csr_path
)
if not os.path.exists(self.accountkey_path):
raise CertificateError(
"The account key %s does not exist" % self.accountkey_path
)
if not os.path.exists(self.challenge_path):
raise CertificateError(
"The challenge path %s does not exist" % self.challenge_path
)
self.acme_tiny_path = self.module.get_bin_path("acme-tiny", required=True)
def generate_certificate(self):
"""(Re-)Generate certificate."""
command = [self.acme_tiny_path]
if self.use_chain:
command.append("--chain")
command.extend(["--account-key", self.accountkey_path])
if self.csr_content is not None:
# We need to temporarily write the CSR to disk
fd, tmpsrc = tempfile.mkstemp()
self.module.add_cleanup_file(tmpsrc) # Ansible will delete the file on exit
f = os.fdopen(fd, "wb")
try:
f.write(self.csr_content)
except Exception as err:
try:
f.close()
except Exception:
pass
self.module.fail_json(
msg="failed to create temporary CSR file: %s" % to_native(err),
exception=traceback.format_exc(),
)
f.close()
command.extend(["--csr", tmpsrc])
else:
command.extend(["--csr", self.csr_path])
command.extend(["--acme-dir", self.challenge_path])
command.extend(["--directory-url", self.acme_directory])
try:
self.cert = to_bytes(self.module.run_command(command, check_rc=True)[1])
except OSError as exc:
raise CertificateError(exc)
def get_certificate_data(self):
"""Return bytes for self.cert."""
return self.cert
def dump(self, include_certificate):
result = super(AcmeCertificateBackend, self).dump(include_certificate)
result["accountkey"] = self.accountkey_path
return result
class AcmeCertificateProvider(CertificateProvider):
def validate_module_args(self, module):
if module.params["acme_accountkey_path"] is None:
module.fail_json(
msg="The acme_accountkey_path option must be specified for the acme provider."
)
if module.params["acme_challenge_path"] is None:
module.fail_json(
msg="The acme_challenge_path option must be specified for the acme provider."
)
def needs_version_two_certs(self, module):
return False
def create_backend(self, module, backend):
return AcmeCertificateBackend(module, backend)
def add_acme_provider_to_argument_spec(argument_spec):
argument_spec.argument_spec["provider"]["choices"].append("acme")
argument_spec.argument_spec.update(
dict(
acme_accountkey_path=dict(type="path"),
acme_challenge_path=dict(type="path"),
acme_chain=dict(type="bool", default=False),
acme_directory=dict(
type="str", default="https://acme-v02.api.letsencrypt.org/directory"
),
)
)

View File

@@ -1,287 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org>
# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import datetime
import os
from ansible.module_utils.common.text.converters import to_bytes, to_native
from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import (
CRYPTOGRAPHY_TIMEZONE,
cryptography_serial_number_of_cert,
get_not_valid_after,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate import (
CertificateBackend,
CertificateError,
CertificateProvider,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
load_certificate,
)
from ansible_collections.community.crypto.plugins.module_utils.ecs.api import (
ECSClient,
RestOperationException,
SessionConfigurationException,
)
from ansible_collections.community.crypto.plugins.module_utils.time import (
get_now_datetime,
get_relative_time_option,
)
try:
from cryptography.x509.oid import NameOID
except ImportError:
pass
class EntrustCertificateBackend(CertificateBackend):
def __init__(self, module, backend):
super(EntrustCertificateBackend, self).__init__(module, backend)
self.trackingId = None
self.notAfter = get_relative_time_option(
module.params["entrust_not_after"],
"entrust_not_after",
backend=self.backend,
with_timezone=CRYPTOGRAPHY_TIMEZONE,
)
if self.csr_content is None and self.csr_path is None:
raise CertificateError(
"csr_path or csr_content is required for entrust provider"
)
if self.csr_content is None and not os.path.exists(self.csr_path):
raise CertificateError(
"The certificate signing request file {0} does not exist".format(
self.csr_path
)
)
self._ensure_csr_loaded()
# ECS API defaults to using the validated organization tied to the account.
# We want to always force behavior of trying to use the organization provided in the CSR.
# To that end we need to parse out the organization from the CSR.
self.csr_org = None
if self.backend == "cryptography":
csr_subject_orgs = self.csr.subject.get_attributes_for_oid(
NameOID.ORGANIZATION_NAME
)
if len(csr_subject_orgs) == 1:
self.csr_org = csr_subject_orgs[0].value
elif len(csr_subject_orgs) > 1:
self.module.fail_json(
msg=(
"Entrust provider does not currently support multiple validated organizations. Multiple organizations found in "
"Subject DN: '{0}'. ".format(self.csr.subject)
)
)
# If no organization in the CSR, explicitly tell ECS that it should be blank in issued cert, not defaulted to
# organization tied to the account.
if self.csr_org is None:
self.csr_org = ""
try:
self.ecs_client = ECSClient(
entrust_api_user=self.module.params["entrust_api_user"],
entrust_api_key=self.module.params["entrust_api_key"],
entrust_api_cert=self.module.params["entrust_api_client_cert_path"],
entrust_api_cert_key=self.module.params[
"entrust_api_client_cert_key_path"
],
entrust_api_specification_path=self.module.params[
"entrust_api_specification_path"
],
)
except SessionConfigurationException as e:
module.fail_json(
msg="Failed to initialize Entrust Provider: {0}".format(
to_native(e.message)
)
)
def generate_certificate(self):
"""(Re-)Generate certificate."""
body = {}
# Read the CSR that was generated for us
if self.csr_content is not None:
# csr_content contains bytes
body["csr"] = to_native(self.csr_content)
else:
with open(self.csr_path, "r") as csr_file:
body["csr"] = csr_file.read()
body["certType"] = self.module.params["entrust_cert_type"]
# Handle expiration (30 days if not specified)
expiry = self.notAfter
if not expiry:
gmt_now = get_now_datetime(with_timezone=CRYPTOGRAPHY_TIMEZONE)
expiry = gmt_now + datetime.timedelta(days=365)
expiry_iso3339 = expiry.strftime("%Y-%m-%dT%H:%M:%S.00Z")
body["certExpiryDate"] = expiry_iso3339
body["org"] = self.csr_org
body["tracking"] = {
"requesterName": self.module.params["entrust_requester_name"],
"requesterEmail": self.module.params["entrust_requester_email"],
"requesterPhone": self.module.params["entrust_requester_phone"],
}
try:
result = self.ecs_client.NewCertRequest(Body=body)
self.trackingId = result.get("trackingId")
except RestOperationException as e:
self.module.fail_json(
msg="Failed to request new certificate from Entrust Certificate Services (ECS): {0}".format(
to_native(e.message)
)
)
self.cert_bytes = to_bytes(result.get("endEntityCert"))
self.cert = load_certificate(
path=None, content=self.cert_bytes, backend=self.backend
)
def get_certificate_data(self):
"""Return bytes for self.cert."""
return self.cert_bytes
def needs_regeneration(self):
parent_check = super(EntrustCertificateBackend, self).needs_regeneration()
try:
cert_details = self._get_cert_details()
except RestOperationException as e:
self.module.fail_json(
msg="Failed to get status of existing certificate from Entrust Certificate Services (ECS): {0}.".format(
to_native(e.message)
)
)
# Always issue a new certificate if the certificate is expired, suspended or revoked
status = cert_details.get("status", False)
if status == "EXPIRED" or status == "SUSPENDED" or status == "REVOKED":
return True
# If the requested cert type was specified and it is for a different certificate type than the initial certificate, a new one is needed
if (
self.module.params["entrust_cert_type"]
and cert_details.get("certType")
and self.module.params["entrust_cert_type"] != cert_details.get("certType")
):
return True
return parent_check
def _get_cert_details(self):
cert_details = {}
try:
self._ensure_existing_certificate_loaded()
except Exception:
return
if self.existing_certificate:
serial_number = None
expiry = None
if self.backend == "cryptography":
serial_number = "{0:X}".format(
cryptography_serial_number_of_cert(self.existing_certificate)
)
expiry = get_not_valid_after(self.existing_certificate)
# get some information about the expiry of this certificate
expiry_iso3339 = expiry.strftime("%Y-%m-%dT%H:%M:%S.00Z")
cert_details["expiresAfter"] = expiry_iso3339
# If a trackingId is not already defined (from the result of a generate)
# use the serial number to identify the tracking Id
if self.trackingId is None and serial_number is not None:
cert_results = self.ecs_client.GetCertificates(
serialNumber=serial_number
).get("certificates", {})
# Finding 0 or more than 1 result is a very unlikely use case, it simply means we cannot perform additional checks
# on the 'state' as returned by Entrust Certificate Services (ECS). The general certificate validity is
# still checked as it is in the rest of the module.
if len(cert_results) == 1:
self.trackingId = cert_results[0].get("trackingId")
if self.trackingId is not None:
cert_details.update(
self.ecs_client.GetCertificate(trackingId=self.trackingId)
)
return cert_details
class EntrustCertificateProvider(CertificateProvider):
def validate_module_args(self, module):
pass
def needs_version_two_certs(self, module):
return False
def create_backend(self, module, backend):
return EntrustCertificateBackend(module, backend)
def add_entrust_provider_to_argument_spec(argument_spec):
argument_spec.argument_spec["provider"]["choices"].append("entrust")
argument_spec.argument_spec.update(
dict(
entrust_cert_type=dict(
type="str",
default="STANDARD_SSL",
choices=[
"STANDARD_SSL",
"ADVANTAGE_SSL",
"UC_SSL",
"EV_SSL",
"WILDCARD_SSL",
"PRIVATE_SSL",
"PD_SSL",
"CDS_ENT_LITE",
"CDS_ENT_PRO",
"SMIME_ENT",
],
),
entrust_requester_email=dict(type="str"),
entrust_requester_name=dict(type="str"),
entrust_requester_phone=dict(type="str"),
entrust_api_user=dict(type="str"),
entrust_api_key=dict(type="str", no_log=True),
entrust_api_client_cert_path=dict(type="path"),
entrust_api_client_cert_key_path=dict(type="path", no_log=True),
entrust_api_specification_path=dict(
type="path",
default="https://cloud.entrust.net/EntrustCloud/documentation/cms-api-2.1.0.yaml",
),
entrust_not_after=dict(type="str", default="+365d"),
)
)
argument_spec.required_if.append(
[
"provider",
"entrust",
[
"entrust_requester_email",
"entrust_requester_name",
"entrust_requester_phone",
"entrust_api_user",
"entrust_api_key",
"entrust_api_client_cert_path",
"entrust_api_client_cert_key_path",
],
]
)

View File

@@ -1,266 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org>
# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import os
from random import randrange
from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import (
CRYPTOGRAPHY_TIMEZONE,
cryptography_key_needs_digest_for_signing,
cryptography_serial_number_of_cert,
cryptography_verify_certificate_signature,
get_not_valid_after,
get_not_valid_before,
set_not_valid_after,
set_not_valid_before,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate import (
CertificateBackend,
CertificateError,
CertificateProvider,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
select_message_digest,
)
from ansible_collections.community.crypto.plugins.module_utils.time import (
get_relative_time_option,
)
try:
import cryptography
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import Encoding
except ImportError:
pass
class SelfSignedCertificateBackendCryptography(CertificateBackend):
def __init__(self, module):
super(SelfSignedCertificateBackendCryptography, self).__init__(
module, "cryptography"
)
self.create_subject_key_identifier = module.params[
"selfsigned_create_subject_key_identifier"
]
self.notBefore = get_relative_time_option(
module.params["selfsigned_not_before"],
"selfsigned_not_before",
backend=self.backend,
with_timezone=CRYPTOGRAPHY_TIMEZONE,
)
self.notAfter = get_relative_time_option(
module.params["selfsigned_not_after"],
"selfsigned_not_after",
backend=self.backend,
with_timezone=CRYPTOGRAPHY_TIMEZONE,
)
self.digest = select_message_digest(module.params["selfsigned_digest"])
self.version = module.params["selfsigned_version"]
self.serial_number = x509.random_serial_number()
if self.csr_path is not None and not os.path.exists(self.csr_path):
raise CertificateError(
"The certificate signing request file {0} does not exist".format(
self.csr_path
)
)
if self.privatekey_content is None and not os.path.exists(self.privatekey_path):
raise CertificateError(
"The private key file {0} does not exist".format(self.privatekey_path)
)
self._module = module
self._ensure_private_key_loaded()
self._ensure_csr_loaded()
if self.csr is None:
# Create empty CSR on the fly
csr = cryptography.x509.CertificateSigningRequestBuilder()
csr = csr.subject_name(cryptography.x509.Name([]))
digest = None
if cryptography_key_needs_digest_for_signing(self.privatekey):
digest = self.digest
if digest is None:
self.module.fail_json(
msg='Unsupported digest "{0}"'.format(
module.params["selfsigned_digest"]
)
)
try:
self.csr = csr.sign(self.privatekey, digest, default_backend())
except TypeError as e:
if (
str(e) == "Algorithm must be a registered hash algorithm."
and digest is None
):
self.module.fail_json(
msg="Signing with Ed25519 and Ed448 keys requires cryptography 2.8 or newer."
)
raise
if cryptography_key_needs_digest_for_signing(self.privatekey):
if self.digest is None:
raise CertificateError(
"The digest %s is not supported with the cryptography backend"
% module.params["selfsigned_digest"]
)
else:
self.digest = None
def generate_certificate(self):
"""(Re-)Generate certificate."""
try:
cert_builder = x509.CertificateBuilder()
cert_builder = cert_builder.subject_name(self.csr.subject)
cert_builder = cert_builder.issuer_name(self.csr.subject)
cert_builder = cert_builder.serial_number(self.serial_number)
cert_builder = set_not_valid_before(cert_builder, self.notBefore)
cert_builder = set_not_valid_after(cert_builder, self.notAfter)
cert_builder = cert_builder.public_key(self.privatekey.public_key())
has_ski = False
for extension in self.csr.extensions:
if isinstance(extension.value, x509.SubjectKeyIdentifier):
if self.create_subject_key_identifier == "always_create":
continue
has_ski = True
cert_builder = cert_builder.add_extension(
extension.value, critical=extension.critical
)
if not has_ski and self.create_subject_key_identifier != "never_create":
cert_builder = cert_builder.add_extension(
x509.SubjectKeyIdentifier.from_public_key(
self.privatekey.public_key()
),
critical=False,
)
except ValueError as e:
raise CertificateError(str(e))
try:
certificate = cert_builder.sign(
private_key=self.privatekey,
algorithm=self.digest,
backend=default_backend(),
)
except TypeError as e:
if (
str(e) == "Algorithm must be a registered hash algorithm."
and self.digest is None
):
self.module.fail_json(
msg="Signing with Ed25519 and Ed448 keys requires cryptography 2.8 or newer."
)
raise
self.cert = certificate
def get_certificate_data(self):
"""Return bytes for self.cert."""
return self.cert.public_bytes(Encoding.PEM)
def needs_regeneration(self):
if super(SelfSignedCertificateBackendCryptography, self).needs_regeneration(
not_before=self.notBefore, not_after=self.notAfter
):
return True
self._ensure_existing_certificate_loaded()
# Check whether certificate is signed by private key
if not cryptography_verify_certificate_signature(
self.existing_certificate, self.privatekey.public_key()
):
return True
return False
def dump(self, include_certificate):
result = super(SelfSignedCertificateBackendCryptography, self).dump(
include_certificate
)
if self.module.check_mode:
result.update(
{
"notBefore": self.notBefore.strftime("%Y%m%d%H%M%SZ"),
"notAfter": self.notAfter.strftime("%Y%m%d%H%M%SZ"),
"serial_number": self.serial_number,
}
)
else:
if self.cert is None:
self.cert = self.existing_certificate
result.update(
{
"notBefore": get_not_valid_before(self.cert).strftime(
"%Y%m%d%H%M%SZ"
),
"notAfter": get_not_valid_after(self.cert).strftime(
"%Y%m%d%H%M%SZ"
),
"serial_number": cryptography_serial_number_of_cert(self.cert),
}
)
return result
def generate_serial_number():
"""Generate a serial number for a certificate"""
while True:
result = randrange(0, 1 << 160)
if result >= 1000:
return result
class SelfSignedCertificateProvider(CertificateProvider):
def validate_module_args(self, module):
if (
module.params["privatekey_path"] is None
and module.params["privatekey_content"] is None
):
module.fail_json(
msg="One of privatekey_path and privatekey_content must be specified for the selfsigned provider."
)
def needs_version_two_certs(self, module):
return module.params["selfsigned_version"] == 2
def create_backend(self, module, backend):
if backend == "cryptography":
return SelfSignedCertificateBackendCryptography(module)
def add_selfsigned_provider_to_argument_spec(argument_spec):
argument_spec.argument_spec["provider"]["choices"].append("selfsigned")
argument_spec.argument_spec.update(
dict(
selfsigned_version=dict(type="int", default=3),
selfsigned_digest=dict(type="str", default="sha256"),
selfsigned_not_before=dict(
type="str", default="+0s", aliases=["selfsigned_notBefore"]
),
selfsigned_not_after=dict(
type="str", default="+3650d", aliases=["selfsigned_notAfter"]
),
selfsigned_create_subject_key_identifier=dict(
type="str",
default="create_if_not_provided",
choices=["create_if_not_provided", "always_create", "never_create"],
),
)
)

View File

@@ -1,33 +0,0 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2020, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.crypto.plugins.module_utils.argspec import (
ArgumentSpec as _ArgumentSpec,
)
class ArgumentSpec(_ArgumentSpec):
def create_ansible_module_helper(self, clazz, args, **kwargs):
result = super(ArgumentSpec, self).create_ansible_module_helper(
clazz, args, **kwargs
)
result.deprecate(
"The crypto.module_backends.common module utils is deprecated and will be removed from community.crypto 3.0.0."
" Use the argspec module utils from community.crypto instead.",
version="3.0.0",
collection_name="community.crypto",
)
return result
__all__ = ("AnsibleModule", "ArgumentSpec")

View File

@@ -1,114 +0,0 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2020, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import traceback
from ansible.module_utils.basic import missing_required_lib
from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_crl import (
TIMESTAMP_FORMAT,
cryptography_decode_revoked_certificate,
cryptography_dump_revoked,
cryptography_get_signature_algorithm_oid_from_crl,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import (
cryptography_oid_to_name,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import (
identify_pem_format,
)
from ansible_collections.community.crypto.plugins.module_utils.version import (
LooseVersion,
)
# crypto_utils
MINIMAL_CRYPTOGRAPHY_VERSION = "1.2"
CRYPTOGRAPHY_IMP_ERR = None
try:
import cryptography
from cryptography import x509
from cryptography.hazmat.backends import default_backend
CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__)
except ImportError:
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
CRYPTOGRAPHY_FOUND = False
else:
CRYPTOGRAPHY_FOUND = True
class CRLInfoRetrieval(object):
def __init__(self, module, content, list_revoked_certificates=True):
# content must be a bytes string
self.module = module
self.content = content
self.list_revoked_certificates = list_revoked_certificates
self.name_encoding = module.params.get("name_encoding", "ignore")
def get_info(self):
self.crl_pem = identify_pem_format(self.content)
try:
if self.crl_pem:
self.crl = x509.load_pem_x509_crl(self.content, default_backend())
else:
self.crl = x509.load_der_x509_crl(self.content, default_backend())
except ValueError as e:
self.module.fail_json(msg="Error while decoding CRL: {0}".format(e))
result = {
"changed": False,
"format": "pem" if self.crl_pem else "der",
"last_update": None,
"next_update": None,
"digest": None,
"issuer_ordered": None,
"issuer": None,
}
result["last_update"] = self.crl.last_update.strftime(TIMESTAMP_FORMAT)
result["next_update"] = self.crl.next_update.strftime(TIMESTAMP_FORMAT)
result["digest"] = cryptography_oid_to_name(
cryptography_get_signature_algorithm_oid_from_crl(self.crl)
)
issuer = []
for attribute in self.crl.issuer:
issuer.append([cryptography_oid_to_name(attribute.oid), attribute.value])
result["issuer_ordered"] = issuer
result["issuer"] = {}
for k, v in issuer:
result["issuer"][k] = v
if self.list_revoked_certificates:
result["revoked_certificates"] = []
for cert in self.crl:
entry = cryptography_decode_revoked_certificate(cert)
result["revoked_certificates"].append(
cryptography_dump_revoked(entry, idn_rewrite=self.name_encoding)
)
return result
def get_crl_info(module, content, list_revoked_certificates=True):
if not CRYPTOGRAPHY_FOUND:
module.fail_json(
msg=missing_required_lib(
"cryptography >= {0}".format(MINIMAL_CRYPTOGRAPHY_VERSION)
),
exception=CRYPTOGRAPHY_IMP_ERR,
)
info = CRLInfoRetrieval(
module, content, list_revoked_certificates=list_revoked_certificates
)
return info.get_info()

View File

@@ -1,406 +0,0 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org>
# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at>
# Copyright (c) 2020, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import abc
import binascii
import traceback
from ansible.module_utils import six
from ansible.module_utils.basic import missing_required_lib
from ansible.module_utils.common.text.converters import to_native
from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import (
cryptography_decode_name,
cryptography_get_extensions_from_csr,
cryptography_oid_to_name,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.publickey_info import (
get_publickey_info,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
load_certificate_request,
)
from ansible_collections.community.crypto.plugins.module_utils.version import (
LooseVersion,
)
MINIMAL_CRYPTOGRAPHY_VERSION = "1.3"
CRYPTOGRAPHY_IMP_ERR = None
try:
import cryptography
from cryptography import x509
from cryptography.hazmat.primitives import serialization
CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__)
except ImportError:
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
CRYPTOGRAPHY_FOUND = False
else:
CRYPTOGRAPHY_FOUND = True
TIMESTAMP_FORMAT = "%Y%m%d%H%M%SZ"
@six.add_metaclass(abc.ABCMeta)
class CSRInfoRetrieval(object):
def __init__(self, module, backend, content, validate_signature):
# content must be a bytes string
self.module = module
self.backend = backend
self.content = content
self.validate_signature = validate_signature
@abc.abstractmethod
def _get_subject_ordered(self):
pass
@abc.abstractmethod
def _get_key_usage(self):
pass
@abc.abstractmethod
def _get_extended_key_usage(self):
pass
@abc.abstractmethod
def _get_basic_constraints(self):
pass
@abc.abstractmethod
def _get_ocsp_must_staple(self):
pass
@abc.abstractmethod
def _get_subject_alt_name(self):
pass
@abc.abstractmethod
def _get_name_constraints(self):
pass
@abc.abstractmethod
def _get_public_key_pem(self):
pass
@abc.abstractmethod
def _get_public_key_object(self):
pass
@abc.abstractmethod
def _get_subject_key_identifier(self):
pass
@abc.abstractmethod
def _get_authority_key_identifier(self):
pass
@abc.abstractmethod
def _get_all_extensions(self):
pass
@abc.abstractmethod
def _is_signature_valid(self):
pass
def get_info(self, prefer_one_fingerprint=False):
result = dict()
self.csr = load_certificate_request(
None, content=self.content, backend=self.backend
)
subject = self._get_subject_ordered()
result["subject"] = dict()
for k, v in subject:
result["subject"][k] = v
result["subject_ordered"] = subject
result["key_usage"], result["key_usage_critical"] = self._get_key_usage()
result["extended_key_usage"], result["extended_key_usage_critical"] = (
self._get_extended_key_usage()
)
result["basic_constraints"], result["basic_constraints_critical"] = (
self._get_basic_constraints()
)
result["ocsp_must_staple"], result["ocsp_must_staple_critical"] = (
self._get_ocsp_must_staple()
)
result["subject_alt_name"], result["subject_alt_name_critical"] = (
self._get_subject_alt_name()
)
(
result["name_constraints_permitted"],
result["name_constraints_excluded"],
result["name_constraints_critical"],
) = self._get_name_constraints()
result["public_key"] = to_native(self._get_public_key_pem())
public_key_info = get_publickey_info(
self.module,
self.backend,
key=self._get_public_key_object(),
prefer_one_fingerprint=prefer_one_fingerprint,
)
result.update(
{
"public_key_type": public_key_info["type"],
"public_key_data": public_key_info["public_data"],
"public_key_fingerprints": public_key_info["fingerprints"],
}
)
ski = self._get_subject_key_identifier()
if ski is not None:
ski = to_native(binascii.hexlify(ski))
ski = ":".join([ski[i : i + 2] for i in range(0, len(ski), 2)])
result["subject_key_identifier"] = ski
aki, aci, acsn = self._get_authority_key_identifier()
if aki is not None:
aki = to_native(binascii.hexlify(aki))
aki = ":".join([aki[i : i + 2] for i in range(0, len(aki), 2)])
result["authority_key_identifier"] = aki
result["authority_cert_issuer"] = aci
result["authority_cert_serial_number"] = acsn
result["extensions_by_oid"] = self._get_all_extensions()
result["signature_valid"] = self._is_signature_valid()
if self.validate_signature and not result["signature_valid"]:
self.module.fail_json(msg="CSR signature is invalid!", **result)
return result
class CSRInfoRetrievalCryptography(CSRInfoRetrieval):
"""Validate the supplied CSR, using the cryptography backend"""
def __init__(self, module, content, validate_signature):
super(CSRInfoRetrievalCryptography, self).__init__(
module, "cryptography", content, validate_signature
)
self.name_encoding = module.params.get("name_encoding", "ignore")
def _get_subject_ordered(self):
result = []
for attribute in self.csr.subject:
result.append([cryptography_oid_to_name(attribute.oid), attribute.value])
return result
def _get_key_usage(self):
try:
current_key_ext = self.csr.extensions.get_extension_for_class(x509.KeyUsage)
current_key_usage = current_key_ext.value
key_usage = dict(
digital_signature=current_key_usage.digital_signature,
content_commitment=current_key_usage.content_commitment,
key_encipherment=current_key_usage.key_encipherment,
data_encipherment=current_key_usage.data_encipherment,
key_agreement=current_key_usage.key_agreement,
key_cert_sign=current_key_usage.key_cert_sign,
crl_sign=current_key_usage.crl_sign,
encipher_only=False,
decipher_only=False,
)
if key_usage["key_agreement"]:
key_usage.update(
dict(
encipher_only=current_key_usage.encipher_only,
decipher_only=current_key_usage.decipher_only,
)
)
key_usage_names = dict(
digital_signature="Digital Signature",
content_commitment="Non Repudiation",
key_encipherment="Key Encipherment",
data_encipherment="Data Encipherment",
key_agreement="Key Agreement",
key_cert_sign="Certificate Sign",
crl_sign="CRL Sign",
encipher_only="Encipher Only",
decipher_only="Decipher Only",
)
return (
sorted(
[
key_usage_names[name]
for name, value in key_usage.items()
if value
]
),
current_key_ext.critical,
)
except cryptography.x509.ExtensionNotFound:
return None, False
def _get_extended_key_usage(self):
try:
ext_keyusage_ext = self.csr.extensions.get_extension_for_class(
x509.ExtendedKeyUsage
)
return (
sorted(
[cryptography_oid_to_name(eku) for eku in ext_keyusage_ext.value]
),
ext_keyusage_ext.critical,
)
except cryptography.x509.ExtensionNotFound:
return None, False
def _get_basic_constraints(self):
try:
ext_keyusage_ext = self.csr.extensions.get_extension_for_class(
x509.BasicConstraints
)
result = ["CA:{0}".format("TRUE" if ext_keyusage_ext.value.ca else "FALSE")]
if ext_keyusage_ext.value.path_length is not None:
result.append("pathlen:{0}".format(ext_keyusage_ext.value.path_length))
return sorted(result), ext_keyusage_ext.critical
except cryptography.x509.ExtensionNotFound:
return None, False
def _get_ocsp_must_staple(self):
try:
try:
# This only works with cryptography >= 2.1
tlsfeature_ext = self.csr.extensions.get_extension_for_class(
x509.TLSFeature
)
value = (
cryptography.x509.TLSFeatureType.status_request
in tlsfeature_ext.value
)
except AttributeError:
# Fallback for cryptography < 2.1
oid = x509.oid.ObjectIdentifier("1.3.6.1.5.5.7.1.24")
tlsfeature_ext = self.csr.extensions.get_extension_for_oid(oid)
value = tlsfeature_ext.value.value == b"\x30\x03\x02\x01\x05"
return value, tlsfeature_ext.critical
except cryptography.x509.ExtensionNotFound:
return None, False
def _get_subject_alt_name(self):
try:
san_ext = self.csr.extensions.get_extension_for_class(
x509.SubjectAlternativeName
)
result = [
cryptography_decode_name(san, idn_rewrite=self.name_encoding)
for san in san_ext.value
]
return result, san_ext.critical
except cryptography.x509.ExtensionNotFound:
return None, False
def _get_name_constraints(self):
try:
nc_ext = self.csr.extensions.get_extension_for_class(x509.NameConstraints)
permitted = [
cryptography_decode_name(san, idn_rewrite=self.name_encoding)
for san in nc_ext.value.permitted_subtrees or []
]
excluded = [
cryptography_decode_name(san, idn_rewrite=self.name_encoding)
for san in nc_ext.value.excluded_subtrees or []
]
return permitted, excluded, nc_ext.critical
except cryptography.x509.ExtensionNotFound:
return None, None, False
def _get_public_key_pem(self):
return self.csr.public_key().public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
def _get_public_key_object(self):
return self.csr.public_key()
def _get_subject_key_identifier(self):
try:
ext = self.csr.extensions.get_extension_for_class(x509.SubjectKeyIdentifier)
return ext.value.digest
except cryptography.x509.ExtensionNotFound:
return None
def _get_authority_key_identifier(self):
try:
ext = self.csr.extensions.get_extension_for_class(
x509.AuthorityKeyIdentifier
)
issuer = None
if ext.value.authority_cert_issuer is not None:
issuer = [
cryptography_decode_name(san, idn_rewrite=self.name_encoding)
for san in ext.value.authority_cert_issuer
]
return (
ext.value.key_identifier,
issuer,
ext.value.authority_cert_serial_number,
)
except cryptography.x509.ExtensionNotFound:
return None, None, None
def _get_all_extensions(self):
return cryptography_get_extensions_from_csr(self.csr)
def _is_signature_valid(self):
return self.csr.is_signature_valid
def get_csr_info(
module, backend, content, validate_signature=True, prefer_one_fingerprint=False
):
if backend == "cryptography":
info = CSRInfoRetrievalCryptography(
module, content, validate_signature=validate_signature
)
return info.get_info(prefer_one_fingerprint=prefer_one_fingerprint)
def select_backend(module, backend, content, validate_signature=True):
if backend == "auto":
# Detection what is possible
can_use_cryptography = (
CRYPTOGRAPHY_FOUND
and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION)
)
# Try cryptography
if can_use_cryptography:
backend = "cryptography"
# Success?
if backend == "auto":
module.fail_json(
msg=(
"Cannot detect the required Python library " "cryptography (>= {0})"
).format(MINIMAL_CRYPTOGRAPHY_VERSION)
)
if backend == "cryptography":
if not CRYPTOGRAPHY_FOUND:
module.fail_json(
msg=missing_required_lib(
"cryptography >= {0}".format(MINIMAL_CRYPTOGRAPHY_VERSION)
),
exception=CRYPTOGRAPHY_IMP_ERR,
)
return backend, CSRInfoRetrievalCryptography(
module, content, validate_signature=validate_signature
)
else:
raise ValueError("Unsupported value for backend: {0}".format(backend))

Some files were not shown because too many files have changed in this diff Show More